From aecc93b7ab781bf672d21826a5b2936afc8e99ec Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 17 Oct 2026 15:55:36 -0500 Subject: [PATCH] Much improved printing. More sound devices supported. Sound recording. --- Makefile | 12 +- config/ASSOC.INI | 8 + config/DOSPROG.BMP | 3 + config/README.md | 9 +- config/dvx.ini | 54 +- docs/dvx_basic_reference.html | 240 +- docs/dvx_system_reference.html | 480 +- installToolchain.sh | 12 +- screenshots/86box/addons-addon-no.png | 4 +- screenshots/86box/apps-clock.png | 4 +- screenshots/86box/apps-cpanel.png | 4 +- screenshots/86box/apps-desktop.png | 4 +- screenshots/86box/apps-dvxbasic.png | 4 +- screenshots/86box/apps-dvxhelp.png | 4 +- screenshots/86box/apps-iconed.png | 4 +- screenshots/86box/apps-imgview.png | 4 +- screenshots/86box/apps-notepad.png | 4 +- screenshots/86box/apps-player.png | 3 + screenshots/86box/apps-resedit.png | 4 +- screenshots/86box/banked-bk-back.png | 4 +- screenshots/86box/banked-bk-desk.png | 4 +- screenshots/86box/banked-bk-drag.png | 4 +- screenshots/86box/banked-bk-note.png | 4 +- screenshots/86box/boot-boot.png | 4 +- screenshots/86box/cpanel-cp-color.png | 4 +- screenshots/86box/cpanel-cp-desk.png | 4 +- screenshots/86box/cpanel-cp-joy.png | 4 +- screenshots/86box/cpanel-cp-mouse.png | 4 +- screenshots/86box/cpanel-cp-net.png | 4 +- screenshots/86box/cpanel-cp-print.png | 4 +- screenshots/86box/cpanel-cp-sound.png | 4 +- screenshots/86box/cpanel-cp-types.png | 4 +- screenshots/86box/cpanel-cp-video.png | 4 +- screenshots/86box/dosprog-dp-after.png | 3 + screenshots/86box/dosprog-dp-ask.png | 3 + screenshots/86box/dosprog-dp-back.png | 3 + screenshots/86box/exit-cancel.png | 4 +- screenshots/86box/exit-savechg.png | 4 +- screenshots/86box/f1-h-basdem.png | 4 +- screenshots/86box/f1-h-clock.png | 4 +- screenshots/86box/f1-h-cpanel.png | 4 +- screenshots/86box/f1-h-iconed.png | 4 +- screenshots/86box/f1-h-imgvie.png | 4 +- screenshots/86box/f1-h-notepa.png | 4 +- screenshots/86box/f1-h-player.png | 3 + screenshots/86box/f1-h-progmn.png | 4 +- screenshots/86box/f1-h-resedi.png | 4 +- screenshots/86box/filedlg-fd-back.png | 4 +- screenshots/86box/filedlg-fd-list.png | 4 +- screenshots/86box/filedlg-fd-nordy.png | 4 +- screenshots/86box/filedlg-fd-open.png | 4 +- screenshots/86box/fullmode-fm-desk.png | 4 +- screenshots/86box/fullscr-fs-desk.png | 4 +- screenshots/86box/hang-ended.png | 4 +- screenshots/86box/hang-taskmgr.png | 4 +- screenshots/86box/install-in-desk.png | 4 +- screenshots/86box/lfn-lfnsmk.png | 4 +- screenshots/86box/menus-m-altdn.png | 4 +- screenshots/86box/menus-m-altup.png | 4 +- screenshots/86box/menus-m-design.png | 4 +- screenshots/86box/menus-m-drop.png | 4 +- screenshots/86box/menus-m-file.png | 4 +- screenshots/86box/menus-m-run.png | 4 +- screenshots/86box/midiin-rc-midi.png | 3 + screenshots/86box/net-netsmk.png | 4 +- screenshots/86box/pardsgn-pardsgn.png | 4 +- screenshots/86box/parhlp-parhlp-l.png | 4 +- screenshots/86box/parhlp-parhlp.png | 4 +- screenshots/86box/parpm-parpm.png | 4 +- screenshots/86box/paths-paths.png | 4 +- screenshots/86box/player-p-mid.png | 3 + screenshots/86box/player-p-mod.png | 3 + screenshots/86box/player-p-voc.png | 3 + screenshots/86box/player-p-wav.png | 3 + screenshots/86box/print-print1.png | 4 +- screenshots/86box/record-rc-open.png | 3 + screenshots/86box/safevid-sv-desk.png | 4 +- screenshots/86box/saver-sv-back.png | 3 + screenshots/86box/saver-sv-blank.png | 3 + screenshots/86box/saver-sv-cpd.png | 3 + screenshots/86box/sdk-h-sdkapp.png | 4 +- screenshots/86box/sdk-sdkapp.png | 4 +- screenshots/86box/sdk-sdkbas.png | 4 +- screenshots/86box/sdk-sdkgreet.png | 4 +- screenshots/86box/sdk-sdktbx.png | 4 +- screenshots/86box/sdkgame-sdkgame.png | 4 +- screenshots/86box/software-sw-done.png | 4 +- screenshots/86box/software-sw-plan.png | 4 +- screenshots/86box/sql-sqlsmk.png | 4 +- screenshots/86box/ucdemo-ucdemo.png | 4 +- screenshots/86box/ucide-ucide-ds.png | 4 +- screenshots/86box/ucide-ucide-ru.png | 4 +- screenshots/86box/ucide-ucide-tb.png | 4 +- screenshots/86box/video-v-800.png | 4 +- screenshots/86box/video-v-pick.png | 4 +- screenshots/86box/winops-w-back.png | 4 +- screenshots/86box/winops-w-min.png | 4 +- screenshots/86box/winops-w-move.png | 4 +- screenshots/86box/winops-w-rest.png | 4 +- screenshots/86box/winops-w-size.png | 4 +- screenshots/dosbox/addons-addon-no.png | 4 +- screenshots/dosbox/apps-clock.png | 4 +- screenshots/dosbox/apps-cpanel.png | 4 +- screenshots/dosbox/apps-desktop.png | 4 +- screenshots/dosbox/apps-dvxbasic.png | 4 +- screenshots/dosbox/apps-dvxhelp.png | 4 +- screenshots/dosbox/apps-iconed.png | 4 +- screenshots/dosbox/apps-imgview.png | 4 +- screenshots/dosbox/apps-notepad.png | 4 +- screenshots/dosbox/apps-player.png | 3 + screenshots/dosbox/apps-resedit.png | 4 +- screenshots/dosbox/banked-bk-back.png | 4 +- screenshots/dosbox/banked-bk-desk.png | 4 +- screenshots/dosbox/banked-bk-drag.png | 4 +- screenshots/dosbox/banked-bk-note.png | 4 +- screenshots/dosbox/boot-boot.png | 4 +- screenshots/dosbox/cpanel-cp-color.png | 4 +- screenshots/dosbox/cpanel-cp-desk.png | 4 +- screenshots/dosbox/cpanel-cp-joy.png | 4 +- screenshots/dosbox/cpanel-cp-mouse.png | 4 +- screenshots/dosbox/cpanel-cp-net.png | 4 +- screenshots/dosbox/cpanel-cp-print.png | 4 +- screenshots/dosbox/cpanel-cp-sound.png | 4 +- screenshots/dosbox/cpanel-cp-types.png | 4 +- screenshots/dosbox/cpanel-cp-video.png | 4 +- screenshots/dosbox/dosprog-dp-after.png | 3 + screenshots/dosbox/dosprog-dp-ask.png | 3 + screenshots/dosbox/dosprog-dp-back.png | 3 + screenshots/dosbox/exit-cancel.png | 4 +- screenshots/dosbox/exit-savechg.png | 4 +- screenshots/dosbox/f1-h-basdem.png | 4 +- screenshots/dosbox/f1-h-clock.png | 4 +- screenshots/dosbox/f1-h-cpanel.png | 4 +- screenshots/dosbox/f1-h-iconed.png | 4 +- screenshots/dosbox/f1-h-imgvie.png | 4 +- screenshots/dosbox/f1-h-notepa.png | 4 +- screenshots/dosbox/f1-h-player.png | 3 + screenshots/dosbox/f1-h-progmn.png | 4 +- screenshots/dosbox/f1-h-resedi.png | 4 +- screenshots/dosbox/filedlg-fd-back.png | 4 +- screenshots/dosbox/filedlg-fd-list.png | 4 +- screenshots/dosbox/filedlg-fd-nordy.png | 4 +- screenshots/dosbox/filedlg-fd-open.png | 4 +- screenshots/dosbox/fullmode-fm-desk.png | 4 +- screenshots/dosbox/fullscr-fs-desk.png | 4 +- screenshots/dosbox/hang-ended.png | 4 +- screenshots/dosbox/hang-taskmgr.png | 4 +- screenshots/dosbox/install-in-desk.png | 4 +- screenshots/dosbox/menus-m-altdn.png | 4 +- screenshots/dosbox/menus-m-altup.png | 4 +- screenshots/dosbox/menus-m-design.png | 4 +- screenshots/dosbox/menus-m-drop.png | 4 +- screenshots/dosbox/menus-m-file.png | 4 +- screenshots/dosbox/menus-m-run.png | 4 +- screenshots/dosbox/midiin-rc-midi.png | 3 + screenshots/dosbox/net-netsmk.png | 4 +- screenshots/dosbox/pardsgn-pardsgn.png | 4 +- screenshots/dosbox/parhlp-parhlp-l.png | 4 +- screenshots/dosbox/parhlp-parhlp.png | 4 +- screenshots/dosbox/parpm-parpm.png | 4 +- screenshots/dosbox/paths-paths.png | 4 +- screenshots/dosbox/player-p-mid.png | 3 + screenshots/dosbox/player-p-mod.png | 3 + screenshots/dosbox/player-p-voc.png | 3 + screenshots/dosbox/player-p-wav.png | 3 + screenshots/dosbox/print-print1.png | 4 +- screenshots/dosbox/record-rc-open.png | 3 + screenshots/dosbox/safevid-sv-desk.png | 4 +- screenshots/dosbox/saver-sv-back.png | 3 + screenshots/dosbox/saver-sv-blank.png | 3 + screenshots/dosbox/saver-sv-cpd.png | 3 + screenshots/dosbox/sdk-h-sdkapp.png | 4 +- screenshots/dosbox/sdk-sdkapp.png | 4 +- screenshots/dosbox/sdk-sdkbas.png | 4 +- screenshots/dosbox/sdk-sdkgreet.png | 4 +- screenshots/dosbox/sdk-sdktbx.png | 4 +- screenshots/dosbox/software-sw-done.png | 4 +- screenshots/dosbox/software-sw-plan.png | 4 +- screenshots/dosbox/sql-sqlsmk.png | 4 +- screenshots/dosbox/ucdemo-ucdemo.png | 4 +- screenshots/dosbox/ucide-ucide-ds.png | 4 +- screenshots/dosbox/ucide-ucide-ru.png | 4 +- screenshots/dosbox/ucide-ucide-tb.png | 4 +- screenshots/dosbox/video-v-800.png | 4 +- screenshots/dosbox/video-v-pick.png | 4 +- screenshots/dosbox/winops-w-back.png | 4 +- screenshots/dosbox/winops-w-min.png | 4 +- screenshots/dosbox/winops-w-move.png | 4 +- screenshots/dosbox/winops-w-rest.png | 4 +- screenshots/dosbox/winops-w-size.png | 4 +- screenshots/masks/install-su-lic.mask | 2 + server/README.md | 10 + server/test/run.sh | 8 +- server/test/serve.sh | 8 +- src/addons/README.md | 24 + src/addons/banks/dmxopl/PACKAGE.INI | 20 + .../dmxopl/SHARED/BANKS/KPUNCH/DMXOPL.OP2 | Bin 0 -> 11908 bytes .../dmxopl/SHARED/BANKS/KPUNCH/DMXOPL.TXT | 43 + src/addons/banks/dmxopl3/PACKAGE.INI | 23 + .../dmxopl3/SHARED/BANKS/KPUNCH/DMXOPL3.OP2 | Bin 0 -> 11908 bytes .../dmxopl3/SHARED/BANKS/KPUNCH/DMXOPL3.TXT | 44 + src/addons/banks/dvxgm/PACKAGE.INI | 19 + .../banks/dvxgm/SHARED/BANKS/KPUNCH/DVXGM.TXT | 46 + src/addons/banks/dvxgm/soundfont.ini | 19 + src/addons/banks/installSoundFonts.sh | 89 + src/addons/fonts/prfonts/PACKAGE.INI | 18 + src/addons/fonts/prfonts/fonts.ini | 126 + src/apps/kpunch/Makefile | 2 +- src/apps/kpunch/README.md | 62 +- src/apps/kpunch/basdemo/basdemo.dbp | 3 +- src/apps/kpunch/basdemo/basdemo.dhs | 1 + src/apps/kpunch/basdemo/basdemo.frm | 131 + src/apps/kpunch/cpanel/cpJoystick.c | 16 +- src/apps/kpunch/cpanel/cpNet.c | 10 +- src/apps/kpunch/cpanel/cpPrinter.c | 71 +- src/apps/kpunch/cpanel/cpSounds.c | 444 +- src/apps/kpunch/cpanel/cpTypes.c | 7 +- src/apps/kpunch/cpanel/cpanel.c | 190 +- src/apps/kpunch/cpanel/cpanel.dhs | 54 +- src/apps/kpunch/dvxbasic/Makefile | 4 +- src/apps/kpunch/dvxbasic/basdev/basdev.dhs | 2 +- src/apps/kpunch/dvxbasic/compiler/basEvents.h | 7 + src/apps/kpunch/dvxbasic/compiler/compiler.c | 24 + src/apps/kpunch/dvxbasic/ctrlover.dhs | 4 +- src/apps/kpunch/dvxbasic/form.dhs | 2 +- src/apps/kpunch/dvxbasic/formrt/basRunHost.c | 55 +- src/apps/kpunch/dvxbasic/formrt/basRunHost.h | 10 +- src/apps/kpunch/dvxbasic/formrt/formGfx.c | 693 +- src/apps/kpunch/dvxbasic/formrt/formPrinter.c | 1253 ++++ src/apps/kpunch/dvxbasic/formrt/formrt.c | 30 +- src/apps/kpunch/dvxbasic/formrt/formrt.h | 66 +- src/apps/kpunch/dvxbasic/ide/idePackage.c | 8 +- src/apps/kpunch/dvxbasic/langref.dhs | 277 +- src/apps/kpunch/dvxbasic/runtime/gfx.c | 166 +- src/apps/kpunch/dvxbasic/runtime/gfx.h | 33 +- src/apps/kpunch/dvxbasic/stub/basstub.c | 13 +- src/apps/kpunch/dvxbasic/userctl.dhs | 2 +- src/apps/kpunch/dvxdemo/dvxdemo.c | 3 +- src/apps/kpunch/fileMgr/fileMgr.dhs | 1 + src/apps/kpunch/fileMgr/fileMgr.frm | 4 +- src/apps/kpunch/pkgbuild/pkgbuild.dhs | 3 +- src/apps/kpunch/player/ICON32.BMP | 3 + src/apps/kpunch/player/player.dbp | 24 + src/apps/kpunch/player/player.dhs | 150 + src/apps/kpunch/player/player.frm | 1146 +++ src/apps/kpunch/progman/pmProps.c | 10 +- src/apps/kpunch/progman/progman.c | 13 +- src/apps/kpunch/progman/progman.dhs | 34 +- src/apps/kpunch/progman/userHelp.dhs | 28 +- src/apps/kpunch/recorder/ICON32.BMP | 3 + src/apps/kpunch/recorder/recorder.dbp | 25 + src/apps/kpunch/recorder/recorder.dhs | 87 + src/apps/kpunch/recorder/recorder.frm | 918 +++ src/common.mk | 5 +- src/include/basic/fileops.bas | 1 + src/include/basic/music.bas | 121 +- src/include/basic/pkg.bas | 6 +- src/include/basic/print.bas | 21 + src/include/basic/shell.bas | 40 +- src/include/basic/sound.bas | 126 +- src/include/basic/system.bas | 41 +- src/installer/mkDist.sh | 23 + src/installer/setup.c | 2 +- src/libs/kpunch/audio/Makefile | 18 +- src/libs/kpunch/audio/audio.c | 222 +- src/libs/kpunch/audio/audio.dhs | 125 +- src/libs/kpunch/audio/audio.h | 379 +- src/libs/kpunch/audio/audioBas.c | 585 +- src/libs/kpunch/audio/audioDrv.h | 125 +- src/libs/kpunch/audio/audioInt.h | 62 +- src/libs/kpunch/audio/audioLoad.c | 135 +- src/libs/kpunch/audio/audioMidiIn.c | 294 + src/libs/kpunch/audio/audioMix.c | 249 +- src/libs/kpunch/audio/audioRec.c | 473 ++ src/libs/kpunch/audio/audioSet.c | 50 +- src/libs/kpunch/audio/audioShell.c | 5 +- src/libs/kpunch/audio/audioSynth.c | 23 +- src/libs/kpunch/audio/audioWt.c | 430 ++ src/libs/kpunch/audio/codec/codecCalc.c | 88 + src/libs/kpunch/audio/codec/codecCalc.h | 48 + src/libs/kpunch/audio/codec/codecHw.c | 225 + src/libs/kpunch/audio/codec/codecHw.h | 100 + src/libs/kpunch/audio/drivers/awe/aweCalc.c | 191 + src/libs/kpunch/audio/drivers/awe/aweCalc.h | 77 + src/libs/kpunch/audio/drivers/awe/aweDrv.c | 842 +++ src/libs/kpunch/audio/drivers/gus/gusDrv.c | 690 ++ .../kpunch/audio/drivers/guswt/gusWtDrv.c | 45 + src/libs/kpunch/audio/drivers/lpt/lptDrv.c | 344 + src/libs/kpunch/audio/drivers/mpu401/mpuDrv.c | 70 +- src/libs/kpunch/audio/drivers/opl/oplDrv.c | 52 +- src/libs/kpunch/audio/drivers/sb/sbCalc.c | 50 +- src/libs/kpunch/audio/drivers/sb/sbCalc.h | 26 +- src/libs/kpunch/audio/drivers/sb/sbDrv.c | 566 +- src/libs/kpunch/audio/drivers/wss/wssCalc.c | 64 + src/libs/kpunch/audio/drivers/wss/wssCalc.h | 41 + src/libs/kpunch/audio/drivers/wss/wssDrv.c | 385 + src/libs/kpunch/audio/gus/gusCalc.c | 275 + src/libs/kpunch/audio/gus/gusCalc.h | 129 + src/libs/kpunch/audio/gus/gusHw.c | 558 ++ src/libs/kpunch/audio/gus/gusHw.h | 215 + src/libs/kpunch/audio/gus/gusSynth.c | 277 + src/libs/kpunch/audio/gus/gusSynth.h | 51 + src/libs/kpunch/audio/isa/isaCalc.c | 54 + src/libs/kpunch/audio/isa/isaCalc.h | 52 + src/libs/kpunch/audio/isa/isaHw.c | 288 + src/libs/kpunch/audio/isa/isaHw.h | 114 + src/libs/kpunch/audio/math/drvMath.c | 61 + src/libs/kpunch/audio/math/drvMath.h | 45 + src/libs/kpunch/dvxnet/netDos.c | 21 +- src/libs/kpunch/dvxshell/Makefile | 10 +- src/libs/kpunch/dvxshell/dvxshell.dhs | 117 +- src/libs/kpunch/dvxshell/pmModel.c | 12 + src/libs/kpunch/dvxshell/pmModel.h | 8 +- src/libs/kpunch/dvxshell/shellApp.c | 66 +- src/libs/kpunch/dvxshell/shellApp.h | 22 + src/libs/kpunch/dvxshell/shellBas.c | 38 +- src/libs/kpunch/dvxshell/shellMain.c | 10 + src/libs/kpunch/dvxshell/shellProg.c | 24 +- src/libs/kpunch/dvxshell/shellProg.h | 12 + src/libs/kpunch/dvxshell/shellRun.c | 117 +- src/libs/kpunch/dvxshell/shellRun.h | 23 +- src/libs/kpunch/dvxshell/shellSaver.c | 231 + src/libs/kpunch/dvxshell/shellSaver.h | 89 + src/libs/kpunch/dvxshell/shellSysBas.c | 72 +- src/libs/kpunch/http/http.c | 16 +- src/libs/kpunch/json/json.c | 6 +- src/libs/kpunch/libdvx/apiref.dhs | 49 +- src/libs/kpunch/libdvx/dvxApp.c | 144 +- src/libs/kpunch/libdvx/dvxApp.h | 34 + src/libs/kpunch/libdvx/dvxText.c | 161 +- src/libs/kpunch/libdvx/dvxText.h | 10 +- src/libs/kpunch/libdvx/dvxWgt.h | 8 +- src/libs/kpunch/libdvx/games.dhs | 4 +- src/libs/kpunch/libdvx/platform/dvxPlat.h | 45 +- src/libs/kpunch/libdvx/platform/dvxPlatIsr.h | 4 + .../kpunch/libdvx/platform/dvxPlatformDos.c | 270 +- .../kpunch/libdvx/platform/dvxPlatformHost.c | 36 + .../kpunch/libdvx/platform/dvxPlatformIrq.c | 100 +- .../kpunch/libdvx/platform/dvxPlatformUtil.c | 32 +- src/libs/kpunch/libdvx/widgetLayout.c | 19 +- src/libs/kpunch/music/Makefile | 11 +- src/libs/kpunch/music/music.c | 1290 +++- src/libs/kpunch/music/music.dhs | 96 +- src/libs/kpunch/music/music.h | 165 +- src/libs/kpunch/music/musicBas.c | 243 +- src/libs/kpunch/music/musicCents.c | 143 + src/libs/kpunch/music/musicFound.c | 74 + src/libs/kpunch/music/musicInt.h | 713 +- src/libs/kpunch/music/musicMidiRec.c | 237 + src/libs/kpunch/music/musicMml.c | 89 +- src/libs/kpunch/music/musicModHw.c | 384 + src/libs/kpunch/music/musicModTick.c | 215 + src/libs/kpunch/music/musicMpu.c | 2 +- src/libs/kpunch/music/musicPat.c | 547 ++ src/libs/kpunch/music/musicSeq.c | 37 +- src/libs/kpunch/music/musicSf2.c | 994 +++ src/libs/kpunch/music/musicShell.c | 9 +- src/libs/kpunch/music/musicSmf.c | 126 +- src/libs/kpunch/music/musicSound.c | 249 + src/libs/kpunch/music/musicWave.c | 1064 +++ src/libs/kpunch/music/musicWaveLoad.c | 386 + src/libs/kpunch/music/musicWaveSoft.c | 260 + src/libs/kpunch/music/musicXmp.c | 161 +- .../thirdparty/libxmp-lite/DVX-VERSION.md | 33 +- .../dvx-patches/0001-hardware-voices.patch | 238 + .../thirdparty/libxmp-lite/include/xmp.h | 27 + .../music/thirdparty/libxmp-lite/src/common.h | 2 + .../thirdparty/libxmp-lite/src/control.c | 27 + .../music/thirdparty/libxmp-lite/src/mixer.c | 101 + .../music/thirdparty/libxmp-lite/src/mixer.h | 1 + src/libs/kpunch/pkg/pkg.dhs | 5 +- src/libs/kpunch/pkg/pkgBas.c | 11 + src/libs/kpunch/pkg/pkgBas.h | 1 + src/libs/kpunch/pkg/pkgCli.c | 4 +- src/libs/kpunch/pkg/pkgDpk.c | 8 +- src/libs/kpunch/pkg/pkgManifest.c | 5 +- src/libs/kpunch/pkg/pkgManifest.h | 1 + src/libs/kpunch/pkg/pkgOp.c | 2 +- src/libs/kpunch/pkg/pkgPlan.c | 10 +- src/libs/kpunch/pkgnet/pkgNet.c | 7 +- src/libs/kpunch/print/print.dep | 1 + src/libs/kpunch/print/print.dhs | 136 +- src/libs/kpunch/print/print.h | 295 +- src/libs/kpunch/print/printBas.c | 102 +- src/libs/kpunch/print/printEscp.c | 264 + src/libs/kpunch/print/printFont.c | 386 + src/libs/kpunch/print/printInt.h | 197 + src/libs/kpunch/print/printIw.c | 242 + src/libs/kpunch/print/printJob.c | 133 +- src/libs/kpunch/print/printPcl.c | 239 + src/libs/kpunch/print/printPs.c | 946 +++ src/libs/kpunch/print/printPsAfm.c | 264 + src/libs/kpunch/print/printRaster.c | 642 +- src/libs/kpunch/print/printSys.c | 135 +- src/libs/kpunch/print/printText.c | 36 +- src/libs/kpunch/print/printUi.c | 127 +- src/libs/kpunch/taskmgr/shellTaskMgr.c | 8 +- src/savers/README.md | 34 + src/savers/kpunch/Makefile | 82 + src/savers/kpunch/blank/blank.bas | 32 + src/savers/kpunch/blank/blank.dbp | 11 + src/savers/kpunch/marquee/marquee.bas | 85 + src/savers/kpunch/marquee/marquee.dbp | 11 + src/savers/kpunch/mystify/mystify.bas | 131 + src/savers/kpunch/mystify/mystify.dbp | 11 + src/savers/kpunch/starfld/starfld.c | 210 + src/savers/kpunch/starfld/starfld.res | 6 + src/sdkHeaders.mk | 2 +- src/test/abi/Makefile | 6 +- src/test/abi/abiCheck.py | 14 +- src/test/abi/abiSizes.txt | 24 +- src/test/audio/Makefile | 17 +- src/test/audio/audioTest.c | 1071 ++- src/test/audio/fakeDrv.c | 45 +- src/test/audio/fakeSynth.c | 85 +- src/test/basdev/testBasDev.c | 9 + src/test/dos/fixtures/dosrun/dosprog.bat | 3 + src/test/dos/fixtures/dosrun/dosrun.dbp | 18 + src/test/dos/fixtures/dosrun/dosrun.frm | 44 + src/test/dos/fixtures/parcp/parcp.frm | 11 +- src/test/dos/fixtures/prdraw/prdraw.dbp | 14 + src/test/dos/fixtures/prdraw/prdraw.frm | 66 + src/test/dos/golden/awemod/86box/audio.json | 59 + src/test/dos/golden/awerom/86box/audio.json | 59 + src/test/dos/golden/awewave/86box/audio.json | 59 + src/test/dos/golden/dosprog/dosran.txt | 1 + src/test/dos/golden/gus/86box/audio.json | 111 + src/test/dos/golden/gus/dosbox/audio.json | 111 + src/test/dos/golden/gusmod/86box/audio.json | 59 + src/test/dos/golden/gusmod/dosbox/audio.json | 59 + src/test/dos/golden/guspat/86box/audio.json | 59 + src/test/dos/golden/guspat/dosbox/audio.json | 59 + src/test/dos/golden/guswave/86box/audio.json | 59 + src/test/dos/golden/guswave/dosbox/audio.json | 59 + src/test/dos/golden/guswt/86box/audio.json | 59 + src/test/dos/golden/guswt/dosbox/audio.json | 59 + src/test/dos/golden/lptcovox/86box/audio.json | 111 + .../dos/golden/lptcovox/dosbox/audio.json | 111 + src/test/dos/golden/lptdss/86box/audio.json | 111 + src/test/dos/golden/lptdss/dosbox/audio.json | 111 + src/test/dos/golden/lptst86/86box/audio.json | 111 + src/test/dos/golden/lptston/dosbox/audio.json | 111 + src/test/dos/golden/musmod/86box/audio.json | 40 +- src/test/dos/golden/player/86box/audio.json | 59 + src/test/dos/golden/player/dosbox/audio.json | 59 + src/test/dos/golden/prdjcol/prdjcol.prn | Bin 0 -> 66375 bytes src/test/dos/golden/prlj300/prlj300.prn | Bin 0 -> 30271 bytes src/test/dos/golden/prps/prps.prn | 104 + src/test/dos/golden/softwave/86box/audio.json | 59 + .../dos/golden/softwave/dosbox/audio.json | 59 + src/test/dos/golden/wss/86box/audio.json | 111 + src/test/dos/mkMedia.py | 175 + src/test/dos/musTest/musTest.c | 433 +- src/test/dos/runDos.sh | 137 +- src/test/dos/scripts/apps.scr | 7 + src/test/dos/scripts/awemod.scr | 20 + src/test/dos/scripts/awerom.scr | 21 + src/test/dos/scripts/awewave.scr | 23 + src/test/dos/scripts/dosprog.scr | 34 + src/test/dos/scripts/endbas.scr | 22 + src/test/dos/scripts/f1.scr | 13 + src/test/dos/scripts/gus.scr | 17 + src/test/dos/scripts/gusmod.scr | 19 + src/test/dos/scripts/guspat.scr | 19 + src/test/dos/scripts/guswave.scr | 20 + src/test/dos/scripts/guswt.scr | 23 + src/test/dos/scripts/lptcovox.scr | 18 + src/test/dos/scripts/lptdss.scr | 17 + src/test/dos/scripts/lptst86.scr | 20 + src/test/dos/scripts/lptston.scr | 19 + src/test/dos/scripts/midiin.scr | 28 + src/test/dos/scripts/player.scr | 46 + src/test/dos/scripts/prdjcol.scr | 18 + src/test/dos/scripts/print.scr | 5 +- src/test/dos/scripts/prlj300.scr | 18 + src/test/dos/scripts/prps.scr | 19 + src/test/dos/scripts/record.scr | 42 + src/test/dos/scripts/saver.scr | 68 + src/test/dos/scripts/sndload.scr | 18 + src/test/dos/scripts/softwave.scr | 21 + src/test/dos/scripts/svdbg.scr | 20 + src/test/dos/scripts/wss.scr | 20 + src/test/formrt/testFormRt.c | 67 + src/test/ide/Makefile | 2 +- src/test/lint/analyzerBaseline.txt | 14 +- src/test/lint/clangTidyBaseline.txt | 18 + src/test/lint/codespellWords.txt | 8 + src/test/lint/cppcheckBaseline.txt | 42 +- src/test/lint/staticGate.sh | 2 +- src/test/music/Makefile | 73 +- src/test/music/musicFiles.c | 203 + src/test/music/musicFiles.h | 76 + src/test/music/musicTest.c | 2783 ++++++- src/test/music/playerTest.c | 635 ++ src/test/music/recorderTest.c | 601 ++ src/test/pkg/pkgTest.c | 7 +- src/test/pkg/pkgTestBas.c | 6 + src/test/print/Makefile | 11 +- src/test/print/golden/deskjet500c.prn | Bin 0 -> 96899 bytes src/test/print/golden/deskjetcolor.prn | Bin 0 -> 125094 bytes src/test/print/golden/epson24color.prn | Bin 0 -> 171440 bytes src/test/print/golden/epson9color.prn | Bin 0 -> 48026 bytes src/test/print/golden/imagewriter.prn | Bin 0 -> 32114 bytes src/test/print/golden/imagewritercolor.prn | Bin 0 -> 83843 bytes src/test/print/golden/laserjet300.prn | Bin 0 -> 32237 bytes src/test/print/golden/postscript.prn | 360 + src/test/print/golden/postscriptcolor.prn | 6450 +++++++++++++++++ src/test/print/printTest.c | 2206 +++++- src/test/samples/golden/basdemo-debug.app | Bin 28811 -> 32116 bytes src/test/samples/golden/basdemo-release.app | Bin 25058 -> 27900 bytes src/test/samples/golden/clock-debug.app | Bin 9791 -> 9823 bytes src/test/samples/golden/clock-release.app | Bin 9390 -> 9422 bytes src/test/samples/golden/fileMgr-debug.app | Bin 34668 -> 34766 bytes src/test/samples/golden/fileMgr-release.app | Bin 29463 -> 29553 bytes src/test/samples/golden/helpedit-debug.app | Bin 11983 -> 11994 bytes src/test/samples/golden/helpedit-release.app | Bin 10851 -> 10862 bytes src/test/samples/golden/iconed-debug.app | Bin 18582 -> 18748 bytes src/test/samples/golden/iconed-release.app | Bin 15987 -> 16153 bytes src/test/samples/golden/imgview-debug.app | Bin 9319 -> 9485 bytes src/test/samples/golden/imgview-release.app | Bin 8900 -> 9066 bytes src/test/samples/golden/inc-comm-debug.app | Bin 6737 -> 6737 bytes src/test/samples/golden/inc-comm-release.app | Bin 6685 -> 6685 bytes src/test/samples/golden/inc-commdlg-debug.app | Bin 6379 -> 6390 bytes .../samples/golden/inc-commdlg-release.app | Bin 6327 -> 6338 bytes .../samples/golden/inc-compiler-debug.app | Bin 6978 -> 6978 bytes .../samples/golden/inc-compiler-release.app | Bin 6926 -> 6926 bytes src/test/samples/golden/inc-conn-debug.app | Bin 6552 -> 6552 bytes src/test/samples/golden/inc-conn-release.app | Bin 6500 -> 6500 bytes src/test/samples/golden/inc-debug-debug.app | Bin 6434 -> 6434 bytes src/test/samples/golden/inc-debug-release.app | Bin 6382 -> 6382 bytes .../samples/golden/inc-designer-debug.app | Bin 6877 -> 6877 bytes .../samples/golden/inc-designer-release.app | Bin 6825 -> 6825 bytes src/test/samples/golden/inc-fileops-debug.app | Bin 7555 -> 7555 bytes .../samples/golden/inc-fileops-release.app | Bin 7503 -> 7503 bytes src/test/samples/golden/inc-font-debug.app | Bin 6368 -> 6368 bytes src/test/samples/golden/inc-font-release.app | Bin 6316 -> 6316 bytes src/test/samples/golden/inc-game-debug.app | Bin 6217 -> 6217 bytes src/test/samples/golden/inc-game-release.app | Bin 6165 -> 6165 bytes src/test/samples/golden/inc-help-debug.app | Bin 6220 -> 6220 bytes src/test/samples/golden/inc-help-release.app | Bin 6168 -> 6168 bytes src/test/samples/golden/inc-hlpfile-debug.app | Bin 6502 -> 6502 bytes .../samples/golden/inc-hlpfile-release.app | Bin 6450 -> 6450 bytes src/test/samples/golden/inc-http-debug.app | Bin 0 -> 6341 bytes src/test/samples/golden/inc-http-release.app | Bin 0 -> 6289 bytes .../samples/golden/inc-joystick-debug.app | Bin 6669 -> 6669 bytes .../samples/golden/inc-joystick-release.app | Bin 6617 -> 6617 bytes src/test/samples/golden/inc-json-debug.app | Bin 0 -> 6265 bytes src/test/samples/golden/inc-json-release.app | Bin 0 -> 6213 bytes src/test/samples/golden/inc-layout-debug.app | Bin 6187 -> 6187 bytes .../samples/golden/inc-layout-release.app | Bin 6135 -> 6135 bytes .../samples/golden/inc-listhelp-debug.app | Bin 6723 -> 6723 bytes .../samples/golden/inc-listhelp-release.app | Bin 6671 -> 6671 bytes src/test/samples/golden/inc-music-debug.app | Bin 6478 -> 6726 bytes src/test/samples/golden/inc-music-release.app | Bin 6426 -> 6674 bytes src/test/samples/golden/inc-net-debug.app | Bin 6576 -> 6576 bytes src/test/samples/golden/inc-net-release.app | Bin 6524 -> 6524 bytes src/test/samples/golden/inc-pkg-debug.app | Bin 0 -> 6759 bytes src/test/samples/golden/inc-pkg-release.app | Bin 0 -> 6707 bytes src/test/samples/golden/inc-pkgnet-debug.app | Bin 0 -> 6515 bytes .../samples/golden/inc-pkgnet-release.app | Bin 0 -> 6463 bytes src/test/samples/golden/inc-prefs-debug.app | Bin 6576 -> 6608 bytes src/test/samples/golden/inc-prefs-release.app | Bin 6524 -> 6556 bytes src/test/samples/golden/inc-print-debug.app | Bin 6755 -> 6910 bytes src/test/samples/golden/inc-print-release.app | Bin 6703 -> 6858 bytes .../samples/golden/inc-resource-debug.app | Bin 6304 -> 6304 bytes .../samples/golden/inc-resource-release.app | Bin 6252 -> 6252 bytes .../samples/golden/inc-richtext-debug.app | Bin 6190 -> 6190 bytes .../samples/golden/inc-richtext-release.app | Bin 6138 -> 6138 bytes .../samples/golden/inc-security-debug.app | Bin 6453 -> 6453 bytes .../samples/golden/inc-security-release.app | Bin 6401 -> 6401 bytes src/test/samples/golden/inc-shell-debug.app | Bin 7593 -> 7625 bytes src/test/samples/golden/inc-shell-release.app | Bin 7541 -> 7573 bytes src/test/samples/golden/inc-sound-debug.app | Bin 6977 -> 7411 bytes src/test/samples/golden/inc-sound-release.app | Bin 6925 -> 7359 bytes src/test/samples/golden/inc-sql-debug.app | Bin 6332 -> 6332 bytes src/test/samples/golden/inc-sql-release.app | Bin 6280 -> 6280 bytes src/test/samples/golden/inc-system-debug.app | Bin 6936 -> 7092 bytes .../samples/golden/inc-system-release.app | Bin 6884 -> 7040 bytes src/test/samples/golden/inc-xfer-debug.app | Bin 6486 -> 6486 bytes src/test/samples/golden/inc-xfer-release.app | Bin 6434 -> 6434 bytes src/test/samples/golden/notepad-debug.app | Bin 12104 -> 12270 bytes src/test/samples/golden/notepad-release.app | Bin 10863 -> 11029 bytes src/test/samples/golden/pkgbuild-debug.app | Bin 0 -> 27270 bytes src/test/samples/golden/pkgbuild-release.app | Bin 0 -> 24984 bytes src/test/samples/golden/player-debug.app | Bin 0 -> 23604 bytes src/test/samples/golden/player-release.app | Bin 0 -> 19799 bytes src/test/samples/golden/recorder-debug.app | Bin 0 -> 22865 bytes src/test/samples/golden/recorder-release.app | Bin 0 -> 20075 bytes src/test/samples/golden/resedit-debug.app | Bin 13422 -> 13433 bytes src/test/samples/golden/resedit-release.app | Bin 11881 -> 11892 bytes src/test/samples/golden/software-debug.app | Bin 0 -> 36697 bytes src/test/samples/golden/software-release.app | Bin 0 -> 32713 bytes src/test/samples/golden/terminal-debug.app | Bin 47374 -> 47709 bytes src/test/samples/golden/terminal-release.app | Bin 42724 -> 43024 bytes src/test/samples/golden/welcome-debug.app | Bin 78180 -> 78212 bytes src/test/samples/golden/welcome-release.app | Bin 75712 -> 75744 bytes src/test/samples/golden/widshow-debug.app | Bin 14389 -> 14389 bytes src/test/samples/golden/widshow-release.app | Bin 13463 -> 13463 bytes src/test/shell/Makefile | 15 +- src/test/shell/appsTest.c | 719 +- src/test/shell/basic/shbind/shbind.frm | 6 + src/test/shell/progmanTest.c | 7 + src/test/shell/shellFixture.c | 11 +- src/test/shell/shellFixture.h | 4 +- src/test/unit/testFonts.c | 46 +- src/test/unit/testLayout.c | 5 +- src/test/unit/testPlatform.c | 1 + src/test/widgets/testMisc.c | 23 +- src/tools/Makefile | 50 +- src/tools/README.md | 20 + src/tools/afmWidths.py | 155 + src/tools/mksf.c | 1047 +++ src/widgets/kpunch/richLabel/richlbl.dhs | 2 +- .../kpunch/richLabel/widgetRichLabel.c | 11 +- src/widgets/kpunch/wgtsys.dhs | 5 +- 615 files changed, 49876 insertions(+), 2900 deletions(-) create mode 100644 config/DOSPROG.BMP create mode 100644 screenshots/86box/apps-player.png create mode 100644 screenshots/86box/dosprog-dp-after.png create mode 100644 screenshots/86box/dosprog-dp-ask.png create mode 100644 screenshots/86box/dosprog-dp-back.png create mode 100644 screenshots/86box/f1-h-player.png create mode 100644 screenshots/86box/midiin-rc-midi.png create mode 100644 screenshots/86box/player-p-mid.png create mode 100644 screenshots/86box/player-p-mod.png create mode 100644 screenshots/86box/player-p-voc.png create mode 100644 screenshots/86box/player-p-wav.png create mode 100644 screenshots/86box/record-rc-open.png create mode 100644 screenshots/86box/saver-sv-back.png create mode 100644 screenshots/86box/saver-sv-blank.png create mode 100644 screenshots/86box/saver-sv-cpd.png create mode 100644 screenshots/dosbox/apps-player.png create mode 100644 screenshots/dosbox/dosprog-dp-after.png create mode 100644 screenshots/dosbox/dosprog-dp-ask.png create mode 100644 screenshots/dosbox/dosprog-dp-back.png create mode 100644 screenshots/dosbox/f1-h-player.png create mode 100644 screenshots/dosbox/midiin-rc-midi.png create mode 100644 screenshots/dosbox/player-p-mid.png create mode 100644 screenshots/dosbox/player-p-mod.png create mode 100644 screenshots/dosbox/player-p-voc.png create mode 100644 screenshots/dosbox/player-p-wav.png create mode 100644 screenshots/dosbox/record-rc-open.png create mode 100644 screenshots/dosbox/saver-sv-back.png create mode 100644 screenshots/dosbox/saver-sv-blank.png create mode 100644 screenshots/dosbox/saver-sv-cpd.png create mode 100644 screenshots/masks/install-su-lic.mask create mode 100644 src/addons/README.md create mode 100644 src/addons/banks/dmxopl/PACKAGE.INI create mode 100644 src/addons/banks/dmxopl/SHARED/BANKS/KPUNCH/DMXOPL.OP2 create mode 100644 src/addons/banks/dmxopl/SHARED/BANKS/KPUNCH/DMXOPL.TXT create mode 100644 src/addons/banks/dmxopl3/PACKAGE.INI create mode 100644 src/addons/banks/dmxopl3/SHARED/BANKS/KPUNCH/DMXOPL3.OP2 create mode 100644 src/addons/banks/dmxopl3/SHARED/BANKS/KPUNCH/DMXOPL3.TXT create mode 100644 src/addons/banks/dvxgm/PACKAGE.INI create mode 100644 src/addons/banks/dvxgm/SHARED/BANKS/KPUNCH/DVXGM.TXT create mode 100644 src/addons/banks/dvxgm/soundfont.ini create mode 100755 src/addons/banks/installSoundFonts.sh create mode 100644 src/addons/fonts/prfonts/PACKAGE.INI create mode 100644 src/addons/fonts/prfonts/fonts.ini create mode 100644 src/apps/kpunch/dvxbasic/formrt/formPrinter.c create mode 100644 src/apps/kpunch/player/ICON32.BMP create mode 100644 src/apps/kpunch/player/player.dbp create mode 100644 src/apps/kpunch/player/player.dhs create mode 100644 src/apps/kpunch/player/player.frm create mode 100644 src/apps/kpunch/recorder/ICON32.BMP create mode 100644 src/apps/kpunch/recorder/recorder.dbp create mode 100644 src/apps/kpunch/recorder/recorder.dhs create mode 100644 src/apps/kpunch/recorder/recorder.frm create mode 100644 src/libs/kpunch/audio/audioMidiIn.c create mode 100644 src/libs/kpunch/audio/audioRec.c create mode 100644 src/libs/kpunch/audio/audioWt.c create mode 100644 src/libs/kpunch/audio/codec/codecCalc.c create mode 100644 src/libs/kpunch/audio/codec/codecCalc.h create mode 100644 src/libs/kpunch/audio/codec/codecHw.c create mode 100644 src/libs/kpunch/audio/codec/codecHw.h create mode 100644 src/libs/kpunch/audio/drivers/awe/aweCalc.c create mode 100644 src/libs/kpunch/audio/drivers/awe/aweCalc.h create mode 100644 src/libs/kpunch/audio/drivers/awe/aweDrv.c create mode 100644 src/libs/kpunch/audio/drivers/gus/gusDrv.c create mode 100644 src/libs/kpunch/audio/drivers/guswt/gusWtDrv.c create mode 100644 src/libs/kpunch/audio/drivers/lpt/lptDrv.c create mode 100644 src/libs/kpunch/audio/drivers/wss/wssCalc.c create mode 100644 src/libs/kpunch/audio/drivers/wss/wssCalc.h create mode 100644 src/libs/kpunch/audio/drivers/wss/wssDrv.c create mode 100644 src/libs/kpunch/audio/gus/gusCalc.c create mode 100644 src/libs/kpunch/audio/gus/gusCalc.h create mode 100644 src/libs/kpunch/audio/gus/gusHw.c create mode 100644 src/libs/kpunch/audio/gus/gusHw.h create mode 100644 src/libs/kpunch/audio/gus/gusSynth.c create mode 100644 src/libs/kpunch/audio/gus/gusSynth.h create mode 100644 src/libs/kpunch/audio/isa/isaCalc.c create mode 100644 src/libs/kpunch/audio/isa/isaCalc.h create mode 100644 src/libs/kpunch/audio/isa/isaHw.c create mode 100644 src/libs/kpunch/audio/isa/isaHw.h create mode 100644 src/libs/kpunch/audio/math/drvMath.c create mode 100644 src/libs/kpunch/audio/math/drvMath.h create mode 100644 src/libs/kpunch/dvxshell/shellSaver.c create mode 100644 src/libs/kpunch/dvxshell/shellSaver.h create mode 100644 src/libs/kpunch/music/musicCents.c create mode 100644 src/libs/kpunch/music/musicFound.c create mode 100644 src/libs/kpunch/music/musicMidiRec.c create mode 100644 src/libs/kpunch/music/musicModHw.c create mode 100644 src/libs/kpunch/music/musicModTick.c create mode 100644 src/libs/kpunch/music/musicPat.c create mode 100644 src/libs/kpunch/music/musicSf2.c create mode 100644 src/libs/kpunch/music/musicSound.c create mode 100644 src/libs/kpunch/music/musicWave.c create mode 100644 src/libs/kpunch/music/musicWaveLoad.c create mode 100644 src/libs/kpunch/music/musicWaveSoft.c create mode 100644 src/libs/kpunch/music/thirdparty/libxmp-lite/dvx-patches/0001-hardware-voices.patch create mode 100644 src/libs/kpunch/print/printEscp.c create mode 100644 src/libs/kpunch/print/printFont.c create mode 100644 src/libs/kpunch/print/printIw.c create mode 100644 src/libs/kpunch/print/printPcl.c create mode 100644 src/libs/kpunch/print/printPs.c create mode 100644 src/libs/kpunch/print/printPsAfm.c create mode 100644 src/savers/README.md create mode 100644 src/savers/kpunch/Makefile create mode 100644 src/savers/kpunch/blank/blank.bas create mode 100644 src/savers/kpunch/blank/blank.dbp create mode 100644 src/savers/kpunch/marquee/marquee.bas create mode 100644 src/savers/kpunch/marquee/marquee.dbp create mode 100644 src/savers/kpunch/mystify/mystify.bas create mode 100644 src/savers/kpunch/mystify/mystify.dbp create mode 100644 src/savers/kpunch/starfld/starfld.c create mode 100644 src/savers/kpunch/starfld/starfld.res create mode 100644 src/test/dos/fixtures/dosrun/dosprog.bat create mode 100644 src/test/dos/fixtures/dosrun/dosrun.dbp create mode 100644 src/test/dos/fixtures/dosrun/dosrun.frm create mode 100644 src/test/dos/fixtures/prdraw/prdraw.dbp create mode 100644 src/test/dos/fixtures/prdraw/prdraw.frm create mode 100644 src/test/dos/golden/awemod/86box/audio.json create mode 100644 src/test/dos/golden/awerom/86box/audio.json create mode 100644 src/test/dos/golden/awewave/86box/audio.json create mode 100644 src/test/dos/golden/dosprog/dosran.txt create mode 100644 src/test/dos/golden/gus/86box/audio.json create mode 100644 src/test/dos/golden/gus/dosbox/audio.json create mode 100644 src/test/dos/golden/gusmod/86box/audio.json create mode 100644 src/test/dos/golden/gusmod/dosbox/audio.json create mode 100644 src/test/dos/golden/guspat/86box/audio.json create mode 100644 src/test/dos/golden/guspat/dosbox/audio.json create mode 100644 src/test/dos/golden/guswave/86box/audio.json create mode 100644 src/test/dos/golden/guswave/dosbox/audio.json create mode 100644 src/test/dos/golden/guswt/86box/audio.json create mode 100644 src/test/dos/golden/guswt/dosbox/audio.json create mode 100644 src/test/dos/golden/lptcovox/86box/audio.json create mode 100644 src/test/dos/golden/lptcovox/dosbox/audio.json create mode 100644 src/test/dos/golden/lptdss/86box/audio.json create mode 100644 src/test/dos/golden/lptdss/dosbox/audio.json create mode 100644 src/test/dos/golden/lptst86/86box/audio.json create mode 100644 src/test/dos/golden/lptston/dosbox/audio.json create mode 100644 src/test/dos/golden/player/86box/audio.json create mode 100644 src/test/dos/golden/player/dosbox/audio.json create mode 100644 src/test/dos/golden/prdjcol/prdjcol.prn create mode 100644 src/test/dos/golden/prlj300/prlj300.prn create mode 100644 src/test/dos/golden/prps/prps.prn create mode 100644 src/test/dos/golden/softwave/86box/audio.json create mode 100644 src/test/dos/golden/softwave/dosbox/audio.json create mode 100644 src/test/dos/golden/wss/86box/audio.json create mode 100755 src/test/dos/mkMedia.py create mode 100644 src/test/dos/scripts/awemod.scr create mode 100644 src/test/dos/scripts/awerom.scr create mode 100644 src/test/dos/scripts/awewave.scr create mode 100644 src/test/dos/scripts/dosprog.scr create mode 100644 src/test/dos/scripts/endbas.scr create mode 100644 src/test/dos/scripts/gus.scr create mode 100644 src/test/dos/scripts/gusmod.scr create mode 100644 src/test/dos/scripts/guspat.scr create mode 100644 src/test/dos/scripts/guswave.scr create mode 100644 src/test/dos/scripts/guswt.scr create mode 100644 src/test/dos/scripts/lptcovox.scr create mode 100644 src/test/dos/scripts/lptdss.scr create mode 100644 src/test/dos/scripts/lptst86.scr create mode 100644 src/test/dos/scripts/lptston.scr create mode 100644 src/test/dos/scripts/midiin.scr create mode 100644 src/test/dos/scripts/player.scr create mode 100644 src/test/dos/scripts/prdjcol.scr create mode 100644 src/test/dos/scripts/prlj300.scr create mode 100644 src/test/dos/scripts/prps.scr create mode 100644 src/test/dos/scripts/record.scr create mode 100644 src/test/dos/scripts/saver.scr create mode 100644 src/test/dos/scripts/sndload.scr create mode 100644 src/test/dos/scripts/softwave.scr create mode 100644 src/test/dos/scripts/svdbg.scr create mode 100644 src/test/dos/scripts/wss.scr create mode 100644 src/test/music/musicFiles.c create mode 100644 src/test/music/musicFiles.h create mode 100644 src/test/music/playerTest.c create mode 100644 src/test/music/recorderTest.c create mode 100644 src/test/print/golden/deskjet500c.prn create mode 100644 src/test/print/golden/deskjetcolor.prn create mode 100644 src/test/print/golden/epson24color.prn create mode 100644 src/test/print/golden/epson9color.prn create mode 100644 src/test/print/golden/imagewriter.prn create mode 100644 src/test/print/golden/imagewritercolor.prn create mode 100644 src/test/print/golden/laserjet300.prn create mode 100644 src/test/print/golden/postscript.prn create mode 100644 src/test/print/golden/postscriptcolor.prn create mode 100644 src/test/samples/golden/inc-http-debug.app create mode 100644 src/test/samples/golden/inc-http-release.app create mode 100644 src/test/samples/golden/inc-json-debug.app create mode 100644 src/test/samples/golden/inc-json-release.app create mode 100644 src/test/samples/golden/inc-pkg-debug.app create mode 100644 src/test/samples/golden/inc-pkg-release.app create mode 100644 src/test/samples/golden/inc-pkgnet-debug.app create mode 100644 src/test/samples/golden/inc-pkgnet-release.app create mode 100644 src/test/samples/golden/pkgbuild-debug.app create mode 100644 src/test/samples/golden/pkgbuild-release.app create mode 100644 src/test/samples/golden/player-debug.app create mode 100644 src/test/samples/golden/player-release.app create mode 100644 src/test/samples/golden/recorder-debug.app create mode 100644 src/test/samples/golden/recorder-release.app create mode 100644 src/test/samples/golden/software-debug.app create mode 100644 src/test/samples/golden/software-release.app create mode 100644 src/tools/afmWidths.py create mode 100644 src/tools/mksf.c diff --git a/Makefile b/Makefile index 0147c5d9..1a1f1933 100644 --- a/Makefile +++ b/Makefile @@ -39,11 +39,11 @@ LIBS = $(sort $(notdir $(patsubst %/Makefile,%,$(wildcard src/libs/kpunch/*/Makefile)))) CORE_LIBS = libdvx libtasks -.PHONY: all clean $(LIBS) loader widgets apps tools installer dist deploy-helpsrc compile-help deploy-sdk test test-quick test-asan test-host test-modules test-samples coverage test-qa test-fuzz bench bench-compare +.PHONY: all clean $(LIBS) loader widgets apps savers tools installer dist deploy-helpsrc compile-help deploy-sdk test test-quick test-asan test-host test-modules test-samples coverage test-qa test-fuzz bench bench-compare # Every build ends by making the release media in dist/ (a second or # two), so dist/ always holds the latest DVX. -all: $(LIBS) loader tools widgets apps installer deploy-helpsrc compile-help deploy-sdk +all: $(LIBS) loader tools widgets apps savers installer deploy-helpsrc compile-help deploy-sdk src/installer/mkDist.sh $(or $(FLOPPY),1.44M) $(LIBS): @@ -77,6 +77,11 @@ dist: all apps: libdvx libtasks dvxshell tools $(MAKE) -C src/apps/kpunch +# The screen savers (bin/shared/savers): BASIC ones need bascomp, which +# the apps build makes. +savers: apps + $(MAKE) -C src/savers/kpunch + # Copies help sources into the module directories under bin/, so every # module that owns one must be built first. deploy-helpsrc: $(LIBS) widgets apps tools @@ -132,7 +137,7 @@ test-samples: # Builds every module first so the check sees the current binaries. BASIC_STUB = obj/dvxbasic/basstub.app -test-modules: $(LIBS) loader widgets apps +test-modules: $(LIBS) loader widgets apps savers src/test/shell/checkDxeExports.sh . $(BASIC_STUB) # BASIC compiler / VM benchmarks (src/test/basic/basBench.c, programs in @@ -288,6 +293,7 @@ clean: $(MAKE) -C src/loader clean $(MAKE) -C src/widgets/kpunch clean $(MAKE) -C src/apps/kpunch clean + $(MAKE) -C src/savers/kpunch clean $(MAKE) -C src/tools clean $(MAKE) -C src/tools/proxy clean $(MAKE) -C src/installer clean diff --git a/config/ASSOC.INI b/config/ASSOC.INI index 37ce1201..00386929 100644 --- a/config/ASSOC.INI +++ b/config/ASSOC.INI @@ -24,3 +24,11 @@ CTL = apps/kpunch/dvxbasic/dvxbasic.app HLP = apps/kpunch/dvxhelp/dvxhelp.app DHS = apps/kpunch/dvxhelp/helpedit.app DPK = apps/kpunch/software/software.app +MOD = apps/kpunch/player/player.app +S3M = apps/kpunch/player/player.app +XM = apps/kpunch/player/player.app +IT = apps/kpunch/player/player.app +MID = apps/kpunch/player/player.app +RMI = apps/kpunch/player/player.app +WAV = apps/kpunch/player/player.app +VOC = apps/kpunch/player/player.app diff --git a/config/DOSPROG.BMP b/config/DOSPROG.BMP new file mode 100644 index 00000000..8ad13ad6 --- /dev/null +++ b/config/DOSPROG.BMP @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dad889643c3f2f3efc39e0d5c418233ce0b742d39e7545bbd079b4f1e10a344 +size 3126 diff --git a/config/README.md b/config/README.md index 86fe89e1..9a34d669 100644 --- a/config/README.md +++ b/config/README.md @@ -16,6 +16,7 @@ the shipped list. |-------------|-------------|-------------| | `dvx.ini` | `bin/system/DVX.INI` | The settings a new user starts with | | `ASSOC.INI` | `bin/system/ASSOC.INI` | File associations: which program opens each document type (shellAssoc.h; changed on the Control Panel's File Types page, in the user's ASSOC.INI) | +| `DOSPROG.BMP` | `bin/system/DOSPROG.BMP` | The icon of a DOS program (EXE, COM, BAT) in Program Manager when it has no picture of its own (SHELL_DOS_ICON, shellProg.h) | | `themes/cde.thm` | `bin/shared/themes/cde.thm` | CDE color theme | | `themes/geos.thm` | `bin/shared/themes/geos.thm` | GEOS Ensemble theme | | `themes/hotdog.thm` | `bin/shared/themes/hotdog.thm` | Hot Dog Stand theme | @@ -99,6 +100,8 @@ compiled-in defaults. |-----|---------|--------|-------------| | `wallpaper` | (none) | file path | Path to wallpaper image | | `mode` | stretch | stretch, tile, center | Wallpaper display mode | +| `saver` | (none) | file name | Screen saver: a program in SHARED\SAVERS or the user's SAVERS | +| `saverWait` | 10 | 1-60 | Minutes with no key or mouse input before the screen saver starts | ### [network] Section @@ -125,9 +128,11 @@ as a whole in favour of the defaults. | Key | Default | Values | Description | |-----|---------|--------|-------------| -| `port` | LPT1 | LPT1, LPT2, LPT3, FILE | Where printed output goes | +| `port` | LPT1 | LPT1, LPT2, LPT3, COM1, COM2, COM3, COM4, FILE | Where printed output goes | +| `baud` | 9600 | 300, 1200, 2400, 4800, 9600, 19200, 38400 | A serial printer's speed (port COM1-COM4) | +| `handshake` | dtrdsr | none, xonxoff, rtscts, dtrdsr | How a serial printer holds the computer back: XON / XOFF characters, or its ready line on the computer's CTS or DSR (Apple's ImageWriter cable) | | `file` | PRINTER.PRN | file path | The file used with port = FILE | -| `model` | text | text, epson9, epson24, laserjet | Printer model; all but text print pictures | +| `model` | text | text, epson9, epson24, laserjet, laserjet300, epson9color, epson24color, deskjet500c, deskjetcolor, postscript, postscriptcolor, imagewriter, imagewritercolor | Printer model; all but text print pictures; epson9color, epson24color, deskjet500c, deskjetcolor, postscriptcolor, and imagewritercolor in color | | `linesPerPage` | 0 | 0, 10-200 | Lines per page; 0 = the model's usual length (66, laser 60) | | `columns` | 80 | 20-255 | Characters across the page | | `marginTop`, `marginBottom` | 3 | lines | Top and bottom margins (header / footer in their middle line) | diff --git a/config/dvx.ini b/config/dvx.ini index 471f6af0..ce52cfad 100644 --- a/config/dvx.ini +++ b/config/dvx.ini @@ -65,9 +65,19 @@ user = password = ; Printer settings (Control Panel's Printer page and File > Page Setup). -; port: LPT1, LPT2, LPT3 or FILE (print into the file named by file) +; port: LPT1, LPT2, LPT3, COM1 to COM4 (a serial printer), or FILE +; (print into the file named by file) +; baud, handshake: a serial printer's speed (300 to 38400) and how it +; holds the computer back: none, xonxoff, rtscts (its ready line on +; CTS), or dtrdsr (on DSR, as Apple's ImageWriter cable wires it) ; model: text (any printer, text only), epson9, epson24 (Epson ESC/P -; dot matrix) or laserjet (HP PCL); all but text print pictures +; dot matrix), laserjet (HP PCL, 150 dpi), laserjet300 (HP +; LaserJet III, DeskJet, and later; 300 dpi), epson9color, +; epson24color (Epson with a color ribbon), deskjet500c, +; deskjetcolor (DeskJet 550C and later), postscript, +; postscriptcolor, imagewriter, or imagewritercolor (Apple +; ImageWriter II); all but text print pictures, the color ones in +; color ; linesPerPage: 0 = the printer's usual page length (66, laser 60) ; columns: characters across the page; margins in lines (top, bottom) ; and characters (left, right) @@ -88,10 +98,14 @@ tabWidth = 8 wrap = true header = &f footer = Page &p +baud = 9600 +handshake = dtrdsr ; Sound settings. -; driver: auto (find the sound hardware), sb (Sound Blaster family), -; speaker (PC speaker) or null (no sound) +; driver: auto (find the sound hardware), wss (Windows Sound System), +; sb (Sound Blaster family), gus (Gravis UltraSound), lpt (a +; DAC on the printer port), speaker (PC speaker) or null (no +; sound) ; rate: mixing rate in Hz (4000-48000, default 22050); the sound card may ; run slightly faster or slower ; bits, channels: 0 = the best the sound card can do, or 8 / 16 bits and @@ -103,17 +117,37 @@ footer = Page &p ; the computer's time); false = tones only ; fmTones: true = square and triangle tones play on the music synthesizer ; chip when there is one; false = always mixed -; midi: where MIDI music plays: auto (the synthesizer chip, else a -; built-in synthesizer), mpu401 (an external synthesizer on the -; MIDI port), fm (the chip) or software (the built-in synthesizer) +; midi: where MIDI music plays: auto (a Gravis UltraSound's or Sound +; Blaster AWE's recorded instruments, else the synthesizer chip, +; else a built-in synthesizer), mpu401 (an external synthesizer on +; the MIDI port), fm (the chip), software (the built-in +; synthesizer) or softwave (the SoundFont played by the computer) +; midiIn: where MIDI input (a MIDI keyboard) comes from: none or mpu401 +; (the MIDI port, shared with MIDI music when midi = mpu401 too) +; modules: where music modules (MOD, S3M, XM, IT) play: wavetable (a +; Gravis UltraSound's or Sound Blaster AWE's own voices when no +; other music plays there and the module fits, else mixed by +; the computer) or software (always mixed by the computer) ; mpuMode: what is on the MIDI port: gm (a General MIDI synthesizer), ; mt32 (a Roland MT-32) or raw (send music unchanged) ; Optional: oplPort (default 388) and mpuPort (default 330), in hex; ; midiBank = the instrument bank for MIDI music (OP2 format): its name in ; SHARED\BANKS or USER\BANKS (an add-on's as VENDOR\NAME), or a path from -; the DVX folder; not set, the bundled one. Control Panel picks it. +; the DVX folder; not set, the bundled one. Control Panel picks it; +; each MIDI music reads it as it starts. +; soundFont = the recorded instruments (a SoundFont, .SF2 or .SBK) for a +; Gravis UltraSound, a Sound Blaster AWE and softwave, named as midiBank +; is, or the path of a Gravis UltraSound's ULTRASND.INI (its patches); +; not set, KPUNCH\DVXGM.SF2 (an add-on), else the card's own. ; The Sound Blaster's port, IRQ and DMA come from the BLASTER setting (for -; example SET BLASTER=A220 I5 D1 H5 in AUTOEXEC.BAT). +; example SET BLASTER=A220 I5 D1 H5 in AUTOEXEC.BAT; an AWE adds E620), +; the Gravis UltraSound's from ULTRASND (SET ULTRASND=220,1,1,11,5). +; Optional: gusVoices (14-32, default 28: fewer play at a higher rate); +; wssPort (530, 604, E80 or F40, hex), wssIrq (7, 9, 10 or 11, default 7) +; and wssDma (0, 1 or 3, default 1) for a Windows Sound System card; +; lptDac (auto, covox, disney or stereo; auto finds a Disney Sound Source +; or a Stereo-on-1, a Covox must be named) and lptPort (hex, default +; LPT1) for a DAC on the printer port. [audio] driver = auto @@ -125,6 +159,8 @@ volume = 100 speakerPcm = false fmTones = true midi = auto +midiIn = none +modules = wavetable mpuMode = gm ; Desktop sounds (Control Panel's Sounds page): the sound file (WAV or VOC) diff --git a/docs/dvx_basic_reference.html b/docs/dvx_basic_reference.html index 753fdbc4..1a99ebb9 100644 --- a/docs/dvx_basic_reference.html +++ b/docs/dvx_basic_reference.html @@ -107,6 +107,7 @@ img { max-width: 100%; }
  • SQL Functions
  • App Object
  • Clipboard Object
  • +
  • Printer Object
  • Screen Object
  • INI Functions
  • Predefined Constants
  • @@ -455,6 +456,7 @@ img { max-width: 100%; }
  • Drawing Methods
  • DrawScrollArrow
  • DrawScrollBar
  • +
  • DrawStyle
  • DrawWidth
  • DropDown
  • DVX BASIC
  • @@ -473,6 +475,7 @@ img { max-width: 100%; }
  • END SUB
  • END TYPE
  • END WITH
  • +
  • EndDoc
  • ENUM
  • ENVIRON$
  • EOF
  • @@ -701,6 +704,7 @@ img { max-width: 100%; }
  • Key Codes
  • Keyboard Shortcuts
  • KILL
  • +
  • KillDoc
  • Label
  • Labels
  • Late Binding
  • @@ -771,9 +775,16 @@ img { max-width: 100%; }
  • MF
  • MID$
  • MIDI
  • +
  • MIDI keyboard
  • MidiAcquire
  • +
  • MidiInClock
  • +
  • MidiInMessage
  • +
  • MidiInOpen
  • +
  • MidiInTime
  • MidiPortInfo$
  • MidiPut
  • +
  • MidiRecordNew
  • +
  • MidiRecordSave
  • Migration
  • Min
  • MINUTE
  • @@ -805,9 +816,12 @@ img { max-width: 100%; }
  • MusicInfo$
  • MusicLength
  • MusicLoad
  • +
  • MusicLoadMacro
  • MusicMidiOpen
  • +
  • MusicMidiSend
  • MusicNoteOff
  • MusicNoteOn
  • +
  • MusicPatchSet$
  • MusicPause
  • MusicPitchBend
  • MusicPlay
  • @@ -815,7 +829,9 @@ img { max-width: 100%; }
  • MusicPosition
  • MusicProgram
  • MusicResume
  • +
  • MusicRomBank$
  • MusicSeek
  • +
  • MusicSetBank
  • MusicStop
  • MusicVolume
  • NAME
  • @@ -830,6 +846,7 @@ img { max-width: 100%; }
  • New UserControl
  • New UserControl
  • NewArgs
  • +
  • NewPage
  • NEXT
  • NOT
  • Nothing
  • @@ -878,6 +895,7 @@ img { max-width: 100%; }
  • PictureBox
  • pkg.bas
  • PkgBuild
  • +
  • PkgFileOwner$
  • PkgInstallStart
  • PkgKeygen
  • pkgnet.bas
  • @@ -924,6 +942,18 @@ img { max-width: 100%; }
  • print.bas
  • PrintAccel
  • PrintChoose
  • +
  • Printer Object
  • +
  • Printer.Circle
  • +
  • Printer.Copies
  • +
  • Printer.EndDoc
  • +
  • Printer.FontSize
  • +
  • Printer.KillDoc
  • +
  • Printer.Line
  • +
  • Printer.NewPage
  • +
  • Printer.Page
  • +
  • Printer.PaintPicture
  • +
  • Printer.Print
  • +
  • Printer.PSet
  • PrintForm
  • PrintPageSetting
  • PrintPageSetup
  • @@ -971,7 +1001,10 @@ img { max-width: 100%; }
  • READ
  • RealMode
  • Recent files menu
  • +
  • Recording
  • RecordSource
  • +
  • RecordStart
  • +
  • RecordStop
  • REDIM
  • Reference Counting
  • Release Notes
  • @@ -1148,19 +1181,23 @@ img { max-width: 100%; }
  • SoundCaps
  • SoundCreate
  • SoundError$
  • +
  • SoundFont
  • SoundFree
  • SoundGetVolume
  • SoundInfo$
  • SoundLength
  • SoundLoad
  • +
  • SoundPause
  • SoundPlay
  • SoundPlayEvent
  • SoundPlaying
  • +
  • SoundResume
  • SoundSetFrequency
  • SoundSetPan
  • SoundSetPitch
  • SoundSetVolume
  • SoundStreamClose
  • +
  • SoundStreamFlush
  • SoundStreamOpen
  • SoundStreamWrite
  • SoundVolume
  • @@ -1318,15 +1355,22 @@ img { max-width: 100%; }
  • vbCFText
  • vbCharacters
  • vbCritical
  • +
  • vbCross
  • vbCrosshair
  • vbCtrlMask
  • vbCurrency
  • vbCyan
  • +
  • vbDash
  • +
  • vbDashDot
  • +
  • vbDashDotDot
  • vbDataObject
  • vbDate
  • vbDefault
  • +
  • vbDiagonalCross
  • vbDirectory
  • +
  • vbDot
  • vbDouble
  • +
  • vbDownwardDiagonal
  • vbEmpty
  • vbEndDrag
  • vbEnter
  • @@ -1336,6 +1380,8 @@ img { max-width: 100%; }
  • vbFirstFullWeek
  • vbFirstJan1
  • vbFriday
  • +
  • vbFSSolid
  • +
  • vbFSTransparent
  • vbGet
  • vbGrayCopy
  • vbGreen
  • @@ -1343,10 +1389,13 @@ img { max-width: 100%; }
  • vbHimetric
  • vbHimetric
  • vbHorizontal
  • +
  • vbHorizontalLine
  • vbHourglass
  • vbInches
  • vbInformation
  • +
  • vbInsideSolid
  • vbInteger
  • +
  • vbInvisible
  • vbKeyBack
  • vbKeyDelete
  • vbKeyReturn
  • @@ -1381,6 +1430,10 @@ img { max-width: 100%; }
  • vbPicTypeNone
  • vbPixels
  • vbPoints
  • +
  • vbPRCMColor
  • +
  • vbPRCMMonochrome
  • +
  • vbPRORLandscape
  • +
  • vbPRORPortrait
  • vbQuestion
  • vbReadOnly
  • vbRed
  • @@ -1395,6 +1448,7 @@ img { max-width: 100%; }
  • vbSizeNS
  • vbSizeNWSE
  • vbSizeWE
  • +
  • vbSolid
  • vbSrcCopy
  • vbString
  • vbSunday
  • @@ -1414,6 +1468,7 @@ img { max-width: 100%; }
  • vbTransparentCopy
  • vbTuesday
  • vbTwips
  • +
  • vbUpwardDiagonal
  • vbUseCompareOption
  • vbUser
  • vbUserDefinedType
  • @@ -1421,6 +1476,7 @@ img { max-width: 100%; }
  • vbUseSystemDayOfWeek
  • vbVariant
  • vbVertical
  • +
  • vbVerticalLine
  • vbWednesday
  • vbWhite
  • vbYellow
  • @@ -1435,6 +1491,7 @@ img { max-width: 100%; }
  • VT100
  • Watch Expressions
  • Watch Window
  • +
  • Wavetable synthesizer
  • Web requests
  • WEEKDAY
  • WEEKDAYNAME
  • @@ -1457,6 +1514,10 @@ img { max-width: 100%; }
  • WrapBox
  • WRITE #
  • Writing a control
  • +
  • WtAlloc
  • +
  • WtInfo$
  • +
  • WtStart
  • +
  • WtUpload
  • WYSIWYG
  • xfer.bas
  • XferAnswer
  • @@ -3653,6 +3714,17 @@ Loop While INKEY$ = "" FULLSCREEN Do Loop While INKEY$ = "" +

    Writing a Screen Saver

    +

    A screen saver is a program with no form that shows its screen on the whole screen and draws until DVX closes it. Put the program (.app) in the SAVERS folder of your user folder (FileOpsKindDir$(FILEOPS_KIND_SAVERS, True)); it is then listed on Control Panel's Desktop page by its project Name. DVX starts it with the command line /S after the wait with no input, and closes its screen at the next key or mouse movement, which ends the program, so it needs no INKEY$ of its own.

    +
    SCREEN 13
    +CLS
    +FULLSCREEN ON
    +
    +DO
    +    ' draw a frame
    +    FRAMEWAIT 30
    +LOOP
    +

    SysScreenSaver$, SysSetScreenSaver, SysScreenSaverStart, and SysIdleSeconds (system.bas) read and change the screen saver from a program.

    File I/O

    @@ -4681,6 +4753,62 @@ Print "Running from: " & App.Path If Clipboard.GetFormat(vbCFText) Then Text2.Text = Clipboard.GetText()

    A control or form of your own named Clipboard takes the name's place.

    +
    +

    Printer Object

    +

    The Printer object draws pages on the printer chosen in the Control Panel, as in Visual Basic: text in any font at a size in points, lines, boxes, circles, dots, and pictures, anywhere on the page, page after page. It needs a printer model that prints pictures (the Control Panel's Printer page); on a color printer the colors print as they are, on the others in shades of grey.

    +

    On a PostScript printer the page is drawn by the printer itself, as lines, curves, and letters rather than dots, so it prints as sharp as the printer can. Text is in the printer's own Helvetica, Times, or Courier, whichever is nearest FontName: Courier for a fixed-pitch font or a name with Mono or Courier in it, Times for a name with Serif, Times, Roman, Tinos, or Ledger in it (but not Sans), and Helvetica for the rest. TextWidth and TextHeight measure the printer's letters, so text lines up as it will print.

    +

    A page is 8 by 10 inches. Its coordinates are twips (1440 an inch) until ScaleMode or Scale changes them, as on Visual Basic's Printer, so a program can place things in inches, centimetres, points, or its own units. Drawing goes to the page being made; NewPage prints it and starts the next, and EndDoc prints the last one and ends the document. The document starts when the first page is printed, at the printer and with the copies the program's last PrintChoose (print.bas) chose, or else as the Control Panel has them; while it prints, the Printing window shows, with Cancel. A program that ends with a page drawn or a document started has EndDoc done for it.

    +
      Method                                          Description
    +  ------                                          -----------
    +  Printer.Print items                             Text at CurrentX / CurrentY in the font and ForeColor, as PRINT lays it out: ; joins, a comma goes to the next 14-character zone, TAB(n) and SPC(n) count characters of the font (its 0's width), and a new line goes down a line of the font. CurrentX / CurrentY move past it.
    +  Printer.Line [(x1, y1)]-(x2, y2) [, color] [, B | BF]  A line, a box, or a filled box, as on a PictureBox (STEP works too).
    +  Printer.Circle (x, y), r [, color [, start, end [, aspect]]]  A circle, ellipse, or arc; filled in FillColor when FillStyle is not transparent. Circles are round on paper on every printer.
    +  Printer.PSet (x, y) [, color]                   One dot (DrawWidth across).
    +  Printer.PaintPicture pic, x, y [, w, h [, srcX, srcY, srcW, srcH]]  A picture (a PictureBox, a file, or LoadPicture's), stretched to w x h; without them, at its size on the screen (15 twips a pixel).
    +  Printer.NewPage                                 Print the page (even a blank one) and start the next; Page goes up by 1.
    +  Printer.EndDoc                                  Print the page drawn and end the document; Page goes back to 1.
    +  Printer.KillDoc                                 Throw the page drawn away and end the document (pages already printed stay printed).
    +  Printer.TextWidth(text), TextHeight(text)       The size text takes in the font, in the Printer's scale.
    +  Printer.Scale (x1, y1)-(x2, y2), ScaleX, ScaleY  As on a PictureBox: a scale of your own, and lengths from one scale to another.
    +
      Property                                        Description
    +  --------                                        -----------
    +  CurrentX, CurrentY                              Where the next drawing starts.
    +  ForeColor, FillColor, FillStyle, DrawWidth, DrawStyle  As on a PictureBox (black, transparent fills, a solid pen 1 dot wide to start).
    +  ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight  The scale (vbTwips to start).
    +  FontName, FontSize, FontBold, FontItalic, FontUnderline, FontStrikethru  The font of Print's text; FontSize is in points (12 to start, in DejaVu Sans). Text prints crisp where the font has large sizes (the Printer Fonts add-on gives DejaVu Sans, Serif, and Sans Mono them), and blockier where a screen size is enlarged.
    +  Copies                                          How many times each page prints (1 to 99); as PrintChoose chose when it is not set.
    +  Page                                            The page being drawn (1 first). Read only.
    +  Width, Height                                   The page's size in twips (11520 by 14400). Read only.
    +  TwipsPerPixelX, TwipsPerPixelY                  Twips in one of the printer's dots. Read only.
    +  ColorMode                                       vbPRCMColor on a color printer, else vbPRCMMonochrome. Read only.
    +  Orientation                                     vbPRORPortrait: pages print upright only.
    +  DeviceName, Port                                The printer model and where it is connected, as the Control Panel names them. Read only.
    +
    Sub mnuReport_Click ()
    +    Printer.ScaleMode = vbInches
    +    Printer.FontSize = 18
    +    Printer.FontBold = True
    +    Printer.CurrentX = 1
    +    Printer.CurrentY = 1
    +    Printer.Print "Sales by Region"
    +    Printer.FontSize = 11
    +    Printer.FontBold = False
    +    Printer.Line (1, 1.5)-(7, 1.5)
    +    Printer.FillStyle = 0
    +    Printer.FillColor = vbBlue
    +    Printer.Line (1, 2)-(1 + nNorth / 100, 2.4), vbBlue, B
    +    Printer.CurrentX = 1: Printer.CurrentY = 2.5
    +    Printer.Print "North"; Tab(20); nNorth
    +    Printer.EndDoc
    +End Sub
    +

    The Printer has no Cls, Point, PAINT, or DRAW (Visual Basic's Printer has none of them either), and no Printer.Font object: use FontName, FontSize, and the other Font properties. A raster operation given to PaintPicture is not used on paper. A control or form of your own named Printer takes the name's place.

    +

    Printer Constants

    +
      Constant            Value   Description
    +  --------            -----   -----------
    +  vbPRORPortrait      1       Printer.Orientation: upright pages (the only one)
    +  vbPRORLandscape     2       Printer.Orientation: sideways pages (not supported)
    +  vbPRCMMonochrome    1       Printer.ColorMode: black (and grey) only
    +  vbPRCMColor         2       Printer.ColorMode: color
    +

    Screen Object

    The Screen object is the DVX desktop, as in Visual Basic: its size, so a program can place its forms (for example in a corner of the screen), its colour depth, the mouse pointer, and the arrangement of the windows on it.

    @@ -4894,6 +5022,25 @@ End Sub vbHimetric 8 Hundredths of a millimetre (ScaleX / ScaleY only; a Picture's Width and Height) vbPicTypeNone 0 Picture.Type of an empty picture vbPicTypeBitmap 1 Picture.Type of a loaded picture +

    Fill and Pen Constants

    +

    The values of FillStyle and DrawStyle on Forms, PictureBoxes, and the Printer (see Drawing on Forms and PictureBoxes).

    +
      Constant             Value   Description
    +  --------             -----   -----------
    +  vbFSSolid            0       FillStyle: filled solid in FillColor
    +  vbFSTransparent      1       FillStyle: not filled (the default)
    +  vbHorizontalLine     2       FillStyle: horizontal lines
    +  vbVerticalLine       3       FillStyle: vertical lines
    +  vbUpwardDiagonal     4       FillStyle: lines rising to the right
    +  vbDownwardDiagonal   5       FillStyle: lines falling to the right
    +  vbCross              6       FillStyle: a cross hatch
    +  vbDiagonalCross      7       FillStyle: a diagonal cross hatch
    +  vbSolid              0       DrawStyle: a solid pen (the default)
    +  vbDash               1       DrawStyle: dashes
    +  vbDot                2       DrawStyle: dots
    +  vbDashDot            3       DrawStyle: dash, dot
    +  vbDashDotDot         4       DrawStyle: dash, dot, dot
    +  vbInvisible          5       DrawStyle: no lines or outlines (fills still drawn)
    +  vbInsideSolid        6       DrawStyle: solid

    VarType Constants

    The values VARTYPE returns (see Variants). For an array, VARTYPE adds vbArray to the type of its elements.

      Constant            Value   Kind of value
    @@ -4957,8 +5104,8 @@ End Sub
    security.bas Security: random bytes for keys, a key exchange, a cipher, SHA-256 hashes, HMAC signatures, key making, and a safe compare (SecRandom$, SecDhCreate, SecCipherCrypt$, SecSha256$, SecHmac$, SecHkdf$, ...) game.bas Games: whether a key is being held down right now, forgetting held keys, frames FRAMEWAIT skipped, waiting for the screen's retrace (GameKeyDown, GameKeyClear, FrameMissed, GameWaitRetrace) joystick.bas Joysticks: how many there are, their names, where each stick points, and which buttons are pressed (JoyCount, JoyX, JoyY, JoyButton, ...); setting them up: calibration, dead zone, game port mode, keyboard joystick keys (JoySetCalibration, JoyCalibration, JoyRescan, JoyKeyIndex, JoySettingsStore, ...) - sound.bas Sound: play WAV and VOC files, tones and noise, set the volume (PlayWave, PlayTone, StopWave, SoundInfo$, ...); sounds loaded once and played with volume, pan, and pitch, sound made by the program as it goes, the FM synthesizer chip (SoundLoad, SoundPlay, SoundSetPitch, SoundCreate, SoundStreamOpen, SoundStreamWrite, FmAlloc, FmWrite, ...); DVX's own event sounds (SoundPlayEvent), master volume, sound driver, and MIDI settings (SoundEventFile$, SoundSetMasterVolume, SoundSettingsStore, ...) - music.bas Music: play MOD, S3M, XM, IT, and MIDI files, and play notes yourself (MusicLoad, MusicPlay, MusicStop, MusicNoteOn, MusicInfo$, ...); raw bytes to the MIDI port (MidiAcquire, MidiPut, MidiRelease, MidiPortInfo$) + sound.bas Sound: play WAV and VOC files, tones and noise, set the volume (PlayWave, PlayTone, StopWave, SoundInfo$, ...); sounds loaded once and played with volume, pan, and pitch, sound made by the program as it goes, recording from a microphone or line input (RecordStart, RecordStop, RecordLevel, ...), the FM synthesizer chip, a wavetable card's voices and memory (SoundLoad, SoundPlay, SoundSetPitch, SoundCreate, SoundStreamOpen, SoundStreamWrite, FmAlloc, FmWrite, WtAlloc, WtStart, ...); DVX's own event sounds (SoundPlayEvent), master volume, sound driver, and MIDI settings (SoundEventFile$, SoundSetMasterVolume, SoundSettingsStore, ...) + music.bas Music: play MOD, S3M, XM, IT, MIDI, WAV, and VOC files, and play notes yourself (MusicLoad, MusicPlay, MusicStop, MusicNoteOn, MusicInfo$, ...); instruments from banks and SoundFonts, a sound card's own (MusicSetBank, MusicPatchSet$, MusicRomBank$); raw bytes to the MIDI port (MidiAcquire, MidiPut, MidiRelease, MidiPortInfo$); what a MIDI keyboard plays, heard as it comes and recorded into MIDI files (MidiInOpen, MidiInMessage, MusicMidiSend, MidiRecordNew, MidiRecordSave, ...) fileops.bas Files and folders: folder listings for a file list, paths and names, the file dialogs' filter rule, attributes and the time a file was changed, sizes, free space and disk sizes, drive kinds, whether a folder can be written, the folder for scratch files, making a folder path, and copying, moving, and deleting whole folders at once or as a job with progress, Cancel, and questions (FileOpsList, FileOpsListRows$, FileOpsFilterMatch, FileOpsSetModified, FileOpsDriveType, FileOpsIsWritable, FileOpsMakePath, FileOpsJobCreate, FileOpsJobRunFor, ...) system.bas What Control Panel changes, at once for the whole desktop: the screen mode, the system colors and themes, the wallpaper, text smoothing, and the mouse (with DVX's limits and defaults, MOUSE_* and SYS_VIDEO_DEFAULT_*), and storing them in DVX's settings; the system font's character pictures; the mouse pointer and the Shift, Ctrl, and Alt keys anywhere on the screen, moving the pointer, typing keys into the window in front, a picture of the whole screen, and the memory (SysSetVideoMode, SysSetColor, SysLoadTheme, SysSetWallpaper, SysSetTextSmoothing, SysSetMouse, SysColorsStore, SysFontGlyphRow, SysMouseX, SysSendKey, SysScreenshot, SysMemoryFreeKb, ...) font.bas Fonts: the font families DVX has, the sizes each comes in, whether one is fixed-pitch, and how wide text and how tall a line is in a font (FontFamilyCount, FontFamilyName$, FontFamilyFixed, FontSizeCount, FontSizeAt, FontNearestSize, FontTextWidth, FontLineHeight, FontAscent, FontRescan) @@ -4967,7 +5114,7 @@ End Sub compiler.bas The BASIC compiler: compile a project or files you give it, find compile errors in their files, list a program's procedures and the language's keywords, build an .app or a control file; put a project's UserControls on a form designer's toolbox; read source text as the compiler does (tokens, code or string or comment, renaming a control in code, WithEvents variables, class Events), F1 help topics; open, change, and save project files and UserControl headers (CompSourceFromProject, CompSourceAdd, CompCompile, CompError$, CompMapFile$, CompProcList$, CompBuildApp, CompDesignControls, CompTokens$, CompRenameInCode$, CompHelpTopic$, ProjOpen, ProjSave, ProjCtlSetHeader$, ...) debug.bas Run a compiled program inside yours and debug it: breakpoints, stepping, its variables, expressions, Immediate-window lines, its PRINT output (RunCreate, RunStart, RunPoll, DbgSetBreakpoints, DbgStepOver, DbgLocals$, DbgEval$, DbgExec$, ...) designer.bas Form designers: the control types, whether each holds other controls and can be placed, the names of new controls, default events, toolbox tooltips and icons, and each type's properties (with their types and value names), methods, and events (with each event's parameters); the names in .frm and class module text; .frm files as data to read, edit, and write, controls copied and pasted as text, and a loaded form written back as .frm text (CtlTypeCount, CtlTypeName$, CtlTypeFlags, CtlPropCount, CtlPropName$, CtlPropType, CtlEventList$, CtlEventParams$, CtlTypeEventParams$, FrmFormName$, FrmControlNames$, ClsClassName$, FrmOpen, FrmText$, FrmCtlAdd, FrmCtlText$, FrmCtlPaste, FrmMenuAdd, FormText$, ...) - shell.bas DVX itself, everything Program Manager and Task Manager do: start programs and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...) + shell.bas DVX itself, everything Program Manager and Task Manager do: start programs (DVX's and DOS programs) and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellRunDos, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...) pkg.bas Add-on packages: the packages installed in DVX and their details, files, and needs; reading .DPK files and planning what installing them takes; installing and removing as a job with progress; trusting a publisher's key; and making packages, signing them, and making keys (PkgOpen, PkgInstalledRows$, PkgOfferFile, PkgPlan, PkgInstallStart, PkgRunFor, PkgRemoveStart, PkgBuild, PkgSign, PkgKeygen, ...) json.bas JSON: read a document (a web service's reply, a settings file) and find values in it by path such as packages[3].version, and quote text for JSON of your own (JsonParse, JsonText$, JsonNumber, JsonCount, JsonKey$, JsonQuote$, ...) http.bas Web requests: GET a page, a file, or a web service's answer, or POST to one, over the network a little at a time with progress, to memory or to a file (resuming one left part-way), following redirects (HttpOpen, HttpStart, HttpRunFor, HttpStatus, HttpBody$, HttpSaveTo, ...) @@ -5079,7 +5226,7 @@ End Sub SerGetRi(com) True while the other end rings. SerLineErrors(com) Errors seen on received data, added up: SER_LINE_OVERRUN (data came faster than it was read), SER_LINE_PARITY, SER_LINE_FRAMING, SER_LINE_BREAK; 0 for none.

    Printing

    -

    With print.bas in the project, a program prints like the programs that come with DVX. PrintTextDialog shows the Print dialog (printer, copies, pages) and prints the text on pages laid out by the page setup: margins, a header, and a footer with the document name, date, and page number. PrintPageSetup shows the Page Setup dialog, which the user's choices are saved from for every program. PrintPictureDialog prints a picture file scaled to the page, on printer models that print pictures (PrintCanPrintPictures says whether the chosen one does). PrintText and PrintPicture print without asking first. Each returns True when it printed.

    +

    With print.bas in the project, a program prints like the programs that come with DVX. PrintTextDialog shows the Print dialog (printer, copies, pages) and prints the text on pages laid out by the page setup: margins, a header, and a footer with the document name, date, and page number. PrintPageSetup shows the Page Setup dialog, which the user's choices are saved from for every program. PrintPictureDialog prints a picture file scaled to the page, on printer models that print pictures (PrintCanPrintPictures says whether the chosen one does), in color on a color printer (PrintCanPrintColor says whether it is one) and in shades of grey on the others. PrintText and PrintPicture print without asking first. Each returns True when it printed.

    Sub mnuPrint_Click ()
         PrintTextDialog txtNotes.Text, "Notes"
     End Sub
    @@ -5087,7 +5234,7 @@ End Sub
     Sub mnuPageSetup_Click ()
         PrintPageSetup
     End Sub
    -

    For printer output a line at a time, see LPRINT and OPEN "LPT1:".

    +

    For printer output a line at a time, see LPRINT and OPEN "LPT1:". To draw whole pages (text in any font and size, lines, boxes, circles, and pictures), use the Printer object.

    A program that prints its own way has more:

      Function                                            Description
       --------                                            -----------
    @@ -5119,8 +5266,13 @@ End If
      Function                                       Description
       --------                                       -----------
       PrintModelCount() / PrintModelLabel$(model)    How many printer models there are, and the name of each.
    -  PrintDestCount() / PrintDestLabel$(dest)       How many places the printer can be connected to (LPT1, LPT2, LPT3, a file), and the name of each.
    +  PrintDestCount() / PrintDestLabel$(dest)       How many places the printer can be connected to (LPT1 to LPT3, COM1 to COM4, a file), and the name of each.
       PrintDestIsFile(dest)                          True for the destination that prints into a file.
    +  PrintDestIsSerial(dest)                        True for a serial port (COM1 to COM4), which needs a speed and a handshake.
    +  PrintBaudRate(index)                           The serial speeds offered, from index 0 (0 past the last).
    +  PrintHandshakeCount() / PrintHandshakeLabel$(handshake)  How many handshakes there are, and the name of each (None, XON / XOFF, Hardware (CTS), Hardware (DSR, Apple)).
    +  PrintSettingBaud(prefs), PrintSettingHandshake(prefs)  A serial printer's speed and handshake in the settings.
    +  PrintSerialStore(prefs, baud, handshake)       Put a serial printer's speed and handshake into the settings; False for a speed not offered.
       PrintSettingModel(prefs)                       The printer model in the settings.
       PrintSettingDest(prefs)                        Where the printer is connected.
       PrintSettingFile$(prefs)                       The file printed into when the destination is a file.
    @@ -5148,7 +5300,7 @@ End Sub

    Paths: FileOpsPathNormalize$ (the full path of an existing folder, or ""), FileOpsPathParent$, FileOpsPathJoin$, FileOpsPathSame, FileOpsPathIsRoot, FileOpsPathIsAbsolute (True for a path that is complete by itself, such as "C:\DOCS", False for one that counts from the current folder), FileOpsPathIsInside(path, folder) (True when path is folder itself or anywhere inside it: check it before copying or moving a folder, so it is never copied into itself), FileOpsPathSep$ (the character between folder names, "\" on DOS), FileOpsPathNative$ (a path written with that character, for showing it: "C:/DOCS/A.TXT" becomes "C:\DOCS\A.TXT"), FileOpsBaseName$, and FileOpsExtension$. Every one of them takes / and \ alike and gives back \. FileOpsFilterMatch(itemName, filter) tells whether a name matches a filter the way the file dialogs match it: wildcards * and ?, several patterns separated by ; such as "*.bmp;*.png", case not mattering.

    Files: FileOpsMakeDir and FileOpsRename (FileOpsError$ says why one failed), FileOpsTempPath$ and FileOpsReplaceFile (save safely: write the new file to FileOpsTempPath$(path$), then FileOpsReplaceFile swaps it in; the old file comes back if that fails), FileOpsValidName(folder, itemName) (whether a name can be used for a new file or folder in folder, by the rules of its disk; FileOpsError$ says why not), FileOpsValidLongName and FileOpsValidShortName (the long-name or the 8.3 rules by themselves, whatever the disk takes), FileOpsShortPath$ (the short 8.3 form of an existing path, for an old program that cannot take long names or blanks), FileOpsMakePath (a folder and every missing folder above it, which MKDIR does not make), FileOpsIsFolder, FileOpsGetAttr, and FileOpsSetAttr (the FILEOPS_ATTR_ bits), FileOpsModified$ and FileOpsSetModified (when a file was last changed, read and set in the form "YYYY-MM-DD HH:MM"; FileOpsSetModified returns False for a date that does not exist), FileOpsTreeSize with FileOpsTreeBytes, FileOpsTreeFiles, and FileOpsTreeFolders (the size of a whole folder), and FileOpsFormatSize$.

    Drives: FileOpsDriveList$ (the drive letters), FileOpsDriveType (FILEOPS_DRIVE_FIXED for a hard disk, FILEOPS_DRIVE_REMOVABLE for a floppy disk drive, FILEOPS_DRIVE_REMOTE for a network drive or a CD-ROM; asking never makes a drive spin up), FileOpsBootDrive$ (the drive the computer started from), FileOpsFreeBytes, FileOpsFreeKb, and FileOpsTotalBytes (the size of the disk; right for disks past 2 GB too), FileOpsLongNames(path) (True when the disk holding path takes long file names: up to 255 characters, with spaces and several dots; otherwise names are 8.3). FileOpsIsWritable(folder) is False where files cannot be made (a CD-ROM, a write-protected floppy), and FileOpsReadOnlySystem() is True when nothing can be saved: DVX runs from such a disk and there is no TEMP folder to keep the user folder in. FileOpsTempDir$() is the folder for scratch files ("" when there is none).

    -

    DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, desktop pictures, and MIDI instrument banks come in two folders each, DVX's own (and add-on packages') in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, FILEOPS_KIND_WPAPER, and FILEOPS_KIND_BANKS. An add-on's files are in a folder of its vendor's inside a kind's folder and are named VENDOR\NAME (for example ACME\SPACE.THM). FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.

    +

    DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, desktop pictures, MIDI instrument banks, and screen savers come in two folders each, DVX's own (and add-on packages') in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, FILEOPS_KIND_WPAPER, FILEOPS_KIND_BANKS, and FILEOPS_KIND_SAVERS. An add-on's files are in a folder of its vendor's inside a kind's folder and are named VENDOR\NAME (for example ACME\SPACE.THM). FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.

    FileOpsCopyTree, FileOpsMoveTree, and FileOpsDeleteTree do a whole folder at once. For a long copy, move, or delete the program stays usable when it runs the work as a job, a little at a time from a Timer: FileOpsJobCreate, then FileOpsJobAddSource for each file or folder, then FileOpsJobRunFor from the timer until it returns FILEOPS_STEP_DONE or FILEOPS_STEP_CANCELLED. When it returns FILEOPS_STEP_ASK (a file already exists, a file is read-only) or FILEOPS_STEP_ERROR, show FileOpsJobMessage$ and pass the user's choice to FileOpsJobAnswer. FileOpsJobCurrent$, FileOpsJobFilesDone, FileOpsJobFilesTotal, FileOpsJobPercent, FileOpsJobBytesDone, and FileOpsJobBytesTotal show the progress, and FileOpsJobCancel stops the job. Files copied from a CD-ROM are read-only; FileOpsJobClearAttr job, FILEOPS_ATTR_READONLY before the job runs makes every copy changeable (FILEOPS_ATTR_EDITABLE clears all four attribute bits). Whatever a program leaves open is freed when it ends.

    Dim job As Long
     
    @@ -5176,7 +5328,7 @@ Sub Timer1_Timer ()
     End Sub

    Working with DVX

    With shell.bas in the project, a program works with DVX the way Program Manager does -- a whole desktop can be written in BASIC.

    -

    Starting programs. ShellRun program, args starts a program (args is what it gets as COMMAND$), or opens a document in the program set for its type; it returns True when it started. ShellRunProgram(program, args, folder) does the same the way a Program Manager item does: file names in args, and a document named without a folder, are looked for in folder first ("" for none), and it returns the new program's id (0 when it did not start; DVX has already said why). ShellMinimizeApp id runs that program minimized: its windows become icons, including the ones it opens while it starts. ShellRunCommand(commandLine) runs a whole command line (a program, in double quotes when its name has a blank, then its arguments) and returns the id too. ShellRunDialog$(history) shows DVX's Run dialog offering history (earlier commands, newest first, one per line) and returns what the user chose, "" for Cancel. ShellOpenDocument path opens a document in the program set for its type in the Control Panel.

    +

    Starting programs. ShellRun program, args starts a program (args is what it gets as COMMAND$), runs a DOS program, or opens a document in the program set for its type; it returns True when it started (or the DOS program will run). ShellRunProgram(program, args, folder) does the same the way a Program Manager item does: file names in args, and a document named without a folder, are looked for in folder first ("" for none), and it returns the new program's id (SHELL_RUN_DOS for a DOS program, 0 when it did not start; DVX has already said why). DOS programs are .EXE, .COM, and .BAT files (ShellIsDosProgram(path) says whether a file is one). ShellRunDos program, args, folder, pause runs one with DVX stepped aside: once your procedure has returned, DVX names the programs running besides the desktop, this one included, and asks them to close (each asks about unsaved work; No, or a program kept open, cancels the run); the screen then becomes the DOS screen until the program ends, and DVX comes back with the desktop. folder is where it runs ("" for its own folder); pause True shows "Press any key to return to DVX." when it ends. ShellMinimizeApp id runs that program minimized: its windows become icons, including the ones it opens while it starts. ShellRunCommand(commandLine) runs a whole command line (a program, in double quotes when its name has a blank, then its arguments) and returns the id too. ShellRunDialog$(history) shows DVX's Run dialog offering history (earlier commands, newest first, one per line) and returns what the user chose, "" for Cancel. ShellOpenDocument path opens a document in the program set for its type in the Control Panel.

    Installed programs. ShellProgramScan(folder, skipPath) finds every program in folder and its subfolders ("" for the APPS folder) and returns a list: ShellProgramCount(list) programs, ShellProgramPath$(list, index) each one's path (index from 0), ShellProgramFree list when done (a list not freed is freed when the program ends). Give ShellAppPath$() as skipPath to leave this program out. ShellProgramName$(path) and ShellProgramDescription$(path) are the name and description a program shows, and ShellIsProgram(path) tells a program from a document. LOADPICTURE of a program gives its icon, so a PictureBox can show a grid of programs with PaintPicture. ShellProgramsFolder$() is the folder the installed programs are in (where ShellProgramScan("") looks), for example to start a Browse for a program there with DlgFileOpenIn$.

    File types. ShellAssocProgram$(extension) tells which program opens files with that extension (with ",arguments" after it when it is given any), ShellAssocCommand$(document) just the program that opens a document. ShellAssocCount() file types are set; ShellAssocExt$(index) and ShellAssocValue$(index) read them (index from 0). ShellAssocSet(extension, program) sets one and ShellAssocRemove(extension) removes one, saved at once; ShellAssocNormalize$(extension) is the extension as stored (".txt" gives "TXT"; "" when it cannot be one).

    Running programs. ShellRunningCount() says how many programs are running, and ShellRunningId(index) is the id of each one (index from 0; the same id ShellRunProgram returns). ShellAppId() is this program's own id. ShellRunningName$(id) and ShellRunningPath$(id) are the name a running program shows and the path of its .app ("" when it is not running), and ShellRunningFind(program) is the id of a running copy of a program, 0 when it is not running. ShellRunningActivate id brings a program to the front the way Task Manager's Switch To does (a minimized window is restored). ShellRunningClose id asks a program to close as if the user had closed its windows: it may ask to save its work and the user may cancel, and it is asked once your code has returned to DVX, so check from a Timer whether it is still running. ShellRunningKill id ends a program at once without asking it, the way Task Manager's End Task does: whatever it had not saved is lost, so keep it for a program that does not respond. ShellAppIsHung(id) is True when a program has been busy for 5 seconds or longer ("Not responding" in Task Manager). A program cannot end itself with ShellRunningKill (END does that). ShellAppMemory(id) is how many bytes of memory a program is using. ShellChangeCount() changes whenever a program starts or ends: check it from a Timer and update what you show when it is not the value you saw last. ShellTaskManager opens the Task Manager, ShellSystemInfo$() describes the computer (several lines), and ShellExitDvx exits DVX: every program is asked to close, each asking about unsaved work, and a program that stays open stops the exit (ask the user first).

    @@ -5235,7 +5387,7 @@ End Sub End Sub

    Add-on Packages

    With pkg.bas in the project, a program installs, removes, and makes add-on packages -- the .DPK files Software installs -- the way Software and Package Builder (both written in BASIC) do. PkgOpen() starts a session (a number; PkgClose ends it), and PkgError$(session) says why the last call that failed did. Lists come back as rows for a ListView's SetRows: cells separated by TAB, each row ending in a line feed.

    -

    What is installed. PkgInstalledRows$ lists every installed package (DVX itself is kpunch.dvx): id, name, version, kind, and summary. A package is named by its id, vendor.name (acme.chess). PkgField$(s, id, field) gives one detail (PKG_FIELD_NAME, PKG_FIELD_VERSION, PKG_FIELD_PUBLISHER, PKG_FIELD_SIGNER_NAME, ...); PkgFilesRows$, PkgDependsRows$, and PkgNeededBy$ its files, what it needs, and what needs it.

    +

    What is installed. PkgInstalledRows$ lists every installed package (DVX itself is kpunch.dvx): id, name, version, kind, and summary. A package is named by its id, vendor.name (acme.chess). PkgField$(s, id, field) gives one detail (PKG_FIELD_NAME, PKG_FIELD_VERSION, PKG_FIELD_PUBLISHER, PKG_FIELD_SIGNER_NAME, ...); PkgFilesRows$, PkgDependsRows$, and PkgNeededBy$ its files, what it needs, and what needs it. PkgFileOwner$(s, fileName) is the other way: the id of the package that put a file there (its name from the DVX folder, such as "SHARED\BANKS\KPUNCH\DMXOPL.OP2"; "" when none did).

    Installing. PkgOfferFile(s, path) reads a package file and every package beside it (what it may need) as offers, numbered from 0, and returns the number of the one at path (-1 when it cannot be read). PkgOfferField$ and PkgOfferFilesRows$ describe an offer before it is installed; PKG_FIELD_TRUST says whether DVX trusts who signed it. PkgPlan(s, "0") plans installing it with everything it needs, checked against this DVX and the packages installed (False, with PkgError$ saying why, when it cannot be installed); PkgPlanRows$ lists what will go in, PkgPlanBytes the disk space, and PkgPlanRestart whether libraries or widgets are among them (they work from the next time DVX starts). PkgInstallStart(s) then starts the job, and PkgRunFor(s, milliseconds) runs it a little at a time, returning PKG_RUN_BUSY until it is PKG_RUN_DONE or PKG_RUN_FAILED (PkgRunMessage$ says why); PkgRunPercent and PkgRunCurrent$ show how far it is.

    offer = PkgOfferFile(s, path)
     If offer >= 0 And PkgPlan(s, CStr(offer)) Then
    @@ -5289,12 +5441,13 @@ body = "{" & JsonQuote$("name") & ": " &am
     

    Screen mode: SysVideoModeCount() says how many modes the display offers (numbered from 0), and SysVideoModeWidth, SysVideoModeHeight, and SysVideoModeBpp (bits per pixel) describe each. SysSetVideoMode(wide, high, bpp) changes the mode and keeps every window; it returns False when the mode could not be set (the old one stays), also while a program has the whole screen to itself. A mode the monitor cannot show leaves the user unable to answer, so do as Control Panel does: ask "Keep this mode?" and go back to the old mode by itself after some seconds. Screen.Width, Screen.Height, and Screen.BitsPerPixel tell the mode in use, and SYS_VIDEO_DEFAULT_WIDTH, SYS_VIDEO_DEFAULT_HEIGHT, and SYS_VIDEO_DEFAULT_BPP the mode DVX starts in when its settings name none. SysScreenshot(path) saves the whole screen as a picture file (BMP when path ends in .bmp, PNG otherwise) and returns True when it was written.

    Colors: the system colors are numbered from 0 to SysColorCount() - 1. SysColorLabel$(id) is the text to show for one ("Desktop", "Window Face" ...), SysColorName$(id) the name it is stored as, and SysColorId(colorName) the id of a name (-1 for none) -- to draw in the user's colors, for example SysGetColor(SysColorId("desktop")). SysGetColor and SysSetColor read and set a color as an RGB() value; each SysSetColor redraws the whole screen. SysResetColors goes back to DVX's own colors. SysLoadTheme(path) and SysSaveTheme(path) read and write a theme file (.THM; DVX's own are in SHARED\THEMES, the user's in the THEMES folder of the user folder, FileOpsKindDir$(FILEOPS_KIND_THEMES, True)); both return True when they worked.

    Wallpaper: SysSetWallpaper(path) shows a BMP, JPG, PNG, or GIF image behind the desktop ("" for none) and returns False when the image could not be loaded (the old one stays). SysSetWallpaperMode mode says how it fills the screen, a choice of PREFS_CHOICE_WALLPAPER (Stretch, Tile, or Center; see Settings Files). SysWallpaper$() and SysWallpaperMode() tell what is in use.

    +

    Screen saver: SysSetScreenSaver fileName, minutes chooses the screen saver -- a program in the SAVERS folders, named as FileOpsKindList(FILEOPS_KIND_SAVERS) lists it, or "" for none -- and the minutes with no key or mouse input before it starts (SCREEN_SAVER_WAIT_MIN to SCREEN_SAVER_WAIT_MAX; DVX's default is SCREEN_SAVER_WAIT_DEFAULT). SysScreenSaver$() and SysScreenSaverWait() tell what is in use, SysScreenSaverStart() starts it now as Control Panel's Test does (False when none is chosen or one is running), and SysScreenSaverRunning() is True while one runs. SysIdleSeconds() is how long it has been since the last key or mouse input anywhere on the screen.

    Text: SysSetTextSmoothing True smooths the edges of text and SysSetTextSmoothing False stops it; SysTextSmoothing() tells which the user chose. Text is smoothed only when the screen shows more than 256 colors, and only in the fonts and sizes that come smoothed. Every window is drawn again when it changes.

    Mouse: SysSetMouse wheelDir, dblClickMs, accel, mickeys, wheelStep applies a whole mouse setup at once: the scroll wheel's direction (1 or -1, the values of PREFS_CHOICE_WHEEL), the double-click time in milliseconds, the acceleration (a value of PREFS_CHOICE_ACCEL), how far the mouse moves for 8 pixels of pointer movement (8 is normal, higher is slower), and the lines scrolled per wheel notch (1 to 10). An accel or mickeys of 0 leaves that one as it is. SysMouseWheelDir, SysMouseDoubleClick, SysMouseAccel, SysMouseMickeys, and SysMouseWheelStep read the setup in effect. MOUSE_WHEEL_STEP_MIN and MOUSE_WHEEL_STEP_MAX are the limits of the wheel step, and MOUSE_WHEEL_STEP_DEFAULT, MOUSE_DBLCLICK_DEFAULT_MS, and MOUSE_SPEED_DEFAULT are DVX's defaults (what a Default button puts back).

    The pointer and the keyboard: SysMouseX() and SysMouseY() are where the mouse pointer is on the screen, and SysMouseButtons() the buttons held (vbLeftButton, vbRightButton, and vbMiddleButton bits) -- also while the pointer is over another program's window, where your MouseMove events do not see it. SysSetMousePos x, y moves the pointer (it is kept on the screen). SysShiftState() is the Shift, Ctrl, and Alt keys held now (vbShiftMask, vbCtrlMask, and vbAltMask bits). SysSendKey(keyCode, shift) types a key into the window that has the focus as if the user pressed it, like SendKeys in other BASICs: keyCode as a KeyDown event gets it -- a character code such as ASC("a") or 13 for Enter, or a vbKey constant such as vbKeyF1 -- and shift the vbShiftMask, vbCtrlMask, and vbAltMask bits held with it (SysSendKey ASC("s"), vbCtrlMask is Ctrl+S). The keys arrive in order, one at a time, once your code has returned to DVX; up to 64 can wait, and SysSendKey returns False when that many are waiting. A program cannot press the mouse buttons: to "click" a control, call its event procedure or set its value.

    Memory: SysMemoryTotalKb() is the computer's memory in KB and SysMemoryFreeKb() how much of it is free (-1 when it cannot be told).

    System font: SysFontWidth() and SysFontHeight() are the size of every character of the system font in pixels, and SysFontFirstChar() to SysFontFirstChar() + SysFontCharCount() - 1 the character codes it has. SysFontGlyphRow(code, row) is one row of a character's picture: a bit per pixel, &H80 the leftmost -- for drawing text dot by dot, enlarged or in a font editor of your own.

    -

    Saving: SysColorsStore, SysWallpaperStore, SysFontsStore, and SysMouseStore write the colors, the wallpaper, text smoothing, and the mouse setup into a prefs.bas handle, in the sections DVX reads when it starts; PrefsSetVideoMode does the same for a screen mode. Store them in PrefsLoad(PrefsDvxIni$()) and save it, and DVX starts with them next time. SysColorsLoad, SysWallpaperLoad, SysFontsLoad, and SysMouseLoad apply what a handle holds. A PrefsCreate handle filled with the Store calls is a snapshot in memory: load it again to undo every change, the way Cancel works in Control Panel.

    +

    Saving: SysColorsStore, SysWallpaperStore, SysScreenSaverStore, SysFontsStore, and SysMouseStore write the colors, the wallpaper, the screen saver, text smoothing, and the mouse setup into a prefs.bas handle, in the sections DVX reads when it starts; PrefsSetVideoMode does the same for a screen mode. Store them in PrefsLoad(PrefsDvxIni$()) and save it, and DVX starts with them next time. SysColorsLoad, SysWallpaperLoad, SysScreenSaverLoad, SysFontsLoad, and SysMouseLoad apply what a handle holds. A PrefsCreate handle filled with the Store calls is a snapshot in memory: load it again to undo every change, the way Cancel works in Control Panel.

    Dim snap As Long
     Dim oldW As Long, oldH As Long, oldBpp As Long
     
    @@ -5420,8 +5573,8 @@ Sub cmdFire_Click ()
         End If
     End Sub

    WAV files must be uncompressed (8 or 16 bits, mono or stereo); VOC files may be 8- or 16-bit. QBasic's BEEP, SOUND, and PLAY work too, without sound.bas.

    -

    PlayTriangle(frequency, milliseconds, volume) plays a triangle tone, softer than PlayTone's square tone. SoundGetVolume() gives the volume of everything the program plays. SoundCaps(field) tells what the sound device can do, by name in any case: "dac" (it plays recorded sound), "synth" (it has a music synthesizer), "tone" (it plays tones on a tone generator), and "running" are True or False; "voices" (how many sounds play at once), "rate" (hertz), "bits", and "channels" are numbers. SoundPlayEvent eventIdx, wait plays one of DVX's own event sounds (SOUND_EVENT_DEFAULT, SOUND_EVENT_ERROR, SOUND_EVENT_WARNING, ...), the one the settings choose; with wait True it returns once the sound has played.

    -

    A sound played often is best loaded once. SoundLoad(fileName) loads a WAV or VOC file and returns a sound number, or 0 (SoundError$() says why); SoundCreate(data, rate, bits, channels) makes a sound from raw sound data in a string: 8-bit samples (0 to 255, 128 is silence) or 16-bit ones (two bytes each, the low byte first), mono or stereo (left, right, left, ...), rate times a second (SOUND_RATE_MIN to SOUND_RATE_MAX). SoundPlay(sound, volume, pan, loopIt) plays it on a new voice and returns the voice number; pan goes from SOUND_PAN_LEFT (-100) through SOUND_PAN_CENTER (0) to SOUND_PAN_RIGHT (100). SoundLength(sound) is its length in milliseconds and SoundFree sound stops its voices and forgets it. While a voice plays, SoundSetVolume voice, volume, SoundSetPan voice, pan, and SoundSetPitch voice, percent change it (pitch is a percent of the recorded speed: SOUND_PITCH_NORMAL is 100, 200 is an octave higher, from SOUND_PITCH_MIN 25 to SOUND_PITCH_MAX 400), and SoundSetFrequency voice, hertz changes a tone's frequency. A program's sounds are freed when it ends.

    +

    PlayTriangle(frequency, milliseconds, volume) plays a triangle tone, softer than PlayTone's square tone. SoundGetVolume() gives the volume of everything the program plays. SoundCaps(field) tells what the sound device can do, by name in any case: "dac" (it plays recorded sound), "synth" (it has a music synthesizer), "tone" (it plays tones on a tone generator), "running", and "record" (it records sound) are True or False; "voices" (how many sounds play at once), "rate" (hertz), "bits", "channels", and "recordsources" (the inputs it records from) are numbers. SoundPlayEvent eventIdx, wait plays one of DVX's own event sounds (SOUND_EVENT_DEFAULT, SOUND_EVENT_ERROR, SOUND_EVENT_WARNING, ...), the one the settings choose; with wait True it returns once the sound has played.

    +

    A sound played often is best loaded once. SoundLoad(fileName) loads a WAV or VOC file and returns a sound number, or 0 (SoundError$() says why); SoundCreate(data, rate, bits, channels) makes a sound from raw sound data in a string: 8-bit samples (0 to 255, 128 is silence) or 16-bit ones (two bytes each, the low byte first), mono or stereo (left, right, left, ...), rate times a second (SOUND_RATE_MIN to SOUND_RATE_MAX). SoundPlay(sound, volume, pan, loopIt) plays it on a new voice and returns the voice number; pan goes from SOUND_PAN_LEFT (-100) through SOUND_PAN_CENTER (0) to SOUND_PAN_RIGHT (100). SoundLength(sound) is its length in milliseconds and SoundFree sound stops its voices and forgets it. While a voice plays, SoundSetVolume voice, volume, SoundSetPan voice, pan, and SoundSetPitch voice, percent change it (pitch is a percent of the recorded speed: SOUND_PITCH_NORMAL is 100, 200 is an octave higher, from SOUND_PITCH_MIN 25 to SOUND_PITCH_MAX 400), and SoundSetFrequency voice, hertz changes a tone's frequency. SoundPause voice holds a sound's voice where it is, silent, and SoundResume voice goes on from there (a tone cannot be paused). A program's sounds are freed when it ends.

    Dim Shared laser As Long
     
     Sub Form1_Load ()
    @@ -5433,15 +5586,21 @@ Sub Fire (x As Integer)
         v = SoundPlay(laser, 80, (x - 160) * 100 \ 160, False)
         SoundSetPitch v, 90 + Int(Rnd * 20)
     End Sub
    -

    A program can also make its sound as it goes, for speech or music of its own. SoundStreamOpen(rate, bits, channels, bufferMs) opens a stream that starts playing at once (silence until data arrives) and holds bufferMs milliseconds of sound (0 for the usual SOUND_STREAM_MS_DEFAULT; SOUND_STREAM_MS_MIN to SOUND_STREAM_MS_MAX). SoundStreamWrite(stream, data) adds raw sound data in the stream's format and returns how many bytes it took; keep the rest and write it again later. SoundStreamSpace(stream) is the room left and SoundStreamQueued(stream) what is waiting to play, both in samples (a stereo sample counts once). SoundStreamVoice(stream) is the stream's voice, for SoundSetVolume, SoundSetPan, and SoundSetPitch, and SoundStreamClose stream stops it. A Timer keeps a stream fed:

    +

    A program can also make its sound as it goes, for speech or music of its own. SoundStreamOpen(rate, bits, channels, bufferMs) opens a stream that starts playing at once (silence until data arrives) and holds bufferMs milliseconds of sound (0 for the usual SOUND_STREAM_MS_DEFAULT; SOUND_STREAM_MS_MIN to SOUND_STREAM_MS_MAX). SoundStreamWrite(stream, data) adds raw sound data in the stream's format and returns how many bytes it took; keep the rest and write it again later. SoundStreamSpace(stream) is the room left and SoundStreamQueued(stream) what is waiting to play, both in samples (a stereo sample counts once). SoundStreamVoice(stream) is the stream's voice, for SoundSetVolume, SoundSetPan, SoundSetPitch, SoundPause, and SoundResume. SoundStreamFlush stream drops what is waiting to play, so what is written next is heard at once (to jump to another place in what you play), and SoundStreamClose stream stops it. A Timer keeps a stream fed:

    Sub Timer1_Timer ()
         Dim taken As Long
         taken = SoundStreamWrite(stream, pending)
         pending = Mid$(pending, taken + 1)
         If Len(pending) = 0 Then pending = MakeMoreSound()
     End Sub
    +

    A program can record sound from the sound card's microphone, line, or CD input into a WAV file. SoundCaps("record") says whether the card can, and SoundCaps("recordsources") which inputs it has (the bit 2 ^ RECORD_MIC, RECORD_LINE, or RECORD_CD set for each). RecordStart(fileName, rate, bits, channels, inputSource) starts recording (0 for 22,050 Hz, 16-bit, mono) and returns True, or False with SoundError$ saying why; the card records at its nearest, which RecordRate(), RecordBits(), and RecordChannels() tell. While it records, RecordLength() is how long the recording is (milliseconds) and RecordLevel() the loudest sound since it was last asked (percent: a level meter); RecordLost() counts samples dropped because DVX was too busy. RecordStop() ends it and returns True when the file was all written; Recording() is True in between. Most sound cards record or play, not both, so nothing plays while a program records, and one program records at a time. A program's recording ends when the program does.

    +
    If RecordStart("NOTE.WAV", 0, 0, 0, RECORD_MIC) Then
    +    ' ... a Timer shows RecordLength() and RecordLevel()
    +    ok = RecordStop()
    +End If

    On a computer with an FM synthesizer chip (AdLib and most Sound Blasters), a program can play instruments of its own. FmInfo$(field) describes the chip: "description" (like "OPL3 FM synthesizer at 388h", "" when there is none), "voices", "free" (voices no program has), "stereo", and "owned" (one program has the whole chip). FmAlloc() takes one of its two-operator voices (0 when none is free), FmWrite(voice, register, value) sets one of the voice's registers (the FM_ constants: FM_MOD_CHAR, FM_CAR_LEVEL, FM_FNUM_LOW, FM_KEY_BLOCK with FM_KEY_ON, ...; True when it worked), and FmFree voice gives it back. A player of chip music files can take the whole chip with FmRawAcquire() (False while another program has it), set any register with FmRawWrite(register, value) (bank * FM_BANK_REGS + register; the second bank only on an OPL3) and give it back with FmRawRelease. A frequency number for FM_FNUM_LOW and FM_KEY_BLOCK is hertz * 2 ^ 20 / (FM_CHIP_HZ * 2 ^ block).

    -

    sound.bas also changes DVX's own sound settings, as the Control Panel's Sounds page does. DVX plays a sound for each of SoundEventCount() events, numbered from 0: SoundEventLabel$(eventIdx) names one ("Start DVX", "Error", ...) and SoundEventKey$(eventIdx) gives its key in DVX.INI. With a settings handle from prefs.bas (PrefsLoad(PrefsDvxIni$())), SoundEventFile$(handle, eventIdx) is the event's sound file ("" for none) and SoundSetEventFile handle, eventIdx, fileName changes it; after the program saves the file, SoundEventsReload makes DVX play the new sounds. SoundTestFile(fileName) plays a file the way DVX plays its event sounds (0 when it cannot, and SoundError$() says why), and SoundFolder$() is the folder of DVX's own sounds. SoundMasterVolume() and SoundSetMasterVolume percent are the volume of all sound from every program. SoundDriverCount() reads the list of sound drivers and SoundDriverName$(index) names each; SoundDriverAuto$() is the setting that means "find the best one". SoundMidiCount() and SoundMidiLabel$(index) list where MIDI music can play. SoundSettingDriver$(handle), SoundSettingMidi(handle), and SoundSettingVolume(handle) read the sound driver, MIDI choice, and master volume from the settings, and SoundSettingsStore handle, driver, midi, volume stores them (a new driver or MIDI choice takes effect when DVX next starts).

    +

    On a wavetable sound card (a Gravis UltraSound, a Sound Blaster AWE32 or AWE64), a program can play recorded sounds on the card's own voices from the card's memory. WtInfo$(field) describes the card: "description" ("" when there is none), "voices", "free" (voices no program has), "memory" and "freememory" (bytes), "level" (how many centibels louder a voice at full volume is than the loudest recorded sound the card plays: 0 when it is the same or not known), and what the card does itself ("envelopes", "lfos", "filter", "effects", "8bit", "rom"; True or False as text). WtMemAlloc(bytes, bits) takes memory for samples of 8 or 16 bits (its address, -1 when there is no room), WtUpload(address, pcm, bits) copies 16-bit mono samples there (a binary string, two bytes a sample; True when it worked), and WtMemFree address gives it back. WtAlloc() takes a voice (0 when none is free); WtStart(voice, address, startFrame, endFrame, loopStart, loopEnd, loopMode, bits, pitch, attenuation, pan) plays a sample on it from startFrame (0: its first; loopMode 0 plays once, 1 loops, 3 loops until WtRelease; pitch in cents, 0 for 44,100 samples a second and 1200 an octave higher; attenuation in centibels, 0 loudest and 60 half as loud; pan -100 to 100). WtSetPitch, WtSetAttenuation, and WtSetPan change a playing voice, WtStop silences it, WtActive(voice) is True while it sounds, and WtFree voice gives it back. Everything a program took is given back when it ends.

    +

    sound.bas also changes DVX's own sound settings, as the Control Panel's Sounds page does. DVX plays a sound for each of SoundEventCount() events, numbered from 0: SoundEventLabel$(eventIdx) names one ("Start DVX", "Error", ...) and SoundEventKey$(eventIdx) gives its key in DVX.INI. With a settings handle from prefs.bas (PrefsLoad(PrefsDvxIni$())), SoundEventFile$(handle, eventIdx) is the event's sound file ("" for none) and SoundSetEventFile handle, eventIdx, fileName changes it; after the program saves the file, SoundEventsReload makes DVX play the new sounds. SoundTestFile(fileName) plays a file the way DVX plays its event sounds (0 when it cannot, and SoundError$() says why), and SoundFolder$() is the folder of DVX's own sounds. SoundMasterVolume() and SoundSetMasterVolume percent are the volume of all sound from every program. SoundDriverCount() reads the list of sound drivers and SoundDriverName$(index) names each; SoundDriverAuto$() is the setting that means "find the best one". SoundMidiCount() and SoundMidiLabel$(index) list where MIDI music can play, SoundMidiInCount() and SoundMidiInLabel$(index) where MIDI input can come from, and SoundModulesCount() and SoundModulesLabel$(index) where music modules can play. SoundSettingDriver$(handle), SoundSettingMidi(handle), SoundSettingModules(handle), and SoundSettingVolume(handle) read the sound driver, MIDI choice, modules choice, and master volume from the settings, and SoundSettingsStore handle, driver, midi, modules, volume stores them (a new driver or MIDI choice takes effect when DVX next starts, a modules choice with the next music); SoundSettingMidiIn(handle) and SoundSettingMidiInStore handle, midiIn read and store the MIDI input choice (it takes effect when DVX next starts).

    Dim ini As Long
     Dim i As Long
     
    @@ -5452,8 +5611,12 @@ For i = 0 To SoundEventCount() - 1
     Next i

    See: BEEP, SOUND, and PLAY

    Music

    -

    With music.bas in the project, a program plays music files: tracker modules (MOD, S3M, XM, and IT files, made of recorded instrument sounds) wherever the computer can play sampled sound, and MIDI files (.MID, types 0 and 1) on the computer's music synthesizer, or on a built-in synthesizer that sounds much the same when there is none. Several programs can play music at once, and everything a program plays stops when it ends.

    +

    With music.bas in the project, a program plays music files: tracker modules (MOD, S3M, XM, and IT files, made of recorded instrument sounds) wherever the computer can play sampled sound (on a Gravis UltraSound's or Sound Blaster AWE's own voices, which takes the computer little time), MIDI files (.MID, types 0 and 1) on the computer's music synthesizer, or on a built-in synthesizer that sounds much the same when there is none, and WAV and VOC sound files wherever the computer can play sampled sound (a long WAV recording is read from the disk as it plays, so it needs no memory). Several programs can play music at once, and everything a program plays stops when it ends.

    MusicLoad(fileName) loads a music file and returns its music number, or MUSIC_FAILED (0) when it could not be loaded; MusicError$() then says why. MusicPlay(music, loopIt) plays it from where it is (the start after loading or stopping); with loopIt True it starts over at the end until it is stopped. MusicStop(music) stops and goes back to the start, MusicPause(music) and MusicResume(music) pause and go on, and MusicPlaying(music) is True while it plays. MusicVolume(music, volume) sets its volume in percent (0 to 100). MusicPosition(music) and MusicLength(music) give the place and the length in milliseconds, and MusicSeek(music, milliseconds) goes to a place (a module goes to the start of the part that holds it). MusicFree(music) stops a music and forgets it.

    +

    MusicLoadMacro(macro) makes a music from a music macro string, the kind PLAY takes, with its notes on the General MIDI instruments its I commands name (the piano before any I): the tune can then be played in the background, paused, stopped, and made quieter like any music. MIDI music plays on the instruments chosen in Control Panel; MusicSetBank music, bank plays one music on another instrument bank, named as in the banks folders ("" is DVX's standard bank), before the music first plays (MusicError$ says when the bank is missing or damaged). A SoundFont (.SF2 or .SBK) gives the recorded instruments for a wavetable card or the built-in SoundFont synthesizer, as can a Gravis UltraSound's own patches: MusicPatchSet$() names them (their ULTRASND.INI, "" when there are none), and MusicRomBank$() names the instruments built into a Sound Blaster AWE card ("" when Creative's software is not installed).

    +
    tune = MusicLoadMacro("T150 I41 O4 L8 CEG>C4")
    +MusicSetBank tune, "KPUNCH\DMXOPL.OP2"
    +MusicPlay tune, False
    Dim Shared tune As Long
     
     Sub Form1_Load ()
    @@ -5482,8 +5645,44 @@ Sub tmrNoteOff_Timer ()
         MusicNoteOff keys, 1, 60
         tmrNoteOff.Enabled = False
     End Sub
    -

    MusicGetVolume(music) gives a music's volume in percent. MusicInfo$(music, field) tells about it, by name in any case: "title" (a module's own title), "output" (where it plays, for people), "kind" ("module", "midi", or "live"), "where" ("stream", "mpu401", "fm", or "software"; "" before it first plays), and "paused" and "looping" (True or False as text; VAL gives the value).

    -

    A program that sends MIDI itself (system exclusive messages, a sequencer of its own) can have the MIDI port, one program at a time, when the settings send MIDI music to the MIDI port. MidiPortInfo$(field) describes it: "description" ("" when there is none), "present", and "owned" (a program has it). MidiAcquire() takes the port (False when another program has it or there is none), MidiPut(byteValue) sends one byte (False when the port could not take it yet: send it again), and MidiRelease gives the port back.

    +

    MusicGetVolume(music) gives a music's volume in percent. MusicInfo$(music, field) tells about it, by name in any case: "title" (a module's own title, or the name a MIDI file gives itself; "" for a sound file), "output" (where it plays, for people), "kind" ("module", "midi", "live", or "sound"), "where" ("stream", "mpu401", "fm", "software", "wave", or "softwave"; "wave" is a wavetable card's voices, for a module too; "" before it first plays), and "paused" and "looping" (True or False as text; VAL gives the value).

    +

    A program that sends MIDI itself (system exclusive messages, a sequencer of its own) can have the MIDI port, one program at a time, when the settings send MIDI music to the MIDI port. MidiPortInfo$(field) describes it: "description" ("" when there is none), "present", "owned" (a program has it), "input" (it brings MIDI in), and "inputowned" (a program reads what comes in). MidiAcquire() takes the port (False when another program has it or there is none), MidiPut(byteValue) sends one byte (False when the port could not take it yet: send it again), and MidiRelease gives the port back.

    +

    When the settings take MIDI input from the MIDI port (Control Panel, Sounds, MIDI in), a program can read what a MIDI keyboard or another instrument plays. MidiInOpen() starts reading (False when there is no MIDI input or another program reads it; SoundError$() says which) and MidiInClose stops; a program's reading stops when it ends. MidiInMessage() gives the next message that came, or MIDI_NONE (-1) when none waits. A message is packed into one number: the status is midiMsg AND 255 (its kind, midiMsg AND MIDI_KIND_MASK, is MIDI_NOTE_ON, MIDI_NOTE_OFF, MIDI_CONTROL, MIDI_PROGRAM, MIDI_PITCH_BEND, MIDI_KEY_PRESSURE, or MIDI_CHANNEL_PRESSURE, and its channel, numbered as MusicNoteOn numbers them, is (midiMsg AND MIDI_CHANNEL_MASK) + 1), the first data byte (the note) is (midiMsg \ 256) AND 255, and the second (the velocity) (midiMsg \ 65536) AND 255. A note on with a velocity of 0 is a note off too. MidiInTime() is when the message MidiInMessage gave last came and MidiInClock() the time now, both in milliseconds since MidiInOpen; MidiInLost() counts bytes lost because the program read too late. MusicMidiSend music, midiMsg plays a message as it came on a live music (MIDI thru), so what is played is heard.

    +

    A MIDI recording keeps messages with their times and saves them as a MIDI file that MusicLoad plays. MidiRecordNew() returns a recording number (0 when there is no room; MusicError$() says why), MidiRecordAdd recording, milliseconds, midiMsg adds a message (MidiInTime() is the time to give, when the recording started with MidiInOpen), MidiRecordCount(recording) and MidiRecordLength(recording) tell how many messages it has and when the last came, MidiRecordSave(recording, fileName) writes the file (False when it could not be written; MusicError$() says why; a note still held at the end is let go), and MidiRecordFree recording frees it. A program's recordings are freed when it ends. The Sound Recorder records a MIDI keyboard this way:

    +
    Dim Shared take As Long
    +Dim Shared keys As Long
    +
    +Sub cmdRecord_Click ()
    +    If MidiInOpen() Then
    +        take = MidiRecordNew()
    +        keys = MusicMidiOpen()
    +        tmrTake.Enabled = True
    +    End If
    +End Sub
    +
    +Sub tmrTake_Timer ()
    +    Dim midiMsg As Long
    +
    +    midiMsg = MidiInMessage()
    +
    +    Do While midiMsg <> MIDI_NONE
    +        MusicMidiSend keys, midiMsg
    +        MidiRecordAdd take, MidiInTime(), midiMsg
    +        midiMsg = MidiInMessage()
    +    Loop
    +End Sub
    +
    +Sub cmdStop_Click ()
    +    tmrTake.Enabled = False
    +    MidiInClose
    +    MusicFree keys
    +
    +    If Not MidiRecordSave(take, "SONG.MID") Then
    +        Print "Not saved: " & MusicError$()
    +    End If
    +
    +    MidiRecordFree take
    +End Sub

    Network Connections

    With net.bas in the project, a program talks to other computers over the network set up in the Control Panel. TcpConnect returns a handle (0 when it failed; NetError$ says why), TcpSend sends text, and TcpRecv$ returns whatever has arrived without waiting, so a Timer keeps the program responsive:

    Dim conn As Long
    @@ -6012,7 +6211,8 @@ End Sub
    CurrentX Single 0 Where the next drawing or printing starts. CurrentY Single 0 DrawWidth Integer 1 Pen width in pixels. - FillStyle Integer 1 How boxes (B) and circles are filled with FillColor: 0 solid, 1 (the default) not at all, 2 horizontal lines, 3 vertical lines, 4 upward diagonals, 5 downward diagonals, 6 a cross hatch, 7 a diagonal cross hatch (the lines 8 pixels apart, the gaps left as they are). Other values are an error. BF fills solid in the line's colour. + DrawStyle Integer 0 The pen's lines and outlines: 0 solid, 1 dashes, 2 dots, 3 dash-dot, 4 dash-dot-dot (a pen 1 pixel wide only; a wider one draws solid), 5 none at all (fills are still drawn), 6 solid. Other values are an error. The constants vbSolid to vbInsideSolid name them. + FillStyle Integer 1 How boxes (B) and circles are filled with FillColor: 0 solid, 1 (the default) not at all, 2 horizontal lines, 3 vertical lines, 4 upward diagonals, 5 downward diagonals, 6 a cross hatch, 7 a diagonal cross hatch (the lines 8 pixels apart, the gaps left as they are). Other values are an error. BF fills solid in the line's colour. The constants vbFSSolid to vbDiagonalCross name them. FillColor Long &H000000 Colour boxes and circles are filled with. FontScale Integer 1 How many times as large Print writes its text (1 to 8): each dot of the font becomes a FontScale x FontScale block, for titles and banners. TextWidth and TextHeight follow it; PrintAccel does not. AutoRedraw Boolean False True: no Paint events (see above). @@ -6425,7 +6625,7 @@ Dim g As New Form1 ' declares and makes a copy at once
    End If End Sub

    Drawing

    -

    Forms have Visual Basic's drawing methods and properties (Cls, PSet, Line, Circle, Point, Print, PaintPicture, DrawFocusRect, DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar, DrawScrollArrow, ScrollBarClick, ScrollBarsNeeded, PrintAccel, Scale, CurrentX, CurrentY, ForeColor, BackColor, DrawWidth, FillColor, FillStyle, FontScale, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight): see Drawing on Forms and PictureBoxes. In the form's own code use Me.Line, Me.Circle, and so on.

    +

    Forms have Visual Basic's drawing methods and properties (Cls, PSet, Line, Circle, Point, Print, PaintPicture, DrawFocusRect, DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar, DrawScrollArrow, ScrollBarClick, ScrollBarsNeeded, PrintAccel, Scale, CurrentX, CurrentY, ForeColor, BackColor, DrawWidth, DrawStyle, FillColor, FillStyle, FontScale, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight): see Drawing on Forms and PictureBoxes. In the form's own code use Me.Line, Me.Circle, and so on.

    Example

    Sub Form1_Load ()
         Form1.Caption = "Hello World"
    @@ -6526,7 +6726,7 @@ End Sub
    PropertyChanged name Tells a designer that a property changed, so its property sheet shows the new value. Cls, Line, PSet, Circle, Print, Point, PaintPicture, DrawFocusRect, TextWidth, TextHeight, Scale, ScaleX, ScaleY Drawing, as on a PictureBox. DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar, DrawScrollArrow, ScrollBarClick, ScrollBarsNeeded, PrintAccel, TextWidthAccel, AccelKey Drawing in DVX's own look (bevels, button faces, arrows, upright and horizontal scroll bars and their arrow buttons, captions with an underlined access key), as on a PictureBox. - CurrentX, CurrentY, ForeColor, BackColor, FillColor, FillStyle, FontScale, DrawWidth, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight Drawing properties, as on a PictureBox. + CurrentX, CurrentY, ForeColor, BackColor, FillColor, FillStyle, FontScale, DrawWidth, DrawStyle, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight Drawing properties, as on a PictureBox.

    The drawing members work exactly as on a PictureBox (see Drawing on Forms and PictureBoxes). Write them with UserControl in front -- UserControl.Line, UserControl.Print -- as a form's code writes Me.Line: a drawing statement on its own (Line, Cls, Print) draws on the program's screen, not on the control.

    The drawing stays until you draw again. UserControl_Paint runs when the control first appears, after its size changed, when you call Refresh, and after you call Invalidate; draw everything there and call UserControl.Invalidate (or UserControl.Refresh, to see the result at once) whenever what it shows changes.

    The Control's Events

    diff --git a/docs/dvx_system_reference.html b/docs/dvx_system_reference.html index 1ccfcb3c..2f927e77 100644 --- a/docs/dvx_system_reference.html +++ b/docs/dvx_system_reference.html @@ -138,6 +138,7 @@ img { max-width: 100%; }
  • System Information
  • Installed Programs
  • Running Programs and Documents
  • +
  • The Screen Saver
  • File Associations
  • BASIC Entry Points
  • Program Groups
  • @@ -335,7 +336,9 @@ img { max-width: 100%; }
  • ASSOC.INI
  • audio
  • audio.h
  • +
  • AUDIO_WT_PITCH_RATE
  • audioClose
  • +
  • audioDecode
  • audioDeskFile
  • audioDeskPlay
  • audioDeskPlayFile
  • @@ -350,6 +353,10 @@ img { max-width: 100%; }
  • AudioInfoT
  • audioMidiAcquire
  • audioMidiChoices
  • +
  • audioMidiInOpen
  • +
  • audioMidiInRead
  • +
  • AudioMidiMsgT
  • +
  • audioModulesChoices
  • audioNotesAdd
  • audioNotesOpen
  • AudioNotesT
  • @@ -358,12 +365,28 @@ img { max-width: 100%; }
  • audioPlay
  • audioPlayFile
  • audioPump
  • +
  • audioRecClose
  • +
  • audioRecFile
  • +
  • audioRecOpen
  • +
  • audioRecPush
  • +
  • audioRecRead
  • audioSampleLoad
  • +
  • audioSettingModules
  • audioSettingsStore
  • +
  • audioStreamFlush
  • audioStreamOpen
  • audioStreamWrite
  • AudioSynthDriverT
  • audioTone
  • +
  • audioVoicePause
  • +
  • audioWavHeader
  • +
  • audioWavParse
  • +
  • audioWtAlloc
  • +
  • audioWtMemAlloc
  • +
  • audioWtNoteInit
  • +
  • audioWtStart
  • +
  • audioWtUpload
  • +
  • AudioWtVoiceT
  • Back
  • Backbuffer
  • Backing Up
  • @@ -468,6 +491,7 @@ img { max-width: 100%; }
  • Cooperative multitasking
  • Cooperative Multitasking
  • Copy and Paste
  • +
  • Covox
  • CP437
  • CP437
  • Crash Recovery
  • @@ -528,10 +552,14 @@ img { max-width: 100%; }
  • dirtyListMerge
  • DirtyListT
  • DirtyListT
  • +
  • Disney Sound Source
  • Display Pipeline
  • DisplayT
  • DisplayT
  • DJGPP
  • +
  • DOS Programs
  • +
  • DOS programs
  • +
  • DOS programs
  • Double Buffer
  • Double-click
  • Double-Click
  • @@ -686,8 +714,10 @@ img { max-width: 100%; }
  • dvxGetDisplay
  • dvxGetFont
  • dvxGetVideoModes
  • +
  • DVXGM.SF2
  • dvxHideWindow
  • DvxHotkeyFnT
  • +
  • dvxIdleMs
  • dvxImageInfo
  • dvxInfoBox
  • dvxInit
  • @@ -746,6 +776,7 @@ img { max-width: 100%; }
  • dvxResRemove
  • dvxRestoreWindow
  • dvxRun
  • +
  • dvxRunDos
  • dvxSaveImage
  • dvxSaveTheme
  • dvxScreenshot
  • @@ -810,6 +841,7 @@ img { max-width: 100%; }
  • Dynamic Loading
  • EINPROGRESS
  • Empty Floppy Drive
  • +
  • EMU8000
  • End Task
  • Event Dispatch
  • Events
  • @@ -896,6 +928,8 @@ img { max-width: 100%; }
  • getaddrinfo
  • Getting Started
  • Go-Back-N
  • +
  • Gravis UltraSound
  • +
  • Gravis UltraSound patches
  • HAL
  • HBox
  • HDLC
  • @@ -1048,6 +1082,11 @@ img { max-width: 100%; }
  • Message Boxes
  • MIDI
  • MIDI
  • +
  • MIDI input
  • +
  • MIDI recording
  • +
  • MidiInMessage
  • +
  • MidiInOpen
  • +
  • MidiRecordNew
  • Minimize
  • Minimized Windows
  • mkcd.sh
  • @@ -1055,6 +1094,7 @@ img { max-width: 100%; }
  • MOD
  • Modem
  • Modules
  • +
  • Modules on the wavetable synthesizer
  • Mouse
  • Mouse
  • Mouse Click
  • @@ -1070,17 +1110,33 @@ img { max-width: 100%; }
  • Music macro language
  • music.bas
  • music.h
  • +
  • MUSIC_ERR_UNSUPPORTED
  • +
  • MUSIC_ERR_WRITE
  • +
  • MUSIC_KIND_SOUND
  • musicBasPlay
  • MusicInfoT
  • musicLoad
  • MusicLoad
  • +
  • musicLoadMacro
  • +
  • MusicLoadMacro
  • musicMidiNoteOn
  • musicMidiOpen
  • +
  • musicMidiRecordNew
  • +
  • musicMidiRecordSave
  • +
  • musicMidiSend
  • +
  • MusicMidiSend
  • musicMmlOpen
  • musicMmlPlay
  • MusicMmlT
  • +
  • musicModHw
  • +
  • MusicPatchSet$
  • +
  • musicPatchSetPath
  • musicPlay
  • MusicPlay
  • +
  • MusicRomBank$
  • +
  • musicRomBankPath
  • +
  • musicSetBank
  • +
  • MusicSetBank
  • musicStop
  • NET = 1
  • net.bas
  • @@ -1157,6 +1213,7 @@ img { max-width: 100%; }
  • platformClock
  • platformDirIsWritable
  • platformDiskSpace
  • +
  • platformDosRun
  • platformDriveList
  • PlatformDriveT
  • platformEntropyStamp
  • @@ -1181,6 +1238,7 @@ img { max-width: 100%; }
  • platformMicros
  • platformMousePoll
  • platformNormalizeFKeyScan
  • +
  • platformPathAbsolute
  • platformPathDirName
  • platformPathIsAbsolute
  • platformPathIsSep
  • @@ -1199,6 +1257,7 @@ img { max-width: 100%; }
  • platformShutdownHookRemove
  • platformSplashNearestColor
  • platformStatSize
  • +
  • platformSuspendHookAdd
  • platformTempDir
  • platformTimerAdd
  • platformTimerAddFast
  • @@ -1255,16 +1314,24 @@ img { max-width: 100%; }
  • Print
  • print
  • printDialog
  • -
  • PrintDriverT
  • +
  • printDocOpen
  • +
  • printDocPage
  • +
  • printDocPicture
  • +
  • PrintDocT
  • Printer
  • +
  • Printer Fonts
  • +
  • printFontOpen
  • +
  • PrintFontT
  • Printing
  • printJobOpen
  • PrintJobT
  • printJobWrite
  • +
  • printModelPage
  • printPageSetupDialog
  • -
  • printRasterJob
  • printTextJob
  • +
  • printUiDocOpen
  • printUiText
  • +
  • printVecBegin
  • prjLoad
  • PrjStateT
  • Problems
  • @@ -1283,6 +1350,9 @@ img { max-width: 100%; }
  • Read-only Disk
  • Read-only Media
  • Receive ring
  • +
  • Recording
  • +
  • RecordStart
  • +
  • RecordStop
  • rectCopy
  • rectCopyGrayscale
  • rectFill
  • @@ -1328,6 +1398,7 @@ img { max-width: 100%; }
  • Run
  • Run dialog
  • Run Minimized
  • +
  • Running DOS Programs
  • Running Programs
  • S3M
  • Safe Video Mode
  • @@ -1335,8 +1406,12 @@ img { max-width: 100%; }
  • Save As
  • Save Dialog
  • Saved games
  • +
  • SBK
  • Screen Problems
  • Screen Resolution
  • +
  • Screen Saver
  • +
  • Screen saver
  • +
  • Screensaver
  • Screenshot
  • Scroll Bars
  • Scroll Wheel
  • @@ -1373,6 +1448,7 @@ img { max-width: 100%; }
  • setClipRect
  • Settings
  • Setup
  • +
  • SF2
  • SHA-256
  • SHARED
  • Shared Library
  • @@ -1381,7 +1457,11 @@ img { max-width: 100%; }
  • SHELL_APP_DESCRIPTOR
  • SHELL_APP_NAME_MAX
  • SHELL_DESKTOP_APP
  • +
  • SHELL_DOS_FILTER
  • +
  • SHELL_DOS_ICON
  • SHELL_INI_WELCOME
  • +
  • SHELL_RUN_DOS
  • +
  • SHELL_SAVER_ARGS
  • SHELL_STACK_DEFAULT
  • shellActivateApp
  • shellApp.h
  • @@ -1431,6 +1511,8 @@ img { max-width: 100%; }
  • ShellGroupsLoad
  • shellInfoInit
  • ShellInstanceCount
  • +
  • shellIsDosProgram
  • +
  • ShellIsDosProgram
  • shellIsProgram
  • ShellIsProgram
  • shellItemIcon
  • @@ -1458,6 +1540,7 @@ img { max-width: 100%; }
  • shellReapApp
  • shellReapApps
  • shellRegisterAppCleanup
  • +
  • shellRegisterAppKill
  • shellRegisterDesktopUpdate
  • shellRegisterIdle
  • shellRequestAppClose
  • @@ -1469,6 +1552,9 @@ img { max-width: 100%; }
  • ShellRunCommand
  • shellRunDialog
  • ShellRunDialog
  • +
  • shellRunDos
  • +
  • ShellRunDos
  • +
  • shellRunDosRequest
  • shellRunExitRequest
  • ShellRunningActivate
  • shellRunningAppCount
  • @@ -1482,6 +1568,14 @@ img { max-width: 100%; }
  • ShellRunningPath
  • shellRunProgram
  • ShellRunProgram
  • +
  • shellSaverFromPrefs
  • +
  • shellSaverName
  • +
  • shellSaverRunning
  • +
  • shellSaverSet
  • +
  • shellSaverStart
  • +
  • shellSaverStep
  • +
  • shellSaverToPrefs
  • +
  • shellSaverWait
  • ShellSetDesktop
  • ShellSetPriority
  • ShellSetWelcome
  • @@ -1494,6 +1588,7 @@ img { max-width: 100%; }
  • shellTaskMgrRefresh
  • shellTerminateAllApps
  • shellUnregisterAppCleanup
  • +
  • shellUnregisterAppKill
  • shellUnregisterDesktopUpdate
  • shellUnregisterIdle
  • shellUserPath
  • @@ -1504,13 +1599,20 @@ img { max-width: 100%; }
  • socket
  • Sockets
  • Software
  • +
  • softwave
  • sound
  • Sound
  • Sound Blaster
  • +
  • Sound Blaster AWE32
  • sound.bas
  • +
  • SoundFont
  • SoundInfo$
  • +
  • SoundModulesLabel$
  • +
  • SoundPause
  • +
  • SoundResume
  • Sounds
  • SoundSettingsStore
  • +
  • SoundStreamFlush
  • Spacer
  • Spinner
  • Splitter
  • @@ -1521,6 +1623,7 @@ img { max-width: 100%; }
  • Starting Programs
  • Startup Group
  • StatusBar
  • +
  • Stereo-on-1
  • STICK
  • STICK
  • STRIG
  • @@ -1529,11 +1632,13 @@ img { max-width: 100%; }
  • Submenus
  • Switch To
  • Switching Programs
  • +
  • SYNTHGM.SBK
  • SYS_VIDEO_DEFAULT_WIDTH
  • SysColorId
  • SysColorsStore
  • SysFontsStore
  • SysGetColor
  • +
  • SysIdleSeconds
  • SysLoadTheme
  • SysMemoryFreeKb
  • SysMemoryTotalKb
  • @@ -1542,11 +1647,14 @@ img { max-width: 100%; }
  • SysMouseX
  • SysMouseY
  • SysSaveTheme
  • +
  • SysScreenSaver$
  • +
  • SysScreenSaverStart
  • SysScreenshot
  • SysSendKey
  • SysSetColor
  • SysSetMouse
  • SysSetMousePos
  • +
  • SysSetScreenSaver
  • SysSetTextSmoothing
  • SysSetVideoMode
  • SysSetWallpaper
  • @@ -1610,6 +1718,7 @@ img { max-width: 100%; }
  • Troubleshooting
  • tsYield
  • UART
  • +
  • ULTRASND.INI
  • Undo
  • Undo
  • Undo
  • @@ -1625,6 +1734,7 @@ img { max-width: 100%; }
  • Using BASIC Controls from C
  • VBE
  • VBox
  • +
  • Vector Pages
  • VENDOR
  • VESA
  • VESA
  • @@ -1632,9 +1742,13 @@ img { max-width: 100%; }
  • videoInit
  • VideoModeInfoT
  • videoShutdown
  • +
  • VOC
  • Volume
  • VT100
  • Wallpaper
  • +
  • WAV
  • +
  • Wavetable MIDI
  • +
  • Wavetable synthesizer
  • WCLASS_FIXED_FONT
  • WCLASS_RAISES_EVENTS
  • Web requests
  • @@ -1966,6 +2080,7 @@ img { max-width: 100%; }
  • Window Menu
  • Window Stack
  • Windows
  • +
  • Windows Sound System
  • WindowStackT
  • WindowT
  • WindowT
  • @@ -2001,6 +2116,8 @@ img { max-width: 100%; }
  • wordEnd
  • wordStart
  • WrapBox
  • +
  • WtInfo$
  • +
  • WtStart
  • xfer
  • xfer.bas
  • xferAnswer
  • @@ -2294,14 +2411,15 @@ img { max-width: 100%; }

    Printing

    Programs that can print have Print... in their File menu, and programs that print text also have Page Setup.... Every program prints to the same printer, so you only tell DVX once which printer you have and where it is plugged in (see Changing Settings). You can also send printouts into a file instead of a printer.

    The Print Dialog

    -

    File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print. When the printout goes into a file, type the file's name, or click Browse... to pick it.

    +

    File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, a serial port such as COM1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print. When the printout goes into a file, type the file's name, or click Browse... to pick it.

    While a printout is going to the printer, a small Printing window shows it is busy. You can keep working in other programs, and Cancel stops the printout.

    When the Printer Has a Problem

    If the printer is out of paper, switched off, off line, or not answering, a message says what is wrong. Fix the problem and choose Retry to carry on, or Cancel to stop printing. Nothing waits forever.

    Page Setup

    File > Page Setup... chooses how text is laid out on paper: the margins, how many lines fit on a page, how wide a line is, and a header and footer printed on every page (for example the file name and the page number). Your choices are kept for next time and used by every program that prints text.

    Pictures

    -

    Pictures are printed in black and white, as large as fits on the page. Printing pictures needs your printer's model to be set; with the setting for text only, a program tells you so.

    +

    Pictures print as large as fits on the page: in color on a color printer (one with a color ribbon or cartridge), and in shades of grey on the others. Printing pictures needs your printer's model to be set (see Changing Settings); with the setting for text only, a program tells you so.

    +

    Some programs draw whole pages of their own, such as reports with charts. On a PostScript printer the printer draws those pages itself, as sharp as it can print. On other printers their text prints crisper with the Printer Fonts add-on installed (see Adding and Removing Software).

    Running Programs

    @@ -2310,10 +2428,10 @@ img { max-width: 100%; }

    Double-click an icon to start that program, or select it with the arrow keys and press Enter. The program opens in a new window. While it is loading, the mouse pointer shows an hourglass. The Program Manager's Search box finds a program in every group as you type its name.

    To start a program file that has no icon, choose Run... from the Program Manager's File menu and type or pick the file.

    Opening Documents

    -

    A document can be opened the same way as a program: DVX starts the program set up for its kind of file (the letters after the dot in its name, such as TXT) and gives it the document, which the program opens right away. Which program opens which kind of file is set in the settings program (see Changing Settings).

    +

    A document can be opened the same way as a program: DVX starts the program set up for its kind of file (the letters after the dot in its name, such as TXT) and gives it the document, which the program opens right away: a picture opens in the Image Viewer, and a music or sound file (MOD, S3M, XM, IT, MID, RMI, WAV, or VOC) plays in Media Player. Which program opens which kind of file is set in the settings program (see Changing Settings).

    Running Several Programs

    You can run many programs at the same time, each in its own window. They all keep running while you work in another one.

    -

    Most programs can only run once at a time. If you start a program that is already running, DVX tells you so; find its window instead with Alt+Tab or the Task Manager. Some programs can be opened more than once, each copy in its own window.

    +

    Most programs can only run once at a time. If you start a program that is already running, DVX tells you so; find its window instead with Alt+Tab or the Task Manager. A few take the document you opened instead: a music file opened while Media Player is running is added to its list and plays. Some programs can be opened more than once, each copy in its own window.

    Switching Between Programs

    Programs That Use the Whole Screen

    Some programs, games especially, can fill the whole screen instead of a window (many use Alt+Enter to switch). Your other programs keep running while they do. Alt+Tab and Ctrl+Esc still work and bring the desktop back; when you return to the program, it fills the screen again.

    +

    Running DOS Programs

    +

    DVX can start ordinary DOS programs too (files ending in .EXE, .COM, or .BAT): from an icon in the Program Manager, from Run..., or by double-clicking one in the File Manager. Such a program has the whole screen to itself. Before it starts, DVX names the programs that will close and asks whether to go on; each one closes as it would if you closed it, asking about unsaved work. When the DOS program ends, DVX comes back with the Program Manager. The Program Manager's own help (DOS Programs) tells how to add one as an icon.

    Closing a Program

    Close the program's main window, or choose Exit from its File menu. A program that has unsaved work usually asks whether you want to save it first. See Working with Windows for all the ways to close a window.

    Help for Each Program

    @@ -2370,13 +2490,14 @@ img { max-width: 100%; }

    Changing Settings

    -

    The mouse, the screen colors, the desktop picture, the screen resolution, which program opens each kind of document, the network, the printer, joysticks, and the sounds are changed with the settings program, which has an icon in the Installed Programs group of the Program Manager. Start it like any other program. Its own help, opened with F1 or its Help menu, explains every setting.

    +

    The mouse, the screen colors, the desktop picture and the screen saver, the screen resolution, which program opens each kind of document, the network, the printer, joysticks, and the sounds are changed with the settings program, which has an icon in the Installed Programs group of the Program Manager. Start it like any other program. Its own help, opened with F1 or its Help menu, explains every setting.

    Settings you keep are saved, and DVX uses them again the next time it starts.

    DVX plays a short sound when it starts and when it ends, and when a message appears (a different one for errors, warnings, questions, and information). Each sound can be changed or turned off, and the volume set, in the settings program.

    +

    A screen saver can fill the screen with a moving picture when the computer has not been used for a while; pressing a key or moving the mouse brings your work back. Choose one, and how many minutes to wait, on the Desktop page of the settings program. There is none until you choose one.

    Tip: If a new screen resolution leaves the screen unreadable, just wait: DVX goes back to the old one after 15 seconds unless you confirm the change.

    Where your settings and files are kept

    Everything DVX and its programs save for you goes in one folder, the USER folder inside the DVX folder (for example C:\DVX\USER): your settings, each program's settings and the files it saves (such as saved games), Terminal's downloads, and DVX's log. To back up your work, copy this one folder. Installing a new version of DVX never changes it.

    -

    The fonts, sounds, themes, and desktop pictures that come with DVX are in the SHARED folder. To add your own, put them in the folder of the same name in your USER folder: FONTS, SOUNDS, THEMES, or WPAPER. The settings program lists yours together with DVX's, and a file of yours with the same name as one of DVX's is used instead of it. Themes you save go there too.

    +

    The fonts, sounds, themes, desktop pictures, and screen savers that come with DVX are in the SHARED folder. To add your own, put them in the folder of the same name in your USER folder: FONTS, SOUNDS, THEMES, WPAPER, or SAVERS. The settings program lists yours together with DVX's, and a file of yours with the same name as one of DVX's is used instead of it. Themes you save go there too.

    To keep the USER folder somewhere else (on another disk, or one for each person who uses the computer), set DVXUSER to that folder before starting DVX, for example SET DVXUSER=D:\MYDVX in AUTOEXEC.BAT.

    @@ -2461,7 +2582,8 @@ img { max-width: 100%; }

    When an add-on package's library or widget cannot be loaded (it was made for another version of DVX, it needs something that is not installed, or its file is damaged), DVX starts without it and a message once the desktop is up lists what was left out and why. The rest of DVX works as usual. Reinstall the add-on (a version made for this DVX), or remove it with the Software program.

    You can pick another resolution at any time with the settings program (see Changing Settings).

    There is no sound

    -

    DVX finds the sound hardware by itself. A Sound Blaster needs its BLASTER setting in AUTOEXEC.BAT, for example SET BLASTER=A220 I5 D1 H5 (the card's manual gives the numbers). Without a sound card DVX uses the PC speaker, which plays tones and beeps but not recorded sounds. The settings program shows the sound hardware in use, lets you pick it yourself, and sets the volume; if a sound is set to none there, nothing is heard for it. Without any sound hardware DVX and its programs work the same, silently.

    +

    DVX finds the sound hardware by itself. A Sound Blaster needs its BLASTER setting in AUTOEXEC.BAT, for example SET BLASTER=A220 I5 D1 H5, and a Gravis UltraSound its ULTRASND setting, for example SET ULTRASND=220,1,1,11,5 (the card's manual or setup program gives the numbers; a Sound Blaster AWE's setting includes E620 for its music chip). A Windows Sound System card is found at its usual addresses; a sound device on the printer port (a Disney Sound Source or a Stereo-on-1) is found on its own, while a Covox Speech Thing must be chosen in the settings file (DVX.INI [audio] lptDac = covox). Without a sound card DVX uses the PC speaker, which plays tones and beeps but not recorded sounds. The settings program shows the sound hardware in use, lets you pick it yourself, and sets the volume; if a sound is set to none there, nothing is heard for it. Without any sound hardware DVX and its programs work the same, silently.

    +

    MIDI music plays on the sound card's own recorded instruments (a Gravis UltraSound or a Sound Blaster AWE) when DVX's General MIDI SoundFont is installed (the Software program offers it) or the card's own instruments are there; otherwise on its music chip, or on DVX's own synthesizer. Music modules (MOD, S3M, XM, and IT files) play on a Gravis UltraSound's or a Sound Blaster AWE's own voices, which takes the computer little time; on other cards the computer mixes them, which takes much of a slow computer's time (the Control Panel's Sounds page can have them always mixed).

    An empty floppy or CD-ROM drive

    When a program tries to read a drive that has no disk in it, or to write to a write-protected disk, the program simply reports that it could not read or write the file. DVX never stops at the DOS "Abort, Retry, Fail" question. Put the disk in and try again.

    Installing, upgrading, and removing DVX

    @@ -3616,7 +3738,7 @@ DVX_ABI_STAMP(mylib);

    dvxText.h -- Fonts

    -

    The font families DVX draws with: "System", the built-in 8x16 font, and the families in the FONTS folder. Which families that folder holds depends on the install -- Setup puts in none, the base set (DejaVu Sans, Sans Mono, and Serif), or all of them -- and dvxFontOpen of a family that is not there gives the system font. A family has up to four faces (regular, bold, italic, bold italic), each a .DFN file holding the face at the pixel sizes it was made at; the files are made on the build host from TrueType, Type 1, and bitmap fonts (src/tools/mkfont.c), so DVX only copies bitmaps.

    +

    The font families DVX draws with: "System", the built-in 8x16 font, and the families in the FONTS folder. Which families that folder holds depends on the install -- Setup puts in none, the base set (DejaVu Sans, Sans Mono, and Serif), or all of them -- and dvxFontOpen of a family that is not there gives the system font. A family has up to four faces (regular, bold, italic, bold italic), each a .DFN file holding the face at the pixel sizes it was made at; the files are made on the build host from TrueType, Type 1, and bitmap fonts (src/tools/mkfont.c), so DVX only copies bitmaps. More files of the same family and style (up to four in all) add the sizes the face lacks, each size read from the file it came in: the printer-fonts add-on gives the DejaVu faces their large sizes that way, from FONTS\KPUNCH.

    dvxFontOpen gives a BitmapFontT every text call draws and measures with. It never scales: it picks the face's size nearest the one asked for. A style the family has no face for is made from the nearest face -- bold by thickening each stroke a column (two from 24 pixels), italic by slanting a column per four rows -- and the font's flags say so. Every character of code page 437 draws: what a face lacks is filled in when the font loads. Box drawing, block, and shade characters are drawn to fill the character cell (the pitch, or the digit 0's width) so lines join, also in place of a face's own when its file asks; other characters come from FONT_FALLBACK_FAMILY (FONT_FALLBACK_FIXED for a fixed-pitch face, centered in its pitch) at the nearest size and the same style, or from the system font when that family is not installed.

    A face can also hold a size smoothed (anti-aliased): each pixel one of four levels, from none to full, drawn as that blend of the text color and what is under it. When smoothing is on (dvxFontSetSmooth) such a size opens smoothed, with FONT_FLAG_GRAY set and FONT_GRAY_BPP bits per pixel; other sizes and faces stay 1-bit. At 8 bits per pixel there are no colors to blend: the pixels of FONT_GRAY_HALF and more are drawn, the rest not.

    Fonts are shared and counted: the same family, size, and style opened again is the same font, and dvxFontClose gives one up. A few fonts no one holds stay loaded so reopening them is quick. A font's memory belongs to DVX, not to the program that opened it. The family list is read from DVX_FONTS_DIR the first time it is needed; a file that cannot be read is logged and skipped.

    @@ -3671,7 +3793,7 @@ bool dvxFontSmooth(void);
    bool dvxFontAddFile(int32_t owner, const char *path);
     bool dvxFontAddMemory(int32_t owner, const void *data, uint32_t length, const char *name);

    Add the .DFN at path, or held in data (length bytes, copied at once; name names it in the log), as a face of its family for program owner (an app id; 0 for every program, as SETUP.EXE adds the fonts it carries). Families added for owner 0 are seen by every program and kept by dvxFontRescan.

    -

    Returns: True when added; false, logged, when the font cannot be read, calls its family "System", or owner has a face of that family and style already.

    +

    Returns: True when added; false, logged, when the font cannot be read, calls its family "System", or brings no size that owner's face of that family and style lacks (a second file of a face adds its sizes to it).

    dvxFontAddResources

    int32_t dvxFontAddResources(int32_t owner, const char *path);

    Add every font resource (DVX_RES_FONT) of the file at path -- a program, a library, or a resource file -- for owner. The shell calls it for each program it loads.

    @@ -5065,6 +5187,15 @@ const BlitOpsT *dvxExclusiveBlitOps(AppContextT *ctx);

    dvxExclusiveWaitRetrace

    void dvxExclusiveWaitRetrace(void);

    Wait for the start of the next vertical retrace (at most two frames' time), before a flush, so the frame is not torn.

    +

    DOS Programs: dvxRunDos

    +
    int32_t dvxRunDos(AppContextT *ctx, const char *program, const char *args, const char *folder, bool pause);
    +int32_t platformDosRun(const char *command, const char *folder, bool pause, const DisplayT *desktop);
    +bool    platformPathAbsolute(const char *path, char *out, int32_t outSize);
    +

    dvxRunDos runs a DOS program (an .EXE, .COM, or .BAT file, or a command DOS knows) with DVX stepped aside, and comes back. Full screen, menus, and drags end; program is made absolute (a bare name stays one, for the PATH) and given to DOS by its short name, with args after it, in folder (NULL or "": the program's own folder). platformDosRun then runs the suspend hooks (newest first), lets the keyboard hook and the timer service go (the timers are kept and stand still), sets text mode, gives the INT 24h handler and Ctrl+Break back to DOS, makes the folder current, and runs the command line with system() (as at the DOS prompt: a batch file through the command interpreter, PATH, redirection); with pause it prints "Press any key to return to DVX." and waits for a key. Everything is taken back in the opposite order, the desktop's mode (and palette) set again, and the resume hooks run (oldest first); dvxRunDos sets the mouse up again where it was, forgets held keys, and repaints the whole screen. DVX logs "DOS: running" and "DOS: back from" with the command line. Nothing of DVX runs meanwhile: close the other programs first (the shell's shellRunDos asks them). Returns the program's exit code, or -1 when it could not be run (errno: ENOENT, ENOMEM, E2BIG for a command line longer than DOS's 126 characters). DVX stays in memory: the program gets the conventional memory DVX's DOS part and the DPMI host leave. On the host, platformDosRun is system() in the folder with the hooks around it.

    +

    platformPathAbsolute makes a path absolute against the current folder (it need not exist).

    +

    Idle Time: dvxIdleMs

    +
    uint32_t dvxIdleMs(const AppContextT *ctx);
    +

    The milliseconds since the last key or mouse input: a key (pressed, released, or typed), the mouse moving, a button, or the wheel, injected input (a test script, SysSendKey) included, and input that came while DVX was busy and was thrown away. DVX coming back from a DOS program or from full-screen mode counts as input too. The shell's screen saver starts after DVX.INI [desktop] saverWait minutes of it (shellSaver.h); BASIC: SysIdleSeconds. The time is kept in AppContextT.lastInputTime (platformClock ticks).

    Accelerators

    dvxCreateAccelTable

    AccelTableT *dvxCreateAccelTable(void);
    @@ -5362,7 +5493,7 @@ bool dvxAppIsHung(const AppContextT *ctx, int32_t appId); WindowT *window Owning window int32_t x, y, w, h Computed geometry (relative to content area) int32_t calcMinW, calcMinH Computed minimum size (from layout pass) - int32_t minW, minH, maxW, maxH, prefW, prefH Size hints (tagged sizes) + int32_t minW, minH, maxW, maxH Size hints (tagged sizes): the least and the most the layout gives the widget int32_t weight Extra-space distribution weight (0=fixed, 100=normal) WidgetAlignE align Main-axis alignment for children int32_t spacing, padding Tagged sizes for child spacing and padding @@ -6727,9 +6858,16 @@ void platformIrqRestore(uint32_t state); typedef void (*PlatformShutdownFnT)(void); bool platformShutdownHookAdd(PlatformShutdownFnT fn); -void platformShutdownHookRemove(PlatformShutdownFnT fn); +void platformShutdownHookRemove(PlatformShutdownFnT fn); + +#define PLATFORM_SUSPEND_HOOKS 8 +typedef void (*PlatformSuspendFnT)(bool suspending); + +bool platformSuspendHookAdd(PlatformSuspendFnT fn); +void platformSuspendHookRemove(PlatformSuspendFnT fn);

    platformIrqSave disables interrupts and returns the previous state for platformIrqRestore; bracket the few instructions that change data an interrupt handler reads (nothing happens on the host).

    A shutdown hook runs when DVX ends without its normal shutdown (exit() or a crash with no recovery point), before the timer service and the keyboard hook are given back, so a module that drives hardware from an interrupt (a sound card playing by DMA) can stop it. Each hook runs once. A module removes its hook before it is unloaded; at most PLATFORM_SHUTDOWN_HOOKS exist at once (Add returns false when full; adding one already present does nothing).

    +

    A suspend hook runs as fn(true) before DVX steps aside for a DOS program (platformDosRun, dvxRunDos) and as fn(false) once DVX is back, so a module that drives hardware itself lets it go (and takes it again then, or at its next use): audio.lib stops every sound, closes the output and the synthesizers, and opens the synthesizers again; music.lib stops every music and lets go of where it plays, closing its context (and the SoundFont kept on the wavetable synthesizer) when none is left; dvxnet stops the packet driver (it starts again at the next use). Suspending runs the newest hook first (a library built on another, loaded after it, lets go before it), resuming the oldest first; a hook may remove itself as they run. At most PLATFORM_SUSPEND_HOOKS; the same rules as the shutdown hooks otherwise.

    Video

    platformVideoInit / platformVideoShutdown

    int32_t platformVideoInit(DisplayT *d, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
    @@ -6930,6 +7068,7 @@ char    platformBootDrive(void);
    #define DVX_THEMES_DIR "THEMES" #define DVX_WPAPER_DIR "WPAPER" #define DVX_BANKS_DIR "BANKS" +#define DVX_SAVERS_DIR "SAVERS" #define DVX_VENDOR_DIR "KPUNCH" typedef enum { @@ -6938,6 +7077,7 @@ typedef enum { DvxKindThemesE, DvxKindWpaperE, DvxKindBanksE, + DvxKindSaversE, DvxKindCountE } DvxKindE; @@ -6960,12 +7100,12 @@ char **platformKindReadDir(DvxKindE kind); SYSTEM DVX's own tools and files, and the shipped DVX.INI and ASSOC.INI LIBS, WIDGETS The libraries and widgets APPS\<vendor>\<program> A program, its help, and DATA (the files it ships; read-only) - SHARED FONTS, SOUNDS, THEMES, WPAPER, and BANKS: what DVX and add-on packages ship for every program (an add-on's in a folder of its vendor's, such as FONTS\ACME) - USER (the user folder) DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, WPAPER, and BANKS, KEYS (publishers' keys the user trusts), APPS\<program path> (each program's settings and saved files), and DOWNLOAD + SHARED FONTS, SOUNDS, THEMES, WPAPER, BANKS, and SAVERS: what DVX and add-on packages ship for every program (an add-on's in a folder of its vendor's, such as FONTS\ACME) + USER (the user folder) DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, WPAPER, BANKS, and SAVERS, KEYS (publishers' keys the user trusts), APPS\<program path> (each program's settings and saved files), and DOWNLOAD

    Only packages put files in SHARED: DVX's own, and add-on packages (pkg.lib), each in its vendor's folders. A program reads it but never writes or installs into it; a program's own files live with it (its DATA folder, or resources inside the .app), under its own vendor and program folder, so two programs' files never collide.

    platformUserInit, called once by the loader, decides the user folder: the folder the DVXUSER environment variable names, else USER in the DVX folder, made when missing. When it cannot be written (DVX on a CD-ROM without DVXUSER), DVXUSER in the TEMP folder is used instead and platformUserInTemp is true; without a TEMP folder nothing can be saved and platformIsReadOnly is true. The first start copies SYSTEM\DVX.INI to the user folder. platformUserDir is the folder (DVXUSER or USER before platformUserInit has decided); platformUserPath joins a name to it; platformDvxIniPath is its DVX.INI. platformUserDirOf is the user folder of a DVX installed in another folder (DVXUSER, else that folder's USER), for Setup.

    platformAppUserDir is a program's folder in the user folder: APPS\KPUNCH\TERMINAL\TERMINAL.APP gives USER\APPS\KPUNCH\TERMINAL; a program outside the DVX folder gives USER\APPS\<its file name without the extension>. It is not made here (the shell's userDir and BASIC's App.User are made on first use).

    -

    SHARED and the user folder have the same five kinds of folder (DvxKindE): fonts, sounds, themes, desktop pictures, and MIDI instrument banks. Each may hold folders of add-on vendors (SHARED\FONTS\ACME), one level deep, whose files are named VENDOR\NAME. platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name (NAME or VENDOR\NAME, either separator), the user's first, so a user's file of the same name wins; any other name with a folder in it, or an absolute one, is taken as the path it is (a VENDOR\NAME not in the kind's folders is tried that way too). platformKindNameOf is the other way: a file in one of the kind's folders gives its name alone, or VENDOR\NAME in a vendor's folder, which is what settings store (DVX.INI [sounds], [desktop] wallpaper, and [audio] midiBank), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them, with each vendor folder's files as VENDOR\NAME (the folders themselves are not listed); free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas, with FILEOPS_KIND_FONTS to FILEOPS_KIND_BANKS), and App.User for a program's own folder.

    +

    SHARED and the user folder have the same six kinds of folder (DvxKindE): fonts, sounds, themes, desktop pictures, MIDI instrument banks, and screen savers. Each may hold folders of add-on vendors (SHARED\FONTS\ACME), one level deep, whose files are named VENDOR\NAME. platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name (NAME or VENDOR\NAME, either separator), the user's first, so a user's file of the same name wins; any other name with a folder in it, or an absolute one, is taken as the path it is (a VENDOR\NAME not in the kind's folders is tried that way too). platformKindNameOf is the other way: a file in one of the kind's folders gives its name alone, or VENDOR\NAME in a vendor's folder, which is what settings store (DVX.INI [sounds], [desktop] wallpaper and saver, and [audio] midiBank), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them, with each vendor folder's files as VENDOR\NAME (the folders themselves are not listed); free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas, with FILEOPS_KIND_FONTS to FILEOPS_KIND_SAVERS), and App.User for a program's own folder.

    Read-only media: platformSetReadOnly / platformIsReadOnly / platformDirIsWritable / platformTempDir / platformSetLogMemory / platformLogMemory

    #define PLATFORM_LOG_MEM_MAX 16384
     
    @@ -7083,7 +7223,7 @@ audioClose(audio);                              // or let the program end
       volume               0 .. AUDIO_VOLUME_MAX (256)
       pan                  AUDIO_PAN_LEFT (-256) .. AUDIO_PAN_RIGHT (256); centre plays at full volume on both sides and panning turns the other side down
       pitch                AUDIO_PITCH_MIN (64) .. AUDIO_PITCH_MAX (1024), AUDIO_PITCH_NORMAL (256) = recorded speed
    -

    audioVoiceSetVolume, audioVoiceSetPan, audioVoiceSetPitch, audioVoiceStop, and audioVoiceIsPlaying take the context and the voice id; a voice id of another context, or of a voice that has ended, gives AUDIO_ERR_VOICE. audioStopAll stops every sample and tone of a context.

    +

    audioVoiceSetVolume, audioVoiceSetPan, audioVoiceSetPitch, audioVoicePause, audioVoiceStop, and audioVoiceIsPlaying take the context and the voice id; a voice id of another context, or of a voice that has ended, gives AUDIO_ERR_VOICE. audioVoicePause(ctx, voice, paused) holds a sample's or a stream's voice where it is (it stays playing, silent, and takes nothing from its stream) or lets it go on; a tone or a note queue gives AUDIO_ERR_ARG. audioStopAll stops every sample and tone of a context.

    audioSetEndFn(ctx, fn, userData) asks for a call when a voice of ctx ends, with the reason: AUDIO_END_FINISHED (played out), AUDIO_END_STOPPED (stopped, or its sample or stream went away), or AUDIO_END_DROPPED (the voice limit made room for a louder sound). The call comes from audioPump or the shell's idle time, as the program that owns the context, never from an interrupt. The mixer runs up to one buffer (about 50 ms) ahead of what is heard, so a notification can come that much before the sound has finished playing.

    Tones

    audioTone(ctx, wave, hz, volume, ms) plays a square (AUDIO_WAVE_SQUARE), triangle (AUDIO_WAVE_TRIANGLE), or noise (AUDIO_WAVE_NOISE) tone of hz (AUDIO_TONE_MIN_HZ 20 to AUDIO_TONE_MAX_HZ 20000) for ms milliseconds, or until stopped when ms is 0. For noise, hz is how fast the noise changes: higher sounds brighter. audioVoiceSetFrequency changes a playing tone. When the computer has an FM chip (and DVX.INI [audio] fmTones is not false), a square or triangle tone plays on a free FM voice (a square as a carrier modulated at twice its pitch, which gives odd harmonics like a square wave; a triangle as a sine) at up to the chip's highest note (about 6,200 Hz); when every FM voice is taken it is mixed like any sound. Its voice id, volume, pan, pitch, and end notification work the same either way. On a device with a DAC tones are mixed like any sound; on the PC speaker in its tones mode the loudest square or triangle tone playing is sounded by the timer chip.

    @@ -7099,7 +7239,18 @@ audioNotesAdd(tune, 0, 50000); // a rest -

    audioStreamOpen(ctx, fmt, bufferMs, fill, userData, &stream) opens a stream of any rate from AUDIO_RATE_MIN (4000) to AUDIO_RATE_MAX (48000), 8 or 16 bits, mono or stereo, with a buffer of bufferMs milliseconds (0: 250). It plays at once on its own voice (audioStreamVoice), silence until samples arrive. Without fill the program writes samples with audioStreamWrite, which takes as many frames as fit and returns how many; audioStreamSpace and audioStreamQueued tell how much room and how much sound are left. With fill, the library asks for samples whenever the buffer has room: fill(stream, buf, frames, userData) writes up to frames frames in the stream's format and returns how many. Fill functions run from audioPump and the shell's idle time; a program that keeps the desktop busy every frame should call audioPump once a frame. A stream that runs dry plays silence and counts underruns. audioStreamClose stops and frees it.

    +

    audioStreamOpen(ctx, fmt, bufferMs, fill, userData, &stream) opens a stream of any rate from AUDIO_RATE_MIN (4000) to AUDIO_RATE_MAX (48000), 8 or 16 bits, mono or stereo, with a buffer of bufferMs milliseconds (0: 250). It plays at once on its own voice (audioStreamVoice), silence until samples arrive. Without fill the program writes samples with audioStreamWrite, which takes as many frames as fit and returns how many; audioStreamSpace and audioStreamQueued tell how much room and how much sound are left. With fill, the library asks for samples whenever the buffer has room: fill(stream, buf, frames, userData) writes up to frames frames in the stream's format and returns how many. Fill functions run from audioPump and the shell's idle time; a program that keeps the desktop busy every frame should call audioPump once a frame. A stream that runs dry plays silence and counts underruns. audioStreamFlush drops what the stream holds and has not played yet (a seek or a stop is heard at once; the two frames the mixer has read ahead still play). audioStreamClose stops and frees it.

    +

    audioDrv.h also declares, for audio.lib and the libraries built on it (music.lib), audioDecode (a WAV or VOC file in memory as 16-bit frames), audioConvert (8- or 16-bit frames as 16-bit ones), and audioWavParse(head, headBytes, fileBytes, &fmt, &dataAt, &dataBytes) (a WAV file's format and where its frames are, from its first bytes: the frames are clamped to the file and whole; AUDIO_ERR_FORMAT for anything but a RIFF WAVE file, AUDIO_ERR_UNSUPPORTED, AUDIO_ERR_CORRUPT).

    +

    Recording

    +
    AudioFormatT fmt = { 22050, 16, 1 };
    +AudioRecT   *rec = NULL;
    +
    +if (audioRecFile(audio, "TALK.WAV", &fmt, AUDIO_REC_MIC, &rec) == AUDIO_OK) {
    +    // ... later
    +    audioRecClose(rec);
    +}
    +

    audioRecOpen(ctx, fmt, source, bufferMs, &rec) records from the card's microphone, line, or CD input (AUDIO_REC_MIC, AUDIO_REC_LINE, AUDIO_REC_CD; AudioInfoT.canRecord, and recSources with bit 1 << source for each the card has) into a buffer of bufferMs (0: AUDIO_REC_MS_DEFAULT, 2 seconds; AUDIO_REC_MS_MIN to AUDIO_REC_MS_MAX). fmt asks for a rate, 8 or 16 bits, and mono or stereo (0: AUDIO_RATE_DEFAULT, AUDIO_REC_BITS_DEFAULT, mono) and comes back as what the card records: its nearest rate, and its own bits and channels when it cannot do the ones asked (a card before the SB Pro records 8-bit mono). The program takes the frames with audioRecRead(rec, pcm, frames) before the buffer fills; audioRecQueued says how many wait, audioRecLost how many were dropped because the buffer was full (whole blocks, so the frames stay in order), audioRecFrames how many were taken, and audioRecPeak the loudest sample since it was last asked, in percent (a level meter). audioRecFile(ctx, path, fmt, source, &rec) records into a WAV file instead: the library writes the frames as they come (audioPump and the shell's idle time) and the file's sizes at audioRecClose; the file starts with an empty header, so one cut short still reads. Everything that can be checked (the arguments, another recording, the card and its input) is checked before the file is made, so a recording that cannot start leaves a file of that name alone. audioRecClose stops and returns AUDIO_ERR_WRITE when the file could not all be written. audioWavHeader(out, fmt, dataBytes) writes the AUDIO_WAV_HEADER_BYTES of a WAV header for programs writing their own files.

    +

    The Sound Blasters (sb), Windows Sound System boards (wss), and the Gravis UltraSound (gus) record; the DACs on the printer port, the PC speaker, and null do not. Most cards record or play, not both, so recording takes the card: its output closes (as after a silence), voices already playing hold where they are, and a new voice gets AUDIO_ERR_BUSY (the desktop's sounds are skipped) until the recording ends and the held voices play on. One recording at a time for all of DVX (another gets AUDIO_ERR_BUSY). Closing the context (the program's end) and a DOS program run with DVX stepped aside (audioSuspend) end the recording.

    FM Synthesizer

    int32_t v = audioFmAlloc(audio);                // AUDIO_ERR_NO_VOICE: all taken
     
    @@ -7110,19 +7261,34 @@ audioFmFree(audio, v);                          // key off, back to everyoneAn OPL2 (9 voices) or OPL3 (18 voices, each playing left, right, or both) found by the opl driver is shared by every program: audioFmAlloc(ctx) takes a two-operator voice (the least recently given back first, so a released note's tail is cut last) and returns its id, AUDIO_ERR_NO_VOICE when all are taken, AUDIO_ERR_BUSY while a program has the whole chip, AUDIO_ERR_NO_SYNTH without a chip. audioFmWrite(ctx, voice, reg, value) sets a register of the voice's channel, numbered as for the first channel of an OPL2 (AUDIO_FM_MOD_CHAR 20h, AUDIO_FM_CAR_CHAR 23h, levels 40h/43h, attack 60h/63h, sustain 80h/83h, waveforms E0h/E3h, AUDIO_FM_FNUM_LOW A0h, AUDIO_FM_KEY_BLOCK B0h, AUDIO_FM_FEEDBACK C0h); the library adds the channel's offsets and bank. On an OPL3 a feedback value without an output bit (AUDIO_FM_OUT_LEFT, AUDIO_FM_OUT_RIGHT) plays on both sides. Frequencies: hz = fnum x AUDIO_FM_CHIP_HZ (49,716) x 2^block / 2^20. audioFmFree keys the voice off and gives it back; closing the context gives back all of its voices. audioFmGetInfo tells the chip, its voices, and how many are free.

    audioFmRawAcquire(ctx) gives one program the whole chip: every voice the others hold is keyed off and taken back (their tones go on in the mixer), new voices are refused, and audioFmRawWrite(ctx, reg, value) sets any register (bank x AUDIO_FM_BANK_REGS + index). A second program asking gets AUDIO_ERR_BUSY. audioFmRawRelease (or closing the context) silences the chip, sets it up again, and gives its voices back to everyone.

    audioFmAlloc, audioFmFree, audioFmWrite, and audioFmRawWrite may also be called from a timer callback (platformTimerAdd); music.lib plays MIDI that way.

    +

    Wavetable Synthesizer

    +
    uint32_t      addr = 0;
    +int32_t       v    = audioWtAlloc(audio);       // AUDIO_ERR_NO_VOICE: all taken
    +AudioWtVoiceT note;
    +
    +audioWtMemAlloc(audio, frames * 2, 16, &addr);
    +audioWtUpload(audio, addr, pcm, frames, 16);
    +audioWtNoteInit(&note);                         // a plain note: no envelope, the filter open
    +note.addr = addr;
    +note.end  = frames;
    +note.bits = 16;
    +audioWtStart(audio, v, &note);                  // pitch 0: AUDIO_WT_PITCH_RATE (44,100) frames a second
    +

    A wavetable synthesizer (a Gravis UltraSound's GF1, a Sound Blaster AWE32's or AWE64's EMU8000) plays samples from its own memory, and its voices and memory are shared by every program. audioWtGetInfo tells the chip (description, voices, free voices, memory, the largest free block, what it does itself: AUDIO_WT_ENVELOPES, AUDIO_WT_LFOS, AUDIO_WT_FILTER, AUDIO_WT_EFFECTS, AUDIO_WT_8BIT for 8-bit samples, AUDIO_WT_BANK_256K when a 16-bit sample must not cross a 256 KB boundary, AUDIO_WT_ROM for a General MIDI sample ROM; and its level, the centibels a voice at full volume is louder than full-scale digital sound, measured: 0 on a GUS, whose digital sound plays on its own voices, 76 on an AWE). audioWtMemAlloc(ctx, bytes, bits, &addr) takes memory for samples of bits bits (AUDIO_ERR_NO_ROOM when no free block is large enough), audioWtUpload(ctx, addr, frames, count, bits) copies 16-bit frames into it at bits, audioWtMemFree gives it back. audioWtAlloc(ctx) takes a voice (the least recently given back first), audioWtStart(ctx, voice, note) starts it on a note (AudioWtVoiceT: the sample's address, or AUDIO_WT_ADDR_ROM with an address in the ROM, and in frames from there the one it starts from (offset: before the loop or inside it), its loop, and its end; pitch in cents, 0 for AUDIO_WT_PITCH_RATE (44,100) frames a second; attenuation in centibels; pan; the filter, reverb and chorus; both envelopes in timecents; both LFOs; audioWtNoteInit makes a plain one, with no envelope or LFO shaping it, the filter open, no effects, centred, at full volume, for the caller to set the sample on), audioWtUpdate changes what its what bits say (AUDIO_WT_SET_PITCH, _VOLUME, _PAN, _FILTER, _EFFECTS, _LFO), audioWtRelease lets the key up, audioWtStop stops it at once, audioWtActive is false once it is silent, audioWtFree gives it back. The chip does what its flags say; the program does the rest by updating the voice as the note goes on. Closing the context gives back its voices and its memory. The voice calls may be made from a timer callback (music.lib plays SoundFont MIDI and modules that way); the memory calls may not.

    MIDI Port

    With DVX.INI [audio] midi = mpu401 the mpu401 driver opens an MPU-401 (or compatible, such as a Sound Blaster 16's) in UART mode, for an external synthesizer such as a Roland MT-32 or SC-55. The port belongs to one program at a time: audioMidiAcquire(ctx) (AUDIO_ERR_BUSY when another has it, AUDIO_ERR_NO_SYNTH without a port), audioMidiPut(ctx, byte) sends a byte and returns AUDIO_ERR_BUSY when the port cannot take one yet (send it again later; it may be called from a timer callback), audioMidiRelease gives it back (closing the context does too). audioMidiGetInfo tells whether there is a port and whether it is in use. music.lib uses the port for MIDI music.

    +

    MIDI Input

    +

    With DVX.INI [audio] midiIn = mpu401 the same port also brings in what a MIDI keyboard or another instrument plays (the port is shared with MIDI music when midi = mpu401 too). One program reads it at a time: audioMidiInOpen(ctx) (AUDIO_ERR_NO_SYNTH when there is no MIDI input, AUDIO_ERR_BUSY when another program reads it), audioMidiInClose(ctx) (closing the context does it too, and a DOS program run with DVX stepped aside closes it). A timer reads the port AUDIO_MIDI_IN_HZ (1,000) times a second into a buffer of AUDIO_MIDI_IN_BYTES with the time each byte came; audioMidiInRead(ctx, &msg) gives the next whole message, oldest first (false when none waits): AudioMidiMsgT has its status (a channel message 80h..EFh, the kind and the channel, or system common F1h..F3h), data1, data2, length (1 to 3 bytes), and ms, when its first byte came in milliseconds since audioMidiInOpen. Running status is filled in; clock and active-sensing bytes are dropped and system exclusive messages skipped. audioMidiInClock(ctx) is the time now on that clock, and audioMidiInLost(ctx) the bytes dropped because the buffer was full (the program read too late). audioMidiGetInfo says whether there is MIDI input (inPresent) and whether a program reads it (inOwned). music.lib plays what comes in (musicMidiSend) and records it into MIDI files (musicMidiRecordNew); the Sound Recorder does both.

    Volume and the Voice Limit

    audioSetMasterVolume sets the volume of all sound (DVX.INI [audio] volume, in percent, sets it when the device starts); audioSetVolume sets the volume of everything one context plays. At most the voice limit (DVX.INI [audio] voices, 1 to AUDIO_VOICES_MAX, default 16) plays at once. Starting another sample or tone then stops the quietest one playing (it ends with AUDIO_END_DROPPED); when the new sound would itself be the quietest it is not started (AUDIO_ERR_NO_VOICE). Streams are never dropped.

    Files

    WAV: uncompressed PCM, 8-bit unsigned or 16-bit signed, mono or stereo, 4000 to 48000 Hz; WAVE_FORMAT_EXTENSIBLE with the PCM sub-format is accepted, other chunks are skipped, and a data chunk that claims more bytes than the file holds plays what is there. VOC (Creative Voice File, header checksum checked): sound blocks with 8-bit unsigned samples, continuation, silence, extended (rate and stereo for the next sound block), and new-format blocks with 8-bit or 16-bit PCM; markers and text are skipped, and a repeated section plays once. Compressed sound (ADPCM, 24-bit, floating point) gives AUDIO_ERR_UNSUPPORTED, a damaged file AUDIO_ERR_CORRUPT, a file that is neither AUDIO_ERR_FORMAT.

    The Mixer

    -

    All sound is mixed in 16-bit integers at the rate the device achieved (the mixer never assumes the rate it asked for: a Sound Blaster Pro asked for 22,050 Hz plays at 22,222 Hz), and every source is resampled to it with linear interpolation. Gains combine volume, pan, context volume, and master volume; the sum is clipped to 16 bits. The mixer runs from the sound card's own interrupt (the Sound Blaster, at the end of each half buffer of about 23 ms), or from the timer service for devices without one, so sound keeps playing however busy the programs are. It uses no floating point and touches only memory pinned for interrupts (sample and stream buffers are pinned when they are made).

    +

    All sound is mixed in 16-bit integers at the rate the device achieved (the mixer never assumes the rate it asked for: a Sound Blaster Pro asked for 22,050 Hz plays at 22,222 Hz), and every source is resampled to it with linear interpolation. Gains combine volume, pan, context volume, and master volume; the sum is clipped to 16 bits. The mixer runs from the sound card's own interrupt (at the end of each half buffer of about 23 ms), or from the timer service for devices without one, so sound keeps playing however busy the programs are. It uses no floating point and touches only memory pinned for interrupts (sample and stream buffers are pinned when they are made). A 486's multiplies are slow, so the mixer does none whose result is known: a sound at the device's rate is copied frame for frame without interpolating, a mono one is worked out once for both sides, and a centred mono one scaled once. Measured on an emulated 486DX2-66 at 22,050 Hz, eight mono sounds at the device's rate take about 19 percent of the CPU, eight at half of it (interpolated) about 30 percent.

    Settings

    DVX.INI section [audio]:

      Key          Meaning
       ---          -------
    -  driver       auto (default: find the hardware), or a driver name: sb, speaker, null
    +  driver       auto (default: find the hardware), or a driver name: wss, sb, gus, lpt, speaker, null
       rate         mixing rate asked for, 4000 to 48000 (default 22050)
       bits         0 (the device's best), 8, or 16
       channels     0 (the device's best), 1, or 2
    @@ -7131,11 +7297,19 @@ audioFmFree(audio, v);                          // key off, back to everyone
    -

    A settings screen reads and writes the keys it offers through the library, so the key names live in one place (audio.h, Settings; prefs is a copy of DVX.INI the caller loads and saves): audioSettingDriver(prefs) (AUDIO_DRIVER_AUTO when missing), audioSettingMidi(prefs) (an index in audioMidiChoices, auto when missing or unknown), audioSettingVolume(prefs) (percent, clamped to 0..100, 100 when missing), and audioSettingsStore(prefs, driver, midi, volume) writes all three (an empty driver stores auto, a midi index out of range stores auto). audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] holds the midi choices in list order, each with its label for people and the name stored (a PrefsChoiceT, dvxPrefs.h). audioPercentToVolume(percent) turns a percent into 0..AUDIO_VOLUME_MAX for audioSetMasterVolume. The device reads the driver and volume through the same functions when it starts. The Control Panel's Sounds page uses all of these.

    +

    A settings screen reads and writes the keys it offers through the library, so the key names live in one place (audio.h, Settings; prefs is a copy of DVX.INI the caller loads and saves): audioSettingDriver(prefs) (AUDIO_DRIVER_AUTO when missing), audioSettingMidi(prefs) (an index in audioMidiChoices, auto when missing or unknown), audioSettingMidiIn(prefs) (an index in audioMidiInChoices, none when missing or unknown), audioSettingModules(prefs) (an index in audioModulesChoices, wavetable when missing or unknown), audioSettingVolume(prefs) (percent, clamped to 0..100, 100 when missing), and audioSettingsStore(prefs, driver, midi, modules, volume) writes all four (an empty driver stores auto, a midi index out of range stores auto, a modules index out of range wavetable); audioSettingMidiInStore(prefs, midiIn) writes midiIn (an index out of range stores none), which takes effect when the device next starts. audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] holds the midi choices in list order, each with its label for people and the name stored (a PrefsChoiceT, dvxPrefs.h); audioMidiInChoices[AUDIO_MIDI_IN_CHOICE_COUNT] the midiIn choices, audioModulesChoices[AUDIO_MODULES_CHOICE_COUNT] the modules choices (wavetable: on a wavetable synthesizer's voices when it is free and the module fits there; software: always mixed in software). audioPercentToVolume(percent) turns a percent into 0..AUDIO_VOLUME_MAX for audioSetMasterVolume. The device reads the driver and volume through the same functions when it starts. The Control Panel's Sounds page uses all of these.

    Desktop Sounds

    The library plays the sounds DVX makes for its events (dvxPlaySound in dvxApp.h): when it loads it sets DVX's sound hook to audioDeskPlay(event, wait). The file of each event is read from DVX.INI [sounds] at the first sound: the key is audioDeskKey(event) and the value a WAV or VOC file, empty or missing for none: a name alone is found in the sound folders (the user folder's SOUNDS first, then SHARED\SOUNDS: platformKindFind), anything else is a path; audioDeskReload makes the next sound read the section again (the Control Panel calls it after saving). The sounds play on a context of the library's own, so no program's end cuts one short, at full volume under the master volume. When no sound device starts, the sounds stay silent without trying again until the next reload. With wait the call returns once the sound has played (at most 5 seconds); after the exit sound further events are quiet (DVX is ending). audioDeskLabel(event) names an event for people, audioDeskPlayFile(path) plays any file the same way (to try one out). audioDeskFile(prefs, event) reads an event's file from a copy of DVX.INI ("" for none) and audioDeskSetFile(prefs, event, path) sets it; the library's own reading of [sounds] goes through audioDeskFile too. The default set, made by DVX's own sound generator at build time, is in SHARED\SOUNDS.

      Key           Event
    @@ -7152,20 +7326,25 @@ audioFmFree(audio, v);                          // key off, back to everyoneDrivers
     
      Driver       Device
       ------       ------
    -  sb           Sound Blaster family by DSP version: 1.x (8-bit single-cycle), 1.5 / 2.0 (8-bit auto-init, high-speed above 23,000 Hz on 2.0), Pro and Pro 2 (8-bit stereo at 11,025 or 22,050 Hz), 16 (8- or 16-bit, mono or stereo, any rate from 5,000 to 44,100 Hz). The port, IRQ, and DMA channels come from the BLASTER variable (e.g. SET BLASTER=A220 I5 D1 H5); without it an SB16 is found at 220h or 240h and asked for its IRQ and DMA. If no interrupt arrives from the card within half a second (a wrong IRQ), the next driver is tried.
    +  sb           Sound Blaster family by DSP version: 1.x (8-bit single-cycle), 1.5 / 2.0 (8-bit auto-init, high-speed above 23,000 Hz on 2.0), Pro and Pro 2 (8-bit stereo at 11,025 or 22,050 Hz), 16 (8- or 16-bit, mono or stereo, any rate from 5,000 to 44,100 Hz). It records with the same modes' input commands (sbRecordPlan): before the Pro the microphone only, 8-bit mono up to 13,000 Hz (15,000 on DSP 2.01, high-speed above 13,000); the Pro and the 16 also the line and CD inputs (mixer 0Ch; 3Dh / 3Eh with the microphone at full on the 16), recording what they play; the mixer registers it changes are put back when it stops. The port, IRQ, and DMA channels come from the BLASTER variable (e.g. SET BLASTER=A220 I5 D1 H5); without it an SB16 is found at 220h or 240h and asked for its IRQ and DMA. If no interrupt arrives from the card within half a second (a wrong IRQ), the next driver is tried.
       speaker      The PC speaker. By default tones only: the timer chip plays the loudest square or triangle tone as a square wave, with no interrupts and no CPU cost; sampled sound and noise are not heard. With speakerPcm = true it plays everything by pulse-width modulation: the timer interrupt runs at 22,096 Hz and each interrupt sets the next pulse (about 5.7 bits). That costs a lot of CPU time: 22,096 interrupts a second plus the mixing. A 486DX2-66 spends a large share of its time on it (estimated 10 to 15 percent for the interrupts alone on plain DOS, much more under a memory manager or Windows) and can lose interrupts, which slows the sound and the DVX clock down. Meant for Pentium-class machines.
    +  wss          A Windows Sound System board (an AD1848 codec or its kin: Microsoft's, Crystal CS4231 / CS4248, Aztech, OPTi in WSS mode) at wssPort: 8- or 16-bit, mono or stereo, the codec's nearest rate, double-buffered by auto-init DMA on wssIrq and wssDma (set through the board's configuration register; a Plug and Play board set up by its own utility must be on the ones DVX.INI names). It records the same way (the codec's capture on the same DMA channel): the line input, AUX1 (the CD on WSS boards), or the microphone with its 20 dB boost, putting back the inputs it found. Tried before the Sound Blaster, which such a board often also has.
    +  gus          A Gravis UltraSound, found through the ULTRASND variable (port, DMA channels, IRQs: SET ULTRASND=220,1,1,11,5): digital sound on two of its voices from a ring at the top of its memory, refilled by DMA at each half, and its wavetable synthesizer on the rest (gusVoices active in all). It records from the microphone or the line input on ULTRASND's second DMA channel (the first when the second is not given): an UltraSound MAX (board version 10 or 11) through its CS4231 codec, as a Windows Sound System board records (8- or 16-bit, mono or stereo, the codec's rates), any other board with the GF1's own sampling (8-bit, mono or stereo, 5,510 to 44,100 Hz, each half started from the terminal count of the one before, since the GF1 does not run on by itself); no emulator DVX is tested on records on a GUS, so this follows Linux's drivers. Tried after a Sound Blaster beside it, which then plays the digital sound while the GUS plays music (guswt).
    +  lpt          A DAC on the parallel port (lptDac, lptPort): a Covox Speech Thing (used only when lptDac = covox: it cannot be found), a Disney Sound Source (a FIFO at a fixed 7 kHz, topped up by the timer), or a Stereo-on-1. 8-bit at most 11,048 Hz; the Covox and the Stereo-on-1 take a timer interrupt for every frame.
       null         No sound device: plays nothing, but voices still run their course and end, so programs behave the same.
    -  opl          (synthesizer) The OPL2 or OPL3 FM chip of an AdLib or Sound Blaster at 388h (oplPort), found by the AdLib timer test; the status register tells an OPL2 from an OPL3. Register writes are timed by status reads (3.3 and 23 us on an OPL2) with interrupts off around each index/data pair. Closing zeroes every register.
    -  mpu401       (synthesizer) An MPU-401 MIDI port in UART mode at mpuPort, only with midi = mpu401: reset (FFh, answered FEh; tried twice, since a port left in UART mode takes the first FFh as data), then UART mode (3Fh). Bytes are sent when the port says it is ready (a short wait, else the caller sends later); input the port has (active sensing) is read and dropped. Closing sends All Notes Off on every channel and resets the port.
    + opl (synthesizer) The OPL2 or OPL3 FM chip of an AdLib or Sound Blaster at 388h (oplPort), found by the AdLib timer test (timer 1 must overflow at its fastest count and not at its slowest, which also tells the AdLib-compatible timers of a Gravis UltraSound, which has no FM, from a chip); the status register tells an OPL2 from an OPL3. Register writes are timed by status reads (3.3 and 23 us on an OPL2) with interrupts off around each index/data pair. Closing zeroes every register. + awe (synthesizer) The EMU8000 of a Sound Blaster AWE32 or AWE64 (BLASTER's E, else A + 400h), set up as Creative's programmer's guide gives: 30 voices (two carry the FM chip's sound), its memory in 512 KB steps, envelopes, LFOs, filter, reverb and chorus in hardware, and its 1 MB General MIDI sample ROM. + gus, guswt (synthesizer) The Gravis UltraSound's voices playing samples from its memory, with the library running the envelopes and LFOs: gus.drv's own when it plays the digital sound too, else guswt.drv (the GUS beside another sound device, all its voices for music). + mpu401 (synthesizer) An MPU-401 MIDI port in UART mode at mpuPort, only with midi or midiIn = mpu401: reset (FFh, answered FEh; tried twice, since a port left in UART mode takes the first FFh as data), then UART mode (3Fh). Bytes are sent when the port says it is ready (a short wait, else the caller sends later); input the port has waiting then is kept for get, the MIDI input's timer (64 bytes; more is dropped while nobody reads it). Closing sends All Notes Off on every channel and resets the port.

    audioDriverNames(names, max) lists the short names of the sound drivers installed, the one tried first first (for a settings screen; AUDIO_DRIVERS_MAX is the most DVX's own callers ask for).

    -

    Sound drivers are tried in this order: the one DVX.INI names, then from the highest priority down (Sound Blaster, PC speaker, null). The first that starts is used. Synthesizer drivers are opened with the sound device, beside it: of each kind (FM chip, MIDI port) the first that finds its hardware is kept, and they close with the device. The sound device's description and hasSynth in AudioInfoT tell whether there is a synthesizer.

    +

    Sound drivers are tried in this order: the one DVX.INI names, then from the highest priority down (Windows Sound System, Sound Blaster, Gravis UltraSound, parallel port DAC, PC speaker, null). The first that starts is used. Synthesizer drivers are opened with the sound device, beside it: the sound device's own first (the GUS's), then of each kind (FM chip, MIDI port, wavetable) the first that finds its hardware, and they close with the device. The sound device's description and hasSynth in AudioInfoT tell whether there is a synthesizer.

    Writing a Driver

    -

    A driver is a driver module (see dvxDrv.h in the API reference): <name>.drv in bin/libs/kpunch/audio/drivers, built by the audio Makefile from each drivers/<name>/ folder, that exports one AudioDriverT named audioDriver, written with AUDIO_DRIVER(name, AUDIO_PRIORITY_*, .open = ..., .play = ..., .close = ...). The descriptor carries DVX_ABI_VERSION; a driver built for another version is refused. audio.lib loads the drivers when the first program opens sound, keeps the one that starts (and the synthesizers that find their hardware) and unloads the rest. open detects the hardware and fills AudioDrvInfoT (description, achieved rate, bits, channels, hasDac, hasSynth, a hardware tone function) without making a sound; play starts output; close stops it and releases everything, after which the mixer is not called again. After a few seconds of silence audio.lib calls close and later open and play again on the same driver, so open must work more than once and must not make a sound (the driver's module stays loaded in between). While playing, the driver calls audioMixRender(buffer, frames) for each block of output from its own interrupt, or calls audioClockStart to have the timer service drive the mixer (the output is thrown away) when it only plays tones or nothing. Code and data an interrupt touches must be pinned: audioLockSpan pins a module's listed functions and data (a module's code comes before its data, so one span covers them), and the library is built without hot/cold function splitting and without loops turned into library calls. Interrupt code must not call the C library, DVX, or DOS and must not use floating point. audio.lib stops the device from a shutdown hook if DVX ends without closing it.

    -

    A synthesizer driver exports an AudioSynthDriverT named audioSynth instead, written with AUDIO_SYNTH_DRIVER(name, priority, .kind = AUDIO_SYNTH_FM or AUDIO_SYNTH_MIDI, .open = ..., .close = ..., .write = ... or .put = ...). open detects the hardware, fills AudioSynthDrvInfoT (description; for FM the voices and whether it is stereo) and leaves it silent; close silences it. write(reg, value) sets an FM register (bank x AUDIO_FM_BANK_REGS + index) and must keep the index/data pair together (audioIrqOff / audioIrqRestore); put(byte) sends a MIDI byte, returning false when the port is not ready. Both run at interrupt time too (timer callbacks), so they follow the rules above and are pinned by the driver.

    +

    A driver is a driver module (see dvxDrv.h in the API reference): <name>.drv in bin/libs/kpunch/audio/drivers, built by the audio Makefile from each drivers/<name>/ folder, that exports one AudioDriverT named audioDriver, written with AUDIO_DRIVER(name, AUDIO_PRIORITY_*, .open = ..., .play = ..., .close = ...). The descriptor carries DVX_ABI_VERSION; a driver built for another version is refused. audio.lib loads the drivers when the first program opens sound, keeps the one that starts (and the synthesizers that find their hardware) and unloads the rest. open detects the hardware and fills AudioDrvInfoT (description, achieved rate, bits, channels, hasDac, hasSynth, a hardware tone function) without making a sound; play starts output; close stops it and releases everything, after which the mixer is not called again. After a few seconds of silence audio.lib calls close and later open and play again on the same driver, so open must work more than once and must not make a sound (the driver's module stays loaded in between). A driver whose card records also sets AudioDrvInfoT.recSources and gives recordStart(want, source, got) and recordStop: recordStart is called with the output closed and takes the card for recording (its own DMA buffer and interrupt; the format the card records in got), its interrupt then calls audioRecPush(src, bytes) with each block recorded (whole frames; a block that does not fit is dropped and counted), and recordStop gives everything back as close does (sb.drv, wss.drv, and gus.drv use their close for both; the AD1848 codec's routines a Windows Sound System board and an UltraSound MAX share are codec/codecHw.c). While playing, the driver calls audioMixRender(buffer, frames) for each block of output from its own interrupt, or calls audioClockStart to have the timer service drive the mixer (the output is thrown away) when it only plays tones or nothing. Code and data an interrupt touches must be pinned: audioLockSpan pins a module's listed functions and data (a module's code comes before its data, so one span covers them), and the library is built without hot/cold function splitting and without loops turned into library calls. Interrupt code must not call the C library, DVX, or DOS and must not use floating point. audio.lib stops the device from a shutdown hook if DVX ends without closing it.

    +

    A synthesizer driver exports an AudioSynthDriverT named audioSynth instead, written with AUDIO_SYNTH_DRIVER(name, priority, .kind = AUDIO_SYNTH_FM, AUDIO_SYNTH_MIDI, or AUDIO_SYNTH_WAVETABLE, .open = ..., .close = ..., then .write, .put, or the wavetable functions). open detects the hardware, fills AudioSynthDrvInfoT (description; for FM the voices and whether it is stereo; for a wavetable synthesizer its voices, memory, AUDIO_WT_* flags, and level, 0 when it is as loud as the digital sound or not known) and leaves it silent; close silences it. write(reg, value) sets an FM register (bank x AUDIO_FM_BANK_REGS + index) and must keep the index/data pair together (audioIrqOff / audioIrqRestore); put(byte) sends a MIDI byte, returning false when the port is not ready; a MIDI driver whose port receives also has get(&byte), the next byte the port has (false when none), which the MIDI input's timer calls (mpu401.drv keeps what put finds waiting for get). A wavetable driver has upload(addr, frames, count, bits) (program time; addresses as audioWtMemAlloc gives them) and start, update, release, stop, and active for its voices (numbered from 0, as audio.lib hands them out); it plays a note as AudioWtVoiceT gives it, in its own units. write, put, and the voice functions run at interrupt time too (timer callbacks), so they follow the rules above and are pinned by the driver. A sound driver whose card also has a synthesizer (the GUS) points AudioDrvInfoT.synth at its own AudioSynthDriverT, which is opened before the others.

    BASIC

    src/include/basic/sound.bas (DECLARE LIBRARY "audio"): PlayWave(fileName), PlayWaveLoop(fileName), PlayTone(frequency, milliseconds, volume), PlayNoise(frequency, milliseconds, volume) return a voice number (0 on failure: SoundError$ says why); StopWave(voice), StopAllSounds, SoundPlaying(voice), SoundVolume(percent), SoundInfo$ (the device, e.g. "Sound Blaster 16 (DSP 4.05) at 220h, 22050 Hz, 16-bit stereo"). Volumes are percent. Each BASIC program plays through its own context, closed when it ends.

    -

    For a settings program (the Control Panel's Sounds page): SoundEventCount, SoundEventLabel$(eventIdx), SoundEventKey$(eventIdx) (audioDeskLabel / audioDeskKey; events numbered from 0 in DvxSoundE order); SoundEventFile$(prefs, eventIdx) and SoundSetEventFile(prefs, eventIdx, fileName) (audioDeskFile / audioDeskSetFile); SoundEventsReload (audioDeskReload); SoundTestFile(fileName) (audioDeskPlayFile, a name alone found in the sound folders as the events' files are: a voice number, or 0 with SoundError$); SoundFolder$ (SHARED\SOUNDS, the sounds DVX ships; FileOpsKindDir$ and FileOpsKindList give the user's too); SoundMasterVolume and SoundSetMasterVolume(percent) (audioGetMasterVolume / audioSetMasterVolume in percent); SoundDriverCount (reads audioDriverNames, at most AUDIO_DRIVERS_MAX) and SoundDriverName$(index); SoundDriverAuto$ (AUDIO_DRIVER_AUTO); SoundMidiCount and SoundMidiLabel$(index) (audioMidiChoices); SoundSettingDriver$(prefs), SoundSettingMidi(prefs), SoundSettingVolume(prefs), and SoundSettingsStore(prefs, driver, midi, volume) (the audioSetting functions). prefs is a prefs.bas handle (prefsBasHandle, dvxPrefs.h); a handle that is not open reads the defaults and stores nothing.

    -

    The rest of audio.h, in percent: PlayTriangle (audioTone with AUDIO_WAVE_TRIANGLE); SoundGetVolume (audioGetVolume); SoundCaps(field) (audioGetInfo: "dac", "synth", "tone", "running" True / False; "voices" = voiceLimit, "rate", "bits", "channels"; any case, 0 for another field); SoundPlayEvent(eventIdx, wait) (dvxPlaySound / dvxPlaySoundWait, so the hook audioDeskPlay plays it; CONSTs SOUND_EVENT_* in DvxSoundE order). Samples: SoundLoad(fileName) (audioSampleLoad) and SoundCreate(pcm AS BINARY STRING, rate, bits, channels) (audioSampleCreate; frames = bytes / frame size, a trailing partial frame ignored) return a sound number; SoundFree (audioSampleFree), SoundLength (audioSampleInfo, in milliseconds), SoundPlay(sound, volume, pan, loopIt) (audioPlay; pan -100..100 scaled to AUDIO_PAN_LEFT..AUDIO_PAN_RIGHT). Voices: SoundSetVolume, SoundSetPan, SoundSetPitch(voice, percent) (25..400 percent of AUDIO_PITCH_NORMAL, clamped), and SoundSetFrequency(voice, hz) (the audioVoiceSet functions; the result is kept for SoundError$). Streams (push only): SoundStreamOpen(rate, bits, channels, bufferMs) (audioStreamOpen without a fill function), SoundStreamWrite(stream, pcm AS BINARY STRING) (audioStreamWrite of the whole frames; returns the bytes taken), SoundStreamSpace / SoundStreamQueued (frames), SoundStreamVoice, SoundStreamClose. FM: FmInfo$(field) (audioFmGetInfo: "description", "voices", "free", "stereo", "owned" as text), FmAlloc, FmWrite(voice, reg, value) (True / False), FmFree, FmRawAcquire, FmRawWrite(reg, value), FmRawRelease, with CONSTs FM_* for the AUDIO_FM_* values and FM_BANK_REGS (AUDIO_FM_BANK_REGS). The MIDI port, declared in music.bas: MidiPortInfo$(field) (audioMidiGetInfo: "description", "present", "owned"), MidiAcquire, MidiPut(byteValue) (False on AUDIO_ERR_BUSY: send it again), MidiRelease. CONSTs SOUND_PAN_*, SOUND_PITCH_* (percent), SOUND_RATE_* (AUDIO_RATE_*), and SOUND_STREAM_MS_* (AUDIO_STREAM_MS_*).

    +

    For a settings program (the Control Panel's Sounds page): SoundEventCount, SoundEventLabel$(eventIdx), SoundEventKey$(eventIdx) (audioDeskLabel / audioDeskKey; events numbered from 0 in DvxSoundE order); SoundEventFile$(prefs, eventIdx) and SoundSetEventFile(prefs, eventIdx, fileName) (audioDeskFile / audioDeskSetFile); SoundEventsReload (audioDeskReload); SoundTestFile(fileName) (audioDeskPlayFile, a name alone found in the sound folders as the events' files are: a voice number, or 0 with SoundError$); SoundFolder$ (SHARED\SOUNDS, the sounds DVX ships; FileOpsKindDir$ and FileOpsKindList give the user's too); SoundMasterVolume and SoundSetMasterVolume(percent) (audioGetMasterVolume / audioSetMasterVolume in percent); SoundDriverCount (reads audioDriverNames, at most AUDIO_DRIVERS_MAX) and SoundDriverName$(index); SoundDriverAuto$ (AUDIO_DRIVER_AUTO); SoundMidiCount and SoundMidiLabel$(index) (audioMidiChoices); SoundMidiInCount and SoundMidiInLabel$(index) (audioMidiInChoices); SoundModulesCount and SoundModulesLabel$(index) (audioModulesChoices); SoundSettingDriver$(prefs), SoundSettingMidi(prefs), SoundSettingMidiIn(prefs), SoundSettingModules(prefs), SoundSettingVolume(prefs), SoundSettingsStore(prefs, driver, midi, modules, volume), and SoundSettingMidiInStore(prefs, midiIn) (the audioSetting functions). prefs is a prefs.bas handle (prefsBasHandle, dvxPrefs.h); a handle that is not open reads the defaults and stores nothing.

    +

    The rest of audio.h, in percent: PlayTriangle (audioTone with AUDIO_WAVE_TRIANGLE); SoundGetVolume (audioGetVolume); SoundCaps(field) (audioGetInfo: "dac", "synth", "tone", "running", "record" True / False; "voices" = voiceLimit, "rate", "bits", "channels", "recordsources"; any case, 0 for another field); SoundPlayEvent(eventIdx, wait) (dvxPlaySound / dvxPlaySoundWait, so the hook audioDeskPlay plays it; CONSTs SOUND_EVENT_* in DvxSoundE order). Samples: SoundLoad(fileName) (audioSampleLoad) and SoundCreate(pcm AS BINARY STRING, rate, bits, channels) (audioSampleCreate; frames = bytes / frame size, a trailing partial frame ignored) return a sound number; SoundFree (audioSampleFree), SoundLength (audioSampleInfo, in milliseconds), SoundPlay(sound, volume, pan, loopIt) (audioPlay; pan -100..100 scaled to AUDIO_PAN_LEFT..AUDIO_PAN_RIGHT). Voices: SoundSetVolume, SoundSetPan, SoundSetPitch(voice, percent) (25..400 percent of AUDIO_PITCH_NORMAL, clamped), and SoundSetFrequency(voice, hz) (the audioVoiceSet functions), SoundPause(voice) and SoundResume(voice) (audioVoicePause; a sound's or a stream's voice); the result is kept for SoundError$. Streams (push only): SoundStreamOpen(rate, bits, channels, bufferMs) (audioStreamOpen without a fill function), SoundStreamWrite(stream, pcm AS BINARY STRING) (audioStreamWrite of the whole frames; returns the bytes taken), SoundStreamSpace / SoundStreamQueued (frames), SoundStreamVoice, SoundStreamFlush (audioStreamFlush), SoundStreamClose. Recording, one per program (audioRecFile): RecordStart(fileName, rate, bits, channels, inputSource) (True / False with SoundError$; 0 for the defaults), RecordStop (audioRecClose; True when the file was all written), Recording, RecordLength (milliseconds recorded, written or waiting), RecordLevel (audioRecPeak), RecordLost, RecordRate, RecordBits, RecordChannels (audioRecFormat), with CONSTs RECORD_MIC, RECORD_LINE, RECORD_CD (AUDIO_REC_*) and SoundCaps "record" and "recordsources". FM: FmInfo$(field) (audioFmGetInfo: "description", "voices", "free", "stereo", "owned" as text), FmAlloc, FmWrite(voice, reg, value) (True / False), FmFree, FmRawAcquire, FmRawWrite(reg, value), FmRawRelease, with CONSTs FM_* for the AUDIO_FM_* values and FM_BANK_REGS (AUDIO_FM_BANK_REGS). Wavetable: WtInfo$(field) (audioWtGetInfo: "description", "voices", "free", "memory", "freememory", "level", and the flags "envelopes", "lfos", "filter", "effects", "8bit", "rom" as text), WtMemAlloc(bytes, bits) (an address, -1 for no room), WtUpload(address, pcm AS BINARY STRING, bits) (16-bit mono samples), WtMemFree, WtAlloc, WtStart(voice, address, startFrame, endFrame, loopStart, loopEnd, loopMode, bits, pitch, attenuation, pan) (a plain note: no envelope or LFO, the filter open; pitch in cents, attenuation in centibels, pan -100..100 scaled to AUDIO_WT_PAN_MAX), WtSetPitch, WtSetAttenuation, WtSetPan (audioWtUpdate of the note kept for the voice; True / False), WtRelease, WtStop, WtActive, WtFree. The MIDI port, declared in music.bas: MidiPortInfo$(field) (audioMidiGetInfo: "description", "present", "owned", "input", "inputowned"), MidiAcquire, MidiPut(byteValue) (False on AUDIO_ERR_BUSY: send it again), MidiRelease; its input: MidiInOpen (True / False with SoundError$), MidiInClose, MidiInMessage (audioMidiInRead: the message packed as status + data1 * 256 + data2 * 65536, AUDIO_MIDI_PACKED_*, or MIDI_NONE, -1), MidiInTime (the ms of the message MidiInMessage gave last), MidiInClock (audioMidiInClock), MidiInLost, with CONSTs MIDI_NOTE_OFF to MIDI_PITCH_BEND, MIDI_KIND_MASK, and MIDI_CHANNEL_MASK. CONSTs SOUND_PAN_*, SOUND_PITCH_* (percent), SOUND_RATE_* (AUDIO_RATE_*), and SOUND_STREAM_MS_* (AUDIO_STREAM_MS_*).

    Sound and stream numbers are 1..64 per kind, valid only for the program that got them; audioClose of a BASIC context forgets its numbers (audioBasForget) as it frees the samples and streams. BINARY STRING parameters arrive as a pointer and a byte count (two C parameters; see the DECLARE LIBRARY rules in the BASIC reference).

    @@ -7217,7 +7396,7 @@ audioFmFree(audio, v); // key off, back to everyoneThe project (.dbp) model: PrjStateT with its file list (PrjFileT: path relative to the project's folder, kind -- PrjFileKindE: module, class module, UserControl ([Controls], .ctl), or form, in compile order -- edit buffer) and settings, prjLoad / prjSave (one table of [Project] / [Settings] keys drives load, save, prjFieldGet, and prjFieldSet; [Project] Type = Control makes a control project, prjIsControlProject), prjNew, prjAddFile, prjRemoveFile, prjRenameFile (disk and project together), prjFileIndexByPath, prjKindForPath (the build's rule, basBuildWrapForPath), prjKindWrap, prjClassName (a class module's or UserControl's class name), prjReadFileText, the fonts the program carries ([Fonts]: prjAddFont, prjRemoveFont, prjFontCount, prjFontPath; the build makes them FONTn font resources, basBuildSpecT fonts), and the source map of the last compile (prjMapLine, prjMapFileLine). The IDE keeps only its Project window and Properties dialog (ideProject.c); compiler.bas has ProjOpen, ProjNew, ProjSave, ProjClose, ProjPath$, ProjFileCount, ProjFile$, ProjFilePath$, ProjFileKind, ProjFileFind, ProjAddFile, ProjRemoveFile, ProjRenameFile, ProjFontCount, ProjFont$, ProjAddFont, ProjRemoveFont, ProjGet$, and ProjSet; CompKeywordClass and CompKeywordReserved expose the keyword table's flags.

    A UserControl's header (the Begin UserControl block of a .ctl) is read and changed key by key, every other line of the text kept: prjCtlKeyAt / prjCtlKeyFind give the keys (PrjCtlKeyT: name and PrjCtlValueE kind -- Name, the control's name on the Begin line, then Width, Height, CanGetFocus, BackColor, ToolboxIcon, Description, DefaultEvent), prjCtlHeaderGet the value the control takes (its default when the header leaves the key out), prjCtlHeaderSet a malloc'd copy of the text with one key changed, added before End, or removed (an empty value), written as its kind is (a text in quotes, a color &HBBGGRR&, True or False), and prjCtlNew the text of a new UserControl (its header with Width and Height, Option Explicit, an empty UserControl_Paint written with PRJ_HANDLER_FMT and PRJ_PRIVATE, the form the IDE writes event handlers in). The IDE's File > New UserControl and Properties window use them; compiler.bas has ProjCtlNew$, ProjCtlHeader$, ProjCtlSetHeader$, and ProjCtlKeys$ (the keys and their kinds, prjCtlKeyAt order).

    Running a Program (basRunHost.h, in basrt.lib)

    -

    basRunHostCreate makes the VM and form runtime for a module from a BasRunSpecT: App.Path, App.User (from the program's file: its folder in the user folder), App.Data (the DATA folder beside it), COMMAND$, the print, input, and DoEvents callbacks, the screen window's name, the help file, and whether runtime errors show their own box. basRunHostRegisterFrm registers a .frm text, basRunHostLoadForms loads the forms and shows the startup form, basRunHostSlice runs module code once under the step limit (an error's message and line are always set afterwards), basRunHostAlive says whether the event-loop phase goes on, basRunHostTagBreak puts BAS_RUN_BREAK_TAG on the captions while paused, and basRunHostDestroy ends it. The runtime made inside another program's code does not become the current runtime (basFormRtCurrent / basFormRtSetCurrent); each of these calls makes it current while its code runs.

    +

    basRunHostCreate makes the VM and form runtime for a module from a BasRunSpecT: App.Path, App.User (from the program's file: its folder in the user folder), App.Data (the DATA folder beside it), COMMAND$, the print, input, and DoEvents callbacks, the screen window's name, the help file, whether runtime errors show their own box, and whether it is a standalone program (the stub's own: the runtime owns the module, frees it with itself, and is ended through the shell's kill hook, shellRegisterAppKill, when the program is ended by force; the IDE and basdev end their runtimes themselves). basRunHostRegisterFrm registers a .frm text, basRunHostLoadForms loads the forms and shows the startup form, basRunHostSlice runs module code once under the step limit (an error's message and line are always set afterwards), basRunHostAlive says whether the event-loop phase goes on, basRunHostTagBreak puts BAS_RUN_BREAK_TAG on the captions while paused, and basRunHostDestroy ends it. The runtime made inside another program's code does not become the current runtime (basFormRtCurrent / basFormRtSetCurrent); each of these calls makes it current while its code runs.

    VM accessors a debugger host uses (vm.h): basVmSetAppPaths, basVmSetBreakpointCallback, basVmSetDebugPaused, basVmResume, basVmStop, basVmClearStepState, basVmGetCurrentLine, basVmGetErrorLine.

    From BASIC

    compiler.bas and debug.bas declare the entry points (see Include Libraries in the BASIC language reference). Source sets, modules, and runs are handles freed with their program. A run is polled: RunPoll runs the child for a bounded time and never waits. A pause in the child's module code returns RUN_PAUSED; a pause inside one of its event handlers (or its forms' Load code, inside RunStart) waits in place while the child pumps DVX events, so the caller's Timer and menus can step, continue, or stop it; RunPoll called from there returns at once. RunFree while the child's code is on the stack stops it at once and releases it at the next library call after that code has unwound.

    @@ -8039,6 +8218,7 @@ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

    System Information

    Installed Programs

    Running Programs and Documents

    +

    The Screen Saver

    File Associations

    BASIC Entry Points

    BASIC System Settings

    @@ -8117,7 +8297,8 @@ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. shutdownFn void (*)(void) Pointer to appShutdown (may be NULL). dxeCtx DxeAppContextT * Heap-allocated context (address stable across realloc). taskCodeDepth int32_t * Per task id: SHELL_APP_CALL calls into this app still open on that task's stack. - killPending bool A force kill was deferred; shellReapApps finishes it. + killPending bool A force kill was deferred; shellReapApps finishes it. + faulted bool It crashed: its force kill runs no kill hooks (shellRegisterAppKill).

    App Lifecycle API

    @@ -8141,7 +8322,7 @@ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

    Gracefully shut down a single app. Calls the app's shutdownFn (if present), destroys all windows belonging to the app, kills its task (if any), releases any widget classes and system-wide hotkeys the app registered and did not unregister (wgtUnregisterAppClasses, dvxUnregisterAppHotkeys), closes the DXE handle, and frees the context. The slot returns to AppStateFreeE.

    shellForceKillApp

    void shellForceKillApp(AppContextT *ctx, ShellAppT *app);
    -

    Forcibly kill an app. Used by the Task Manager "End Task" or as part of crash recovery. The app's shutdownFn is NOT called: a force kill targets a hung or faulting app whose own code cannot be trusted to run. Instead the shell drops every idle and desktop-update callback the app registered, destroys all of the app's windows, kills its task (for main-loop apps), and finally closes the DXE handle.

    +

    Forcibly kill an app. Used by the Task Manager "End Task" or as part of crash recovery (which marks the app faulted first). The app's shutdownFn is NOT called: a force kill targets a hung or faulting app whose own code cannot be trusted to run. Instead the libraries' kill hooks (shellRegisterAppKill) end what they run for the app, unless it faulted, then the shell drops every idle and desktop-update callback the app registered, destroys all of the app's windows, kills its task (for main-loop apps), and finally closes the DXE handle.

    Cleanup order: callbacks and windows first (removes them from the compositor), then the task (frees its stack; any dispatch frames still open on it are dropped from the context's dispatch depth), then the widget classes and system-wide hotkeys the app registered (its shutdownFn never got the chance to unregister them), then the DXE handle (unmaps the code). Closing the DXE before destroying windows would cause callbacks into unmapped code.

    If the app's code may still be running underneath the call -- End Task clicked in a dialog the app has open, a kill issued from the app's own task, or from inside a shell call into the app -- the unload is deferred: the app's windows are hidden and queued for destruction, the slot is marked Terminating, and the shell finishes the kill (still without shutdownFn) at the next safe point of its main loop, once that code has returned. Event-handler frames on the app's own task do not hold the kill back, since they end with the task: an app stuck in an endless event handler is killed at once (or at the next main-loop pass).

    shellOpenHelp

    @@ -8259,6 +8440,10 @@ shellUserPath(ctx, "PROGMAN.INI", path, sizeof(path)); void shellRegisterAppCleanup(ShellAppCleanupFnT fn); void shellUnregisterAppCleanup(ShellAppCleanupFnT fn);

    Register a function the shell calls with an app's id whenever that app ends -- normally, force-killed, or after a crash -- once its windows are gone, next to the shell's own release of the app's widget classes and hotkeys. A library uses it to release what the app left open with it: the networking library closes the app's sockets this way. For library code that stays loaded; registering the same function again has no effect, and unregistering one that is not registered does nothing.

    +

    shellRegisterAppKill

    +
    void shellRegisterAppKill(ShellAppCleanupFnT fn);
    +void shellUnregisterAppKill(ShellAppCleanupFnT fn);
    +

    Register a function the shell calls with an app's id when the app is ended by force (End Task, a screen saver that would not close, DVX exiting) while none of the app's code is running and its windows are still there -- before the windows go and before the cleanup hooks. A library ends what it runs for the app the way the app's own end would: the BASIC runtime (basrt) frees a standalone program's VM, forms, screen, and module this way, closing its serial ports and its forms' windows, so End Task on a BASIC program leaks nothing. Not called for an app that faulted (its state cannot be trusted), nor for one whose kill had to wait until its code returned (End Task from inside the app's own dialog: its windows went at once). Registration rules as for cleanup hooks.

    shellDesktopUpdate

    void shellDesktopUpdate(void);

    Notify all registered desktop callbacks that app state has changed. Called internally by the shell after loading or reaping an app. Can also be called by apps that need to trigger a desktop refresh. Each call also adds one to the change counter (shellDesktopChangeCount).

    @@ -8323,7 +8508,13 @@ bool shellIsProgram(const char *path);

    One way for every program to start another (Program Manager's items and Run..., the Task Manager's Run..., File Manager, setup programs). A command line is a program, in quotes if it contains blanks, followed by its arguments; it is split with dvxNextArg, the same rule a program uses to read its own arguments. A document in place of a program opens in the program associated with its file type (see File Associations).

    shellRunProgram

    int32_t shellRunProgram(AppContextT *ctx, const char *program, const char *args, const char *folder);
    -

    Start program with args. A .app is loaded with shellLoadAppWithArgs, its arguments first passed through shellResolveArgs(folder, ...). Anything else is a document: it is looked for in folder first (when relative), must exist, and is opened with shellAssocCommand's program and arguments. Problems are shown to the user in an error box (a missing document, a document type with no association; load errors come from shellLoadApp as usual). Returns the new application id, or -1.

    +

    Start program with args. A .app is loaded with shellLoadAppWithArgs, its arguments first passed through shellResolveArgs(folder, ...). A DOS program (shellIsDosProgram: .EXE, .COM, or .BAT, any case) is set to run with shellRunDos (folder is where it runs; looked for in folder first when relative). Anything else is a document: it is looked for in folder first (when relative), must exist, and is opened with shellAssocCommand's program and arguments; when that program is a DOS program, the document goes to it by its full short name (it runs in its own folder) through shellRunDos. Problems are shown to the user in an error box (a missing document, a document type with no association; load errors come from shellLoadApp as usual). Returns the new application id, SHELL_RUN_DOS (0) for a DOS program set to run, or -1.

    +

    shellRunDos / shellRunDosRequest

    +
    void shellRunDos(const char *program, const char *args, const char *folder, bool pause);
    +void shellRunDosRequest(AppContextT *ctx, int32_t keepAppId);
    +bool shellIsDosProgram(const char *path);
    +

    Run a DOS program with DVX stepped aside (libdvx's dvxRunDos, which takes the same program, args, folder, and pause). shellRunDos only records the request (a newer one replaces one still waiting): closing the other programs pumps the event loop, which is not safe from an event handler. The main loop calls shellRunDosRequest after shellRunExitRequest; keepAppId is the desktop, which stays. A program named with a folder must exist ("Cannot find X.", titled Run DOS Program); a bare name is looked for on the PATH by DOS. When programs other than the desktop are running, a question names them (by their program names, shellProgramInfo) and says each asks about unsaved work as it closes; No, or a program that keeps a window (shellCloseAllApps), cancels the run. A program that cannot be started is reported after DVX is back ("DVX could not run X." with the reason). The Task Manager is the shell's own window and is not asked. Program Manager's DOS items call shellRunDos with their "Wait for a key" setting (PmItemT.pause, PROGMAN.INI itemN.pause); shellRunProgram uses pause false.

    +

    SHELL_DOS_FILTER ("DOS programs (*.exe;*.com;*.bat)") is the file dialogs' filter for them (Run..., Program Manager's Properties, Control Panel's File Types). A Program Manager item for a DOS program with no icon file of its own shows SHELL_DOS_ICON (SYSTEM\DOSPROG.BMP, from config/DOSPROG.BMP).

    shellRunCommand

    int32_t shellRunCommand(AppContextT *ctx, const char *command);

    shellSplitCommand, then shellRunProgram with no working folder. Returns the application id or -1 (also for an empty command).

    @@ -8341,6 +8532,33 @@ bool shellIsProgram(const char *path); void shellMinimizeIdle(AppContextT *ctx);

    Run Minimized. shellMinimizeApp minimizes every visible window of appId (an id from shellRunProgram) at once and again on each of the next SHELL_MINIMIZE_FRAMES (60) idle frames, because a program with a main loop opens its windows from its own task after it has started. Asking again for the same program starts its frames over; nothing happens for an id that is not running, and a program that ends is forgotten (an app-cleanup hook). shellMinimizeIdle is the per-frame step, called by the shell's idle dispatch. Program Manager's Run Minimized items and BASIC's ShellMinimizeApp use it.

    +
    +

    The Screen Saver

    +

    Header: dvxshell/shellSaver.h

    +

    A screen saver is a program (.app, C or BASIC) in a SAVERS folder (DvxKindSaversE: SHARED\SAVERS, which DVX and add-ons fill, and the user's SAVERS); Program Manager does not list it. After the wait with no key or mouse input (dvxIdleMs) the shell starts the chosen one with SHELL_SAVER_ARGS ("/S"). It takes the whole screen (dvxExclusiveBegin; BASIC SCREEN and FULLSCREEN ON) and draws until its window is closed; it needs no input handling of its own, since the input that wakes the screen goes to it (full-screen mode), never to the program underneath. Input SHELL_SAVER_GRACE_MS (1,000 ms) or more after it started asks each of its windows to close (dvxRequestWindowClose, as Alt+F4 does); one still running SHELL_SAVER_END_MS (3,000 ms) later is ended (shellForceKillApp). The log says "Shell: screen saver X started" and "... ended".

    +

    The saver does not start while a dialog box is open (ctx->modalWindow) or a program has the full screen (ctx->exclusive.owner: a game played with a joystick gives no key or mouse input). After a saver ends (or fails to start: missing, or not a program, logged and never shown in a message box) the next wait counts from then.

    +

    DVX.INI [desktop] saver is the saver's name in SAVERS ("" or missing: none) and saverWait the minutes (SHELL_SAVER_WAIT_MIN to SHELL_SAVER_WAIT_MAX, 1 to 60; SHELL_SAVER_WAIT_DEFAULT, 10, when missing). The shell reads them at start; Control Panel's Desktop page sets them live and stores them with OK.

    +
    void        shellSaverSet(const char *name, int32_t minutes);
    +const char *shellSaverName(void);
    +int32_t     shellSaverWait(void);
    +bool        shellSaverStart(void);
    +bool        shellSaverRunning(void);
    +void        shellSaverStep(AppContextT *ctx);
    +void        shellSaverFromPrefs(PrefsHandleT *h);
    +void        shellSaverToPrefs(PrefsHandleT *h);
    +
      Function              What it does
    +  --------              ------------
    +  shellSaverSet         Choose the saver (a file in SAVERS is kept by its name there, platformKindNameOf; any other path as it is; "" or NULL: none) and the wait, clamped to the range; at once
    +  shellSaverName / Wait The settings in force
    +  shellSaverStart       Start the saver now (Control Panel's Test); only recorded, the next shellSaverStep starts it.  False with none chosen or one running
    +  shellSaverRunning     True while a saver runs
    +  shellSaverStep        The main loop's step (shell top level): starts the saver when due, ends a running one at input
    +  shellSaverFromPrefs   Read [desktop] saver and saverWait (start, Control Panel's Cancel)
    +  shellSaverToPrefs     Write them (Control Panel's OK and its Cancel snapshot)
    +

    BASIC: SysScreenSaver$, SysScreenSaverWait, SysSetScreenSaver, SysScreenSaverStart, SysScreenSaverRunning, SysScreenSaverStore, SysScreenSaverLoad, and SysIdleSeconds (system.bas; see BASIC System Settings).

    +

    Writing a screen saver

    +

    A C saver is a main-loop program that opens a window, takes the full screen with dvxExclusiveBegin, and draws (dvxExclusivePresent8, paced with gameFrameWait) until the window's onClose sets a flag; then it destroys the window and returns (src/savers/kpunch/starfld). A BASIC saver is a program with no form: SCREEN 13, FULLSCREEN ON, and a loop drawing with FRAMEWAIT (src/savers/kpunch/blank, marquee, mystify); closing its screen window ends it. Put the .app in SHARED\SAVERS (an add-on package's kind saver) or the user's SAVERS; the name Control Panel lists is the program's (its "name" resource, a BASIC project's Name).

    +

    File Associations

    Header: dvxshell/shellAssoc.h

    @@ -8372,10 +8590,11 @@ bool shellAssocNormalize(const char *extension, char *out);
    int32_t     ShellRun(const char *program, const char *args);
     int32_t     ShellRunProgram(const char *program, const char *args, const char *folder);
     int32_t     ShellRunCommand(const char *command);
    +void        ShellRunDos(const char *program, const char *args, const char *folder, int32_t pause);
     const char *ShellRunDialog(const char *history);
     void        ShellMinimizeApp(int32_t appId);
     int32_t     ShellOpenDocument(const char *path);
    -

    ShellRunProgram is shellRunProgram (a document opens through its association; file names in args, and a relative document, are looked for in folder; "" for none), returning the new app id or 0. ShellRun is ShellRunProgram without a folder, True when it started; the two are one launch path. ShellRunCommand is shellRunCommand (id or 0). ShellRunDialog splits history at line ends (CHR$(10), a CR before it is dropped, empty lines skipped) into the list shellRunDialog shows, and returns the command chosen or "" when cancelled. ShellMinimizeApp is shellMinimizeApp. ShellOpenDocument is shellAssocOpenDocument (it shows nothing when there is no association; ShellRunProgram does).

    +

    ShellRunProgram is shellRunProgram (a document opens through its association; file names in args, and a relative document, are looked for in folder; "" for none), returning the new app id, -1 (shell.bas SHELL_RUN_DOS) for a DOS program set to run, or 0. ShellRun is ShellRunProgram without a folder, True when it started or a DOS program will run; the two are one launch path. ShellRunCommand is shellRunCommand (id, -1, or 0). ShellRunDos is shellRunDos (pause True or False): the DOS program runs once the calling procedure has returned and the programs besides the desktop, the caller too, have closed. ShellRunDialog splits history at line ends (CHR$(10), a CR before it is dropped, empty lines skipped) into the list shellRunDialog shows, and returns the command chosen or "" when cancelled. ShellMinimizeApp is shellMinimizeApp. ShellOpenDocument is shellAssocOpenDocument (it shows nothing when there is no association; ShellRunProgram does).

    Installed programs

    int32_t     ShellProgramScan(const char *folder, const char *skipPath);
     int32_t     ShellProgramCount(int32_t list);
    @@ -8383,8 +8602,9 @@ const char *ShellProgramPath(int32_t list, int32_t index);
     void        ShellProgramFree(int32_t list);
     const char *ShellProgramName(const char *path);
     const char *ShellProgramDescription(const char *path);
    -int32_t     ShellIsProgram(const char *path);
    -

    ShellProgramScan is shellProgramScan of folder ("" for SHELL_PROG_DIR) without skipPath ("" for none), paths only (readInfo false), so it never opens a program; it returns a list handle, 0 when nothing was found. ShellProgramCount and ShellProgramPath read it (0 / "" for a bad list or index); ShellProgramFree frees it. ShellProgramName and ShellProgramDescription are shellProgramInfo's name and description, read one program at a time so a long list can be filled with DoEvents in between (the C progress callback). ShellIsProgram is shellIsProgram.

    +int32_t ShellIsProgram(const char *path); +int32_t ShellIsDosProgram(const char *path); +

    ShellProgramScan is shellProgramScan of folder ("" for SHELL_PROG_DIR) without skipPath ("" for none), paths only (readInfo false), so it never opens a program; it returns a list handle, 0 when nothing was found. ShellProgramCount and ShellProgramPath read it (0 / "" for a bad list or index); ShellProgramFree frees it. ShellProgramName and ShellProgramDescription are shellProgramInfo's name and description, read one program at a time so a long list can be filled with DoEvents in between (the C progress callback). ShellIsProgram is shellIsProgram, ShellIsDosProgram shellIsDosProgram.

    File types

    const char *ShellAssocProgram(const char *extension);
     const char *ShellAssocCommand(const char *document);
    @@ -8469,7 +8689,7 @@ const char *ShellItemIcon(const char *iconPath, const char *path);

    BASIC System Settings

    Source: dvxshell/shellSysBas.c; BASIC declarations: src/include/basic/system.bas (DECLARE LIBRARY "dvxshell"); prototypes in shellApp.h.

    -

    What Control Panel changes, for DVX BASIC programs: the video mode, the system colors and themes, the wallpaper, text smoothing, and the mouse, each applied at once to shellContext(). Only BASIC-marshalable types; True (-1) / False (0); colors are BASIC RGB() values (&HBBGGRR, converted with wgtBasicColor); lists are 0-based. Arguments named prefs are prefs.bas handles, resolved with prefsBasHandle (dvxPrefs.h); 0 or a closed handle makes a Store or Load do nothing (a Load returns False).

    +

    What Control Panel changes, for DVX BASIC programs: the video mode, the system colors and themes, the wallpaper, the screen saver, text smoothing, and the mouse, each applied at once to shellContext(). Only BASIC-marshalable types; True (-1) / False (0); colors are BASIC RGB() values (&HBBGGRR, converted with wgtBasicColor); lists are 0-based. Arguments named prefs are prefs.bas handles, resolved with prefsBasHandle (dvxPrefs.h); 0 or a closed handle makes a Store or Load do nothing (a Load returns False).

    int32_t     SysVideoModeCount(void);
     int32_t     SysVideoModeWidth(int32_t index);
     int32_t     SysVideoModeHeight(int32_t index);
    @@ -8492,6 +8712,14 @@ void        SysSetWallpaperMode(int32_t mode);
     int32_t     SysWallpaperMode(void);
     void        SysWallpaperStore(int32_t prefs);
     int32_t     SysWallpaperLoad(int32_t prefs);
    +const char *SysScreenSaver(void);
    +int32_t     SysScreenSaverWait(void);
    +void        SysSetScreenSaver(const char *fileName, int32_t minutes);
    +int32_t     SysScreenSaverStart(void);
    +int32_t     SysScreenSaverRunning(void);
    +void        SysScreenSaverStore(int32_t prefs);
    +void        SysScreenSaverLoad(int32_t prefs);
    +int32_t     SysIdleSeconds(void);
     void        SysSetTextSmoothing(int32_t on);
     int32_t     SysTextSmoothing(void);
     void        SysFontsStore(int32_t prefs);
    @@ -8532,6 +8760,12 @@ int32_t     SysMemoryFreeKb(void);
    SysSetWallpaperMode dvxSetWallpaperMode with the value of prefsWallpaperChoices[mode]; other modes are ignored SysWallpaper / Mode ctx->wallpaperPath; the prefsWallpaperChoices index of ctx->wallpaperMode SysWallpaperStore / Load dvxWallpaperToPrefs / dvxWallpaperFromPrefs (Load: False when the image could not be loaded) + SysScreenSaver / Wait shellSaverName / shellSaverWait + SysSetScreenSaver shellSaverSet + SysScreenSaverStart shellSaverStart (True when it will start) + SysScreenSaverRunning shellSaverRunning + SysScreenSaverStore / Load shellSaverToPrefs / shellSaverFromPrefs + SysIdleSeconds dvxIdleMs / 1000 SysSetTextSmoothing dvxSetFontSmoothing (nonzero is on) SysTextSmoothing ctx->fontSmoothing (the setting; text is smoothed only above 8 bits per pixel) SysFontsStore / Load dvxFontsToPrefs / dvxFontsFromPrefs @@ -9179,7 +9413,7 @@ if (!dvxExclusivePresent8(ctx, fb, 320, 200, 320)) {

    Sound, Music, and MIDI

    -

    Games play sound on whatever the computer has: a Sound Blaster family card, an FM synthesizer chip, a MIDI port, the PC speaker, or nothing. Nothing fails because of missing hardware: audioOpen returns NULL (or the device plays tones only), and the game carries on silently. Several programs can play at once, and everything a program opened is stopped and released when it ends.

    +

    Games play sound on whatever the computer has: a Sound Blaster family card, a Windows Sound System card, a Gravis UltraSound, a DAC on the printer port, an FM synthesizer chip, a wavetable synthesizer (a Gravis UltraSound's or a Sound Blaster AWE's), a MIDI port, the PC speaker, or nothing. Nothing fails because of missing hardware: audioOpen returns NULL (or the device plays tones only), and the game carries on silently. Several programs can play at once, and everything a program opened is stopped and released when it ends.

    Sound Effects and Tones (audio.h)

    • audioOpen() gives the program an audio context (the first one opened starts the sound device).
    • @@ -9190,7 +9424,7 @@ if (!dvxExclusivePresent8(ctx, fb, 320, 200, 320)) {
    • audioSetVolume sets the program's volume; the master volume belongs to the user (Control Panel).

    Music (music.h)

    -

    musicLoad or musicLoadMem load a tracker module (MOD, S3M, XM, IT) or a Standard MIDI File; musicPlay(music, loop) plays it, with pause, resume, stop, volume, position, and an end notification. MIDI plays on the best synthesizer there is: an external one on the MPU-401 port when the user chose it, else the FM chip with a General MIDI bank, else DVX's own FM software synthesizer; modules play on any card that plays sampled sound. musicMidiOpen gives live MIDI (note on and off, program change, controllers, pitch bend), and musicMmlPlay plays tunes written in QBasic's music macro language.

    +

    musicLoad or musicLoadMem load a tracker module (MOD, S3M, XM, IT) or a Standard MIDI File; musicPlay(music, loop) plays it, with pause, resume, stop, volume, position, and an end notification. MIDI plays on the best synthesizer there is: an external one on the MPU-401 port when the user chose it, else a wavetable card's recorded instruments (a Gravis UltraSound or a Sound Blaster AWE with a SoundFont), else the FM chip with a General MIDI bank, else DVX's own FM software synthesizer; modules play on a wavetable card's own voices (taking little of the CPU), else on any card that plays sampled sound (mixed in software). musicMidiOpen gives live MIDI (note on and off, program change, controllers, pitch bend), and musicMmlPlay plays tunes written in QBasic's music macro language.

    The FM Chip and the MIDI Port

    For music drivers of its own, a game can allocate two-operator FM voices shared with the other programs (audioFmAlloc, audioFmWrite with the AUDIO_FM_* register numbers), take the whole FM chip for itself (audioFmRawAcquire; refused with AUDIO_ERR_BUSY while another program has it), or send bytes to the MIDI port (audioMidiAcquire, audioMidiPut). Give them back as soon as the game does not need them.

    Keeping Sound Fed

    @@ -10055,8 +10289,8 @@ int32_t listIconScrollDrag(const ListIconGridT *g, int32_t y, int32_t grab);

    Music Library

    -

    Music for DVX programs on whatever sound hardware the computer has: tracker modules (MOD, S3M, XM, IT), Standard MIDI Files (type 0 and 1), and live MIDI a program sends itself. Every music belongs to the program that loaded it and is stopped and freed when the program ends, however it ends; any number of programs may play music at the same time.

    -

    Header: music/music.h. Loaded as: bin/libs/kpunch/music/music.lib (needs audio.lib); the General MIDI instrument bank is bin/libs/kpunch/music/genmidi.op2.

    +

    Music for DVX programs on whatever sound hardware the computer has: tracker modules (MOD, S3M, XM, IT), Standard MIDI Files (type 0 and 1), WAV and VOC sound files, and live MIDI a program sends itself. Every music belongs to the program that loaded it and is stopped and freed when the program ends, however it ends; any number of programs may play music at the same time.

    +

    Header: music/music.h. Loaded as: bin/libs/kpunch/music/music.lib (needs audio.lib); the General MIDI instrument bank for FM is bin/libs/kpunch/music/genmidi.op2, and the General MIDI SoundFont for wavetable music is an add-on (SHARED\BANKS\KPUNCH\DVXGM.SF2).

    Playing a File

    MusicT *tune = NULL;
     int32_t rc   = musicLoad("THEME.MID", &tune);    // or musicLoadMem(data, size, &tune)
    @@ -10067,23 +10301,32 @@ if (rc != MUSIC_OK) {
         musicSetEndFn(tune, onTuneEnd, NULL);
         musicPlay(tune, false);                      // true: loop until stopped
     }
    -

    musicLoad and musicLoadMem tell the kind by the contents: a MIDI file (or a RIFF RMID file around one) by its header, anything else is offered to the module player. Errors: MUSIC_ERR_OPEN (unreadable file), MUSIC_ERR_FORMAT (not music DVX plays, or a type 2 MIDI file), MUSIC_ERR_CORRUPT, MUSIC_ERR_NO_MEMORY, MUSIC_ERR_NO_DEVICE (no sound device could start), MUSIC_ERR_BANK (MIDI with no usable instrument bank and no MPU-401 port). Nothing plays until musicPlay(music, loop), which plays from the current position (the start after a load or a stop; a music that played to its end starts over). musicPause / musicResume, musicStop (back to the start), musicIsPlaying, musicSetVolume (0..MUSIC_VOLUME_MAX, per music; the master volume applies too - on the FM chip and the MPU-401 port as it was when the music started or its volume was last set), musicGetPosition / musicSetPosition (milliseconds), musicGetInfo (kind, where it plays, a description of the device, title, length, position, state), and musicFree complete the set. musicSetEndFn(music, fn, userData) asks for a call when a music that does not loop plays to its end; it comes in the shell's idle time as the program that owns the music, never from an interrupt.

    -

    Setting the position of a MIDI file sends the instruments and controllers set before that point (no notes). A module goes to the start of the pattern that holds the time.

    +

    musicLoad and musicLoadMem tell the kind by the contents: a WAV or VOC sound file (MUSIC_KIND_SOUND) or a MIDI file (or a RIFF RMID file around one) by its header, anything else is offered to the module player. A WAV loaded by its path is read from the disk as it plays (only its header is read at the load), so a long recording needs no memory; a VOC, or a WAV given in memory, is decoded into memory (audioDecode). Errors: MUSIC_ERR_OPEN (unreadable file), MUSIC_ERR_FORMAT (not music DVX plays, or a type 2 MIDI file), MUSIC_ERR_UNSUPPORTED (a sound file that is compressed or not 8- or 16-bit; its text is audio.lib's), MUSIC_ERR_CORRUPT, MUSIC_ERR_NO_MEMORY, MUSIC_ERR_NO_DEVICE (no sound device could start), MUSIC_ERR_BANK (MIDI with no usable instrument bank and no MPU-401 port). Nothing plays until musicPlay(music, loop), which plays from the current position (the start after a load or a stop; a music that played to its end starts over). musicPause / musicResume, musicStop (back to the start), musicIsPlaying, musicSetVolume (0..MUSIC_VOLUME_MAX, per music; the master volume applies too - on the FM chip and the MPU-401 port as it was when the music started or its volume was last set), musicGetPosition / musicSetPosition (milliseconds), musicGetInfo (kind, where it plays, a description of the device, title, length, position, state; the title is a module's own, a MIDI file's sequence name (the first track's name, control characters as spaces), and none for a sound), and musicFree complete the set. musicSetEndFn(music, fn, userData) asks for a call when a music that does not loop plays to its end; it comes in the shell's idle time as the program that owns the music, never from an interrupt.

    +

    musicLoadMacro(macro, &music) makes a MIDI music from a music macro string (the language PLAY takes, below): its notes play on the General MIDI instruments its I commands name, the piano for notes before any I or after I0, and it is then played, paused, and freed like a MIDI file (MUSIC_ERR_MACRO for a mistake in the string). musicSetBank(music, bank) plays one MIDI music on another instrument bank than DVX.INI's choice: bank names it as midiBank and soundFont do ("" the standard OP2 bank, NULL DVX.INI's choices again). A SoundFont (.SF2 or .SBK) or an UltraSound's ULTRASND.INI is for wavetable music (the FM bank stays DVX.INI's), any other bank for the FM synthesizers. The bank is read at once, so a missing or damaged one is MUSIC_ERR_BANK there and then; it must come before the music's first musicPlay (MUSIC_ERR_ARG for a module, live MIDI, or a music that has played). Control Panel's Instruments Test is musicLoadMacro and musicSetBank.

    +

    Setting the position of a MIDI file sends the instruments and controllers set before that point (no notes). A module goes to the start of the pattern that holds the time; a sound goes to the frame.

    Where Music Plays

    -

    Modules are rendered by libxmp-lite into a stream of the sound device (16-bit stereo at the device's rate, 400 ms of buffer, linear interpolation), so they play on any device that plays sampled sound.

    +

    A module plays on the wavetable synthesizer's own voices (a Gravis UltraSound's, an AWE32's or AWE64's) when there is one and no other music plays on it (one music at a time, MIDI or a module; a stopped one gives it up): its samples go into the chip's memory and libxmp's voices are played on the chip's, nothing mixed in software (MUSIC_OUT_WAVE; see Modules). Otherwise libxmp-lite renders it into a stream of the sound device (16-bit stereo at the device's rate, 400 ms of buffer, linear interpolation), so it plays on any device that plays sampled sound (MUSIC_OUT_STREAM): with DVX.INI [audio] modules = software (audioModulesChoices; the Control Panel's Sounds page), when the module has more channels than the chip has voices or a stereo sample, or when its samples do not fit in the chip's memory (the SoundFont kept there for MIDI gives way first).

    +

    A sound always plays on a stream (MUSIC_OUT_STREAM) at its own rate and channels, 16-bit, with the same 400 ms of buffer; the mixer converts it to the device's rate. A pause, a seek, or a stop of any music on a stream is heard at once: the stream's voice is held where it is (audioVoicePause) and, for a seek or a stop, what it holds is dropped (audioStreamFlush).

    MIDI goes to the best synthesizer there is, decided at the first musicPlay (or musicMidiOpen), with DVX.INI [audio] midi:

      midi       MIDI plays on
       ----       -------------
    -  auto       the FM chip (OPL2 / OPL3) when there is one and no program has taken the whole chip; else the software synthesizer
    +  auto       a wavetable synthesizer (a Gravis UltraSound, a Sound Blaster AWE32 or AWE64) when there is one, no other music is using it, and its SoundFont loads; else the FM chip (OPL2 / OPL3) when there is one and no program has taken the whole chip; else the software synthesizer
       mpu401     the MPU-401 port (mpuPort, default 330h) when it was found and no other music is using it; else as auto
    -  fm         as auto
    -  software   the software synthesizer
    + fm the FM chip when there is one and it is free; else the software synthesizer + software the software synthesizer + softwave the SoundFont software synthesizer (below); else the software synthesizer

    On the FM chip and the MPU-401 port the song is played by the timer service (500 times a second), so it keeps time however busy the programs are. On the FM chip every note takes a voice from audio.lib's shared pool; when none is free the note plays on the software synthesizer instead, which then runs beside the chip (its stream is always open for that). The software synthesizer runs in the stream's fill function (the shell's idle time), 64 frames between event checks, 150 ms ahead of what is heard; a program that keeps the desktop busy every frame should call audioPump once a frame, as for any stream.

    General MIDI on FM

    -

    Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank (MUSIC_INI_BANK) names another OP2 file instead: a bank in SHARED\BANKS or the user's BANKS folder by its name (an add-on package's as VENDOR\NAME), or any OP2 file by its path from the DVX folder, for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. Control Panel's Sounds page lists the banks to choose from. A bank that is not there falls back to the bundled one (MUSIC_BANK_DEFAULT). The bank is read once, at the first MIDI music that needs it, so a new choice takes effect when DVX next starts.

    +

    Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank (MUSIC_INI_BANK) names another OP2 file instead: a bank in SHARED\BANKS or the user's BANKS folder by its name (an add-on package's as VENDOR\NAME), or any OP2 file by its path from the DVX folder, for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. Control Panel's Sounds page lists the banks to choose from; DVX's own add-ons offer DMXOPL and DMXOPL3 (MIT, by ConSiGno and Sneakernets) through Software. A bank that is not there falls back to the bundled one (MUSIC_BANK_DEFAULT). DVX.INI's choice is read as each MIDI music starts, so a new choice reaches the next one; a music keeps the bank it started with, and a bank is freed with the last music playing on it.

    What is played: note on / off, velocity, program change, channel volume (CC7), expression (CC11), pan (CC10: left, both, or right on an OPL3 and the software synthesizer), sustain pedal (CC64), pitch bend with the bend range from RPN 0 (default 2 semitones), all notes off (CC123, CC120), reset controllers (CC121), and General MIDI System On (a reset). Channel 10 plays percussion. Loudness is velocity x volume x expression x the music's volume on a 40 log10 curve, added as attenuation to the voice's level. OP2 banks are tuned for DMX, which sounds note n where MIDI has note n + 12, so notes are played an octave up from the bank's offsets. Not played: modulation (CC1), aftertouch, the FM chip's own rhythm mode, four-operator voices.

    The Software Synthesizer

    When there is no FM chip (or it is busy), MIDI plays on DVX's own software FM synthesizer: an integer two-operator FM chip modelled on the OPL2 (the same log-sine and exponential tables, waveforms 0 to 3, feedback, the chip's envelope rates and key scaling, key scale level, tremolo, and vibrato) with 24 voices, rendering at the device's rate into a stream. It is driven with the same bank and the same register values as the chip, so music sounds alike on both. The host tests compare it with emu8950, an accurate OPL2 emulator, for several patches: pitch, loudness over time, and harmonics agree closely. Cost on a 486: roughly 1 to 2 percent of a 486DX2-66 per sounding voice at 22,050 Hz.

    +

    SoundFonts on a Wavetable Synthesizer

    +

    A wavetable synthesizer (audio.lib's gus and guswt drivers for a Gravis UltraSound, awe for the EMU8000 of a Sound Blaster AWE32 or AWE64) plays recorded instruments from its own memory. Its instruments come from a SoundFont, DVX.INI [audio] soundFont (MUSIC_INI_SOUND_FONT), named as midiBank names an OP2 bank; not set, the standard one (MUSIC_SOUND_FONT_DEFAULT, KPUNCH\DVXGM.SF2: DVX's General MIDI SoundFont add-on, made from FluidR3_GM by mksf, about 3.4 MB). SoundFont 2 files are read in full; SoundFont 1 (.SBK, the AWE32's own) as far as it is known (it has no public specification). soundFont may also name a Gravis UltraSound's ULTRASND.INI by its path, which plays that folder's .PAT patches as instruments; and where the standard SoundFont is not installed, the patches in the folder ULTRADIR names play (musicPatchSetPath), else the AWE's own bank for its sample ROM (musicRomBankPath: SFBANK\SYNTHGM.SF2 or SYNTHGM.SBK in the folder SOUND names, as Creative's drivers installed it). Control Panel's Sounds page lists all of these.

    +

    A song loads only the samples its notes play (the bank selects, programs, and notes are looked at first), each with the frames a chip's interpolation reads after it, into memory the synthesizer gives every program a share of; what a song loaded stays for the next while it fits, and room is made by giving up the samples used longest ago. When the samples still do not fit they are all loaded again smaller: 8-bit where the chip plays it, then at half the rate, then a quarter; at the least, the ones that do not fit are left out. Notes the bank lacks, or whose samples were left out, play on the FM chip or the software synthesizer, as do the notes beyond the synthesizer's voices. One music plays on the wavetable synthesizer at a time (another meanwhile plays on FM); its SoundFont and samples are kept 60 seconds after the last music (MUSIC_FONT_KEEP_SEC). Live MIDI loads a program's samples as it is chosen. A SoundFont made for an AWE's General MIDI ROM (ROM samples) plays from the card's ROM with nothing loaded; on any other synthesizer its notes go to FM.

    +

    The AWE runs the SoundFont's envelopes, LFOs, filter, and reverb and chorus sends itself; on the GUS the library runs the envelopes and LFOs (its volume and pitch changed as the note goes on), without a filter or effects.

    +

    The SoundFont Software Synthesizer

    +

    With DVX.INI [audio] midi = softwave, MIDI plays from the SoundFont in software on any device that plays sampled sound: the song's samples are read into memory at their own rates and mixed into the music's stream (linear interpolation, the envelopes and LFOs as on the GUS, no filter or effects), with the software FM synthesizer beside it for the notes the bank lacks. It needs the memory for the song's samples (a General MIDI song of ten instruments and a drum kit about 360 KB of DVXGM's) and more CPU time than FM.

    The MPU-401 Port

    With DVX.INI [audio] midi = mpu401, MIDI goes out of the MPU-401 port (audio.lib's mpu401 driver) to an external synthesizer, one music at a time (a second MIDI music meanwhile plays on the FM chip or the software synthesizer). Bytes are queued and sent by the timer as fast as the port takes them, with a 40 ms pause after every system exclusive message (first-generation MT-32s lose data without it). The music's volume scales every channel volume (CC7) sent. DVX.INI [audio] mpuMode says what is on the port:

      mpuMode    Treatment
    @@ -10099,9 +10342,25 @@ musicMidiOpen(&keys);                          // plays at once
     musicMidiProgram(keys, 0, 73);                 // flute on channel 0
     musicMidiNoteOn(keys, 0, 60, 100);             // middle C
     musicMidiNoteOff(keys, 0, 60);
    -

    musicMidiOpen opens a music with no file that plays at once, on the same synthesizer a MIDI file would use. musicMidiNoteOn, musicMidiNoteOff, musicMidiProgram, musicMidiControl, and musicMidiPitchBend (-8192..8191) send channel messages (channels 0 to 15, data 0 to 127) to it, or to a MIDI file that is playing (for dynamic game scores); MUSIC_ERR_ARG for a module or a value out of range.

    +

    musicMidiOpen opens a music with no file that plays at once, on the same synthesizer a MIDI file would use. musicMidiNoteOn, musicMidiNoteOff, musicMidiProgram, musicMidiControl, and musicMidiPitchBend (-8192..8191) send channel messages (channels 0 to 15, data 0 to 127) to it, or to a MIDI file that is playing (for dynamic game scores); MUSIC_ERR_ARG for a module or a value out of range. musicMidiSend(music, status, data1, data2) sends any channel message as MIDI input brings it (audioMidiInRead: status 80h..EFh with the channel in it; a data byte the message does not use is ignored), so what a keyboard plays sounds as it comes (MIDI thru).

    +

    Recording MIDI

    +
    MusicMidiRecordT *take = NULL;
    +AudioMidiMsgT     msg;
    +
    +musicMidiRecordNew(&take);
    +
    +while (audioMidiInRead(audio, &msg)) {         // each tick, while recording
    +    musicMidiSend(keys, msg.status, msg.data1, msg.data2);
    +    musicMidiRecordAdd(take, msg.ms, msg.status, msg.data1, msg.data2);
    +}
    +
    +musicMidiRecordSave(take, "SONG.MID");
    +musicMidiRecordFree(take);
    +

    A MIDI recording keeps channel messages with their times (musicMidiRecordAdd(rec, ms, status, data1, data2), ms from the recording's start; a time before the last message's counts as the last's; MUSIC_ERR_ARG for anything but a channel message) and musicMidiRecordSave(rec, path) writes them as a Standard MIDI File: type 0, one track of 1,000 ticks a quarter note at 60 quarter notes a minute, so a tick is a millisecond. A note still held at the end is let go there, so the file never leaves a note sounding. MUSIC_ERR_WRITE when the file cannot be written (nothing is left of one not all written). musicMidiRecordCount and musicMidiRecordLength (the last message's time) say what it holds; musicMidiRecordFree frees it, and a program's recordings are freed when it ends. musicLoad plays the file. The music macros (below) are written into MIDI files by the same code.

    Modules

    -

    MOD (ProTracker and compatibles), S3M, XM, and IT through libxmp-lite 4.7.3 (thirdparty/libxmp-lite, unmodified; formats only, no depackers or ProWizard). Stereo separation is 50 percent (softer than the Amiga's hard left and right). The whole module is held in memory. libxmp works out pitches with floating point, so modules play in the shell's idle time, never at interrupt time; on a 486SX (no maths coprocessor) that goes through floating point emulation and is slow. Rough cost on a 486DX2-66 at 22,050 Hz: 5 percent for a 4-channel MOD, 20 to 25 percent for a 16-channel XM.

    +

    MOD (ProTracker and compatibles), S3M, XM, and IT through libxmp-lite 4.7.3 (thirdparty/libxmp-lite with DVX's patches, listed in its DVX-VERSION.md; formats only, no depackers or ProWizard). Stereo separation is 50 percent (softer than the Amiga's hard left and right). The whole module is held in memory. libxmp works out pitches with floating point, so it plays modules in the shell's idle time, never at interrupt time; on a 486SX (no maths coprocessor) that goes through floating point emulation and is slow. On a stream libxmp mixes every channel in software (linear interpolation, three multiplies a channel a frame).

    +

    On a wavetable synthesizer (musicModHw.c) libxmp mixes nothing: DVX's hardware voice patch has it report each of its voices as every tick starts (the sample, where it is, its rate, volume, pan, and the loop it is in), and only move them on. Those ticks are made up to 400 ms ahead in the idle time, as the synthesizer's notes, and the music timer plays them on time (500 times a second: a note starts within 2 ms of its time). A voice starts on the chip where libxmp starts or moves it (a note, a sample offset, a retrigger, another sample or loop); otherwise its pitch, volume, and pan follow libxmp's each tick, and the chip's own interpolation plays it. The samples are stored as they are (an 8-bit one 8-bit where the chip plays those, as a GUS does), else all 8-bit. The chips loop only forward, so a ping-pong loop is stored unrolled: its way back after its end, each frame where libxmp reads it (an Impulse Tracker module's way back a frame shorter, as Impulse Tracker plays it). What the chips cannot do plays near enough: a ping-pong sustain loop goes forward, a sample played backwards with no loop goes forward, and Impulse Tracker's filters are not heard; a voice the chip has none free for (a program holds the rest) is not heard. A voice at full volume plays 8 dB down, with the chip's level (AudioWtInfoT) taken off too, so a module is about as loud as libxmp mixes it. A pause stops the voices, and the play goes on from the tick that was playing; musicGetPosition is the tick being heard.

    +

    Measured on an emulated 486DX2-66 at 22,050 Hz, a 4-channel MOD with all four channels sounding takes about 49 percent of the CPU mixed in software and about 14 percent on a Gravis UltraSound's voices, most of that libxmp's own work each tick (about 1.3 ms of the 20).

    Music Macros

    MusicMmlT *tune = NULL;
     
    @@ -10110,13 +10369,14 @@ musicMmlPlay(tune, "MB T140 O3 L8 CDEC CDEC EFG4 EFG4");

    A macro queue plays tunes written in QBasic's music macro language (the commands are listed in music.h and in the BASIC language reference: notes A-G with sharps and flats, N, O, > and <, L, P, dots, T, MN / ML / MS, MF / MB). musicMmlPlay parses the whole string first; a mistake anywhere returns MUSIC_ERR_MACRO and queues nothing. The settings last from one string to the next. musicMmlTone queues a square-wave tone of milliHz thousandths of a hertz (0: silence) for us microseconds (QBasic's SOUND). musicMmlQueued counts the notes not yet played, musicMmlStop silences the queue, musicMmlBusy says whether a caller that follows QBasic should still wait (foreground mode: until everything has played; background: while more than MUSIC_MML_BACKGROUND_MAX notes wait) and moves the queue along. A queue belongs to the program that opened it and is freed when that program ends.

    Timing is QBasic's: a whole note is four beats of 60 / T seconds, each dot adds half again, and a note sounds for 7/8 (MN), all (ML), or 3/4 (MS) of its length. Octave 3 starts at middle C; octave 3's A is 440 Hz. The notes play as square waves on an audio.lib note queue, each to the exact sample (on the PC speaker as tones). DVX's own I command (I1 to I128) plays the notes that follow on that General MIDI instrument: each run of such notes becomes a small MIDI file that plays like any MIDI music (on the synthesizer DVX.INI [audio] midi chooses); I0 returns to tones. Runs play one after the other; when the instruments cannot play (no instrument bank, no device) the run plays as tones.

    Several Programs at Once

    -

    Every music plays through one audio context of the library's own; audio.lib mixes all streams, so modules and software MIDI of any number of programs play together. FM voices are shared with every program's tones and FM music; the MPU-401 port serves one music. When a program ends, its musics stop and are freed and their FM voices and the port are given back; the others go on.

    +

    Every music plays through one audio context of the library's own; audio.lib mixes all streams, so modules and software MIDI of any number of programs play together. FM voices are shared with every program's tones and FM music; the MPU-401 port serves one music, and so does the wavetable synthesizer (MIDI or a module). When a program ends, its musics stop and are freed and their FM voices and the port are given back; the others go on.

    BASIC

    -

    src/include/basic/music.bas (DECLARE LIBRARY "music"): MusicLoad(fileName) and MusicMidiOpen() return a music number (0 on failure: MusicError$ says why); MusicPlay(music, loopIt), MusicStop, MusicPause, MusicResume, MusicPlaying, MusicVolume(music, percent), MusicPosition, MusicLength, MusicSeek(music, ms), MusicFree; live MIDI MusicNoteOn(music, channel, note, velocity), MusicNoteOff, MusicProgram, MusicControl, MusicPitchBend with channels 1 to 16 (10 for drums). A number only works for the program that got it. MusicGetVolume(music) (musicGetVolume in percent) and MusicInfo$(music, field) (musicGetInfo: "title", "output", "kind" = "module" / "midi" / "live" for MusicKindE, "where" = "" / "stream" / "mpu401" / "fm" / "software" for MusicOutputE, "paused" and "looping" as "-1" / "0"; any case, "" for another field). music.bas also declares the raw MIDI port calls of audio.lib (MidiPortInfo$, MidiAcquire, MidiPut, MidiRelease; see the sound library).

    +

    src/include/basic/music.bas (DECLARE LIBRARY "music"): MusicLoad(fileName) and MusicMidiOpen() return a music number (0 on failure: MusicError$ says why); MusicPlay(music, loopIt), MusicStop, MusicPause, MusicResume, MusicPlaying, MusicVolume(music, percent), MusicPosition, MusicLength, MusicSeek(music, ms), MusicFree; live MIDI MusicNoteOn(music, channel, note, velocity), MusicNoteOff, MusicProgram, MusicControl, MusicPitchBend with channels 1 to 16 (10 for drums), and MusicMidiSend(music, midiMsg) (musicMidiSend of a message packed as MidiInMessage gives it, AUDIO_MIDI_PACKED_*). MIDI recordings: MidiRecordNew (a recording number, 0 when all 8 are in use), MidiRecordAdd(recording, milliseconds, midiMsg), MidiRecordCount, MidiRecordLength, MidiRecordSave(recording, fileName) (True / False with MusicError$), MidiRecordFree. A number only works for the program that got it. MusicLoadMacro(macro) (musicLoadMacro, a music number) and MusicSetBank(music, bank) (musicSetBank, "" the standard bank, a SoundFont or an ULTRASND.INI for wavetable music; MusicError$ says when it is missing or damaged); MusicPatchSet$ and MusicRomBank$ (musicPatchSetPath, musicRomBankPath: "" when there is none). MusicGetVolume(music) (musicGetVolume in percent) and MusicInfo$(music, field) (musicGetInfo: "title", "output", "kind" = "module" / "midi" / "live" / "sound" for MusicKindE, "where" = "" / "stream" / "mpu401" / "fm" / "software" / "wave" / "softwave" for MusicOutputE, "paused" and "looping" as "-1" / "0"; any case, "" for another field). music.bas also declares the raw MIDI port calls of audio.lib (MidiPortInfo$, MidiAcquire, MidiPut, MidiRelease) and its MIDI input (MidiInOpen, MidiInClose, MidiInMessage, MidiInTime, MidiInClock, MidiInLost, and the MIDI_* CONSTs); see the sound library.

    The BASIC runtime itself calls musicBasBeep, musicBasSound(hz, ticks), musicBasPlay(macro), musicBasPlayCount, and musicBasBusy for the statements BEEP, SOUND, and PLAY and the PLAY(n) function; each BASIC program has its own macro queue. QBasic's X command and "=" numbers (given with VARPTR$) are filled in by the BASIC runtime before musicBasPlay is called, so the macro language itself has neither. SOUND's duration is in clock ticks (18.2 a second) and 0 stops the queue; BEEP is 800 Hz for a quarter second.

    Licences

    libxmp-lite (Extended Module Player Lite), (C) 1996-2026 Claudio Matsuoka and Hipolito Carraro Jr, MIT licence:

    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.

    +

    The DVX General MIDI SoundFont add-on (DVXGM.SF2) is made from FluidR3_GM by Frank Wen (MIT licence); its notice is installed beside it (SHARED\BANKS\KPUNCH\DVXGM.TXT).

    The GENMIDI instrument bank is from Freedoom 0.13.0, Copyright (C) 2001-2024 Contributors to the Freedoom project. All rights reserved. BSD 3-clause licence:

    Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of the Freedoom project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

    @@ -10170,7 +10430,7 @@ pkgNetFree(n);

    Add-on packages: the .DPK file format, manifests, Ed25519 keys and signatures, the packages installed in a DVX folder, the keys DVX trusts, planning what an install or removal takes, and installing and removing a package a step at a time. Software, Package Builder, the IDE's File > Make Package, DVXPKG.EXE, the build machine's dvxpkg, and Setup all use it.

    Header: pkg/pkg.h (it includes the others below)

    Loaded as: bin/libs/kpunch/pkg/pkg.lib

    -

    BASIC: pkg.bas (sessions, offers, plans, install and remove jobs, building, signing, and keys).

    +

    BASIC: pkg.bas (sessions, offers, plans, install and remove jobs, building, signing, and keys; PkgFileOwner$ is pkgDbOwner).

    A Package

    A package is NAME.DPK: a ZIP read front to back, whose first entry is PACKAGE.INI, the manifest; then PACKAGE.SIG when the publisher signed it; then the files, named relative to the DVX folder with / between parts.

    [package]
    @@ -10178,7 +10438,7 @@ format    = 1
     id        = acme.chess          ; vendor.name, each 1..8 of a-z 0-9 _
     name      = Chess
     version   = 1.2.0
    -kind      = app                 ; app library widget font bank theme wallpaper sound help bundle system
    +kind      = app                 ; app library widget font bank theme wallpaper sound saver help bundle system
     summary   = Play chess against the computer.
     publisher = Acme Games          ; also author, licence, homepage
     dvx       = 1.0.0               ; the oldest DVX it runs on
    @@ -10195,6 +10455,7 @@ APPS/ACME/CHESS/CHESS.APP = 61234 <SHA-256, 64 hex digits>
    font, bank SHARED/FONTS/VENDOR, SHARED/BANKS/VENDOR theme, wallpaper SHARED/THEMES/VENDOR, SHARED/WPAPER/VENDOR sound SHARED/SOUNDS/VENDOR + saver SHARED/SAVERS/VENDOR (a screen saver: a program, so it needs abi) bundle no files: a set of packages, its dependencies system DVX itself (kpunch.dvx): anywhere but the user folder

    Manifests (pkgManifest.h)

    @@ -10262,7 +10523,7 @@ dvxpkg list [-r DVXDIR]

    Header: print/print.h

    Loaded as: bin/libs/kpunch/print/print.lib

    Settings

    -

    The printer is chosen once for everything, in DVX.INI [printer] (the Control Panel's Printer page and the Page Setup dialog edit it): where output goes (PrintDestE: LPT1, LPT2, LPT3, or a file), the printer model (PrintModelE: text only, Epson 9-pin, Epson 24-pin, HP LaserJet), and the page setup (PrintPageSetupT).

    +

    The printer is chosen once for everything, in DVX.INI [printer] (the Control Panel's Printer page and the Page Setup dialog edit it): where output goes (PrintDestE: LPT1 to LPT3, COM1 to COM4, or a file), a serial port's speed and handshake (baud, PrintHandshakeE), the printer model (PrintModelE: text only; Epson 9-pin or 24-pin, each with a black or a color ribbon; HP LaserJet at 150 dpi; HP LaserJet III or DeskJet at 300 dpi; HP DeskJet 500C; HP DeskJet 550C and later; PostScript, black or color; Apple ImageWriter II, black or color), and the page setup (PrintPageSetupT).

      Function                                   Description
       --------                                   -----------
       printSettingsLoad(&s)                      Read DVX.INI into a PrintSettingsT (defaults for anything missing; a page setup that cannot work is replaced by the defaults as a whole).
    @@ -10276,7 +10537,7 @@ dvxpkg list [-r DVXDIR]
    printDeviceProblem(dest, file) NULL when the destination can be used, else why not (printing to a file needs its name). printDeviceStore(h, model, dest, file) Put model, destination, and file into the [printer] section of h with the page setup as it is in DVX.INI now (the caller saves h). The Control Panel's OK and print.bas use it.

    Jobs and Printer Problems

    -

    A job sends bytes to a printer port or into a file. On DOS a port is driven through the BIOS printer service (INT 17h); a byte is sent only when a status check says the printer is ready, so the BIOS never waits by itself. While the printer is busy the job calls the idle hook; a printer busy for PRINT_BUSY_TIMEOUT_MS (10 seconds), out of paper, off line, or reporting an error is a problem, and so is a file that cannot be written (disk full). A problem goes to the ask hook: Retry tries again, Cancel ends the job and every later write fails at once.

    +

    A job sends bytes to a printer port or into a file. On DOS a parallel port is driven through the BIOS printer service (INT 17h); a byte is sent only when a status check says the printer is ready, so the BIOS never waits by itself. A serial port (COM1 to COM4, for serial printers such as the ImageWriter II) is opened through the serial library when the job starts, at the speed and handshake in the settings (8 data bits, no parity, 1 stop bit); its interrupt handler sends from a buffer as the printer's handshake allows (XON / XOFF, or the printer's DTR seen on the computer's CTS, or on its DSR as Apple's cable wires it), the port is busy while the buffer is well filled, and when the job closes it waits for the buffer to empty before closing the port. A PrintPortOpsT's open, pending, and close (NULL for none) are these steps. While the printer is busy the job calls the idle hook; a printer busy for PRINT_BUSY_TIMEOUT_MS (10 seconds), out of paper, off line, or reporting an error is a problem, and so is a file that cannot be written (disk full). A problem goes to the ask hook: Retry tries again, Cancel ends the job and every later write fails at once.

    PrintHooksT hooks = { myAsk, myIdle, NULL, myState };
     char        err[PRINT_MSG_MAX];
     PrintJobT  *job = printJobOpen(PrintDestLpt1E, NULL, &hooks, err, sizeof(err));
    @@ -10294,17 +10555,92 @@ if (!job) {
       finished(ctx, closing)        When a write that called idle returns, and (closing true) when the job is freed.

    printJobWrite returns once every byte was taken, or false when the job is cancelled. printJobBytes gives the count sent, printJobCancelled whether it was cancelled. printStatusProblem(status, afterWrite, where, out, size) turns BIOS status bits (PRINT_STATUS_*) into the message the user sees. printSetPortOps replaces the port services (present / status / write) with your own, as the tests do to play a printer that is busy or out of paper; NULL restores the real ones. Elsewhere than DOS the real ports always exist, are always ready, and append to lpt1.prn .. lpt3.prn in the working directory.

    Text

    -

    printTextJob(job, model, text, len, &pageSetup, &info) prints plain text on pages. Lines end with LF or CR LF; a form feed in the text starts a new page. Each printed line starts with marginLeft blanks and ends with CR LF, tabs are expanded to tabWidth, other control characters print as blanks, and a line longer than the text width is wrapped at the last blank before it (a word longer than a whole line is split) or, with wrap off, cut off. Every page ends with a form feed. For the Epson and LaserJet models the printer's reset command comes first (and, for the LaserJet, last).

    +

    printTextJob(job, model, text, len, &pageSetup, &info) prints plain text on pages. Lines end with LF or CR LF; a form feed in the text starts a new page. Each printed line starts with marginLeft blanks and ends with CR LF, tabs are expanded to tabWidth, other control characters print as blanks, and a line longer than the text width is wrapped at the last blank before it (a word longer than a whole line is split) or, with wrap off, cut off. Every page ends with a form feed. For the Epson and LaserJet models the printer's reset command comes first (and, for the LaserJet, last). A PostScript printer prints no plain text, so on the PostScript models the pages are PostScript: Courier as large as lets the page setup's columns fill 8 inches and its lines 10, a show per line (CP437 letters and signs in ISO Latin-1; line and block characters as -, |, +, and #).

    A page is marginTop lines with the header in their middle line, the text, then marginBottom lines with the footer in their middle line. Header and footer codes: &f file name (PrintTextInfoT.title), &p page number, &n number of pages, &d date, &t time (PrintTextInfoT.date / time, or today and now), && an ampersand; &l, &c, and &r put what follows at the left, centre, or right (text before any of them is centred). printFormatHeader expands a header for one page.

    PrintTextInfoT also chooses a page range (firstPage, lastPage; 0 = all) and the number of copies. printTextPageCount returns the number of pages text takes.

    -

    Pictures and Printer Drivers

    -

    printRasterJob(job, driver, rgb, w, h, pitch) prints a picture of R, G, B bytes on one page: scaled to the largest size that fits the printable area (8 by 10 inches) keeping its shape, and dithered to black and white with a 4x4 ordered pattern. The driver gets one band of dot rows at a time, so only one band is ever in memory. printDriver(model) returns the driver of a model (NULL for text only).

    -
      Model                  Driver
    -  -----                  ------
    -  Epson 9-pin (ESC/P)    120 x 72 dots per inch (ESC * 1), 8-row bands, paper fed with ESC J; blank bands only feed paper, and each pass stops after its last black dot.
    -  Epson 24-pin (ESC/P)   180 x 180 dots per inch (ESC * 39, 3 bytes per column), 24-row bands.
    -  HP LaserJet (PCL)      150 dots per inch raster graphics, one uncompressed row transfer per dot row (every LaserJet understands it), trailing white bytes left out.
    -

    A PrintDriverT holds the resolution, the printable size in dots, the band height, the text reset strings and usual page length, and begin / band / end functions that write the printer's commands; a new printer family is one more driver.

    +

    Documents: Pictures and Drawn Pages

    +

    A document is pages of dot graphics on a picture-printing model. printDocOpen(job, model) starts one on a job (the document owns the job from then on, and printDocClose closes it). Each page is the whole printable area (8 by 10 inches): printDocPage(doc, fn, ctx) asks the program's PrintBandFnT for it a band at a time, top down, as R, G, B bytes at the printer's resolution (the band comes white), and printDocPicture(doc, rgb, w, h, pitch) prints a picture scaled to the largest size that fits, keeping its shape. Only one band of the page is ever in memory, so a page costs the same however large it is.

    +
      Function                                   Description
    +  --------                                   -----------
    +  printModelPage(model, &info)               The page a model prints on (PrintPageInfoT: dots per inch across and down, the printable area in dots, color or not, and vector: a PostScript printer, which can draw the page itself); false for text only. info may be NULL to ask only whether the model prints pictures.
    +  printDocOpen(job, model)                   Start a document; NULL (the job closed) for a text-only model.
    +  printDocInfo(doc, &info)                   The document's page.
    +  printDocPage(doc, fn, ctx)                 Print a page the program draws. fn(ctx, rgb, width, y0, rows) fills rows y0 to y0 + rows - 1; false from it stops the page.
    +  printDocPicture(doc, rgb, w, h, pitch)     Print a picture as a page.
    +  printDocCancelled(doc)                     True when the job was cancelled.
    +  printDocClose(doc)                         End the document and close its job; true when every page printed. After a failure every later page fails at once.
    +  printUiDocOpen(ctx, title, &choice)        A document on the printer in the settings, at a Print dialog answer's destination (NULL: the settings'), with the Printing window up until it closes and the Retry box; tells the user why when it cannot open. Copies are the program's: print each page as often as wanted.
    +  printUiDocClose(ctx, doc)                  Close it, telling the user of a failure (not of a cancel).
    +

    Each band is dithered to the printer's inks with an ordered pattern (4x4, 17 levels, on the dot-matrix printers and the 150 dpi LaserJet; 8x8, 65 levels, at 300 dpi). A black-only printer prints the brightness. A color printer prints cyan, magenta, and yellow from the red, green, and blue; with black ink as well, black takes what the three have in common (full undercolor removal). Each ink is dithered with its own offset of the pattern, so dots of different inks do not land on each other in a regular way, and the pattern follows the row's place on the page, so it runs on from band to band.

    +
    static bool drawBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    // a red bar one inch down, a quarter inch high
    +    const PrintPageInfoT *pi = (const PrintPageInfoT *)ctx;
    +
    +    for (int32_t r = 0; r < rows; r++) {
    +        int32_t y = y0 + r;
    +
    +        if (y >= pi->dpiY && y < pi->dpiY + pi->dpiY / 4) {
    +            for (int32_t x = 0; x < width; x++) {
    +                uint8_t *p = rgb + ((r * width) + x) * 3;
    +
    +                p[0] = 0xFF;
    +                p[1] = 0;
    +                p[2] = 0;
    +            }
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +PrintDocT     *doc = printUiDocOpen(ctx, "Report", NULL);
    +PrintPageInfoT pi;
    +
    +if (doc) {
    +    printDocInfo(doc, &pi);
    +    printDocPage(doc, drawBand, &pi);
    +    printUiDocClose(ctx, doc);
    +}
    +

    Vector Pages

    +

    On a PostScript printer (PrintPageInfoT.vector) a page can be drawn by the printer itself, as lines, curves, fills, and its own letters, instead of being sent as dots: sharper at any size, and much smaller. printVecBegin(doc) starts such a page and printVecEnd(doc) prints it; in between, the drawing calls below go to the printer as they are made. Coordinates are the page's dots (y down, a dot's centre at whole numbers, so a shape covers the same dots it would in a band), colors are 0xRRGGBB (grey on the black-and-white model), and widths are in dots. printVecBegin is false, and the drawing calls do nothing, on a printer that cannot or a document that failed; a raster page (printDocPage, printDocPicture) is refused while a vector page is open, and printDocClose ends one left open.

    +
      Function                                                     Description
    +  --------                                                     -----------
    +  printVecBegin(doc), printVecEnd(doc)                         Start a vector page; print it (false when it did not print).
    +  printVecLine(doc, x0, y0, x1, y1, color, width, dash)        A line, round at the ends; dash is a 16-step pattern from its top bit (0xFFFF solid), a step a dot along the line's longer side, as BASIC's LINE style.
    +  printVecRect(doc, x0, y0, x1, y1, color, fill)               A filled box, corners inclusive.
    +  printVecEllipseFill(doc, cx, cy, rx, ry, color, fill)        A filled ellipse, out to the dots rx and ry from its centre.
    +  printVecEllipse(doc, cx, cy, rx, ry, color, width, dash, arc, start, end)  An ellipse's outline, or (arc) the part from start to end radians counterclockwise; dash as printVecLine's, along the outline from angle 0.
    +  printVecDot(doc, x, y, color, width)                         A round dot width dots across.
    +  printVecText(doc, font, x, y, text, len, color)              Text with the top of its line at (x, y), with the font's underline and strike-through.
    +  printVecImage(doc, rgb, w, h, sx, sy, sw, sh, dx, dy, dw, dh)  The part sx, sy, sw, sh of a picture (R, G, B bytes) stretched over the dots dx, dy, dw, dh by the printer.
    +

    A fill (PrintFillE) is solid or a hatch of lines one dot wide every PRINT_HATCH_DOTS (8) dots, horizontal, vertical, either diagonal, or crossed, lined up across the page as BASIC's FillStyle hatches are. The pen (its color, width, and dashes) and the font are sent only when they change, and each of the printer's faces is made Latin-1 the first time the document uses it.

    +

    A font opened for a vector page (printFontOpen with page.vector set) is the printer's own Helvetica, Times, or Courier in the style asked for (FONT_STYLE_BOLD, FONT_STYLE_ITALIC): Courier for a fixed-pitch family or one with Mono or Courier in its name, Times for one with Serif, Times, Roman, Tinos, or Ledger in it (but not Sans), else Helvetica. It is exactly the size asked for, and printFontWidth, printFontHeight (1.2 times the size), and printFontAscent measure the printer's letters by Adobe's published widths, so text placed with them lines up as it prints. It draws only with printVecText (printFontSpans and printFontDraw draw nothing with it); for DVX's own letters drawn in bands on a PostScript printer, open the font with vector cleared in a copy of the page.

    +

    Text on a Page

    +

    A page a program draws can carry text in any font family (dvxText.h) at a size in points. printFontOpen(&page, family, decipoints, style) gives a PrintFontT for a page's resolution (its PrintPageInfoT): the size is in tenths of a point (120 for 12 points), the style any FONT_STYLE_ bits, underline and strike-through included. The font is the family's smallest strike at least as large as the size in printer dots (12 points is 50 dots at 300 dpi, 30 at 180), else its largest, scaled to the size across and down apart, since a 9-pin printer's dots are not square. Scaling is nearest pixel; scaling down, every pixel row and column of the strike still marks a dot, so thin strokes stay.

    +

    The base set's sizes go up to 24 pixels, so most printed sizes are scaled up from it and look blocky at 300 dpi. The Printer Fonts add-on (kpunch.prfonts) gives DejaVu Sans, Serif, and Sans Mono in every style at 25 to 100 pixels, about 6 to 24 points at 300 dpi and 10 to 40 at 180: installed, those families print crisp at those sizes with no change to the program.

    +
      Function                                                    Description
    +  --------                                                    -----------
    +  printFontOpen(&page, family, decipoints, style)             The font; NULL when out of memory or for a size of 0 or less. printFontClose gives it up.
    +  printFontAscent(font), printFontHeight(font)                Dots from the top of a line to its baseline, and to the next line.
    +  printFontWidth(font, text, len)                             Dots across len characters of text.
    +  printFontSpans(font, x, y, text, len, top, bottom, fn, ctx) The text with the top of its line at (x, y) as runs of dots (PrintSpanFnT fn(ctx, x0, x1, y)), only rows top to bottom: a line that misses them is skipped at once, so a page drawn band by band costs little for the lines elsewhere.
    +  printFontDraw(font, x, y, text, len, color, rgb, width, y0, rows)  The same painted in color (0xRRGGBB) into the band a PrintBandFnT is filling.
    +

    Positions are worked out in the strike's pixels from the start of the text and only then turned into dots, so printFontWidth and what is drawn always agree.

    +

    Printer Drivers

    +
      Model                                  Driver
    +  -----                                  ------
    +  Epson 9-pin (ESC/P)                    120 x 72 dots per inch (ESC * 1), 8-row bands, paper fed with ESC J; blank bands only feed paper, and each pass stops after its last black dot.
    +  Epson 24-pin (ESC/P)                   180 x 180 dots per inch (ESC * 39, 3 bytes per column), 24-row bands.
    +  HP LaserJet (PCL)                      150 dots per inch raster graphics, one uncompressed row transfer per dot row (every LaserJet understands it), trailing white bytes left out.
    +  HP LaserJet III or DeskJet (300 dpi)   300 dots per inch (PCL 5, and PCL 3 on DeskJets): each row packed (compression mode 2, TIFF PackBits) and blank rows skipped (ESC * b n Y) instead of sent, so a page with white space on it costs next to nothing.
    +  Epson 9-pin / 24-pin color             As the black ones, with a four-color ribbon: a pass of the head for each ink with ESC r n choosing its band of the ribbon, yellow first and black last (dark ink does not smear into the light ones).
    +  HP DeskJet 500C                        As the 300 dpi LaserJet, in three planes (ESC * r -3 U: cyan, magenta, yellow; black is the three together): ESC * b n V for each plane of a row but the last, ESC * b n W for the last.
    +  HP DeskJet 550C and later              As the 500C, in four planes (ESC * r -4 U: black, cyan, magenta, yellow).
    +  PostScript                             Level 2 at 300 dpi: each band an imagemask of its black dots, packed (RunLengthDecode, the same PackBits) and in ASCII85, which any port carries; a band with no ink is not sent. The 8 by 10 inch area is centred across the paper the printer has loaded, half an inch from its top.
    +  PostScript (color)                     As PostScript, each band a colorimage of 4 bits a dot (cyan, magenta, yellow, black) from the dithered planes. Both PostScript models also draw vector pages (see Vector Pages).
    +  Apple ImageWriter II                   144 x 144 dots per inch: 144 across (ESC p), and down by two passes of the 8 graphics wires 1/144 inch apart (the even rows of a 16-row band, a 1/144-inch line feed, the odd rows, then ESC T 15 and a line feed). A line is ESC G nnnn and its columns (bit 0 the top dot), placed at its first dot (ESC F nnnn). The document resets the printer (ESC c) and sets its switches whatever its DIP switches say: no line feed after CR, 8-bit data, no perforation skip; then unidirectional printing (ESC >), which keeps the passes in line.
    +  Apple ImageWriter II (color)           As the ImageWriter II with the four-color ribbon: a line for each ink with ESC K n choosing its band, yellow first and black last, CR between them.
    +

    The drivers are inside the library: each holds its resolution, the printable size in dots, the band height, the dither size, its inks, the text reset strings and usual page length, and begin / band / end functions that write the printer's commands. A new printer family is one more driver.

    Dialogs and Ready-Made Printing

      Function                                              Description
       --------                                              -----------
    @@ -10317,7 +10653,8 @@ if (!job) {
       printUiOpenPort(ctx, port)                            A job with the user interface hooks on LPTn (0-based) or PRINT_PORT_DEFAULT.

    Jobs opened by these ask about printer problems with a Retry / Cancel message box. While a write waits, a modal Printing window with a Cancel button appears (at once for a document, after half a second for other output) and the whole desktop keeps running under it. Problems are told to the user; the functions return true only when everything was printed. Pictures need a printer model that prints them; on a text-only printer the user is told to choose the model in the Control Panel.

    BASIC

    -

    src/include/basic/print.bas declares PrintTextDialog, PrintText, PrintPictureDialog, PrintPicture, PrintPageSetup, and PrintCanPrintPictures. For the printer settings it also declares PrintModelCount / PrintModelLabel$, PrintDestCount / PrintDestLabel$ / PrintDestIsFile, PrintSettingModel / PrintSettingDest / PrintSettingFile$ (printSettingsRead on a prefs.bas handle, through prefsBasHandle), PrintSettingsProblem$ (printDeviceProblem), and PrintSettingsStore (printDeviceStore).

    +

    BASIC's Printer object (the form runtime, formPrinter.c) is built on the calls above: it records a page's drawing, prints it with printUiDocOpen / printDocPage (each band drawn again from the record), and prints text with printFontOpen and printFontDraw; on a PostScript printer it draws the record once more as a vector page (printVec*), in the printer's own faces; its document goes where the program's last PrintChoose chose (printBasChoice, the running program's own answer only).

    +

    src/include/basic/print.bas declares PrintTextDialog, PrintText, PrintPictureDialog, PrintPicture, PrintPageSetup, PrintCanPrintPictures, and PrintCanPrintColor (printModelPage). For serial printers it declares PrintDestIsSerial (printDestIsSerial), PrintBaudRate (printBaudRate), PrintHandshakeCount / PrintHandshakeLabel$ (printHandshakeLabel), PrintSettingBaud / PrintSettingHandshake (printSettingsRead), and PrintSerialStore (printSerialStore). For the printer settings it also declares PrintModelCount / PrintModelLabel$, PrintDestCount / PrintDestLabel$ / PrintDestIsFile, PrintSettingModel / PrintSettingDest / PrintSettingFile$ (printSettingsRead on a prefs.bas handle, through prefsBasHandle), PrintSettingsProblem$ (printDeviceProblem), and PrintSettingsStore (printDeviceStore).

      BASIC                                             C it calls
       -----                                             ----------
       PrintPageSetting(prefs, field), PrintPageText$(prefs, field)  printSettingsRead on a prefs.bas handle: one PrintPageSetupT field by name (Lines, Columns, MarginTop, MarginBottom, MarginLeft, MarginRight, TabWidth, Wrap; Header, Footer).
    @@ -11350,9 +11687,8 @@ End Sub
    ----- ---- ----------- name char[32] Widget name for lookup via wgtFind(). x, y, w, h int32_t Computed geometry relative to the window content area (set by layout). - minW, minH int32_t (tagged) Minimum size hints. Use wgtPixels(), wgtChars(), or wgtPercent(). 0 = auto. - maxW, maxH int32_t (tagged) Maximum size constraints. 0 = no limit. - prefW, prefH int32_t (tagged) Preferred size. 0 = auto. + minW, minH int32_t (tagged) Minimum size hints: the widget is never laid out smaller. Use wgtPixels(), wgtChars(), or wgtPercent(). 0 = auto. A fixed width in a row (a button's, a column of labels) is minW with weight 0. + maxW, maxH int32_t (tagged) Maximum size constraints: the widget never grows larger; across its box a capped widget is centred. 0 = no limit. weight int32_t Extra-space distribution weight. 0 = fixed, 100 = normal. A widget with weight=200 gets twice the extra space of one with weight=100. align WidgetAlignE Main-axis alignment for children: AlignStartE, AlignCenterE, AlignEndE. spacing int32_t (tagged) Spacing between children (containers only). WGT_GAP_DEFAULT = the class's default; 0 = none. @@ -12353,7 +12689,7 @@ wgtRichEditSetSelColor(re, DVX_RGB(0, 0, 160));

    RichLabel

    -

    Text in many fonts, styles, and colors, wrapped and aligned paragraph by paragraph. The text is built by calls: wgtRichLabelSetFont, wgtRichLabelSetStyle, and wgtRichLabelSetColor choose what the following wgtRichLabelAddText uses; wgtRichLabelAddLineBreak starts a new line and wgtRichLabelAddParagraph a new paragraph with its own alignment. Text starts in the widget's own font (wgtSetFont, or its container's). The widget is laid out at its width hint (prefW, else minW, which BASIC's Width sets), wrapping at spaces (a word too wide for a line breaks inside it); without one each paragraph is one line. Every line is as tall as the tallest font on it, its text standing on one baseline. Not focusable; drawn the same enabled or not. It fills its rectangle as a Label does: with the nearest explicit bgColor, else the content background. Built on the text flow in libdvx (dvxFlow.h).

    +

    Text in many fonts, styles, and colors, wrapped and aligned paragraph by paragraph. The text is built by calls: wgtRichLabelSetFont, wgtRichLabelSetStyle, and wgtRichLabelSetColor choose what the following wgtRichLabelAddText uses; wgtRichLabelAddLineBreak starts a new line and wgtRichLabelAddParagraph a new paragraph with its own alignment. Text starts in the widget's own font (wgtSetFont, or its container's). The widget is laid out at its width hint (minW, which BASIC's Width sets), wrapping at spaces (a word too wide for a line breaks inside it); without one each paragraph is one line. Every line is as tall as the tallest font on it, its text standing on one baseline. Not focusable; drawn the same enabled or not. It fills its rectangle as a Label does: with the nearest explicit bgColor, else the content background. Built on the text flow in libdvx (dvxFlow.h).

    Header: widgets/richLbl.h

    Creation

    WidgetT *rl = wgtRichLabel(parent);
    diff --git a/installToolchain.sh b/installToolchain.sh
    index 79dd4385..f2e22390 100755
    --- a/installToolchain.sh
    +++ b/installToolchain.sh
    @@ -59,6 +59,9 @@
     #   toolchains/fonts     which pins them): FreeType for the font converter,
     #                        built from source, and every font DVX converts
     #                        and ships, with its licence (src/fonts/sources.txt)
    +#   toolchains/soundfonts the SoundFonts the bank add-ons are made from
    +#                        (src/addons/banks/installSoundFonts.sh, which pins
    +#                        them): FluidR3_GM, for the dvxgm add-on
     #
     # Every download is checked against a pinned SHA-256 before it is unpacked,
     # so a changed or corrupted archive is refused instead of installed.
    @@ -346,13 +349,20 @@ else
         "${ROOT}/src/fonts/installFonts.sh"
     fi
     
    +# The SoundFonts the bank add-ons are made from, pinned in their own script
    +if [ "${FORCE}" -eq 1 ]; then
    +    "${ROOT}/src/addons/banks/installSoundFonts.sh" --force
    +else
    +    "${ROOT}/src/addons/banks/installSoundFonts.sh"
    +fi
    +
     # Verify what the build needs.
     if [ "$("${DJGPP_GCC}" -dumpfullversion)" != "${DJGPP_VERSION}" ]; then
         echo "installToolchain: ${DJGPP_GCC} is not GCC ${DJGPP_VERSION}" >&2
         exit 1
     fi
     
    -for f in "${DJGPP_DIR}/i586-pc-msdosdjgpp/bin/dxe3gen" "${DJGPP_DIR}/i586-pc-msdosdjgpp/bin/exe2coff" "${CWSDSTUB}" "${UPX_BIN}" "${BOX86_BIN}" "${BOX86_ROMS}/machines" "${FREEDOS_DIR}/MBR.BIN" "${FREEDOS_DIR}/BOOTSECT.BIN" "${FREEDOS_DIR}/KERNEL.SYS" "${FREEDOS_DIR}/COMMAND.COM" "${FREEDOS_DIR}/HIMEMX.EXE" "${FREEDOS_DIR}/UDVD2.SYS" "${FREEDOS_DIR}/SHSUCDX.COM" "${FREEDOS_DIR}/CTMOUSE.EXE" "${DOSBOX_BIN}" "${CPPCHECK_BIN}" "${TOOLCHAINS}/lrzsz/bin/lsz" "${TOOLCHAINS}/pktdrv/NE2000.COM" "${TOOLCHAINS}/emu8950/emu8950.c" "${TOOLCHAINS}/soundfont/TimGM6mb.sf2" "${TOOLCHAINS}/freetype/lib/libfreetype.a" "${TOOLCHAINS}/fonts/.installed"; do
    +for f in "${DJGPP_DIR}/i586-pc-msdosdjgpp/bin/dxe3gen" "${DJGPP_DIR}/i586-pc-msdosdjgpp/bin/exe2coff" "${CWSDSTUB}" "${UPX_BIN}" "${BOX86_BIN}" "${BOX86_ROMS}/machines" "${FREEDOS_DIR}/MBR.BIN" "${FREEDOS_DIR}/BOOTSECT.BIN" "${FREEDOS_DIR}/KERNEL.SYS" "${FREEDOS_DIR}/COMMAND.COM" "${FREEDOS_DIR}/HIMEMX.EXE" "${FREEDOS_DIR}/UDVD2.SYS" "${FREEDOS_DIR}/SHSUCDX.COM" "${FREEDOS_DIR}/CTMOUSE.EXE" "${DOSBOX_BIN}" "${CPPCHECK_BIN}" "${TOOLCHAINS}/lrzsz/bin/lsz" "${TOOLCHAINS}/pktdrv/NE2000.COM" "${TOOLCHAINS}/emu8950/emu8950.c" "${TOOLCHAINS}/soundfont/TimGM6mb.sf2" "${TOOLCHAINS}/freetype/lib/libfreetype.a" "${TOOLCHAINS}/fonts/.installed" "${TOOLCHAINS}/soundfonts/FluidR3_GM.sf2"; do
         if [ ! -e "${f}" ]; then
             echo "installToolchain: expected file missing after install: ${f}" >&2
             exit 1
    diff --git a/screenshots/86box/addons-addon-no.png b/screenshots/86box/addons-addon-no.png
    index 07e38fee..3b255300 100644
    --- a/screenshots/86box/addons-addon-no.png
    +++ b/screenshots/86box/addons-addon-no.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:87a79d1bb9a57eba5bc045892b359d85c20a332a95de259c233ec36b1de3ef2e
    -size 18229
    +oid sha256:ff64f081e2927482a5cc2dbf8470b9bf95da1d46ff64a7c146f5c1898020a2e1
    +size 18045
    diff --git a/screenshots/86box/apps-clock.png b/screenshots/86box/apps-clock.png
    index aac25d3e..29394ee6 100644
    --- a/screenshots/86box/apps-clock.png
    +++ b/screenshots/86box/apps-clock.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c47ef1d6f91f6bf682c65cffe7a635fac96e7b09d59fd05a54736b9ae8e72880
    -size 17993
    +oid sha256:d3e5f6fdf925d1abd17b30278162c56c2ea6d6548128ec958fa0da73c120b3e9
    +size 18012
    diff --git a/screenshots/86box/apps-cpanel.png b/screenshots/86box/apps-cpanel.png
    index eb7203d1..7c2c7af8 100644
    --- a/screenshots/86box/apps-cpanel.png
    +++ b/screenshots/86box/apps-cpanel.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
    -size 16427
    +oid sha256:88949a0a11f46387060a4e4a5faaca763bd1fd8da35bae21bce78e78e74b3c02
    +size 15931
    diff --git a/screenshots/86box/apps-desktop.png b/screenshots/86box/apps-desktop.png
    index 9fc09f1a..a090d4e6 100644
    --- a/screenshots/86box/apps-desktop.png
    +++ b/screenshots/86box/apps-desktop.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0a697ed80887720ffc0e258516c4a59068e86521f79c4d9d777e5b6d703a73cf
    -size 18588
    +oid sha256:a12c2d2fcac3b118c581b7cab7237adb773677a002781b4819728fa927ed9130
    +size 18563
    diff --git a/screenshots/86box/apps-dvxbasic.png b/screenshots/86box/apps-dvxbasic.png
    index 16b7bf11..3566300e 100644
    --- a/screenshots/86box/apps-dvxbasic.png
    +++ b/screenshots/86box/apps-dvxbasic.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c5b9aedac500ad8ff4cbbb7b59bf49751f4bde23f6e8f8a3ae9f0f34424307a1
    -size 18393
    +oid sha256:f36dae98688d62bd81a87e6129466223b5604ad6100b0b03bf358c72c0c62e54
    +size 18388
    diff --git a/screenshots/86box/apps-dvxhelp.png b/screenshots/86box/apps-dvxhelp.png
    index 1bd4ab98..878b2d82 100644
    --- a/screenshots/86box/apps-dvxhelp.png
    +++ b/screenshots/86box/apps-dvxhelp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:5e2c8e671952f6ddfb618246e4b20fb37acc9dc23aa3f9a1616a188a78f3a974
    -size 21764
    +oid sha256:4e557895dd4541eea4a1ff516cf44c08beb425ac7dc01359ddaaa914ee873430
    +size 21777
    diff --git a/screenshots/86box/apps-iconed.png b/screenshots/86box/apps-iconed.png
    index 658cb98c..a9b291b1 100644
    --- a/screenshots/86box/apps-iconed.png
    +++ b/screenshots/86box/apps-iconed.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:35e2e173eb0155a1799f985effc2d4100ea31be802ad7e312d5ca66751601fa0
    -size 14832
    +oid sha256:9e4cdc9de6d03c087b222bd6bb1a134718847b9f804098fb16e8c3f8c2608de5
    +size 14813
    diff --git a/screenshots/86box/apps-imgview.png b/screenshots/86box/apps-imgview.png
    index 65619ad3..b6c84679 100644
    --- a/screenshots/86box/apps-imgview.png
    +++ b/screenshots/86box/apps-imgview.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:20216cfc2f5c629596ee18af24de18501b2431af8ee731ceb5be4427e219661b
    -size 12892
    +oid sha256:9861190daaec8871a3fbab7bd5a3913ccd8a1e7fdfe990900ebc4013ce6a763d
    +size 12874
    diff --git a/screenshots/86box/apps-notepad.png b/screenshots/86box/apps-notepad.png
    index f8d5861d..ed658741 100644
    --- a/screenshots/86box/apps-notepad.png
    +++ b/screenshots/86box/apps-notepad.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:cbbc99f6095370b23c0cda96486df13fb5c1e6654ff371b9a67b8fe62a116eda
    -size 13750
    +oid sha256:04fc14a8ec0a1b19d97363f61a0e25abe53caa288aebb89dafbf610c81253653
    +size 13752
    diff --git a/screenshots/86box/apps-player.png b/screenshots/86box/apps-player.png
    new file mode 100644
    index 00000000..7305aa21
    --- /dev/null
    +++ b/screenshots/86box/apps-player.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:52310f0edc3d1689175593eddb3e0a06345b933605ebecf5d47ca63987afe488
    +size 13332
    diff --git a/screenshots/86box/apps-resedit.png b/screenshots/86box/apps-resedit.png
    index 4becf7fd..10bc49a9 100644
    --- a/screenshots/86box/apps-resedit.png
    +++ b/screenshots/86box/apps-resedit.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:3fc86dd4c3acc583cdb962e02b1c3a563a67bd5d84c0ed1a19314937ab191c51
    -size 14168
    +oid sha256:fffa0df7faae7202429cf3c5e782703f68ceb804bb52177c0088f0f2c67e3288
    +size 14176
    diff --git a/screenshots/86box/banked-bk-back.png b/screenshots/86box/banked-bk-back.png
    index 7976b145..9c404784 100644
    --- a/screenshots/86box/banked-bk-back.png
    +++ b/screenshots/86box/banked-bk-back.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:3425e4a35d856bef29f68f9732c7fad18669dfd3ebd1178835c0191827029cfb
    -size 18540
    +oid sha256:28c79ae8f56c95b2bcfc337305fd2503a44b4a11cd21cae8bb2d8075288ce389
    +size 18520
    diff --git a/screenshots/86box/banked-bk-desk.png b/screenshots/86box/banked-bk-desk.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/86box/banked-bk-desk.png
    +++ b/screenshots/86box/banked-bk-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/banked-bk-drag.png b/screenshots/86box/banked-bk-drag.png
    index 51540044..683e86c0 100644
    --- a/screenshots/86box/banked-bk-drag.png
    +++ b/screenshots/86box/banked-bk-drag.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:d815eedadc4035fea2027775eb340ae6b33b2b4f9bf90115ddacfe565096f7d9
    -size 15053
    +oid sha256:914ffc97631ee498aa1422cde129c85adfd627f1b1ab9b0957ade2e4ec08dd34
    +size 14781
    diff --git a/screenshots/86box/banked-bk-note.png b/screenshots/86box/banked-bk-note.png
    index f80814d6..ed658741 100644
    --- a/screenshots/86box/banked-bk-note.png
    +++ b/screenshots/86box/banked-bk-note.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1ffe8d8b71867ed7b7c676213a0b715f4de49565e1e83f0694466d2d701673cb
    -size 13754
    +oid sha256:04fc14a8ec0a1b19d97363f61a0e25abe53caa288aebb89dafbf610c81253653
    +size 13752
    diff --git a/screenshots/86box/boot-boot.png b/screenshots/86box/boot-boot.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/86box/boot-boot.png
    +++ b/screenshots/86box/boot-boot.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/cpanel-cp-color.png b/screenshots/86box/cpanel-cp-color.png
    index 5261386c..afd39ff6 100644
    --- a/screenshots/86box/cpanel-cp-color.png
    +++ b/screenshots/86box/cpanel-cp-color.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:660bf211134751affcd3a05e3d5e8e8c297cda750a417873a642c2ee72861542
    -size 18266
    +oid sha256:e66c7fb68c7e1ae2a6e0f140254fab01fa9d355e0cd714e97982666b27968420
    +size 18719
    diff --git a/screenshots/86box/cpanel-cp-desk.png b/screenshots/86box/cpanel-cp-desk.png
    index c912aaa5..1bc6c33c 100644
    --- a/screenshots/86box/cpanel-cp-desk.png
    +++ b/screenshots/86box/cpanel-cp-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:97022e5d29c1c26e7d47625d3fb54121173c54b9f58f884a17c451a555a3b3b3
    -size 14436
    +oid sha256:9030c3b8ed8d9e54aed50f1ab5d56a015e7938ea038ee487e3f188235de8e699
    +size 15016
    diff --git a/screenshots/86box/cpanel-cp-joy.png b/screenshots/86box/cpanel-cp-joy.png
    index a2a740d4..0e0b2aaa 100644
    --- a/screenshots/86box/cpanel-cp-joy.png
    +++ b/screenshots/86box/cpanel-cp-joy.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:eb4bdad8d10caa593d3cfab42126f537714a1d2ea70e06ca64656e26fbecb785
    -size 16334
    +oid sha256:a27192d2ccf98d8d10ff977d4ed3c8bcda6a911c1f9578001b1219a02ade9346
    +size 15833
    diff --git a/screenshots/86box/cpanel-cp-mouse.png b/screenshots/86box/cpanel-cp-mouse.png
    index eb7203d1..7c2c7af8 100644
    --- a/screenshots/86box/cpanel-cp-mouse.png
    +++ b/screenshots/86box/cpanel-cp-mouse.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
    -size 16427
    +oid sha256:88949a0a11f46387060a4e4a5faaca763bd1fd8da35bae21bce78e78e74b3c02
    +size 15931
    diff --git a/screenshots/86box/cpanel-cp-net.png b/screenshots/86box/cpanel-cp-net.png
    index 8a0078a8..c05764ef 100644
    --- a/screenshots/86box/cpanel-cp-net.png
    +++ b/screenshots/86box/cpanel-cp-net.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:27f0996dda3d29cd134257c40b19a6ef215ce45162999250c704004570995426
    -size 16199
    +oid sha256:11daa8703e086e9c3b3aa60d075616204bf2d9f7d228ffa86a0538dcbba25b0f
    +size 15677
    diff --git a/screenshots/86box/cpanel-cp-print.png b/screenshots/86box/cpanel-cp-print.png
    index 6b3a71c0..10aa46db 100644
    --- a/screenshots/86box/cpanel-cp-print.png
    +++ b/screenshots/86box/cpanel-cp-print.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:e35a2a6c663538c45692d66e5335d808e323bda1c5f1c53e51f1124a2e6cba85
    -size 15909
    +oid sha256:f36a45f38c72c9997d4b91ae8166beb9d557d304d2c298c718906dd5c0fb7f75
    +size 16993
    diff --git a/screenshots/86box/cpanel-cp-sound.png b/screenshots/86box/cpanel-cp-sound.png
    index 8b22b99b..0d2ceba7 100644
    --- a/screenshots/86box/cpanel-cp-sound.png
    +++ b/screenshots/86box/cpanel-cp-sound.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8af0d409e9b907747748030acb9e6974893bdd3a426d142d679a023bfd1ac6ce
    -size 17643
    +oid sha256:685f0c278368b5a74e5aa3710c0dc03a71b2f8f0bf1d2519309957090d7bcbbf
    +size 18726
    diff --git a/screenshots/86box/cpanel-cp-types.png b/screenshots/86box/cpanel-cp-types.png
    index 1e405480..0b7d27dd 100644
    --- a/screenshots/86box/cpanel-cp-types.png
    +++ b/screenshots/86box/cpanel-cp-types.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:53601bf8609488ed1a85b388e405854ec038f216b2cf6d4350d749be05195dd5
    -size 23172
    +oid sha256:2749e1d0ca5ca17f97455a0e6a4200d587f3b0961b135ce80fb37ee586325ef8
    +size 24863
    diff --git a/screenshots/86box/cpanel-cp-video.png b/screenshots/86box/cpanel-cp-video.png
    index ba8d7ec3..20cde1f5 100644
    --- a/screenshots/86box/cpanel-cp-video.png
    +++ b/screenshots/86box/cpanel-cp-video.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0811ca3be22aeba9decba8821ed65220c26b37d3155d01320fd8b46ec9116285
    -size 19815
    +oid sha256:f1bbd95ec800c2b9dcb50cc3b6de3ce242a51358f7ff3fbadef67ff6da390ce0
    +size 20039
    diff --git a/screenshots/86box/dosprog-dp-after.png b/screenshots/86box/dosprog-dp-after.png
    new file mode 100644
    index 00000000..15ff8dc1
    --- /dev/null
    +++ b/screenshots/86box/dosprog-dp-after.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/dosprog-dp-ask.png b/screenshots/86box/dosprog-dp-ask.png
    new file mode 100644
    index 00000000..4fa453f2
    --- /dev/null
    +++ b/screenshots/86box/dosprog-dp-ask.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d1eac79fc717600f2dbd52fd23090afb5d214ba255bc962e8c447394fc0da496
    +size 17445
    diff --git a/screenshots/86box/dosprog-dp-back.png b/screenshots/86box/dosprog-dp-back.png
    new file mode 100644
    index 00000000..15ff8dc1
    --- /dev/null
    +++ b/screenshots/86box/dosprog-dp-back.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/exit-cancel.png b/screenshots/86box/exit-cancel.png
    index cee10809..4583b6ea 100644
    --- a/screenshots/86box/exit-cancel.png
    +++ b/screenshots/86box/exit-cancel.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:3478e1d84decc5ac10844883062e6bea6885e30ec7ebe0101ebee7d9444ae619
    -size 14351
    +oid sha256:4331208df0cdee00be0e014b9251f7c5e44e19c0ed02586d4c78d473349e3b48
    +size 14347
    diff --git a/screenshots/86box/exit-savechg.png b/screenshots/86box/exit-savechg.png
    index 74b3e466..6dcf3389 100644
    --- a/screenshots/86box/exit-savechg.png
    +++ b/screenshots/86box/exit-savechg.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c375e0886534255d93d2ae1ecf32683d0036cfbfb628320140a7a5fc9b019695
    -size 14348
    +oid sha256:13ec25fc0b583981119747baa7fa24e7e97429106d1c5861be1494304ec29e6b
    +size 14350
    diff --git a/screenshots/86box/f1-h-basdem.png b/screenshots/86box/f1-h-basdem.png
    index f178ed94..7593a315 100644
    --- a/screenshots/86box/f1-h-basdem.png
    +++ b/screenshots/86box/f1-h-basdem.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:f5b3bc24e986cc704e74e6b7dd72b8f42ddc87c9a88e7c535d5c5d94d6c2c51c
    -size 20728
    +oid sha256:a360f2333c0d5f40cea16b7da81344b99ea18bd23e5c806a97b2d2443b179d59
    +size 20718
    diff --git a/screenshots/86box/f1-h-clock.png b/screenshots/86box/f1-h-clock.png
    index e293aa1d..3c960f64 100644
    --- a/screenshots/86box/f1-h-clock.png
    +++ b/screenshots/86box/f1-h-clock.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0ce6bf30393be569792f8c906b1fe1506ee09aa304d58a1c5ac2c233bfd28d28
    -size 18940
    +oid sha256:47b038f1dacb57e532944605cc0c1ef39802378553953fd8ce052049876c8aaf
    +size 18956
    diff --git a/screenshots/86box/f1-h-cpanel.png b/screenshots/86box/f1-h-cpanel.png
    index a75dbb95..775690f0 100644
    --- a/screenshots/86box/f1-h-cpanel.png
    +++ b/screenshots/86box/f1-h-cpanel.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:e6fe705e41b7a4dc8b969945e455b2d79fd32d517f78b829ded6cfd44324db70
    -size 22663
    +oid sha256:30800dde17d30be96cbd5e8de2072d710eb89c88a2f49fa92136ed0d983affa1
    +size 23161
    diff --git a/screenshots/86box/f1-h-iconed.png b/screenshots/86box/f1-h-iconed.png
    index a2a4f8da..c88408fd 100644
    --- a/screenshots/86box/f1-h-iconed.png
    +++ b/screenshots/86box/f1-h-iconed.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ab09163d20f674e0bd2effd008ea3489f5b3c223355aa1c4790ec8f11edbe2a3
    -size 17919
    +oid sha256:3d48d0d2dcb8e4d9540d23830147904d5d23e35f02ccaf4155ed0f71e00b57a2
    +size 17930
    diff --git a/screenshots/86box/f1-h-imgvie.png b/screenshots/86box/f1-h-imgvie.png
    index 2997b10f..2f5e2f34 100644
    --- a/screenshots/86box/f1-h-imgvie.png
    +++ b/screenshots/86box/f1-h-imgvie.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:551335eede657c3a0ba3d34ea23d8134214f3b7914510cdd02d417f14cd949dc
    -size 19273
    +oid sha256:20a69aa1199ae81c20ce4f7a506bb6f4cae993bf87dce5e363aa1dac097f8081
    +size 19288
    diff --git a/screenshots/86box/f1-h-notepa.png b/screenshots/86box/f1-h-notepa.png
    index edb6ee26..d7321644 100644
    --- a/screenshots/86box/f1-h-notepa.png
    +++ b/screenshots/86box/f1-h-notepa.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8a377752193cd085742ecf9a616c990e897189c20443ca754434a3fb036c47d3
    -size 18895
    +oid sha256:f88980948aa279f05b4e22b59c98fb86299259bf5e0c8bf63ed397656cd136f7
    +size 18896
    diff --git a/screenshots/86box/f1-h-player.png b/screenshots/86box/f1-h-player.png
    new file mode 100644
    index 00000000..f02e0686
    --- /dev/null
    +++ b/screenshots/86box/f1-h-player.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:92aedcc272e3e121db4c7f83114d41465372ad0c0d1a0869eb6013efeb502073
    +size 19021
    diff --git a/screenshots/86box/f1-h-progmn.png b/screenshots/86box/f1-h-progmn.png
    index d01028fe..ad036001 100644
    --- a/screenshots/86box/f1-h-progmn.png
    +++ b/screenshots/86box/f1-h-progmn.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:63826ce353d7dbb386da4d50f7ee526ab96d324db12761727fe418a9b8b857a5
    -size 21918
    +oid sha256:21085ae6d3c9049c88bdb7c0704c643722af5f6e92479264915f295e9b377c99
    +size 22021
    diff --git a/screenshots/86box/f1-h-resedi.png b/screenshots/86box/f1-h-resedi.png
    index 3e519eee..3ce29b45 100644
    --- a/screenshots/86box/f1-h-resedi.png
    +++ b/screenshots/86box/f1-h-resedi.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ae20d685ccbd0230a20c48efa684c0fde6ae1eed48aeaf1a6fedc81b4c2ede50
    -size 20875
    +oid sha256:1a9e3cac4489b2633d731e6ea969c36f641f1404502d73d143d9a6befe02ac00
    +size 20888
    diff --git a/screenshots/86box/filedlg-fd-back.png b/screenshots/86box/filedlg-fd-back.png
    index f579cfe5..9846da68 100644
    --- a/screenshots/86box/filedlg-fd-back.png
    +++ b/screenshots/86box/filedlg-fd-back.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:7382ad12f6411a8448df45ea184d8306423a51e0fbd8bb2d46beeaa34b43ac29
    -size 15234
    +oid sha256:c7a84dbadd39c80e002a54630e71c97343f4f3def3ac0d5dc33eb461a2faa924
    +size 15247
    diff --git a/screenshots/86box/filedlg-fd-list.png b/screenshots/86box/filedlg-fd-list.png
    index e0f6968e..556eb3eb 100644
    --- a/screenshots/86box/filedlg-fd-list.png
    +++ b/screenshots/86box/filedlg-fd-list.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:6c48f8df6224e37237a612f8440c83040123955ea3b7ce6c2c33907df55df575
    -size 15346
    +oid sha256:eaab61394767ee5b4695072c145a64777c68ea0f5d620edbe68b390363e11b39
    +size 15349
    diff --git a/screenshots/86box/filedlg-fd-nordy.png b/screenshots/86box/filedlg-fd-nordy.png
    index 383cd199..18d68769 100644
    --- a/screenshots/86box/filedlg-fd-nordy.png
    +++ b/screenshots/86box/filedlg-fd-nordy.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b6b6cd04bf85ff797c3be50ecff2fe0a3efc61e19ea397fc00c31ec7369f1111
    -size 15443
    +oid sha256:797486eee6e9cc31ea083b929ccc5262d1a612bda098c5f655833e5eec0318a1
    +size 15453
    diff --git a/screenshots/86box/filedlg-fd-open.png b/screenshots/86box/filedlg-fd-open.png
    index 56bee79c..1adec5b7 100644
    --- a/screenshots/86box/filedlg-fd-open.png
    +++ b/screenshots/86box/filedlg-fd-open.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:fa786de9621f9d9a39373e800572fbbce9807a7c432f0330572908f602a2060a
    -size 15205
    +oid sha256:87706be4507c6378bb3b126ec0ba89eb6d86bb1a07e822571d6cd6cecc45ce11
    +size 15176
    diff --git a/screenshots/86box/fullmode-fm-desk.png b/screenshots/86box/fullmode-fm-desk.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/86box/fullmode-fm-desk.png
    +++ b/screenshots/86box/fullmode-fm-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/fullscr-fs-desk.png b/screenshots/86box/fullscr-fs-desk.png
    index c2e26be0..7b0f9490 100644
    --- a/screenshots/86box/fullscr-fs-desk.png
    +++ b/screenshots/86box/fullscr-fs-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:adb402decf2e7dc5f420b0f5d479eaaa0cec605c2e4b58ed44ab6575165a0fd5
    -size 17558
    +oid sha256:29abf7f02187f54f333e23f40a4f4fda4e3d6e306e8153e688553af9209df357
    +size 17506
    diff --git a/screenshots/86box/hang-ended.png b/screenshots/86box/hang-ended.png
    index 87a65414..8f5b923b 100644
    --- a/screenshots/86box/hang-ended.png
    +++ b/screenshots/86box/hang-ended.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:967335f41a1b4b76a584e0eeffbfd607ce3063794b7c9a066ef097e3ce152d3b
    -size 15165
    +oid sha256:3fbb5bd05eb93ea9cd3195ac2990d9c0b538a3322e6f8f4ee1e8a8debcfa169d
    +size 15148
    diff --git a/screenshots/86box/hang-taskmgr.png b/screenshots/86box/hang-taskmgr.png
    index f365e9ad..6a068bc2 100644
    --- a/screenshots/86box/hang-taskmgr.png
    +++ b/screenshots/86box/hang-taskmgr.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2953c993db395b28cbdae6e951af44a949cd201cda152d9ef7e9f4af80a661ec
    -size 16192
    +oid sha256:12e5fd681c5b852d1409666069caac5602cccea5a7017e98838d3e777d203089
    +size 16018
    diff --git a/screenshots/86box/install-in-desk.png b/screenshots/86box/install-in-desk.png
    index 00444c34..7fbf6e91 100644
    --- a/screenshots/86box/install-in-desk.png
    +++ b/screenshots/86box/install-in-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:78ef264c66bdc27492ebbfc01453f7face1b2fbca1f9eb0621836481aae7c499
    -size 18548
    +oid sha256:1fe45de792395dc0311670f3607a216f3944928d61c85e4aa55d0349f2379e09
    +size 18601
    diff --git a/screenshots/86box/lfn-lfnsmk.png b/screenshots/86box/lfn-lfnsmk.png
    index 27ce819e..43e7e7c8 100644
    --- a/screenshots/86box/lfn-lfnsmk.png
    +++ b/screenshots/86box/lfn-lfnsmk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8c9500f652006d5b48f34837dcc059eb25d878fc6d174a04aa2d17d1c137319c
    -size 17034
    +oid sha256:ca206b4f6fa0a6fe6d27bf8ab71106f174b5fd15eef660931d8c574894bed50a
    +size 17119
    diff --git a/screenshots/86box/menus-m-altdn.png b/screenshots/86box/menus-m-altdn.png
    index 95e69470..a3180645 100644
    --- a/screenshots/86box/menus-m-altdn.png
    +++ b/screenshots/86box/menus-m-altdn.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
    -size 16816
    +oid sha256:17dbbfe03c1e4e37960e82731d72920ecd7a1bad53ad51031a4b3ff6e708e8a7
    +size 16401
    diff --git a/screenshots/86box/menus-m-altup.png b/screenshots/86box/menus-m-altup.png
    index 290bda7e..104e2339 100644
    --- a/screenshots/86box/menus-m-altup.png
    +++ b/screenshots/86box/menus-m-altup.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2bd9800a5cf14a50f770c69b5a785724482fdb62aac9ee7de8069aa9c16eb2ed
    -size 16426
    +oid sha256:4746eeae2ccbd52434df49c45e9045fc5c8a47f7a64ce0ab735c2ea62fb84571
    +size 15971
    diff --git a/screenshots/86box/menus-m-design.png b/screenshots/86box/menus-m-design.png
    index 65a83dc5..d54d6915 100644
    --- a/screenshots/86box/menus-m-design.png
    +++ b/screenshots/86box/menus-m-design.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b520534eccc14ec24b1826a4d0ef7fe0c9e7d56f86befef504de05d7faa25b46
    -size 23621
    +oid sha256:c1eeb4d3e4cb61600af378d7f36c0f96290f74fe2da8fa14066ef8e779d1459c
    +size 23804
    diff --git a/screenshots/86box/menus-m-drop.png b/screenshots/86box/menus-m-drop.png
    index 95e69470..a3180645 100644
    --- a/screenshots/86box/menus-m-drop.png
    +++ b/screenshots/86box/menus-m-drop.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
    -size 16816
    +oid sha256:17dbbfe03c1e4e37960e82731d72920ecd7a1bad53ad51031a4b3ff6e708e8a7
    +size 16401
    diff --git a/screenshots/86box/menus-m-file.png b/screenshots/86box/menus-m-file.png
    index bedb5c3d..62513274 100644
    --- a/screenshots/86box/menus-m-file.png
    +++ b/screenshots/86box/menus-m-file.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:5805a5feb76aa404a93656e174b878dbce68fea3cb26a178b2ef6803e864611c
    -size 14731
    +oid sha256:e2197b06a2e6ced93066e8a8c96b085866685af9e117284a4e7061d547156633
    +size 14721
    diff --git a/screenshots/86box/menus-m-run.png b/screenshots/86box/menus-m-run.png
    index 08415ab8..309d2dc5 100644
    --- a/screenshots/86box/menus-m-run.png
    +++ b/screenshots/86box/menus-m-run.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1cacc50a8de3c2c2594d2ab5f99adce74b3fd124f67c49652767f843a475a5b1
    -size 18218
    +oid sha256:2661a25a8cfcde559ce0b39092629459bb6e7c8618cbac4c9524da4d7cb3e622
    +size 18231
    diff --git a/screenshots/86box/midiin-rc-midi.png b/screenshots/86box/midiin-rc-midi.png
    new file mode 100644
    index 00000000..4fcb13ba
    --- /dev/null
    +++ b/screenshots/86box/midiin-rc-midi.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cd8a9e6857d3154e1bcbee3ed5e6ee46ff25c49ef23275f5f178f2a1ec437383
    +size 16033
    diff --git a/screenshots/86box/net-netsmk.png b/screenshots/86box/net-netsmk.png
    index e6ed02ab..c7e8436a 100644
    --- a/screenshots/86box/net-netsmk.png
    +++ b/screenshots/86box/net-netsmk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2d559bf9d6b37813421ef3623c2c49427cb3356b0ad887e776f352f84f3fb54b
    -size 16779
    +oid sha256:6b99df6febae1feac39833afaa1ffb12c28c589632d8af95405fb55effeb3da1
    +size 16763
    diff --git a/screenshots/86box/pardsgn-pardsgn.png b/screenshots/86box/pardsgn-pardsgn.png
    index ab8ba75f..9da764d4 100644
    --- a/screenshots/86box/pardsgn-pardsgn.png
    +++ b/screenshots/86box/pardsgn-pardsgn.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:9d93f1f23f8ffc5fedd123ef6156982bd420a237247c7bf2b2e1a40029119945
    -size 14439
    +oid sha256:99b20a168970a3135ca8f7634a3a5896abc18ba673b33b9e20a2447b41d04d30
    +size 14438
    diff --git a/screenshots/86box/parhlp-parhlp-l.png b/screenshots/86box/parhlp-parhlp-l.png
    index ba97be84..433432b8 100644
    --- a/screenshots/86box/parhlp-parhlp-l.png
    +++ b/screenshots/86box/parhlp-parhlp-l.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1466e4d7946dc061dbb1c3485acb38ee6cbcd0930806a25b48fc355f641a2020
    -size 15611
    +oid sha256:582f9570cb535cda8be533c31da0f425a8ef42c24e630511b5125b53058086fd
    +size 15605
    diff --git a/screenshots/86box/parhlp-parhlp.png b/screenshots/86box/parhlp-parhlp.png
    index 72a5f511..be99b909 100644
    --- a/screenshots/86box/parhlp-parhlp.png
    +++ b/screenshots/86box/parhlp-parhlp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c114ad172296848ae10b9e4cd85574be604f42018ebb95024199603713ac0fda
    -size 15611
    +oid sha256:b4428ce7fb858c787b2bdb070c0c16f3d9190785cdb51f082046fa0e94bff114
    +size 15603
    diff --git a/screenshots/86box/parpm-parpm.png b/screenshots/86box/parpm-parpm.png
    index 08b4193d..a50f176d 100644
    --- a/screenshots/86box/parpm-parpm.png
    +++ b/screenshots/86box/parpm-parpm.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:032f48556016077f70afc3308a88898fee372db590b371ba381e6408472626d5
    -size 17348
    +oid sha256:4fc2c96e65240cabcb060dbc0c92e4c59d5424afb2790448e3e66ecffe985f5a
    +size 17400
    diff --git a/screenshots/86box/paths-paths.png b/screenshots/86box/paths-paths.png
    index 2b823c9e..879cd012 100644
    --- a/screenshots/86box/paths-paths.png
    +++ b/screenshots/86box/paths-paths.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:58503a736c280538316a7afa3e0d14485397b3122fc68a04a4ad94515662379d
    -size 16660
    +oid sha256:53ad7a1a3d0f2b1838d3f7544ffd8c82a39c3763defa4058cf7b6c331b82f132
    +size 16687
    diff --git a/screenshots/86box/player-p-mid.png b/screenshots/86box/player-p-mid.png
    new file mode 100644
    index 00000000..ee4771e1
    --- /dev/null
    +++ b/screenshots/86box/player-p-mid.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:fb2c251bc81190cf753890c9acb4b528d5637a8b3029dc6c7d37d17d5c8f68a3
    +size 14998
    diff --git a/screenshots/86box/player-p-mod.png b/screenshots/86box/player-p-mod.png
    new file mode 100644
    index 00000000..eee36d8d
    --- /dev/null
    +++ b/screenshots/86box/player-p-mod.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d2115cb3af475de499f43b42efaa5ecedcb88ceede12d1238406512fb2c4de71
    +size 14530
    diff --git a/screenshots/86box/player-p-voc.png b/screenshots/86box/player-p-voc.png
    new file mode 100644
    index 00000000..67e72d18
    --- /dev/null
    +++ b/screenshots/86box/player-p-voc.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:9375379dc9066aa01d0f857c6387134cba2f2e1d29315babade0d1fe062e63d6
    +size 14712
    diff --git a/screenshots/86box/player-p-wav.png b/screenshots/86box/player-p-wav.png
    new file mode 100644
    index 00000000..8c4ca0b1
    --- /dev/null
    +++ b/screenshots/86box/player-p-wav.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:81ab1b214f13c292b7034488d9001d405c9fa9fce3b649c412fb98bb4d5ff9da
    +size 13944
    diff --git a/screenshots/86box/print-print1.png b/screenshots/86box/print-print1.png
    index c82bc456..64f230bd 100644
    --- a/screenshots/86box/print-print1.png
    +++ b/screenshots/86box/print-print1.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ad64cbc363a6f7ec0491d1ea4e480ed1b48d2b47c09363c722cd89d2bd5d56ad
    -size 14872
    +oid sha256:211b7ed636aabb8995a4e3e2deedfe4231aa7a053fba9cd03302013ff3c7d6ca
    +size 14867
    diff --git a/screenshots/86box/record-rc-open.png b/screenshots/86box/record-rc-open.png
    new file mode 100644
    index 00000000..b72ad1d9
    --- /dev/null
    +++ b/screenshots/86box/record-rc-open.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d69371019843a140889ff6b81c8813ae24288caeee4507bee430dcf75e33cd93
    +size 15955
    diff --git a/screenshots/86box/safevid-sv-desk.png b/screenshots/86box/safevid-sv-desk.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/86box/safevid-sv-desk.png
    +++ b/screenshots/86box/safevid-sv-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/saver-sv-back.png b/screenshots/86box/saver-sv-back.png
    new file mode 100644
    index 00000000..15ff8dc1
    --- /dev/null
    +++ b/screenshots/86box/saver-sv-back.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/86box/saver-sv-blank.png b/screenshots/86box/saver-sv-blank.png
    new file mode 100644
    index 00000000..cfe14ee9
    --- /dev/null
    +++ b/screenshots/86box/saver-sv-blank.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:db875c74be9d218490440cedf7e28ebd06dd070fd4ceacbf34613e08798982e7
    +size 1928
    diff --git a/screenshots/86box/saver-sv-cpd.png b/screenshots/86box/saver-sv-cpd.png
    new file mode 100644
    index 00000000..d15c8fe4
    --- /dev/null
    +++ b/screenshots/86box/saver-sv-cpd.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:77deb8e3c185672514bdb5ecfd1a0a1e4029530c7b2c9a658281223c3b5b833f
    +size 14857
    diff --git a/screenshots/86box/sdk-h-sdkapp.png b/screenshots/86box/sdk-h-sdkapp.png
    index 51487527..f55a89f1 100644
    --- a/screenshots/86box/sdk-h-sdkapp.png
    +++ b/screenshots/86box/sdk-h-sdkapp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:41b5260accda5a08f0fb80acd96eba344ac23a0375d7b0d08bf03189cfcbda02
    -size 16834
    +oid sha256:6c5c19fea8dc7918d3a13927feb14e7f2497e7b60a28b0e6df8f069c607c2399
    +size 16860
    diff --git a/screenshots/86box/sdk-sdkapp.png b/screenshots/86box/sdk-sdkapp.png
    index 323617f2..baac89fd 100644
    --- a/screenshots/86box/sdk-sdkapp.png
    +++ b/screenshots/86box/sdk-sdkapp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:af5d5842a32438d1b385e419e7fbf828e01e6b80ae36f4b22836e754d0473fb7
    -size 17648
    +oid sha256:64fdda7f9f947dbe9e575486bb3aab472947df0e6952c3814ef37517f035d2a2
    +size 17662
    diff --git a/screenshots/86box/sdk-sdkbas.png b/screenshots/86box/sdk-sdkbas.png
    index e4fcbf90..0baf4b36 100644
    --- a/screenshots/86box/sdk-sdkbas.png
    +++ b/screenshots/86box/sdk-sdkbas.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c8e1ec94de567e5720ad98859d35615b94e8407ecb7f360433024884925c7b44
    -size 16835
    +oid sha256:901624bb829f6d3183a65a9fe5e07574135feee06bc875fb38c758a419b068e8
    +size 16723
    diff --git a/screenshots/86box/sdk-sdkgreet.png b/screenshots/86box/sdk-sdkgreet.png
    index 50ba0573..bb1f7484 100644
    --- a/screenshots/86box/sdk-sdkgreet.png
    +++ b/screenshots/86box/sdk-sdkgreet.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:963ffc7f70050d21d40be5d6212dc8cd74dcb0ed55dd80811a93c4f1a80d35a2
    -size 16847
    +oid sha256:aa33e5fbe856662b8a15e73c7ff774b301454c3084df4876e1bbf651d3c25fd3
    +size 16838
    diff --git a/screenshots/86box/sdk-sdktbx.png b/screenshots/86box/sdk-sdktbx.png
    index 6b5272d7..24260f82 100644
    --- a/screenshots/86box/sdk-sdktbx.png
    +++ b/screenshots/86box/sdk-sdktbx.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ddbac3d1aa96225dcb75dbb31a10d3be96a14ea7a143b7929c1dfeb984dcf6a0
    -size 23525
    +oid sha256:fb2e99f81d2fd46ebf15c15fbd18dc40290b12aef056ce503bee8d34c72e5db8
    +size 23699
    diff --git a/screenshots/86box/sdkgame-sdkgame.png b/screenshots/86box/sdkgame-sdkgame.png
    index 1674f772..6d68fe11 100644
    --- a/screenshots/86box/sdkgame-sdkgame.png
    +++ b/screenshots/86box/sdkgame-sdkgame.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0cc69633147d663f0529171d4ecbf484af883910f88afff826da778f590f4ecb
    -size 14704
    +oid sha256:8fac6dcd60199c3d55a21dcf16fac1ea45576356f2616bc7c7a08264ca6d2e7c
    +size 14519
    diff --git a/screenshots/86box/software-sw-done.png b/screenshots/86box/software-sw-done.png
    index 91354225..57f4b35c 100644
    --- a/screenshots/86box/software-sw-done.png
    +++ b/screenshots/86box/software-sw-done.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b67f1571788b597396f49cfd195c8d5e7189726deddefa48f6f2f814ce5683c0
    -size 16954
    +oid sha256:fb87800e15edba0b804e70e95f56656184d4a949b8e78afbe8bc8fcfff056889
    +size 16948
    diff --git a/screenshots/86box/software-sw-plan.png b/screenshots/86box/software-sw-plan.png
    index 441de340..8e06251a 100644
    --- a/screenshots/86box/software-sw-plan.png
    +++ b/screenshots/86box/software-sw-plan.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8f09da4c5d11fbe26a2c29aa35ab702dcced4dbbe764a631548e6ea2ff465504
    -size 18411
    +oid sha256:0d2eea6d6d92b615c178e12e004fbf4d74a029322e6a9634c1ec90a033b28105
    +size 18391
    diff --git a/screenshots/86box/sql-sqlsmk.png b/screenshots/86box/sql-sqlsmk.png
    index aecc479c..91eecc55 100644
    --- a/screenshots/86box/sql-sqlsmk.png
    +++ b/screenshots/86box/sql-sqlsmk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:929127846378dfc4a48ec57d28bad1491a02d51fbeb4ae19212561e8a4bbb7e3
    -size 16921
    +oid sha256:6f0385872d52d227121faf4e1453cfbacae6e6be7b9568743fae5d9e53eeed3f
    +size 16932
    diff --git a/screenshots/86box/ucdemo-ucdemo.png b/screenshots/86box/ucdemo-ucdemo.png
    index a007a82e..410cb0bc 100644
    --- a/screenshots/86box/ucdemo-ucdemo.png
    +++ b/screenshots/86box/ucdemo-ucdemo.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:788aadd3810a1f056f07ae5c8bbdfe0413439dbedd403672a40e719aad43a654
    -size 15947
    +oid sha256:942eaa7a6c067a823b30d0a337c18638964bf01ac8c21861f46db4a509b628f7
    +size 15850
    diff --git a/screenshots/86box/ucide-ucide-ds.png b/screenshots/86box/ucide-ucide-ds.png
    index c6c86b18..26601f99 100644
    --- a/screenshots/86box/ucide-ucide-ds.png
    +++ b/screenshots/86box/ucide-ucide-ds.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c377444a4b6e7369fa35a92e916c3f86d90a3d95e961f16b3c8471464776712a
    -size 31429
    +oid sha256:98eea34512e3f348a3041b12b6ca7ef522f9edcabe494a0f15d2a8e896cf9a1e
    +size 31561
    diff --git a/screenshots/86box/ucide-ucide-ru.png b/screenshots/86box/ucide-ucide-ru.png
    index b8aefcce..bcc692f5 100644
    --- a/screenshots/86box/ucide-ucide-ru.png
    +++ b/screenshots/86box/ucide-ucide-ru.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1eef572ce0f038dcadc4f2c4acefca46008c44589362cba01d43c3df697f2034
    -size 25351
    +oid sha256:1d7326ed86366bbe1b3359ee7235ba5f6e538c613bccd0b68c8e5bae4e45e7d7
    +size 25273
    diff --git a/screenshots/86box/ucide-ucide-tb.png b/screenshots/86box/ucide-ucide-tb.png
    index aff9255b..646e96b4 100644
    --- a/screenshots/86box/ucide-ucide-tb.png
    +++ b/screenshots/86box/ucide-ucide-tb.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2a8dc683b59b689949ec7da17d049d42f8bbb16ba7ba7694d96546a747ca6462
    -size 31580
    +oid sha256:77eaddc942ed51d860f97a5701db753b7010a68fa5d74088922e1d10cc5ef039
    +size 31708
    diff --git a/screenshots/86box/video-v-800.png b/screenshots/86box/video-v-800.png
    index 3ad661b2..e5db0736 100644
    --- a/screenshots/86box/video-v-800.png
    +++ b/screenshots/86box/video-v-800.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:60fd02f0d1a4a0dfcf9a542c2ee25d352a6aac068ea68d06450e671df055bd54
    -size 26736
    +oid sha256:116b45917198465fb7b0d5754b8ad6bd051fe7973513df6b2bec798b4f123484
    +size 27408
    diff --git a/screenshots/86box/video-v-pick.png b/screenshots/86box/video-v-pick.png
    index 2d0f7057..c82dea0e 100644
    --- a/screenshots/86box/video-v-pick.png
    +++ b/screenshots/86box/video-v-pick.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:299f307b02e46be9d74b27c141734049f39f710e6cb11036d6065320c068cee7
    -size 20592
    +oid sha256:fd390e8064ab4e69f1e153d2a307dc6573a7f10838a8dd91fc9ede61490e263c
    +size 20877
    diff --git a/screenshots/86box/winops-w-back.png b/screenshots/86box/winops-w-back.png
    index be07800e..30e1daa5 100644
    --- a/screenshots/86box/winops-w-back.png
    +++ b/screenshots/86box/winops-w-back.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:7f11737da501c34b2ff60c4aa9793bbfb61d8249fa1963980baddafb039d39c6
    -size 13351
    +oid sha256:b03a051f539425d65c03f55ea55d268b7b5e5bc67a0a5d6a9adf91476a077c4b
    +size 13342
    diff --git a/screenshots/86box/winops-w-min.png b/screenshots/86box/winops-w-min.png
    index 0764e0ab..df5efc3d 100644
    --- a/screenshots/86box/winops-w-min.png
    +++ b/screenshots/86box/winops-w-min.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:9d27acebba6cb4ec6c56b2f4cc243456fead36d108df990672b1ed977038c4b4
    -size 18646
    +oid sha256:15ab809411b9700e376cbf85fac70eea54048454944da138591b11a2d0371a30
    +size 18613
    diff --git a/screenshots/86box/winops-w-move.png b/screenshots/86box/winops-w-move.png
    index b47f2f37..8eace288 100644
    --- a/screenshots/86box/winops-w-move.png
    +++ b/screenshots/86box/winops-w-move.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c521f421f2386015fde0b8549294d22b2dc41da962bf0fd1a6c54d6d8279aa1f
    -size 13484
    +oid sha256:47c8b321c4d4195a5ceb0d714b8060f0dac35ecea4b823dac23c2c6008d33d03
    +size 13472
    diff --git a/screenshots/86box/winops-w-rest.png b/screenshots/86box/winops-w-rest.png
    index e7af769f..290eacc3 100644
    --- a/screenshots/86box/winops-w-rest.png
    +++ b/screenshots/86box/winops-w-rest.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:65dc513b0eb279c182bebb578475fb679a276264fc0f10ea4bf2ee11566f7689
    -size 13362
    +oid sha256:fc09124460ff651d26956a59f0c72338efa7edb38561b2bd535513dcfdf0fb59
    +size 13376
    diff --git a/screenshots/86box/winops-w-size.png b/screenshots/86box/winops-w-size.png
    index 6105bc85..750ba2c9 100644
    --- a/screenshots/86box/winops-w-size.png
    +++ b/screenshots/86box/winops-w-size.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ddb01ff055fb3275275546a598f70d27f1db2ea4a88dcccbc0cb9256924a49e6
    -size 13319
    +oid sha256:69dd279e18f40ef4a337f2e1455992924c7b465d39692060b4fb761e91edcf5e
    +size 13329
    diff --git a/screenshots/dosbox/addons-addon-no.png b/screenshots/dosbox/addons-addon-no.png
    index 07e38fee..3b255300 100644
    --- a/screenshots/dosbox/addons-addon-no.png
    +++ b/screenshots/dosbox/addons-addon-no.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:87a79d1bb9a57eba5bc045892b359d85c20a332a95de259c233ec36b1de3ef2e
    -size 18229
    +oid sha256:ff64f081e2927482a5cc2dbf8470b9bf95da1d46ff64a7c146f5c1898020a2e1
    +size 18045
    diff --git a/screenshots/dosbox/apps-clock.png b/screenshots/dosbox/apps-clock.png
    index e4a58726..27767c4b 100644
    --- a/screenshots/dosbox/apps-clock.png
    +++ b/screenshots/dosbox/apps-clock.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:fb0cf0a293985c16f50cddb6287044fd462a35f99a88bba055ea9f4e4f9b403d
    -size 18018
    +oid sha256:018eafb9168ef5da0224b0ef56dc3ef8f2ad150a3ddb2c5f0e6445583cc2b980
    +size 18024
    diff --git a/screenshots/dosbox/apps-cpanel.png b/screenshots/dosbox/apps-cpanel.png
    index eb7203d1..7c2c7af8 100644
    --- a/screenshots/dosbox/apps-cpanel.png
    +++ b/screenshots/dosbox/apps-cpanel.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
    -size 16427
    +oid sha256:88949a0a11f46387060a4e4a5faaca763bd1fd8da35bae21bce78e78e74b3c02
    +size 15931
    diff --git a/screenshots/dosbox/apps-desktop.png b/screenshots/dosbox/apps-desktop.png
    index 9fc09f1a..a090d4e6 100644
    --- a/screenshots/dosbox/apps-desktop.png
    +++ b/screenshots/dosbox/apps-desktop.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0a697ed80887720ffc0e258516c4a59068e86521f79c4d9d777e5b6d703a73cf
    -size 18588
    +oid sha256:a12c2d2fcac3b118c581b7cab7237adb773677a002781b4819728fa927ed9130
    +size 18563
    diff --git a/screenshots/dosbox/apps-dvxbasic.png b/screenshots/dosbox/apps-dvxbasic.png
    index 16b7bf11..3566300e 100644
    --- a/screenshots/dosbox/apps-dvxbasic.png
    +++ b/screenshots/dosbox/apps-dvxbasic.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c5b9aedac500ad8ff4cbbb7b59bf49751f4bde23f6e8f8a3ae9f0f34424307a1
    -size 18393
    +oid sha256:f36dae98688d62bd81a87e6129466223b5604ad6100b0b03bf358c72c0c62e54
    +size 18388
    diff --git a/screenshots/dosbox/apps-dvxhelp.png b/screenshots/dosbox/apps-dvxhelp.png
    index 1bd4ab98..878b2d82 100644
    --- a/screenshots/dosbox/apps-dvxhelp.png
    +++ b/screenshots/dosbox/apps-dvxhelp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:5e2c8e671952f6ddfb618246e4b20fb37acc9dc23aa3f9a1616a188a78f3a974
    -size 21764
    +oid sha256:4e557895dd4541eea4a1ff516cf44c08beb425ac7dc01359ddaaa914ee873430
    +size 21777
    diff --git a/screenshots/dosbox/apps-iconed.png b/screenshots/dosbox/apps-iconed.png
    index 658cb98c..a9b291b1 100644
    --- a/screenshots/dosbox/apps-iconed.png
    +++ b/screenshots/dosbox/apps-iconed.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:35e2e173eb0155a1799f985effc2d4100ea31be802ad7e312d5ca66751601fa0
    -size 14832
    +oid sha256:9e4cdc9de6d03c087b222bd6bb1a134718847b9f804098fb16e8c3f8c2608de5
    +size 14813
    diff --git a/screenshots/dosbox/apps-imgview.png b/screenshots/dosbox/apps-imgview.png
    index 65619ad3..b6c84679 100644
    --- a/screenshots/dosbox/apps-imgview.png
    +++ b/screenshots/dosbox/apps-imgview.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:20216cfc2f5c629596ee18af24de18501b2431af8ee731ceb5be4427e219661b
    -size 12892
    +oid sha256:9861190daaec8871a3fbab7bd5a3913ccd8a1e7fdfe990900ebc4013ce6a763d
    +size 12874
    diff --git a/screenshots/dosbox/apps-notepad.png b/screenshots/dosbox/apps-notepad.png
    index f80814d6..c3ac5869 100644
    --- a/screenshots/dosbox/apps-notepad.png
    +++ b/screenshots/dosbox/apps-notepad.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1ffe8d8b71867ed7b7c676213a0b715f4de49565e1e83f0694466d2d701673cb
    -size 13754
    +oid sha256:3cc900212b164e48a971c16706b2a1f50319dbc1936e2864c5550e07ab6c631c
    +size 13751
    diff --git a/screenshots/dosbox/apps-player.png b/screenshots/dosbox/apps-player.png
    new file mode 100644
    index 00000000..7305aa21
    --- /dev/null
    +++ b/screenshots/dosbox/apps-player.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:52310f0edc3d1689175593eddb3e0a06345b933605ebecf5d47ca63987afe488
    +size 13332
    diff --git a/screenshots/dosbox/apps-resedit.png b/screenshots/dosbox/apps-resedit.png
    index 4becf7fd..10bc49a9 100644
    --- a/screenshots/dosbox/apps-resedit.png
    +++ b/screenshots/dosbox/apps-resedit.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:3fc86dd4c3acc583cdb962e02b1c3a563a67bd5d84c0ed1a19314937ab191c51
    -size 14168
    +oid sha256:fffa0df7faae7202429cf3c5e782703f68ceb804bb52177c0088f0f2c67e3288
    +size 14176
    diff --git a/screenshots/dosbox/banked-bk-back.png b/screenshots/dosbox/banked-bk-back.png
    index 7976b145..9c404784 100644
    --- a/screenshots/dosbox/banked-bk-back.png
    +++ b/screenshots/dosbox/banked-bk-back.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:3425e4a35d856bef29f68f9732c7fad18669dfd3ebd1178835c0191827029cfb
    -size 18540
    +oid sha256:28c79ae8f56c95b2bcfc337305fd2503a44b4a11cd21cae8bb2d8075288ce389
    +size 18520
    diff --git a/screenshots/dosbox/banked-bk-desk.png b/screenshots/dosbox/banked-bk-desk.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/dosbox/banked-bk-desk.png
    +++ b/screenshots/dosbox/banked-bk-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/banked-bk-drag.png b/screenshots/dosbox/banked-bk-drag.png
    index 51540044..683e86c0 100644
    --- a/screenshots/dosbox/banked-bk-drag.png
    +++ b/screenshots/dosbox/banked-bk-drag.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:d815eedadc4035fea2027775eb340ae6b33b2b4f9bf90115ddacfe565096f7d9
    -size 15053
    +oid sha256:914ffc97631ee498aa1422cde129c85adfd627f1b1ab9b0957ade2e4ec08dd34
    +size 14781
    diff --git a/screenshots/dosbox/banked-bk-note.png b/screenshots/dosbox/banked-bk-note.png
    index f8d5861d..c3ac5869 100644
    --- a/screenshots/dosbox/banked-bk-note.png
    +++ b/screenshots/dosbox/banked-bk-note.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:cbbc99f6095370b23c0cda96486df13fb5c1e6654ff371b9a67b8fe62a116eda
    -size 13750
    +oid sha256:3cc900212b164e48a971c16706b2a1f50319dbc1936e2864c5550e07ab6c631c
    +size 13751
    diff --git a/screenshots/dosbox/boot-boot.png b/screenshots/dosbox/boot-boot.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/dosbox/boot-boot.png
    +++ b/screenshots/dosbox/boot-boot.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/cpanel-cp-color.png b/screenshots/dosbox/cpanel-cp-color.png
    index 5261386c..afd39ff6 100644
    --- a/screenshots/dosbox/cpanel-cp-color.png
    +++ b/screenshots/dosbox/cpanel-cp-color.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:660bf211134751affcd3a05e3d5e8e8c297cda750a417873a642c2ee72861542
    -size 18266
    +oid sha256:e66c7fb68c7e1ae2a6e0f140254fab01fa9d355e0cd714e97982666b27968420
    +size 18719
    diff --git a/screenshots/dosbox/cpanel-cp-desk.png b/screenshots/dosbox/cpanel-cp-desk.png
    index c912aaa5..1bc6c33c 100644
    --- a/screenshots/dosbox/cpanel-cp-desk.png
    +++ b/screenshots/dosbox/cpanel-cp-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:97022e5d29c1c26e7d47625d3fb54121173c54b9f58f884a17c451a555a3b3b3
    -size 14436
    +oid sha256:9030c3b8ed8d9e54aed50f1ab5d56a015e7938ea038ee487e3f188235de8e699
    +size 15016
    diff --git a/screenshots/dosbox/cpanel-cp-joy.png b/screenshots/dosbox/cpanel-cp-joy.png
    index a2a740d4..0e0b2aaa 100644
    --- a/screenshots/dosbox/cpanel-cp-joy.png
    +++ b/screenshots/dosbox/cpanel-cp-joy.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:eb4bdad8d10caa593d3cfab42126f537714a1d2ea70e06ca64656e26fbecb785
    -size 16334
    +oid sha256:a27192d2ccf98d8d10ff977d4ed3c8bcda6a911c1f9578001b1219a02ade9346
    +size 15833
    diff --git a/screenshots/dosbox/cpanel-cp-mouse.png b/screenshots/dosbox/cpanel-cp-mouse.png
    index eb7203d1..7c2c7af8 100644
    --- a/screenshots/dosbox/cpanel-cp-mouse.png
    +++ b/screenshots/dosbox/cpanel-cp-mouse.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:6bf51593a279f8352dde3ef83d94942cb7d82d3522c91d2a3b43758674bd3535
    -size 16427
    +oid sha256:88949a0a11f46387060a4e4a5faaca763bd1fd8da35bae21bce78e78e74b3c02
    +size 15931
    diff --git a/screenshots/dosbox/cpanel-cp-net.png b/screenshots/dosbox/cpanel-cp-net.png
    index 8a0078a8..c05764ef 100644
    --- a/screenshots/dosbox/cpanel-cp-net.png
    +++ b/screenshots/dosbox/cpanel-cp-net.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:27f0996dda3d29cd134257c40b19a6ef215ce45162999250c704004570995426
    -size 16199
    +oid sha256:11daa8703e086e9c3b3aa60d075616204bf2d9f7d228ffa86a0538dcbba25b0f
    +size 15677
    diff --git a/screenshots/dosbox/cpanel-cp-print.png b/screenshots/dosbox/cpanel-cp-print.png
    index 6b3a71c0..10aa46db 100644
    --- a/screenshots/dosbox/cpanel-cp-print.png
    +++ b/screenshots/dosbox/cpanel-cp-print.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:e35a2a6c663538c45692d66e5335d808e323bda1c5f1c53e51f1124a2e6cba85
    -size 15909
    +oid sha256:f36a45f38c72c9997d4b91ae8166beb9d557d304d2c298c718906dd5c0fb7f75
    +size 16993
    diff --git a/screenshots/dosbox/cpanel-cp-sound.png b/screenshots/dosbox/cpanel-cp-sound.png
    index 8b22b99b..0d2ceba7 100644
    --- a/screenshots/dosbox/cpanel-cp-sound.png
    +++ b/screenshots/dosbox/cpanel-cp-sound.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8af0d409e9b907747748030acb9e6974893bdd3a426d142d679a023bfd1ac6ce
    -size 17643
    +oid sha256:685f0c278368b5a74e5aa3710c0dc03a71b2f8f0bf1d2519309957090d7bcbbf
    +size 18726
    diff --git a/screenshots/dosbox/cpanel-cp-types.png b/screenshots/dosbox/cpanel-cp-types.png
    index 1e405480..0b7d27dd 100644
    --- a/screenshots/dosbox/cpanel-cp-types.png
    +++ b/screenshots/dosbox/cpanel-cp-types.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:53601bf8609488ed1a85b388e405854ec038f216b2cf6d4350d749be05195dd5
    -size 23172
    +oid sha256:2749e1d0ca5ca17f97455a0e6a4200d587f3b0961b135ce80fb37ee586325ef8
    +size 24863
    diff --git a/screenshots/dosbox/cpanel-cp-video.png b/screenshots/dosbox/cpanel-cp-video.png
    index e9b7cb6f..051600cc 100644
    --- a/screenshots/dosbox/cpanel-cp-video.png
    +++ b/screenshots/dosbox/cpanel-cp-video.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:d4cc901354dfe241498eab49317b5c9c91603c4c582209dbec21973b61054c7b
    -size 20198
    +oid sha256:f2fb899519c6fa3f2ece1475e58d57e038cce4903d1e43dc512327f488fe0432
    +size 20329
    diff --git a/screenshots/dosbox/dosprog-dp-after.png b/screenshots/dosbox/dosprog-dp-after.png
    new file mode 100644
    index 00000000..15ff8dc1
    --- /dev/null
    +++ b/screenshots/dosbox/dosprog-dp-after.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/dosprog-dp-ask.png b/screenshots/dosbox/dosprog-dp-ask.png
    new file mode 100644
    index 00000000..4fa453f2
    --- /dev/null
    +++ b/screenshots/dosbox/dosprog-dp-ask.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d1eac79fc717600f2dbd52fd23090afb5d214ba255bc962e8c447394fc0da496
    +size 17445
    diff --git a/screenshots/dosbox/dosprog-dp-back.png b/screenshots/dosbox/dosprog-dp-back.png
    new file mode 100644
    index 00000000..15ff8dc1
    --- /dev/null
    +++ b/screenshots/dosbox/dosprog-dp-back.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/exit-cancel.png b/screenshots/dosbox/exit-cancel.png
    index e565888c..06fbf701 100644
    --- a/screenshots/dosbox/exit-cancel.png
    +++ b/screenshots/dosbox/exit-cancel.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:f7508aa18520dc0675e54492895fe3e348ff659bf52b6c0c7f2efe3e6035c38d
    -size 14342
    +oid sha256:60f5b5910fb8aae98bc0beb01d144a15fc8622c9966f0ff6d4bc4bf894e5517e
    +size 14349
    diff --git a/screenshots/dosbox/exit-savechg.png b/screenshots/dosbox/exit-savechg.png
    index 74b3e466..6dcf3389 100644
    --- a/screenshots/dosbox/exit-savechg.png
    +++ b/screenshots/dosbox/exit-savechg.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c375e0886534255d93d2ae1ecf32683d0036cfbfb628320140a7a5fc9b019695
    -size 14348
    +oid sha256:13ec25fc0b583981119747baa7fa24e7e97429106d1c5861be1494304ec29e6b
    +size 14350
    diff --git a/screenshots/dosbox/f1-h-basdem.png b/screenshots/dosbox/f1-h-basdem.png
    index f178ed94..7593a315 100644
    --- a/screenshots/dosbox/f1-h-basdem.png
    +++ b/screenshots/dosbox/f1-h-basdem.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:f5b3bc24e986cc704e74e6b7dd72b8f42ddc87c9a88e7c535d5c5d94d6c2c51c
    -size 20728
    +oid sha256:a360f2333c0d5f40cea16b7da81344b99ea18bd23e5c806a97b2d2443b179d59
    +size 20718
    diff --git a/screenshots/dosbox/f1-h-clock.png b/screenshots/dosbox/f1-h-clock.png
    index e293aa1d..3c960f64 100644
    --- a/screenshots/dosbox/f1-h-clock.png
    +++ b/screenshots/dosbox/f1-h-clock.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0ce6bf30393be569792f8c906b1fe1506ee09aa304d58a1c5ac2c233bfd28d28
    -size 18940
    +oid sha256:47b038f1dacb57e532944605cc0c1ef39802378553953fd8ce052049876c8aaf
    +size 18956
    diff --git a/screenshots/dosbox/f1-h-cpanel.png b/screenshots/dosbox/f1-h-cpanel.png
    index a75dbb95..775690f0 100644
    --- a/screenshots/dosbox/f1-h-cpanel.png
    +++ b/screenshots/dosbox/f1-h-cpanel.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:e6fe705e41b7a4dc8b969945e455b2d79fd32d517f78b829ded6cfd44324db70
    -size 22663
    +oid sha256:30800dde17d30be96cbd5e8de2072d710eb89c88a2f49fa92136ed0d983affa1
    +size 23161
    diff --git a/screenshots/dosbox/f1-h-iconed.png b/screenshots/dosbox/f1-h-iconed.png
    index a2a4f8da..c88408fd 100644
    --- a/screenshots/dosbox/f1-h-iconed.png
    +++ b/screenshots/dosbox/f1-h-iconed.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ab09163d20f674e0bd2effd008ea3489f5b3c223355aa1c4790ec8f11edbe2a3
    -size 17919
    +oid sha256:3d48d0d2dcb8e4d9540d23830147904d5d23e35f02ccaf4155ed0f71e00b57a2
    +size 17930
    diff --git a/screenshots/dosbox/f1-h-imgvie.png b/screenshots/dosbox/f1-h-imgvie.png
    index 2997b10f..2f5e2f34 100644
    --- a/screenshots/dosbox/f1-h-imgvie.png
    +++ b/screenshots/dosbox/f1-h-imgvie.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:551335eede657c3a0ba3d34ea23d8134214f3b7914510cdd02d417f14cd949dc
    -size 19273
    +oid sha256:20a69aa1199ae81c20ce4f7a506bb6f4cae993bf87dce5e363aa1dac097f8081
    +size 19288
    diff --git a/screenshots/dosbox/f1-h-notepa.png b/screenshots/dosbox/f1-h-notepa.png
    index edb6ee26..d7321644 100644
    --- a/screenshots/dosbox/f1-h-notepa.png
    +++ b/screenshots/dosbox/f1-h-notepa.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8a377752193cd085742ecf9a616c990e897189c20443ca754434a3fb036c47d3
    -size 18895
    +oid sha256:f88980948aa279f05b4e22b59c98fb86299259bf5e0c8bf63ed397656cd136f7
    +size 18896
    diff --git a/screenshots/dosbox/f1-h-player.png b/screenshots/dosbox/f1-h-player.png
    new file mode 100644
    index 00000000..f02e0686
    --- /dev/null
    +++ b/screenshots/dosbox/f1-h-player.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:92aedcc272e3e121db4c7f83114d41465372ad0c0d1a0869eb6013efeb502073
    +size 19021
    diff --git a/screenshots/dosbox/f1-h-progmn.png b/screenshots/dosbox/f1-h-progmn.png
    index d01028fe..ad036001 100644
    --- a/screenshots/dosbox/f1-h-progmn.png
    +++ b/screenshots/dosbox/f1-h-progmn.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:63826ce353d7dbb386da4d50f7ee526ab96d324db12761727fe418a9b8b857a5
    -size 21918
    +oid sha256:21085ae6d3c9049c88bdb7c0704c643722af5f6e92479264915f295e9b377c99
    +size 22021
    diff --git a/screenshots/dosbox/f1-h-resedi.png b/screenshots/dosbox/f1-h-resedi.png
    index 3e519eee..3ce29b45 100644
    --- a/screenshots/dosbox/f1-h-resedi.png
    +++ b/screenshots/dosbox/f1-h-resedi.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ae20d685ccbd0230a20c48efa684c0fde6ae1eed48aeaf1a6fedc81b4c2ede50
    -size 20875
    +oid sha256:1a9e3cac4489b2633d731e6ea969c36f641f1404502d73d143d9a6befe02ac00
    +size 20888
    diff --git a/screenshots/dosbox/filedlg-fd-back.png b/screenshots/dosbox/filedlg-fd-back.png
    index f579cfe5..9846da68 100644
    --- a/screenshots/dosbox/filedlg-fd-back.png
    +++ b/screenshots/dosbox/filedlg-fd-back.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:7382ad12f6411a8448df45ea184d8306423a51e0fbd8bb2d46beeaa34b43ac29
    -size 15234
    +oid sha256:c7a84dbadd39c80e002a54630e71c97343f4f3def3ac0d5dc33eb461a2faa924
    +size 15247
    diff --git a/screenshots/dosbox/filedlg-fd-list.png b/screenshots/dosbox/filedlg-fd-list.png
    index eee89a60..7dccbfe0 100644
    --- a/screenshots/dosbox/filedlg-fd-list.png
    +++ b/screenshots/dosbox/filedlg-fd-list.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:6e5d6348e54496727f261d2760cbc13a37de24c8a96a20741c479dcb25602c90
    -size 15447
    +oid sha256:7dd6b181c9e9144228a7f89b94b670543083ec7018ae9f68e10ddcfc45e612c2
    +size 15451
    diff --git a/screenshots/dosbox/filedlg-fd-nordy.png b/screenshots/dosbox/filedlg-fd-nordy.png
    index 383cd199..18d68769 100644
    --- a/screenshots/dosbox/filedlg-fd-nordy.png
    +++ b/screenshots/dosbox/filedlg-fd-nordy.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b6b6cd04bf85ff797c3be50ecff2fe0a3efc61e19ea397fc00c31ec7369f1111
    -size 15443
    +oid sha256:797486eee6e9cc31ea083b929ccc5262d1a612bda098c5f655833e5eec0318a1
    +size 15453
    diff --git a/screenshots/dosbox/filedlg-fd-open.png b/screenshots/dosbox/filedlg-fd-open.png
    index 56bee79c..1adec5b7 100644
    --- a/screenshots/dosbox/filedlg-fd-open.png
    +++ b/screenshots/dosbox/filedlg-fd-open.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:fa786de9621f9d9a39373e800572fbbce9807a7c432f0330572908f602a2060a
    -size 15205
    +oid sha256:87706be4507c6378bb3b126ec0ba89eb6d86bb1a07e822571d6cd6cecc45ce11
    +size 15176
    diff --git a/screenshots/dosbox/fullmode-fm-desk.png b/screenshots/dosbox/fullmode-fm-desk.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/dosbox/fullmode-fm-desk.png
    +++ b/screenshots/dosbox/fullmode-fm-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/fullscr-fs-desk.png b/screenshots/dosbox/fullscr-fs-desk.png
    index c2e26be0..7b0f9490 100644
    --- a/screenshots/dosbox/fullscr-fs-desk.png
    +++ b/screenshots/dosbox/fullscr-fs-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:adb402decf2e7dc5f420b0f5d479eaaa0cec605c2e4b58ed44ab6575165a0fd5
    -size 17558
    +oid sha256:29abf7f02187f54f333e23f40a4f4fda4e3d6e306e8153e688553af9209df357
    +size 17506
    diff --git a/screenshots/dosbox/hang-ended.png b/screenshots/dosbox/hang-ended.png
    index 903e1f9c..e8dd2143 100644
    --- a/screenshots/dosbox/hang-ended.png
    +++ b/screenshots/dosbox/hang-ended.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:20c8a70d7547ebefa20be0b9c5cb5e9a1be589abae0e0eea1aba70977c3fbc61
    -size 15158
    +oid sha256:a8e96d2fa598cf563ef80728c0201e51306b0eb9d7ab4169107d33ac62f279bb
    +size 15155
    diff --git a/screenshots/dosbox/hang-taskmgr.png b/screenshots/dosbox/hang-taskmgr.png
    index 3d00f119..3aba658d 100644
    --- a/screenshots/dosbox/hang-taskmgr.png
    +++ b/screenshots/dosbox/hang-taskmgr.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b4c43dbc18bb351f0053d1862ce9ff76815cb11db0520454868b684f8efce1cc
    -size 16206
    +oid sha256:45d1606cf7d9d2da3dc9f7abcccce1f1bea6b55b95fe4a27f0b0c618a8884065
    +size 16019
    diff --git a/screenshots/dosbox/install-in-desk.png b/screenshots/dosbox/install-in-desk.png
    index 00444c34..7fbf6e91 100644
    --- a/screenshots/dosbox/install-in-desk.png
    +++ b/screenshots/dosbox/install-in-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:78ef264c66bdc27492ebbfc01453f7face1b2fbca1f9eb0621836481aae7c499
    -size 18548
    +oid sha256:1fe45de792395dc0311670f3607a216f3944928d61c85e4aa55d0349f2379e09
    +size 18601
    diff --git a/screenshots/dosbox/menus-m-altdn.png b/screenshots/dosbox/menus-m-altdn.png
    index 95e69470..a3180645 100644
    --- a/screenshots/dosbox/menus-m-altdn.png
    +++ b/screenshots/dosbox/menus-m-altdn.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
    -size 16816
    +oid sha256:17dbbfe03c1e4e37960e82731d72920ecd7a1bad53ad51031a4b3ff6e708e8a7
    +size 16401
    diff --git a/screenshots/dosbox/menus-m-altup.png b/screenshots/dosbox/menus-m-altup.png
    index 290bda7e..104e2339 100644
    --- a/screenshots/dosbox/menus-m-altup.png
    +++ b/screenshots/dosbox/menus-m-altup.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2bd9800a5cf14a50f770c69b5a785724482fdb62aac9ee7de8069aa9c16eb2ed
    -size 16426
    +oid sha256:4746eeae2ccbd52434df49c45e9045fc5c8a47f7a64ce0ab735c2ea62fb84571
    +size 15971
    diff --git a/screenshots/dosbox/menus-m-design.png b/screenshots/dosbox/menus-m-design.png
    index 65a83dc5..d54d6915 100644
    --- a/screenshots/dosbox/menus-m-design.png
    +++ b/screenshots/dosbox/menus-m-design.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b520534eccc14ec24b1826a4d0ef7fe0c9e7d56f86befef504de05d7faa25b46
    -size 23621
    +oid sha256:c1eeb4d3e4cb61600af378d7f36c0f96290f74fe2da8fa14066ef8e779d1459c
    +size 23804
    diff --git a/screenshots/dosbox/menus-m-drop.png b/screenshots/dosbox/menus-m-drop.png
    index 95e69470..a3180645 100644
    --- a/screenshots/dosbox/menus-m-drop.png
    +++ b/screenshots/dosbox/menus-m-drop.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:0e1679bdc50865c2087f3687a210a7677c7175c07b9d050bcf6e04dc21d3d39e
    -size 16816
    +oid sha256:17dbbfe03c1e4e37960e82731d72920ecd7a1bad53ad51031a4b3ff6e708e8a7
    +size 16401
    diff --git a/screenshots/dosbox/menus-m-file.png b/screenshots/dosbox/menus-m-file.png
    index bedb5c3d..62513274 100644
    --- a/screenshots/dosbox/menus-m-file.png
    +++ b/screenshots/dosbox/menus-m-file.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:5805a5feb76aa404a93656e174b878dbce68fea3cb26a178b2ef6803e864611c
    -size 14731
    +oid sha256:e2197b06a2e6ced93066e8a8c96b085866685af9e117284a4e7061d547156633
    +size 14721
    diff --git a/screenshots/dosbox/menus-m-run.png b/screenshots/dosbox/menus-m-run.png
    index 08415ab8..309d2dc5 100644
    --- a/screenshots/dosbox/menus-m-run.png
    +++ b/screenshots/dosbox/menus-m-run.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1cacc50a8de3c2c2594d2ab5f99adce74b3fd124f67c49652767f843a475a5b1
    -size 18218
    +oid sha256:2661a25a8cfcde559ce0b39092629459bb6e7c8618cbac4c9524da4d7cb3e622
    +size 18231
    diff --git a/screenshots/dosbox/midiin-rc-midi.png b/screenshots/dosbox/midiin-rc-midi.png
    new file mode 100644
    index 00000000..4fcb13ba
    --- /dev/null
    +++ b/screenshots/dosbox/midiin-rc-midi.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cd8a9e6857d3154e1bcbee3ed5e6ee46ff25c49ef23275f5f178f2a1ec437383
    +size 16033
    diff --git a/screenshots/dosbox/net-netsmk.png b/screenshots/dosbox/net-netsmk.png
    index e6ed02ab..c7e8436a 100644
    --- a/screenshots/dosbox/net-netsmk.png
    +++ b/screenshots/dosbox/net-netsmk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2d559bf9d6b37813421ef3623c2c49427cb3356b0ad887e776f352f84f3fb54b
    -size 16779
    +oid sha256:6b99df6febae1feac39833afaa1ffb12c28c589632d8af95405fb55effeb3da1
    +size 16763
    diff --git a/screenshots/dosbox/pardsgn-pardsgn.png b/screenshots/dosbox/pardsgn-pardsgn.png
    index ab8ba75f..9da764d4 100644
    --- a/screenshots/dosbox/pardsgn-pardsgn.png
    +++ b/screenshots/dosbox/pardsgn-pardsgn.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:9d93f1f23f8ffc5fedd123ef6156982bd420a237247c7bf2b2e1a40029119945
    -size 14439
    +oid sha256:99b20a168970a3135ca8f7634a3a5896abc18ba673b33b9e20a2447b41d04d30
    +size 14438
    diff --git a/screenshots/dosbox/parhlp-parhlp-l.png b/screenshots/dosbox/parhlp-parhlp-l.png
    index ba97be84..433432b8 100644
    --- a/screenshots/dosbox/parhlp-parhlp-l.png
    +++ b/screenshots/dosbox/parhlp-parhlp-l.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1466e4d7946dc061dbb1c3485acb38ee6cbcd0930806a25b48fc355f641a2020
    -size 15611
    +oid sha256:582f9570cb535cda8be533c31da0f425a8ef42c24e630511b5125b53058086fd
    +size 15605
    diff --git a/screenshots/dosbox/parhlp-parhlp.png b/screenshots/dosbox/parhlp-parhlp.png
    index 72a5f511..be99b909 100644
    --- a/screenshots/dosbox/parhlp-parhlp.png
    +++ b/screenshots/dosbox/parhlp-parhlp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c114ad172296848ae10b9e4cd85574be604f42018ebb95024199603713ac0fda
    -size 15611
    +oid sha256:b4428ce7fb858c787b2bdb070c0c16f3d9190785cdb51f082046fa0e94bff114
    +size 15603
    diff --git a/screenshots/dosbox/parpm-parpm.png b/screenshots/dosbox/parpm-parpm.png
    index cb031cc7..6b631e94 100644
    --- a/screenshots/dosbox/parpm-parpm.png
    +++ b/screenshots/dosbox/parpm-parpm.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:87c7a3bf5f3fdc650ec7d00458828d5dca01d28be6d1ae33ac0b85ccb2023616
    -size 17434
    +oid sha256:698945834aca4b2659a1f02658f2128f135459f9d28a7e10d4d19ce5c22c846d
    +size 17471
    diff --git a/screenshots/dosbox/paths-paths.png b/screenshots/dosbox/paths-paths.png
    index 2b823c9e..879cd012 100644
    --- a/screenshots/dosbox/paths-paths.png
    +++ b/screenshots/dosbox/paths-paths.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:58503a736c280538316a7afa3e0d14485397b3122fc68a04a4ad94515662379d
    -size 16660
    +oid sha256:53ad7a1a3d0f2b1838d3f7544ffd8c82a39c3763defa4058cf7b6c331b82f132
    +size 16687
    diff --git a/screenshots/dosbox/player-p-mid.png b/screenshots/dosbox/player-p-mid.png
    new file mode 100644
    index 00000000..ee4771e1
    --- /dev/null
    +++ b/screenshots/dosbox/player-p-mid.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:fb2c251bc81190cf753890c9acb4b528d5637a8b3029dc6c7d37d17d5c8f68a3
    +size 14998
    diff --git a/screenshots/dosbox/player-p-mod.png b/screenshots/dosbox/player-p-mod.png
    new file mode 100644
    index 00000000..eee36d8d
    --- /dev/null
    +++ b/screenshots/dosbox/player-p-mod.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d2115cb3af475de499f43b42efaa5ecedcb88ceede12d1238406512fb2c4de71
    +size 14530
    diff --git a/screenshots/dosbox/player-p-voc.png b/screenshots/dosbox/player-p-voc.png
    new file mode 100644
    index 00000000..67e72d18
    --- /dev/null
    +++ b/screenshots/dosbox/player-p-voc.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:9375379dc9066aa01d0f857c6387134cba2f2e1d29315babade0d1fe062e63d6
    +size 14712
    diff --git a/screenshots/dosbox/player-p-wav.png b/screenshots/dosbox/player-p-wav.png
    new file mode 100644
    index 00000000..8c4ca0b1
    --- /dev/null
    +++ b/screenshots/dosbox/player-p-wav.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:81ab1b214f13c292b7034488d9001d405c9fa9fce3b649c412fb98bb4d5ff9da
    +size 13944
    diff --git a/screenshots/dosbox/print-print1.png b/screenshots/dosbox/print-print1.png
    index c82bc456..64f230bd 100644
    --- a/screenshots/dosbox/print-print1.png
    +++ b/screenshots/dosbox/print-print1.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ad64cbc363a6f7ec0491d1ea4e480ed1b48d2b47c09363c722cd89d2bd5d56ad
    -size 14872
    +oid sha256:211b7ed636aabb8995a4e3e2deedfe4231aa7a053fba9cd03302013ff3c7d6ca
    +size 14867
    diff --git a/screenshots/dosbox/record-rc-open.png b/screenshots/dosbox/record-rc-open.png
    new file mode 100644
    index 00000000..b72ad1d9
    --- /dev/null
    +++ b/screenshots/dosbox/record-rc-open.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:d69371019843a140889ff6b81c8813ae24288caeee4507bee430dcf75e33cd93
    +size 15955
    diff --git a/screenshots/dosbox/safevid-sv-desk.png b/screenshots/dosbox/safevid-sv-desk.png
    index c93e492a..15ff8dc1 100644
    --- a/screenshots/dosbox/safevid-sv-desk.png
    +++ b/screenshots/dosbox/safevid-sv-desk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:588e46fa25956b91e94945fa2c0ee8f26648a4748da1317a7997b72967defd4e
    -size 18426
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/saver-sv-back.png b/screenshots/dosbox/saver-sv-back.png
    new file mode 100644
    index 00000000..15ff8dc1
    --- /dev/null
    +++ b/screenshots/dosbox/saver-sv-back.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:cea09d3eadedea2fcea39c17002b18035d67234c98d346cf3d950d4cf75df82d
    +size 18427
    diff --git a/screenshots/dosbox/saver-sv-blank.png b/screenshots/dosbox/saver-sv-blank.png
    new file mode 100644
    index 00000000..cfe14ee9
    --- /dev/null
    +++ b/screenshots/dosbox/saver-sv-blank.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:db875c74be9d218490440cedf7e28ebd06dd070fd4ceacbf34613e08798982e7
    +size 1928
    diff --git a/screenshots/dosbox/saver-sv-cpd.png b/screenshots/dosbox/saver-sv-cpd.png
    new file mode 100644
    index 00000000..d15c8fe4
    --- /dev/null
    +++ b/screenshots/dosbox/saver-sv-cpd.png
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:77deb8e3c185672514bdb5ecfd1a0a1e4029530c7b2c9a658281223c3b5b833f
    +size 14857
    diff --git a/screenshots/dosbox/sdk-h-sdkapp.png b/screenshots/dosbox/sdk-h-sdkapp.png
    index 51487527..f55a89f1 100644
    --- a/screenshots/dosbox/sdk-h-sdkapp.png
    +++ b/screenshots/dosbox/sdk-h-sdkapp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:41b5260accda5a08f0fb80acd96eba344ac23a0375d7b0d08bf03189cfcbda02
    -size 16834
    +oid sha256:6c5c19fea8dc7918d3a13927feb14e7f2497e7b60a28b0e6df8f069c607c2399
    +size 16860
    diff --git a/screenshots/dosbox/sdk-sdkapp.png b/screenshots/dosbox/sdk-sdkapp.png
    index 323617f2..baac89fd 100644
    --- a/screenshots/dosbox/sdk-sdkapp.png
    +++ b/screenshots/dosbox/sdk-sdkapp.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:af5d5842a32438d1b385e419e7fbf828e01e6b80ae36f4b22836e754d0473fb7
    -size 17648
    +oid sha256:64fdda7f9f947dbe9e575486bb3aab472947df0e6952c3814ef37517f035d2a2
    +size 17662
    diff --git a/screenshots/dosbox/sdk-sdkbas.png b/screenshots/dosbox/sdk-sdkbas.png
    index e4fcbf90..0baf4b36 100644
    --- a/screenshots/dosbox/sdk-sdkbas.png
    +++ b/screenshots/dosbox/sdk-sdkbas.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c8e1ec94de567e5720ad98859d35615b94e8407ecb7f360433024884925c7b44
    -size 16835
    +oid sha256:901624bb829f6d3183a65a9fe5e07574135feee06bc875fb38c758a419b068e8
    +size 16723
    diff --git a/screenshots/dosbox/sdk-sdkgreet.png b/screenshots/dosbox/sdk-sdkgreet.png
    index 50ba0573..bb1f7484 100644
    --- a/screenshots/dosbox/sdk-sdkgreet.png
    +++ b/screenshots/dosbox/sdk-sdkgreet.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:963ffc7f70050d21d40be5d6212dc8cd74dcb0ed55dd80811a93c4f1a80d35a2
    -size 16847
    +oid sha256:aa33e5fbe856662b8a15e73c7ff774b301454c3084df4876e1bbf651d3c25fd3
    +size 16838
    diff --git a/screenshots/dosbox/sdk-sdktbx.png b/screenshots/dosbox/sdk-sdktbx.png
    index 6b5272d7..24260f82 100644
    --- a/screenshots/dosbox/sdk-sdktbx.png
    +++ b/screenshots/dosbox/sdk-sdktbx.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ddbac3d1aa96225dcb75dbb31a10d3be96a14ea7a143b7929c1dfeb984dcf6a0
    -size 23525
    +oid sha256:fb2e99f81d2fd46ebf15c15fbd18dc40290b12aef056ce503bee8d34c72e5db8
    +size 23699
    diff --git a/screenshots/dosbox/software-sw-done.png b/screenshots/dosbox/software-sw-done.png
    index 91354225..57f4b35c 100644
    --- a/screenshots/dosbox/software-sw-done.png
    +++ b/screenshots/dosbox/software-sw-done.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b67f1571788b597396f49cfd195c8d5e7189726deddefa48f6f2f814ce5683c0
    -size 16954
    +oid sha256:fb87800e15edba0b804e70e95f56656184d4a949b8e78afbe8bc8fcfff056889
    +size 16948
    diff --git a/screenshots/dosbox/software-sw-plan.png b/screenshots/dosbox/software-sw-plan.png
    index 441de340..8e06251a 100644
    --- a/screenshots/dosbox/software-sw-plan.png
    +++ b/screenshots/dosbox/software-sw-plan.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:8f09da4c5d11fbe26a2c29aa35ab702dcced4dbbe764a631548e6ea2ff465504
    -size 18411
    +oid sha256:0d2eea6d6d92b615c178e12e004fbf4d74a029322e6a9634c1ec90a033b28105
    +size 18391
    diff --git a/screenshots/dosbox/sql-sqlsmk.png b/screenshots/dosbox/sql-sqlsmk.png
    index aecc479c..91eecc55 100644
    --- a/screenshots/dosbox/sql-sqlsmk.png
    +++ b/screenshots/dosbox/sql-sqlsmk.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:929127846378dfc4a48ec57d28bad1491a02d51fbeb4ae19212561e8a4bbb7e3
    -size 16921
    +oid sha256:6f0385872d52d227121faf4e1453cfbacae6e6be7b9568743fae5d9e53eeed3f
    +size 16932
    diff --git a/screenshots/dosbox/ucdemo-ucdemo.png b/screenshots/dosbox/ucdemo-ucdemo.png
    index a007a82e..410cb0bc 100644
    --- a/screenshots/dosbox/ucdemo-ucdemo.png
    +++ b/screenshots/dosbox/ucdemo-ucdemo.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:788aadd3810a1f056f07ae5c8bbdfe0413439dbedd403672a40e719aad43a654
    -size 15947
    +oid sha256:942eaa7a6c067a823b30d0a337c18638964bf01ac8c21861f46db4a509b628f7
    +size 15850
    diff --git a/screenshots/dosbox/ucide-ucide-ds.png b/screenshots/dosbox/ucide-ucide-ds.png
    index c6c86b18..26601f99 100644
    --- a/screenshots/dosbox/ucide-ucide-ds.png
    +++ b/screenshots/dosbox/ucide-ucide-ds.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c377444a4b6e7369fa35a92e916c3f86d90a3d95e961f16b3c8471464776712a
    -size 31429
    +oid sha256:98eea34512e3f348a3041b12b6ca7ef522f9edcabe494a0f15d2a8e896cf9a1e
    +size 31561
    diff --git a/screenshots/dosbox/ucide-ucide-ru.png b/screenshots/dosbox/ucide-ucide-ru.png
    index b8aefcce..bcc692f5 100644
    --- a/screenshots/dosbox/ucide-ucide-ru.png
    +++ b/screenshots/dosbox/ucide-ucide-ru.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:1eef572ce0f038dcadc4f2c4acefca46008c44589362cba01d43c3df697f2034
    -size 25351
    +oid sha256:1d7326ed86366bbe1b3359ee7235ba5f6e538c613bccd0b68c8e5bae4e45e7d7
    +size 25273
    diff --git a/screenshots/dosbox/ucide-ucide-tb.png b/screenshots/dosbox/ucide-ucide-tb.png
    index aff9255b..646e96b4 100644
    --- a/screenshots/dosbox/ucide-ucide-tb.png
    +++ b/screenshots/dosbox/ucide-ucide-tb.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:2a8dc683b59b689949ec7da17d049d42f8bbb16ba7ba7694d96546a747ca6462
    -size 31580
    +oid sha256:77eaddc942ed51d860f97a5701db753b7010a68fa5d74088922e1d10cc5ef039
    +size 31708
    diff --git a/screenshots/dosbox/video-v-800.png b/screenshots/dosbox/video-v-800.png
    index 7ecc5797..237f1f13 100644
    --- a/screenshots/dosbox/video-v-800.png
    +++ b/screenshots/dosbox/video-v-800.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:b1c866d754308a03556dbc88d9829173ba968b7493ee3e26ef0d595e66c63947
    -size 26979
    +oid sha256:c6698d96ca96377e94ad30267d92ac46e66d1e02704e802d9cc905d557063caa
    +size 27692
    diff --git a/screenshots/dosbox/video-v-pick.png b/screenshots/dosbox/video-v-pick.png
    index ddfa2e65..f919cb7f 100644
    --- a/screenshots/dosbox/video-v-pick.png
    +++ b/screenshots/dosbox/video-v-pick.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:14b9358260721db22e68dde83e99a1ee2618b0e888e7163bdc445711c913750f
    -size 20572
    +oid sha256:6feb187165573f5514faf88e0a924acd32278a3b9db50728214ad7c51ae2d6a7
    +size 20904
    diff --git a/screenshots/dosbox/winops-w-back.png b/screenshots/dosbox/winops-w-back.png
    index be07800e..30e1daa5 100644
    --- a/screenshots/dosbox/winops-w-back.png
    +++ b/screenshots/dosbox/winops-w-back.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:7f11737da501c34b2ff60c4aa9793bbfb61d8249fa1963980baddafb039d39c6
    -size 13351
    +oid sha256:b03a051f539425d65c03f55ea55d268b7b5e5bc67a0a5d6a9adf91476a077c4b
    +size 13342
    diff --git a/screenshots/dosbox/winops-w-min.png b/screenshots/dosbox/winops-w-min.png
    index 0764e0ab..df5efc3d 100644
    --- a/screenshots/dosbox/winops-w-min.png
    +++ b/screenshots/dosbox/winops-w-min.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:9d27acebba6cb4ec6c56b2f4cc243456fead36d108df990672b1ed977038c4b4
    -size 18646
    +oid sha256:15ab809411b9700e376cbf85fac70eea54048454944da138591b11a2d0371a30
    +size 18613
    diff --git a/screenshots/dosbox/winops-w-move.png b/screenshots/dosbox/winops-w-move.png
    index b47f2f37..8eace288 100644
    --- a/screenshots/dosbox/winops-w-move.png
    +++ b/screenshots/dosbox/winops-w-move.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:c521f421f2386015fde0b8549294d22b2dc41da962bf0fd1a6c54d6d8279aa1f
    -size 13484
    +oid sha256:47c8b321c4d4195a5ceb0d714b8060f0dac35ecea4b823dac23c2c6008d33d03
    +size 13472
    diff --git a/screenshots/dosbox/winops-w-rest.png b/screenshots/dosbox/winops-w-rest.png
    index e7af769f..290eacc3 100644
    --- a/screenshots/dosbox/winops-w-rest.png
    +++ b/screenshots/dosbox/winops-w-rest.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:65dc513b0eb279c182bebb578475fb679a276264fc0f10ea4bf2ee11566f7689
    -size 13362
    +oid sha256:fc09124460ff651d26956a59f0c72338efa7edb38561b2bd535513dcfdf0fb59
    +size 13376
    diff --git a/screenshots/dosbox/winops-w-size.png b/screenshots/dosbox/winops-w-size.png
    index 6105bc85..750ba2c9 100644
    --- a/screenshots/dosbox/winops-w-size.png
    +++ b/screenshots/dosbox/winops-w-size.png
    @@ -1,3 +1,3 @@
     version https://git-lfs.github.com/spec/v1
    -oid sha256:ddb01ff055fb3275275546a598f70d27f1db2ea4a88dcccbc0cb9256924a49e6
    -size 13319
    +oid sha256:69dd279e18f40ef4a337f2e1455992924c7b465d39692060b4fb761e91edcf5e
    +size 13329
    diff --git a/screenshots/masks/install-su-lic.mask b/screenshots/masks/install-su-lic.mask
    new file mode 100644
    index 00000000..f635c0f2
    --- /dev/null
    +++ b/screenshots/masks/install-su-lic.mask
    @@ -0,0 +1,2 @@
    +# Blinking text caret at the start of the licence text
    +77 210 3 18
    diff --git a/server/README.md b/server/README.md
    index 94782a85..2a2985f8 100644
    --- a/server/README.md
    +++ b/server/README.md
    @@ -121,6 +121,16 @@ Software then lists it first on its Updates page. DVX installs it only when the
     signature checks against the key in its own `SYSTEM\KEYS`, and names the add-ons a new ABI would
     leave unable to load before anything is installed.
     
    +The same build writes DVX's own add-ons to `dist/addons/` (`src/addons/README.md`): instrument
    +banks for MIDI music, kept out of the base install. They are uploaded and approved the same way:
    +
    +    for dpk in dist/addons/*.DPK; do
    +        curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/octet-stream" \
    +             --data-binary @"$dpk" https://packages.example.com/v1/upload
    +    done
    +    calog tools/review.lua -- review.conf approve kpunch.dmxopl 1.11.0
    +    calog tools/review.lua -- review.conf approve kpunch.dmxopl3 2.11.0
    +
     ## Configuration
     
     `dvx.conf.example` lists every key with a comment. Beyond singemaster's (`bindHost`, `bindPort`,
    diff --git a/server/test/run.sh b/server/test/run.sh
    index 3681b25c..fc2ceeb8 100755
    --- a/server/test/run.sh
    +++ b/server/test/run.sh
    @@ -8,7 +8,7 @@
     # Exit status 0 when every suite passed.
     
     set -u
    -cd "$(dirname "$0")/.."
    +cd "$(dirname "$0")/.." || exit 1
     CALOG=${CALOG:-$HOME/claude/calog/bin/calog}
     SINGE=${SINGE_HOME:-$(cd ../../singemaster && pwd)}
     DVXPKG=$(cd .. && pwd)/bin/host/dvxpkg
    @@ -29,7 +29,7 @@ package() {
     	mkdir -p "$(dirname "$stage/$3")"
     	printf 'contents of %s\n' "$3" > "$stage/$3"
     	printf '%b' "$2" > "$DATA/$1.ini"
    -	"$DVXPKG" build "$DATA/$1.ini" "$stage" "$DATA/$1.dpk" ${4:-} > /dev/null || echo "fixture $1 failed"
    +	"$DVXPKG" build "$DATA/$1.ini" "$stage" "$DATA/$1.dpk" ${4:+"$4"} > /dev/null || echo "fixture $1 failed"
     }
     package cards  '[package]\nid = acme.cards\nname = Cards\nversion = 1.0.0\nkind = library\nsummary = Card games; for everyone\n' LIBS/ACME/CARDS/CARDS.LIB "$DATA/ACME.SEC"
     package cards2 '[package]\nid = acme.cards\nname = Cards\nversion = 1.1.0\nkind = library\n' LIBS/ACME/CARDS/CARDS.LIB
    @@ -57,7 +57,7 @@ CONF
     
     stop() {  # stop :  ask, then insist, so a stuck server cannot hang the run
     	kill "$1" 2>/dev/null
    -	for i in $(seq 1 40); do
    +	for _ in $(seq 1 40); do
     		kill -0 "$1" 2>/dev/null || break
     		sleep 0.25
     	done
    @@ -66,7 +66,7 @@ stop() {  # stop :  ask, then insist, so a stuck server cannot hang the run
     }
     
     wait_for() {  # wait_for 
    -	for i in $(seq 1 40); do
    +	for _ in $(seq 1 40); do
     		curl -s -m 1 "http://127.0.0.1:$1/v1/health" > /dev/null 2>&1 && return 0
     		sleep 0.25
     	done
    diff --git a/server/test/serve.sh b/server/test/serve.sh
    index 21df12b9..3bb43a5a 100755
    --- a/server/test/serve.sh
    +++ b/server/test/serve.sh
    @@ -17,7 +17,7 @@
     # when calog or singemaster is not there.
     
     set -u
    -cd "$(dirname "$0")/.."
    +cd "$(dirname "$0")/.." || exit 1
     CALOG=${CALOG:-$HOME/claude/calog/bin/calog}
     SINGE=${SINGE_HOME:-$(cd ../../singemaster 2>/dev/null && pwd)}
     DATA=$1
    @@ -47,7 +47,9 @@ trap 'stop; exit 0' TERM INT
     trap stop EXIT
     
     [ -x "$CALOG" ] || fail "calog is not at $CALOG (set CALOG)"
    -[ -n "$SINGE" ] && [ -f "$SINGE/lib/service.lua" ] || fail "singemaster is not beside this repository (set SINGE_HOME)"
    +if [ -z "$SINGE" ] || [ ! -f "$SINGE/lib/service.lua" ]; then
    +    fail "singemaster is not beside this repository (set SINGE_HOME)"
    +fi
     [ -x "$DVXPKG" ] || fail "the package tool is not at $DVXPKG"
     
     rm -rf "$DATA" && mkdir -p "$DATA/server" "$DATA/empty" "$DATA/next/SYSTEM" "$GUEST/SYSTEM/KEYS"
    @@ -97,7 +99,7 @@ CONF
     "$CALOG" dvxserver.lua -- "singe=$SINGE" "$DATA/dvx.conf" > "$DATA/server.log" 2>&1 &
     SERVER=$!
     
    -for i in $(seq 1 40); do
    +for _ in $(seq 1 40); do
         curl -s -m 1 "http://127.0.0.1:$PORT/v1/health" > /dev/null 2>&1 && break
         sleep 0.25
     done
    diff --git a/src/addons/README.md b/src/addons/README.md
    new file mode 100644
    index 00000000..52658a92
    --- /dev/null
    +++ b/src/addons/README.md
    @@ -0,0 +1,24 @@
    +# DVX's own add-ons
    +
    +Add-on packages that DVX's operator publishes on the package server, kept
    +out of the base install to keep it small.  Each folder `KIND/NAME` holds a
    +package template (`PACKAGE.INI`, without `[files]`) and the files the
    +package installs, laid out as they go into the DVX folder.  Every build
    +(`src/installer/mkDist.sh`, run by `make`) packs each one into
    +`dist/addons/NAME.DPK`; they are uploaded and approved like any other
    +package (`server/README.md`).
    +
    +Third-party content is kept unchanged, with its origin (the commit it came
    +from and its SHA-256) in the comments of `PACKAGE.INI` and its licence in a
    +text file the package installs beside it.
    +
    +A folder with a `fonts.ini` instead holds a font manifest (as
    +`src/fonts/fonts.ini`): the build makes its `.DFN` files with mkfont into
    +`obj/addons/NAME/SHARED/FONTS/KPUNCH`, and mkDist packs that folder.  Each
    +`.DFN` carries its licence text.
    +
    +| Folder | Package | What it is |
    +|---|---|---|
    +| `banks/dmxopl` | `kpunch.dmxopl` | DMXOPL 1.11, a General MIDI instrument bank by ConSiGno (MIT) |
    +| `banks/dmxopl3` | `kpunch.dmxopl3` | DMXOPL3, its OPL3 successor, by Sneakernets (MIT) |
    +| `fonts/prfonts` | `kpunch.prfonts` | Printer Fonts: DejaVu Sans, Serif, and Sans Mono in every style at 25 to 100 pixels, for crisp printed text (about 6 MB installed) |
    diff --git a/src/addons/banks/dmxopl/PACKAGE.INI b/src/addons/banks/dmxopl/PACKAGE.INI
    new file mode 100644
    index 00000000..6885a53e
    --- /dev/null
    +++ b/src/addons/banks/dmxopl/PACKAGE.INI
    @@ -0,0 +1,20 @@
    +; DMXOPL 1.11 as an add-on package: a General MIDI instrument bank for
    +; MIDI music on the FM chip and DVX's FM software synthesizer.  mkDist
    +; builds it into dist/addons/DMXOPL.DPK for the package server.
    +;
    +; From https://github.com/sneakernets/DMXOPL, branch master, commit
    +; 63bd4821c6adaff29074f1f6486261961c2014b4 (2017-08-22, "V1.11 Release"):
    +; GENMIDI.op2, unchanged, as SHARED\BANKS\KPUNCH\DMXOPL.OP2 (SHA-256
    +; d3feb807e5102d7de3075c2351cced7e853ba2da8708eff98cc1c8ef8f240ce2).
    +; MIT licence; DMXOPL.TXT beside it carries the licence and the credits.
    +[package]
    +format    = 1
    +id        = kpunch.dmxopl
    +name      = DMXOPL
    +version   = 1.11
    +kind      = bank
    +summary   = General MIDI instruments for FM music, made to sound like a Roland Sound Canvas.
    +publisher = Kangaroo Punch Studios
    +author    = ConSiGno
    +licence   = MIT
    +homepage  = https://github.com/sneakernets/DMXOPL
    diff --git a/src/addons/banks/dmxopl/SHARED/BANKS/KPUNCH/DMXOPL.OP2 b/src/addons/banks/dmxopl/SHARED/BANKS/KPUNCH/DMXOPL.OP2
    new file mode 100644
    index 0000000000000000000000000000000000000000..a483b71bffaa041b3d8c1ff0985a55095d17b844
    GIT binary patch
    literal 11908
    zcmcgRTW}lKb!T_+;t~P@K1Azbc_B&^C5=*mlqEZ3yGv4%>4{ZRBrSQ;rp6Ln0ULo>
    z2mnRPZR(X|OSUqdQt_i{@)60doku%UDRP>8Qg&N6f0i7NJ#9XyCVtGcneZh_lt*RH
    z*$aTgVi#i7ncffXo;~ND_c`}mu%)-}$!DH;q6NT!_Ui+@vDP1*p5uUnxyAOWSspe8
    z5C#wtCYmUIlk5EvqG4;Q?BRlw5WRj`fVDoP0ojY#$n<3ZzBV+|$eXYW&O+pNjyJ$x
    ze--(-kaq!YD8eo{1?y)%2l)^D+{`afFw%rBeG0~U2o+%${28o&_j7=+a@=f*<_5qz
    zr1^yHF?F611;Z;wuHLZMnP|A_gCKi6q3JOWc+*z2>kml(
    zR&G1-Su^t+kQ>F&mHzDX6*U1(SM
    zd9Pr&YFqRg!PN0@7u_7J_HEQ_5D8zPf6jn3qYLvUtlSqZ1Q3I^rwPR@4+0ST%~X2J|vo!=?7rqeh*jC!oKj-kC|+ewe!Ts0PFqj#~v!~tdu3aCjr#K#Vel`X{=Awd1?`i
    z`^D(=ts>5b&re!mq7G?3cD5^+!ToD}!AoX+5wCJSW{#a{
    z52egz-e?jPrIkkt``pOH_Lw?%HW3#GnhzRzsN*;Gn|0wpb0x$ZqDP7~Ipx^5Vd_1O
    zH)=e}DK}@vJ#(4k5RScqB~3X;eLno+3m|V5L;sX#`fzXH3&O(>7WFfr5ff1Uii^Vs
    zE_;2rx8LXFpcuS{M~NK`_0{N92N&Wr;Eh#2sdE>DUGOr*o_FJU
    zC5Cl$Mjg>GSm`amL?gZPFfRu^VFjB8)y~j64|7Hcl%uNv8WAQRPGtr`UJDmvd6v&p
    z=Mmnp1iRoR2rAvY5%h<$jjT@3Dczh==MN3+EynL8C_ifkqcISEzJ_9hz3v4}wkA&b
    z2kf)82vd$lI(Ji@#@^UYR?#Z23!(`hYeGJLta4r4ThOO3fwL&)(u+4~59RMExPAz|
    z)E0;x+ReTt{$vne0Y4WgM_jBaieU_;<&e1yT|!w;f$FyXDu
    zv={Qu&h`r@5V;%UzLoaTD)%AP;KoIPm)E$$i&*SbPY4Ufh9%gX6Rvz;ki!s}NDAhB
    zC|&wHL2kwo*~ehY)u}@rr1N9~<7`SYVJN}mpUFcm0a}n|Kcktv$>1{unCy~r+wtJ?
    zMW?qr<=AP68JJUH-{edq<0Flm@cB0iunWEg(v{aZSr9K~;+#QL=g%QrBSw`t@%<8e
    z)Qb?F-irN34A0?AL^Ex8x`s!)@WIIh6HPw;DsOD&FPu01jP>-4n>X6vIwEngUUwnv
    z#vH4ObrfQun6P)U2ooRa!drL_`lSo6vz}muUGQy~oWefus*RnXl)!~BNAS@>Rs$4^
    zQwp9JTRfBf1)4Vxba2KND8bZ!PLB8U#{GV|dw|(~q68QDoQB+$7S4D8q6anu2xv$4
    z$|gKNJOs-GQ%sdmKW7AeF{$9gm<1-9*iR2(eoTI!)8ya{s?r{c338uS*c@Ad&
    zkimU%=mS`3#&Rml9+UFb?oNM*#3C6|hL+Y7)!o|y?hx@Uc-A{!-cHy~LglQkn
    zY`3mreR)Fj&)V}Ln&1!riyC3@qbhTq^rL?z8t${76CZoq$8X>O=ko3!*H!fl^@d-=4$f=&HHcNV)mz#d3IZ5hE3f
    z{6z?b_$8V*?dB+?>HPfw@P;Sw`yek&Flm)``KP8xXPEztC@TGoeAd91hs^D__AxG2
    z+fAC=k0a6p3m$tKoOpZSPJ%a}j=NRJ4Q`6`@xv`M4`|Lc8ojrh@WK>;o6W@M&Z2Oe
    zUh3R8@iYDAfU+LBegkV40*kLX-1U%8zPu@Y#Mh8aJGN)HnSd6yoD%2AR%Q7-fCsZhVmD5#e?{Z}1}j-)^cwTID*UtlpYQ
    zgJi+SIF`|3um~TS@FKnoLqOr~SueuiwBht^^{2Nmp{8ORxV^)7yVbsSj&5+T7x{nUp^Ff+Ng*)gUPUP=6
    z_=jlV-w$FQ`rvk}BcEL>ppoIrgV=j>n
    z>syXFwoRJAVc#qM=&c?0G}xme#=Hl;;}6cSCP|owdYxI+>@l~vMB`ba=_MKum=D$u
    zpJ+1ATwnbShcg#rx~JjRRh)A@)o31F0Zowm#;-Z36@PG3XWp^NpCJ75eY|jsKbU*Q
    zk>>0FG84n!Q+>ahdk)jlTotkS`e{b<>depWX*%QjXfChCrN^^MdRXeylypfeV%e3|
    z63M(&BC`_gO{wvG)_H%Ao<4GXM;`xH@@6{!-~RR}*-TE0C-v;`Ju&K5Q)(`++|%Cw
    zk4;Y>PwAPYo>uQs*fwd;XezFas9AdkJ<7<4j)CvZCX}=dNuQdHkLGgPG1Za8X4lfE
    z?&N4To^;;Zud2?%kxgejj-l0bJKdI~IKK@l>4c8yd#_wdiU0VhmRGV;TQ0AvsnQP>
    zu$2D={5O^7o?Al0HmQOlo>G-`k)#4UGMZP1w?Yx$drZv^XVLv>iD_Ulb&r)AXy
    z$R59~@L9q_!9Z*A(tV0CD%sbi{atUfYy
    z@9h`%Q#nh6+0l`VY9}A-BaQvBN6*?PVON&wZU7Hi`yN=xZ0`^dF1=!ZOe#l4lS({kTf&Eu
    zcmSlFHSCR({na|yk}SKoCsk!wYR6+w8O!T>K3Ue>af6BPklNx(Dy8We)fT@K@k#CQ
    z;j)v1Ni+99NO%PY(1dk6$Y5QGYX#YbP
    zzA;tJlyhcV_dP8|rM9f1l_rwAvMeLmCLsrkfA%q
    z-ZIiSCs=hvJer@-hwzlP;z$w1WAs(L07%1GJyWsY)Q=p1!`qI0QXR2qFhEmXnMLJ5
    zmy-TwweuaTb41Ij_RSkveNE3-+YVn-QrgInbA5Ix3A{LE91DD(HauLV`~me#D_Xft
    z%b`IneT4cCjxozWm)<)QdZL;E12hgxd+>a-pIr{=`tVRnj~^*gGQci=S8g>{oGs&Z$!OarCl{iG{JA##HH#bd02hb5~YXu(jBsOv(XO
    z$>HVGx~;^eOUvT%kJV>)_#@Szno`aCoz#!>Uuj$ErAJL^aXpiFI$=69nUpeGp>}Ei
    z?2&-Kaf`JywpR#I^ZE)Ik_@D
    zO7{zg{pH$LLft7nhn=+tf5q6*qd2;ua%163pE^vVHEzfG`hUM2dNX))yZ7ymdKD4f
    zIQb{S@R^eq`+L(;8}
    z4LS8DA}_PwX2LzF4WWme^J}^NHrv4rU6+nOBG@R0pPtmm@b>A5Hs^m9f4m7MD*OCW
    ze%RxQzdeTOW9o-jJZx^Z9;W?O(9FInO`i5wK|?p)Z7aPnV{Y}p252ym}$zzL<1WCxl~*~m7}ZW4^GK)Tayd@bMD3bL*&
    z$#J0F_S!tdOecg*>1OlChBl;W+LW@X$&a
    zz=4?2B-;hO#mU|oG&8&|iQeR#05w?}@AWqXSY!9#KHUxgp5z-G*sI;C23{8t3vdR!
    zx8tC@9PX)fEzJBL~|Urr9b5L
    zbuMq}V?#%I`a^*A;7%?;Rv#TYk}X!hTXbKY&C2@9!uzWc&vVR90oilOKe74+(Vd(J
    zrVqe0z(ZAt1z5pkO@M9g6!;seyr~-oP2=1tYau2+56xTzU3S*Jp8^o!^UOo(i!ikk
    z&dngUAts&I97M|Xb`D*}WHr8mc!M1bO#ac~a%v8yHr7nw7qj&h{NV@gxoKO@b{$sF
    z;kyHFq9hgWyii0$A8Pw2PkX7sb>zGYNX5$j^KV
    zx&w8zeY6}CjraLl!Cz-@o_WiHF->QRF~#(iORG8EZd0zV<#dw&_m}L5tqfBhkX^p%
    z1@LclarlML=b2W*7OrZV2$+A)K=k$1;1`5TXYd{6-RSG9Ie)cqIft1(9)Z*AR`>!9
    zhOG7;!quJ}CRv=`i1898`x`mEnrvrSoXBATo(1_IHiCbJ=@g1jClQC#>(mI?9GJhaE}0|K%Tate7mpL%43|755thh#Haq%1V6P-
    zyF6mbjR|N^erDC1M0e^2$M_)Tbj;_}4J(ihhCUncoI2`K
    zr@>D;w@hz98izZF>Fl_F>*I8r=)QK#!hBfwTc1S-9x>-9*~G_t0}p7U-8(aj0N-)*
    z(-zDkSX|)1&1gh$Pt0T9*#pz_7PimNu+xtiIN&j+IT{Dj@Fg@>M)Mqeer8?-iz9t$
    zpUCtf>^HFAuxk?Ja}tHf``%4Jf1nRf^uttx;JuK=q_byw5%hHqFP(3C8mH+*IN+T{
    z8=iL~$K=;H!BmaIm;BhUt2(`C1+;x(uQ0WO(HsNsN421@5$h)2M)OEkryumS_Il+l
    z@H?69IyAj-6XOH!H{P}yxp8}(4zc`0tPw^{&|&4@+~lDV+?U_Z=8r++d;cke21w^L
    z9zK`DGsDg@Dv&#T`7?<1@Yrq+Ryusw8fhKRR}m)LrRYXpU+GYkO-BC6(T$wmWbafq
    zky^%ikM?)*{u+m;b0yFCl**cb=#>T@YWU_}JQtKdqgPt3P!G+$HY>xV^XRN>)oaD(
    z{#8aVDCKhkrVk)ZqagRMq#6m3TEPC%_~c=*`$?utpj^r5RY?L7eM5^*?`K(APQ|uDwY)|Emlv{;QSpJH;^N^9hI+
    z=u?mS_zLu?XE9x(@iZ7Z=V&^FoX*F=w+H>&YQJ=l7nuBxeR$sQvtK^QThUHBr>CIV
    zZ2|vkkQFRD0`ivK?YzFiE_ZG-Fsm&+n0EKrntL$()El4l&w_uAt<}K9=UI^6e319o
    zin4-POgTUB=7XH>24xf-+Q4)^1IcIr_raz91O^7*C0|CoehCY38fukpUaxh!uQhNi
    z?&p+lPOov+&FnHT<&T_b0KLH_kD)BmSvii$R_kp07dz;in5;<{?QOyS=~5<|j9RDk
    zAztTF6vRgVz`D`568Sik$?Yf#_tEFlW8~q{zSSJaf-ljCK0u$9<`16t&TRm)Q;uyh
    zWXaQ*$eWz9f?G%y^-B5JApp#&sYZ^y^Nii%q1GQhTyg{4!c_$k0*(Sic(V?tG!1gWIhh+K28bPmjdUs(;kncTXn=t`h+YsZ(MUM{2
    z$X~-0xJDnik1)w!&WC6&^q(SM?wKFwdhRhu9YW39T*{&C2A}w!2G9vEWq3SSpC{qL?Ew+mTo;DEZ}`X8-73O*
    zXEZpH^Op`+y)`gps~+`Hs+O_q?CFzvMXmrqxO2wavZD?
    z^O)*t>(6%ax}~a}dyLlsu4G!z8Cc0;idU^MZ`5AFd4tXTIgE3H(Q{8gBKQK%E&PR-
    z(UlZa{s}9uqg{B=87AAkSXZ&x{M2pXv0q%zVbVvwa1r~UQ@(JXWBZCR+4bwF
    z!26qcWdiFV<@5D^f%9(=5u+)HN!CfF9rVrO_1=Kt=T{H3bNXh;W2!&L&-UW|!r9r~
    z$D!@%%w!&8vNJv>!Q0!x>#IfSK)sPu&FNYJB!}16V)&h84amLJt7T8n;G+~_s$rPSj)nmy+8rf?R^m?ILuBX2EC9|t_E`jG1EI({D
    z&OtF2;0JJS>Y7D&f%ob)!#~eoxrQ|heBLZ3S%I@DoJC!oKVx?pynmfdQf}n20DlhG
    z+j{}l*hcG7AF5HomR>7jFJWUwdKQ%E+gL;#KK&f9xl;ZsE8&6XEC!~Sx*x;p+Th|Q
    z6C67i++zZd^APtKrn$if`^D%R0IPU;GHuLb{OB9l^Ta%+I@Uf`$N5*|T#y!6+#9B+
    zEq-vg=jL(TWbv8=ZV}@Sz@_7q&*_Y9@t=8E12v2HQvB$_voqtffXm+U!VQttUb9-a
    z>bMW@dNuAhUlr!Xnx7YP4R3G*6<6w1$(YMu*sYc
    ztKt1P(bUDA>)EBu|vzYW*zi?H=vr{+sqjG$7sGGq`U>E3KJ7vcifV=${JUSCng29*K1z-Lf
    zas}?@I|iQ`s`GdUS-@){y&;;j3#&8DIXHQp
    zRi{|k^Vouz9_;V!Xh@T5&8|Zh%#E8R{vlb{w&SPl9PQ{ZW@_g&)l3%*;dPwy5Hq)P
    z^ZS5_OtG2B#!9h{)qgrvC*_#2E=O)s~w~pZkw7eI)$^#DmShWjFMxNhXofS##V*xk3Fv-}v6s0rVt%a1MJT#reH)D%6fFPLb`K
    zfcVq{Kg;-hmEo=u9~TfGfY}V{EOB#oEEpo@A^D7KFMKGbqQ^Favx6X77jdqvhA%P(
    zwj<`*y&L&7--!>>cx)dLABXkXHq!Zvk{m;tjf{_z{_iQT&*o24#Ao9Nvj(3{GlcDk
    z=?LLn9VeeV@j;p`ccQVv$sP9OIgF2;O9hPHU5t^>@86lv@jt4Xt>txx%kqtt{v$$;
    zv%pW;oOx`86Z`GgkC80+xWkZDmBqhgI74=Qrzv@zY+ak2A)3-$wZiEh%hTgL-v7As
    zLk6GjUl4ZXG0N%|QwPY0!UsW~-78O}I;lqRZ}nLav)uSY>*ABxg`C{{i@BUA&gYZg
    zDf9;tLor63S4+ueLFXKPvEXIMMj
    z&U`+95vJC|z860sdRBj03>p`sp|`(bG|oRJ8uAyOi*>)5e+_}t@!}^`Ueg((fqQc_
    z-w@5c|IK8%x=0qlR|bupXpB8{2Jb|Jc5UasJczaf%r_DrdY0RTG@d)rpsYu^uQ8aI
    zhtyLje`>RM5%CK21=8%ROvCc$apyCChB;Q_II|^J`%Cm`tLY~puCph;vP_Z2COU7G
    zrmL_CI8I;ufqA^puKT1Z{MX6mc8S
    zH2aB01onk{P}aVc3s=D}*z0DWq22V9*tN5M;VOrHpc2iFyP&D%zW*x@svSR>*9=J}
    zvTEVqKg2&N9Y0AwQI_U=zex4t|Ec!>NFSwAPEU!>-#cm0ypVdwoTf9Vjl~n8ptL8Z
    zLb^FpPz+NR5jfaB6T5OQf7k_kzm8UB#L$?}M;|ZmB+g;;F#!$M{rRCG+
    zu6N!Y;p0cbT69>8sCP$RLbY=&91M-9v66-Ci=8oLd_ajw`(r~&q#V)j@V%p+9M3x9j_K%Du)WN0o{ZFg0!C2^NH6mp>NXwp`p?E@zCD1|U>)B0sOh&Xw
    zC>US1St9>t&wrN^XIHJFSm*Vlb*b&iP-IArEvdI;pRpbd1rHaj0_={d2|QtOsSN+V
    zuo7i!lD9~*0hiY4{#E+)9f>4{D=;WnH)U`zq=iEjl8e?WRR^pe;bjH0NX2d~l87k-
    z=FUahKujIc!kW~FMKm&m9^U!LlMG<@0kEaU@SWF2^R0@liArKmg
    zDv?mh``imduCyx>S4RfIs^l%wpm^O^yiWelDQ^-&=KtKfX#{ebe7kQWz6Z5X(7ZzC
    z{UhxSB}_yFVq+sw)ojHe4#c#PfePg}Fg8#rJ>U^K&Qf_Pcs?RnANOi8^Caww(Yfox
    zCTm^;v-!=aOxzEJ>)*!xoncBnrEGf(s1YqzfuKu?g|Pdp-#%fn&C>)BH
    zcMz2otwMLe{*MQ%e1LmG*cvLF;N4*iph@23UiGTfS<3g<9wj1G;?Wh>#v?dCB(O^6
    zcou&54J(JohLzxOnIn8?7#o23R>o$3knFFRqJ`p0em|@#gAyM4xH6v5w8U^>Z6ac}
    z-Y&HTm2fzuMOD-F4qP7&J^AFa&S8jR`yZfn1qaZf;_U|iZBiRM;U%kC`P-$o2e1p6
    z>B#v1fz(FdTPRsfc$64j@S?{se;oU#=}uZl^P^h$NSsPxF%hjZ^R-Z6+ssR#bu@oK
    z8^eu{qV
    z2oBL^3gSANuOvpacyw5emE;GmllknH5y3g3SVvq(^M|wn?9#<+S^Y8kDqaAj!I%~;
    z*?*se9LNDUylqVkt0P4k^wCsTU{CSSE+z6*rT&iRb0id3&5JiO`>vL#v>m>yghL}0
    zT1b~Ngcql%X>DNkhXw~L9DhLlB1)*(swEr1DyM&LM56k^y}*4of?+
    zi<(W30EaYfa3HJ&4;Sz({25R}hc&ieYKy4jB_V!94QqpV!wP64h1>4>cdv>AbX=9X
    zkD!-LOw7*5G^R?AY8c6)`KBwTDp*?Bl9|vxRf*%}vuu32LNRRrc>2nW7y-P0viqIX
    zi}PQpguQAw6x5=Lavi2K8VxIB@v`#wj76lrVXchxqWPc2VYv>=?gu98ec{j`Hh267
    zpHH`2}!p$
    zK2WYU5&1IvO$OY9p#k&|UNTL1)2l-JO|}CIU6)3GBD6hfQlB2y#&LQpyUgZ)X_(R{
    zh*U}mlcoH?o1)1&@i$*F3}X7>B@c_6sfTHQg=NFO3PqmwS6GH_y4zM5FCW3NE~uFI
    z2C@KaQ!F8spJE?IuT)qzobWq*(*J{MdG!qcP!p4IQa7n_#!~&2
    +        rm -f "${archive}"
    +        exit 1
    +    fi
    +}
    +
    +
    +if [ "${FORCE}" -eq 0 ] && [ -f "${SF_DIR}/${PIN_FILE}" ] && [ "$(cat "${SF_DIR}/${PIN_FILE}")" = "${FLUID_SHA256}" ] && [ -f "${SF_DIR}/FluidR3_GM.sf2" ]; then
    +    echo "installSoundFonts: FluidR3_GM already installed"
    +    exit 0
    +fi
    +
    +fetch "${FLUID_URL}" "${FLUID_FILE}" "${FLUID_SHA256}"
    +rm -rf "${SF_DIR}" "${SF_DIR}.new"
    +mkdir -p "${SF_DIR}.new"
    +(cd "${SF_DIR}.new" && ar x "${CACHE}/${FLUID_FILE}" data.tar.xz && tar -xJf data.tar.xz "${FLUID_MEMBER}" "${FLUID_COPYRIGHT}" && mv "${FLUID_MEMBER}" FluidR3_GM.sf2 && mv "${FLUID_COPYRIGHT}" FluidR3_GM.copyright && rm -rf usr data.tar.xz)
    +mv "${SF_DIR}.new" "${SF_DIR}"
    +echo "${FLUID_SHA256}" > "${SF_DIR}/${PIN_FILE}"
    +echo "installSoundFonts: FluidR3_GM installed in ${SF_DIR}"
    diff --git a/src/addons/fonts/prfonts/PACKAGE.INI b/src/addons/fonts/prfonts/PACKAGE.INI
    new file mode 100644
    index 00000000..0819c958
    --- /dev/null
    +++ b/src/addons/fonts/prfonts/PACKAGE.INI
    @@ -0,0 +1,18 @@
    +; Printer Fonts as an add-on package: the DejaVu Sans, Serif, and Sans
    +; Mono faces DVX ships, in every style, at the large sizes text is
    +; printed at (25 to 100 pixels), so printed text is crisp instead of
    +; scaled up from the screen sizes.  The build makes the .DFN files from
    +; fonts.ini beside this file (mkfont, src/tools/Makefile) into
    +; obj/addons/prfonts, and mkDist packs them into dist/addons/PRFONTS.DPK
    +; for the package server.  Each file carries the DejaVu licence text.
    +[package]
    +format    = 1
    +id        = kpunch.prfonts
    +name      = Printer Fonts
    +version   = 2.37
    +kind      = font
    +summary   = Large sizes of the DejaVu fonts, for sharp printed text.
    +publisher = Kangaroo Punch Studios
    +author    = DejaVu fonts team
    +licence   = Bitstream Vera and Arev (DejaVu changes public domain)
    +homepage  = https://dejavu-fonts.github.io
    diff --git a/src/addons/fonts/prfonts/fonts.ini b/src/addons/fonts/prfonts/fonts.ini
    new file mode 100644
    index 00000000..1b20befa
    --- /dev/null
    +++ b/src/addons/fonts/prfonts/fonts.ini
    @@ -0,0 +1,126 @@
    +; fonts.ini -- the printer-fonts add-on (kpunch.prfonts): the DejaVu
    +; Sans, Serif, and Sans Mono faces DVX ships, at the large sizes text is
    +; printed at, made by mkfont (src/tools/mkfont.c documents every key)
    +; into SHARED\FONTS\KPUNCH.  Each file adds its sizes to the face of the
    +; same family and style in the base set, so printing finds a strike near
    +; the size in printer dots (12 points is 50 dots at 300 dpi, 30 at 180)
    +; and scales it only a little; without the add-on it scales the base
    +; set's largest (24 pixels).  Sizes: 25 to 100 pixels, about 6 to 24
    +; points at 300 dpi and 10 to 40 at 180.  Nothing smoothed: printers
    +; take dots.
    +;
    +; Names: P (printer), the family's short form (SANS, SERF, MONO), then
    +; B, I, or Z as in the base set.
    +
    +[fonts]
    +PSANS    = DejaVu Sans
    +PSANSB   = DejaVu Sans
    +PSANSI   = DejaVu Sans
    +PSANSZ   = DejaVu Sans
    +PSERF    = DejaVu Serif
    +PSERFB   = DejaVu Serif
    +PSERFI   = DejaVu Serif
    +PSERFZ   = DejaVu Serif
    +PMONO    = DejaVu Sans Mono
    +PMONOB   = DejaVu Sans Mono
    +PMONOI   = DejaVu Sans Mono
    +PMONOZ   = DejaVu Sans Mono
    +
    +[licences]
    +DEJAVU   = dejavu/dejavu-fonts-ttf-2.37/LICENSE
    +
    +[PSANS]
    +style    = regular
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSans.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = native
    +
    +[PSANSB]
    +style    = bold
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSans-Bold.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = native
    +
    +[PSANSI]
    +style    = italic
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSans-Oblique.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = auto
    +
    +[PSANSZ]
    +style    = bolditalic
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSans-BoldOblique.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = auto
    +
    +[PSERF]
    +style    = regular
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSerif.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = native
    +
    +[PSERFB]
    +style    = bold
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSerif-Bold.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = native
    +
    +[PSERFI]
    +style    = italic
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSerif-Italic.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = auto
    +
    +[PSERFZ]
    +style    = bolditalic
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSerif-BoldItalic.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = auto
    +
    +[PMONO]
    +style    = regular
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSansMono.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = native
    +
    +[PMONOB]
    +style    = bold
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSansMono-Bold.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = native
    +
    +[PMONOI]
    +style    = italic
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSansMono-Oblique.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = auto
    +
    +[PMONOZ]
    +style    = bolditalic
    +licence  = DEJAVU
    +boxdraw  = true
    +source   = dejavu/dejavu-fonts-ttf-2.37/ttf/DejaVuSansMono-BoldOblique.ttf
    +sizes    = 25 29 33 38 42 50 58 67 75 83 100
    +hinting  = auto
    diff --git a/src/apps/kpunch/Makefile b/src/apps/kpunch/Makefile
    index 5c67a77a..e20a4986 100644
    --- a/src/apps/kpunch/Makefile
    +++ b/src/apps/kpunch/Makefile
    @@ -49,7 +49,7 @@ BASCOMP   = ../../../bin/host/bascomp
     # Editor, dvxhelp/helpedit, installs beside the Help Viewer in dvxhelp).
     # The program is bin/apps/kpunch//.app,  being the last
     # part of the directory.
    -BASIC_APPS = iconed notepad imgview dvxhelp/helpedit:dvxhelp resedit basdemo widshow clock welcome fileMgr terminal software pkgbuild
    +BASIC_APPS = iconed notepad imgview dvxhelp/helpedit:dvxhelp resedit basdemo widshow clock welcome fileMgr terminal software pkgbuild player recorder
     
     basicDir  = $(firstword $(subst :, ,$(1)))
     basicName = $(notdir $(call basicDir,$(1)))
    diff --git a/src/apps/kpunch/README.md b/src/apps/kpunch/README.md
    index 4ffacb57..9731b783 100644
    --- a/src/apps/kpunch/README.md
    +++ b/src/apps/kpunch/README.md
    @@ -16,7 +16,7 @@ the BASIC-based sample apps. Two flavours of app live side by side:
       copy of `basstub.app`, which acts as the runtime host. See
       `iconed/`, `notepad/`, `imgview/`, `basdemo/`, `resedit/`,
       `widshow/`, `dvxhelp/helpedit/`, `clock/`, `welcome/`, `fileMgr/`,
    -  `terminal/`.
    +  `terminal/`, `player/`, `recorder/`.
     
     The rest of this document covers writing a C app against the SDK.
     For BASIC apps, see `dvxbasic/README.md`.  A new app outside this tree
    @@ -290,6 +290,56 @@ Options), `termPass.frm` (passphrase), `termProg.frm` (File Transfer),
     start), with the conn.bas, xfer.bas, prefs.bas, fileops.bas, shell.bas,
     commdlg.bas, and help.bas libraries; its help is `terminal.dhs`.
     
    +### Media Player (player, BASIC)
    +
    +| | |
    +|---|---|
    +| File | `apps/kpunch/player/player.app` |
    +| Type | BASIC (main-loop via basstub) |
    +| Multi-instance | No (a second start hands its files over: NewArgs) |
    +
    +Plays music modules (MOD, S3M, XM, IT), MIDI files (MID, RMI), and WAV
    +and VOC sounds from a list, through music.lib (music.bas): play, pause,
    +stop, previous, next, a position slider that seeks once it stands
    +still, a volume slider, Repeat Off / List / Track, and Shuffle (a
    +shuffled play order with the track playing first). A 250 ms Timer shows
    +the position and notices a track's end. Files named on its command line
    +are added to the list and the first plays; ASSOC.INI opens those file
    +types in it. The list and the settings are kept in PLAYER.INI in its
    +settings folder. Built from `player.dbp` + `player.frm`, with the
    +music.bas, prefs.bas, fileops.bas, commdlg.bas, and help.bas libraries;
    +its help is `player.dhs`. Tested on the host by
    +`src/test/music/playerTest.c` and on DOS by `player.scr`.
    +
    +### Sound Recorder (recorder, BASIC)
    +
    +| | |
    +|---|---|
    +| File | `apps/kpunch/recorder/recorder.app` |
    +| Type | BASIC (main-loop via basstub) |
    +| Multi-instance | No |
    +
    +Records sound from the card's microphone, line, or CD input into WAV
    +files through audio.lib's recording (sound.bas RecordStart,
    +RecordStop, RecordLength, RecordLevel): Record, Stop, Play (PlayWave),
    +a level meter (a ProgressBar) and the length shown by a 100 ms Timer,
    +the format the card chose. A recording goes into RECORDING.WAV in its
    +settings folder until File > Save As copies it out (FILECOPY); New,
    +Open, and closing ask about a recording not saved. Options picks the
    +quality (telephone 8 kHz 8-bit mono, radio 22 kHz 8-bit mono, CD 44.1
    +kHz 16-bit stereo) and the input (inputs the card lacks are greyed).
    +The MIDI Keyboard input (when DVX.INI [audio] midiIn names the MIDI
    +port) records MIDI instead: MidiInOpen, each message played as it
    +comes on a live music (MusicMidiSend) and kept in a MIDI recording
    +(MidiRecordAdd with MidiInTime), the Timer at 20 ms meanwhile, the
    +level the last note's velocity; Stop writes RECORDING.MID
    +(MidiRecordSave) and Play plays it with music.lib. Open takes WAV and
    +MIDI files (MusicLoad tells which). Settings are kept in RECORDER.INI.
    +Built from `recorder.dbp` + `recorder.frm`, with the sound.bas,
    +music.bas, prefs.bas, fileops.bas, commdlg.bas, and help.bas
    +libraries; its help is `recorder.dhs`. Tested on the host by
    +`src/test/music/recorderTest.c` and on DOS by `record.scr`.
    +
     ### DVX Demo (dvxdemo)
     
     | | |
    @@ -643,6 +693,16 @@ apps/kpunch/
         termMain.bas         start
         terminal.dhs
         ICON32.BMP
    +  player/
    +    player.dbp
    +    player.frm
    +    player.dhs
    +    ICON32.BMP
    +  recorder/
    +    recorder.dbp
    +    recorder.frm
    +    recorder.dhs
    +    ICON32.BMP
     
       dvxbasic/              see dvxbasic/README.md
         compiler/            lexer, parser, codegen, strip, obfuscate
    diff --git a/src/apps/kpunch/basdemo/basdemo.dbp b/src/apps/kpunch/basdemo/basdemo.dbp
    index 1f018641..83b526c4 100644
    --- a/src/apps/kpunch/basdemo/basdemo.dbp
    +++ b/src/apps/kpunch/basdemo/basdemo.dbp
    @@ -12,7 +12,8 @@ File0 = ../../../include/basic/commdlg.bas
     File1 = ../../../include/basic/fileops.bas
     File2 = ../../../include/basic/help.bas
     File3 = ../../../include/basic/font.bas
    -Count = 4
    +File4 = ../../../include/basic/print.bas
    +Count = 5
     
     [Classes]
     File0 = shape.cls
    diff --git a/src/apps/kpunch/basdemo/basdemo.dhs b/src/apps/kpunch/basdemo/basdemo.dhs
    index 7ca728eb..e7f39983 100644
    --- a/src/apps/kpunch/basdemo/basdemo.dhs
    +++ b/src/apps/kpunch/basdemo/basdemo.dhs
    @@ -75,6 +75,7 @@ The File I/O and INI examples create their files in the program's own data folde
       Demos   Timer...               Open a window with a counter that ticks twice a second.
       Demos   Fonts...               Open a window of text in several fonts, styles, and colors; a sample line whose font you choose with Choose Font; and a box to write styled text in, with Bold, Italic, and Underline buttons.
       Demos   Objects                Make shapes from the Shape class module, keep them in a Collection, and list them with FOR EACH.
    +  Demos   Print Report...        Print a page drawn with the Printer object: a title, a table of sales figures lined up with TextWidth, and a bar chart with dotted grid lines (DrawStyle) and filled bars (FillStyle and FillColor). The Print dialog chooses where it goes and how many copies; printing needs your printer's model set in the Control Panel.
       Help    Contents               Open this help.
       Help    About...               Show the program name and copyright.
     .endtable
    diff --git a/src/apps/kpunch/basdemo/basdemo.frm b/src/apps/kpunch/basdemo/basdemo.frm
    index 38582d5a..4eff6621 100644
    --- a/src/apps/kpunch/basdemo/basdemo.frm
    +++ b/src/apps/kpunch/basdemo/basdemo.frm
    @@ -48,6 +48,12 @@ Begin Form BasicDemo
             Begin Menu mnuObjects
                 Caption = "&Objects"
             End
    +        Begin Menu mnuSepR2
    +            Caption = "-"
    +        End
    +        Begin Menu mnuReport
    +            Caption = "Print &Report..."
    +        End
         End
     
         Begin Menu mnuHelp
    @@ -206,6 +212,19 @@ CONST FONT_FORM_H    = 380
     CONST FONT_RICH_W    = 360
     CONST FONT_BIG       = 20
     CONST FONT_SERIF     = "DejaVu Serif"
    +CONST FONT_SANS      = "DejaVu Sans"
    +CONST REPORT_ROWS    = 4
    +CONST REPORT_LEFT    = 1                ' the report's left and right edges, inches
    +CONST REPORT_RIGHT   = 7
    +CONST REPORT_TOP     = 0.75
    +CONST REPORT_FIGURES = 4                ' where the table's figures end
    +CONST REPORT_GAP     = 0.25
    +CONST REPORT_TITLE   = 20               ' points
    +CONST REPORT_TEXT    = 11
    +CONST REPORT_RULE_W  = 3                ' printer dots
    +CONST REPORT_CHART_H = 3                ' inches
    +CONST REPORT_SCALE   = 6000             ' sales at the top of the chart
    +CONST REPORT_GRID    = 1000             ' sales between the chart's grid lines
     
     DIM SHARED gfxOpen   AS BOOLEAN
     DIM SHARED dynOpen   AS BOOLEAN
    @@ -1108,3 +1127,115 @@ SUB mnuObjects_Click
         Say "TypeName(s) = " + TypeName(s) + ", s Is shapes(2): " + IIf(s Is shapes(2), "yes", "no")
         LblStatus.Caption = "Objects demo complete."
     END SUB
    +
    +
    +SUB mnuReport_Click
    +    ' Print Report: a page drawn with the Printer object -- a title, a
    +    ' table of figures, and a bar chart -- on the printer chosen in the
    +    ' Control Panel, where and as many times as the Print dialog says
    +    DIM regions(1 TO REPORT_ROWS) AS STRING
    +    DIM sales(1 TO REPORT_ROWS) AS LONG
    +    DIM shades(1 TO REPORT_ROWS) AS LONG
    +    DIM total AS LONG
    +    DIM level AS LONG
    +    DIM i AS INTEGER
    +    DIM y AS SINGLE
    +    DIM x AS SINGLE
    +    DIM pitch AS SINGLE
    +    DIM axis AS SINGLE
    +
    +    IF PrintCanPrintPictures() = 0 THEN
    +        MsgBox "To print the report, choose your printer's model on the Control Panel's Printer page."
    +        EXIT SUB
    +    END IF
    +
    +    IF PrintChoose("Sales Report", 1) = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    regions(1) = "North"
    +    sales(1)   = 4200
    +    shades(1)  = RGB(0, 90, 200)
    +    regions(2) = "South"
    +    sales(2)   = 3150
    +    shades(2)  = RGB(220, 120, 0)
    +    regions(3) = "East"
    +    sales(3)   = 5480
    +    shades(3)  = RGB(0, 150, 70)
    +    regions(4) = "West"
    +    sales(4)   = 2630
    +    shades(4)  = RGB(170, 0, 120)
    +
    +    ' The title and a rule under it, measured in inches
    +    Printer.ScaleMode = vbInches
    +    Printer.FontName = FONT_SERIF
    +    Printer.FontSize = REPORT_TITLE
    +    Printer.FontBold = True
    +    Printer.CurrentX = REPORT_LEFT
    +    Printer.CurrentY = REPORT_TOP
    +    Printer.Print "Quarterly Sales by Region"
    +    Printer.FontBold = False
    +    Printer.FontSize = REPORT_TEXT
    +    Printer.CurrentX = REPORT_LEFT
    +    Printer.Print "From the DVX BASIC Feature Tour"
    +    y = Printer.CurrentY + REPORT_GAP / 2
    +    Printer.DrawWidth = REPORT_RULE_W
    +    Printer.Line (REPORT_LEFT, y)-(REPORT_RIGHT, y)
    +    Printer.DrawWidth = 1
    +
    +    ' The table: each region and its sales, then the total
    +    Printer.FontName = FONT_SANS
    +    y = y + REPORT_GAP
    +
    +    FOR i = 1 TO REPORT_ROWS
    +        ReportRow regions(i), sales(i), y, False
    +        total = total + sales(i)
    +        y = y + Printer.TextHeight("X")
    +    NEXT
    +
    +    ReportRow "Total", total, y, True
    +
    +    ' The chart: dotted grid lines, the axis, and a bar for each region
    +    ' in its color with its name under it
    +    axis = y + Printer.TextHeight("X") + REPORT_GAP + REPORT_CHART_H
    +    Printer.DrawStyle = vbDot
    +
    +    FOR level = REPORT_GRID TO REPORT_SCALE STEP REPORT_GRID
    +        y = axis - level / REPORT_SCALE * REPORT_CHART_H
    +        Printer.Line (REPORT_LEFT, y)-(REPORT_RIGHT, y)
    +    NEXT
    +
    +    Printer.DrawStyle = vbSolid
    +    Printer.Line (REPORT_LEFT, axis)-(REPORT_RIGHT, axis)
    +    Printer.FillStyle = vbFSSolid
    +    pitch = (REPORT_RIGHT - REPORT_LEFT) / REPORT_ROWS
    +
    +    FOR i = 1 TO REPORT_ROWS
    +        x = REPORT_LEFT + (i - 1) * pitch + pitch / 4
    +        Printer.FillColor = shades(i)
    +        Printer.Line (x, axis - sales(i) / REPORT_SCALE * REPORT_CHART_H)-(x + pitch / 2, axis), vbBlack, B
    +        Printer.CurrentX = x + pitch / 4 - Printer.TextWidth(regions(i)) / 2
    +        Printer.CurrentY = axis + REPORT_GAP / 2
    +        Printer.Print regions(i)
    +    NEXT
    +
    +    Printer.FillStyle = vbFSTransparent
    +    Printer.EndDoc
    +    LblStatus.Caption = "Report sent to the printer."
    +END SUB
    +
    +
    +SUB ReportRow(label AS STRING, figure AS LONG, y AS SINGLE, bold AS BOOLEAN)
    +    ' One line of the report's table: the label, and the figure ending
    +    ' at REPORT_FIGURES
    +    DIM text AS STRING
    +
    +    text = FORMAT$(figure, "#,##0")
    +    Printer.FontBold = bold
    +    Printer.CurrentX = REPORT_LEFT
    +    Printer.CurrentY = y
    +    Printer.Print label;
    +    Printer.CurrentX = REPORT_FIGURES - Printer.TextWidth(text)
    +    Printer.Print text
    +    Printer.FontBold = False
    +END SUB
    diff --git a/src/apps/kpunch/cpanel/cpJoystick.c b/src/apps/kpunch/cpanel/cpJoystick.c
    index 8c66d036..57028e3a 100644
    --- a/src/apps/kpunch/cpanel/cpJoystick.c
    +++ b/src/apps/kpunch/cpanel/cpJoystick.c
    @@ -227,7 +227,7 @@ static WidgetT *addRow(WidgetT *parent, const char *label) {
         WidgetT *lbl = wgtLabel(row, label);
     
         row->spacing = wgtPixels(CPJ_GAP);
    -    lbl->prefW   = wgtPixels(CPJ_LABEL_W);
    +    lbl->minW    = wgtPixels(CPJ_LABEL_W);
         return row;
     }
     
    @@ -270,23 +270,23 @@ void cpJoystickBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         WidgetT *detect = wgtButton(row, "Detect");
     
         detect->onClick    = onDetect;
    -    detect->prefW      = wgtPixels(CPJ_SMALL_BTN_W);
    +    detect->minW       = wgtPixels(CPJ_SMALL_BTN_W);
         sView              = wgtCanvas(page, CPJ_VIEW_W, CPJ_VIEW_H);
         row                = wgtHBox(page);
         row->spacing       = wgtPixels(CPJ_GAP);
         sCalBtn            = wgtButton(row, "Calibrate...");
         sCalBtn->onClick   = onCalibrate;
    -    sCalBtn->prefW     = wgtPixels(CPJ_BTN_W);
    +    sCalBtn->minW      = wgtPixels(CPJ_BTN_W);
         sResetBtn          = wgtButton(row, "Automatic");
         sResetBtn->onClick = onReset;
    -    sResetBtn->prefW   = wgtPixels(CPJ_BTN_W);
    +    sResetBtn->minW    = wgtPixels(CPJ_BTN_W);
     
         wgtLabel(row, "Dead zone:");
         sZoneSldr            = wgtSlider(row, 0, JOY_DEADZONE_MAX);
         sZoneSldr->weight    = WGT_WEIGHT_FILL;
         sZoneSldr->onChange  = onZone;
         sZoneLbl             = wgtLabel(row, "");
    -    sZoneLbl->prefW      = wgtPixels(CPJ_ZONE_LBL_W);
    +    sZoneLbl->minW       = wgtPixels(CPJ_ZONE_LBL_W);
     
         row                  = addRow(page, "Game port:");
         sModeDrop            = wgtDropdown(row);
    @@ -301,7 +301,7 @@ void cpJoystickBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         WidgetT *keys = wgtButton(row, "Keys...");
     
         keys->onClick = onKeys;
    -    keys->prefW   = wgtPixels(CPJ_SMALL_BTN_W);
    +    keys->minW    = wgtPixels(CPJ_SMALL_BTN_W);
     
         WidgetT *timer = wgtTimer(page, CPJ_TICK_MS, true);
     
    @@ -447,12 +447,12 @@ static void onCalibrate(WidgetT *w) {
         row->spacing     = wgtPixels(CPJ_GAP);
         sWiz.next        = wgtButton(row, "Next");
         sWiz.next->onClick = onWizNext;
    -    sWiz.next->prefW   = wgtPixels(CPJ_SMALL_BTN_W);
    +    sWiz.next->minW    = wgtPixels(CPJ_SMALL_BTN_W);
     
         WidgetT *cancel = wgtButton(row, "Cancel");
     
         cancel->onClick = onWizCancel;
    -    cancel->prefW   = wgtPixels(CPJ_SMALL_BTN_W);
    +    cancel->minW    = wgtPixels(CPJ_SMALL_BTN_W);
     
         WidgetT *timer = wgtTimer(root, CPJ_TICK_MS, true);
     
    diff --git a/src/apps/kpunch/cpanel/cpNet.c b/src/apps/kpunch/cpanel/cpNet.c
    index 9494f12d..8e982914 100644
    --- a/src/apps/kpunch/cpanel/cpNet.c
    +++ b/src/apps/kpunch/cpanel/cpNet.c
    @@ -114,8 +114,8 @@ static WidgetT *addField(WidgetT *parent, const char *label, int32_t maxLen, con
         WidgetT *in  = wgtTextInput(row, maxLen);
     
         row->spacing = wgtPixels(CN_GAP);
    -    lbl->prefW   = wgtPixels(CN_LABEL_W);
    -    in->prefW    = wgtChars(CN_FIELD_CHARS);
    +    lbl->minW    = wgtPixels(CN_LABEL_W);
    +    in->minW     = wgtChars(CN_FIELD_CHARS);
         wgtSetText(in, text);
         return in;
     }
    @@ -173,7 +173,7 @@ void cpNetBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         // Packet driver interrupt: "Find it" or one of 60h..80h
         row          = wgtHBox(page);
         row->spacing = wgtPixels(CN_GAP);
    -    wgtLabel(row, "Packet driver:")->prefW = wgtPixels(CN_LABEL_W);
    +    wgtLabel(row, "Packet driver:")->minW = wgtPixels(CN_LABEL_W);
         snprintf(sVectorText[0], CN_VECTOR_TEXT, "%s", "Find it");
         sVectorLabels[0] = sVectorText[0];
     
    @@ -193,10 +193,10 @@ void cpNetBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         row->spacing = wgtPixels(CN_GAP);
         btn          = wgtButton(row, "Status...");
         btn->onClick = onStatus;
    -    btn->prefW   = wgtPixels(CN_BTN_W);
    +    btn->minW    = wgtPixels(CN_BTN_W);
         btn          = wgtButton(row, "Restart");
         btn->onClick = onRestart;
    -    btn->prefW   = wgtPixels(CN_BTN_W);
    +    btn->minW    = wgtPixels(CN_BTN_W);
     }
     
     
    diff --git a/src/apps/kpunch/cpanel/cpPrinter.c b/src/apps/kpunch/cpanel/cpPrinter.c
    index c043d42e..f4faf38b 100644
    --- a/src/apps/kpunch/cpanel/cpPrinter.c
    +++ b/src/apps/kpunch/cpanel/cpPrinter.c
    @@ -25,8 +25,9 @@
     // The printing library is looked up by name when the page is built, so
     // the Control Panel still works where it is not installed (the page then
     // says so).  Page Setup... opens the library's own dialog, which saves
    -// the page setup at once; OK stores the model and port and keeps the
    -// page setup as it is in DVX.INI then.
    +// the page setup at once; OK stores the model and port (and a serial
    +// port's speed and handshake, which only a COM port enables) and keeps
    +// the page setup as it is in DVX.INI then.
     
     #include "cpPrinter.h"
     #include "print/print.h"
    @@ -39,12 +40,15 @@
     #include "textInput/textInpt.h"
     
     #include 
    +#include 
    +#include 
     #include 
     
     #define CPR_LABEL_W     110
     #define CPR_FIELD_CHARS 24
     #define CPR_GAP         4
     #define CPR_BTN_W       110
    +#define CPR_BAUD_CHARS  8       // a speed's label, "38400" and its NUL
     
     // The library's functions and tables (NULL when it is not installed)
     typedef struct {
    @@ -54,6 +58,10 @@ typedef struct {
         __typeof__(printPageSetupDialog) *pageSetup;
         __typeof__(printDestLabel)       *destLabel;
         __typeof__(printModelLabel)      *modelLabel;
    +    __typeof__(printDestIsSerial)    *isSerial;
    +    __typeof__(printBaudRate)        *baudRate;
    +    __typeof__(printHandshakeLabel)  *handshakeLabel;
    +    __typeof__(printSerialStore)     *serialStore;
     } PrintFnsT;
     
     static PrintFnsT    sPrint;
    @@ -62,8 +70,13 @@ static WidgetT     *sModel  = NULL;
     static WidgetT     *sDest   = NULL;
     static WidgetT     *sFile   = NULL;
     static WidgetT     *sBrowse = NULL;    // beside sFile
    +static WidgetT     *sBaud   = NULL;
    +static WidgetT     *sShake  = NULL;
     static const char  *sDestLabels[PrintDestCountE];
     static const char  *sModelLabels[PrintModelCountE];
    +static const char  *sShakeLabels[PrintHandshakeCountE];
    +static const char  *sBaudLabels[PRINT_BAUD_COUNT];
    +static char         sBaudText[PRINT_BAUD_COUNT][CPR_BAUD_CHARS];
     
     // ============================================================
     // Prototypes
    @@ -84,7 +97,7 @@ static WidgetT *addRow(WidgetT *parent, const char *label) {
         WidgetT *lbl = wgtLabel(row, label);
     
         row->spacing = wgtPixels(CPR_GAP);
    -    lbl->prefW   = wgtPixels(CPR_LABEL_W);
    +    lbl->minW    = wgtPixels(CPR_LABEL_W);
         return row;
     }
     
    @@ -92,15 +105,19 @@ static WidgetT *addRow(WidgetT *parent, const char *label) {
     void cpPrinterBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         PrintSettingsT s;
     
    -    sAc               = ac;
    -    sPrint.read       = (__typeof__(sPrint.read))dlsym(RTLD_DEFAULT, DVX_SYM("printSettingsRead"));
    -    sPrint.problem    = (__typeof__(sPrint.problem))dlsym(RTLD_DEFAULT, DVX_SYM("printDeviceProblem"));
    -    sPrint.store      = (__typeof__(sPrint.store))dlsym(RTLD_DEFAULT, DVX_SYM("printDeviceStore"));
    -    sPrint.pageSetup  = (__typeof__(sPrint.pageSetup))dlsym(RTLD_DEFAULT, DVX_SYM("printPageSetupDialog"));
    -    sPrint.destLabel  = (__typeof__(sPrint.destLabel))dlsym(RTLD_DEFAULT, DVX_SYM("printDestLabel"));
    -    sPrint.modelLabel = (__typeof__(sPrint.modelLabel))dlsym(RTLD_DEFAULT, DVX_SYM("printModelLabel"));
    +    sAc                   = ac;
    +    sPrint.read           = (__typeof__(sPrint.read))dlsym(RTLD_DEFAULT, DVX_SYM("printSettingsRead"));
    +    sPrint.problem        = (__typeof__(sPrint.problem))dlsym(RTLD_DEFAULT, DVX_SYM("printDeviceProblem"));
    +    sPrint.store          = (__typeof__(sPrint.store))dlsym(RTLD_DEFAULT, DVX_SYM("printDeviceStore"));
    +    sPrint.pageSetup      = (__typeof__(sPrint.pageSetup))dlsym(RTLD_DEFAULT, DVX_SYM("printPageSetupDialog"));
    +    sPrint.destLabel      = (__typeof__(sPrint.destLabel))dlsym(RTLD_DEFAULT, DVX_SYM("printDestLabel"));
    +    sPrint.modelLabel     = (__typeof__(sPrint.modelLabel))dlsym(RTLD_DEFAULT, DVX_SYM("printModelLabel"));
    +    sPrint.isSerial       = (__typeof__(sPrint.isSerial))dlsym(RTLD_DEFAULT, DVX_SYM("printDestIsSerial"));
    +    sPrint.baudRate       = (__typeof__(sPrint.baudRate))dlsym(RTLD_DEFAULT, DVX_SYM("printBaudRate"));
    +    sPrint.handshakeLabel = (__typeof__(sPrint.handshakeLabel))dlsym(RTLD_DEFAULT, DVX_SYM("printHandshakeLabel"));
    +    sPrint.serialStore    = (__typeof__(sPrint.serialStore))dlsym(RTLD_DEFAULT, DVX_SYM("printSerialStore"));
     
    -    if (!sPrint.read || !sPrint.problem || !sPrint.store || !sPrint.pageSetup || !sPrint.destLabel || !sPrint.modelLabel) {
    +    if (!sPrint.read || !sPrint.problem || !sPrint.store || !sPrint.pageSetup || !sPrint.destLabel || !sPrint.modelLabel || !sPrint.isSerial || !sPrint.baudRate || !sPrint.handshakeLabel || !sPrint.serialStore) {
             memset(&sPrint, 0, sizeof(sPrint));
             wgtLabel(page, "Printing is not installed.");
             return;
    @@ -116,6 +133,18 @@ void cpPrinterBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
             sModelLabels[i] = sPrint.modelLabel((PrintModelE)i);
         }
     
    +    int32_t baudAt = 0;
    +
    +    for (int32_t i = 0; i < PRINT_BAUD_COUNT; i++) {
    +        snprintf(sBaudText[i], sizeof(sBaudText[i]), "%" PRId32, sPrint.baudRate(i));
    +        sBaudLabels[i] = sBaudText[i];
    +        baudAt         = sPrint.baudRate(i) == s.baud ? i : baudAt;
    +    }
    +
    +    for (int32_t i = 0; i < PrintHandshakeCountE; i++) {
    +        sShakeLabels[i] = sPrint.handshakeLabel((PrintHandshakeE)i);
    +    }
    +
         sModel = wgtDropdown(addRow(page, "Printer model:"));
         wgtDropdownSetItems(sModel, sModelLabels, PrintModelCountE);
         wgtDropdownSetSelected(sModel, s.model);
    @@ -127,10 +156,16 @@ void cpPrinterBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         WidgetT *fileRow = addRow(page, "File name:");
     
         sFile            = wgtTextInput(fileRow, PRINT_PATH_MAX - 1);
    -    sFile->prefW     = wgtChars(CPR_FIELD_CHARS);
    +    sFile->minW      = wgtChars(CPR_FIELD_CHARS);
         sBrowse          = wgtButton(fileRow, "&Browse...");
         sBrowse->onClick = onBrowse;
         wgtSetText(sFile, s.filePath);
    +    sBaud  = wgtDropdown(addRow(page, "Speed (baud):"));
    +    wgtDropdownSetItems(sBaud, sBaudLabels, PRINT_BAUD_COUNT);
    +    wgtDropdownSetSelected(sBaud, baudAt);
    +    sShake = wgtDropdown(addRow(page, "Handshake:"));
    +    wgtDropdownSetItems(sShake, sShakeLabels, PrintHandshakeCountE);
    +    wgtDropdownSetSelected(sShake, s.handshake);
         onDest(sDest);
         wgtLabel(page, "Text only works with any printer; choose your printer's");
         wgtLabel(page, "model to print pictures too.");
    @@ -138,7 +173,7 @@ void cpPrinterBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         WidgetT *btn = wgtButton(wgtHBox(page), "Page Setup...");
     
         btn->onClick = onPageSetup;
    -    btn->prefW   = wgtPixels(CPR_BTN_W);
    +    btn->minW    = wgtPixels(CPR_BTN_W);
     }
     
     
    @@ -159,6 +194,7 @@ void cpPrinterStore(PrefsHandleT *prefs) {
         }
     
         sPrint.store(prefs, (PrintModelE)wgtDropdownGetSelected(sModel), (PrintDestE)wgtDropdownGetSelected(sDest), wgtGetText(sFile));
    +    sPrint.serialStore(prefs, sPrint.baudRate(wgtDropdownGetSelected(sBaud)), (PrintHandshakeE)wgtDropdownGetSelected(sShake));
     }
     
     
    @@ -177,12 +213,17 @@ static void onBrowse(WidgetT *w) {
     }
     
     
    -// The file name only matters when printing to a file.
    +// The file name only matters when printing to a file, the speed and
    +// handshake on a serial port.
     static void onDest(WidgetT *w) {
    -    bool toFile = wgtDropdownGetSelected(w) == PrintDestFileE;
    +    PrintDestE dest   = (PrintDestE)wgtDropdownGetSelected(w);
    +    bool       toFile = dest == PrintDestFileE;
    +    bool       serial = sPrint.isSerial(dest);
     
         wgtSetEnabled(sFile, toFile);
         wgtSetEnabled(sBrowse, toFile);
    +    wgtSetEnabled(sBaud, serial);
    +    wgtSetEnabled(sShake, serial);
     }
     
     
    diff --git a/src/apps/kpunch/cpanel/cpSounds.c b/src/apps/kpunch/cpanel/cpSounds.c
    index f3d7aea2..41330f65 100644
    --- a/src/apps/kpunch/cpanel/cpSounds.c
    +++ b/src/apps/kpunch/cpanel/cpSounds.c
    @@ -26,11 +26,19 @@
     // Control Panel still works where it is not installed (the page then says
     // so).  The sound files offered are those in the default sounds folder,
     // the ones DVX.INI names and any the user browses to; "(None)" is always
    -// first.  The MIDI instrument banks offered are the standard one and
    -// those in the banks folders.  The volume applies at once (Cancel puts
    -// DVX.INI's back); the sounds, the driver, the MIDI choice, and the bank
    -// are written by OK, and the driver, MIDI choice, and bank take effect
    -// when DVX next starts.
    +// first.  Two rows of instrument banks: FM banks (OP2) for the FM chip
    +// and the FM software synthesizer, and SoundFonts for wavetable music
    +// (and a sound card's own banks: an UltraSound's patches, an AWE's ROM
    +// bank, where its software installed them); each offers the standard one
    +// and those in the banks folders, each shown by the name of the add-on
    +// that installed it when the package library can say, and its Test plays
    +// a short tune on the one selected.  The volume applies at once (Cancel
    +// puts DVX.INI's back); the sounds, the driver, the MIDI and modules
    +// choices, and the banks are written by OK.  The banks and the modules
    +// choice reach the next music; the driver and the MIDI choice take
    +// effect when DVX next starts.  The music and package libraries are
    +// looked up by name too, and the page does without them.  The page fits
    +// 640x480: the volume and the driver share a row.
     
     #include "cpSounds.h"
     #include "audio/audio.h"
    @@ -43,7 +51,9 @@
     #include "label/label.h"
     #include "listBox/listBox.h"
     #include "music/music.h"
    +#include "pkg/pkgDb.h"
     #include "slider/slider.h"
    +#include "spacer/spacer.h"
     #include "stb_ds_wrap.h"
     
     #include 
    @@ -55,66 +65,137 @@
     
     #define CPS_LABEL_W      100
     #define CPS_GAP          4
    +#define CPS_PAIR_GAP     12      // between two settings on one row
     #define CPS_BTN_W        70
    -#define CPS_LIST_H       148     // the event list: nine rows
     #define CPS_VOL_LBL_W    40
     #define CPS_TEXT_BUF     128
     #define CPS_DRV_ITEMS    (AUDIO_DRIVERS_MAX + 2)   // Automatic, the drivers, DVX.INI's own choice
     #define PERCENT_MAX      100
     #define CPS_NONE_LABEL   "(None)"
     #define CPS_BANK_LABEL   "Standard"
    -#define CPS_BANK_EXT     ".op2"
    +#define CPS_BANK_TITLE   "Instruments"
    +#define CPS_PATCHES      "Gravis UltraSound patches"
    +#define CPS_ROM_BANK     "Sound Blaster AWE ROM instruments"
    +// The Instruments Test: the same rising arpeggio on five General MIDI
    +// instruments in turn (piano, guitar, violin, trumpet, flute)
    +#define CPS_BANK_TUNE    "T150 L8 I1 O3 CEG>C4 I25 O3 CEG>C4 I41 O4 CEG>C4 I57 O4 CEG>C4 I74 O5 CEG>C4"
     
     // A dropdown's items: the values DVX.INI keeps and the labels shown
     typedef struct {
         char       **values;    // stb_ds, owned
    -    const char **labels;    // stb_ds, parallel (into values, or a literal for "")
    +    const char **labels;    // stb_ds, parallel (into values or owned, or a literal for "")
    +    char       **owned;     // stb_ds: labels made for items (choiceSetLabel)
     } ChoicesT;
     
     // The sound library's functions (NULL when it is not installed)
     typedef struct {
    -    __typeof__(audioDeskFile)        *file;
    -    __typeof__(audioDeskLabel)       *label;
    -    __typeof__(audioDeskPlayFile)    *playFile;
    -    __typeof__(audioDeskReload)      *reload;
    -    __typeof__(audioDeskSetFile)     *setFile;
    -    __typeof__(audioDriverNames)     *driverNames;
    -    __typeof__(audioErrorText)       *errorText;
    -    __typeof__(audioGetInfo)         *getInfo;
    -    const PrefsChoiceT               *midiChoices;
    -    __typeof__(audioPercentToVolume) *toVolume;
    -    __typeof__(audioSetMasterVolume) *setVolume;
    -    __typeof__(audioSettingDriver)   *driver;
    -    __typeof__(audioSettingMidi)     *midi;
    -    __typeof__(audioSettingsStore)   *store;
    -    __typeof__(audioSettingVolume)   *volume;
    +    __typeof__(audioDeskFile)           *file;
    +    __typeof__(audioDeskLabel)          *label;
    +    __typeof__(audioDeskPlayFile)       *playFile;
    +    __typeof__(audioDeskReload)         *reload;
    +    __typeof__(audioDeskSetFile)        *setFile;
    +    __typeof__(audioDriverNames)        *driverNames;
    +    __typeof__(audioErrorText)          *errorText;
    +    __typeof__(audioGetInfo)            *getInfo;
    +    const PrefsChoiceT                  *midiChoices;
    +    const PrefsChoiceT                  *midiInChoices;
    +    const PrefsChoiceT                  *modulesChoices;
    +    __typeof__(audioPercentToVolume)    *toVolume;
    +    __typeof__(audioSetMasterVolume)    *setVolume;
    +    __typeof__(audioSettingDriver)      *driver;
    +    __typeof__(audioSettingMidi)        *midi;
    +    __typeof__(audioSettingMidiIn)      *midiIn;
    +    __typeof__(audioSettingMidiInStore) *midiInStore;
    +    __typeof__(audioSettingModules)     *modules;
    +    __typeof__(audioSettingsStore)      *store;
    +    __typeof__(audioSettingVolume)      *volume;
     } SndFnsT;
     
    +// How the music library finds a sound card's own bank (musicPatchSetPath,
    +// musicRomBankPath)
    +typedef bool BankFindFnT(char *out, int32_t outSize);
    +
    +// The music library's functions, for the Instruments Test (NULL when it
    +// is not installed: no Test)
    +typedef struct {
    +    __typeof__(musicErrorText)    *errorText;
    +    __typeof__(musicFree)         *release;
    +    __typeof__(musicLoadMacro)    *loadMacro;
    +    __typeof__(musicPatchSetPath) *patchSet;
    +    __typeof__(musicPlay)         *play;
    +    __typeof__(musicRomBankPath)  *romBank;
    +    __typeof__(musicSetBank)      *setBank;
    +} MusFnsT;
    +
    +// The package library's functions, for the banks' names (NULL when it is
    +// not installed: the names of their files)
    +typedef struct {
    +    __typeof__(pkgDbFree)  *release;
    +    __typeof__(pkgDbLoad)  *load;
    +    __typeof__(pkgDbOwner) *owner;
    +} PkgFnsT;
    +
    +// A function looked up by name into a field of one of those
     typedef struct {
         const char *name;
         size_t      offset;
    -} SndFnSymT;
    +} FnSymT;
     
    -#define SND_FN_SYM(field, fn) { DVX_SYM(#fn), offsetof(SndFnsT, field) }
    +#define FN_SYM(type, field, fn) { DVX_SYM(#fn), offsetof(type, field) }
     
    -static const SndFnSymT sSyms[] = {
    -    SND_FN_SYM(file,        audioDeskFile),
    -    SND_FN_SYM(label,       audioDeskLabel),
    -    SND_FN_SYM(playFile,    audioDeskPlayFile),
    -    SND_FN_SYM(reload,      audioDeskReload),
    -    SND_FN_SYM(setFile,     audioDeskSetFile),
    -    SND_FN_SYM(driverNames, audioDriverNames),
    -    SND_FN_SYM(errorText,   audioErrorText),
    -    SND_FN_SYM(getInfo,     audioGetInfo),
    -    SND_FN_SYM(midiChoices, audioMidiChoices),
    -    SND_FN_SYM(toVolume,    audioPercentToVolume),
    -    SND_FN_SYM(setVolume,   audioSetMasterVolume),
    -    SND_FN_SYM(driver,      audioSettingDriver),
    -    SND_FN_SYM(midi,        audioSettingMidi),
    -    SND_FN_SYM(store,       audioSettingsStore),
    -    SND_FN_SYM(volume,      audioSettingVolume)
    +static const FnSymT sSndSyms[] = {
    +    FN_SYM(SndFnsT, file,           audioDeskFile),
    +    FN_SYM(SndFnsT, label,          audioDeskLabel),
    +    FN_SYM(SndFnsT, playFile,       audioDeskPlayFile),
    +    FN_SYM(SndFnsT, reload,         audioDeskReload),
    +    FN_SYM(SndFnsT, setFile,        audioDeskSetFile),
    +    FN_SYM(SndFnsT, driverNames,    audioDriverNames),
    +    FN_SYM(SndFnsT, errorText,      audioErrorText),
    +    FN_SYM(SndFnsT, getInfo,        audioGetInfo),
    +    FN_SYM(SndFnsT, midiChoices,    audioMidiChoices),
    +    FN_SYM(SndFnsT, midiInChoices,  audioMidiInChoices),
    +    FN_SYM(SndFnsT, modulesChoices, audioModulesChoices),
    +    FN_SYM(SndFnsT, toVolume,       audioPercentToVolume),
    +    FN_SYM(SndFnsT, setVolume,      audioSetMasterVolume),
    +    FN_SYM(SndFnsT, driver,         audioSettingDriver),
    +    FN_SYM(SndFnsT, midi,           audioSettingMidi),
    +    FN_SYM(SndFnsT, midiIn,         audioSettingMidiIn),
    +    FN_SYM(SndFnsT, midiInStore,    audioSettingMidiInStore),
    +    FN_SYM(SndFnsT, modules,        audioSettingModules),
    +    FN_SYM(SndFnsT, store,          audioSettingsStore),
    +    FN_SYM(SndFnsT, volume,         audioSettingVolume)
     };
     
    +static const FnSymT sMusSyms[] = {
    +    FN_SYM(MusFnsT, errorText, musicErrorText),
    +    FN_SYM(MusFnsT, release,   musicFree),
    +    FN_SYM(MusFnsT, loadMacro, musicLoadMacro),
    +    FN_SYM(MusFnsT, patchSet,  musicPatchSetPath),
    +    FN_SYM(MusFnsT, play,      musicPlay),
    +    FN_SYM(MusFnsT, romBank,   musicRomBankPath),
    +    FN_SYM(MusFnsT, setBank,   musicSetBank)
    +};
    +
    +static const FnSymT sPkgSyms[] = {
    +    FN_SYM(PkgFnsT, release, pkgDbFree),
    +    FN_SYM(PkgFnsT, load,    pkgDbLoad),
    +    FN_SYM(PkgFnsT, owner,   pkgDbOwner)
    +};
    +
    +// A row of instrument banks: the files it lists (ext, and altExt when it
    +// is not NULL), the DVX.INI [audio] key it sets (not set: the standard
    +// one), the bank its standard one is as musicSetBank takes it ("" for the
    +// bundled FM bank), and whether it offers the sound cards' own banks
    +typedef struct {
    +    const char *key;
    +    const char *ext;
    +    const char *altExt;
    +    const char *standard;
    +    bool        cardBanks;
    +    WidgetT    *drop;
    +    ChoicesT    choices;        // as stored ("" for the standard one)
    +} BankRowT;
    +
     // The sound files listed from the sounds folder
     static const char *sSoundExts[] = { ".wav", ".voc" };
     
    @@ -123,6 +204,9 @@ static const char *sSoundExts[] = { ".wav", ".voc" };
     // ============================================================
     
     static SndFnsT       sSnd;
    +static MusFnsT       sMus;
    +static PkgFnsT       sPkg;
    +static MusicT       *sBankTune   = NULL;   // an instruments Test playing
     static AppContextT  *sAc         = NULL;
     static PrefsHandleT *sPrefs      = NULL;
     static WidgetT      *sEventList  = NULL;
    @@ -131,26 +215,36 @@ static WidgetT      *sVolSldr    = NULL;
     static WidgetT      *sVolLbl     = NULL;
     static WidgetT      *sDrvDrop    = NULL;
     static WidgetT      *sMidiDrop   = NULL;
    -static WidgetT      *sBankDrop   = NULL;
    +static WidgetT      *sMidiInDrop = NULL;
    +static WidgetT      *sModDrop    = NULL;
     static ChoicesT      sFiles;               // the sound files as stored ("" for none)
    -static ChoicesT      sBanks;               // the MIDI banks as stored ("" for the standard one)
    +static BankRowT      sFmBanks    = { MUSIC_INI_BANK, ".op2", NULL, "", false, NULL, { NULL, NULL, NULL } };
    +static BankRowT      sSoundFonts = { MUSIC_INI_SOUND_FONT, ".sf2", ".sbk", MUSIC_SOUND_FONT_DEFAULT, true, NULL, { NULL, NULL, NULL } };
     static char          sDrvNames[CPS_DRV_ITEMS][AUDIO_NAME_MAX];   // as stored
     static const char   *sDrvLabels[CPS_DRV_ITEMS];
     static int32_t       sDrvCount   = 0;
     static const char   *sEventLabels[SoundCountE];
     static const char   *sMidiLabels[AUDIO_MIDI_CHOICE_COUNT];
    +static const char   *sMidiInLabels[AUDIO_MIDI_IN_CHOICE_COUNT];
    +static const char   *sModLabels[AUDIO_MODULES_CHOICE_COUNT];
     static int32_t       sChoice[SoundCountE];
     
     // ============================================================
     // Prototypes
     // ============================================================
     
    -static int32_t  addBanks(PrefsHandleT *prefs);
    +static void     addBankRow(WidgetT *page, const char *label, BankRowT *row, PrefsHandleT *prefs);
    +static int32_t  addBanks(BankRowT *row, PrefsHandleT *prefs);
     static int32_t  addDriver(const char *name, const char *label);
     static int32_t  addFile(const char *path);
    +static void     addFound(BankRowT *row, BankFindFnT *find, const char *label);
    +static void     addPairGap(WidgetT *row);
     static WidgetT *addRow(WidgetT *parent, const char *label);
     static void     addSoundsDir(void);
    +static void     bankLabel(const char *name, const BankRowT *row, const PkgDbT *db, char *out, int32_t outSize);
    +static bool     bankOfRow(const BankRowT *row, const char *name);
     static int32_t  choiceAdd(ChoicesT *c, const char *value, const char *emptyLabel);
    +static void     choiceSetLabel(ChoicesT *c, int32_t i, const char *label);
     static void     choicesFree(ChoicesT *c);
     static int      compareNames(const void *a, const void *b);
     void            cpSoundsApply(void);
    @@ -158,33 +252,72 @@ void            cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *pref
     void            cpSoundsFree(void);
     void            cpSoundsRevert(void);
     void            cpSoundsStore(PrefsHandleT *prefs);
    +static void     onBankTest(WidgetT *w);
     static void     onBrowse(WidgetT *w);
     static void     onEvent(WidgetT *w);
     static void     onFile(WidgetT *w);
     static void     onTest(WidgetT *w);
     static void     onVolume(WidgetT *w);
    -static bool     resolveLibrary(void);
    +static bool     resolveFns(const FnSymT *syms, int32_t count, void *fns, size_t size);
     static void     showVolume(void);
     
     
    -// The MIDI instrument banks: the standard one, those in the banks folders
    -// (by the names DVX.INI keeps), and DVX.INI's own choice when it is none
    -// of them (so OK keeps it).  Returns DVX.INI's choice's item.
    -static int32_t addBanks(PrefsHandleT *prefs) {
    +// A row of banks (its dropdown, and its Test when the music library is
    +// there).
    +static void addBankRow(WidgetT *page, const char *label, BankRowT *row, PrefsHandleT *prefs) {
    +    int32_t  sel = addBanks(row, prefs);
    +    WidgetT *box = addRow(page, label);
    +
    +    row->drop         = wgtDropdown(box);
    +    row->drop->weight = WGT_WEIGHT_FILL;
    +    wgtDropdownSetItems(row->drop, row->choices.labels, (int32_t)arrlen(row->choices.labels));
    +    wgtDropdownSetSelected(row->drop, sel);
    +
    +    if (sMus.loadMacro) {
    +        WidgetT *btn = wgtButton(box, "Test");
    +
    +        btn->onClick  = onBankTest;
    +        btn->userData = row;
    +        btn->minW     = wgtPixels(CPS_BTN_W);
    +    }
    +}
    +
    +
    +// A row's banks: the standard one, those of its kind in the banks folders
    +// (by the names DVX.INI keeps, shown by bankLabel; the standard one not
    +// twice), the sound cards' own banks where the row offers them, and
    +// DVX.INI's own choice when it is none of them (so OK keeps it).  Returns
    +// DVX.INI's choice's item.
    +static int32_t addBanks(BankRowT *row, PrefsHandleT *prefs) {
         char  **names = platformKindReadDir(DvxKindBanksE);
    +    PkgDbT  db;
    +    bool    haveDb = sPkg.load && sPkg.load(&db, "");
         int32_t sel;
     
    -    choiceAdd(&sBanks, "", CPS_BANK_LABEL);
    +    choiceAdd(&row->choices, "", CPS_BANK_LABEL);
         qsort(names, arrlen(names), sizeof(names[0]), compareNames);
     
         for (int32_t i = 0; i < (int32_t)arrlen(names); i++) {
    -        if (dvxHasExt(names[i], CPS_BANK_EXT)) {
    -            choiceAdd(&sBanks, names[i], CPS_BANK_LABEL);
    +        if (bankOfRow(row, names[i]) && strcasecmp(names[i], row->standard) != 0) {
    +            char label[DVX_MAX_PATH];
    +
    +            bankLabel(names[i], row, haveDb ? &db : NULL, label, sizeof(label));
    +            choiceSetLabel(&row->choices, choiceAdd(&row->choices, names[i], CPS_BANK_LABEL), label);
             }
         }
     
    +    if (row->cardBanks) {
    +        addFound(row, sMus.patchSet, CPS_PATCHES);
    +        addFound(row, sMus.romBank, CPS_ROM_BANK);
    +    }
    +
    +    // Partly loaded when out of memory: freed whenever it was loaded at all
    +    if (sPkg.load) {
    +        sPkg.release(&db);
    +    }
    +
         dvxReadDirFree(names);
    -    sel = choiceAdd(&sBanks, prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, ""), CPS_BANK_LABEL);
    +    sel = choiceAdd(&row->choices, prefsGetString(prefs, AUDIO_INI_SECTION, row->key, ""), CPS_BANK_LABEL);
         return sel;
     }
     
    @@ -213,13 +346,34 @@ static int32_t addFile(const char *path) {
     }
     
     
    +// A sound card's own bank, when find (NULL without the music library)
    +// finds it: an item by its path, shown as label.
    +static void addFound(BankRowT *row, BankFindFnT *find, const char *label) {
    +    char path[DVX_MAX_PATH];
    +
    +    if (find && find(path, sizeof(path))) {
    +        choiceSetLabel(&row->choices, choiceAdd(&row->choices, path, CPS_BANK_LABEL), label);
    +    }
    +}
    +
    +
    +// A fixed gap before the second setting on a row (what is left of the
    +// row stays with the first).
    +static void addPairGap(WidgetT *row) {
    +    WidgetT *gap = wgtSpacer(row);
    +
    +    gap->weight = 0;
    +    gap->minW   = wgtPixels(CPS_PAIR_GAP);
    +}
    +
    +
     // "Label:" and the row for its controls.
     static WidgetT *addRow(WidgetT *parent, const char *label) {
         WidgetT *row = wgtHBox(parent);
         WidgetT *lbl = wgtLabel(row, label);
     
         row->spacing = wgtPixels(CPS_GAP);
    -    lbl->prefW   = wgtPixels(CPS_LABEL_W);
    +    lbl->minW    = wgtPixels(CPS_LABEL_W);
         return row;
     }
     
    @@ -242,6 +396,50 @@ static void addSoundsDir(void) {
     }
     
     
    +// How a bank is shown: as the name of the add-on that installed it (db,
    +// when the package library is there), with the bank's own name after it
    +// when that add-on has more than one of the row's; else as its name
    +// without the extension.
    +static void bankLabel(const char *name, const BankRowT *row, const PkgDbT *db, char *out, int32_t outSize) {
    +    char                 title[DVX_MAX_PATH];
    +    const PkgInstalledT *owner = NULL;
    +    int32_t              banks = 0;
    +    const char          *dot;
    +
    +    snprintf(title, sizeof(title), "%s", name);
    +    dot = strrchr(platformPathBaseName(title), '.');
    +
    +    if (dot) {
    +        title[dot - title] = '\0';
    +    }
    +
    +    if (db) {
    +        char path[DVX_MAX_PATH];
    +
    +        snprintf(path, sizeof(path), DVX_SHARED_DIR DVX_PATH_SEP DVX_BANKS_DIR DVX_PATH_SEP "%s", name);
    +        owner = sPkg.owner(db, path, NULL);
    +    }
    +
    +    for (int32_t i = 0; owner && i < (int32_t)arrlen(owner->manifest.files); i++) {
    +        banks += bankOfRow(row, owner->manifest.files[i].name) ? 1 : 0;
    +    }
    +
    +    if (!owner) {
    +        snprintf(out, (size_t)outSize, "%s", title);
    +    } else if (banks > 1) {
    +        snprintf(out, (size_t)outSize, "%s: %s", owner->manifest.name, platformPathBaseName(title));
    +    } else {
    +        snprintf(out, (size_t)outSize, "%s", owner->manifest.name);
    +    }
    +}
    +
    +
    +// name is one of the row's kind of bank (by its extension).
    +static bool bankOfRow(const BankRowT *row, const char *name) {
    +    return dvxHasExt(name, row->ext) || (row->altExt && dvxHasExt(name, row->altExt));
    +}
    +
    +
     // The item of value, added when it is not there yet (told apart ignoring
     // case: DOS names have none); "" shows as emptyLabel.  Out of memory: the
     // first item.
    @@ -266,15 +464,34 @@ static int32_t choiceAdd(ChoicesT *c, const char *value, const char *emptyLabel)
     }
     
     
    +// Show item i as label (a copy, freed with the choices).
    +static void choiceSetLabel(ChoicesT *c, int32_t i, const char *label) {
    +    char *copy = strdup(label);
    +
    +    if (copy && i >= 0 && i < (int32_t)arrlen(c->labels)) {
    +        arrput(c->owned, copy);
    +        c->labels[i] = copy;
    +    } else {
    +        free(copy);
    +    }
    +}
    +
    +
     static void choicesFree(ChoicesT *c) {
         for (int32_t i = 0; i < (int32_t)arrlen(c->values); i++) {
             free(c->values[i]);
         }
     
    +    for (int32_t i = 0; i < (int32_t)arrlen(c->owned); i++) {
    +        free(c->owned[i]);
    +    }
    +
         arrfree(c->values);
         arrfree(c->labels);
    +    arrfree(c->owned);
         c->values = NULL;
         c->labels = NULL;
    +    c->owned  = NULL;
     }
     
     
    @@ -294,11 +511,14 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         sAc    = ac;
         sPrefs = prefs;
     
    -    if (!resolveLibrary()) {
    +    if (!resolveFns(sSndSyms, DVX_ARRAY_LEN(sSndSyms), &sSnd, sizeof(sSnd))) {
             wgtLabel(page, "Sound is not installed.");
             return;
         }
     
    +    resolveFns(sMusSyms, DVX_ARRAY_LEN(sMusSyms), &sMus, sizeof(sMus));
    +    resolveFns(sPkgSyms, DVX_ARRAY_LEN(sPkgSyms), &sPkg, sizeof(sPkg));
    +
         addFile("");
         addSoundsDir();
     
    @@ -309,7 +529,6 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
     
         wgtLabel(page, "Events:");
         sEventList           = wgtListBox(page);
    -    sEventList->prefH    = wgtPixels(CPS_LIST_H);
         sEventList->onChange = onEvent;
         wgtListBoxSetItems(sEventList, sEventLabels, SoundCountE);
     
    @@ -323,19 +542,10 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
         WidgetT *btn = wgtButton(row, "Browse...");
     
         btn->onClick = onBrowse;
    -    btn->prefW   = wgtPixels(CPS_BTN_W);
    +    btn->minW    = wgtPixels(CPS_BTN_W);
         btn          = wgtButton(row, "Test");
         btn->onClick = onTest;
    -    btn->prefW   = wgtPixels(CPS_BTN_W);
    -
    -    row                 = addRow(page, "Volume:");
    -    sVolSldr            = wgtSlider(row, 0, PERCENT_MAX);
    -    sVolSldr->weight    = WGT_WEIGHT_FILL;
    -    sVolSldr->onChange  = onVolume;
    -    sVolLbl             = wgtLabel(row, "");
    -    sVolLbl->prefW      = wgtPixels(CPS_VOL_LBL_W);
    -    wgtSliderSetValue(sVolSldr, sSnd.volume(prefs));
    -    showVolume();
    +    btn->minW    = wgtPixels(CPS_BTN_W);
     
         // Driver: "Automatic", every installed driver, and DVX.INI's own
         // choice even when that driver is missing (so OK keeps it).
    @@ -359,20 +569,42 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
             drvSel = addDriver(want, NULL);
         }
     
    -    sDrvDrop = wgtDropdown(addRow(page, "Sound driver:"));
    +    // The volume and the driver share a row (the page fits 640x480)
    +    row                 = addRow(page, "Volume:");
    +    sVolSldr            = wgtSlider(row, 0, PERCENT_MAX);
    +    sVolSldr->weight    = WGT_WEIGHT_FILL;
    +    sVolSldr->onChange  = onVolume;
    +    sVolLbl             = wgtLabel(row, "");
    +    sVolLbl->minW       = wgtPixels(CPS_VOL_LBL_W);
    +    wgtSliderSetValue(sVolSldr, sSnd.volume(prefs));
    +    showVolume();
    +    addPairGap(row);
    +    wgtLabel(row, "Sound driver:");
    +    sDrvDrop = wgtDropdown(row);
         wgtDropdownSetItems(sDrvDrop, sDrvLabels, sDrvCount);
         wgtDropdownSetSelected(sDrvDrop, drvSel);
     
    +    // Where MIDI music plays and where MIDI comes in share a row (the page
    +    // has no height to spare)
         prefsChoiceLabels(sSnd.midiChoices, AUDIO_MIDI_CHOICE_COUNT, sMidiLabels);
    -    sMidiDrop = wgtDropdown(addRow(page, "MIDI music:"));
    +    row       = addRow(page, "MIDI music:");
    +    sMidiDrop = wgtDropdown(row);
         wgtDropdownSetItems(sMidiDrop, sMidiLabels, AUDIO_MIDI_CHOICE_COUNT);
         wgtDropdownSetSelected(sMidiDrop, sSnd.midi(prefs));
    +    addPairGap(row);
    +    wgtLabel(row, "MIDI in:");
    +    prefsChoiceLabels(sSnd.midiInChoices, AUDIO_MIDI_IN_CHOICE_COUNT, sMidiInLabels);
    +    sMidiInDrop = wgtDropdown(row);
    +    wgtDropdownSetItems(sMidiInDrop, sMidiInLabels, AUDIO_MIDI_IN_CHOICE_COUNT);
    +    wgtDropdownSetSelected(sMidiInDrop, sSnd.midiIn(prefs));
     
    -    int32_t bankSel = addBanks(prefs);
    +    prefsChoiceLabels(sSnd.modulesChoices, AUDIO_MODULES_CHOICE_COUNT, sModLabels);
    +    sModDrop = wgtDropdown(addRow(page, "Modules:"));
    +    wgtDropdownSetItems(sModDrop, sModLabels, AUDIO_MODULES_CHOICE_COUNT);
    +    wgtDropdownSetSelected(sModDrop, sSnd.modules(prefs));
     
    -    sBankDrop = wgtDropdown(addRow(page, "Instruments:"));
    -    wgtDropdownSetItems(sBankDrop, sBanks.labels, (int32_t)arrlen(sBanks.labels));
    -    wgtDropdownSetSelected(sBankDrop, bankSel);
    +    addBankRow(page, "Instruments:", &sFmBanks, prefs);
    +    addBankRow(page, "SoundFont:", &sSoundFonts, prefs);
     
         AudioInfoT info;
         char       text[CPS_TEXT_BUF];
    @@ -388,8 +620,14 @@ void cpSoundsBuild(AppContextT *ac, WidgetT *page, PrefsHandleT *prefs) {
     
     
     void cpSoundsFree(void) {
    +    if (sMus.release) {
    +        sMus.release(sBankTune);
    +    }
    +
    +    sBankTune = NULL;
         choicesFree(&sFiles);
    -    choicesFree(&sBanks);
    +    choicesFree(&sFmBanks.choices);
    +    choicesFree(&sSoundFonts.choices);
     }
     
     
    @@ -409,16 +647,48 @@ void cpSoundsStore(PrefsHandleT *prefs) {
             sSnd.setFile(prefs, (DvxSoundE)i, sFiles.values[sChoice[i]]);
         }
     
    -    // The standard bank is no setting at all
    -    const char *bank = sBanks.values[wgtDropdownGetSelected(sBankDrop)];
    +    // A standard bank is no setting at all
    +    const BankRowT *rows[] = { &sFmBanks, &sSoundFonts };
     
    -    if (bank[0]) {
    -        prefsSetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, bank);
    -    } else {
    -        prefsRemove(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK);
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(rows); i++) {
    +        const char *bank = rows[i]->choices.values[wgtDropdownGetSelected(rows[i]->drop)];
    +
    +        if (bank[0]) {
    +            prefsSetString(prefs, AUDIO_INI_SECTION, rows[i]->key, bank);
    +        } else {
    +            prefsRemove(prefs, AUDIO_INI_SECTION, rows[i]->key);
    +        }
         }
     
    -    sSnd.store(prefs, sDrvNames[wgtDropdownGetSelected(sDrvDrop)], wgtDropdownGetSelected(sMidiDrop), wgtSliderGetValue(sVolSldr));
    +    sSnd.store(prefs, sDrvNames[wgtDropdownGetSelected(sDrvDrop)], wgtDropdownGetSelected(sMidiDrop), wgtDropdownGetSelected(sModDrop), wgtSliderGetValue(sVolSldr));
    +    sSnd.midiInStore(prefs, wgtDropdownGetSelected(sMidiInDrop));
    +}
    +
    +
    +// Play CPS_BANK_TUNE on the bank selected in the button's row (stored or
    +// not), in place of the tune a press before started.
    +static void onBankTest(WidgetT *w) {
    +    const BankRowT *row  = (const BankRowT *)w->userData;
    +    const char     *bank = row->choices.values[wgtDropdownGetSelected(row->drop)];
    +    int32_t         rc;
    +
    +    sMus.release(sBankTune);
    +    sBankTune = NULL;
    +    rc        = sMus.loadMacro(CPS_BANK_TUNE, &sBankTune);
    +
    +    if (rc == MUSIC_OK) {
    +        rc = sMus.setBank(sBankTune, bank[0] ? bank : row->standard);
    +    }
    +
    +    if (rc == MUSIC_OK) {
    +        rc = sMus.play(sBankTune, false);
    +    }
    +
    +    if (rc != MUSIC_OK) {
    +        sMus.release(sBankTune);
    +        sBankTune = NULL;
    +        dvxMessageBox(sAc, CPS_BANK_TITLE, sMus.errorText(rc), MB_OK | MB_ICONWARNING);
    +    }
     }
     
     
    @@ -493,16 +763,18 @@ static void onVolume(WidgetT *w) {
     }
     
     
    -static bool resolveLibrary(void) {
    -    for (int32_t i = 0; i < DVX_ARRAY_LEN(sSyms); i++) {
    -        void *fn = dlsym(RTLD_DEFAULT, sSyms[i].name);
    +// Fill fns (size bytes) from the functions syms names; false, with fns
    +// cleared, when the library is not there (any one is missing).
    +static bool resolveFns(const FnSymT *syms, int32_t count, void *fns, size_t size) {
    +    for (int32_t i = 0; i < count; i++) {
    +        void *fn = dlsym(RTLD_DEFAULT, syms[i].name);
     
             if (!fn) {
    -            memset(&sSnd, 0, sizeof(sSnd));
    +            memset(fns, 0, size);
                 return false;
             }
     
    -        memcpy((char *)&sSnd + sSyms[i].offset, &fn, sizeof(fn));
    +        memcpy((char *)fns + syms[i].offset, &fn, sizeof(fn));
         }
     
         return true;
    diff --git a/src/apps/kpunch/cpanel/cpTypes.c b/src/apps/kpunch/cpanel/cpTypes.c
    index 064814b6..dbe289d3 100644
    --- a/src/apps/kpunch/cpanel/cpTypes.c
    +++ b/src/apps/kpunch/cpanel/cpTypes.c
    @@ -146,17 +146,17 @@ void cpTypesBuild(WidgetT *page) {
     
         WidgetT *addBtn = wgtButton(btnRow, "&Add...");
     
    -    addBtn->prefW   = wgtPixels(CT_BTN_W);
    +    addBtn->minW    = wgtPixels(CT_BTN_W);
         addBtn->onClick = onAdd;
     
         WidgetT *changeBtn = wgtButton(btnRow, "C&hange...");
     
    -    changeBtn->prefW   = wgtPixels(CT_BTN_W);
    +    changeBtn->minW    = wgtPixels(CT_BTN_W);
         changeBtn->onClick = onChange;
     
         WidgetT *removeBtn = wgtButton(btnRow, "&Remove");
     
    -    removeBtn->prefW   = wgtPixels(CT_BTN_W);
    +    removeBtn->minW    = wgtPixels(CT_BTN_W);
         removeBtn->onClick = onRemove;
     }
     
    @@ -211,6 +211,7 @@ static void onBrowse(WidgetT *w) {
         TypeDlgT         *td        = (TypeDlgT *)w->userData;
         const FileFilterT filters[] = {
             { "Programs (*.app)" },
    +        { SHELL_DOS_FILTER },
             { "All Files (*.*)" }
         };
         char              path[DVX_MAX_PATH];
    diff --git a/src/apps/kpunch/cpanel/cpanel.c b/src/apps/kpunch/cpanel/cpanel.c
    index 4bbbc7f6..13593820 100644
    --- a/src/apps/kpunch/cpanel/cpanel.c
    +++ b/src/apps/kpunch/cpanel/cpanel.c
    @@ -25,7 +25,7 @@
     // System configuration app with these tabs:
     //   Mouse      -- scroll direction, wheel speed, double-click speed, acceleration, cursor speed
     //   Colors     -- every system color, theme load/save
    -//   Desktop    -- wallpaper image (stretch mode)
    +//   Desktop    -- wallpaper image (stretch mode), the screen saver
     //   Video      -- resolution and color depth
     //   File Types -- which program opens each kind of document (cpTypes.c)
     //   Network    -- TCP/IP settings and the network's state (cpNet.c)
    @@ -50,10 +50,13 @@
     #include "listBox/listBox.h"
     #include "slider/slider.h"
     #include "spacer/spacer.h"
    +#include "spinner/spinner.h"
     #include "tabControl/tabCtrl.h"
     #include "dvxWm.h"
     #include "dvxPlat.h"
     #include "shellApp.h"
    +#include "shellProg.h"
    +#include "shellSaver.h"
     #include "cpJoystick.h"
     #include "cpNet.h"
     #include "cpPrinter.h"
    @@ -94,6 +97,7 @@
     #define CP_REVERT_PREFIX      "The old mode comes back in "
     #define CP_REVERT_SUFFIX      " seconds."
     #define CP_WPAPER_BTN_W       90
    +#define CP_SAVER_NONE         "(None)"
     #define CP_LABEL_BUF          48
     #define CP_FILTER_BUF         128
     #define CP_ENTRY_NAME_MAX     64
    @@ -171,6 +175,11 @@ static WidgetT     *sWpaperList    = NULL;
     static WidgetT     *sWpModeDrop    = NULL;
     static FileEntryT  *sWpaperEntries = NULL;  // stb_ds dynamic array
     static const char **sWpaperLabels  = NULL;  // stb_ds dynamic array
    +static WidgetT     *sSaverDrop     = NULL;
    +static WidgetT     *sSaverWait     = NULL;
    +static WidgetT     *sSaverTest     = NULL;
    +static FileEntryT  *sSaverEntries  = NULL;  // stb_ds dynamic array: (None), then the savers (path: the name SAVERS knows)
    +static const char **sSaverLabels   = NULL;  // stb_ds dynamic array
     
     // Video tab
     static const VideoModeInfoT *sVideoModes                 = NULL;  // points into ctx, not owned
    @@ -190,12 +199,15 @@ static WidgetT     *sThemeList    = NULL;
     // Prototypes
     // ============================================================
     
    +static void        addGap(WidgetT *page);
     static void        applyMouseConfig(void);
     void               appShutdown(void);
     static void        buildColorsTab(WidgetT *page);
     static void        buildDesktopTab(WidgetT *page);
     static void        buildMouseTab(WidgetT *page);
     static void        buildVideoTab(WidgetT *page);
    +static void        entriesClear(FileEntryT **entries, const char ***labels);
    +static void        entriesLabels(const FileEntryT *entries, const char ***labels);
     static int         entryNameCompare(const void *a, const void *b);
     static void        onAccelChange(WidgetT *w);
     static void        onApplyTheme(WidgetT *w);
    @@ -212,6 +224,8 @@ static void        onDblClickTest(WidgetT *w);
     static void        onMenu(WindowT *win, int32_t menuId);
     static void        onOk(WidgetT *w);
     static void        onResetColors(WidgetT *w);
    +static void        onSaverChange(WidgetT *w);
    +static void        onSaverTest(WidgetT *w);
     static void        onSaveTheme(WidgetT *w);
     static void        onSmoothText(WidgetT *w);
     static void        onSpeedSlider(WidgetT *w);
    @@ -223,8 +237,10 @@ static void        onWallpaperMode(WidgetT *w);
     static void        onWheelChange(WidgetT *w);
     static void        onWheelStepSlider(WidgetT *w);
     static void        restoreSnapshot(void);
    +static int32_t     saverIndex(void);
     static void        saveSnapshot(void);
     static void        scanKindEntries(DvxKindE kind, const char *const *exts, int32_t extCount, bool stripExt, FileEntryT **entries, const char ***labels);
    +static void        scanSavers(void);
     static void        scanThemes(void);
     static void        scanWallpapers(void);
     static void        setIntLabel(WidgetT *lbl, const char *prefix, int32_t val, const char *suffix);
    @@ -249,6 +265,15 @@ SHELL_APP_DESCRIPTOR(
     );
     
     
    +// A small fixed gap between a page's rows.
    +static void addGap(WidgetT *page) {
    +    WidgetT *gap = wgtSpacer(page);
    +
    +    gap->weight = 0;
    +    gap->minH   = wgtPixels(CP_SPACING_SMALL);
    +}
    +
    +
     static void applyMouseConfig(void) {
         int32_t dirIdx   = wgtDropdownGetSelected(sWheelDrop);
         int32_t dbl      = wgtSliderGetValue(sDblClickSldr);
    @@ -286,6 +311,10 @@ void appShutdown(void) {
         sWpaperEntries = NULL;
         arrfree(sWpaperLabels);
         sWpaperLabels = NULL;
    +    arrfree(sSaverEntries);
    +    sSaverEntries = NULL;
    +    arrfree(sSaverLabels);
    +    sSaverLabels = NULL;
         arrfree(sVideoLabels);
         sVideoLabels = NULL;
         arrfree(sLabelBufs);
    @@ -340,19 +369,19 @@ static void buildColorsTab(WidgetT *page) {
     
         WidgetT *loadBtn = wgtButton(themeRow, "Apply");
         loadBtn->onClick = onApplyTheme;
    -    loadBtn->prefW   = wgtPixels(CP_THEME_BTN_W);
    +    loadBtn->minW    = wgtPixels(CP_THEME_BTN_W);
     
         WidgetT *browseBtn = wgtButton(themeRow, "Load...");
         browseBtn->onClick = onBrowseTheme;
    -    browseBtn->prefW   = wgtPixels(CP_THEME_BTN_W);
    +    browseBtn->minW    = wgtPixels(CP_THEME_BTN_W);
     
         WidgetT *saveBtn = wgtButton(themeRow, "Save As...");
         saveBtn->onClick = onSaveTheme;
    -    saveBtn->prefW   = wgtPixels(CP_SAVE_BTN_W);
    +    saveBtn->minW    = wgtPixels(CP_SAVE_BTN_W);
     
         WidgetT *resetBtn = wgtButton(themeRow, "Reset");
         resetBtn->onClick = onResetColors;
    -    resetBtn->prefW   = wgtPixels(CP_THEME_BTN_W);
    +    resetBtn->minW    = wgtPixels(CP_THEME_BTN_W);
     
         // Right side: the selected color and its values, in a non-weighted
         // inner box so they stay at natural size; the outer box absorbs extra
    @@ -394,15 +423,15 @@ static void buildDesktopTab(WidgetT *page) {
     
         WidgetT *applyBtn = wgtButton(btnRow, "Apply");
         applyBtn->onClick = onApplyWallpaper;
    -    applyBtn->prefW   = wgtPixels(CP_WPAPER_BTN_W);
    +    applyBtn->minW    = wgtPixels(CP_WPAPER_BTN_W);
     
         WidgetT *chooseBtn = wgtButton(btnRow, "Browse...");
         chooseBtn->onClick = onChooseWallpaper;
    -    chooseBtn->prefW   = wgtPixels(CP_WPAPER_BTN_W);
    +    chooseBtn->minW    = wgtPixels(CP_WPAPER_BTN_W);
     
         WidgetT *clearBtn = wgtButton(btnRow, "Clear");
         clearBtn->onClick  = onClearWallpaper;
    -    clearBtn->prefW    = wgtPixels(CP_WPAPER_BTN_W);
    +    clearBtn->minW     = wgtPixels(CP_WPAPER_BTN_W);
     
         wgtSpacer(btnRow)->weight = WGT_WEIGHT_FILL;
         wgtLabel(btnRow, "Mode:");
    @@ -415,6 +444,29 @@ static void buildDesktopTab(WidgetT *page) {
         sWpModeDrop->onChange = onWallpaperMode;
         wgtDropdownSetItems(sWpModeDrop, modeItems, PREFS_WALLPAPER_CHOICE_COUNT);
         wgtDropdownSetSelected(sWpModeDrop, (int32_t)sAc->wallpaperMode);
    +
    +    // The screen saver: (None) or one of the savers by its name, the
    +    // wait, and Test (shellSaver.h).  Live like the rest.
    +    WidgetT *saverRow = wgtHBox(page);
    +    saverRow->spacing = wgtPixels(CP_SPACING);
    +    wgtLabel(saverRow, "Screen sa&ver:");
    +
    +    scanSavers();
    +    sSaverDrop = wgtDropdown(saverRow);
    +    sSaverDrop->weight   = WGT_WEIGHT_FILL;
    +    sSaverDrop->onChange = onSaverChange;
    +    wgtDropdownSetItems(sSaverDrop, sSaverLabels, arrlen(sSaverLabels));
    +    wgtDropdownSetSelected(sSaverDrop, saverIndex());
    +
    +    wgtLabel(saverRow, "&Wait:");
    +    sSaverWait = wgtSpinner(saverRow, SHELL_SAVER_WAIT_MIN, SHELL_SAVER_WAIT_MAX, 1);
    +    sSaverWait->onChange = onSaverChange;
    +    wgtSpinnerSetValue(sSaverWait, shellSaverWait());
    +    wgtLabel(saverRow, "minutes");
    +
    +    sSaverTest = wgtButton(saverRow, "&Test");
    +    sSaverTest->onClick = onSaverTest;
    +    wgtSetEnabled(sSaverTest, shellSaverName()[0] != '\0');
     }
     
     
    @@ -436,7 +488,7 @@ static void buildMouseTab(WidgetT *page) {
         wgtDropdownSetItems(sWheelDrop, wheelItems, PREFS_WHEEL_DIR_CHOICE_COUNT);
         wgtDropdownSetSelected(sWheelDrop, prefsChoiceIndexOfValue(prefsWheelDirChoices, PREFS_WHEEL_DIR_CHOICE_COUNT, sAc->wheelDirection));
     
    -    wgtSpacer(page)->prefH = wgtPixels(CP_SPACING_SMALL);
    +    addGap(page);
     
         // Wheel speed (lines per notch)
         wgtLabel(page, "Scroll Speed:");
    @@ -451,11 +503,11 @@ static void buildMouseTab(WidgetT *page) {
     
         wgtSliderSetValue(sWheelStepSldr, sAc->wheelStep);
         wgtLabel(wheelStepRow, "Fast  ");
    -    sWheelStepLbl = wgtLabel(wheelStepRow, "");
    -    sWheelStepLbl->prefW = wgtPixels(CP_DBLCLICK_LBL_W);
    +    sWheelStepLbl       = wgtLabel(wheelStepRow, "");
    +    sWheelStepLbl->minW = wgtPixels(CP_DBLCLICK_LBL_W);
         updateWheelStepLabel();
     
    -    wgtSpacer(page)->prefH = wgtPixels(CP_SPACING_SMALL);
    +    addGap(page);
     
         // Double-click speed
         wgtLabel(page, "Double-Click Speed:");
    @@ -470,11 +522,11 @@ static void buildMouseTab(WidgetT *page) {
     
         wgtSliderSetValue(sDblClickSldr, sAc->dblClickMs);
         wgtLabel(dblRow, "Slow  ");
    -    sDblClickLbl = wgtLabel(dblRow, "");
    -    sDblClickLbl->prefW = wgtPixels(CP_DBLCLICK_LBL_W);
    +    sDblClickLbl       = wgtLabel(dblRow, "");
    +    sDblClickLbl->minW = wgtPixels(CP_DBLCLICK_LBL_W);
         updateDblClickLabel();
     
    -    wgtSpacer(page)->prefH = wgtPixels(CP_SPACING_SMALL);
    +    addGap(page);
     
         // Acceleration
         WidgetT *accelRow = wgtHBox(page);
    @@ -491,7 +543,7 @@ static void buildMouseTab(WidgetT *page) {
         wgtDropdownSetItems(sAccelDrop, accelItems, PREFS_ACCEL_CHOICE_COUNT);
         wgtDropdownSetSelected(sAccelDrop, prefsChoiceIndexOfValue(prefsAccelChoices, PREFS_ACCEL_CHOICE_COUNT, sAc->mouseAccel));
     
    -    wgtSpacer(page)->prefH = wgtPixels(CP_SPACING_SMALL);
    +    addGap(page);
     
         // Speed (mickey-to-pixel ratio)
         wgtLabel(page, "Cursor Speed:");
    @@ -508,11 +560,11 @@ static void buildMouseTab(WidgetT *page) {
         // INI 'speed' key use); convert to the inverted slider position.
         wgtSliderSetValue(sSpeedSldr, CP_SPEED_INVERT - sAc->mouseMickeys);
         wgtLabel(speedRow, "Fast  ");
    -    sSpeedLbl = wgtLabel(speedRow, "");
    -    sSpeedLbl->prefW = wgtPixels(CP_DBLCLICK_LBL_W);
    +    sSpeedLbl       = wgtLabel(speedRow, "");
    +    sSpeedLbl->minW = wgtPixels(CP_DBLCLICK_LBL_W);
         updateSpeedLabel();
     
    -    wgtSpacer(page)->prefH = wgtPixels(CP_SPACING_SMALL);
    +    addGap(page);
     
         // Double-click test area
         WidgetT *testRow = wgtHBox(page);
    @@ -520,7 +572,7 @@ static void buildMouseTab(WidgetT *page) {
     
         WidgetT *testBtn = wgtButton(testRow, "Test Area");
         testBtn->onDblClick = onDblClickTest;
    -    testBtn->prefW      = wgtPixels(CP_TEST_BTN_W);
    +    testBtn->minW       = wgtPixels(CP_TEST_BTN_W);
     
         sDblTestLbl = wgtLabel(testRow, "Double-click the button to test");
     }
    @@ -581,7 +633,7 @@ static void buildVideoTab(WidgetT *page) {
     
         WidgetT *applyBtn = wgtButton(page, "Apply Mode");
         applyBtn->onClick = onVideoApply;
    -    applyBtn->prefW   = wgtPixels(CP_APPLY_BTN_W);
    +    applyBtn->maxW    = wgtPixels(CP_APPLY_BTN_W);
     
         sSmoothChk = wgtCheckbox(page, "Smooth the edges of text (more than 256 colors)");
         wgtCheckboxSetChecked(sSmoothChk, sAc->fontSmoothing);
    @@ -589,6 +641,22 @@ static void buildVideoTab(WidgetT *page) {
     }
     
     
    +// A list's entries and labels emptied (kept allocated for the next scan).
    +static void entriesClear(FileEntryT **entries, const char ***labels) {
    +    arrsetlen(*entries, 0);
    +    arrsetlen(*labels, 0);
    +}
    +
    +
    +// A list's labels: each entry's display name, in order (once the entries
    +// are all there: the labels point into them).
    +static void entriesLabels(const FileEntryT *entries, const char ***labels) {
    +    for (int32_t i = 0; i < arrlen(entries); i++) {
    +        arrput(*labels, entries[i].name);
    +    }
    +}
    +
    +
     // qsort: entries by display name, case ignored (scanKindEntries)
     static int entryNameCompare(const void *a, const void *b) {
         return strcasecmp(((const FileEntryT *)a)->name, ((const FileEntryT *)b)->name);
    @@ -789,6 +857,7 @@ static void onOk(WidgetT *w) {
         dvxMouseToPrefs(sAc, sPrefs);
         dvxColorsToPrefs(sAc, sPrefs);
         dvxWallpaperToPrefs(sAc, sPrefs);
    +    shellSaverToPrefs(sPrefs);
         dvxFontsToPrefs(sAc, sPrefs);
         prefsSetVideoMode(sPrefs, sAc->display.width, sAc->display.height, sAc->display.format.bitsPerPixel);
     
    @@ -816,6 +885,21 @@ static void onResetColors(WidgetT *w) {
     }
     
     
    +static void onSaverChange(WidgetT *w) {
    +    int32_t sel = wgtDropdownGetSelected(sSaverDrop);
    +
    +    (void)w;
    +    shellSaverSet(sel > 0 && sel < arrlen(sSaverEntries) ? sSaverEntries[sel].path : "", wgtSpinnerGetValue(sSaverWait));
    +    wgtSetEnabled(sSaverTest, shellSaverName()[0] != '\0');
    +}
    +
    +
    +static void onSaverTest(WidgetT *w) {
    +    (void)w;
    +    shellSaverStart();
    +}
    +
    +
     static void onSaveTheme(WidgetT *w) {
         (void)w;
     
    @@ -932,11 +1016,11 @@ static void onVideoApply(WidgetT *w) {
         btnRow->spacing = wgtPixels(CP_SPACING);
     
         WidgetT *yesBtn = wgtButton(btnRow, "&Keep");
    -    yesBtn->prefW   = wgtPixels(CP_CONFIRM_BTN_W);
    +    yesBtn->minW    = wgtPixels(CP_CONFIRM_BTN_W);
         yesBtn->onClick = onVideoConfirmYes;
     
         WidgetT *noBtn = wgtButton(btnRow, "&Revert");
    -    noBtn->prefW    = wgtPixels(CP_CONFIRM_BTN_W);
    +    noBtn->minW     = wgtPixels(CP_CONFIRM_BTN_W);
         noBtn->onClick  = onVideoConfirmNo;
     
         dvxFitWindow(sAc, confirmWin);
    @@ -1029,10 +1113,23 @@ static void restoreSnapshot(void) {
         }
     
         dvxWallpaperFromPrefs(sAc, sSnapshot);
    +    shellSaverFromPrefs(sSnapshot);
         dvxFontsFromPrefs(sAc, sSnapshot);
     }
     
     
    +// The list entry of the saver chosen; 0 ((None)) when there is none.
    +static int32_t saverIndex(void) {
    +    for (int32_t i = 1; i < arrlen(sSaverEntries); i++) {
    +        if (strcasecmp(sSaverEntries[i].path, shellSaverName()) == 0) {
    +            return i;
    +        }
    +    }
    +
    +    return 0;
    +}
    +
    +
     // The state Cancel goes back to, written like DVX.INI but kept in memory.
     static void saveSnapshot(void) {
         prefsClose(sSnapshot);
    @@ -1042,6 +1139,7 @@ static void saveSnapshot(void) {
             dvxColorsToPrefs(sAc, sSnapshot);
             dvxMouseToPrefs(sAc, sSnapshot);
             dvxWallpaperToPrefs(sAc, sSnapshot);
    +        shellSaverToPrefs(sSnapshot);
             dvxFontsToPrefs(sAc, sSnapshot);
             prefsSetVideoMode(sSnapshot, sAc->display.width, sAc->display.height, sAc->display.format.bitsPerPixel);
         }
    @@ -1055,8 +1153,7 @@ static void saveSnapshot(void) {
     // buffer are shortened for display only; the path always uses the
     // original filename.
     static void scanKindEntries(DvxKindE kind, const char *const *exts, int32_t extCount, bool stripExt, FileEntryT **entries, const char ***labels) {
    -    arrsetlen(*entries, 0);
    -    arrsetlen(*labels, 0);
    +    entriesClear(entries, labels);
     
         char  **names = platformKindReadDir(kind);
         int32_t n     = (int32_t)arrlen(names);
    @@ -1101,10 +1198,43 @@ static void scanKindEntries(DvxKindE kind, const char *const *exts, int32_t extC
             qsort(*entries, (size_t)arrlen(*entries), sizeof((*entries)[0]), entryNameCompare);
         }
     
    -    // Build label array now that the entry array is stable
    -    for (int32_t i = 0; i < arrlen(*entries); i++) {
    -        arrput(*labels, (*entries)[i].name);
    +    entriesLabels(*entries, labels);
    +}
    +
    +
    +// (None), then every program in the SAVERS folders by its name (its
    +// "name" resource), in file order; a saver chosen that is not in them
    +// (named by a path) comes last.
    +static void scanSavers(void) {
    +    char      **names = platformKindReadDir(DvxKindSaversE);
    +    FileEntryT  entry = { .name = CP_SAVER_NONE };
    +
    +    entriesClear(&sSaverEntries, &sSaverLabels);
    +    arrput(sSaverEntries, entry);
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(names); i++) {
    +        char          path[DVX_MAX_PATH];
    +        ShellProgramT info;
    +
    +        if (!shellIsProgram(names[i]) || !platformKindFind(path, sizeof(path), DvxKindSaversE, names[i])) {
    +            continue;
    +        }
    +
    +        shellProgramInfo(path, &info);
    +        snprintf(entry.name, sizeof(entry.name), "%s", info.name);
    +        snprintf(entry.path, sizeof(entry.path), "%s", names[i]);
    +        arrput(sSaverEntries, entry);
         }
    +
    +    dvxReadDirFree(names);
    +
    +    if (shellSaverName()[0] && saverIndex() == 0) {
    +        snprintf(entry.name, sizeof(entry.name), "%s", shellSaverName());
    +        snprintf(entry.path, sizeof(entry.path), "%s", shellSaverName());
    +        arrput(sSaverEntries, entry);
    +    }
    +
    +    entriesLabels(sSaverEntries, &sSaverLabels);
     }
     
     
    @@ -1236,11 +1366,11 @@ int32_t appMain(DxeAppContextT *ctx) {
     
         WidgetT *okBtn = wgtButton(btnRow, "OK");
         okBtn->onClick = onOk;
    -    okBtn->prefW   = wgtPixels(CP_OK_CANCEL_BTN_W);
    +    okBtn->minW    = wgtPixels(CP_OK_CANCEL_BTN_W);
     
         WidgetT *cancelBtn = wgtButton(btnRow, "Cancel");
         cancelBtn->onClick = onCancel;
    -    cancelBtn->prefW   = wgtPixels(CP_OK_CANCEL_BTN_W);
    +    cancelBtn->minW    = wgtPixels(CP_OK_CANCEL_BTN_W);
     
         dvxFitWindow(sAc, sWin);
         return 0;
    diff --git a/src/apps/kpunch/cpanel/cpanel.dhs b/src/apps/kpunch/cpanel/cpanel.dhs
    index 3b48cd80..8f1454cf 100644
    --- a/src/apps/kpunch/cpanel/cpanel.dhs
    +++ b/src/apps/kpunch/cpanel/cpanel.dhs
    @@ -34,11 +34,11 @@
     
     .h1 Control Panel
     
    -The Control Panel changes how DVX looks and works: the mouse, the screen colors, the desktop picture, the screen resolution, which program opens each kind of file, the network settings, the printer, the game controllers, and the sounds. Its window has nine tabs; when they do not all fit, the arrows beside them show the others. Click a tab to see its settings.
    +The Control Panel changes how DVX looks and works: the mouse, the screen colors, the desktop picture and the screen saver, the screen resolution, which program opens each kind of file, the network settings, the printer, the game controllers, and the sounds. Its window has nine tabs; when they do not all fit, the arrows beside them show the others. Click a tab to see its settings.
     
     .link cpanel.mouse Mouse: pointer speed, double-click speed, and the scroll wheel
     .link cpanel.colors Colors: the colors of every part of the screen, and color themes
    -.link cpanel.desktop Desktop: the picture behind the windows
    +.link cpanel.desktop Desktop: the picture behind the windows, and the screen saver
     .link cpanel.video Video: the screen resolution, number of colors, and text smoothing
     .link cpanel.types File Types: which program opens each kind of document
     .link cpanel.network Network: connecting DVX to a network, and its status
    @@ -129,11 +129,13 @@ A theme is a saved set of all the colors. The row under the list works with them
     Remember to click OK if you want to keep the colors after you close the Control Panel.
     
     .topic cpanel.desktop
    -.title Desktop Picture
    +.title Desktop Picture and Screen Saver
     .toc 0 Desktop
     .index Wallpaper
     .index Desktop Picture
     .index Background
    +.index Screen Saver
    +.index Screensaver
     
     .h1 Desktop Picture
     
    @@ -158,6 +160,29 @@ The Mode list chooses how the picture fills the screen:
     
     If a picture cannot be loaded, the Control Panel says so and the desktop stays as it was.
     
    +.h2 Screen Saver
    +
    +A screen saver fills the screen with a moving picture when the computer has not been used for a while, and goes away when you press a key or move the mouse. The key or click that wakes the screen does nothing else, so it cannot change your work by accident.
    +
    +.list
    +.item Screen saver: choose one of the screen savers, or (None) for no screen saver.
    +.item Wait: the number of minutes, from 1 to 60, with no key pressed and no mouse movement before the screen saver starts.
    +.item Test starts the screen saver now, so you can see it. Press a key or move the mouse to end it.
    +.endlist
    +
    +The screen saver does not start while a message or a dialog box is waiting for an answer, or while a program (such as a game) is using the whole screen.
    +
    +DVX comes with four screen savers:
    +
    +.table
    +  Screen saver   What it shows
    +  ------------   -------------
    +  Blank Screen   A black screen.
    +  Marquee        DVX's name drifting around the screen in changing colors.
    +  Mystify        Two bouncing shapes that leave trails of their outlines.
    +  Starfield      Stars flying toward you out of the middle of the screen.
    +.endtable
    +
     .topic cpanel.video
     .title Screen Resolution and Colors
     .toc 0 Video
    @@ -208,7 +233,7 @@ When you open a document (from the Program Manager, for example), DVX starts the
     .table
       Button       What it does
       ------       ------------
    -  Add...       Set up a new extension: type it (such as TXT), pick the program with Browse..., and click OK. An extension that is already listed gets the new program.
    +  Add...       Set up a new extension: type it (such as TXT), pick the program with Browse..., and click OK. An extension that is already listed gets the new program. The program can be a DOS program (a .EXE, .COM, or .BAT file): opening a document then runs it with DVX stepped aside, as the Program Manager's help describes under DOS Programs.
       Change...    Pick a different program for the selected extension. Double-clicking an extension does the same.
       Remove       Forget the selected extension. The program itself is not removed.
     .endtable
    @@ -282,8 +307,10 @@ The Printer tab tells DVX about your printer. Every program prints to the printe
     .table
       Setting         What it does
       -------         ------------
    -  Printer model   Text only works with any printer, but prints only text. Choose Epson 9-pin, Epson 24-pin, or HP LaserJet if your printer is one of those or works like one (most dot matrix printers work like an Epson, most laser printers like a LaserJet); then pictures can be printed too.
    -  Connected to    The printer port the printer is plugged into: LPT1, LPT2, or LPT3. Choose File to print into a file instead.
    +  Printer model   Text only works with any printer, but prints only text. Choose one of the others if your printer is one of those or works like one (most dot matrix printers work like an Epson, most laser and ink jet printers like a LaserJet); then pictures can be printed too. HP LaserJet III or DeskJet prints pictures sharper and faster on laser and ink jet printers made since about 1990; choose HP LaserJet for an older LaserJet (the LaserJet II or Plus). For color, choose Epson 9-pin color or Epson 24-pin color for a dot matrix printer with a color ribbon, HP DeskJet 500C for a DeskJet that holds one color cartridge, or HP DeskJet 550C and later for one that holds a black and a color cartridge. Choose PostScript or PostScript (color) for a PostScript printer (often an Apple LaserWriter or a printer with a PostScript option); text prints on it too, and pages that programs draw are drawn by the printer itself, as sharp as it can print. Choose Apple ImageWriter II, or Apple ImageWriter II (color) with the four-color ribbon, for an ImageWriter II on a serial port. Pictures print in shades of grey on the models without color.
    +  Connected to    The port the printer is plugged into: LPT1, LPT2, or LPT3 for a parallel printer, COM1 to COM4 for a serial printer (such as the ImageWriter II). Choose File to print into a file instead.
    +  Speed (baud)    For a serial printer: how fast the port sends, to match the printer's own setting (the ImageWriter II's DIP switches offer 300 to 9600; 9600 is usual).
    +  Handshake       For a serial printer: how it tells the computer to wait. Hardware (DSR, Apple) suits Apple's ImageWriter cable; Hardware (CTS) a cable that wires the printer's ready line to CTS; XON / XOFF a printer set to send those characters.
       File name       The file printouts go into when Connected to is File: type it, or click Browse... to pick it. Each printout replaces it.
     .endtable
     
    @@ -347,6 +374,9 @@ Every change works at once, so you can see it in the picture. Like the other tab
     .index Volume
     .index Sound Driver
     .index MIDI Music
    +.index Modules
    +.index SoundFont
    +.index Instruments
     
     .h1 Sounds
     
    @@ -360,12 +390,16 @@ The Sounds tab chooses the sound DVX makes for each event, such as starting, end
       Browse...      Chooses any sound file (WAV or VOC) for the selected event.
       Test           Plays the sound shown, so you can hear it.
       Volume         How loud all sounds and music are, in percent. It changes at once.
    -  Sound driver   The sound hardware to use. Automatic finds it by itself (the usual choice); sb is a Sound Blaster, speaker the PC speaker, null no sound at all.
    -  MIDI music     Where MIDI music plays: Automatic (the music chip on the sound card, or DVX's own synthesizer that sounds much the same), a MIDI port with an external synthesizer, the FM synthesizer chip, or the built-in synthesizer.
    -  Instruments    The instrument sounds MIDI music uses on the music chip and the built-in synthesizer: Standard (the set that comes with DVX), or another instrument bank installed with an add-on package or put in the BANKS folder of your user folder.
    +  Sound driver   The sound hardware to use. Automatic finds it by itself (the usual choice); wss is a Windows Sound System card, sb a Sound Blaster, gus a Gravis UltraSound, lpt a small sound device plugged into the printer port (a Covox Speech Thing, a Disney Sound Source, or a Stereo-on-1), speaker the PC speaker, null no sound at all.
    +  MIDI music     Where MIDI music plays: Automatic (the recorded instruments of a Gravis UltraSound or Sound Blaster AWE card when it has them, else the music chip on the sound card, else DVX's own synthesizer that sounds much the same as the chip), a MIDI port with an external synthesizer, the FM synthesizer chip, the built-in synthesizer, or the built-in SoundFont synthesizer (recorded instruments played by the computer itself, on any sound card; it needs more memory and a faster computer).
    +  MIDI in        Where MIDI input comes from, for programs that read a MIDI keyboard (the Sound Recorder records one): None, or the MIDI port (the MPU-401 port on the sound card, or one like it).
    +  Modules        Where music modules (MOD, S3M, XM, and IT files) play: Wavetable synthesizer when free (on the voices of a Gravis UltraSound or Sound Blaster AWE card, which takes the computer little time, when no other music plays there and the module fits in the card's memory; otherwise mixed by the computer), or Mixed in software (always mixed by the computer, which takes much more of a slow computer's time but plays every effect a module has).
    +  Instruments    The instrument sounds MIDI music uses on the music chip and the built-in synthesizer: Standard (the set that comes with DVX), or another instrument bank. A bank installed with an add-on package (Software offers some) is shown by the add-on's name; one you put in the BANKS folder of your user folder, by its file's name.
    +  SoundFont      The recorded instruments MIDI music uses on a Gravis UltraSound or a Sound Blaster AWE card, and in the built-in SoundFont synthesizer: Standard (DVX's General MIDI SoundFont, an add-on package Software offers), a SoundFont you put in the BANKS folder of your user folder, Gravis UltraSound patches (the card's own instruments, when its software is installed), or Sound Blaster AWE ROM instruments (the instruments built into the card, when Creative's software is installed). Without the Standard SoundFont, the card's own instruments are used when it has them.
    +  Test           Beside Instruments and SoundFont: plays a short tune on the instruments shown, a few in turn, so you can hear them before you choose.
     .endtable
     
    -A new sound driver, MIDI music choice, or set of instruments takes effect when DVX next starts.
    +A new set of instruments, SoundFont, or Modules choice is used by the next music that starts. A new sound driver, MIDI music, or MIDI in choice takes effect when DVX next starts.
     
     .h2 The Events
     
    diff --git a/src/apps/kpunch/dvxbasic/Makefile b/src/apps/kpunch/dvxbasic/Makefile
    index 2df422e5..68e1b008 100644
    --- a/src/apps/kpunch/dvxbasic/Makefile
    +++ b/src/apps/kpunch/dvxbasic/Makefile
    @@ -72,7 +72,7 @@ RES_SRCS     = $(LIBDVX)/dvxResource.c $(LIBDVX)/dvxFontFmt.c $(TOOLSDIR)/dvxRes
     CLI_SRCS     = basBuild.c $(COMP_SRCS) $(VM_LINK_SRCS) $(RES_SRCS) $(SUPPORT_SRCS)
     
     # Runtime library objects (VM + values + form runtime + serialization)
    -RT_OBJS = $(OBJDIR)/vm.o $(OBJDIR)/values.o $(OBJDIR)/dates.o $(OBJDIR)/gfx.o $(OBJDIR)/screen.o $(OBJDIR)/formrt.o $(OBJDIR)/formGfx.o $(OBJDIR)/formDesign.o $(OBJDIR)/formCtl.o $(OBJDIR)/frmParser.o $(OBJDIR)/frmModel.o $(OBJDIR)/frmModelBas.o $(OBJDIR)/basNativeDos.o $(OBJDIR)/serialize.o $(OBJDIR)/basRunHost.o
    +RT_OBJS = $(OBJDIR)/vm.o $(OBJDIR)/values.o $(OBJDIR)/dates.o $(OBJDIR)/gfx.o $(OBJDIR)/screen.o $(OBJDIR)/formrt.o $(OBJDIR)/formGfx.o $(OBJDIR)/formPrinter.o $(OBJDIR)/formDesign.o $(OBJDIR)/formCtl.o $(OBJDIR)/frmParser.o $(OBJDIR)/frmModel.o $(OBJDIR)/frmModelBas.o $(OBJDIR)/basNativeDos.o $(OBJDIR)/serialize.o $(OBJDIR)/basRunHost.o
     RT_TARGETDIR = $(LIBSDIR)/kpunch/basrt
     RT_TARGET    = $(RT_TARGETDIR)/basrt.lib
     
    @@ -311,7 +311,7 @@ clean: host-basrt-clean
     # of the host build.
     HOSTRT_DIR      = $(HOST_OBJROOT)/basrt
     HOSTRT_TARGET   = $(HOST_OBJROOT)/basrt.a
    -HOSTRT_SRCS     = $(COMP_SRCS) $(VM_SRCS) formrt/formrt.c formrt/formGfx.c formrt/formDesign.c formrt/formCtl.c formrt/frmParser.c formrt/frmModel.c formrt/frmModelBas.c formrt/basNativeHost.c formrt/basRunHost.c
    +HOSTRT_SRCS     = $(COMP_SRCS) $(VM_SRCS) formrt/formrt.c formrt/formGfx.c formrt/formPrinter.c formrt/formDesign.c formrt/formCtl.c formrt/frmParser.c formrt/frmModel.c formrt/frmModelBas.c formrt/basNativeHost.c formrt/basRunHost.c
     HOSTRT_OBJS     = $(patsubst %.c,$(HOSTRT_DIR)/%.o,$(notdir $(HOSTRT_SRCS)))
     HOSTRT_CFLAGS   = $(HOST_CFLAGS) -Wno-stringop-truncation $(DVX_INCLUDES) -I../../../widgets/kpunch -I../../../libs/kpunch/dvxshell -I../../../libs/kpunch/libtasks
     
    diff --git a/src/apps/kpunch/dvxbasic/basdev/basdev.dhs b/src/apps/kpunch/dvxbasic/basdev/basdev.dhs
    index f6c10a10..ea9c0bf8 100644
    --- a/src/apps/kpunch/dvxbasic/basdev/basdev.dhs
    +++ b/src/apps/kpunch/dvxbasic/basdev/basdev.dhs
    @@ -116,7 +116,7 @@ A UserControl's header (the Begin UserControl block of a .ctl) is read and chang
     
     .h2 Running a Program (basRunHost.h, in basrt.lib)
     
    -basRunHostCreate makes the VM and form runtime for a module from a BasRunSpecT: App.Path, App.User (from the program's file: its folder in the user folder), App.Data (the DATA folder beside it), COMMAND$, the print, input, and DoEvents callbacks, the screen window's name, the help file, and whether runtime errors show their own box. basRunHostRegisterFrm registers a .frm text, basRunHostLoadForms loads the forms and shows the startup form, basRunHostSlice runs module code once under the step limit (an error's message and line are always set afterwards), basRunHostAlive says whether the event-loop phase goes on, basRunHostTagBreak puts BAS_RUN_BREAK_TAG on the captions while paused, and basRunHostDestroy ends it. The runtime made inside another program's code does not become the current runtime (basFormRtCurrent / basFormRtSetCurrent); each of these calls makes it current while its code runs.
    +basRunHostCreate makes the VM and form runtime for a module from a BasRunSpecT: App.Path, App.User (from the program's file: its folder in the user folder), App.Data (the DATA folder beside it), COMMAND$, the print, input, and DoEvents callbacks, the screen window's name, the help file, whether runtime errors show their own box, and whether it is a standalone program (the stub's own: the runtime owns the module, frees it with itself, and is ended through the shell's kill hook, shellRegisterAppKill, when the program is ended by force; the IDE and basdev end their runtimes themselves). basRunHostRegisterFrm registers a .frm text, basRunHostLoadForms loads the forms and shows the startup form, basRunHostSlice runs module code once under the step limit (an error's message and line are always set afterwards), basRunHostAlive says whether the event-loop phase goes on, basRunHostTagBreak puts BAS_RUN_BREAK_TAG on the captions while paused, and basRunHostDestroy ends it. The runtime made inside another program's code does not become the current runtime (basFormRtCurrent / basFormRtSetCurrent); each of these calls makes it current while its code runs.
     
     VM accessors a debugger host uses (vm.h): basVmSetAppPaths, basVmSetBreakpointCallback, basVmSetDebugPaused, basVmResume, basVmStop, basVmClearStepState, basVmGetCurrentLine, basVmGetErrorLine.
     
    diff --git a/src/apps/kpunch/dvxbasic/compiler/basEvents.h b/src/apps/kpunch/dvxbasic/compiler/basEvents.h
    index 3e048704..1809eee0 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/basEvents.h
    +++ b/src/apps/kpunch/dvxbasic/compiler/basEvents.h
    @@ -230,6 +230,13 @@ typedef struct {
     #define BAS_SCALE_HIMETRIC     8   // vbHimetric
     #define BAS_SCALE_COUNT        9
     
    +// The Printer object's Orientation and ColorMode (VB's constants)
    +#define BAS_PRINTER_OBJ_NAME   "Printer"
    +#define BAS_PRINTER_PORTRAIT   1   // vbPRORPortrait (the only one)
    +#define BAS_PRINTER_LANDSCAPE  2   // vbPRORLandscape
    +#define BAS_PRINTER_MONO       1   // vbPRCMMonochrome
    +#define BAS_PRINTER_COLOR      2   // vbPRCMColor
    +
     // PaintPicture's raster operations (VB's vb* opcode constants, Windows
     // ternary raster ops): bits 16..23 hold the operation's truth table over
     // the pattern (FillColor), the source and the destination pixels.
    diff --git a/src/apps/kpunch/dvxbasic/compiler/compiler.c b/src/apps/kpunch/dvxbasic/compiler/compiler.c
    index 938d0d85..949e54be 100644
    --- a/src/apps/kpunch/dvxbasic/compiler/compiler.c
    +++ b/src/apps/kpunch/dvxbasic/compiler/compiler.c
    @@ -34,6 +34,7 @@
     #include "dvxTypes.h"
     #include "textHelp.h"
     #include "../runtime/dates.h"
    +#include "../runtime/gfx.h"
     #include "thirdparty/stb_ds_wrap.h"
     
     #include 
    @@ -200,6 +201,29 @@ static const PredefConstT sPredefConsts[] = {
         { "vbMillimeters",        BAS_SCALE_MM },
         { "vbCentimeters",        BAS_SCALE_CM },
         { "vbHimetric",           BAS_SCALE_HIMETRIC },
    +
    +    // FillStyle and DrawStyle
    +    { "vbFSSolid",            BAS_GFX_FILL_SOLID },
    +    { "vbFSTransparent",      BAS_GFX_FILL_TRANSPARENT },
    +    { "vbHorizontalLine",     BAS_GFX_FILL_HORIZONTAL },
    +    { "vbVerticalLine",       BAS_GFX_FILL_VERTICAL },
    +    { "vbUpwardDiagonal",     BAS_GFX_FILL_UP_DIAGONAL },
    +    { "vbDownwardDiagonal",   BAS_GFX_FILL_DOWN_DIAGONAL },
    +    { "vbCross",              BAS_GFX_FILL_CROSS },
    +    { "vbDiagonalCross",      BAS_GFX_FILL_DIAGONAL_CROSS },
    +    { "vbSolid",              BAS_GFX_DRAW_SOLID },
    +    { "vbDash",               BAS_GFX_DRAW_DASH },
    +    { "vbDot",                BAS_GFX_DRAW_DOT },
    +    { "vbDashDot",            BAS_GFX_DRAW_DASH_DOT },
    +    { "vbDashDotDot",         BAS_GFX_DRAW_DASH_DOT_DOT },
    +    { "vbInvisible",          BAS_GFX_DRAW_INVISIBLE },
    +    { "vbInsideSolid",        BAS_GFX_DRAW_INSIDE_SOLID },
    +
    +    // The Printer object's Orientation and ColorMode
    +    { "vbPRORPortrait",       BAS_PRINTER_PORTRAIT },
    +    { "vbPRORLandscape",      BAS_PRINTER_LANDSCAPE },
    +    { "vbPRCMMonochrome",     BAS_PRINTER_MONO },
    +    { "vbPRCMColor",          BAS_PRINTER_COLOR },
         { "vbPicTypeNone",        BAS_PIC_TYPE_NONE },
         { "vbPicTypeBitmap",      BAS_PIC_TYPE_BITMAP },
         { "vbSrcCopy",            BAS_ROP_SRCCOPY },
    diff --git a/src/apps/kpunch/dvxbasic/ctrlover.dhs b/src/apps/kpunch/dvxbasic/ctrlover.dhs
    index f3450096..187b187d 100644
    --- a/src/apps/kpunch/dvxbasic/ctrlover.dhs
    +++ b/src/apps/kpunch/dvxbasic/ctrlover.dhs
    @@ -212,6 +212,7 @@ End Sub
     .index CurrentX
     .index CurrentY
     .index DrawWidth
    +.index DrawStyle
     .index FillColor
     .index FillStyle
     .index FontScale
    @@ -382,7 +383,8 @@ End Sub
       CurrentX      Single    0         Where the next drawing or printing starts.
       CurrentY      Single    0
       DrawWidth     Integer   1         Pen width in pixels.
    -  FillStyle     Integer   1         How boxes (B) and circles are filled with FillColor: 0 solid, 1 (the default) not at all, 2 horizontal lines, 3 vertical lines, 4 upward diagonals, 5 downward diagonals, 6 a cross hatch, 7 a diagonal cross hatch (the lines 8 pixels apart, the gaps left as they are). Other values are an error. BF fills solid in the line's colour.
    +  DrawStyle     Integer   0         The pen's lines and outlines: 0 solid, 1 dashes, 2 dots, 3 dash-dot, 4 dash-dot-dot (a pen 1 pixel wide only; a wider one draws solid), 5 none at all (fills are still drawn), 6 solid. Other values are an error. The constants vbSolid to vbInsideSolid name them.
    +  FillStyle     Integer   1         How boxes (B) and circles are filled with FillColor: 0 solid, 1 (the default) not at all, 2 horizontal lines, 3 vertical lines, 4 upward diagonals, 5 downward diagonals, 6 a cross hatch, 7 a diagonal cross hatch (the lines 8 pixels apart, the gaps left as they are). Other values are an error. BF fills solid in the line's colour. The constants vbFSSolid to vbDiagonalCross name them.
       FillColor     Long      &H000000  Colour boxes and circles are filled with.
       FontScale     Integer   1         How many times as large Print writes its text (1 to 8): each dot of the font becomes a FontScale x FontScale block, for titles and banners. TextWidth and TextHeight follow it; PrintAccel does not.
       AutoRedraw    Boolean   False     True: no Paint events (see above).
    diff --git a/src/apps/kpunch/dvxbasic/form.dhs b/src/apps/kpunch/dvxbasic/form.dhs
    index 15439b35..ed669675 100644
    --- a/src/apps/kpunch/dvxbasic/form.dhs
    +++ b/src/apps/kpunch/dvxbasic/form.dhs
    @@ -201,7 +201,7 @@ End Sub
     
     .h2 Drawing
     
    -Forms have Visual Basic's drawing methods and properties (Cls, PSet, Line, Circle, Point, Print, PaintPicture, DrawFocusRect, DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar, DrawScrollArrow, ScrollBarClick, ScrollBarsNeeded, PrintAccel, Scale, CurrentX, CurrentY, ForeColor, BackColor, DrawWidth, FillColor, FillStyle, FontScale, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight): see Drawing on Forms and PictureBoxes. In the form's own code use Me.Line, Me.Circle, and so on.
    +Forms have Visual Basic's drawing methods and properties (Cls, PSet, Line, Circle, Point, Print, PaintPicture, DrawFocusRect, DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar, DrawScrollArrow, ScrollBarClick, ScrollBarsNeeded, PrintAccel, Scale, CurrentX, CurrentY, ForeColor, BackColor, DrawWidth, DrawStyle, FillColor, FillStyle, FontScale, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight): see Drawing on Forms and PictureBoxes. In the form's own code use Me.Line, Me.Circle, and so on.
     
     .h2 Example
     
    diff --git a/src/apps/kpunch/dvxbasic/formrt/basRunHost.c b/src/apps/kpunch/dvxbasic/formrt/basRunHost.c
    index feebf42f..a4b0f811 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/basRunHost.c
    +++ b/src/apps/kpunch/dvxbasic/formrt/basRunHost.c
    @@ -24,11 +24,14 @@
     
     #include "basRunHost.h"
     #include "../basRes.h"
    +#include "../runtime/serialize.h"
     #include "dvxPlat.h"
     #include "dvxWm.h"
    +#include "shellApp.h"
     
     #include "stb_ds_wrap.h"
     
    +#include 
     #include 
     #include 
     #include 
    @@ -37,8 +40,19 @@
     #define RUN_HOST_ERR_FORMRT "Out of memory creating the form runtime."
     #define RUN_HOST_ERR_OTHER  "Runtime error"
     
    +// A standalone program's runtime, its module, and the program it runs for
    +typedef struct {
    +    BasFormRtT *rt;
    +    BasModuleT *module;
    +    int32_t     appId;
    +} HostRecT;
    +
    +static HostRecT *sHosts      = NULL;    // stb_ds
    +static bool      sKillHooked = false;   // basRunHostAppKilled given to the shell
    +
     // Prototypes (alphabetical)
     bool         basRunHostAlive(const BasFormRtT *rt);
    +static void  basRunHostAppKilled(int32_t appId);
     BasFormRtT  *basRunHostCreate(AppContextT *ctx, BasModuleT *module, const BasRunSpecT *spec, const char **outError);
     void         basRunHostDestroy(BasFormRtT *rt);
     void         basRunHostLoadForms(BasFormRtT *rt, const char *startupForm);
    @@ -52,6 +66,19 @@ bool basRunHostAlive(const BasFormRtT *rt) {
     }
     
     
    +// The shell's kill hook: a program ended by force has its runtimes ended
    +// as its own end would (its windows are still there, none of its code
    +// runs).
    +static void basRunHostAppKilled(int32_t appId) {
    +    for (ptrdiff_t i = arrlen(sHosts) - 1; i >= 0; i--) {
    +        if (i < arrlen(sHosts) && sHosts[i].appId == appId) {
    +            dvxLog("BASIC: '%s' ended by force; its runtime freed", sHosts[i].rt->title);
    +            basRunHostDestroy(sHosts[i].rt);
    +        }
    +    }
    +}
    +
    +
     BasFormRtT *basRunHostCreate(AppContextT *ctx, BasModuleT *module, const BasRunSpecT *spec, const char **outError) {
         BasVmT *vm = basVmCreate();
     
    @@ -115,15 +142,41 @@ BasFormRtT *basRunHostCreate(AppContextT *ctx, BasModuleT *module, const BasRunS
             snprintf(rt->helpFile, sizeof(rt->helpFile), "%s", spec->helpFile);
         }
     
    +    if (spec->standalone) {
    +        HostRecT rec = { rt, module, ctx->currentAppId };
    +
    +        arrput(sHosts, rec);
    +
    +        // The shell is looked up at run time (the host tests have none)
    +        if (!sKillHooked) {
    +            void (*reg)(ShellAppCleanupFnT fn) = dlsym(NULL, DVX_SYM("shellRegisterAppKill"));
    +
    +            sKillHooked = true;
    +
    +            if (reg) {
    +                reg(basRunHostAppKilled);
    +            }
    +        }
    +    }
    +
         return rt;
     }
     
     
     void basRunHostDestroy(BasFormRtT *rt) {
    -    BasVmT *vm = rt->vm;
    +    BasVmT     *vm     = rt->vm;
    +    BasModuleT *module = NULL;
    +
    +    for (ptrdiff_t i = arrlen(sHosts) - 1; i >= 0; i--) {
    +        if (sHosts[i].rt == rt) {
    +            module = sHosts[i].module;
    +            arrdel(sHosts, i);
    +        }
    +    }
     
         basFormRtDestroy(rt);
         basVmDestroy(vm);
    +    basModuleFree(module);
     }
     
     
    diff --git a/src/apps/kpunch/dvxbasic/formrt/basRunHost.h b/src/apps/kpunch/dvxbasic/formrt/basRunHost.h
    index c6547728..e7bcac7d 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/basRunHost.h
    +++ b/src/apps/kpunch/dvxbasic/formrt/basRunHost.h
    @@ -53,6 +53,7 @@ typedef struct {
         BasDoEventsFnT  doEventsFn;          // DoEvents and the pause loop of a breakpoint in a handler
         void           *doEventsCtx;
         bool            suppressErrorDialog; // the host reports runtime errors itself
    +    bool            standalone;          // the .app's own program (basstub): see basRunHostCreate
     } BasRunSpecT;
     
     // A form's caption saved while " [break]" is appended to it.
    @@ -69,13 +70,18 @@ typedef struct {
     bool basRunHostAlive(const BasFormRtT *rt);
     
     // A VM running module (borrowed: it must outlive the runtime) and its
    -// form runtime, set up from spec.  Returns the runtime (rt->vm is the
    +// form runtime, set up from spec.  A standalone program's runtime
    +// (spec->standalone, once it is made) owns the module, freed with it, and
    +// is ended as the program's own end would when the program is ended by
    +// force (the shell's kill hook); the IDE and basdev end theirs
    +// themselves.  Returns the runtime (rt->vm is the
     // VM), or NULL with *outError set when memory runs out.  A module the VM
     // refuses (too many globals) leaves rt->vm->module NULL; running it then
     // fails with the reason in basVmGetError.
     BasFormRtT *basRunHostCreate(AppContextT *ctx, BasModuleT *module, const BasRunSpecT *spec, const char **outError);
     
    -// Destroy the runtime made by basRunHostCreate and its VM.
    +// Destroy the runtime made by basRunHostCreate and its VM (and a
    +// standalone program's module).
     void basRunHostDestroy(BasFormRtT *rt);
     
     // Load every registered form and show the startup form (named, else the
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formGfx.c b/src/apps/kpunch/dvxbasic/formrt/formGfx.c
    index 1ea69746..f1512b27 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/formGfx.c
    +++ b/src/apps/kpunch/dvxbasic/formrt/formGfx.c
    @@ -83,9 +83,7 @@
     #define DRAW_GREEN_SHIFT        8
     #define DRAW_BYTE_MASK          0xFF
     #define DRAW_ASPECT             1.0     // forms and PictureBoxes have square pixels
    -#define DRAW_PAINT_MIN_ARGS     3       // PaintPicture picture, x, y
    -#define DRAW_PAINT_SIZE_ARGS    5       // ... , width, height
    -#define DRAW_PAINT_SRC_ARGS     9       // ... , srcX, srcY, srcWidth, srcHeight
    +#define DRAW_RGB_BYTES          3
     #define DRAW_PAINT_ROP_ARGS     10      // ... , opcode (a raster operation)
     #define DRAW_ROP_COMBOS         8       // pattern / source / destination bit combinations a raster op's truth table covers
     #define DRAW_ROP_PATTERN_BIT    4       // in a combination: the pattern bit
    @@ -163,6 +161,12 @@ struct BasScreenWinT {
         uint8_t  deskPal[BAS_SCREEN_PAL_ENTRIES * BAS_SCREEN_PAL_CHANNELS];  // 256-colour desktop palette the table was made for
     };
     
    +// drawPrint's text: the canvas's drawing state and font
    +typedef struct {
    +    BasDrawT          *d;
    +    const BitmapFontT *font;
    +} CanvasTextT;
    +
     // DrawFocusRect's dots: where and in which pixel value
     typedef struct {
         WidgetT  *canvas;
    @@ -175,6 +179,9 @@ struct BasDrawT {
         WidgetT     *canvas;         // the PictureBox, or the form's drawing area (NULL until a form draws)
         AppContextT *ctx;
         int32_t      scaleMode;      // VB ScaleMode (BAS_SCALE_*)
    +    double       twipsX;         // twips in a pixel across and down (the screen: DRAW_TWIPS_PER_PIXEL; a printer's dots)
    +    double       twipsY;
    +    bool         paper;          // a surface with no canvas (the Printer): no AutoRedraw, BackColor or FontScale
         int32_t      fontScale;      // FontScale: Print's text this many times as large (in the object's font, drawFont)
         bool         autoRedraw;     // True: no Paint events
         bool         autoRedrawSet;  // the program set AutoRedraw (False then clears to BackColor before Paint)
    @@ -216,6 +223,7 @@ typedef enum {
         DRAW_P_BACKCOLOR,
         DRAW_P_CURRENTX,
         DRAW_P_CURRENTY,
    +    DRAW_P_DRAWSTYLE,
         DRAW_P_DRAWWIDTH,
         DRAW_P_FILLCOLOR,
         DRAW_P_FILLSTYLE,
    @@ -260,6 +268,7 @@ static const char *const sDrawProps[DRAW_P_COUNT] = {
         [DRAW_P_BACKCOLOR]   = BAS_PROP_BACKCOLOR,
         [DRAW_P_CURRENTX]    = "CurrentX",
         [DRAW_P_CURRENTY]    = "CurrentY",
    +    [DRAW_P_DRAWSTYLE]   = "DrawStyle",
         [DRAW_P_DRAWWIDTH]   = "DrawWidth",
         [DRAW_P_FILLCOLOR]   = "FillColor",
         [DRAW_P_FILLSTYLE]   = "FillStyle",
    @@ -277,16 +286,20 @@ static const char *const sDrawProps[DRAW_P_COUNT] = {
     // ============================================================
     
     static bool      canvasSize(WidgetT *canvas, int32_t *w, int32_t *h);
    +static int32_t   canvasText(void *ctx, const char *text, int32_t len, int32_t x, int32_t y);
    +static bool      drawCanvasOnly(const BasDrawT *d, int32_t prop);
     static int32_t   drawCls(BasDrawT *d);
     static int32_t   drawFind(const char *const *names, int32_t count, const char *name);
     static void      drawFocusDot(void *ctx, int32_t x, int32_t y);
     static int32_t   drawFocusRectOn(BasDrawT *d, const BasValueT *args, int32_t argc);
     static const BitmapFontT *drawFont(const BasControlT *ctrl);
     static BasDrawT *drawFor(BasFormRtT *rt, BasControlT *ctrl, bool needCanvas);
    +static bool      drawGetProp(BasDrawT *d, DrawPropE prop, BasValueT *out);
     static bool      drawIsSurface(const BasControlT *ctrl);
     static bool      drawMeasure(BasDrawT *d, const BitmapFontT *font, DrawMethodE method, const BasValueT *args, int32_t argc, BasValueT *result);
     static void      drawPaintKeyed(BasFormRtT *rt, BasDrawT *d, WidgetT *src, const uint8_t *pixels, int32_t pitch, uint32_t opcode, const BasValueT *args, int32_t argc, int32_t sx, int32_t sy, int32_t sw, int32_t sh, int32_t dx, int32_t dy);
     static void      drawPaintPicture(BasFormRtT *rt, BasControlT *ctrl, BasDrawT *d, BasValueT *args, int32_t argc);
    +static bool      drawPictureOf(BasFormRtT *rt, BasFormT *form, BasValueT v, WidgetT **src, uint8_t **pixels, int32_t *w, int32_t *h, int32_t *pitch, const BasGfxT **scale);
     static int32_t   drawPixelSpan(const BasGfxT *g, double length, bool across);
     static void      drawPrint(BasDrawT *d, const BitmapFontT *font, BasValueT value, int32_t mode);
     static uint32_t  drawRasterOp(uint32_t rop, uint32_t pattern, uint32_t source, uint32_t dest);
    @@ -294,11 +307,13 @@ static int32_t   drawScale(BasDrawT *d, const BasValueT *args);
     static void      drawScaleApply(BasDrawT *d);
     static int32_t   drawScaleArg(const BasValueT *args, int32_t argc, int32_t idx, int32_t omitted);
     static bool      drawScaleLength(BasDrawT *d, const BasValueT *args, int32_t argc, bool across, double *out);
    +static bool      drawScaleProp(int32_t prop);
    +static void      drawScaleXY(BasFormRtT *rt, BasDrawT *d, DrawMethodE method, const BasValueT *args, int32_t argc, BasValueT *result);
    +static bool      drawSetProp(BasFormRtT *rt, BasDrawT *d, DrawPropE prop, BasValueT value);
     static bool      drawSetScaleProp(BasFormRtT *rt, BasDrawT *d, int32_t prop, BasValueT value);
     static void      drawSync(BasDrawT *d);
     static int32_t   drawThemed(BasFormRtT *rt, BasDrawT *d, const BitmapFontT *font, DrawMethodE method, const BasValueT *args, int32_t argc);
     static double    drawToPixelLength(BasDrawT *d, double length, int32_t scale, bool across);
    -static void      drawToPixels(const BasGfxT *g, double x, double y, double w, double h, int32_t *px, int32_t *py, int32_t *pw, int32_t *ph);
     static uint32_t  readPixel(const uint8_t *src, int32_t bytesPerPixel);
     static void      screenBlinkTick(WidgetT *w);
     static void      screenExpandRow(const BasScreenWinT *sw, uint8_t *dst, const uint8_t *src, int32_t count, int32_t bytesPerPixel);
    @@ -329,6 +344,11 @@ void basFormRtDrawClear(BasFormRtT *rt, BasControlT *ctrl) {
     }
     
     
    +void basFormRtDrawDelete(BasDrawT *d) {
    +    free(d);
    +}
    +
    +
     void basFormRtDrawFree(BasControlT *ctrl) {
         free(ctrl->draw);
         ctrl->draw = NULL;
    @@ -349,70 +369,12 @@ bool basFormRtDrawGetProp(BasFormRtT *rt, BasControlT *ctrl, const char *name, B
             return true;
         }
     
    -    switch ((DrawPropE)prop) {
    -        case DRAW_P_AUTOREDRAW:
    -            *out = basValBool(d->autoRedraw);
    -            break;
    +    return drawGetProp(d, (DrawPropE)prop, out);
    +}
     
    -        case DRAW_P_BACKCOLOR:
    -            *out = basValLong((int32_t)d->gfx.back);
    -            break;
     
    -        case DRAW_P_CURRENTX:
    -            *out = basValDouble(d->gfx.lastX);
    -            break;
    -
    -        case DRAW_P_CURRENTY:
    -            *out = basValDouble(d->gfx.lastY);
    -            break;
    -
    -        case DRAW_P_DRAWWIDTH:
    -            *out = basValInteger((int16_t)d->gfx.drawWidth);
    -            break;
    -
    -        case DRAW_P_FILLCOLOR:
    -            *out = basValLong((int32_t)d->gfx.fillColor);
    -            break;
    -
    -        case DRAW_P_FILLSTYLE:
    -            *out = basValInteger((int16_t)d->gfx.fillStyle);
    -            break;
    -
    -        case DRAW_P_FONTSCALE:
    -            *out = basValInteger((int16_t)d->fontScale);
    -            break;
    -
    -        case DRAW_P_FORECOLOR:
    -            *out = basValLong((int32_t)d->gfx.fore);
    -            break;
    -
    -        case DRAW_P_SCALEHEIGHT:
    -            drawSync(d);
    -            *out = basValDouble(d->gfx.world ? d->gfx.worldHeight : d->gfx.surf.height);
    -            break;
    -
    -        case DRAW_P_SCALELEFT:
    -            *out = basValDouble(d->gfx.world ? d->gfx.worldLeft : 0.0);
    -            break;
    -
    -        case DRAW_P_SCALEMODE:
    -            *out = basValInteger((int16_t)d->scaleMode);
    -            break;
    -
    -        case DRAW_P_SCALETOP:
    -            *out = basValDouble(d->gfx.world ? d->gfx.worldTop : 0.0);
    -            break;
    -
    -        case DRAW_P_SCALEWIDTH:
    -            drawSync(d);
    -            *out = basValDouble(d->gfx.world ? d->gfx.worldWidth : d->gfx.surf.width);
    -            break;
    -
    -        default:
    -            return false;
    -    }
    -
    -    return true;
    +BasGfxT *basFormRtDrawGfx(BasDrawT *d) {
    +    return &d->gfx;
     }
     
     
    @@ -456,15 +418,7 @@ bool basFormRtDrawMethod(BasFormRtT *rt, BasControlT *ctrl, const char *name, Ba
     
         // ScaleX / ScaleY need no drawing area either
         if (method == DRAW_M_SCALEX || method == DRAW_M_SCALEY) {
    -        BasDrawT *scaled = drawFor(rt, ctrl, false);
    -        double    length = 0.0;
    -
    -        if (!scaled || !drawScaleLength(scaled, args, argc, method == DRAW_M_SCALEX, &length)) {
    -            basFormRtRuntimeError(rt, "Illegal function call", "Method: %s\nScaleX and ScaleY take a length, then optionally the scale it is in (vbHimetric when left out) and the scale wanted (the object's ScaleMode when left out); the scales are 0 (vbUser) to 8 (vbHimetric).", name);
    -            return true;
    -        }
    -
    -        *result = basValDouble(length);
    +        drawScaleXY(rt, drawFor(rt, ctrl, false), (DrawMethodE)method, args, argc, result);
             return true;
         }
     
    @@ -542,11 +496,139 @@ bool basFormRtDrawMethod(BasFormRtT *rt, BasControlT *ctrl, const char *name, Ba
     }
     
     
    +// A surface with no canvas: ForeColor black, BackColor white, square
    +// shapes on paper (the aspect from the dots' size), CurrentX / CurrentY 0,
    +// the ScaleMode's units over it.
    +BasDrawT *basFormRtDrawNew(AppContextT *ctx, const BasGfxSurfaceT *surf, double twipsX, double twipsY, int32_t scaleMode) {
    +    BasDrawT *d = (BasDrawT *)calloc(1, sizeof(BasDrawT));
    +
    +    if (!d) {
    +        return NULL;
    +    }
    +
    +    d->ctx       = ctx;
    +    d->paper     = true;
    +    d->twipsX    = twipsX;
    +    d->twipsY    = twipsY;
    +    d->scaleMode = scaleMode;
    +    d->fontScale = DRAW_FONT_SCALE_NORMAL;
    +    basGfxInit(&d->gfx, surf, DRAW_DEFAULT_FORE, DRAW_DEFAULT_BACK, twipsX / twipsY);
    +    d->gfx.lastX = 0.0;
    +    d->gfx.lastY = 0.0;
    +    drawScaleApply(d);
    +    return d;
    +}
    +
    +
    +// Columns and lines count charW x charH pixel cells from the left edge,
    +// CurrentX / CurrentY kept in the scale; text moves CurrentX by the width
    +// emit drew it at.
    +// The picture's pixels as R, G, B bytes (malloc'd), *scale as
    +// drawPictureOf gives it.  NULL, a runtime error raised, when there is no
    +// picture or no memory.
    +uint8_t *basFormRtDrawPictureRgb(BasFormRtT *rt, BasFormT *form, BasValueT pic, int32_t *w, int32_t *h, const BasGfxT **scale) {
    +    uint8_t *pixels = NULL;
    +    WidgetT *src    = NULL;
    +    int32_t  pitch  = 0;
    +    int32_t  bpp    = rt->ctx->display.format.bytesPerPixel;
    +
    +    if (!drawPictureOf(rt, form, pic, &src, &pixels, w, h, &pitch, scale)) {
    +        return NULL;
    +    }
    +
    +    uint8_t *rgb = (uint8_t *)malloc((size_t)*w * (size_t)*h * DRAW_RGB_BYTES);
    +
    +    for (int32_t y = 0; rgb && y < *h; y++) {
    +        for (int32_t x = 0; x < *w; x++) {
    +            uint8_t *p     = rgb + ((size_t)y * (size_t)*w + (size_t)x) * DRAW_RGB_BYTES;
    +            uint32_t pixel = pixels ? readPixel(pixels + y * pitch + x * bpp, bpp) : wgtCanvasGetPixel(src, x, y);
    +
    +            unpackColor(&rt->ctx->display, pixel, &p[0], &p[1], &p[2]);
    +        }
    +    }
    +
    +    dvxFreeImage(pixels);
    +
    +    if (!rgb) {
    +        basFormRtRuntimeError(rt, "Out of memory", "There is not enough memory for the picture.");
    +    }
    +
    +    return rgb;
    +}
    +
    +
     double basFormRtDrawPixelsToUnits(double pixels, int32_t scale, bool across) {
         return pixels * DRAW_TWIPS_PER_PIXEL / sTwipsPerUnit[scale][across ? DRAW_ACROSS : DRAW_DOWN];
     }
     
     
    +void basFormRtDrawPrintItem(BasDrawT *d, int32_t charW, int32_t charH, BasValueT value, int32_t mode, BasDrawTextFnT emit, void *ctx) {
    +    BasGfxT *g      = &d->gfx;
    +    double   left   = g->world ? g->worldLeft : 0.0;
    +    double   px     = basGfxToPixelX(g, g->lastX);
    +    int32_t  cellW  = charW > 0 ? charW : 1;
    +    int32_t  column = (int32_t)(px / cellW);
    +
    +    switch (mode) {
    +        case BAS_OBJ_PRINT_NEWLINE:
    +            g->lastX  = left;
    +            g->lastY += basGfxToUnitsY(g, charH);
    +            return;
    +
    +        case BAS_OBJ_PRINT_ZONE:
    +            g->lastX = left + basGfxToUnitsX(g, (double)(column / BAS_PRINT_ZONE_WIDTH + 1) * BAS_PRINT_ZONE_WIDTH * cellW);
    +            return;
    +
    +        case BAS_OBJ_PRINT_TAB: {
    +            // The 1-based column made 0-based in 64 bits (TAB(INT32_MIN))
    +            int64_t target = (int64_t)basValToInt32(value) - 1;
    +
    +            if (target < column) {
    +                g->lastY += basGfxToUnitsY(g, charH);
    +            }
    +
    +            g->lastX = left + basGfxToUnitsX(g, (double)(target < 0 ? 0 : target) * cellW);
    +            return;
    +        }
    +
    +        case BAS_OBJ_PRINT_SPC:
    +            g->lastX += basGfxToUnitsX(g, (double)basValToInt32(value) * cellW);
    +            return;
    +
    +        default:
    +            break;
    +    }
    +
    +    BasStringT *s = basValPrintString(value);
    +    int32_t     w = emit(ctx, s->data, s->len, basGfxRound(px), basGfxRound(basGfxToPixelY(g, g->lastY)));
    +
    +    g->lastX += basGfxToUnitsX(g, (double)w);
    +    basStringUnref(s);
    +}
    +
    +
    +bool basFormRtDrawScaleCall(BasFormRtT *rt, BasDrawT *d, const char *name, BasValueT *args, int32_t argc, BasValueT *result) {
    +    int32_t method = drawFind(sDrawMethods, DRAW_M_COUNT, name);
    +
    +    *result = basValInteger(0);
    +
    +    if (method == DRAW_M_SCALEX || method == DRAW_M_SCALEY) {
    +        drawScaleXY(rt, d, (DrawMethodE)method, args, argc, result);
    +        return true;
    +    }
    +
    +    if (method != DRAW_M_SCALE) {
    +        return false;
    +    }
    +
    +    if (argc != BAS_WINDOW_ARGS || drawScale(d, args) != 0) {
    +        basFormRtRuntimeError(rt, "Illegal function call", "Method: %s\nScale takes two corners, (x1, y1)-(x2, y2), that are not on one line; Scale alone goes back to the ScaleMode.", name);
    +    }
    +
    +    return true;
    +}
    +
    +
     bool basFormRtDrawSetProp(BasFormRtT *rt, BasControlT *ctrl, const char *name, BasValueT value) {
         int32_t prop = drawIsSurface(ctrl) ? drawFind(sDrawProps, DRAW_P_COUNT, name) : -1;
     
    @@ -557,85 +639,44 @@ bool basFormRtDrawSetProp(BasFormRtT *rt, BasControlT *ctrl, const char *name, B
         // Setting BackColor clears the drawing to it (VB) and the scale
         // follows the drawing area's size, so they need it; the others are
         // only remembered.
    -    bool      scaleProp = prop == DRAW_P_SCALEHEIGHT || prop == DRAW_P_SCALELEFT || prop == DRAW_P_SCALEMODE || prop == DRAW_P_SCALETOP || prop == DRAW_P_SCALEWIDTH;
    -    BasDrawT *d         = drawFor(rt, ctrl, prop == DRAW_P_BACKCOLOR || scaleProp);
    +    BasDrawT *d = drawFor(rt, ctrl, prop == DRAW_P_BACKCOLOR || drawScaleProp(prop));
     
         if (!d) {
             return true;
         }
     
    -    if (scaleProp) {
    -        return drawSetScaleProp(rt, d, prop, value);
    +    return drawSetProp(rt, d, (DrawPropE)prop, value);
    +}
    +
    +
    +bool basFormRtDrawStateGet(BasDrawT *d, const char *name, BasValueT *out) {
    +    int32_t prop = drawFind(sDrawProps, DRAW_P_COUNT, name);
    +
    +    return prop >= 0 && !drawCanvasOnly(d, prop) && drawGetProp(d, (DrawPropE)prop, out);
    +}
    +
    +
    +bool basFormRtDrawStateSet(BasFormRtT *rt, BasDrawT *d, const char *name, BasValueT value) {
    +    int32_t prop = drawFind(sDrawProps, DRAW_P_COUNT, name);
    +
    +    return prop >= 0 && !drawCanvasOnly(d, prop) && drawSetProp(rt, d, (DrawPropE)prop, value);
    +}
    +
    +
    +// A rectangle in a scale -> pixels (no scale: the values are pixels).
    +void basFormRtDrawToPixels(const BasGfxT *g, double x, double y, double w, double h, int32_t *px, int32_t *py, int32_t *pw, int32_t *ph) {
    +    if (!g) {
    +        *px = basGfxRound(x);
    +        *py = basGfxRound(y);
    +        *pw = basGfxRound(w);
    +        *ph = basGfxRound(h);
    +        return;
         }
     
    -    switch ((DrawPropE)prop) {
    -        case DRAW_P_AUTOREDRAW:
    -            d->autoRedraw    = basValIsTruthy(value);
    -            d->autoRedrawSet = true;
    -            break;
    -
    -        case DRAW_P_BACKCOLOR:
    -            d->gfx.back = (uint32_t)basValToInt32(value);
    -
    -            if (d->canvas) {
    -                drawSync(d);
    -                basGfxClear(&d->gfx, value);
    -            }
    -
    -            break;
    -
    -        case DRAW_P_CURRENTX:
    -            d->gfx.lastX = basValToNumber(value);
    -            break;
    -
    -        case DRAW_P_CURRENTY:
    -            d->gfx.lastY = basValToNumber(value);
    -            break;
    -
    -        case DRAW_P_DRAWWIDTH: {
    -            int32_t w = basValToInt32(value);
    -
    -            d->gfx.drawWidth = w < 1 ? 1 : w;
    -            break;
    -        }
    -
    -        case DRAW_P_FILLCOLOR:
    -            d->gfx.fillColor = (uint32_t)basValToInt32(value);
    -            break;
    -
    -        case DRAW_P_FILLSTYLE: {
    -            int32_t style = basValToInt32(value);
    -
    -            if (style < BAS_GFX_FILL_SOLID || style > BAS_GFX_FILL_MAX) {
    -                basFormRtRuntimeError(rt, "Invalid property value", "FillStyle must be 0 (solid), 1 (transparent) or a hatch pattern 2 to 7.");
    -                break;
    -            }
    -
    -            d->gfx.fillStyle = style;
    -            break;
    -        }
    -
    -        case DRAW_P_FONTSCALE: {
    -            int32_t scale = basValToInt32(value);
    -
    -            if (scale < DRAW_FONT_SCALE_NORMAL || scale > DRAW_FONT_SCALE_MAX) {
    -                basFormRtRuntimeError(rt, "Invalid property value", "FontScale must be 1 (the normal size) to 8.");
    -                break;
    -            }
    -
    -            d->fontScale = scale;
    -            break;
    -        }
    -
    -        case DRAW_P_FORECOLOR:
    -            d->gfx.fore = (uint32_t)basValToInt32(value);
    -            break;
    -
    -        default:
    -            return false;
    -    }
    -
    -    return true;
    +    *px = basGfxRound(basGfxToPixelX(g, x));
    +    *py = basGfxRound(basGfxToPixelY(g, y));
    +    *pw = basGfxRound(basGfxToPixelX(g, x + w)) - *px;
    +    *ph = basGfxRound(basGfxToPixelY(g, y + h)) - *py;
     }
     
     
    @@ -721,6 +762,23 @@ static bool canvasSize(WidgetT *canvas, int32_t *w, int32_t *h) {
     }
     
     
    +// A canvas's Print text: in ForeColor, FontScale times as large; its
    +// width in pixels.
    +static int32_t canvasText(void *ctx, const char *text, int32_t len, int32_t x, int32_t y) {
    +    CanvasTextT *t = (CanvasTextT *)ctx;
    +
    +    wgtCanvasSetPenColor(t->d->canvas, basGfxPixel(&t->d->gfx, t->d->gfx.fore));
    +    wgtCanvasDrawTextScaled(t->d->canvas, x, y, text, t->d->fontScale);
    +    return textWidthN(t->font, text, len) * t->d->fontScale;
    +}
    +
    +
    +// The properties a surface without a canvas (the Printer) lacks.
    +static bool drawCanvasOnly(const BasDrawT *d, int32_t prop) {
    +    return d->paper && (prop == DRAW_P_AUTOREDRAW || prop == DRAW_P_BACKCOLOR || prop == DRAW_P_FONTSCALE);
    +}
    +
    +
     // Cls: the drawing cleared to BackColor, CurrentX / CurrentY back to 0.
     // 0 or a BASIC error.
     static int32_t drawCls(BasDrawT *d) {
    @@ -762,7 +820,7 @@ static int32_t drawFocusRectOn(BasDrawT *d, const BasValueT *args, int32_t argc)
         uint32_t      color = (argc == DRAW_FOCUS_COLOR_ARGS) ? (uint32_t)basValToNumber(args[DRAW_FOCUS_COLOR_ARG]) : d->gfx.fore;
         DrawFocusDotT fd;
     
    -    drawToPixels(&d->gfx, basValToNumber(args[0]), basValToNumber(args[1]), basValToNumber(args[2]), basValToNumber(args[3]), &px, &py, &pw, &ph);
    +    basFormRtDrawToPixels(&d->gfx, basValToNumber(args[0]), basValToNumber(args[1]), basValToNumber(args[2]), basValToNumber(args[3]), &px, &py, &pw, &ph);
     
         // A negative width or height counts from the other edge
         if (pw < 0) {
    @@ -825,6 +883,8 @@ static BasDrawT *drawFor(BasFormRtT *rt, BasControlT *ctrl, bool needCanvas) {
             d->gfx.lastY = 0.0;
             d->canvas    = isForm ? NULL : ctrl->widget;
             d->scaleMode = BAS_SCALE_PIXELS;
    +        d->twipsX    = DRAW_TWIPS_PER_PIXEL;
    +        d->twipsY    = DRAW_TWIPS_PER_PIXEL;
             d->fontScale = DRAW_FONT_SCALE_NORMAL;
             ctrl->draw   = d;
         }
    @@ -845,6 +905,80 @@ static BasDrawT *drawFor(BasFormRtT *rt, BasControlT *ctrl, bool needCanvas) {
     }
     
     
    +// A drawing property's value (the switch of basFormRtDrawGetProp and
    +// basFormRtDrawStateGet).  False when prop is none of them.
    +static bool drawGetProp(BasDrawT *d, DrawPropE prop, BasValueT *out) {
    +    switch (prop) {
    +        case DRAW_P_AUTOREDRAW:
    +            *out = basValBool(d->autoRedraw);
    +            break;
    +
    +        case DRAW_P_BACKCOLOR:
    +            *out = basValLong((int32_t)d->gfx.back);
    +            break;
    +
    +        case DRAW_P_CURRENTX:
    +            *out = basValDouble(d->gfx.lastX);
    +            break;
    +
    +        case DRAW_P_CURRENTY:
    +            *out = basValDouble(d->gfx.lastY);
    +            break;
    +
    +        case DRAW_P_DRAWSTYLE:
    +            *out = basValInteger((int16_t)d->gfx.drawStyle);
    +            break;
    +
    +        case DRAW_P_DRAWWIDTH:
    +            *out = basValInteger((int16_t)d->gfx.drawWidth);
    +            break;
    +
    +        case DRAW_P_FILLCOLOR:
    +            *out = basValLong((int32_t)d->gfx.fillColor);
    +            break;
    +
    +        case DRAW_P_FILLSTYLE:
    +            *out = basValInteger((int16_t)d->gfx.fillStyle);
    +            break;
    +
    +        case DRAW_P_FONTSCALE:
    +            *out = basValInteger((int16_t)d->fontScale);
    +            break;
    +
    +        case DRAW_P_FORECOLOR:
    +            *out = basValLong((int32_t)d->gfx.fore);
    +            break;
    +
    +        case DRAW_P_SCALEHEIGHT:
    +            drawSync(d);
    +            *out = basValDouble(d->gfx.world ? d->gfx.worldHeight : d->gfx.surf.height);
    +            break;
    +
    +        case DRAW_P_SCALELEFT:
    +            *out = basValDouble(d->gfx.world ? d->gfx.worldLeft : 0.0);
    +            break;
    +
    +        case DRAW_P_SCALEMODE:
    +            *out = basValInteger((int16_t)d->scaleMode);
    +            break;
    +
    +        case DRAW_P_SCALETOP:
    +            *out = basValDouble(d->gfx.world ? d->gfx.worldTop : 0.0);
    +            break;
    +
    +        case DRAW_P_SCALEWIDTH:
    +            drawSync(d);
    +            *out = basValDouble(d->gfx.world ? d->gfx.worldWidth : d->gfx.surf.width);
    +            break;
    +
    +        default:
    +            return false;
    +    }
    +
    +    return true;
    +}
    +
    +
     // Forms and PictureBoxes take the drawing members.
     static bool drawIsSurface(const BasControlT *ctrl) {
         if (ctrl->form && ctrl == &ctrl->form->formCtrl) {
    @@ -984,43 +1118,25 @@ static void drawPaintKeyed(BasFormRtT *rt, BasDrawT *d, WidgetT *src, const uint
     // the FillColor pattern by the raster operation opcode (vbSrcCopy when
     // left out; vbSrcAnd, vbSrcPaint, vbSrcInvert ... -- any of Windows' 256).
     static void drawPaintPicture(BasFormRtT *rt, BasControlT *ctrl, BasDrawT *d, BasValueT *args, int32_t argc) {
    -    uint8_t  *pixels = NULL;
    -    WidgetT  *src    = NULL;
    -    int32_t   srcW   = 0;
    -    int32_t   srcH   = 0;
    -    int32_t   pitch  = 0;
    -    int32_t   bpp    = rt->ctx->display.format.bytesPerPixel;
    +    uint8_t       *pixels   = NULL;
    +    WidgetT       *src      = NULL;
    +    const BasGfxT *srcScale = NULL;
    +    int32_t        srcW     = 0;
    +    int32_t        srcH     = 0;
    +    int32_t        pitch    = 0;
    +    int32_t        bpp      = rt->ctx->display.format.bytesPerPixel;
     
         if (argc < DRAW_PAINT_MIN_ARGS) {
             basFormRtRuntimeError(rt, "PaintPicture needs a picture, x, and y", "Usage: PaintPicture picture, x, y [, width, height [, srcX, srcY, srcWidth, srcHeight [, opcode]]]");
             return;
         }
     
    -    // The picture: a PictureBox (a Control variable, or its name), else an
    -    // image file (named, or a LOADPICTURE Picture object).
    -    const char  *path = basFormRtPicturePath(rt, args[0]);
    -    BasStringT  *name = path ? basStringNew(path, (int32_t)strlen(path)) : (args[0].type == BAS_TYPE_OBJECT ? NULL : basValFormatString(args[0]));
    -    void        *ref  = name ? basFormRtFindCtrl(rt, ctrl->form, name->data) : args[0].objVal;
    -    BasControlT *pic  = basFormRtResolveCtrl(rt, ref);
    -
    -    src = pic && drawIsSurface(pic) && (!pic->form || pic != &pic->form->formCtrl) ? pic->widget : NULL;
    -
    -    if (!src && name) {
    -        pixels = dvxLoadImage(rt->ctx, name->data, &srcW, &srcH, &pitch);
    -    }
    -
    -    if (name) {
    -        basStringUnref(name);
    -    }
    -
    -    if (!pixels && (!src || !canvasSize(src, &srcW, &srcH))) {
    -        basFormRtRuntimeError(rt, "PaintPicture has no picture", "The picture must be a PictureBox (its name, or a Control variable holding it), a BMP, PNG, JPEG, or GIF image file, a program, widget or library file (its icon), or a picture from LoadPicture.");
    +    if (!drawPictureOf(rt, ctrl->form, args[0], &src, &pixels, &srcW, &srcH, &pitch, &srcScale)) {
             return;
         }
     
         // The destination in this object's scale, the source rectangle in the
         // source PictureBox's (an image file's in pixels)
    -    const BasGfxT *srcScale = src && pic->draw ? &pic->draw->gfx : NULL;
         int32_t        sx       = 0;
         int32_t        sy       = 0;
         int32_t        sw       = srcW;
    @@ -1031,10 +1147,10 @@ static void drawPaintPicture(BasFormRtT *rt, BasControlT *ctrl, BasDrawT *d, Bas
         int32_t        dh       = 0;
     
         if (argc >= DRAW_PAINT_SRC_ARGS) {
    -        drawToPixels(srcScale, basValToNumber(args[5]), basValToNumber(args[6]), basValToNumber(args[7]), basValToNumber(args[8]), &sx, &sy, &sw, &sh);
    +        basFormRtDrawToPixels(srcScale, basValToNumber(args[5]), basValToNumber(args[6]), basValToNumber(args[7]), basValToNumber(args[8]), &sx, &sy, &sw, &sh);
         }
     
    -    drawToPixels(&d->gfx, basValToNumber(args[1]), basValToNumber(args[2]), argc >= DRAW_PAINT_SIZE_ARGS ? basValToNumber(args[3]) : 0.0, argc >= DRAW_PAINT_SIZE_ARGS ? basValToNumber(args[4]) : 0.0, &dx, &dy, &dw, &dh);
    +    basFormRtDrawToPixels(&d->gfx, basValToNumber(args[1]), basValToNumber(args[2]), argc >= DRAW_PAINT_SIZE_ARGS ? basValToNumber(args[3]) : 0.0, argc >= DRAW_PAINT_SIZE_ARGS ? basValToNumber(args[4]) : 0.0, &dx, &dy, &dw, &dh);
     
         if (argc < DRAW_PAINT_SIZE_ARGS) {
             dw = sw;
    @@ -1085,6 +1201,38 @@ static void drawPaintPicture(BasFormRtT *rt, BasControlT *ctrl, BasDrawT *d, Bas
     }
     
     
    +// PaintPicture's picture: a PictureBox (a Control variable, or its name,
    +// looked up from form first), else an image file (named, or a LOADPICTURE
    +// Picture object) loaded into *pixels in the display format (free with
    +// dvxFreeImage).  *scale is the PictureBox's scale (NULL: pixels).  False,
    +// a runtime error raised, when there is none.
    +static bool drawPictureOf(BasFormRtT *rt, BasFormT *form, BasValueT v, WidgetT **src, uint8_t **pixels, int32_t *w, int32_t *h, int32_t *pitch, const BasGfxT **scale) {
    +    const char  *path = basFormRtPicturePath(rt, v);
    +    BasStringT  *name = path ? basStringNew(path, (int32_t)strlen(path)) : (v.type == BAS_TYPE_OBJECT ? NULL : basValFormatString(v));
    +    void        *ref  = name ? basFormRtFindCtrl(rt, form, name->data) : v.objVal;
    +    BasControlT *pic  = basFormRtResolveCtrl(rt, ref);
    +
    +    *pixels = NULL;
    +    *src    = pic && drawIsSurface(pic) && (!pic->form || pic != &pic->form->formCtrl) ? pic->widget : NULL;
    +    *scale  = *src && pic->draw ? &pic->draw->gfx : NULL;
    +
    +    if (!*src && name) {
    +        *pixels = dvxLoadImage(rt->ctx, name->data, w, h, pitch);
    +    }
    +
    +    if (name) {
    +        basStringUnref(name);
    +    }
    +
    +    if (!*pixels && (!*src || !canvasSize(*src, w, h))) {
    +        basFormRtRuntimeError(rt, "PaintPicture has no picture", "The picture must be a PictureBox (its name, or a Control variable holding it), a BMP, PNG, JPEG, or GIF image file, a program, widget or library file (its icon), or a picture from LoadPicture.");
    +        return false;
    +    }
    +
    +    return true;
    +}
    +
    +
     // A length in the object's scale as whole pixels along one axis (across
     // or down), measured from 0 so the scale's origin does not shift it.  No
     // scale (g NULL): the length is pixels.
    @@ -1094,61 +1242,21 @@ static int32_t drawPixelSpan(const BasGfxT *g, double length, bool across) {
         int32_t pw = 0;
         int32_t ph = 0;
     
    -    drawToPixels(g, 0.0, 0.0, length, length, &px, &py, &pw, &ph);
    +    basFormRtDrawToPixels(g, 0.0, 0.0, length, length, &px, &py, &pw, &ph);
     
         return across ? pw : ph;
     }
     
     
    -// One object Print item at CurrentX / CurrentY in ForeColor (font
    -// FontScale times as large, transparent background).  Columns, zones and
    -// lines are counted in (scaled) characters of the font -- its charWidth
    -// across, its line height down -- from the left edge, CurrentX /
    -// CurrentY kept in the scale; text moves CurrentX by its width.
    +// One object Print item on a canvas at CurrentX / CurrentY in ForeColor
    +// (font FontScale times as large, transparent background); columns and
    +// lines in its (scaled) characters (basFormRtDrawPrintItem).
     static void drawPrint(BasDrawT *d, const BitmapFontT *font, BasValueT value, int32_t mode) {
    -    BasGfxT *g      = &d->gfx;
    -    int32_t  charW  = font->charWidth * d->fontScale;
    -    int32_t  charH  = font->charHeight * d->fontScale;
    -    double   left   = g->world ? g->worldLeft : 0.0;
    -    double   px     = basGfxToPixelX(g, g->lastX);
    -    int32_t  column = (int32_t)(px / charW);
    +    CanvasTextT t;
     
    -    switch (mode) {
    -        case BAS_OBJ_PRINT_NEWLINE:
    -            g->lastX  = left;
    -            g->lastY += basGfxToUnitsY(g, charH);
    -            return;
    -
    -        case BAS_OBJ_PRINT_ZONE:
    -            g->lastX = left + basGfxToUnitsX(g, (double)(column / BAS_PRINT_ZONE_WIDTH + 1) * BAS_PRINT_ZONE_WIDTH * charW);
    -            return;
    -
    -        case BAS_OBJ_PRINT_TAB: {
    -            // The 1-based column made 0-based in 64 bits (TAB(INT32_MIN))
    -            int64_t target = (int64_t)basValToInt32(value) - 1;
    -
    -            if (target < column) {
    -                g->lastY += basGfxToUnitsY(g, charH);
    -            }
    -
    -            g->lastX = left + basGfxToUnitsX(g, (double)(target < 0 ? 0 : target) * charW);
    -            return;
    -        }
    -
    -        case BAS_OBJ_PRINT_SPC:
    -            g->lastX += basGfxToUnitsX(g, (double)basValToInt32(value) * charW);
    -            return;
    -
    -        default:
    -            break;
    -    }
    -
    -    BasStringT *s = basValPrintString(value);
    -
    -    wgtCanvasSetPenColor(d->canvas, basGfxPixel(g, g->fore));
    -    wgtCanvasDrawTextScaled(d->canvas, basGfxRound(px), basGfxRound(basGfxToPixelY(g, g->lastY)), s->data, d->fontScale);
    -    g->lastX += basGfxToUnitsX(g, (double)textWidthN(font, s->data, s->len) * d->fontScale);
    -    basStringUnref(s);
    +    t.d    = d;
    +    t.font = font;
    +    basFormRtDrawPrintItem(d, font->charWidth * d->fontScale, font->charHeight * d->fontScale, value, mode, canvasText, &t);
     }
     
     
    @@ -1210,7 +1318,7 @@ static void drawScaleApply(BasDrawT *d) {
     
         const double *twips = sTwipsPerUnit[d->scaleMode];
     
    -    basGfxSetScale(g, 0.0, 0.0, g->surf.width * DRAW_TWIPS_PER_PIXEL / twips[DRAW_ACROSS], g->surf.height * DRAW_TWIPS_PER_PIXEL / twips[DRAW_DOWN]);
    +    basGfxSetScale(g, 0.0, 0.0, g->surf.width * d->twipsX / twips[DRAW_ACROSS], g->surf.height * d->twipsY / twips[DRAW_DOWN]);
     }
     
     
    @@ -1250,6 +1358,116 @@ static bool drawScaleLength(BasDrawT *d, const BasValueT *args, int32_t argc, bo
     }
     
     
    +// True for the scale properties (they follow the drawing area's size).
    +static bool drawScaleProp(int32_t prop) {
    +    return prop == DRAW_P_SCALEHEIGHT || prop == DRAW_P_SCALELEFT || prop == DRAW_P_SCALEMODE || prop == DRAW_P_SCALETOP || prop == DRAW_P_SCALEWIDTH;
    +}
    +
    +
    +// ScaleX / ScaleY into *result: a bad argument is a runtime error.
    +static void drawScaleXY(BasFormRtT *rt, BasDrawT *d, DrawMethodE method, const BasValueT *args, int32_t argc, BasValueT *result) {
    +    double length = 0.0;
    +
    +    if (!d || !drawScaleLength(d, args, argc, method == DRAW_M_SCALEX, &length)) {
    +        basFormRtRuntimeError(rt, "Illegal function call", "Method: %s\nScaleX and ScaleY take a length, then optionally the scale it is in (vbHimetric when left out) and the scale wanted (the object's ScaleMode when left out); the scales are 0 (vbUser) to 8 (vbHimetric).", sDrawMethods[method]);
    +        return;
    +    }
    +
    +    *result = basValDouble(length);
    +}
    +
    +
    +// Set a drawing property (basFormRtDrawSetProp and basFormRtDrawStateSet):
    +// the scale ones through drawSetScaleProp.  False when prop is none of
    +// them.
    +static bool drawSetProp(BasFormRtT *rt, BasDrawT *d, DrawPropE prop, BasValueT value) {
    +    if (drawScaleProp(prop)) {
    +        return drawSetScaleProp(rt, d, prop, value);
    +    }
    +
    +    switch (prop) {
    +        case DRAW_P_AUTOREDRAW:
    +            d->autoRedraw    = basValIsTruthy(value);
    +            d->autoRedrawSet = true;
    +            break;
    +
    +        case DRAW_P_BACKCOLOR:
    +            d->gfx.back = (uint32_t)basValToInt32(value);
    +
    +            if (d->canvas) {
    +                drawSync(d);
    +                basGfxClear(&d->gfx, value);
    +            }
    +
    +            break;
    +
    +        case DRAW_P_CURRENTX:
    +            d->gfx.lastX = basValToNumber(value);
    +            break;
    +
    +        case DRAW_P_CURRENTY:
    +            d->gfx.lastY = basValToNumber(value);
    +            break;
    +
    +        case DRAW_P_DRAWSTYLE: {
    +            int32_t style = basValToInt32(value);
    +
    +            if (style < BAS_GFX_DRAW_SOLID || style > BAS_GFX_DRAW_MAX) {
    +                basFormRtRuntimeError(rt, "Invalid property value", "DrawStyle must be 0 (solid), 1 (dash), 2 (dot), 3 (dash-dot), 4 (dash-dot-dot), 5 (invisible), or 6 (inside solid).");
    +                break;
    +            }
    +
    +            d->gfx.drawStyle = style;
    +            break;
    +        }
    +
    +        case DRAW_P_DRAWWIDTH: {
    +            int32_t w = basValToInt32(value);
    +
    +            d->gfx.drawWidth = w < 1 ? 1 : w;
    +            break;
    +        }
    +
    +        case DRAW_P_FILLCOLOR:
    +            d->gfx.fillColor = (uint32_t)basValToInt32(value);
    +            break;
    +
    +        case DRAW_P_FILLSTYLE: {
    +            int32_t style = basValToInt32(value);
    +
    +            if (style < BAS_GFX_FILL_SOLID || style > BAS_GFX_FILL_MAX) {
    +                basFormRtRuntimeError(rt, "Invalid property value", "FillStyle must be 0 (solid), 1 (transparent) or a hatch pattern 2 to 7.");
    +                break;
    +            }
    +
    +            d->gfx.fillStyle = style;
    +            break;
    +        }
    +
    +        case DRAW_P_FONTSCALE: {
    +            int32_t scale = basValToInt32(value);
    +
    +            if (scale < DRAW_FONT_SCALE_NORMAL || scale > DRAW_FONT_SCALE_MAX) {
    +                basFormRtRuntimeError(rt, "Invalid property value", "FontScale must be 1 (the normal size) to 8.");
    +                break;
    +            }
    +
    +            d->fontScale = scale;
    +            break;
    +        }
    +
    +        case DRAW_P_FORECOLOR:
    +            d->gfx.fore = (uint32_t)basValToInt32(value);
    +            break;
    +
    +        default:
    +            return false;
    +    }
    +
    +    return true;
    +}
    +
    +
     // ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight.  Setting one
     // of the last four makes the scale a user scale (ScaleMode 0) from the
     // current one; ScaleWidth and ScaleHeight may not be 0.
    @@ -1384,7 +1602,7 @@ static int32_t drawThemed(BasFormRtT *rt, BasDrawT *d, const BitmapFontT *font,
             int32_t visible = basValToInt32(args[4]);
             int32_t total   = basValToInt32(args[5]);
     
    -        drawToPixels(g, basValToNumber(args[0]), basValToNumber(args[1]), length, length, &px, &py, &pw, &ph);
    +        basFormRtDrawToPixels(g, basValToNumber(args[0]), basValToNumber(args[1]), length, length, &px, &py, &pw, &ph);
     
             // A control's bar (ListBox's, WGT_SB_W wide) or a popup list's
             if (control && across) {
    @@ -1408,7 +1626,7 @@ static int32_t drawThemed(BasFormRtT *rt, BasDrawT *d, const BitmapFontT *font,
             }
     
             // DrawScrollArrow's size is a length across; DrawArrow's is glyph rows
    -        drawToPixels(g, basValToNumber(args[0]), basValToNumber(args[1]), basValToNumber(args[2]), 0.0, &px, &py, &pw, &ph);
    +        basFormRtDrawToPixels(g, basValToNumber(args[0]), basValToNumber(args[1]), basValToNumber(args[2]), 0.0, &px, &py, &pw, &ph);
     
             if (method == DRAW_M_DRAWSCROLLARROW) {
                 drawScrollbarArrowButton(&disp, ops, cs, px, py, pw, (ScrollArrowDirE)dir);
    @@ -1433,7 +1651,7 @@ static int32_t drawThemed(BasFormRtT *rt, BasDrawT *d, const BitmapFontT *font,
         bool     noFill = argc > DRAW_RECT_FACE_ARG && basValToNumber(args[DRAW_RECT_FACE_ARG]) == DRAW_FACE_NONE;
         uint32_t face   = (argc > DRAW_RECT_FACE_ARG) ? surfToPixel(d, (uint32_t)basValToNumber(args[DRAW_RECT_FACE_ARG])) : cs->windowFace;
     
    -    drawToPixels(g, basValToNumber(args[0]), basValToNumber(args[1]), basValToNumber(args[2]), basValToNumber(args[3]), &px, &py, &pw, &ph);
    +    basFormRtDrawToPixels(g, basValToNumber(args[0]), basValToNumber(args[1]), basValToNumber(args[2]), basValToNumber(args[3]), &px, &py, &pw, &ph);
     
         if (method == DRAW_M_DRAWBUTTONFACE) {
             drawPressableBevel(&disp, ops, px, py, pw, ph, down, face, cs);
    @@ -1452,7 +1670,6 @@ static int32_t drawThemed(BasFormRtT *rt, BasDrawT *d, const BitmapFontT *font,
     }
     
     
    -// A rectangle in a scale -> pixels (no scale: the values are pixels).
     // A length in scale (BAS_SCALE_*) across or down as pixels: vbUser is the
     // object's current scale (a unit ScaleMode or a user scale).
     static double drawToPixelLength(BasDrawT *d, double length, int32_t scale, bool across) {
    @@ -1460,23 +1677,7 @@ static double drawToPixelLength(BasDrawT *d, double length, int32_t scale, bool
             return length / (across ? basGfxToUnitsX(&d->gfx, 1.0) : basGfxToUnitsY(&d->gfx, 1.0));
         }
     
    -    return length / basFormRtDrawPixelsToUnits(1.0, scale, across);
    -}
    -
    -
    -static void drawToPixels(const BasGfxT *g, double x, double y, double w, double h, int32_t *px, int32_t *py, int32_t *pw, int32_t *ph) {
    -    if (!g) {
    -        *px = basGfxRound(x);
    -        *py = basGfxRound(y);
    -        *pw = basGfxRound(w);
    -        *ph = basGfxRound(h);
    -        return;
    -    }
    -
    -    *px = basGfxRound(basGfxToPixelX(g, x));
    -    *py = basGfxRound(basGfxToPixelY(g, y));
    -    *pw = basGfxRound(basGfxToPixelX(g, x + w)) - *px;
    -    *ph = basGfxRound(basGfxToPixelY(g, y + h)) - *py;
    +    return length * sTwipsPerUnit[scale][across ? DRAW_ACROSS : DRAW_DOWN] / (across ? d->twipsX : d->twipsY);
     }
     
     
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formPrinter.c b/src/apps/kpunch/dvxbasic/formrt/formPrinter.c
    new file mode 100644
    index 00000000..0248db9b
    --- /dev/null
    +++ b/src/apps/kpunch/dvxbasic/formrt/formPrinter.c
    @@ -0,0 +1,1253 @@
    +// 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.
    +
    +// formPrinter.c -- DVX BASIC form runtime: VB's Printer object
    +//
    +// Printer draws pages for the printer chosen in the Control Panel,
    +// through the printing library (print.lib, looked up by name as the
    +// other printing calls are, so a program runs where it is not
    +// installed).  Its members, as VB has them:
    +//   drawing    Line, Circle, PSet, Print (; , TAB SPC), PaintPicture;
    +//              TextWidth, TextHeight; Scale, ScaleX, ScaleY
    +//   pages      NewPage, EndDoc, KillDoc; Page
    +//   properties CurrentX / Y, ForeColor, FillColor, FillStyle, DrawWidth,
    +//              DrawStyle,
    +//              ScaleMode / Left / Top / Width / Height, FontName,
    +//              FontSize (points), FontBold, FontItalic, FontUnderline,
    +//              FontStrikethru, Copies, Orientation (portrait), and to
    +//              read Width, Height (twips), ColorMode, TwipsPerPixelX / Y,
    +//              DeviceName, Port
    +// The page is the printable area (8 by 10 inches) in the printer's dots;
    +// ScaleMode starts at twips, as VB's Printer.
    +//
    +// A page is drawn by recording: each Line, Circle, PSet, Print, and
    +// PaintPicture is done at once on a measuring surface (which moves
    +// CurrentX / CurrentY and finds the rows it touches) and kept, with the
    +// drawing state it was done in.  NewPage and EndDoc print the page: the
    +// library asks for it a band at a time, and every kept call whose rows
    +// meet the band is done again into the band.  On a PostScript printer
    +// (a vector page) the kept calls are done again once, as the printer's
    +// own lines, shapes, text (its Helvetica, Times, or Courier, which
    +// TextWidth then measures), and pictures.  The document (the print
    +// job) opens with the first page printed, at the destination and copies
    +// of the program's last PrintChoose when it made one, and stays open to
    +// EndDoc; KillDoc throws the page away and ends it.  A program that ends
    +// with a page drawn or a document open has EndDoc done for it (VB prints
    +// it).
    +
    +#include "formrt.h"
    +#include "../../../../libs/kpunch/print/print.h"
    +#include "dvxPlat.h"
    +#include "dvxText.h"
    +#include "thirdparty/stb_ds_wrap.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define PR_TWIPS_PER_INCH     1440.0
    +#define PR_SCREEN_TWIPS       15.0        // a screen pixel (a picture's own size)
    +#define PR_POINT_TENTHS       10.0        // FontSize in points; the fonts take tenths
    +#define PR_FONT_SIZE_MAX      1000.0      // points
    +#define PR_DEFAULT_FONT       FONT_FALLBACK_FAMILY
    +#define PR_DEFAULT_TENTHS     120         // 12 points
    +#define PR_DEFAULT_TITLE      "BASIC program"
    +#define PR_DIGIT              "0"         // Print's columns: the font's digit width
    +#define PR_RGB_BYTES          3
    +#define PR_RED_SHIFT          16
    +#define PR_GREEN_SHIFT        8
    +#define PR_BYTE_MASK          0xFF
    +#define PR_WHITE              0xFFFFFF
    +#define PR_UNSET              (-1)
    +#define PR_STANDIN_MODEL      PrintModelLaserJet300E  // a text-only printer's page still has a size: a 300 dpi one's
    +
    +// The print library's calls (NULL until looked up; any missing: printing
    +// is not installed)
    +typedef struct {
    +    __typeof__(printSettingsLoad)   *settingsLoad;
    +    __typeof__(printModelPage)      *modelPage;
    +    __typeof__(printModelLabel)     *modelLabel;
    +    __typeof__(printDestLabel)      *destLabel;
    +    __typeof__(printBasChoice)      *basChoice;
    +    __typeof__(printUiDocOpen)      *docOpen;
    +    __typeof__(printUiDocClose)     *docClose;
    +    __typeof__(printDocClose)       *docAbandon;
    +    __typeof__(printDocPage)        *docPage;
    +    __typeof__(printFontOpen)       *fontOpen;
    +    __typeof__(printFontClose)      *fontClose;
    +    __typeof__(printFontWidth)      *fontWidth;
    +    __typeof__(printFontHeight)     *fontHeight;
    +    __typeof__(printFontDraw)       *fontDraw;
    +    __typeof__(printVecBegin)       *vecBegin;
    +    __typeof__(printVecEnd)         *vecEnd;
    +    __typeof__(printVecLine)        *vecLine;
    +    __typeof__(printVecRect)        *vecRect;
    +    __typeof__(printVecEllipse)     *vecEllipse;
    +    __typeof__(printVecEllipseFill) *vecEllipseFill;
    +    __typeof__(printVecDot)         *vecDot;
    +    __typeof__(printVecText)        *vecText;
    +    __typeof__(printVecImage)       *vecImage;
    +} PrApiT;
    +
    +// The kinds of drawing a page keeps
    +typedef enum {
    +    PrOpLineE,
    +    PrOpCircleE,
    +    PrOpPsetE,
    +    PrOpTextE,
    +    PrOpPictureE
    +} PrOpKindE;
    +
    +// One kept drawing call and the rows it touches
    +typedef struct {
    +    PrOpKindE kind;
    +    int32_t   top;
    +    int32_t   bottom;
    +    union {
    +        struct {
    +            int32_t state;                      // states[]: the drawing state it was done in
    +            double  lastX;                      // CurrentX / Y then
    +            double  lastY;
    +            double  args[BAS_GFX_CIRCLE_ARGS];  // its argument block (the largest)
    +            int32_t argc;
    +        } shape;
    +        struct {
    +            int32_t  font;                      // fonts[]
    +            int32_t  x;                         // the top of its line, in dots
    +            int32_t  y;
    +            uint32_t color;                     // 0xRRGGBB
    +            char    *text;                      // malloc'd
    +            int32_t  len;
    +        } text;
    +        struct {
    +            uint8_t *rgb;                       // malloc'd, w x h
    +            int32_t  w;
    +            int32_t  h;
    +            int32_t  sx;                        // the part of it drawn
    +            int32_t  sy;
    +            int32_t  sw;
    +            int32_t  sh;
    +            int32_t  dx;                        // where on the page
    +            int32_t  dy;
    +            int32_t  dw;
    +            int32_t  dh;
    +        } picture;
    +    } u;
    +} PrOpT;
    +
    +// A font text was printed in, opened at the page's resolution
    +typedef struct {
    +    char        family[FONT_FAMILY_MAX];
    +    int32_t     tenths;
    +    uint32_t    style;
    +    PrintFontT *font;
    +} PrFontT;
    +
    +// The Printer's state (one a runtime)
    +struct BasPrinterT {
    +    const PrApiT   *api;                        // the print library's calls (prGet found them all)
    +    BasDrawT       *draw;                       // the page's drawing state, on the measuring surface
    +    PrintPageInfoT  page;
    +    bool            pageKnown;                  // page holds the printer's page (else the model prints text only)
    +    bool            graphics;                   // the model prints pictures
    +    PrintDocT      *doc;                        // the document, once a page went out
    +    int32_t         docCopies;                  // each page printed this many times
    +    int32_t         pageNum;                    // Page: the page being drawn
    +    int32_t         copies;                     // Copies set by the program (0: PrintChoose's, else 1)
    +    char            fontName[FONT_FAMILY_MAX];
    +    int32_t         fontTenths;
    +    uint32_t        fontStyle;
    +    int32_t         font;                       // fonts[] of the font now; PR_UNSET: open it on use
    +    PrOpT          *ops;                        // stb_ds: the page's drawing
    +    BasGfxT        *states;                     // stb_ds: the states ops were done in
    +    PrFontT        *fonts;                      // stb_ds: the fonts the document's text is in
    +    int32_t         touchTop;                   // rows the call being done touches (measuring surface)
    +    int32_t         touchBottom;
    +    bool            stateChanged;               // the drawing state changed since states[] last kept it
    +};
    +
    +// The Printer's own methods and properties (the rest are the drawing
    +// state's: basFormRtDrawState* and basFormRtDrawScaleCall)
    +typedef enum {
    +    PrMCircleE,
    +    PrMEndDocE,
    +    PrMKillDocE,
    +    PrMLineE,
    +    PrMNewPageE,
    +    PrMPaintPictureE,
    +    PrMPrintE,
    +    PrMPsetE,
    +    PrMTextHeightE,
    +    PrMTextWidthE,
    +    PrMCountE
    +} PrMethodE;
    +
    +typedef enum {
    +    PrPColorModeE,
    +    PrPCopiesE,
    +    PrPDeviceNameE,
    +    PrPFontBoldE,
    +    PrPFontItalicE,
    +    PrPFontNameE,
    +    PrPFontSizeE,
    +    PrPFontStrikethruE,
    +    PrPFontUnderlineE,
    +    PrPHeightE,
    +    PrPOrientationE,
    +    PrPPageE,
    +    PrPPortE,
    +    PrPTwipsPerPixelXE,
    +    PrPTwipsPerPixelYE,
    +    PrPWidthE,
    +    PrPCountE
    +} PrPropE;
    +
    +// printFontDraw / a shape's spans into one band
    +typedef struct {
    +    uint8_t *rgb;
    +    int32_t  width;
    +    int32_t  y0;
    +    int32_t  rows;
    +} PrBandT;
    +
    +static const char *const sMethods[PrMCountE] = {
    +    [PrMCircleE]       = "Circle",
    +    [PrMEndDocE]       = "EndDoc",
    +    [PrMKillDocE]      = "KillDoc",
    +    [PrMLineE]         = "Line",
    +    [PrMNewPageE]      = "NewPage",
    +    [PrMPaintPictureE] = "PaintPicture",
    +    [PrMPrintE]        = "Print",
    +    [PrMPsetE]         = "PSet",
    +    [PrMTextHeightE]   = "TextHeight",
    +    [PrMTextWidthE]    = "TextWidth"
    +};
    +
    +static const char *const sProps[PrPCountE] = {
    +    [PrPColorModeE]      = "ColorMode",
    +    [PrPCopiesE]         = "Copies",
    +    [PrPDeviceNameE]     = "DeviceName",
    +    [PrPFontBoldE]       = "FontBold",
    +    [PrPFontItalicE]     = "FontItalic",
    +    [PrPFontNameE]       = "FontName",
    +    [PrPFontSizeE]       = "FontSize",
    +    [PrPFontStrikethruE] = "FontStrikethru",
    +    [PrPFontUnderlineE]  = "FontUnderline",
    +    [PrPHeightE]         = "Height",
    +    [PrPOrientationE]    = "Orientation",
    +    [PrPPageE]           = "Page",
    +    [PrPPortE]           = "Port",
    +    [PrPTwipsPerPixelXE] = "TwipsPerPixelX",
    +    [PrPTwipsPerPixelYE] = "TwipsPerPixelY",
    +    [PrPWidthE]          = "Width"
    +};
    +
    +// The font style bit of each Font property, indexed by PrPropE (0: none)
    +static const uint32_t sFontBits[PrPCountE] = {
    +    [PrPFontBoldE]       = FONT_STYLE_BOLD,
    +    [PrPFontItalicE]     = FONT_STYLE_ITALIC,
    +    [PrPFontStrikethruE] = FONT_STYLE_STRIKE,
    +    [PrPFontUnderlineE]  = FONT_STYLE_UNDERLINE
    +};
    +
    +// The printer's fill for each FillStyle that fills (transparent never
    +// does)
    +static const PrintFillE sFills[BAS_GFX_FILL_MAX + 1] = {
    +    [BAS_GFX_FILL_SOLID]          = PrintFillSolidE,
    +    [BAS_GFX_FILL_TRANSPARENT]    = PrintFillSolidE,
    +    [BAS_GFX_FILL_HORIZONTAL]     = PrintFillHorizontalE,
    +    [BAS_GFX_FILL_VERTICAL]       = PrintFillVerticalE,
    +    [BAS_GFX_FILL_UP_DIAGONAL]    = PrintFillUpDiagonalE,
    +    [BAS_GFX_FILL_DOWN_DIAGONAL]  = PrintFillDownDiagonalE,
    +    [BAS_GFX_FILL_CROSS]          = PrintFillCrossE,
    +    [BAS_GFX_FILL_DIAGONAL_CROSS] = PrintFillDiagonalCrossE
    +};
    +
    +// The drawing properties a new page keeps from the old one when the
    +// printer (so the page's size) changes between documents
    +static const char *const sKeptProps[] = { "ForeColor", "FillColor", "FillStyle", "DrawWidth", "DrawStyle", "ScaleMode" };
    +
    +static const uint8_t sPrinterObj = 0;   // the object BASIC holds: its address
    +static PrApiT        sApi;
    +static bool          sApiTried;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +void               basFormRtPrinterEnd(BasFormRtT *rt);
    +BasValueT          basFormRtPrinterGetProp(BasFormRtT *rt, const char *name);
    +bool               basFormRtPrinterIs(const void *ref);
    +BasValueT          basFormRtPrinterMethod(BasFormRtT *rt, const char *name, BasValueT *args, int32_t argc);
    +void              *basFormRtPrinterRef(void);
    +void               basFormRtPrinterSetProp(BasFormRtT *rt, const char *name, BasValueT value);
    +static const PrApiT *prApi(void);
    +static void        prBandSpan(void *ctx, int32_t x0, int32_t x1, int32_t y, uint32_t pixel);
    +static void        prEndDoc(BasFormRtT *rt, BasPrinterT *p);
    +static int32_t     prFind(const char *const *names, int32_t count, const char *name);
    +static int32_t     prFont(BasPrinterT *p);
    +static void        prFontsClose(BasPrinterT *p);
    +static BasPrinterT *prGet(BasFormRtT *rt);
    +static void        prHome(BasPrinterT *p);
    +static void        prKillDoc(BasPrinterT *p);
    +static uint32_t    prMeasureGet(const void *ctx, int32_t x, int32_t y);
    +static void        prMeasureSpan(void *ctx, int32_t x0, int32_t x1, int32_t y, uint32_t pixel);
    +static void        prNewPage(BasFormRtT *rt, BasPrinterT *p);
    +static bool        prPage(BasFormRtT *rt, BasPrinterT *p, bool drawing);
    +static bool        prPageBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +static void        prPageClear(BasPrinterT *p);
    +static bool        prPageSame(const PrintPageInfoT *a, const PrintPageInfoT *b);
    +static void        prPicture(BasFormRtT *rt, BasPrinterT *p, BasValueT *args, int32_t argc);
    +static void        prPictureBand(const PrOpT *op, const PrBandT *b);
    +static bool        prPrintPage(BasFormRtT *rt, BasPrinterT *p);
    +static void        prShape(BasFormRtT *rt, BasPrinterT *p, PrOpKindE kind, BasValueT *args, int32_t argc);
    +static void        prShapeAgain(const BasPrinterT *p, const PrOpT *op, const BasGfxSurfaceT *surf);
    +static int32_t     prShapeDo(BasGfxT *g, PrOpKindE kind, const BasValueT *args);
    +static int32_t     prText(void *ctx, const char *text, int32_t len, int32_t x, int32_t y);
    +static double      prTwipsX(const BasPrinterT *p);
    +static double      prTwipsY(const BasPrinterT *p);
    +static void        prVecDot(void *ctx, int32_t x, int32_t y, uint32_t pixel, int32_t width);
    +static void        prVecEllipse(void *ctx, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, int32_t width, uint16_t style, bool arc, double start, double end);
    +static void        prVecEllipseFill(void *ctx, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, int32_t fillStyle);
    +static void        prVecLine(void *ctx, double x0, double y0, double x1, double y1, uint32_t pixel, int32_t width, uint16_t style);
    +static bool        prVecPage(BasPrinterT *p);
    +static void        prVecRect(void *ctx, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t pixel, int32_t fillStyle);
    +
    +// A vector page's shapes go to the printer whole
    +static const BasGfxVectorT sPrVec = { prVecLine, prVecRect, prVecEllipse, prVecEllipseFill, prVecDot };
    +
    +
    +void basFormRtPrinterEnd(BasFormRtT *rt) {
    +    BasPrinterT *p = rt->printer;
    +
    +    if (!p) {
    +        return;
    +    }
    +
    +    prEndDoc(rt, p);
    +    basFormRtDrawDelete(p->draw);
    +    free(p);
    +    rt->printer = NULL;
    +}
    +
    +
    +BasValueT basFormRtPrinterGetProp(BasFormRtT *rt, const char *name) {
    +    BasValueT    v = basValInteger(0);
    +    BasPrinterT *p = prGet(rt);
    +    int32_t      prop;
    +
    +    if (!p || !prPage(rt, p, false)) {
    +        return v;
    +    }
    +
    +    prop = prFind(sProps, PrPCountE, name);
    +
    +    switch ((PrPropE)prop) {
    +        case PrPColorModeE:
    +            return basValInteger(p->page.color ? BAS_PRINTER_COLOR : BAS_PRINTER_MONO);
    +
    +        case PrPCopiesE:
    +            return basValInteger((int16_t)(p->copies > 0 ? p->copies : 1));
    +
    +        case PrPDeviceNameE:
    +        case PrPPortE: {
    +            PrintSettingsT st;
    +
    +            p->api->settingsLoad(&st);
    +            return basValStringFromC(prop == PrPDeviceNameE ? p->api->modelLabel(st.model) : p->api->destLabel(st.dest));
    +        }
    +
    +        case PrPFontBoldE:
    +        case PrPFontItalicE:
    +        case PrPFontStrikethruE:
    +        case PrPFontUnderlineE:
    +            return basValBool((p->fontStyle & sFontBits[prop]) != 0);
    +
    +        case PrPFontNameE:
    +            return basValStringFromC(p->fontName);
    +
    +        case PrPFontSizeE:
    +            return basValSingle((float)(p->fontTenths / PR_POINT_TENTHS));
    +
    +        case PrPHeightE:
    +            return basValLong(basGfxRound(p->page.heightDots * prTwipsY(p)));
    +
    +        case PrPOrientationE:
    +            return basValInteger(BAS_PRINTER_PORTRAIT);
    +
    +        case PrPPageE:
    +            return basValLong(p->pageNum);
    +
    +        case PrPTwipsPerPixelXE:
    +            return basValSingle((float)prTwipsX(p));
    +
    +        case PrPTwipsPerPixelYE:
    +            return basValSingle((float)prTwipsY(p));
    +
    +        case PrPWidthE:
    +            return basValLong(basGfxRound(p->page.widthDots * prTwipsX(p)));
    +
    +        default:
    +            break;
    +    }
    +
    +    if (!basFormRtDrawStateGet(p->draw, name, &v)) {
    +        basFormRtRuntimeError(rt, "Printer has no such property", "Property: %s", name);
    +    }
    +
    +    return v;
    +}
    +
    +
    +bool basFormRtPrinterIs(const void *ref) {
    +    return ref == &sPrinterObj;
    +}
    +
    +
    +BasValueT basFormRtPrinterMethod(BasFormRtT *rt, const char *name, BasValueT *args, int32_t argc) {
    +    BasValueT    result = basValInteger(0);
    +    BasPrinterT *p      = prGet(rt);
    +    int32_t      f;
    +
    +    if (!p) {
    +        return result;
    +    }
    +
    +    switch ((PrMethodE)prFind(sMethods, PrMCountE, name)) {
    +        case PrMCircleE:
    +            prShape(rt, p, PrOpCircleE, args, argc);
    +            break;
    +
    +        case PrMEndDocE:
    +            prEndDoc(rt, p);
    +            break;
    +
    +        case PrMKillDocE:
    +            prKillDoc(p);
    +            break;
    +
    +        case PrMLineE:
    +            prShape(rt, p, PrOpLineE, args, argc);
    +            break;
    +
    +        case PrMNewPageE:
    +            prNewPage(rt, p);
    +            break;
    +
    +        case PrMPaintPictureE:
    +            prPicture(rt, p, args, argc);
    +            break;
    +
    +        case PrMPrintE:
    +            if (argc != BAS_OBJ_PRINT_ARGS) {
    +                basFormRtRuntimeError(rt, "Illegal function call", "Method: Print");
    +                break;
    +            }
    +
    +            f = prPage(rt, p, true) ? prFont(p) : PR_UNSET;
    +
    +            if (f != PR_UNSET) {
    +                PrintFontT *font = p->fonts[f].font;
    +
    +                basFormRtDrawPrintItem(p->draw, p->api->fontWidth(font, PR_DIGIT, 1), p->api->fontHeight(font), args[0], basValToInt32(args[1]), prText, p);
    +            }
    +
    +            break;
    +
    +        case PrMPsetE:
    +            prShape(rt, p, PrOpPsetE, args, argc);
    +            break;
    +
    +        case PrMTextHeightE:
    +        case PrMTextWidthE:
    +            if (argc != 1) {
    +                basFormRtRuntimeError(rt, "Illegal function call", "Method: %s\nTextWidth and TextHeight take the text to measure.", name);
    +                break;
    +            }
    +
    +            f = prPage(rt, p, false) ? prFont(p) : PR_UNSET;
    +
    +            if (f != PR_UNSET) {
    +                BasStringT *str  = basValFormatString(args[0]);
    +                PrintFontT *font = p->fonts[f].font;
    +                BasGfxT    *g    = basFormRtDrawGfx(p->draw);
    +                bool        wide = strcasecmp(name, sMethods[PrMTextWidthE]) == 0;
    +
    +                result = basValDouble(fabs(wide ? basGfxToUnitsX(g, p->api->fontWidth(font, str->data, str->len)) : basGfxToUnitsY(g, p->api->fontHeight(font))));
    +                basStringUnref(str);
    +            }
    +
    +            break;
    +
    +        default:
    +            if (!prPage(rt, p, false)) {
    +                break;
    +            }
    +
    +            p->stateChanged = true;
    +
    +            if (!basFormRtDrawScaleCall(rt, p->draw, name, args, argc, &result)) {
    +                basFormRtRuntimeError(rt, "Printer has no such method", "Method: %s", name);
    +            }
    +
    +            break;
    +    }
    +
    +    return result;
    +}
    +
    +
    +void *basFormRtPrinterRef(void) {
    +    return (void *)&sPrinterObj;
    +}
    +
    +
    +void basFormRtPrinterSetProp(BasFormRtT *rt, const char *name, BasValueT value) {
    +    BasPrinterT *p    = prGet(rt);
    +    int32_t      prop = prFind(sProps, PrPCountE, name);
    +
    +    if (!p || !prPage(rt, p, false)) {
    +        return;
    +    }
    +
    +    switch ((PrPropE)prop) {
    +        case PrPCopiesE: {
    +            int32_t n = basValToInt32(value);
    +
    +            if (n < 1 || n > PRINT_COPIES_MAX) {
    +                basFormRtRuntimeError(rt, "Invalid property value", "Copies must be 1 to %d.", PRINT_COPIES_MAX);
    +                return;
    +            }
    +
    +            p->copies = n;
    +            return;
    +        }
    +
    +        case PrPFontBoldE:
    +        case PrPFontItalicE:
    +        case PrPFontStrikethruE:
    +        case PrPFontUnderlineE:
    +            p->fontStyle = basValIsTruthy(value) ? p->fontStyle | sFontBits[prop] : p->fontStyle & ~sFontBits[prop];
    +            p->font      = PR_UNSET;
    +            return;
    +
    +        case PrPFontNameE: {
    +            BasStringT *str = basValFormatString(value);
    +
    +            snprintf(p->fontName, sizeof(p->fontName), "%s", str->data);
    +            basStringUnref(str);
    +            p->font = PR_UNSET;
    +            return;
    +        }
    +
    +        case PrPFontSizeE: {
    +            double points = basValToNumber(value);
    +
    +            if (!(points > 0.0 && points <= PR_FONT_SIZE_MAX)) {
    +                basFormRtRuntimeError(rt, "Invalid property value", "FontSize is in points, above 0 and at most %d.", (int)PR_FONT_SIZE_MAX);
    +                return;
    +            }
    +
    +            p->fontTenths = basGfxRound(points * PR_POINT_TENTHS);
    +            p->fontTenths = p->fontTenths < 1 ? 1 : p->fontTenths;
    +            p->font       = PR_UNSET;
    +            return;
    +        }
    +
    +        case PrPOrientationE:
    +            if (basValToInt32(value) != BAS_PRINTER_PORTRAIT) {
    +                basFormRtRuntimeError(rt, "Invalid property value", "The Printer prints portrait pages only (vbPRORPortrait).");
    +            }
    +
    +            return;
    +
    +        case PrPColorModeE:
    +        case PrPDeviceNameE:
    +        case PrPHeightE:
    +        case PrPPageE:
    +        case PrPPortE:
    +        case PrPTwipsPerPixelXE:
    +        case PrPTwipsPerPixelYE:
    +        case PrPWidthE:
    +            basFormRtRuntimeError(rt, "Property is read-only", "Property: Printer.%s", name);
    +            return;
    +
    +        default:
    +            break;
    +    }
    +
    +    p->stateChanged = true;
    +
    +    if (!basFormRtDrawStateSet(rt, p->draw, name, value)) {
    +        basFormRtRuntimeError(rt, "Printer has no such property", "Property: %s", name);
    +    }
    +}
    +
    +
    +// The print library's calls, looked up once; NULL when any is missing.
    +static const PrApiT *prApi(void) {
    +    if (!sApiTried) {
    +        sApiTried           = true;
    +        sApi.settingsLoad   = (__typeof__(sApi.settingsLoad))dlsym(NULL, DVX_SYM("printSettingsLoad"));
    +        sApi.modelPage      = (__typeof__(sApi.modelPage))dlsym(NULL, DVX_SYM("printModelPage"));
    +        sApi.modelLabel     = (__typeof__(sApi.modelLabel))dlsym(NULL, DVX_SYM("printModelLabel"));
    +        sApi.destLabel      = (__typeof__(sApi.destLabel))dlsym(NULL, DVX_SYM("printDestLabel"));
    +        sApi.basChoice      = (__typeof__(sApi.basChoice))dlsym(NULL, DVX_SYM("printBasChoice"));
    +        sApi.docOpen        = (__typeof__(sApi.docOpen))dlsym(NULL, DVX_SYM("printUiDocOpen"));
    +        sApi.docClose       = (__typeof__(sApi.docClose))dlsym(NULL, DVX_SYM("printUiDocClose"));
    +        sApi.docAbandon     = (__typeof__(sApi.docAbandon))dlsym(NULL, DVX_SYM("printDocClose"));
    +        sApi.docPage        = (__typeof__(sApi.docPage))dlsym(NULL, DVX_SYM("printDocPage"));
    +        sApi.fontOpen       = (__typeof__(sApi.fontOpen))dlsym(NULL, DVX_SYM("printFontOpen"));
    +        sApi.fontClose      = (__typeof__(sApi.fontClose))dlsym(NULL, DVX_SYM("printFontClose"));
    +        sApi.fontWidth      = (__typeof__(sApi.fontWidth))dlsym(NULL, DVX_SYM("printFontWidth"));
    +        sApi.fontHeight     = (__typeof__(sApi.fontHeight))dlsym(NULL, DVX_SYM("printFontHeight"));
    +        sApi.fontDraw       = (__typeof__(sApi.fontDraw))dlsym(NULL, DVX_SYM("printFontDraw"));
    +        sApi.vecBegin       = (__typeof__(sApi.vecBegin))dlsym(NULL, DVX_SYM("printVecBegin"));
    +        sApi.vecEnd         = (__typeof__(sApi.vecEnd))dlsym(NULL, DVX_SYM("printVecEnd"));
    +        sApi.vecLine        = (__typeof__(sApi.vecLine))dlsym(NULL, DVX_SYM("printVecLine"));
    +        sApi.vecRect        = (__typeof__(sApi.vecRect))dlsym(NULL, DVX_SYM("printVecRect"));
    +        sApi.vecEllipse     = (__typeof__(sApi.vecEllipse))dlsym(NULL, DVX_SYM("printVecEllipse"));
    +        sApi.vecEllipseFill = (__typeof__(sApi.vecEllipseFill))dlsym(NULL, DVX_SYM("printVecEllipseFill"));
    +        sApi.vecDot         = (__typeof__(sApi.vecDot))dlsym(NULL, DVX_SYM("printVecDot"));
    +        sApi.vecText        = (__typeof__(sApi.vecText))dlsym(NULL, DVX_SYM("printVecText"));
    +        sApi.vecImage       = (__typeof__(sApi.vecImage))dlsym(NULL, DVX_SYM("printVecImage"));
    +    }
    +
    +    bool all = sApi.settingsLoad && sApi.modelPage && sApi.modelLabel && sApi.destLabel && sApi.basChoice && sApi.docOpen && sApi.docClose && sApi.docAbandon && sApi.docPage && sApi.fontOpen && sApi.fontClose && sApi.fontWidth && sApi.fontHeight && sApi.fontDraw;
    +
    +    all = all && sApi.vecBegin && sApi.vecEnd && sApi.vecLine && sApi.vecRect && sApi.vecEllipse && sApi.vecEllipseFill && sApi.vecDot && sApi.vecText && sApi.vecImage;
    +
    +    return all ? &sApi : NULL;
    +}
    +
    +
    +// A span of a kept shape: the part of it in the band, in its color.
    +static void prBandSpan(void *ctx, int32_t x0, int32_t x1, int32_t y, uint32_t pixel) {
    +    const PrBandT *b   = (const PrBandT *)ctx;
    +    uint32_t       rgb = wgtBasicColor(pixel);
    +    uint8_t        c[PR_RGB_BYTES];
    +
    +    if (y < b->y0 || y >= b->y0 + b->rows) {
    +        return;
    +    }
    +
    +    c[0] = (uint8_t)((rgb >> PR_RED_SHIFT) & PR_BYTE_MASK);
    +    c[1] = (uint8_t)((rgb >> PR_GREEN_SHIFT) & PR_BYTE_MASK);
    +    c[2] = (uint8_t)(rgb & PR_BYTE_MASK);
    +    x0   = x0 < 0 ? 0 : x0;
    +    x1   = x1 >= b->width ? b->width - 1 : x1;
    +
    +    for (uint8_t *dst = b->rgb + ((int64_t)(y - b->y0) * b->width + x0) * PR_RGB_BYTES; x0 <= x1; x0++, dst += PR_RGB_BYTES) {
    +        memcpy(dst, c, PR_RGB_BYTES);
    +    }
    +}
    +
    +
    +// EndDoc: the page drawn printed, the document ended (a failure told
    +// to the user), Page back to 1.
    +static void prEndDoc(BasFormRtT *rt, BasPrinterT *p) {
    +    if (arrlen(p->ops) > 0) {
    +        prPrintPage(rt, p);
    +    }
    +
    +    if (p->doc) {
    +        p->api->docClose(rt->ctx, p->doc);
    +        p->doc = NULL;
    +    }
    +
    +    prPageClear(p);
    +    prFontsClose(p);
    +    p->pageNum = 1;
    +    prHome(p);
    +}
    +
    +
    +// The index of name (case ignored) in names, -1 when it is not there.
    +static int32_t prFind(const char *const *names, int32_t count, const char *name) {
    +    for (int32_t i = 0; i < count; i++) {
    +        if (strcasecmp(names[i], name) == 0) {
    +            return i;
    +        }
    +    }
    +
    +    return -1;
    +}
    +
    +
    +// The font text is in now (FontName, FontSize, the style): fonts[] index,
    +// opened at the page's resolution on first use; PR_UNSET when it cannot
    +// be opened.
    +static int32_t prFont(BasPrinterT *p) {
    +    if (p->font != PR_UNSET) {
    +        return p->font;
    +    }
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(p->fonts); i++) {
    +        if (p->fonts[i].tenths == p->fontTenths && p->fonts[i].style == p->fontStyle && strcasecmp(p->fonts[i].family, p->fontName) == 0) {
    +            p->font = i;
    +            return i;
    +        }
    +    }
    +
    +    PrFontT f;
    +
    +    memset(&f, 0, sizeof(f));
    +    snprintf(f.family, sizeof(f.family), "%s", p->fontName);
    +    f.tenths = p->fontTenths;
    +    f.style  = p->fontStyle;
    +    f.font   = p->api->fontOpen(&p->page, f.family, f.tenths, f.style);
    +
    +    if (!f.font) {
    +        return PR_UNSET;
    +    }
    +
    +    arrput(p->fonts, f);
    +    p->font = (int32_t)arrlen(p->fonts) - 1;
    +    return p->font;
    +}
    +
    +
    +// The document's fonts given up (no kept text uses them).
    +static void prFontsClose(BasPrinterT *p) {
    +    for (int32_t i = 0; i < (int32_t)arrlen(p->fonts); i++) {
    +        p->api->fontClose(p->fonts[i].font);
    +    }
    +
    +    arrfree(p->fonts);
    +    p->font = PR_UNSET;
    +}
    +
    +
    +// The runtime's Printer, made on first use; NULL (a runtime error raised)
    +// when printing is not installed.
    +static BasPrinterT *prGet(BasFormRtT *rt) {
    +    const PrApiT *api = prApi();
    +
    +    if (!api) {
    +        basFormRtRuntimeError(rt, "Printing is not installed", "The Printer object needs the printing library (print.lib).");
    +        return NULL;
    +    }
    +
    +    if (!rt->printer) {
    +        BasPrinterT *p = (BasPrinterT *)calloc(1, sizeof(BasPrinterT));
    +
    +        if (!p) {
    +            basFormRtRuntimeError(rt, "Out of memory", "There is not enough memory for the Printer.");
    +            return NULL;
    +        }
    +
    +        p->api        = api;
    +        p->pageNum    = 1;
    +        p->fontTenths = PR_DEFAULT_TENTHS;
    +        p->font       = PR_UNSET;
    +        snprintf(p->fontName, sizeof(p->fontName), "%s", PR_DEFAULT_FONT);
    +        rt->printer = p;
    +    }
    +
    +    return rt->printer;
    +}
    +
    +
    +// CurrentX / CurrentY to the page's top-left corner.
    +static void prHome(BasPrinterT *p) {
    +    if (!p->draw) {
    +        return;
    +    }
    +
    +    BasGfxT *g = basFormRtDrawGfx(p->draw);
    +
    +    g->lastX = g->world ? g->worldLeft : 0.0;
    +    g->lastY = g->world ? g->worldTop : 0.0;
    +}
    +
    +
    +// KillDoc: the page thrown away, a document ended as it is (pages
    +// already printed stay printed), Page back to 1.
    +static void prKillDoc(BasPrinterT *p) {
    +    prPageClear(p);
    +
    +    if (p->doc) {
    +        p->api->docAbandon(p->doc);
    +        p->doc = NULL;
    +    }
    +
    +    prFontsClose(p);
    +    p->pageNum = 1;
    +    prHome(p);
    +}
    +
    +
    +// The measuring surface is paper: white.
    +static uint32_t prMeasureGet(const void *ctx, int32_t x, int32_t y) {
    +    (void)ctx;
    +    (void)x;
    +    (void)y;
    +    return PR_WHITE;
    +}
    +
    +
    +// The measuring surface draws nothing: it finds the rows a call touches.
    +static void prMeasureSpan(void *ctx, int32_t x0, int32_t x1, int32_t y, uint32_t pixel) {
    +    BasPrinterT *p = (BasPrinterT *)ctx;
    +
    +    (void)x0;
    +    (void)x1;
    +    (void)pixel;
    +    p->touchTop    = y < p->touchTop ? y : p->touchTop;
    +    p->touchBottom = y > p->touchBottom ? y : p->touchBottom;
    +}
    +
    +
    +// NewPage: the page printed (a blank one too), the next one started.
    +static void prNewPage(BasFormRtT *rt, BasPrinterT *p) {
    +    if (!prPage(rt, p, true)) {
    +        return;
    +    }
    +
    +    prPrintPage(rt, p);
    +    p->pageNum++;
    +    prHome(p);
    +}
    +
    +
    +// The page to draw on: between documents the printer's page is read
    +// again (the user may have chosen another printer), the drawing state
    +// kept (all of it while the page is the same); drawing needs a model that
    +// prints pictures.  False, a runtime
    +// error raised, when it cannot be had.
    +static bool prPage(BasFormRtT *rt, BasPrinterT *p, bool drawing) {
    +    const PrApiT *api = p->api;
    +
    +    if (!p->draw || (!p->doc && arrlen(p->ops) == 0)) {
    +        PrintSettingsT st;
    +        PrintPageInfoT page;
    +
    +        api->settingsLoad(&st);
    +        p->graphics = api->modelPage(st.model, &page);
    +
    +        if (!p->graphics) {
    +            api->modelPage(PR_STANDIN_MODEL, &page);
    +        }
    +
    +        if (!p->draw || !prPageSame(&page, &p->page)) {
    +            BasGfxSurfaceT surf;
    +
    +            memset(&surf, 0, sizeof(surf));
    +            surf.ctx    = p;
    +            surf.width  = page.widthDots;
    +            surf.height = page.heightDots;
    +            surf.span   = prMeasureSpan;
    +            surf.get    = prMeasureGet;
    +
    +            BasDrawT *d = basFormRtDrawNew(rt->ctx, &surf, PR_TWIPS_PER_INCH / page.dpiX, PR_TWIPS_PER_INCH / page.dpiY, BAS_SCALE_TWIPS);
    +
    +            if (!d) {
    +                basFormRtRuntimeError(rt, "Out of memory", "There is not enough memory for the Printer's page.");
    +                return false;
    +            }
    +
    +            for (int32_t i = 0; p->draw && i < DVX_ARRAY_LEN(sKeptProps); i++) {
    +                BasValueT v;
    +
    +                if (basFormRtDrawStateGet(p->draw, sKeptProps[i], &v)) {
    +                    basFormRtDrawStateSet(rt, d, sKeptProps[i], v);
    +                }
    +            }
    +
    +            basFormRtDrawDelete(p->draw);
    +            prFontsClose(p);
    +            p->draw         = d;
    +            p->page         = page;
    +            p->stateChanged = true;
    +        }
    +    }
    +
    +    if (drawing && !p->graphics) {
    +        basFormRtRuntimeError(rt, "The printer prints text only", "The printer model chosen in the Control Panel prints text only. To print with the Printer object, choose your printer's model on the Control Panel's Printer page.");
    +        return false;
    +    }
    +
    +    return true;
    +}
    +
    +
    +// One band of the page: every kept call whose rows meet it, done again
    +// into the band in the order it was made.
    +static bool prPageBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    BasPrinterT   *p    = (BasPrinterT *)ctx;
    +    PrBandT        b    = { rgb, width, y0, rows };
    +    BasGfxSurfaceT surf = basFormRtDrawGfx(p->draw)->surf;
    +    int32_t        last = y0 + rows - 1;
    +
    +    surf.ctx  = &b;
    +    surf.span = prBandSpan;
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(p->ops); i++) {
    +        const PrOpT *op = &p->ops[i];
    +
    +        if (op->bottom < y0 || op->top > last) {
    +            continue;
    +        }
    +
    +        if (op->kind == PrOpTextE) {
    +            p->api->fontDraw(p->fonts[op->u.text.font].font, op->u.text.x, op->u.text.y, op->u.text.text, op->u.text.len, op->u.text.color, rgb, width, y0, rows);
    +        } else if (op->kind == PrOpPictureE) {
    +            prPictureBand(op, &b);
    +        } else {
    +            prShapeAgain(p, op, &surf);
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +// The page's kept drawing thrown away.
    +static void prPageClear(BasPrinterT *p) {
    +    for (int32_t i = 0; i < (int32_t)arrlen(p->ops); i++) {
    +        if (p->ops[i].kind == PrOpTextE) {
    +            free(p->ops[i].u.text.text);
    +        } else if (p->ops[i].kind == PrOpPictureE) {
    +            free(p->ops[i].u.picture.rgb);
    +        }
    +    }
    +
    +    arrfree(p->ops);
    +    arrfree(p->states);
    +}
    +
    +
    +// Whether two pages are the same size at the same resolution, in color or
    +// not, drawn in dots or by the printer.
    +static bool prPageSame(const PrintPageInfoT *a, const PrintPageInfoT *b) {
    +    return a->dpiX == b->dpiX && a->dpiY == b->dpiY && a->widthDots == b->widthDots && a->heightDots == b->heightDots && a->color == b->color && a->vector == b->vector;
    +}
    +
    +
    +// PaintPicture picture, x, y [, width, height [, srcX, srcY, srcWidth,
    +// srcHeight]]: the picture (or the part of it) stretched to the
    +// rectangle, nearest pixel; without a width and height it prints at its
    +// size on the screen (15 twips a pixel).  It is copied as it is (a
    +// raster operation after the source rectangle is not used on paper).
    +static void prPicture(BasFormRtT *rt, BasPrinterT *p, BasValueT *args, int32_t argc) {
    +    const BasGfxT *srcScale = NULL;
    +    int32_t        w        = 0;
    +    int32_t        h        = 0;
    +
    +    if (argc < DRAW_PAINT_MIN_ARGS) {
    +        basFormRtRuntimeError(rt, "PaintPicture needs a picture, x, and y", "Usage: Printer.PaintPicture picture, x, y [, width, height [, srcX, srcY, srcWidth, srcHeight]]");
    +        return;
    +    }
    +
    +    if (!prPage(rt, p, true)) {
    +        return;
    +    }
    +
    +    uint8_t *rgb = basFormRtDrawPictureRgb(rt, NULL, args[0], &w, &h, &srcScale);
    +
    +    if (!rgb) {
    +        return;
    +    }
    +
    +    BasGfxT *g = basFormRtDrawGfx(p->draw);
    +    PrOpT    op;
    +
    +    memset(&op, 0, sizeof(op));
    +    op.kind              = PrOpPictureE;
    +    op.u.picture.rgb     = rgb;
    +    op.u.picture.w       = w;
    +    op.u.picture.h       = h;
    +    op.u.picture.sw      = w;
    +    op.u.picture.sh      = h;
    +
    +    if (argc >= DRAW_PAINT_SRC_ARGS) {
    +        basFormRtDrawToPixels(srcScale, basValToNumber(args[5]), basValToNumber(args[6]), basValToNumber(args[7]), basValToNumber(args[8]), &op.u.picture.sx, &op.u.picture.sy, &op.u.picture.sw, &op.u.picture.sh);
    +    }
    +
    +    basFormRtDrawToPixels(g, basValToNumber(args[1]), basValToNumber(args[2]), argc >= DRAW_PAINT_SIZE_ARGS ? basValToNumber(args[3]) : 0.0, argc >= DRAW_PAINT_SIZE_ARGS ? basValToNumber(args[4]) : 0.0, &op.u.picture.dx, &op.u.picture.dy, &op.u.picture.dw, &op.u.picture.dh);
    +
    +    if (argc < DRAW_PAINT_SIZE_ARGS) {
    +        op.u.picture.dw = basGfxRound(op.u.picture.sw * PR_SCREEN_TWIPS / prTwipsX(p));
    +        op.u.picture.dh = basGfxRound(op.u.picture.sh * PR_SCREEN_TWIPS / prTwipsY(p));
    +    }
    +
    +    // The source rectangle inside the picture
    +    int32_t sx = op.u.picture.sx < 0 ? 0 : op.u.picture.sx;
    +    int32_t sy = op.u.picture.sy < 0 ? 0 : op.u.picture.sy;
    +
    +    op.u.picture.sw = (sx + op.u.picture.sw > w ? w - sx : op.u.picture.sw);
    +    op.u.picture.sh = (sy + op.u.picture.sh > h ? h - sy : op.u.picture.sh);
    +    op.u.picture.sx = sx;
    +    op.u.picture.sy = sy;
    +    op.top          = op.u.picture.dy < 0 ? 0 : op.u.picture.dy;
    +    op.bottom       = op.u.picture.dy + op.u.picture.dh - 1;
    +
    +    if (op.u.picture.sw <= 0 || op.u.picture.sh <= 0 || op.u.picture.dw <= 0 || op.u.picture.dh <= 0 || op.bottom < op.top) {
    +        free(rgb);
    +        return;
    +    }
    +
    +    arrput(p->ops, op);
    +}
    +
    +
    +// A kept picture's rows in the band.
    +static void prPictureBand(const PrOpT *op, const PrBandT *b) {
    +    int32_t top    = op->top > b->y0 ? op->top : b->y0;
    +    int32_t bottom = op->bottom < b->y0 + b->rows - 1 ? op->bottom : b->y0 + b->rows - 1;
    +    int32_t x0     = op->u.picture.dx < 0 ? 0 : op->u.picture.dx;
    +    int32_t x1     = op->u.picture.dx + op->u.picture.dw < b->width ? op->u.picture.dx + op->u.picture.dw : b->width;
    +
    +    for (int32_t y = top; y <= bottom; y++) {
    +        int32_t  ry  = op->u.picture.sy + (int32_t)((int64_t)(y - op->u.picture.dy) * op->u.picture.sh / op->u.picture.dh);
    +        uint8_t *dst = b->rgb + (int64_t)(y - b->y0) * b->width * PR_RGB_BYTES;
    +
    +        for (int32_t x = x0; x < x1; x++) {
    +            int32_t rx = op->u.picture.sx + (int32_t)((int64_t)(x - op->u.picture.dx) * op->u.picture.sw / op->u.picture.dw);
    +
    +            memcpy(dst + (int64_t)x * PR_RGB_BYTES, op->u.picture.rgb + ((int64_t)ry * op->u.picture.w + rx) * PR_RGB_BYTES, PR_RGB_BYTES);
    +        }
    +    }
    +}
    +
    +
    +// The page out to the printer, Copies times, the document opened first
    +// (where the program's last PrintChoose said, else the settings'); the
    +// page's drawing is then thrown away.  False when it did not print.
    +static bool prPrintPage(BasFormRtT *rt, BasPrinterT *p) {
    +    const PrApiT *api = p->api;
    +    bool          ok  = true;
    +
    +    if (!p->doc) {
    +        PrintChoiceT ch;
    +        bool         chose = api->basChoice(&ch);
    +
    +        p->doc       = api->docOpen(rt->ctx, rt->title[0] ? rt->title : PR_DEFAULT_TITLE, chose ? &ch : NULL);
    +        p->docCopies = p->copies > 0 ? p->copies : (chose && ch.copies > 0 ? ch.copies : 1);
    +        ok           = p->doc != NULL;
    +    }
    +
    +    for (int32_t c = 0; ok && c < p->docCopies; c++) {
    +        ok = p->page.vector ? prVecPage(p) : api->docPage(p->doc, prPageBand, p);
    +    }
    +
    +    prPageClear(p);
    +    return ok;
    +}
    +
    +
    +// Line, Circle or PSet: its argument block done at once on the measuring
    +// surface (CurrentX / CurrentY move, the rows it touches are found) and
    +// kept with the drawing state it was done in.
    +static void prShape(BasFormRtT *rt, BasPrinterT *p, PrOpKindE kind, BasValueT *args, int32_t argc) {
    +    int32_t need = kind == PrOpLineE ? BAS_GFX_LINE_ARGS : (kind == PrOpCircleE ? BAS_GFX_CIRCLE_ARGS : BAS_GFX_PSET_ARGS);
    +
    +    if (!prPage(rt, p, true)) {
    +        return;
    +    }
    +
    +    if (argc != need) {
    +        basFormRtRuntimeError(rt, "Illegal function call", "Method: Printer.%s", kind == PrOpLineE ? sMethods[PrMLineE] : (kind == PrOpCircleE ? sMethods[PrMCircleE] : sMethods[PrMPsetE]));
    +        return;
    +    }
    +
    +    BasGfxT *g = basFormRtDrawGfx(p->draw);
    +    PrOpT    op;
    +    int32_t  n = (int32_t)arrlen(p->states);
    +
    +    memset(&op, 0, sizeof(op));
    +    op.kind          = kind;
    +    op.u.shape.lastX = g->lastX;
    +    op.u.shape.lastY = g->lastY;
    +    op.u.shape.argc  = argc;
    +
    +    for (int32_t a = 0; a < argc; a++) {
    +        op.u.shape.args[a] = basValToNumber(args[a]);
    +    }
    +
    +    // The state is kept again only once a property or Scale changed it
    +    if (n == 0 || p->stateChanged) {
    +        arrput(p->states, *g);
    +        n++;
    +        p->stateChanged = false;
    +    }
    +
    +    op.u.shape.state = n - 1;
    +    p->touchTop      = INT32_MAX;
    +    p->touchBottom   = PR_UNSET;
    +
    +    if (prShapeDo(g, kind, args) != 0) {
    +        basFormRtRuntimeError(rt, "Illegal function call", "Method: Printer.%s\nAn argument is out of range (a colour, a radius, or an angle).", kind == PrOpLineE ? sMethods[PrMLineE] : (kind == PrOpCircleE ? sMethods[PrMCircleE] : sMethods[PrMPsetE]));
    +        return;
    +    }
    +
    +    // Nothing on the page (off it, or empty): nothing kept
    +    if (p->touchBottom < 0) {
    +        return;
    +    }
    +
    +    op.top    = p->touchTop;
    +    op.bottom = p->touchBottom;
    +    arrput(p->ops, op);
    +}
    +
    +
    +// A kept shape done again on surf, in the drawing state it was done in.
    +static void prShapeAgain(const BasPrinterT *p, const PrOpT *op, const BasGfxSurfaceT *surf) {
    +    BasGfxT   g = p->states[op->u.shape.state];
    +    BasValueT args[BAS_GFX_CIRCLE_ARGS];
    +
    +    g.surf  = *surf;
    +    g.lastX = op->u.shape.lastX;
    +    g.lastY = op->u.shape.lastY;
    +
    +    for (int32_t a = 0; a < op->u.shape.argc; a++) {
    +        args[a] = basValDouble(op->u.shape.args[a]);
    +    }
    +
    +    prShapeDo(&g, op->kind, args);
    +}
    +
    +
    +static int32_t prShapeDo(BasGfxT *g, PrOpKindE kind, const BasValueT *args) {
    +    switch (kind) {
    +        case PrOpLineE:
    +            return basGfxLine(g, args);
    +
    +        case PrOpCircleE:
    +            return basGfxCircle(g, args);
    +
    +        default:
    +            return basGfxPset(g, args);
    +    }
    +}
    +
    +
    +// Print's text (basFormRtDrawPrintItem): kept at its place in ForeColor;
    +// its width in dots.
    +static int32_t prText(void *ctx, const char *text, int32_t len, int32_t x, int32_t y) {
    +    BasPrinterT *p    = (BasPrinterT *)ctx;
    +    PrintFontT  *font = p->fonts[p->font].font;
    +    int32_t      w    = p->api->fontWidth(font, text, len);
    +    PrOpT        op;
    +
    +    if (len <= 0) {
    +        return w;
    +    }
    +
    +    memset(&op, 0, sizeof(op));
    +    op.kind         = PrOpTextE;
    +    op.top          = y;
    +    op.bottom       = y + p->api->fontHeight(font) - 1;
    +    op.u.text.font  = p->font;
    +    op.u.text.x     = x;
    +    op.u.text.y     = y;
    +    op.u.text.color = wgtBasicColor(basFormRtDrawGfx(p->draw)->fore);
    +    op.u.text.len   = len;
    +    op.u.text.text  = (char *)malloc((size_t)len + 1);
    +
    +    if (op.u.text.text) {
    +        memcpy(op.u.text.text, text, (size_t)len + 1);
    +        arrput(p->ops, op);
    +    }
    +
    +    return w;
    +}
    +
    +
    +// Twips in a printer dot across and down.
    +static double prTwipsX(const BasPrinterT *p) {
    +    return PR_TWIPS_PER_INCH / p->page.dpiX;
    +}
    +
    +
    +static double prTwipsY(const BasPrinterT *p) {
    +    return PR_TWIPS_PER_INCH / p->page.dpiY;
    +}
    +
    +
    +// The vector page's shapes: to the printer whole, in RGB.
    +static void prVecDot(void *ctx, int32_t x, int32_t y, uint32_t pixel, int32_t width) {
    +    const BasPrinterT *p = (const BasPrinterT *)ctx;
    +
    +    p->api->vecDot(p->doc, x, y, wgtBasicColor(pixel), width);
    +}
    +
    +
    +static void prVecEllipse(void *ctx, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, int32_t width, uint16_t style, bool arc, double start, double end) {
    +    const BasPrinterT *p = (const BasPrinterT *)ctx;
    +
    +    p->api->vecEllipse(p->doc, cx, cy, rx, ry, wgtBasicColor(pixel), width, style, arc, start, end);
    +}
    +
    +
    +static void prVecEllipseFill(void *ctx, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, int32_t fillStyle) {
    +    const BasPrinterT *p = (const BasPrinterT *)ctx;
    +
    +    p->api->vecEllipseFill(p->doc, cx, cy, rx, ry, wgtBasicColor(pixel), sFills[fillStyle]);
    +}
    +
    +
    +static void prVecLine(void *ctx, double x0, double y0, double x1, double y1, uint32_t pixel, int32_t width, uint16_t style) {
    +    const BasPrinterT *p = (const BasPrinterT *)ctx;
    +
    +    p->api->vecLine(p->doc, x0, y0, x1, y1, wgtBasicColor(pixel), width, style);
    +}
    +
    +
    +// A vector page: every kept call done again once, in the order it was
    +// made, as the printer's own drawing.  False when it did not print.
    +static bool prVecPage(BasPrinterT *p) {
    +    const PrApiT  *api  = p->api;
    +    BasGfxSurfaceT surf = basFormRtDrawGfx(p->draw)->surf;
    +
    +    if (!api->vecBegin(p->doc)) {
    +        return false;
    +    }
    +
    +    surf.ctx = p;
    +    surf.vec = &sPrVec;
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(p->ops); i++) {
    +        const PrOpT *op = &p->ops[i];
    +
    +        if (op->kind == PrOpTextE) {
    +            api->vecText(p->doc, p->fonts[op->u.text.font].font, op->u.text.x, op->u.text.y, op->u.text.text, op->u.text.len, op->u.text.color);
    +        } else if (op->kind == PrOpPictureE) {
    +            api->vecImage(p->doc, op->u.picture.rgb, op->u.picture.w, op->u.picture.h, op->u.picture.sx, op->u.picture.sy, op->u.picture.sw, op->u.picture.sh, op->u.picture.dx, op->u.picture.dy, op->u.picture.dw, op->u.picture.dh);
    +        } else {
    +            prShapeAgain(p, op, &surf);
    +        }
    +    }
    +
    +    return api->vecEnd(p->doc);
    +}
    +
    +
    +static void prVecRect(void *ctx, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t pixel, int32_t fillStyle) {
    +    const BasPrinterT *p = (const BasPrinterT *)ctx;
    +
    +    p->api->vecRect(p->doc, x0, y0, x1, y1, wgtBasicColor(pixel), sFills[fillStyle]);
    +}
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.c b/src/apps/kpunch/dvxbasic/formrt/formrt.c
    index b05c46ab..9acb6e9f 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/formrt.c
    +++ b/src/apps/kpunch/dvxbasic/formrt/formrt.c
    @@ -1497,6 +1497,10 @@ BasValueT basFormRtCallMethod(void *ctx, void *ctrlRef, const char *methodName,
             return screenMethod(rt, methodName, args, argc);
         }
     
    +    if (basFormRtPrinterIs(ctrlRef)) {
    +        return basFormRtPrinterMethod(rt, methodName, args, argc);
    +    }
    +
         BasValueT ucResult;
     
         if (basUcSurfaceCall(rt, ctrlRef, methodName, args, argc, &ucResult)) {
    @@ -1866,7 +1870,7 @@ const char *basFormRtDefaultProp(void *ctx, void *objRef) {
             return BAS_PIC_PROP_DEFAULT;
         }
     
    -    BasControlT *ctrl = (objRef == &sClipboardObj || objRef == &sScreenObj) ? NULL : resolveCtrlRef(rt, objRef);
    +    BasControlT *ctrl = (objRef == &sClipboardObj || objRef == &sScreenObj || basFormRtPrinterIs(objRef)) ? NULL : resolveCtrlRef(rt, objRef);
     
         if (!ctrl || ctrl->menuId > 0 || (ctrl->form && ctrl == &ctrl->form->formCtrl)) {
             return NULL;
    @@ -1899,6 +1903,9 @@ void basFormRtDestroy(BasFormRtT *rt) {
     
         basFormRtScreenClose(rt);
     
    +    // A page drawn on the Printer, or its document, is printed (VB)
    +    basFormRtPrinterEnd(rt);
    +
         // A busy cursor the program left on goes with it
         if (rt->pointerBusy) {
             dvxSetBusy(rt->ctx, false);
    @@ -2013,12 +2020,16 @@ void *basFormRtFindCtrl(void *ctx, void *formRef, const char *ctrlName) {
             }
         }
     
    -    // VB's Clipboard and Screen objects, unless a control or form has the
    -    // name.
    +    // VB's Clipboard, Printer, and Screen objects, unless a control or
    +    // form has the name.
         if (ctrlName && strcasecmp(ctrlName, BAS_CLIPBOARD_NAME) == 0) {
             return (void *)&sClipboardObj;
         }
     
    +    if (ctrlName && strcasecmp(ctrlName, BAS_PRINTER_OBJ_NAME) == 0) {
    +        return basFormRtPrinterRef();
    +    }
    +
         return (ctrlName && strcasecmp(ctrlName, BAS_SCREEN_NAME) == 0) ? (void *)&sScreenObj : NULL;
     }
     
    @@ -2121,6 +2132,10 @@ BasValueT basFormRtGetProp(void *ctx, void *ctrlRef, const char *propName) {
             return screenProp(rt, propName);
         }
     
    +    if (basFormRtPrinterIs(ctrlRef)) {
    +        return basFormRtPrinterGetProp(rt, propName);
    +    }
    +
         const BasPictureT *pic = pictureFind(rt, ctrlRef);
     
         if (pic) {
    @@ -3476,6 +3491,10 @@ const char *basFormRtTypeName(void *ctx, void *objRef) {
             return BAS_SCREEN_NAME;
         }
     
    +    if (basFormRtPrinterIs(objRef)) {
    +        return BAS_PRINTER_OBJ_NAME;
    +    }
    +
         if (pictureFind(rt, objRef)) {
             return BAS_PICTURE_TYPE_NAME;
         }
    @@ -9583,6 +9602,11 @@ static void setPropValue(BasFormRtT *rt, void *ctrlRef, const char *propName, Ba
             return;
         }
     
    +    if (basFormRtPrinterIs(ctrlRef)) {
    +        basFormRtPrinterSetProp(rt, propName, value);
    +        return;
    +    }
    +
         if (pictureFind(rt, ctrlRef)) {
             basFormRtRuntimeError(rt,
                 "Picture properties are read-only",
    diff --git a/src/apps/kpunch/dvxbasic/formrt/formrt.h b/src/apps/kpunch/dvxbasic/formrt/formrt.h
    index a2e03824..0e4e221f 100644
    --- a/src/apps/kpunch/dvxbasic/formrt/formrt.h
    +++ b/src/apps/kpunch/dvxbasic/formrt/formrt.h
    @@ -34,6 +34,7 @@
     
     #include "../runtime/vm.h"
     #include "../runtime/values.h"
    +#include "../runtime/gfx.h"
     #include "../../../../libs/kpunch/game/game.h"
     #include "dvxApp.h"
     #include "dvxDlg.h"
    @@ -49,6 +50,7 @@
     typedef struct BasFormT BasFormT;
     typedef struct BasControlT BasControlT;
     typedef struct BasDrawT BasDrawT;              // a Form's / PictureBox's drawing state (formGfx.c)
    +typedef struct BasPrinterT BasPrinterT;        // the Printer object's state (formPrinter.c)
     typedef struct BasScreenWinT BasScreenWinT;    // the program screen's window (formGfx.c)
     
     // ============================================================
    @@ -604,6 +606,8 @@ typedef struct {
         // Screen.MousePointer when it is not vbHourglass: the pointer over
         // every window of the program (BAS_POINTER_DEFAULT: each control's).
         int32_t           screenPointer;
    +    // The Printer object's pages and document (NULL until it is used).
    +    BasPrinterT      *printer;
     } BasFormRtT;
     
     // ============================================================
    @@ -650,8 +654,8 @@ WindowT *basFormRtScreenWindow(const BasFormRtT *rt);
     // True when name is a Form / PictureBox drawing method (Cls, PSet, Line,
     // Circle, Point, Print, PaintPicture, Scale, ScaleX, ScaleY, TextWidth,
     // TextHeight) or, with isProp, a drawing property (CurrentX, CurrentY,
    -// DrawWidth, FillColor, FillStyle, ForeColor, BackColor, AutoRedraw,
    -// ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight).
    +// DrawStyle, DrawWidth, FillColor, FillStyle, ForeColor, BackColor,
    +// AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight).
     bool basFormRtIsDrawMember(const char *name, bool isProp);
     
     // Close all serial/secLink resources and unregister the idle pollers.
    @@ -995,6 +999,46 @@ bool basFormRtDrawSetProp(BasFormRtT *rt, BasControlT *ctrl, const char *name, B
     // conversions (VB's 15 twips a pixel), shared with Picture.Width / Height.
     double basFormRtDrawPixelsToUnits(double pixels, int32_t scale, bool across);
     
    +// A drawing surface with no canvas (the Printer's page, formPrinter.c):
    +// the drawing state on surf, twipsX / twipsY twips in a pixel across and
    +// down (circles come out round on paper with dots of any shape), the
    +// ScaleMode's units over it.  NULL when out of memory; free with
    +// basFormRtDrawDelete.  basFormRtDrawGfx is its state (CurrentX / Y,
    +// colours, the scale) for the basGfx calls.
    +void      basFormRtDrawDelete(BasDrawT *d);
    +BasGfxT  *basFormRtDrawGfx(BasDrawT *d);
    +BasDrawT *basFormRtDrawNew(AppContextT *ctx, const BasGfxSurfaceT *surf, double twipsX, double twipsY, int32_t scaleMode);
    +
    +// Its properties (CurrentX, CurrentY, DrawStyle, DrawWidth, FillColor,
    +// FillStyle, ForeColor, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth,
    +// ScaleHeight) and
    +// Scale / ScaleX / ScaleY: true when name is one.
    +bool basFormRtDrawScaleCall(BasFormRtT *rt, BasDrawT *d, const char *name, BasValueT *args, int32_t argc, BasValueT *result);
    +bool basFormRtDrawStateGet(BasDrawT *d, const char *name, BasValueT *out);
    +bool basFormRtDrawStateSet(BasFormRtT *rt, BasDrawT *d, const char *name, BasValueT value);
    +
    +// An object's Print item (BAS_OBJ_PRINT_* mode) laid out on d: columns,
    +// zones, TAB and SPC in charW pixels, lines charH pixels; text is drawn by
    +// emit with the top of its line at pixel (x, y) -- NUL-terminated at len --
    +// which returns its width in pixels, and CurrentX moves past it.
    +typedef int32_t (*BasDrawTextFnT)(void *ctx, const char *text, int32_t len, int32_t x, int32_t y);
    +void basFormRtDrawPrintItem(BasDrawT *d, int32_t charW, int32_t charH, BasValueT value, int32_t mode, BasDrawTextFnT emit, void *ctx);
    +
    +// PaintPicture's argument counts (forms, PictureBoxes, and the Printer)
    +#define DRAW_PAINT_MIN_ARGS     3       // PaintPicture picture, x, y
    +#define DRAW_PAINT_SIZE_ARGS    5       // ... , width, height
    +#define DRAW_PAINT_SRC_ARGS     9       // ... , srcX, srcY, srcWidth, srcHeight
    +
    +// A rectangle in g's scale as pixels (g NULL: the values are pixels).
    +void basFormRtDrawToPixels(const BasGfxT *g, double x, double y, double w, double h, int32_t *px, int32_t *py, int32_t *pw, int32_t *ph);
    +
    +// PaintPicture's picture (a PictureBox, by name -- form's first -- or a
    +// Control variable; an image file or a LoadPicture object) as R, G, B
    +// bytes, w x h (malloc'd); *scale is a PictureBox's scale for a source
    +// rectangle (NULL: pixels).  NULL, a runtime error raised, when there is
    +// none.
    +uint8_t *basFormRtDrawPictureRgb(BasFormRtT *rt, BasFormT *form, BasValueT pic, int32_t *w, int32_t *h, const BasGfxT **scale);
    +
     // Cls on a form's or PictureBox's (or UserControl's) drawing: cleared to
     // its BackColor.
     void basFormRtDrawClear(BasFormRtT *rt, BasControlT *ctrl);
    @@ -1020,6 +1064,24 @@ BasControlT *basFormRtResolveCtrl(BasFormRtT *rt, void *ref);
     void basFormRtScreenBind(BasFormRtT *rt);
     void basFormRtScreenClose(BasFormRtT *rt);
     
    +// ============================================================
    +// Between formrt.c and formPrinter.c (VB's Printer object)
    +// ============================================================
    +
    +// The object BASIC holds for Printer (basFormRtFindCtrl), and whether a
    +// reference is it.
    +bool  basFormRtPrinterIs(const void *ref);
    +void *basFormRtPrinterRef(void);
    +
    +// Its methods and properties (runtime errors for unknown ones).
    +BasValueT basFormRtPrinterGetProp(BasFormRtT *rt, const char *name);
    +BasValueT basFormRtPrinterMethod(BasFormRtT *rt, const char *name, BasValueT *args, int32_t argc);
    +void      basFormRtPrinterSetProp(BasFormRtT *rt, const char *name, BasValueT value);
    +
    +// The program ends: a page drawn or a document open is printed (EndDoc),
    +// and the Printer's state freed.
    +void basFormRtPrinterEnd(BasFormRtT *rt);
    +
     // ============================================================
     // Between formrt.c and formDesign.c
     // ============================================================
    diff --git a/src/apps/kpunch/dvxbasic/ide/idePackage.c b/src/apps/kpunch/dvxbasic/ide/idePackage.c
    index ef600c13..ac079a0a 100644
    --- a/src/apps/kpunch/dvxbasic/ide/idePackage.c
    +++ b/src/apps/kpunch/dvxbasic/ide/idePackage.c
    @@ -244,7 +244,13 @@ static void keysList(KeyListT *keys) {
     
         snprintf(keys->items[0], sizeof(keys->items[0]), "%s", PKG_NOT_SIGNED);
     
    -    if (!platformUserPath(dir, sizeof(dir), PKG_KEYS_DIR) || (d = opendir(dir)) == NULL) {
    +    if (!platformUserPath(dir, sizeof(dir), PKG_KEYS_DIR)) {
    +        return;
    +    }
    +
    +    d = opendir(dir);
    +
    +    if (!d) {
             return;
         }
     
    diff --git a/src/apps/kpunch/dvxbasic/langref.dhs b/src/apps/kpunch/dvxbasic/langref.dhs
    index e68c8bab..f1c1cc47 100644
    --- a/src/apps/kpunch/dvxbasic/langref.dhs
    +++ b/src/apps/kpunch/dvxbasic/langref.dhs
    @@ -2278,6 +2278,23 @@ Do
     Loop While INKEY$ = ""
     .endcode
     
    +.h2 Writing a Screen Saver
    +
    +A screen saver is a program with no form that shows its screen on the whole screen and draws until DVX closes it. Put the program (.app) in the SAVERS folder of your user folder (FileOpsKindDir$(FILEOPS_KIND_SAVERS, True)); it is then listed on Control Panel's Desktop page by its project Name. DVX starts it with the command line /S after the wait with no input, and closes its screen at the next key or mouse movement, which ends the program, so it needs no INKEY$ of its own.
    +
    +.code
    +SCREEN 13
    +CLS
    +FULLSCREEN ON
    +
    +DO
    +    ' draw a frame
    +    FRAMEWAIT 30
    +LOOP
    +.endcode
    +
    +SysScreenSaver$, SysSetScreenSaver, SysScreenSaverStart, and SysIdleSeconds (system.bas) read and change the screen saver from a program.
    +
     .topic lang.fileio
     .title File I/O
     .toc 1 File I/O
    @@ -4215,6 +4232,101 @@ If Clipboard.GetFormat(vbCFText) Then Text2.Text = Clipboard.GetText()
     
     A control or form of your own named Clipboard takes the name's place.
     
    +.topic lang.printerobj
    +.title Printer Object
    +.toc 1 Printer Object
    +.index Printer Object
    +.index Printer.Print
    +.index Printer.Line
    +.index Printer.Circle
    +.index Printer.PSet
    +.index Printer.PaintPicture
    +.index Printer.NewPage
    +.index Printer.EndDoc
    +.index Printer.KillDoc
    +.index Printer.Page
    +.index Printer.FontSize
    +.index Printer.Copies
    +.index NewPage
    +.index EndDoc
    +.index KillDoc
    +.index vbPRORPortrait
    +.index vbPRORLandscape
    +.index vbPRCMMonochrome
    +.index vbPRCMColor
    +
    +.h1 Printer Object
    +
    +The Printer object draws pages on the printer chosen in the Control Panel, as in Visual Basic: text in any font at a size in points, lines, boxes, circles, dots, and pictures, anywhere on the page, page after page. It needs a printer model that prints pictures (the Control Panel's Printer page); on a color printer the colors print as they are, on the others in shades of grey.
    +
    +On a PostScript printer the page is drawn by the printer itself, as lines, curves, and letters rather than dots, so it prints as sharp as the printer can. Text is in the printer's own Helvetica, Times, or Courier, whichever is nearest FontName: Courier for a fixed-pitch font or a name with Mono or Courier in it, Times for a name with Serif, Times, Roman, Tinos, or Ledger in it (but not Sans), and Helvetica for the rest. TextWidth and TextHeight measure the printer's letters, so text lines up as it will print.
    +
    +A page is 8 by 10 inches. Its coordinates are twips (1440 an inch) until ScaleMode or Scale changes them, as on Visual Basic's Printer, so a program can place things in inches, centimetres, points, or its own units. Drawing goes to the page being made; NewPage prints it and starts the next, and EndDoc prints the last one and ends the document. The document starts when the first page is printed, at the printer and with the copies the program's last PrintChoose (print.bas) chose, or else as the Control Panel has them; while it prints, the Printing window shows, with Cancel. A program that ends with a page drawn or a document started has EndDoc done for it.
    +
    +.table
    +  Method                                          Description
    +  ------                                          -----------
    +  Printer.Print items                             Text at CurrentX / CurrentY in the font and ForeColor, as PRINT lays it out: ; joins, a comma goes to the next 14-character zone, TAB(n) and SPC(n) count characters of the font (its 0's width), and a new line goes down a line of the font. CurrentX / CurrentY move past it.
    +  Printer.Line [(x1, y1)]-(x2, y2) [, color] [, B | BF]  A line, a box, or a filled box, as on a PictureBox (STEP works too).
    +  Printer.Circle (x, y), r [, color [, start, end [, aspect]]]  A circle, ellipse, or arc; filled in FillColor when FillStyle is not transparent. Circles are round on paper on every printer.
    +  Printer.PSet (x, y) [, color]                   One dot (DrawWidth across).
    +  Printer.PaintPicture pic, x, y [, w, h [, srcX, srcY, srcW, srcH]]  A picture (a PictureBox, a file, or LoadPicture's), stretched to w x h; without them, at its size on the screen (15 twips a pixel).
    +  Printer.NewPage                                 Print the page (even a blank one) and start the next; Page goes up by 1.
    +  Printer.EndDoc                                  Print the page drawn and end the document; Page goes back to 1.
    +  Printer.KillDoc                                 Throw the page drawn away and end the document (pages already printed stay printed).
    +  Printer.TextWidth(text), TextHeight(text)       The size text takes in the font, in the Printer's scale.
    +  Printer.Scale (x1, y1)-(x2, y2), ScaleX, ScaleY  As on a PictureBox: a scale of your own, and lengths from one scale to another.
    +.endtable
    +
    +.table
    +  Property                                        Description
    +  --------                                        -----------
    +  CurrentX, CurrentY                              Where the next drawing starts.
    +  ForeColor, FillColor, FillStyle, DrawWidth, DrawStyle  As on a PictureBox (black, transparent fills, a solid pen 1 dot wide to start).
    +  ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight  The scale (vbTwips to start).
    +  FontName, FontSize, FontBold, FontItalic, FontUnderline, FontStrikethru  The font of Print's text; FontSize is in points (12 to start, in DejaVu Sans). Text prints crisp where the font has large sizes (the Printer Fonts add-on gives DejaVu Sans, Serif, and Sans Mono them), and blockier where a screen size is enlarged.
    +  Copies                                          How many times each page prints (1 to 99); as PrintChoose chose when it is not set.
    +  Page                                            The page being drawn (1 first). Read only.
    +  Width, Height                                   The page's size in twips (11520 by 14400). Read only.
    +  TwipsPerPixelX, TwipsPerPixelY                  Twips in one of the printer's dots. Read only.
    +  ColorMode                                       vbPRCMColor on a color printer, else vbPRCMMonochrome. Read only.
    +  Orientation                                     vbPRORPortrait: pages print upright only.
    +  DeviceName, Port                                The printer model and where it is connected, as the Control Panel names them. Read only.
    +.endtable
    +
    +.code
    +Sub mnuReport_Click ()
    +    Printer.ScaleMode = vbInches
    +    Printer.FontSize = 18
    +    Printer.FontBold = True
    +    Printer.CurrentX = 1
    +    Printer.CurrentY = 1
    +    Printer.Print "Sales by Region"
    +    Printer.FontSize = 11
    +    Printer.FontBold = False
    +    Printer.Line (1, 1.5)-(7, 1.5)
    +    Printer.FillStyle = 0
    +    Printer.FillColor = vbBlue
    +    Printer.Line (1, 2)-(1 + nNorth / 100, 2.4), vbBlue, B
    +    Printer.CurrentX = 1: Printer.CurrentY = 2.5
    +    Printer.Print "North"; Tab(20); nNorth
    +    Printer.EndDoc
    +End Sub
    +.endcode
    +
    +The Printer has no Cls, Point, PAINT, or DRAW (Visual Basic's Printer has none of them either), and no Printer.Font object: use FontName, FontSize, and the other Font properties. A raster operation given to PaintPicture is not used on paper. A control or form of your own named Printer takes the name's place.
    +
    +.h2 Printer Constants
    +
    +.table
    +  Constant            Value   Description
    +  --------            -----   -----------
    +  vbPRORPortrait      1       Printer.Orientation: upright pages (the only one)
    +  vbPRORLandscape     2       Printer.Orientation: sideways pages (not supported)
    +  vbPRCMMonochrome    1       Printer.ColorMode: black (and grey) only
    +  vbPRCMColor         2       Printer.ColorMode: color
    +.endtable
    +
     .topic lang.screenobj
     .title Screen Object
     .toc 1 Screen Object
    @@ -4399,6 +4511,21 @@ IniWrite App.User & "\app.ini", "Display", "FontSize", Str$(fontSize)
     .index vbHimetric
     .index vbPicTypeNone
     .index vbPicTypeBitmap
    +.index vbFSSolid
    +.index vbFSTransparent
    +.index vbHorizontalLine
    +.index vbVerticalLine
    +.index vbUpwardDiagonal
    +.index vbDownwardDiagonal
    +.index vbCross
    +.index vbDiagonalCross
    +.index vbSolid
    +.index vbDash
    +.index vbDot
    +.index vbDashDot
    +.index vbDashDotDot
    +.index vbInvisible
    +.index vbInsideSolid
     .index vbEmpty
     .index vbNull
     .index vbInteger
    @@ -4670,6 +4797,30 @@ The scale units of ScaleMode and of the ScaleX / ScaleY methods, and the values
       vbPicTypeBitmap   1       Picture.Type of a loaded picture
     .endtable
     
    +.h2 Fill and Pen Constants
    +
    +The values of FillStyle and DrawStyle on Forms, PictureBoxes, and the Printer (see Drawing on Forms and PictureBoxes).
    +
    +.table
    +  Constant             Value   Description
    +  --------             -----   -----------
    +  vbFSSolid            0       FillStyle: filled solid in FillColor
    +  vbFSTransparent      1       FillStyle: not filled (the default)
    +  vbHorizontalLine     2       FillStyle: horizontal lines
    +  vbVerticalLine       3       FillStyle: vertical lines
    +  vbUpwardDiagonal     4       FillStyle: lines rising to the right
    +  vbDownwardDiagonal   5       FillStyle: lines falling to the right
    +  vbCross              6       FillStyle: a cross hatch
    +  vbDiagonalCross      7       FillStyle: a diagonal cross hatch
    +  vbSolid              0       DrawStyle: a solid pen (the default)
    +  vbDash               1       DrawStyle: dashes
    +  vbDot                2       DrawStyle: dots
    +  vbDashDot            3       DrawStyle: dash, dot
    +  vbDashDotDot         4       DrawStyle: dash, dot, dot
    +  vbInvisible          5       DrawStyle: no lines or outlines (fills still drawn)
    +  vbInsideSolid        6       DrawStyle: solid
    +.endtable
    +
     .h2 VarType Constants
     
     The values VARTYPE returns (see Variants). For an array, VARTYPE adds vbArray to the type of its elements.
    @@ -4815,11 +4966,17 @@ Colors in the form RGB makes (&HBBGGRR, see Color Functions), as in Visual Basic
     .index SoundSetPan
     .index SoundSetPitch
     .index SoundSetFrequency
    +.index SoundPause
    +.index SoundResume
     .index SoundGetVolume
     .index SoundCaps
     .index SoundPlayEvent
     .index SoundStreamOpen
    +.index RecordStart
    +.index RecordStop
    +.index Recording
     .index SoundStreamWrite
    +.index SoundStreamFlush
     .index SoundStreamClose
     .index FmAlloc
     .index FmWrite
    @@ -4840,6 +4997,12 @@ Colors in the form RGB makes (&HBBGGRR, see Color Functions), as in Visual Basic
     .index MusicFree
     .index MusicError$
     .index MusicMidiOpen
    +.index MusicLoadMacro
    +.index MusicSetBank
    +.index MusicPatchSet$
    +.index MusicRomBank$
    +.index SoundFont
    +.index PkgFileOwner$
     .index MusicNoteOn
     .index MusicNoteOff
     .index MusicProgram
    @@ -4850,6 +5013,19 @@ Colors in the form RGB makes (&HBBGGRR, see Color Functions), as in Visual Basic
     .index MidiAcquire
     .index MidiPut
     .index MidiPortInfo$
    +.index MidiInOpen
    +.index MidiInMessage
    +.index MidiInTime
    +.index MidiInClock
    +.index MusicMidiSend
    +.index MidiRecordNew
    +.index MidiRecordSave
    +.index MIDI keyboard
    +.index WtInfo$
    +.index WtAlloc
    +.index WtStart
    +.index WtUpload
    +.index Wavetable synthesizer
     .index Music
     .index MIDI
     .index conn.bas
    @@ -5099,8 +5275,8 @@ DVX ships a set of BASIC include files (.bas) in the sdk/include/basic directory
       security.bas   Security: random bytes for keys, a key exchange, a cipher, SHA-256 hashes, HMAC signatures, key making, and a safe compare (SecRandom$, SecDhCreate, SecCipherCrypt$, SecSha256$, SecHmac$, SecHkdf$, ...)
       game.bas       Games: whether a key is being held down right now, forgetting held keys, frames FRAMEWAIT skipped, waiting for the screen's retrace (GameKeyDown, GameKeyClear, FrameMissed, GameWaitRetrace)
       joystick.bas   Joysticks: how many there are, their names, where each stick points, and which buttons are pressed (JoyCount, JoyX, JoyY, JoyButton, ...); setting them up: calibration, dead zone, game port mode, keyboard joystick keys (JoySetCalibration, JoyCalibration, JoyRescan, JoyKeyIndex, JoySettingsStore, ...)
    -  sound.bas      Sound: play WAV and VOC files, tones and noise, set the volume (PlayWave, PlayTone, StopWave, SoundInfo$, ...); sounds loaded once and played with volume, pan, and pitch, sound made by the program as it goes, the FM synthesizer chip (SoundLoad, SoundPlay, SoundSetPitch, SoundCreate, SoundStreamOpen, SoundStreamWrite, FmAlloc, FmWrite, ...); DVX's own event sounds (SoundPlayEvent), master volume, sound driver, and MIDI settings (SoundEventFile$, SoundSetMasterVolume, SoundSettingsStore, ...)
    -  music.bas      Music: play MOD, S3M, XM, IT, and MIDI files, and play notes yourself (MusicLoad, MusicPlay, MusicStop, MusicNoteOn, MusicInfo$, ...); raw bytes to the MIDI port (MidiAcquire, MidiPut, MidiRelease, MidiPortInfo$)
    +  sound.bas      Sound: play WAV and VOC files, tones and noise, set the volume (PlayWave, PlayTone, StopWave, SoundInfo$, ...); sounds loaded once and played with volume, pan, and pitch, sound made by the program as it goes, recording from a microphone or line input (RecordStart, RecordStop, RecordLevel, ...), the FM synthesizer chip, a wavetable card's voices and memory (SoundLoad, SoundPlay, SoundSetPitch, SoundCreate, SoundStreamOpen, SoundStreamWrite, FmAlloc, FmWrite, WtAlloc, WtStart, ...); DVX's own event sounds (SoundPlayEvent), master volume, sound driver, and MIDI settings (SoundEventFile$, SoundSetMasterVolume, SoundSettingsStore, ...)
    +  music.bas      Music: play MOD, S3M, XM, IT, MIDI, WAV, and VOC files, and play notes yourself (MusicLoad, MusicPlay, MusicStop, MusicNoteOn, MusicInfo$, ...); instruments from banks and SoundFonts, a sound card's own (MusicSetBank, MusicPatchSet$, MusicRomBank$); raw bytes to the MIDI port (MidiAcquire, MidiPut, MidiRelease, MidiPortInfo$); what a MIDI keyboard plays, heard as it comes and recorded into MIDI files (MidiInOpen, MidiInMessage, MusicMidiSend, MidiRecordNew, MidiRecordSave, ...)
       fileops.bas    Files and folders: folder listings for a file list, paths and names, the file dialogs' filter rule, attributes and the time a file was changed, sizes, free space and disk sizes, drive kinds, whether a folder can be written, the folder for scratch files, making a folder path, and copying, moving, and deleting whole folders at once or as a job with progress, Cancel, and questions (FileOpsList, FileOpsListRows$, FileOpsFilterMatch, FileOpsSetModified, FileOpsDriveType, FileOpsIsWritable, FileOpsMakePath, FileOpsJobCreate, FileOpsJobRunFor, ...)
       system.bas     What Control Panel changes, at once for the whole desktop: the screen mode, the system colors and themes, the wallpaper, text smoothing, and the mouse (with DVX's limits and defaults, MOUSE_* and SYS_VIDEO_DEFAULT_*), and storing them in DVX's settings; the system font's character pictures; the mouse pointer and the Shift, Ctrl, and Alt keys anywhere on the screen, moving the pointer, typing keys into the window in front, a picture of the whole screen, and the memory (SysSetVideoMode, SysSetColor, SysLoadTheme, SysSetWallpaper, SysSetTextSmoothing, SysSetMouse, SysColorsStore, SysFontGlyphRow, SysMouseX, SysSendKey, SysScreenshot, SysMemoryFreeKb, ...)
       font.bas       Fonts: the font families DVX has, the sizes each comes in, whether one is fixed-pitch, and how wide text and how tall a line is in a font (FontFamilyCount, FontFamilyName$, FontFamilyFixed, FontSizeCount, FontSizeAt, FontNearestSize, FontTextWidth, FontLineHeight, FontAscent, FontRescan)
    @@ -5109,7 +5285,7 @@ DVX ships a set of BASIC include files (.bas) in the sdk/include/basic directory
       compiler.bas   The BASIC compiler: compile a project or files you give it, find compile errors in their files, list a program's procedures and the language's keywords, build an .app or a control file; put a project's UserControls on a form designer's toolbox; read source text as the compiler does (tokens, code or string or comment, renaming a control in code, WithEvents variables, class Events), F1 help topics; open, change, and save project files and UserControl headers (CompSourceFromProject, CompSourceAdd, CompCompile, CompError$, CompMapFile$, CompProcList$, CompBuildApp, CompDesignControls, CompTokens$, CompRenameInCode$, CompHelpTopic$, ProjOpen, ProjSave, ProjCtlSetHeader$, ...)
       debug.bas      Run a compiled program inside yours and debug it: breakpoints, stepping, its variables, expressions, Immediate-window lines, its PRINT output (RunCreate, RunStart, RunPoll, DbgSetBreakpoints, DbgStepOver, DbgLocals$, DbgEval$, DbgExec$, ...)
       designer.bas   Form designers: the control types, whether each holds other controls and can be placed, the names of new controls, default events, toolbox tooltips and icons, and each type's properties (with their types and value names), methods, and events (with each event's parameters); the names in .frm and class module text; .frm files as data to read, edit, and write, controls copied and pasted as text, and a loaded form written back as .frm text (CtlTypeCount, CtlTypeName$, CtlTypeFlags, CtlPropCount, CtlPropName$, CtlPropType, CtlEventList$, CtlEventParams$, CtlTypeEventParams$, FrmFormName$, FrmControlNames$, ClsClassName$, FrmOpen, FrmText$, FrmCtlAdd, FrmCtlText$, FrmCtlPaste, FrmMenuAdd, FormText$, ...)
    -  shell.bas      DVX itself, everything Program Manager and Task Manager do: start programs and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...)
    +  shell.bas      DVX itself, everything Program Manager and Task Manager do: start programs (DVX's and DOS programs) and open documents, command lines (split into their parts) and the Run dialog, the installed programs and their names, file types, the running programs (find one, bring it to the front, ask it to close, or end it), this program's priority, keys that work in every program, Task Manager, system information, Exit DVX, the desktop and the program started after it, the DVX log; Program Manager's program groups with their items, shortcut keys, and icons (ShellRun, ShellRunProgram, ShellRunDos, ShellProgramScan, ShellAssocCount, ShellRunningId, ShellRunningActivate, ShellRunningClose, ShellArg$, ShellSetPriority, HotkeyAdd, ShellExitDvx, ShellGroupsLoad, ShellItemGet$, ShellItemIcon$, ...)
       pkg.bas        Add-on packages: the packages installed in DVX and their details, files, and needs; reading .DPK files and planning what installing them takes; installing and removing as a job with progress; trusting a publisher's key; and making packages, signing them, and making keys (PkgOpen, PkgInstalledRows$, PkgOfferFile, PkgPlan, PkgInstallStart, PkgRunFor, PkgRemoveStart, PkgBuild, PkgSign, PkgKeygen, ...)
       json.bas       JSON: read a document (a web service's reply, a settings file) and find values in it by path such as packages[3].version, and quote text for JSON of your own (JsonParse, JsonText$, JsonNumber, JsonCount, JsonKey$, JsonQuote$, ...)
       http.bas       Web requests: GET a page, a file, or a web service's answer, or POST to one, over the network a little at a time with progress, to memory or to a file (resuming one left part-way), following redirects (HttpOpen, HttpStart, HttpRunFor, HttpStatus, HttpBody$, HttpSaveTo, ...)
    @@ -5276,7 +5452,7 @@ With comm.bas, SerOpen returns False when a port cannot be opened; SerLastError(
     
     .h2 Printing
     
    -With print.bas in the project, a program prints like the programs that come with DVX. PrintTextDialog shows the Print dialog (printer, copies, pages) and prints the text on pages laid out by the page setup: margins, a header, and a footer with the document name, date, and page number. PrintPageSetup shows the Page Setup dialog, which the user's choices are saved from for every program. PrintPictureDialog prints a picture file scaled to the page, on printer models that print pictures (PrintCanPrintPictures says whether the chosen one does). PrintText and PrintPicture print without asking first. Each returns True when it printed.
    +With print.bas in the project, a program prints like the programs that come with DVX. PrintTextDialog shows the Print dialog (printer, copies, pages) and prints the text on pages laid out by the page setup: margins, a header, and a footer with the document name, date, and page number. PrintPageSetup shows the Page Setup dialog, which the user's choices are saved from for every program. PrintPictureDialog prints a picture file scaled to the page, on printer models that print pictures (PrintCanPrintPictures says whether the chosen one does), in color on a color printer (PrintCanPrintColor says whether it is one) and in shades of grey on the others. PrintText and PrintPicture print without asking first. Each returns True when it printed.
     
     .code
     Sub mnuPrint_Click ()
    @@ -5288,7 +5464,7 @@ Sub mnuPageSetup_Click ()
     End Sub
     .endcode
     
    -For printer output a line at a time, see LPRINT and OPEN "LPT1:".
    +For printer output a line at a time, see LPRINT and OPEN "LPT1:". To draw whole pages (text in any font and size, lines, boxes, circles, and pictures), use the Printer object.
     
     A program that prints its own way has more:
     
    @@ -5335,8 +5511,13 @@ A program can also change the printer settings the way the Control Panel's Print
       Function                                       Description
       --------                                       -----------
       PrintModelCount() / PrintModelLabel$(model)    How many printer models there are, and the name of each.
    -  PrintDestCount() / PrintDestLabel$(dest)       How many places the printer can be connected to (LPT1, LPT2, LPT3, a file), and the name of each.
    +  PrintDestCount() / PrintDestLabel$(dest)       How many places the printer can be connected to (LPT1 to LPT3, COM1 to COM4, a file), and the name of each.
       PrintDestIsFile(dest)                          True for the destination that prints into a file.
    +  PrintDestIsSerial(dest)                        True for a serial port (COM1 to COM4), which needs a speed and a handshake.
    +  PrintBaudRate(index)                           The serial speeds offered, from index 0 (0 past the last).
    +  PrintHandshakeCount() / PrintHandshakeLabel$(handshake)  How many handshakes there are, and the name of each (None, XON / XOFF, Hardware (CTS), Hardware (DSR, Apple)).
    +  PrintSettingBaud(prefs), PrintSettingHandshake(prefs)  A serial printer's speed and handshake in the settings.
    +  PrintSerialStore(prefs, baud, handshake)       Put a serial printer's speed and handshake into the settings; False for a speed not offered.
       PrintSettingModel(prefs)                       The printer model in the settings.
       PrintSettingDest(prefs)                        Where the printer is connected.
       PrintSettingFile$(prefs)                       The file printed into when the destination is a file.
    @@ -5374,7 +5555,7 @@ Files: FileOpsMakeDir and FileOpsRename (FileOpsError$ says why one failed), Fil
     
     Drives: FileOpsDriveList$ (the drive letters), FileOpsDriveType (FILEOPS_DRIVE_FIXED for a hard disk, FILEOPS_DRIVE_REMOVABLE for a floppy disk drive, FILEOPS_DRIVE_REMOTE for a network drive or a CD-ROM; asking never makes a drive spin up), FileOpsBootDrive$ (the drive the computer started from), FileOpsFreeBytes, FileOpsFreeKb, and FileOpsTotalBytes (the size of the disk; right for disks past 2 GB too), FileOpsLongNames(path) (True when the disk holding path takes long file names: up to 255 characters, with spaces and several dots; otherwise names are 8.3). FileOpsIsWritable(folder) is False where files cannot be made (a CD-ROM, a write-protected floppy), and FileOpsReadOnlySystem() is True when nothing can be saved: DVX runs from such a disk and there is no TEMP folder to keep the user folder in. FileOpsTempDir$() is the folder for scratch files ("" when there is none).
     
    -DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, desktop pictures, and MIDI instrument banks come in two folders each, DVX's own (and add-on packages') in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, FILEOPS_KIND_WPAPER, and FILEOPS_KIND_BANKS. An add-on's files are in a folder of its vendor's inside a kind's folder and are named VENDOR\NAME (for example ACME\SPACE.THM). FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.
    +DVX's folders: FileOpsUserDir$() is the user folder (USER in the DVX folder, or the folder DVXUSER names), where everything DVX and its programs save goes; FileOpsUserPath$(name) is a file or folder in it, for example FileOpsUserPath$("DOWNLOAD"); FileOpsUserInTemp() is True when DVX runs from a disk it cannot write to and keeps the user folder in the TEMP folder. A program's own folder there is App.User. The fonts, sounds, themes, desktop pictures, MIDI instrument banks, and screen savers come in two folders each, DVX's own (and add-on packages') in SHARED and the user's in the user folder; the kinds are FILEOPS_KIND_FONTS, FILEOPS_KIND_SOUNDS, FILEOPS_KIND_THEMES, FILEOPS_KIND_WPAPER, FILEOPS_KIND_BANKS, and FILEOPS_KIND_SAVERS. An add-on's files are in a folder of its vendor's inside a kind's folder and are named VENDOR\NAME (for example ACME\SPACE.THM). FileOpsKindDir$(kind, user) is DVX's folder of a kind (user False) or the user's (True); FileOpsKindBrowseDir$(kind) is the one a file dialog starts in (the user's when it exists); FileOpsKindFind$(kind, fileName) finds a file by name, the user's first ("" when there is none); FileOpsKindName$(kind, path) is the name alone of a file in one of the two folders, the way settings store it ("" for any other path); FileOpsKindList(kind) lists both folders as one, sorted by name, a user's file hiding DVX's of the same name (a list for FileOpsListCount, FileOpsListName$, and the rest; FileOpsListFree when done). Programs read DVX's SHARED folder but never write in it.
     
     FileOpsCopyTree, FileOpsMoveTree, and FileOpsDeleteTree do a whole folder at once. For a long copy, move, or delete the program stays usable when it runs the work as a job, a little at a time from a Timer: FileOpsJobCreate, then FileOpsJobAddSource for each file or folder, then FileOpsJobRunFor from the timer until it returns FILEOPS_STEP_DONE or FILEOPS_STEP_CANCELLED. When it returns FILEOPS_STEP_ASK (a file already exists, a file is read-only) or FILEOPS_STEP_ERROR, show FileOpsJobMessage$ and pass the user's choice to FileOpsJobAnswer. FileOpsJobCurrent$, FileOpsJobFilesDone, FileOpsJobFilesTotal, FileOpsJobPercent, FileOpsJobBytesDone, and FileOpsJobBytesTotal show the progress, and FileOpsJobCancel stops the job. Files copied from a CD-ROM are read-only; FileOpsJobClearAttr job, FILEOPS_ATTR_READONLY before the job runs makes every copy changeable (FILEOPS_ATTR_EDITABLE clears all four attribute bits). Whatever a program leaves open is freed when it ends.
     
    @@ -5409,7 +5590,7 @@ End Sub
     
     With shell.bas in the project, a program works with DVX the way Program Manager does -- a whole desktop can be written in BASIC.
     
    -Starting programs. ShellRun program, args starts a program (args is what it gets as COMMAND$), or opens a document in the program set for its type; it returns True when it started. ShellRunProgram(program, args, folder) does the same the way a Program Manager item does: file names in args, and a document named without a folder, are looked for in folder first ("" for none), and it returns the new program's id (0 when it did not start; DVX has already said why). ShellMinimizeApp id runs that program minimized: its windows become icons, including the ones it opens while it starts. ShellRunCommand(commandLine) runs a whole command line (a program, in double quotes when its name has a blank, then its arguments) and returns the id too. ShellRunDialog$(history) shows DVX's Run dialog offering history (earlier commands, newest first, one per line) and returns what the user chose, "" for Cancel. ShellOpenDocument path opens a document in the program set for its type in the Control Panel.
    +Starting programs. ShellRun program, args starts a program (args is what it gets as COMMAND$), runs a DOS program, or opens a document in the program set for its type; it returns True when it started (or the DOS program will run). ShellRunProgram(program, args, folder) does the same the way a Program Manager item does: file names in args, and a document named without a folder, are looked for in folder first ("" for none), and it returns the new program's id (SHELL_RUN_DOS for a DOS program, 0 when it did not start; DVX has already said why). DOS programs are .EXE, .COM, and .BAT files (ShellIsDosProgram(path) says whether a file is one). ShellRunDos program, args, folder, pause runs one with DVX stepped aside: once your procedure has returned, DVX names the programs running besides the desktop, this one included, and asks them to close (each asks about unsaved work; No, or a program kept open, cancels the run); the screen then becomes the DOS screen until the program ends, and DVX comes back with the desktop. folder is where it runs ("" for its own folder); pause True shows "Press any key to return to DVX." when it ends. ShellMinimizeApp id runs that program minimized: its windows become icons, including the ones it opens while it starts. ShellRunCommand(commandLine) runs a whole command line (a program, in double quotes when its name has a blank, then its arguments) and returns the id too. ShellRunDialog$(history) shows DVX's Run dialog offering history (earlier commands, newest first, one per line) and returns what the user chose, "" for Cancel. ShellOpenDocument path opens a document in the program set for its type in the Control Panel.
     
     Installed programs. ShellProgramScan(folder, skipPath) finds every program in folder and its subfolders ("" for the APPS folder) and returns a list: ShellProgramCount(list) programs, ShellProgramPath$(list, index) each one's path (index from 0), ShellProgramFree list when done (a list not freed is freed when the program ends). Give ShellAppPath$() as skipPath to leave this program out. ShellProgramName$(path) and ShellProgramDescription$(path) are the name and description a program shows, and ShellIsProgram(path) tells a program from a document. LOADPICTURE of a program gives its icon, so a PictureBox can show a grid of programs with PaintPicture. ShellProgramsFolder$() is the folder the installed programs are in (where ShellProgramScan("") looks), for example to start a Browse for a program there with DlgFileOpenIn$.
     
    @@ -5491,7 +5672,7 @@ End Sub
     
     With pkg.bas in the project, a program installs, removes, and makes add-on packages -- the .DPK files Software installs -- the way Software and Package Builder (both written in BASIC) do. PkgOpen() starts a session (a number; PkgClose ends it), and PkgError$(session) says why the last call that failed did. Lists come back as rows for a ListView's SetRows: cells separated by TAB, each row ending in a line feed.
     
    -What is installed. PkgInstalledRows$ lists every installed package (DVX itself is kpunch.dvx): id, name, version, kind, and summary. A package is named by its id, vendor.name (acme.chess). PkgField$(s, id, field) gives one detail (PKG_FIELD_NAME, PKG_FIELD_VERSION, PKG_FIELD_PUBLISHER, PKG_FIELD_SIGNER_NAME, ...); PkgFilesRows$, PkgDependsRows$, and PkgNeededBy$ its files, what it needs, and what needs it.
    +What is installed. PkgInstalledRows$ lists every installed package (DVX itself is kpunch.dvx): id, name, version, kind, and summary. A package is named by its id, vendor.name (acme.chess). PkgField$(s, id, field) gives one detail (PKG_FIELD_NAME, PKG_FIELD_VERSION, PKG_FIELD_PUBLISHER, PKG_FIELD_SIGNER_NAME, ...); PkgFilesRows$, PkgDependsRows$, and PkgNeededBy$ its files, what it needs, and what needs it. PkgFileOwner$(s, fileName) is the other way: the id of the package that put a file there (its name from the DVX folder, such as "SHARED\BANKS\KPUNCH\DMXOPL.OP2"; "" when none did).
     
     Installing. PkgOfferFile(s, path) reads a package file and every package beside it (what it may need) as offers, numbered from 0, and returns the number of the one at path (-1 when it cannot be read). PkgOfferField$ and PkgOfferFilesRows$ describe an offer before it is installed; PKG_FIELD_TRUST says whether DVX trusts who signed it. PkgPlan(s, "0") plans installing it with everything it needs, checked against this DVX and the packages installed (False, with PkgError$ saying why, when it cannot be installed); PkgPlanRows$ lists what will go in, PkgPlanBytes the disk space, and PkgPlanRestart whether libraries or widgets are among them (they work from the next time DVX starts). PkgInstallStart(s) then starts the job, and PkgRunFor(s, milliseconds) runs it a little at a time, returning PKG_RUN_BUSY until it is PKG_RUN_DONE or PKG_RUN_FAILED (PkgRunMessage$ says why); PkgRunPercent and PkgRunCurrent$ show how far it is.
     
    @@ -5572,6 +5753,8 @@ Colors: the system colors are numbered from 0 to SysColorCount() - 1. SysColorLa
     
     Wallpaper: SysSetWallpaper(path) shows a BMP, JPG, PNG, or GIF image behind the desktop ("" for none) and returns False when the image could not be loaded (the old one stays). SysSetWallpaperMode mode says how it fills the screen, a choice of PREFS_CHOICE_WALLPAPER (Stretch, Tile, or Center; see Settings Files). SysWallpaper$() and SysWallpaperMode() tell what is in use.
     
    +Screen saver: SysSetScreenSaver fileName, minutes chooses the screen saver -- a program in the SAVERS folders, named as FileOpsKindList(FILEOPS_KIND_SAVERS) lists it, or "" for none -- and the minutes with no key or mouse input before it starts (SCREEN_SAVER_WAIT_MIN to SCREEN_SAVER_WAIT_MAX; DVX's default is SCREEN_SAVER_WAIT_DEFAULT). SysScreenSaver$() and SysScreenSaverWait() tell what is in use, SysScreenSaverStart() starts it now as Control Panel's Test does (False when none is chosen or one is running), and SysScreenSaverRunning() is True while one runs. SysIdleSeconds() is how long it has been since the last key or mouse input anywhere on the screen.
    +
     Text: SysSetTextSmoothing True smooths the edges of text and SysSetTextSmoothing False stops it; SysTextSmoothing() tells which the user chose. Text is smoothed only when the screen shows more than 256 colors, and only in the fonts and sizes that come smoothed. Every window is drawn again when it changes.
     
     Mouse: SysSetMouse wheelDir, dblClickMs, accel, mickeys, wheelStep applies a whole mouse setup at once: the scroll wheel's direction (1 or -1, the values of PREFS_CHOICE_WHEEL), the double-click time in milliseconds, the acceleration (a value of PREFS_CHOICE_ACCEL), how far the mouse moves for 8 pixels of pointer movement (8 is normal, higher is slower), and the lines scrolled per wheel notch (1 to 10). An accel or mickeys of 0 leaves that one as it is. SysMouseWheelDir, SysMouseDoubleClick, SysMouseAccel, SysMouseMickeys, and SysMouseWheelStep read the setup in effect. MOUSE_WHEEL_STEP_MIN and MOUSE_WHEEL_STEP_MAX are the limits of the wheel step, and MOUSE_WHEEL_STEP_DEFAULT, MOUSE_DBLCLICK_DEFAULT_MS, and MOUSE_SPEED_DEFAULT are DVX's defaults (what a Default button puts back).
    @@ -5582,7 +5765,7 @@ Memory: SysMemoryTotalKb() is the computer's memory in KB and SysMemoryFreeKb()
     
     System font: SysFontWidth() and SysFontHeight() are the size of every character of the system font in pixels, and SysFontFirstChar() to SysFontFirstChar() + SysFontCharCount() - 1 the character codes it has. SysFontGlyphRow(code, row) is one row of a character's picture: a bit per pixel, &H80 the leftmost -- for drawing text dot by dot, enlarged or in a font editor of your own.
     
    -Saving: SysColorsStore, SysWallpaperStore, SysFontsStore, and SysMouseStore write the colors, the wallpaper, text smoothing, and the mouse setup into a prefs.bas handle, in the sections DVX reads when it starts; PrefsSetVideoMode does the same for a screen mode. Store them in PrefsLoad(PrefsDvxIni$()) and save it, and DVX starts with them next time. SysColorsLoad, SysWallpaperLoad, SysFontsLoad, and SysMouseLoad apply what a handle holds. A PrefsCreate handle filled with the Store calls is a snapshot in memory: load it again to undo every change, the way Cancel works in Control Panel.
    +Saving: SysColorsStore, SysWallpaperStore, SysScreenSaverStore, SysFontsStore, and SysMouseStore write the colors, the wallpaper, the screen saver, text smoothing, and the mouse setup into a prefs.bas handle, in the sections DVX reads when it starts; PrefsSetVideoMode does the same for a screen mode. Store them in PrefsLoad(PrefsDvxIni$()) and save it, and DVX starts with them next time. SysColorsLoad, SysWallpaperLoad, SysScreenSaverLoad, SysFontsLoad, and SysMouseLoad apply what a handle holds. A PrefsCreate handle filled with the Store calls is a snapshot in memory: load it again to undo every change, the way Cancel works in Control Panel.
     
     .code
     Dim snap As Long
    @@ -5758,9 +5941,9 @@ End Sub
     
     WAV files must be uncompressed (8 or 16 bits, mono or stereo); VOC files may be 8- or 16-bit. QBasic's BEEP, SOUND, and PLAY work too, without sound.bas.
     
    -PlayTriangle(frequency, milliseconds, volume) plays a triangle tone, softer than PlayTone's square tone. SoundGetVolume() gives the volume of everything the program plays. SoundCaps(field) tells what the sound device can do, by name in any case: "dac" (it plays recorded sound), "synth" (it has a music synthesizer), "tone" (it plays tones on a tone generator), and "running" are True or False; "voices" (how many sounds play at once), "rate" (hertz), "bits", and "channels" are numbers. SoundPlayEvent eventIdx, wait plays one of DVX's own event sounds (SOUND_EVENT_DEFAULT, SOUND_EVENT_ERROR, SOUND_EVENT_WARNING, ...), the one the settings choose; with wait True it returns once the sound has played.
    +PlayTriangle(frequency, milliseconds, volume) plays a triangle tone, softer than PlayTone's square tone. SoundGetVolume() gives the volume of everything the program plays. SoundCaps(field) tells what the sound device can do, by name in any case: "dac" (it plays recorded sound), "synth" (it has a music synthesizer), "tone" (it plays tones on a tone generator), "running", and "record" (it records sound) are True or False; "voices" (how many sounds play at once), "rate" (hertz), "bits", "channels", and "recordsources" (the inputs it records from) are numbers. SoundPlayEvent eventIdx, wait plays one of DVX's own event sounds (SOUND_EVENT_DEFAULT, SOUND_EVENT_ERROR, SOUND_EVENT_WARNING, ...), the one the settings choose; with wait True it returns once the sound has played.
     
    -A sound played often is best loaded once. SoundLoad(fileName) loads a WAV or VOC file and returns a sound number, or 0 (SoundError$() says why); SoundCreate(data, rate, bits, channels) makes a sound from raw sound data in a string: 8-bit samples (0 to 255, 128 is silence) or 16-bit ones (two bytes each, the low byte first), mono or stereo (left, right, left, ...), rate times a second (SOUND_RATE_MIN to SOUND_RATE_MAX). SoundPlay(sound, volume, pan, loopIt) plays it on a new voice and returns the voice number; pan goes from SOUND_PAN_LEFT (-100) through SOUND_PAN_CENTER (0) to SOUND_PAN_RIGHT (100). SoundLength(sound) is its length in milliseconds and SoundFree sound stops its voices and forgets it. While a voice plays, SoundSetVolume voice, volume, SoundSetPan voice, pan, and SoundSetPitch voice, percent change it (pitch is a percent of the recorded speed: SOUND_PITCH_NORMAL is 100, 200 is an octave higher, from SOUND_PITCH_MIN 25 to SOUND_PITCH_MAX 400), and SoundSetFrequency voice, hertz changes a tone's frequency. A program's sounds are freed when it ends.
    +A sound played often is best loaded once. SoundLoad(fileName) loads a WAV or VOC file and returns a sound number, or 0 (SoundError$() says why); SoundCreate(data, rate, bits, channels) makes a sound from raw sound data in a string: 8-bit samples (0 to 255, 128 is silence) or 16-bit ones (two bytes each, the low byte first), mono or stereo (left, right, left, ...), rate times a second (SOUND_RATE_MIN to SOUND_RATE_MAX). SoundPlay(sound, volume, pan, loopIt) plays it on a new voice and returns the voice number; pan goes from SOUND_PAN_LEFT (-100) through SOUND_PAN_CENTER (0) to SOUND_PAN_RIGHT (100). SoundLength(sound) is its length in milliseconds and SoundFree sound stops its voices and forgets it. While a voice plays, SoundSetVolume voice, volume, SoundSetPan voice, pan, and SoundSetPitch voice, percent change it (pitch is a percent of the recorded speed: SOUND_PITCH_NORMAL is 100, 200 is an octave higher, from SOUND_PITCH_MIN 25 to SOUND_PITCH_MAX 400), and SoundSetFrequency voice, hertz changes a tone's frequency. SoundPause voice holds a sound's voice where it is, silent, and SoundResume voice goes on from there (a tone cannot be paused). A program's sounds are freed when it ends.
     
     .code
     Dim Shared laser As Long
    @@ -5776,7 +5959,7 @@ Sub Fire (x As Integer)
     End Sub
     .endcode
     
    -A program can also make its sound as it goes, for speech or music of its own. SoundStreamOpen(rate, bits, channels, bufferMs) opens a stream that starts playing at once (silence until data arrives) and holds bufferMs milliseconds of sound (0 for the usual SOUND_STREAM_MS_DEFAULT; SOUND_STREAM_MS_MIN to SOUND_STREAM_MS_MAX). SoundStreamWrite(stream, data) adds raw sound data in the stream's format and returns how many bytes it took; keep the rest and write it again later. SoundStreamSpace(stream) is the room left and SoundStreamQueued(stream) what is waiting to play, both in samples (a stereo sample counts once). SoundStreamVoice(stream) is the stream's voice, for SoundSetVolume, SoundSetPan, and SoundSetPitch, and SoundStreamClose stream stops it. A Timer keeps a stream fed:
    +A program can also make its sound as it goes, for speech or music of its own. SoundStreamOpen(rate, bits, channels, bufferMs) opens a stream that starts playing at once (silence until data arrives) and holds bufferMs milliseconds of sound (0 for the usual SOUND_STREAM_MS_DEFAULT; SOUND_STREAM_MS_MIN to SOUND_STREAM_MS_MAX). SoundStreamWrite(stream, data) adds raw sound data in the stream's format and returns how many bytes it took; keep the rest and write it again later. SoundStreamSpace(stream) is the room left and SoundStreamQueued(stream) what is waiting to play, both in samples (a stereo sample counts once). SoundStreamVoice(stream) is the stream's voice, for SoundSetVolume, SoundSetPan, SoundSetPitch, SoundPause, and SoundResume. SoundStreamFlush stream drops what is waiting to play, so what is written next is heard at once (to jump to another place in what you play), and SoundStreamClose stream stops it. A Timer keeps a stream fed:
     
     .code
     Sub Timer1_Timer ()
    @@ -5787,9 +5970,20 @@ Sub Timer1_Timer ()
     End Sub
     .endcode
     
    +A program can record sound from the sound card's microphone, line, or CD input into a WAV file. SoundCaps("record") says whether the card can, and SoundCaps("recordsources") which inputs it has (the bit 2 ^ RECORD_MIC, RECORD_LINE, or RECORD_CD set for each). RecordStart(fileName, rate, bits, channels, inputSource) starts recording (0 for 22,050 Hz, 16-bit, mono) and returns True, or False with SoundError$ saying why; the card records at its nearest, which RecordRate(), RecordBits(), and RecordChannels() tell. While it records, RecordLength() is how long the recording is (milliseconds) and RecordLevel() the loudest sound since it was last asked (percent: a level meter); RecordLost() counts samples dropped because DVX was too busy. RecordStop() ends it and returns True when the file was all written; Recording() is True in between. Most sound cards record or play, not both, so nothing plays while a program records, and one program records at a time. A program's recording ends when the program does.
    +
    +.code
    +If RecordStart("NOTE.WAV", 0, 0, 0, RECORD_MIC) Then
    +    ' ... a Timer shows RecordLength() and RecordLevel()
    +    ok = RecordStop()
    +End If
    +.endcode
    +
     On a computer with an FM synthesizer chip (AdLib and most Sound Blasters), a program can play instruments of its own. FmInfo$(field) describes the chip: "description" (like "OPL3 FM synthesizer at 388h", "" when there is none), "voices", "free" (voices no program has), "stereo", and "owned" (one program has the whole chip). FmAlloc() takes one of its two-operator voices (0 when none is free), FmWrite(voice, register, value) sets one of the voice's registers (the FM_ constants: FM_MOD_CHAR, FM_CAR_LEVEL, FM_FNUM_LOW, FM_KEY_BLOCK with FM_KEY_ON, ...; True when it worked), and FmFree voice gives it back. A player of chip music files can take the whole chip with FmRawAcquire() (False while another program has it), set any register with FmRawWrite(register, value) (bank * FM_BANK_REGS + register; the second bank only on an OPL3) and give it back with FmRawRelease. A frequency number for FM_FNUM_LOW and FM_KEY_BLOCK is hertz * 2 ^ 20 / (FM_CHIP_HZ * 2 ^ block).
     
    -sound.bas also changes DVX's own sound settings, as the Control Panel's Sounds page does. DVX plays a sound for each of SoundEventCount() events, numbered from 0: SoundEventLabel$(eventIdx) names one ("Start DVX", "Error", ...) and SoundEventKey$(eventIdx) gives its key in DVX.INI. With a settings handle from prefs.bas (PrefsLoad(PrefsDvxIni$())), SoundEventFile$(handle, eventIdx) is the event's sound file ("" for none) and SoundSetEventFile handle, eventIdx, fileName changes it; after the program saves the file, SoundEventsReload makes DVX play the new sounds. SoundTestFile(fileName) plays a file the way DVX plays its event sounds (0 when it cannot, and SoundError$() says why), and SoundFolder$() is the folder of DVX's own sounds. SoundMasterVolume() and SoundSetMasterVolume percent are the volume of all sound from every program. SoundDriverCount() reads the list of sound drivers and SoundDriverName$(index) names each; SoundDriverAuto$() is the setting that means "find the best one". SoundMidiCount() and SoundMidiLabel$(index) list where MIDI music can play. SoundSettingDriver$(handle), SoundSettingMidi(handle), and SoundSettingVolume(handle) read the sound driver, MIDI choice, and master volume from the settings, and SoundSettingsStore handle, driver, midi, volume stores them (a new driver or MIDI choice takes effect when DVX next starts).
    +On a wavetable sound card (a Gravis UltraSound, a Sound Blaster AWE32 or AWE64), a program can play recorded sounds on the card's own voices from the card's memory. WtInfo$(field) describes the card: "description" ("" when there is none), "voices", "free" (voices no program has), "memory" and "freememory" (bytes), "level" (how many centibels louder a voice at full volume is than the loudest recorded sound the card plays: 0 when it is the same or not known), and what the card does itself ("envelopes", "lfos", "filter", "effects", "8bit", "rom"; True or False as text). WtMemAlloc(bytes, bits) takes memory for samples of 8 or 16 bits (its address, -1 when there is no room), WtUpload(address, pcm, bits) copies 16-bit mono samples there (a binary string, two bytes a sample; True when it worked), and WtMemFree address gives it back. WtAlloc() takes a voice (0 when none is free); WtStart(voice, address, startFrame, endFrame, loopStart, loopEnd, loopMode, bits, pitch, attenuation, pan) plays a sample on it from startFrame (0: its first; loopMode 0 plays once, 1 loops, 3 loops until WtRelease; pitch in cents, 0 for 44,100 samples a second and 1200 an octave higher; attenuation in centibels, 0 loudest and 60 half as loud; pan -100 to 100). WtSetPitch, WtSetAttenuation, and WtSetPan change a playing voice, WtStop silences it, WtActive(voice) is True while it sounds, and WtFree voice gives it back. Everything a program took is given back when it ends.
    +
    +sound.bas also changes DVX's own sound settings, as the Control Panel's Sounds page does. DVX plays a sound for each of SoundEventCount() events, numbered from 0: SoundEventLabel$(eventIdx) names one ("Start DVX", "Error", ...) and SoundEventKey$(eventIdx) gives its key in DVX.INI. With a settings handle from prefs.bas (PrefsLoad(PrefsDvxIni$())), SoundEventFile$(handle, eventIdx) is the event's sound file ("" for none) and SoundSetEventFile handle, eventIdx, fileName changes it; after the program saves the file, SoundEventsReload makes DVX play the new sounds. SoundTestFile(fileName) plays a file the way DVX plays its event sounds (0 when it cannot, and SoundError$() says why), and SoundFolder$() is the folder of DVX's own sounds. SoundMasterVolume() and SoundSetMasterVolume percent are the volume of all sound from every program. SoundDriverCount() reads the list of sound drivers and SoundDriverName$(index) names each; SoundDriverAuto$() is the setting that means "find the best one". SoundMidiCount() and SoundMidiLabel$(index) list where MIDI music can play, SoundMidiInCount() and SoundMidiInLabel$(index) where MIDI input can come from, and SoundModulesCount() and SoundModulesLabel$(index) where music modules can play. SoundSettingDriver$(handle), SoundSettingMidi(handle), SoundSettingModules(handle), and SoundSettingVolume(handle) read the sound driver, MIDI choice, modules choice, and master volume from the settings, and SoundSettingsStore handle, driver, midi, modules, volume stores them (a new driver or MIDI choice takes effect when DVX next starts, a modules choice with the next music); SoundSettingMidiIn(handle) and SoundSettingMidiInStore handle, midiIn read and store the MIDI input choice (it takes effect when DVX next starts).
     
     .code
     Dim ini As Long
    @@ -5806,10 +6000,18 @@ Next i
     
     .h2 Music
     
    -With music.bas in the project, a program plays music files: tracker modules (MOD, S3M, XM, and IT files, made of recorded instrument sounds) wherever the computer can play sampled sound, and MIDI files (.MID, types 0 and 1) on the computer's music synthesizer, or on a built-in synthesizer that sounds much the same when there is none. Several programs can play music at once, and everything a program plays stops when it ends.
    +With music.bas in the project, a program plays music files: tracker modules (MOD, S3M, XM, and IT files, made of recorded instrument sounds) wherever the computer can play sampled sound (on a Gravis UltraSound's or Sound Blaster AWE's own voices, which takes the computer little time), MIDI files (.MID, types 0 and 1) on the computer's music synthesizer, or on a built-in synthesizer that sounds much the same when there is none, and WAV and VOC sound files wherever the computer can play sampled sound (a long WAV recording is read from the disk as it plays, so it needs no memory). Several programs can play music at once, and everything a program plays stops when it ends.
     
     MusicLoad(fileName) loads a music file and returns its music number, or MUSIC_FAILED (0) when it could not be loaded; MusicError$() then says why. MusicPlay(music, loopIt) plays it from where it is (the start after loading or stopping); with loopIt True it starts over at the end until it is stopped. MusicStop(music) stops and goes back to the start, MusicPause(music) and MusicResume(music) pause and go on, and MusicPlaying(music) is True while it plays. MusicVolume(music, volume) sets its volume in percent (0 to 100). MusicPosition(music) and MusicLength(music) give the place and the length in milliseconds, and MusicSeek(music, milliseconds) goes to a place (a module goes to the start of the part that holds it). MusicFree(music) stops a music and forgets it.
     
    +MusicLoadMacro(macro) makes a music from a music macro string, the kind PLAY takes, with its notes on the General MIDI instruments its I commands name (the piano before any I): the tune can then be played in the background, paused, stopped, and made quieter like any music. MIDI music plays on the instruments chosen in Control Panel; MusicSetBank music, bank plays one music on another instrument bank, named as in the banks folders ("" is DVX's standard bank), before the music first plays (MusicError$ says when the bank is missing or damaged). A SoundFont (.SF2 or .SBK) gives the recorded instruments for a wavetable card or the built-in SoundFont synthesizer, as can a Gravis UltraSound's own patches: MusicPatchSet$() names them (their ULTRASND.INI, "" when there are none), and MusicRomBank$() names the instruments built into a Sound Blaster AWE card ("" when Creative's software is not installed).
    +
    +.code
    +tune = MusicLoadMacro("T150 I41 O4 L8 CEG>C4")
    +MusicSetBank tune, "KPUNCH\DMXOPL.OP2"
    +MusicPlay tune, False
    +.endcode
    +
     .code
     Dim Shared tune As Long
     
    @@ -5845,9 +6047,50 @@ Sub tmrNoteOff_Timer ()
     End Sub
     .endcode
     
    -MusicGetVolume(music) gives a music's volume in percent. MusicInfo$(music, field) tells about it, by name in any case: "title" (a module's own title), "output" (where it plays, for people), "kind" ("module", "midi", or "live"), "where" ("stream", "mpu401", "fm", or "software"; "" before it first plays), and "paused" and "looping" (True or False as text; VAL gives the value).
    +MusicGetVolume(music) gives a music's volume in percent. MusicInfo$(music, field) tells about it, by name in any case: "title" (a module's own title, or the name a MIDI file gives itself; "" for a sound file), "output" (where it plays, for people), "kind" ("module", "midi", "live", or "sound"), "where" ("stream", "mpu401", "fm", "software", "wave", or "softwave"; "wave" is a wavetable card's voices, for a module too; "" before it first plays), and "paused" and "looping" (True or False as text; VAL gives the value).
     
    -A program that sends MIDI itself (system exclusive messages, a sequencer of its own) can have the MIDI port, one program at a time, when the settings send MIDI music to the MIDI port. MidiPortInfo$(field) describes it: "description" ("" when there is none), "present", and "owned" (a program has it). MidiAcquire() takes the port (False when another program has it or there is none), MidiPut(byteValue) sends one byte (False when the port could not take it yet: send it again), and MidiRelease gives the port back.
    +A program that sends MIDI itself (system exclusive messages, a sequencer of its own) can have the MIDI port, one program at a time, when the settings send MIDI music to the MIDI port. MidiPortInfo$(field) describes it: "description" ("" when there is none), "present", "owned" (a program has it), "input" (it brings MIDI in), and "inputowned" (a program reads what comes in). MidiAcquire() takes the port (False when another program has it or there is none), MidiPut(byteValue) sends one byte (False when the port could not take it yet: send it again), and MidiRelease gives the port back.
    +
    +When the settings take MIDI input from the MIDI port (Control Panel, Sounds, MIDI in), a program can read what a MIDI keyboard or another instrument plays. MidiInOpen() starts reading (False when there is no MIDI input or another program reads it; SoundError$() says which) and MidiInClose stops; a program's reading stops when it ends. MidiInMessage() gives the next message that came, or MIDI_NONE (-1) when none waits. A message is packed into one number: the status is midiMsg AND 255 (its kind, midiMsg AND MIDI_KIND_MASK, is MIDI_NOTE_ON, MIDI_NOTE_OFF, MIDI_CONTROL, MIDI_PROGRAM, MIDI_PITCH_BEND, MIDI_KEY_PRESSURE, or MIDI_CHANNEL_PRESSURE, and its channel, numbered as MusicNoteOn numbers them, is (midiMsg AND MIDI_CHANNEL_MASK) + 1), the first data byte (the note) is (midiMsg \ 256) AND 255, and the second (the velocity) (midiMsg \ 65536) AND 255. A note on with a velocity of 0 is a note off too. MidiInTime() is when the message MidiInMessage gave last came and MidiInClock() the time now, both in milliseconds since MidiInOpen; MidiInLost() counts bytes lost because the program read too late. MusicMidiSend music, midiMsg plays a message as it came on a live music (MIDI thru), so what is played is heard.
    +
    +A MIDI recording keeps messages with their times and saves them as a MIDI file that MusicLoad plays. MidiRecordNew() returns a recording number (0 when there is no room; MusicError$() says why), MidiRecordAdd recording, milliseconds, midiMsg adds a message (MidiInTime() is the time to give, when the recording started with MidiInOpen), MidiRecordCount(recording) and MidiRecordLength(recording) tell how many messages it has and when the last came, MidiRecordSave(recording, fileName) writes the file (False when it could not be written; MusicError$() says why; a note still held at the end is let go), and MidiRecordFree recording frees it. A program's recordings are freed when it ends. The Sound Recorder records a MIDI keyboard this way:
    +
    +.code
    +Dim Shared take As Long
    +Dim Shared keys As Long
    +
    +Sub cmdRecord_Click ()
    +    If MidiInOpen() Then
    +        take = MidiRecordNew()
    +        keys = MusicMidiOpen()
    +        tmrTake.Enabled = True
    +    End If
    +End Sub
    +
    +Sub tmrTake_Timer ()
    +    Dim midiMsg As Long
    +
    +    midiMsg = MidiInMessage()
    +
    +    Do While midiMsg <> MIDI_NONE
    +        MusicMidiSend keys, midiMsg
    +        MidiRecordAdd take, MidiInTime(), midiMsg
    +        midiMsg = MidiInMessage()
    +    Loop
    +End Sub
    +
    +Sub cmdStop_Click ()
    +    tmrTake.Enabled = False
    +    MidiInClose
    +    MusicFree keys
    +
    +    If Not MidiRecordSave(take, "SONG.MID") Then
    +        Print "Not saved: " & MusicError$()
    +    End If
    +
    +    MidiRecordFree take
    +End Sub
    +.endcode
     
     .h2 Network Connections
     
    diff --git a/src/apps/kpunch/dvxbasic/runtime/gfx.c b/src/apps/kpunch/dvxbasic/runtime/gfx.c
    index 5d2376db..9bd60a35 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/gfx.c
    +++ b/src/apps/kpunch/dvxbasic/runtime/gfx.c
    @@ -43,6 +43,7 @@
     #define GFX_STYLE_SOLID    0xFFFF        // LINE style: every pixel
     #define GFX_STYLE_TOP_BIT  0x8000
     #define GFX_STYLE_BITS     16
    +#define GFX_STYLE_NONE     0x0000        // LINE style: no pixel
     #define GFX_COORD_LIMIT    1.0e9         // pixel coordinates are clamped to this
     #define GFX_RADIUS_MAX     32767.0       // largest CIRCLE radius in pixels (QBasic's integer range)
     #define GFX_ROUND_HALF     0.5
    @@ -71,6 +72,33 @@ typedef struct {
         uint8_t           *done;     // tile fill: one bit per view pixel
     } GfxFloodT;
     
    +// An ellipse outline (CIRCLE): its centre and radii in pixels, the arc
    +// (radians, counterclockwise as the screen shows it) when arc, and the
    +// pen's dashes along it.
    +typedef struct {
    +    int32_t  cx;
    +    int32_t  cy;
    +    int32_t  rx;
    +    int32_t  ry;
    +    uint32_t pixel;
    +    bool     arc;
    +    double   start;
    +    double   end;
    +    uint16_t dash;
    +} GfxEllipseT;
    +
    +// The LINE style of each DrawStyle: dash, dot, dash-dot, and
    +// dash-dot-dot as runs along the pen's path; invisible draws nothing
    +static const uint16_t sDrawDashes[BAS_GFX_DRAW_MAX + 1] = {
    +    [BAS_GFX_DRAW_SOLID]         = GFX_STYLE_SOLID,
    +    [BAS_GFX_DRAW_DASH]          = 0xFFF0,
    +    [BAS_GFX_DRAW_DOT]           = 0xCCCC,
    +    [BAS_GFX_DRAW_DASH_DOT]      = 0xFF18,
    +    [BAS_GFX_DRAW_DASH_DOT_DOT]  = 0xFCCC,
    +    [BAS_GFX_DRAW_INVISIBLE]     = GFX_STYLE_NONE,
    +    [BAS_GFX_DRAW_INSIDE_SOLID]  = GFX_STYLE_SOLID
    +};
    +
     // Unit steps of DRAW's U D L R E F G H (screen y grows downwards).
     typedef struct {
         char    letter;
    @@ -102,9 +130,9 @@ static void     boxOutline(BasGfxT *g, int32_t x0, int32_t y0, int32_t x1, int32
     static bool     clipLine(const BasGfxT *g, double *x0, double *y0, double *x1, double *y1);
     static int32_t  drawMacro(BasGfxT *g, const char *macro, int32_t len, double *px, double *py, int32_t depth);
     static int32_t  drawNumber(const char *s, int32_t len, int32_t *pos, bool allowSign, int32_t *out);
    -static void     ellipse(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, bool arc, double start, double end);
    +static void     ellipse(BasGfxT *g, const GfxEllipseT *e);
     static void     ellipseFill(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel);
    -static void     ellipsePoint(BasGfxT *g, int32_t cx, int32_t cy, int32_t dx, int32_t dy, int32_t rx, int32_t ry, uint32_t pixel, bool arc, double start, double end);
    +static void     ellipsePoint(BasGfxT *g, const GfxEllipseT *e, int32_t dx, int32_t dy);
     static bool     fillable(const GfxFloodT *f, int32_t x, int32_t y);
     static void     fillRect(BasGfxT *g, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t pixel, int32_t style);
     static void     fillSpan(BasGfxT *g, int32_t y, int32_t x0, int32_t x1, uint32_t pixel, int32_t style);
    @@ -118,6 +146,8 @@ static double   mapX(const BasGfxT *g, double x);
     static double   mapY(const BasGfxT *g, double y);
     static int32_t  originX(const BasGfxT *g);
     static int32_t  originY(const BasGfxT *g);
    +static uint16_t penDashes(const BasGfxT *g);
    +static int32_t  penSize(const BasGfxT *g);
     static void     plot(BasGfxT *g, int32_t x, int32_t y, uint32_t pixel);
     static void     plotPixel(BasGfxT *g, int32_t x, int32_t y, uint32_t pixel);
     static void     pointArg(const BasGfxT *g, BasValueT x, BasValueT y, bool step, double *outX, double *outY);
    @@ -175,30 +205,36 @@ int32_t basGfxCircle(BasGfxT *g, const BasValueT *args) {
             return BAS_ERR_OVERFLOW;
         }
     
    -    int32_t  cx    = basGfxRound(mapX(g, x));
    -    int32_t  cy    = basGfxRound(mapY(g, y));
    -    int32_t  irx   = basGfxRound(rx);
    -    int32_t  iry   = basGfxRound(ry);
    -    bool     arc   = (flags & (BAS_GFX_ARC_START | BAS_GFX_ARC_END)) != 0;
    -    uint32_t pixel = basGfxPixel(g, color);
    +    GfxEllipseT e;
    +
    +    e.cx    = basGfxRound(mapX(g, x));
    +    e.cy    = basGfxRound(mapY(g, y));
    +    e.rx    = basGfxRound(rx);
    +    e.ry    = basGfxRound(ry);
    +    e.pixel = basGfxPixel(g, color);
    +    e.arc   = (flags & (BAS_GFX_ARC_START | BAS_GFX_ARC_END)) != 0;
    +    e.start = fabs(start);
    +    e.end   = fabs(end);
    +    e.dash  = penDashes(g);
     
         g->lastX = x;
         g->lastY = y;
     
    -    if (!arc && shapeFilled(g)) {
    -        ellipseFill(g, cx, cy, irx, iry, basGfxPixel(g, g->fillColor));
    +    if (!e.arc && shapeFilled(g)) {
    +        ellipseFill(g, e.cx, e.cy, e.rx, e.ry, basGfxPixel(g, g->fillColor));
         }
     
    -    ellipse(g, cx, cy, irx, iry, pixel, arc, fabs(start), fabs(end));
    +    ellipse(g, &e);
     
         // A negative angle also draws the radius to that end of the arc.
         double ends[] = { start, end };
     
         for (int32_t i = 0; i < (int32_t)(sizeof(ends) / sizeof(ends[0])); i++) {
             if (ends[i] < 0.0) {
    -            double a = -ends[i];
    +            double   a     = -ends[i];
    +            uint16_t style = e.dash;
     
    -            linePixels(g, cx, cy, cx + irx * cos(a), cy - iry * sin(a), pixel, NULL);
    +            linePixels(g, e.cx, e.cy, e.cx + e.rx * cos(a), e.cy - e.ry * sin(a), e.pixel, style == GFX_STYLE_SOLID ? NULL : &style);
             }
         }
     
    @@ -316,7 +352,7 @@ int32_t basGfxLine(BasGfxT *g, const BasValueT *args) {
         double   x2    = 0.0;
         double   y2    = 0.0;
         uint32_t color = g->fore;
    -    uint16_t style = GFX_STYLE_SOLID;
    +    uint16_t style = penDashes(g);
     
         if (flags & BAS_GFX_START) {
             pointArg(g, args[0], args[1], (flags & BAS_GFX_STEP) != 0, &x1, &y1);
    @@ -358,11 +394,11 @@ int32_t basGfxLine(BasGfxT *g, const BasValueT *args) {
                 fillRect(g, px1, py1, px2, py2, basGfxPixel(g, g->fillColor), g->fillStyle);
             }
     
    -        boxOutline(g, px1, py1, px2, py2, pixel, (flags & BAS_GFX_STYLE) ? &style : NULL);
    +        boxOutline(g, px1, py1, px2, py2, pixel, style == GFX_STYLE_SOLID ? NULL : &style);
             return 0;
         }
     
    -    linePixels(g, mapX(g, x1), mapY(g, y1), mapX(g, x2), mapY(g, y2), pixel, (flags & BAS_GFX_STYLE) ? &style : NULL);
    +    linePixels(g, mapX(g, x1), mapY(g, y1), mapX(g, x2), mapY(g, y2), pixel, style == GFX_STYLE_SOLID ? NULL : &style);
         return 0;
     }
     
    @@ -1025,23 +1061,35 @@ static int32_t drawNumber(const char *s, int32_t len, int32_t *pos, bool allowSi
     
     
     // Ellipse outline by the midpoint algorithm (rx, ry in pixels), each point
    -// once per quadrant; an arc keeps only the points between start and end.
    -static void ellipse(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, bool arc, double start, double end) {
    -    if (rx == 0 || ry == 0) {
    -        linePixels(g, cx - rx, cy - ry, cx + rx, cy + ry, pixel, NULL);
    +// once per quadrant; an arc keeps only the points between start and end,
    +// and dashes the points their bits leave out.
    +static void ellipse(BasGfxT *g, const GfxEllipseT *e) {
    +    if (e->dash == GFX_STYLE_NONE) {
             return;
         }
     
    -    double  rx2 = (double)rx * rx;
    -    double  ry2 = (double)ry * ry;
    +    if (g->surf.vec && e->rx > 0 && e->ry > 0) {
    +        g->surf.vec->ellipse(g->surf.ctx, e->cx, e->cy, e->rx, e->ry, e->pixel, penSize(g), e->dash, e->arc, e->start, e->end);
    +        return;
    +    }
    +
    +    if (e->rx == 0 || e->ry == 0) {
    +        uint16_t style = e->dash;
    +
    +        linePixels(g, e->cx - e->rx, e->cy - e->ry, e->cx + e->rx, e->cy + e->ry, e->pixel, style == GFX_STYLE_SOLID ? NULL : &style);
    +        return;
    +    }
    +
    +    double  rx2 = (double)e->rx * e->rx;
    +    double  ry2 = (double)e->ry * e->ry;
         int32_t x   = 0;
    -    int32_t y   = ry;
    +    int32_t y   = e->ry;
         double  dx  = 0.0;
         double  dy  = 2.0 * rx2 * y;
    -    double  d1  = ry2 - rx2 * ry + 0.25 * rx2;
    +    double  d1  = ry2 - rx2 * e->ry + 0.25 * rx2;
     
         while (dx < dy) {
    -        ellipsePoint(g, cx, cy, x, y, rx, ry, pixel, arc, start, end);
    +        ellipsePoint(g, e, x, y);
             x++;
             dx += 2.0 * ry2;
     
    @@ -1057,7 +1105,7 @@ static void ellipse(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t ry,
         double d2 = ry2 * (x + GFX_ROUND_HALF) * (x + GFX_ROUND_HALF) + rx2 * (y - 1.0) * (y - 1.0) - rx2 * ry2;
     
         while (y >= 0) {
    -        ellipsePoint(g, cx, cy, x, y, rx, ry, pixel, arc, start, end);
    +        ellipsePoint(g, e, x, y);
             y--;
             dy -= 2.0 * rx2;
     
    @@ -1074,6 +1122,11 @@ static void ellipse(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t ry,
     
     // A filled ellipse: one span per row.
     static void ellipseFill(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel) {
    +    if (g->surf.vec) {
    +        g->surf.vec->ellipseFill(g->surf.ctx, cx, cy, rx, ry, pixel, g->fillStyle);
    +        return;
    +    }
    +
         for (int32_t dy = -ry; dy <= ry; dy++) {
             double  f    = ry > 0 ? 1.0 - ((double)dy * dy) / ((double)ry * ry) : 1.0;
             int32_t half = basGfxRound(rx * sqrt(f > 0.0 ? f : 0.0));
    @@ -1083,8 +1136,10 @@ static void ellipseFill(BasGfxT *g, int32_t cx, int32_t cy, int32_t rx, int32_t
     }
     
     
    -// The four mirror points of an ellipse point (dx, dy >= 0).
    -static void ellipsePoint(BasGfxT *g, int32_t cx, int32_t cy, int32_t dx, int32_t dy, int32_t rx, int32_t ry, uint32_t pixel, bool arc, double start, double end) {
    +// The four mirror points of an ellipse point (dx, dy >= 0): those on the
    +// arc, and on a dash (the pixels along the outline from angle 0, about
    +// the angle times the mean radius).
    +static void ellipsePoint(BasGfxT *g, const GfxEllipseT *e, int32_t dx, int32_t dy) {
         static const int32_t signs[][2] = { { 1, 1 }, { -1, 1 }, { -1, -1 }, { 1, -1 } };
     
         for (int32_t i = 0; i < (int32_t)(sizeof(signs) / sizeof(signs[0])); i++) {
    @@ -1096,19 +1151,25 @@ static void ellipsePoint(BasGfxT *g, int32_t cx, int32_t cy, int32_t dx, int32_t
                 continue;
             }
     
    -        if (arc) {
    -            double a = atan2((double)-sy / ry, (double)sx / rx);
    +        if (e->arc || e->dash != GFX_STYLE_SOLID) {
    +            double a = atan2((double)-sy / e->ry, (double)sx / e->rx);
     
                 if (a < 0.0) {
                     a += GFX_TWO_PI;
                 }
     
    -            if (!arcContains(a, start, end)) {
    +            if (e->arc && !arcContains(a, e->start, e->end)) {
    +                continue;
    +            }
    +
    +            int32_t step = (int32_t)(a * (e->rx + e->ry) / 2.0) % GFX_STYLE_BITS;
    +
    +            if (!(e->dash & (GFX_STYLE_TOP_BIT >> step))) {
                     continue;
                 }
             }
     
    -        plot(g, cx + sx, cy + sy, pixel);
    +        plot(g, e->cx + sx, e->cy + sy, e->pixel);
         }
     }
     
    @@ -1136,6 +1197,11 @@ static void fillRect(BasGfxT *g, int32_t x0, int32_t y0, int32_t x1, int32_t y1,
         int32_t top    = y0 < y1 ? y0 : y1;
         int32_t bottom = y0 < y1 ? y1 : y0;
     
    +    if (g->surf.vec) {
    +        g->surf.vec->rect(g->surf.ctx, x0, top, x1, bottom, pixel, style);
    +        return;
    +    }
    +
         top    = top < g->viewY0 ? g->viewY0 : top;
         bottom = bottom > g->viewY1 ? g->viewY1 : bottom;
     
    @@ -1325,6 +1391,21 @@ static void linePixels(BasGfxT *g, double x0, double y0, double x1, double y1, u
             return;
         }
     
    +    if (g->surf.vec) {
    +        g->surf.vec->line(g->surf.ctx, x0, y0, x1, y1, pixel, penSize(g), style ? *style : GFX_STYLE_SOLID);
    +
    +        // The style runs on past the line's pixels, as when it is plotted
    +        if (style) {
    +            int32_t ax    = abs(basGfxRound(x1) - basGfxRound(x0));
    +            int32_t ay    = abs(basGfxRound(y1) - basGfxRound(y0));
    +            int32_t steps = ((ax > ay ? ax : ay) + 1) % GFX_STYLE_BITS;
    +
    +            *style = (uint16_t)((*style << steps) | (*style >> ((GFX_STYLE_BITS - steps) % GFX_STYLE_BITS)));
    +        }
    +
    +        return;
    +    }
    +
         int32_t ax  = basGfxRound(x0);
         int32_t ay  = basGfxRound(y0);
         int32_t bx  = basGfxRound(x1);
    @@ -1396,10 +1477,31 @@ static int32_t originY(const BasGfxT *g) {
     }
     
     
    +// The pen's dashes as a LINE style: DrawStyle's, dashed only with a pen
    +// one pixel wide (as VB draws them).
    +static uint16_t penDashes(const BasGfxT *g) {
    +    uint16_t dash = sDrawDashes[g->drawStyle];
    +
    +    return (g->drawWidth > 1 && dash != GFX_STYLE_NONE) ? GFX_STYLE_SOLID : dash;
    +}
    +
    +
    +// The pen's size across in pixels: a disc DrawWidth / 2 pixels round
    +// its centre (one pixel for DrawWidth 0 and 1).
    +static int32_t penSize(const BasGfxT *g) {
    +    return 2 * (g->drawWidth / 2) + 1;
    +}
    +
    +
     // One point with the pen: a pixel, or a disc DrawWidth across.
     static void plot(BasGfxT *g, int32_t x, int32_t y, uint32_t pixel) {
         int32_t r = g->drawWidth / 2;
     
    +    if (g->surf.vec) {
    +        g->surf.vec->dot(g->surf.ctx, x, y, pixel, penSize(g));
    +        return;
    +    }
    +
         if (r <= 0) {
             plotPixel(g, x, y, pixel);
             return;
    diff --git a/src/apps/kpunch/dvxbasic/runtime/gfx.h b/src/apps/kpunch/dvxbasic/runtime/gfx.h
    index f69dc05b..bbcfdd3a 100644
    --- a/src/apps/kpunch/dvxbasic/runtime/gfx.h
    +++ b/src/apps/kpunch/dvxbasic/runtime/gfx.h
    @@ -62,6 +62,17 @@
     #define BAS_GFX_FILL_DIAGONAL_CROSS 7
     #define BAS_GFX_FILL_MAX            7
     
    +// DrawStyle values (VB): the pen solid, dashed four ways (with a pen one
    +// pixel wide; a wider one draws solid), drawing no lines at all, or solid.
    +#define BAS_GFX_DRAW_SOLID          0
    +#define BAS_GFX_DRAW_DASH           1
    +#define BAS_GFX_DRAW_DOT            2
    +#define BAS_GFX_DRAW_DASH_DOT       3
    +#define BAS_GFX_DRAW_DASH_DOT_DOT   4
    +#define BAS_GFX_DRAW_INVISIBLE      5
    +#define BAS_GFX_DRAW_INSIDE_SOLID   6
    +#define BAS_GFX_DRAW_MAX            6
    +
     // PAINT tiles: up to 8 pixels wide and 64 rows (QBasic's limits).
     #define BAS_GFX_TILE_MAX_W   8
     #define BAS_GFX_TILE_MAX_H   64
    @@ -69,8 +80,26 @@
     // DRAW's S (scale) is in quarter pixels; S4 is one pixel a unit.
     #define BAS_GFX_DRAW_SCALE_ONE   4
     
    +// A surface that takes shapes whole instead of spans (a PostScript
    +// page): lines (from x0, y0 to x1, y1, clipped to the view grown by the
    +// pen; style is LINE's 16-pixel mask as it stands at the line's start,
    +// 0xFFFF solid), filled boxes (corners inclusive) and ellipses, ellipse
    +// outlines (an arc from start to end radians, counterclockwise as the
    +// screen shows it, when arc; style's bits a pixel each along the outline
    +// from angle 0), and pen dots, in pixel coordinates (the others not
    +// clipped); width is the pen's size across in pixels, and fillStyle
    +// BAS_GFX_FILL_SOLID or a hatch.
    +typedef struct {
    +    void (*line)(void *ctx, double x0, double y0, double x1, double y1, uint32_t pixel, int32_t width, uint16_t style);
    +    void (*rect)(void *ctx, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t pixel, int32_t fillStyle);
    +    void (*ellipse)(void *ctx, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, int32_t width, uint16_t style, bool arc, double start, double end);
    +    void (*ellipseFill)(void *ctx, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t pixel, int32_t fillStyle);
    +    void (*dot)(void *ctx, int32_t x, int32_t y, uint32_t pixel, int32_t width);
    +} BasGfxVectorT;
    +
     // A drawing surface.  span and get take pixel coordinates the caller has
    -// already clipped to the surface.
    +// already clipped to the surface.  A surface with vec takes the shapes
    +// whole through it instead (span is then not used for them).
     typedef struct {
         void     *ctx;
         int32_t   width;
    @@ -80,6 +109,7 @@ typedef struct {
         uint32_t (*get)(const void *ctx, int32_t x, int32_t y);
         uint32_t (*toPixel)(void *ctx, uint32_t color);                        // NULL: colours are pixels
         uint32_t (*toColor)(const void *ctx, uint32_t pixel);                  // NULL: pixels are colours
    +    const BasGfxVectorT *vec;                                              // NULL: shapes are spans
     } BasGfxSurfaceT;
     
     // A PAINT tile: colour numbers, repeated over the surface from its
    @@ -111,6 +141,7 @@ typedef struct {
         uint32_t       fillColor;      // VB FillColor / FillStyle for boxes and circles
         int32_t        fillStyle;
         int32_t        drawWidth;      // pen size in pixels (VB DrawWidth)
    +    int32_t        drawStyle;      // the pen's dashes (VB DrawStyle: BAS_GFX_DRAW_*)
         double         aspect;         // CIRCLE aspect when none is given
         int32_t        drawScale;      // DRAW S (quarter pixels a unit)
         int32_t        drawTurn;       // DRAW A / TA (degrees)
    diff --git a/src/apps/kpunch/dvxbasic/stub/basstub.c b/src/apps/kpunch/dvxbasic/stub/basstub.c
    index 63275191..979cb7d9 100644
    --- a/src/apps/kpunch/dvxbasic/stub/basstub.c
    +++ b/src/apps/kpunch/dvxbasic/stub/basstub.c
    @@ -80,11 +80,11 @@ static void stubPrint(void *ctx, const char *text, bool newline);
     int32_t     appMain(DxeAppContextT *ctx);
     
     
    -// Resolved by the shell as _appShutdown and called on BOTH graceful reap and
    -// force-kill, before this app's DXE is unmapped.  On a normal exit appMain
    -// already ran basFormRtDestroy; on a force-kill it never returns, so this is
    -// the only path that drops the serial/secLink idle pollers from the shell
    -// registry -- otherwise they would keep polling this app's freed terminals.
    +// Resolved by the shell as _appShutdown and called when the app is reaped
    +// after appMain returned (which already ran basFormRtDestroy, so this
    +// finds the serial ports closed).  A force kill runs none of the app's
    +// code: basrt's kill hook (basRunHostAppKilled) ends the runtime then,
    +// serial ports and idle pollers included, while its windows are there.
     void appShutdown(void) {
         basFormRtSerialShutdown();
     }
    @@ -212,6 +212,7 @@ int32_t appMain(DxeAppContextT *ctx) {
         spec.printFn    = stubPrint;
         spec.inputFn    = stubInput;
         spec.doEventsFn = stubDoEvents;
    +    spec.standalone = true;
     
         BasFormRtT *rt = basRunHostCreate(sAc, mod, &spec, &hostError);
     
    @@ -227,7 +228,6 @@ int32_t appMain(DxeAppContextT *ctx) {
         if (!rt->vm->module) {
             dvxMessageBox(sAc, "Error", basVmGetError(rt->vm), 0);
             basRunHostDestroy(rt);
    -        basModuleFree(mod);
             dvxResClose(res);
             return 1;
         }
    @@ -309,7 +309,6 @@ int32_t appMain(DxeAppContextT *ctx) {
         ctx->newArgsCtx   = NULL;
         sRt               = NULL;
         basRunHostDestroy(rt);
    -    basModuleFree(mod);
     
         return 0;
     }
    diff --git a/src/apps/kpunch/dvxbasic/userctl.dhs b/src/apps/kpunch/dvxbasic/userctl.dhs
    index 0d3b6563..11619df9 100644
    --- a/src/apps/kpunch/dvxbasic/userctl.dhs
    +++ b/src/apps/kpunch/dvxbasic/userctl.dhs
    @@ -132,7 +132,7 @@ Inside the control's code, UserControl is the control itself: the surface it dra
       PropertyChanged name         Tells a designer that a property changed, so its property sheet shows the new value.
       Cls, Line, PSet, Circle, Print, Point, PaintPicture, DrawFocusRect, TextWidth, TextHeight, Scale, ScaleX, ScaleY   Drawing, as on a PictureBox.
       DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar, DrawScrollArrow, ScrollBarClick, ScrollBarsNeeded, PrintAccel, TextWidthAccel, AccelKey   Drawing in DVX's own look (bevels, button faces, arrows, upright and horizontal scroll bars and their arrow buttons, captions with an underlined access key), as on a PictureBox.
    -  CurrentX, CurrentY, ForeColor, BackColor, FillColor, FillStyle, FontScale, DrawWidth, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight   Drawing properties, as on a PictureBox.
    +  CurrentX, CurrentY, ForeColor, BackColor, FillColor, FillStyle, FontScale, DrawWidth, DrawStyle, AutoRedraw, ScaleMode, ScaleLeft, ScaleTop, ScaleWidth, ScaleHeight   Drawing properties, as on a PictureBox.
     .endtable
     
     The drawing members work exactly as on a PictureBox (see Drawing on Forms and PictureBoxes). Write them with UserControl in front -- UserControl.Line, UserControl.Print -- as a form's code writes Me.Line: a drawing statement on its own (Line, Cls, Print) draws on the program's screen, not on the control.
    diff --git a/src/apps/kpunch/dvxdemo/dvxdemo.c b/src/apps/kpunch/dvxdemo/dvxdemo.c
    index 08f5cac0..724f8ea6 100644
    --- a/src/apps/kpunch/dvxdemo/dvxdemo.c
    +++ b/src/apps/kpunch/dvxdemo/dvxdemo.c
    @@ -1033,7 +1033,8 @@ static void setupControlsWindow(void) {
         wgtSetFont(serif, "DejaVu Serif", 16, 0);
         wgtSetFont(mono, "Cousine", 14, FONT_STYLE_BOLD);
         wgtSetFont(script, "Pacifico", 24, 0);
    -    rich->prefW     = wgtPixels(DEMO_RICH_W);
    +    rich->minW      = wgtPixels(DEMO_RICH_W);
    +    rich->maxW      = wgtPixels(DEMO_RICH_W);
         choose->onClick = onChooseFont;
         wgtSetName(sample, DEMO_FONT_SAMPLE);
     
    diff --git a/src/apps/kpunch/fileMgr/fileMgr.dhs b/src/apps/kpunch/fileMgr/fileMgr.dhs
    index 73da0d4a..0ed22e22 100644
    --- a/src/apps/kpunch/fileMgr/fileMgr.dhs
    +++ b/src/apps/kpunch/fileMgr/fileMgr.dhs
    @@ -105,6 +105,7 @@ Double-click an item, or select it and press Enter (or choose File > Open):
     .list
     .item A folder opens in the same window.
     .item A program (Type: Program) starts.
    +.item A DOS program (a file ending in .EXE, .COM, or .BAT) runs with DVX stepped aside: DVX first names the programs that will close, this File Manager too, and asks whether to go on, and comes back when the DOS program ends (see DOS Programs in the Program Manager's help).
     .item A document opens in the program chosen for its type, for example a .TXT file in Notepad. Which program opens each kind of file is set on the File Types tab of the Control Panel. If no program is chosen for a file's type, the File Manager says so.
     .endlist
     
    diff --git a/src/apps/kpunch/fileMgr/fileMgr.frm b/src/apps/kpunch/fileMgr/fileMgr.frm
    index da070ccc..67d17645 100644
    --- a/src/apps/kpunch/fileMgr/fileMgr.frm
    +++ b/src/apps/kpunch/fileMgr/fileMgr.frm
    @@ -1092,7 +1092,9 @@ SUB CmdOpen
     
         ext = FileOpsExtension$(entryName)
     
    -    IF LCase$(ext) = "app" THEN
    +    ' A DOS program runs with DVX stepped aside (DVX asks first when other
    +    ' programs, this one too, would close)
    +    IF LCase$(ext) = "app" OR ShellIsDosProgram(path) THEN
             ShellRun path, ""
             EXIT SUB
         END IF
    diff --git a/src/apps/kpunch/pkgbuild/pkgbuild.dhs b/src/apps/kpunch/pkgbuild/pkgbuild.dhs
    index 0e3fd062..b349496d 100644
    --- a/src/apps/kpunch/pkgbuild/pkgbuild.dhs
    +++ b/src/apps/kpunch/pkgbuild/pkgbuild.dhs
    @@ -67,7 +67,7 @@ What you fill in can be saved as a template (File > Save) and opened again (File
     .item Name: what Software shows, such as Chess.
     .item Package name: who made it and what it is, with a dot between them, such as acme.chess. Each part is 1 to 8 lower-case letters, digits, or _. Two packages with the same package name are the same package, so keep it once you have chosen it.
     .item Version: numbers with dots between them, such as 1.0.0. Give each new build you hand out a higher version: Software installs a higher version over a lower one, but not the other way around.
    -.item Kind: what the package holds -- app (a program), library, widget, font, bank (MIDI instruments), theme, wallpaper (desktop pictures), sound, help, or bundle (no files of its own: a set of other packages, named on the Needs page).
    +.item Kind: what the package holds -- app (a program), library, widget, font, bank (MIDI instruments), theme, wallpaper (desktop pictures), sound, saver (screen savers), help, or bundle (no files of its own: a set of other packages, named on the Needs page).
     .item Summary: a sentence about what it is.
     .item Oldest DVX: the oldest version of DVX it works with. It starts as this DVX's version; leave it empty when any version will do.
     .endlist
    @@ -114,6 +114,7 @@ Where each kind of package keeps its files, for a package named acme.chess:
       theme                SHARED\THEMES\ACME
       wallpaper            SHARED\WPAPER\ACME
       sound                SHARED\SOUNDS\ACME
    +  saver                SHARED\SAVERS\ACME
     .endtable
     
     A package may put files only in its own kind's folder, so it cannot change DVX or another publisher's files. Build Package says so when a file is somewhere else. A file named PACKAGE.INI or PACKAGE.SIG at the top of the folder is left out.
    diff --git a/src/apps/kpunch/player/ICON32.BMP b/src/apps/kpunch/player/ICON32.BMP
    new file mode 100644
    index 00000000..daf9dc04
    --- /dev/null
    +++ b/src/apps/kpunch/player/ICON32.BMP
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:e71d0dd252d1b019e32c45c5b85ede02332a638f77b1ad627cd8837c6eda8ac7
    +size 3126
    diff --git a/src/apps/kpunch/player/player.dbp b/src/apps/kpunch/player/player.dbp
    new file mode 100644
    index 00000000..66f92b91
    --- /dev/null
    +++ b/src/apps/kpunch/player/player.dbp
    @@ -0,0 +1,24 @@
    +[Project]
    +Name = Media Player
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = Plays music modules, MIDI files, and WAV and VOC sounds from a list
    +Icon = ICON32.BMP
    +
    +[Modules]
    +File0 = ../../../include/basic/commdlg.bas
    +File1 = ../../../include/basic/fileops.bas
    +File2 = ../../../include/basic/help.bas
    +File3 = ../../../include/basic/music.bas
    +File4 = ../../../include/basic/prefs.bas
    +Count = 5
    +
    +[Forms]
    +File0 = player.frm
    +Count = 1
    +
    +[Settings]
    +StartupForm = PlayerForm
    +MultiInstance = False
    diff --git a/src/apps/kpunch/player/player.dhs b/src/apps/kpunch/player/player.dhs
    new file mode 100644
    index 00000000..bf43fb2f
    --- /dev/null
    +++ b/src/apps/kpunch/player/player.dhs
    @@ -0,0 +1,150 @@
    +# 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.
    +
    +# Media Player help.  Compiled to player.hlp next to player.app; F1 and
    +# Help > Contents in Media Player open it.
    +
    +.topic player.overview
    +.title Media Player
    +.toc 0 Media Player
    +.default
    +.index Media Player
    +.index Music
    +.index Sounds
    +.index MOD
    +.index S3M
    +.index XM
    +.index IT
    +.index MIDI
    +.index WAV
    +.index VOC
    +.index Playlist
    +
    +.h1 Media Player
    +
    +Media Player plays music and sounds from a list: music modules (MOD, S3M, XM, and IT files), MIDI music (MID and RMI files), and recorded sounds (WAV and VOC files). Add the files you want to hear to the list, and Media Player plays them one after another.
    +
    +.h2 Playing a File
    +
    +Choose File > Open..., pick a file, and it plays at once. It is added to the end of the list, unless it is there already.
    +
    +A music or sound file can also be opened straight from the File Manager (double-click it) or the Program Manager: Media Player starts and plays it. If Media Player is already open, the file is added to its list and plays there, so double-clicking one file after another builds up a list.
    +
    +The top of the window shows what is playing: the music's own title when it has one (modules and many MIDI files do), otherwise the file's name, and below it what kind of file it is and what plays it, for example "Module (XM) - Sound device" or "MIDI music (MID) - OPL3 FM synthesizer". The window's title bar shows the title too.
    +
    +If a file cannot be played, Media Player says why. Recorded sounds must be uncompressed (8-bit or 16-bit); a damaged file, or one that is not music at all, is also refused.
    +
    +.h2 The Controls
    +
    +.table
    +  Control    What it does
    +  -------    ------------
    +  Previous   Go back to the start of the music, or, in its first three seconds, to the track before it.
    +  Play       Play the selected track. While music plays the button says Pause and pauses it; choose it again to go on from the same place.
    +  Stop       Stop the music and go back to its start.
    +  Next       Go to the next track.
    +  Volume     Drag the slider to make the music quieter or louder.
    +.endtable
    +
    +The long slider under the title shows where the music is, with the time played and the whole length beside it, for example 1:23 / 4:56. Drag it, or click it and use the arrow keys, to go to another place. A module goes to the start of the part of the song that holds that place, so it may start a little earlier than where you let go.
    +
    +.h2 The List
    +
    +The list shows the files to play, in the order they play. Double-click a track, or select it and press Enter, to play it.
    +
    +.table
    +  Command          What it does
    +  -------          ------------
    +  Add File...      Add one file to the end of the list.
    +  Add Folder...    Add every music and sound file in a folder, in name order.
    +  Move Up          Move the selected track one place up (Ctrl+Up).
    +  Move Down        Move the selected track one place down (Ctrl+Down).
    +  Remove           Take the selected track out of the list (Del). If it is playing, it stops.
    +  Clear List       Take every track out of the list.
    +.endtable
    +
    +Taking a track out of the list never deletes the file.
    +
    +When a track ends, the next one plays. After the last track Media Player stops, unless Repeat List is on.
    +
    +.h2 Repeat and Shuffle
    +
    +On the Play menu:
    +
    +.list
    +.item Repeat Off: play the list once and stop.
    +.item Repeat List: after the last track, start the list again.
    +.item Repeat Track: play the same track over and over.
    +.item Shuffle: play the tracks in a mixed-up order. Each track plays once before any plays again; with Repeat List each new round has a new order.
    +.endlist
    +
    +.h2 Where the Music Plays
    +
    +Media Player plays through the sound card and the settings on the Control Panel's Sounds page. Recorded sounds and modules play on any sound card that plays recorded sound; with a Gravis UltraSound or a Sound Blaster AWE32 or AWE64, modules can play on the card's own voices, which takes the computer less time. MIDI music plays on the music synthesizer the Sounds page chooses: an external MIDI keyboard or sound module, a wavetable card, the FM chip, or DVX's own synthesizer when the card has none. Change those settings on the Control Panel's Sounds page.
    +
    +WAV recordings are read from the disk as they play, so even a recording far bigger than the computer's memory plays. A VOC file is read into memory first.
    +
    +.h2 Keys
    +
    +.table
    +  Key         What it does
    +  ---         ------------
    +  Ctrl+O      Open a file and play it.
    +  Ctrl+P      Play or pause.
    +  Ctrl+S      Stop.
    +  Ctrl+B      Previous.
    +  Ctrl+F      Next.
    +  Ctrl+Up     Move the selected track up.
    +  Ctrl+Down   Move the selected track down.
    +  Enter       Play the selected track (in the list).
    +  Del         Take the selected track out of the list (in the list).
    +  F1          Open this help.
    +.endtable
    +
    +.h2 Menus
    +
    +.table
    +  Menu    Command               What it does
    +  ----    -------               ------------
    +  File    Open...               Play a file, adding it to the list.
    +  File    Add File...           Add a file to the list.
    +  File    Add Folder...         Add a folder's music and sound files to the list.
    +  File    Move Up               Move the selected track up.
    +  File    Move Down             Move the selected track down.
    +  File    Remove                Take the selected track out of the list.
    +  File    Clear List            Take every track out of the list.
    +  File    Exit                  Close Media Player.
    +  Play    Play / Pause          Play, pause, or go on.
    +  Play    Stop                  Stop and go back to the start.
    +  Play    Previous              The start of the track, or the track before.
    +  Play    Next                  The next track.
    +  Play    Repeat Off            Play the list once.
    +  Play    Repeat List           Play the list over and over.
    +  Play    Repeat Track          Play one track over and over.
    +  Play    Shuffle               Play the list in a mixed-up order.
    +  Help    Contents              Open this help.
    +  Help    About Media Player    Show the program name and copyright.
    +.endtable
    +
    +.note tip
    +Media Player remembers the list, the track you had selected, the volume, Repeat, Shuffle, and the folder you last opened files from. The next time it starts, the list is there, ready to play.
    +.endnote
    diff --git a/src/apps/kpunch/player/player.frm b/src/apps/kpunch/player/player.frm
    new file mode 100644
    index 00000000..80663aa5
    --- /dev/null
    +++ b/src/apps/kpunch/player/player.frm
    @@ -0,0 +1,1146 @@
    +VERSION DVX 1.00
    +Begin Form PlayerForm
    +    Caption   = "Media Player"
    +    Layout    = VBox
    +    AutoSize  = False
    +    Resizable = True
    +    Centered  = True
    +    Width     = 520
    +    Height    = 340
    +    Begin Menu mnuFile
    +        Caption = "&File"
    +        Begin Menu mnuOpen
    +            Caption  = "&Open..."
    +            Shortcut = "Ctrl+O"
    +        End
    +        Begin Menu mnuAddFile
    +            Caption = "&Add File..."
    +        End
    +        Begin Menu mnuAddFolder
    +            Caption = "Add Fol&der..."
    +        End
    +        Begin Menu mnuFileSep1
    +            Caption = "-"
    +        End
    +        Begin Menu mnuMoveUp
    +            Caption  = "Move &Up"
    +            Shortcut = "Ctrl+Up"
    +        End
    +        Begin Menu mnuMoveDown
    +            Caption  = "Move Do&wn"
    +            Shortcut = "Ctrl+Down"
    +        End
    +        Begin Menu mnuRemove
    +            Caption      = "&Remove"
    +            ShortcutText = "Del"
    +        End
    +        Begin Menu mnuClear
    +            Caption = "&Clear List"
    +        End
    +        Begin Menu mnuFileSep2
    +            Caption = "-"
    +        End
    +        Begin Menu mnuExit
    +            Caption = "E&xit"
    +        End
    +    End
    +    Begin Menu mnuPlay
    +        Caption = "&Play"
    +        Begin Menu mnuPlayPause
    +            Caption  = "&Play / Pause"
    +            Shortcut = "Ctrl+P"
    +        End
    +        Begin Menu mnuStop
    +            Caption  = "&Stop"
    +            Shortcut = "Ctrl+S"
    +        End
    +        Begin Menu mnuPrev
    +            Caption  = "Pre&vious"
    +            Shortcut = "Ctrl+B"
    +        End
    +        Begin Menu mnuNext
    +            Caption  = "&Next"
    +            Shortcut = "Ctrl+F"
    +        End
    +        Begin Menu mnuPlaySep1
    +            Caption = "-"
    +        End
    +        Begin Menu mnuRepeatOff
    +            Caption    = "Repeat O&ff"
    +            RadioCheck = True
    +            Checked    = True
    +        End
    +        Begin Menu mnuRepeatList
    +            Caption    = "Repeat &List"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +        Begin Menu mnuRepeatTrack
    +            Caption    = "Repeat &Track"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +        Begin Menu mnuPlaySep2
    +            Caption = "-"
    +        End
    +        Begin Menu mnuShuffle
    +            Caption = "S&huffle"
    +            Checked = False
    +        End
    +    End
    +    Begin Menu mnuHelp
    +        Caption = "&Help"
    +        Begin Menu mnuHelpContents
    +            Caption      = "&Contents"
    +            ShortcutText = "F1"
    +        End
    +        Begin Menu mnuHelpSep
    +            Caption = "-"
    +        End
    +        Begin Menu mnuAbout
    +            Caption = "&About Media Player..."
    +        End
    +    End
    +    Begin Label lblTitle
    +        Caption  = "Nothing is playing"
    +        FontBold = True
    +        Weight   = 0
    +    End
    +    Begin Label lblInfo
    +        Caption = ""
    +        Weight  = 0
    +    End
    +    Begin HBox rowPosition
    +        Weight = 0
    +        Begin HScrollBar sldPosition
    +            Weight      = 1
    +            ToolTipText = "Where the music is. Drag it to go to another place."
    +        End
    +        Begin Label lblTime
    +            Caption   = "0:00 / 0:00"
    +            Alignment = Right
    +            MinWidth  = 104
    +            Weight    = 0
    +        End
    +    End
    +    Begin HBox rowButtons
    +        Weight = 0
    +        Begin CommandButton btnPrev
    +            Caption  = "Pre&vious"
    +            MinWidth = 76
    +            Weight   = 0
    +        End
    +        Begin CommandButton btnPlay
    +            Caption  = "Pl&ay"
    +            MinWidth = 76
    +            Weight   = 0
    +        End
    +        Begin CommandButton btnStop
    +            Caption  = "&Stop"
    +            MinWidth = 76
    +            Weight   = 0
    +        End
    +        Begin CommandButton btnNext
    +            Caption  = "&Next"
    +            MinWidth = 76
    +            Weight   = 0
    +        End
    +        Begin Spacer spcButtons
    +            Weight = 1
    +        End
    +        Begin Label lblVolume
    +            Caption = "Vol&ume:"
    +            Weight  = 0
    +        End
    +        Begin HScrollBar sldVolume
    +            MinWidth    = 80
    +            Weight      = 0
    +            ToolTipText = "How loud the music plays"
    +        End
    +    End
    +    Begin ListBox lstTracks
    +        Weight = 1
    +    End
    +    Begin Timer tmrTick
    +        Interval = 250
    +        Enabled  = True
    +    End
    +End
    +
    +' 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.
    +
    +' Media Player: music modules (MOD, S3M, XM, IT), MIDI files, and WAV and
    +' VOC sounds played from a list (music.bas).
    +'
    +' The list holds files by their paths (trackPath, in the list box's
    +' order); the track loaded is current, its music number music.  Next and
    +' Previous, and the end of a track, walk playOrder: the list's order, or
    +' a shuffled one with the track playing first.  Repeat and Shuffle are
    +' the Play menu's checks, the volume is its slider: nothing else keeps
    +' them.
    +'
    +' A timer shows where the music is and notices its end (playing, not
    +' paused, and MusicPlaying False).  Moving the position slider fires
    +' Change at every step of a drag, so the music goes there only once the
    +' slider has stood still a moment; the timer leaves the slider alone
    +' meanwhile.
    +'
    +' Files named when the program starts, or handed to it while it runs
    +' (a music file opened from Program Manager or File Manager), are added
    +' to the list and the first of them plays.  The list and the settings
    +' are kept in PLAYER.INI in the program's own user folder.
    +
    +OPTION EXPLICIT
    +
    +CONST APP_TITLE = "Media Player"
    +CONST HELP_FILE = "player.hlp"
    +
    +' What Open, Add File, and Add Folder offer
    +CONST MUSIC_PATTERN = "*.mod;*.s3m;*.xm;*.it;*.mid;*.rmi;*.wav;*.voc"
    +CONST OPEN_FILTER   = "Music and sounds (" + MUSIC_PATTERN + ")|All Files (*.*)"
    +
    +' Settings: PLAYER.INI in App.User; the list is File1, File2, ...
    +CONST SETTINGS_FILE  = "PLAYER.INI"
    +CONST INI_PLAYER     = "Player"
    +CONST INI_LIST       = "List"
    +CONST KEY_VOLUME     = "Volume"
    +CONST KEY_REPEAT     = "Repeat"
    +CONST KEY_SHUFFLE    = "Shuffle"
    +CONST KEY_FOLDER     = "Folder"
    +CONST KEY_TRACK      = "Track"
    +CONST KEY_FILE       = "File"
    +CONST REPEAT_OFF     = "off"
    +CONST REPEAT_LIST    = "list"
    +CONST REPEAT_TRACK   = "track"
    +
    +CONST VOLUME_MAX        = 100
    +CONST SEEK_SETTLE_TICKS = 2      ' the slider stood still a whole tick (tmrTick.Interval)
    +CONST RESTART_MS        = 3000   ' Previous this far into a track starts it again
    +CONST MS_PER_SEC        = 1000
    +CONST SEC_PER_MIN       = 60
    +CONST LIST_GROW         = 16     ' trackPath's first size; it grows by doubling
    +CONST NO_TRACK          = -1
    +CONST NO_SEEK           = -1
    +
    +DIM SHARED trackPath() AS STRING   ' the list's files, in its order
    +DIM SHARED trackCount AS LONG
    +DIM SHARED playOrder() AS LONG     ' the order Next takes the tracks in
    +DIM SHARED orderPos AS LONG        ' current's place in playOrder
    +DIM SHARED current AS LONG         ' the track loaded (NO_TRACK: none)
    +DIM SHARED music AS LONG           ' its music number (0: none)
    +DIM SHARED playing AS BOOLEAN      ' started and not stopped since
    +DIM SHARED paused AS BOOLEAN
    +DIM SHARED lastFolder AS STRING    ' where the file dialogs start
    +DIM SHARED seekTo AS LONG          ' where the slider was moved to (NO_SEEK: nowhere)
    +DIM SHARED seekTicks AS LONG       ' ticks the slider has stood still since
    +
    +
    +' ============================================================
    +' Window
    +' ============================================================
    +
    +SUB PlayerForm_Load
    +    RANDOMIZE TIMER
    +    ResetList
    +    seekTo          = NO_SEEK
    +    sldVolume.Max   = VOLUME_MAX
    +    sldVolume.Value = VOLUME_MAX
    +    LoadSettings
    +    ShowTrack
    +    AddArguments
    +END SUB
    +
    +
    +' Another start of the program while it runs: its files are added here.
    +SUB PlayerForm_NewArgs
    +    AddArguments
    +END SUB
    +
    +
    +SUB PlayerForm_Unload
    +    SaveSettings
    +END SUB
    +
    +
    +' The files named by COMMAND$ added to the list (one already there is
    +' not added again); the first of them plays.
    +SUB AddArguments
    +    DIM i AS LONG
    +    DIM index AS LONG
    +    DIM first AS LONG
    +
    +    first = NO_TRACK
    +    i     = 1
    +
    +    DO WHILE COMMAND$(i) <> ""
    +        index = FindTrack(COMMAND$(i))
    +
    +        IF index = NO_TRACK THEN
    +            index = AppendTrack(COMMAND$(i))
    +        END IF
    +
    +        IF first = NO_TRACK THEN
    +            first = index
    +        END IF
    +
    +        i = i + 1
    +    LOOP
    +
    +    IF first <> NO_TRACK THEN
    +        BuildOrder
    +        PlayTrack first
    +    END IF
    +END SUB
    +
    +
    +' ============================================================
    +' The list
    +' ============================================================
    +
    +' An empty list (its memory let go): nothing loaded, nothing current.
    +SUB ResetList
    +    REDIM trackPath(LIST_GROW) AS STRING
    +    trackCount = 0
    +    current    = NO_TRACK
    +END SUB
    +
    +
    +' Add path at the end of the list; its index.  The caller rebuilds the
    +' play order (BuildOrder) once it has added what it adds.
    +FUNCTION AppendTrack(BYVAL path AS STRING) AS LONG
    +    IF trackCount > UBOUND(trackPath) THEN
    +        REDIM PRESERVE trackPath(trackCount * 2 + LIST_GROW) AS STRING
    +    END IF
    +
    +    trackPath(trackCount) = path
    +    lstTracks.AddItem FileOpsBaseName$(path)
    +    AppendTrack = trackCount
    +    trackCount  = trackCount + 1
    +END FUNCTION
    +
    +
    +' The index of path in the list, or NO_TRACK.
    +FUNCTION FindTrack(BYVAL path AS STRING) AS LONG
    +    DIM i AS LONG
    +
    +    FOR i = 0 TO trackCount - 1
    +        IF FileOpsPathSame(trackPath(i), path) THEN
    +            FindTrack = i
    +            EXIT FUNCTION
    +        END IF
    +    NEXT i
    +
    +    FindTrack = NO_TRACK
    +END FUNCTION
    +
    +
    +' The list box shown again from trackPath, index selected.
    +SUB RefreshList(BYVAL index AS LONG)
    +    DIM i AS LONG
    +    DIM spec AS STRING
    +
    +    FOR i = 0 TO trackCount - 1
    +        IF i > 0 THEN
    +            spec = spec + "|"
    +        END IF
    +
    +        spec = spec + FileOpsBaseName$(trackPath(i))
    +    NEXT i
    +
    +    lstTracks.SetItems spec
    +    lstTracks.ListIndex = index
    +END SUB
    +
    +
    +' Take a track out of the list; the one playing stops.
    +SUB RemoveTrack(BYVAL index AS LONG)
    +    DIM i AS LONG
    +
    +    FOR i = index TO trackCount - 2
    +        trackPath(i) = trackPath(i + 1)
    +    NEXT i
    +
    +    trackCount = trackCount - 1
    +
    +    IF index = current THEN
    +        FreeMusic
    +        current = NO_TRACK
    +        ShowTrack
    +    ELSEIF index < current THEN
    +        current = current - 1
    +    END IF
    +
    +    lstTracks.RemoveItem index
    +
    +    IF index < trackCount THEN
    +        lstTracks.ListIndex = index
    +    ELSE
    +        lstTracks.ListIndex = trackCount - 1
    +    END IF
    +
    +    BuildOrder
    +END SUB
    +
    +
    +' Swap the selected track with the one direction (-1 up, 1 down) from it.
    +SUB MoveTrack(BYVAL direction AS LONG)
    +    DIM index AS LONG
    +    DIM other AS LONG
    +    DIM path AS STRING
    +
    +    index = lstTracks.ListIndex
    +    other = index + direction
    +
    +    IF index < 0 OR other < 0 OR other >= trackCount THEN
    +        EXIT SUB
    +    END IF
    +
    +    path              = trackPath(index)
    +    trackPath(index)  = trackPath(other)
    +    trackPath(other)  = path
    +
    +    IF current = index THEN
    +        current = other
    +    ELSEIF current = other THEN
    +        current = index
    +    END IF
    +
    +    RefreshList other
    +    BuildOrder
    +END SUB
    +
    +
    +' ============================================================
    +' Play order
    +' ============================================================
    +
    +' playOrder made again: the list's order, or shuffled with the track
    +' playing first so the others all follow it.
    +SUB BuildOrder
    +    DIM i AS LONG
    +    DIM j AS LONG
    +    DIM t AS LONG
    +
    +    orderPos = 0
    +
    +    IF trackCount = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    REDIM playOrder(trackCount - 1) AS LONG
    +
    +    FOR i = 0 TO trackCount - 1
    +        playOrder(i) = i
    +    NEXT i
    +
    +    IF mnuShuffle.Checked THEN
    +        FOR i = trackCount - 1 TO 1 STEP -1
    +            j            = INT(RND * (i + 1))
    +            t            = playOrder(i)
    +            playOrder(i) = playOrder(j)
    +            playOrder(j) = t
    +        NEXT i
    +
    +        IF current <> NO_TRACK THEN
    +            j            = OrderOf(current)
    +            playOrder(j) = playOrder(0)
    +            playOrder(0) = current
    +        END IF
    +    END IF
    +
    +    orderPos = OrderOf(current)
    +END SUB
    +
    +
    +' The place of a track in playOrder (0 for NO_TRACK).
    +FUNCTION OrderOf(BYVAL track AS LONG) AS LONG
    +    DIM i AS LONG
    +
    +    FOR i = 0 TO trackCount - 1
    +        IF playOrder(i) = track THEN
    +            OrderOf = i
    +            EXIT FUNCTION
    +        END IF
    +    NEXT i
    +
    +    OrderOf = 0
    +END FUNCTION
    +
    +
    +' The track direction (1 next, -1 previous) steps to in playOrder, or
    +' NO_TRACK past the end.  With Repeat List the list goes round again (a
    +' new order when shuffled); before the first track is the first track.
    +FUNCTION StepTrack(BYVAL direction AS LONG) AS LONG
    +    DIM pos AS LONG
    +
    +    IF trackCount = 0 THEN
    +        StepTrack = NO_TRACK
    +        EXIT FUNCTION
    +    END IF
    +
    +    pos = orderPos + direction
    +
    +    IF current = NO_TRACK THEN
    +        pos = 0
    +    ELSEIF pos >= trackCount THEN
    +        IF NOT mnuRepeatList.Checked THEN
    +            StepTrack = NO_TRACK
    +            EXIT FUNCTION
    +        END IF
    +
    +        pos = 0
    +
    +        IF mnuShuffle.Checked AND trackCount > 1 THEN
    +            BuildOrder
    +            pos = 1
    +        END IF
    +    ELSEIF pos < 0 THEN
    +        pos = 0
    +
    +        IF mnuRepeatList.Checked THEN
    +            pos = trackCount - 1
    +        END IF
    +    END IF
    +
    +    orderPos  = pos
    +    StepTrack = playOrder(pos)
    +END FUNCTION
    +
    +
    +' ============================================================
    +' Playing
    +' ============================================================
    +
    +' Load a track and play it; False, once the user has been told why, when
    +' it cannot be played.
    +FUNCTION PlayTrack(BYVAL index AS LONG) AS BOOLEAN
    +    DIM reason AS STRING
    +
    +    FreeMusic
    +    current             = index
    +    orderPos            = OrderOf(index)
    +    lstTracks.ListIndex = index
    +    music               = MusicLoad(trackPath(index))
    +
    +    ' MusicError$ says why the load or the play (the last call) failed.
    +    IF music <> 0 THEN
    +        MusicVolume music, sldVolume.Value
    +        MusicPlay music, mnuRepeatTrack.Checked
    +        playing = MusicPlaying(music) <> 0
    +    END IF
    +
    +    IF NOT playing THEN
    +        reason = MusicError$()
    +        FreeMusic
    +        ShowTrack
    +        lblInfo.Caption = reason
    +        MsgBox "Media Player cannot play " + FileOpsBaseName$(trackPath(index)) + "." + CHR$(10) + CHR$(10) + reason, vbOKOnly + vbExclamation, APP_TITLE
    +        PlayTrack = False
    +        EXIT FUNCTION
    +    END IF
    +
    +    ShowTrack
    +    PlayTrack = True
    +END FUNCTION
    +
    +
    +' Play index, or when it cannot be played the tracks after it (direction
    +' 1) or before it (-1), until one plays or every one has been tried.
    +SUB PlayFrom(BYVAL index AS LONG, BYVAL direction AS LONG)
    +    DIM tries AS LONG
    +
    +    DO WHILE index <> NO_TRACK AND tries < trackCount
    +        IF PlayTrack(index) THEN
    +            EXIT SUB
    +        END IF
    +
    +        tries = tries + 1
    +        index = StepTrack(direction)
    +    LOOP
    +END SUB
    +
    +
    +' Let the music go; nothing is loaded.
    +SUB FreeMusic
    +    IF music <> 0 THEN
    +        MusicFree music
    +    END IF
    +
    +    music   = 0
    +    playing = False
    +    paused  = False
    +    seekTo  = NO_SEEK
    +END SUB
    +
    +
    +' Play, pause, or go on.  Stopped, or with nothing loaded, the selected
    +' track plays (the loaded one goes on from where it is).
    +SUB PlayOrPause
    +    DIM index AS LONG
    +
    +    IF playing THEN
    +        IF paused THEN
    +            MusicPlay music, mnuRepeatTrack.Checked
    +        ELSE
    +            MusicPause music
    +        END IF
    +
    +        paused = NOT paused
    +        ShowState
    +        EXIT SUB
    +    END IF
    +
    +    index = lstTracks.ListIndex
    +
    +    IF index < 0 THEN
    +        index = current
    +    END IF
    +
    +    IF index = NO_TRACK AND trackCount > 0 THEN
    +        index = playOrder(0)
    +    END IF
    +
    +    IF index = NO_TRACK THEN
    +        EXIT SUB
    +    END IF
    +
    +    IF index = current AND music <> 0 THEN
    +        MusicPlay music, mnuRepeatTrack.Checked
    +        playing = True
    +        ShowState
    +    ELSE
    +        PlayTrack index
    +    END IF
    +END SUB
    +
    +
    +SUB StopPlaying
    +    IF music = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    MusicStop music
    +    playing = False
    +    paused  = False
    +    seekTo  = NO_SEEK
    +    ShowState
    +    ShowPosition
    +END SUB
    +
    +
    +SUB PlayNext
    +    PlayFrom StepTrack(1), 1
    +END SUB
    +
    +
    +' Back to the start of the track, or, near its start, the track before.
    +SUB PlayPrevious
    +    IF music <> 0 THEN
    +        IF MusicPosition(music) > RESTART_MS THEN
    +            MusicSeek music, 0
    +            ShowPosition
    +            EXIT SUB
    +        END IF
    +    END IF
    +
    +    PlayFrom StepTrack(-1), -1
    +END SUB
    +
    +
    +' The track played to its end: the next one, or at the end of the list
    +' the last one stopped.
    +SUB TrackEnded
    +    DIM index AS LONG
    +
    +    index = StepTrack(1)
    +
    +    IF index = NO_TRACK THEN
    +        StopPlaying
    +        EXIT SUB
    +    END IF
    +
    +    PlayFrom index, 1
    +END SUB
    +
    +
    +' Repeat Track loops the music itself; a change reaches it at once.
    +SUB ApplyRepeat
    +    IF playing AND NOT paused THEN
    +        MusicPlay music, mnuRepeatTrack.Checked
    +    END IF
    +END SUB
    +
    +
    +SUB tmrTick_Timer
    +    IF music = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    IF seekTo <> NO_SEEK THEN
    +        seekTicks = seekTicks + 1
    +
    +        IF seekTicks >= SEEK_SETTLE_TICKS THEN
    +            MusicSeek music, seekTo
    +            seekTo = NO_SEEK
    +        END IF
    +
    +        EXIT SUB
    +    END IF
    +
    +    IF playing AND NOT paused AND NOT MusicPlaying(music) THEN
    +        TrackEnded
    +        EXIT SUB
    +    END IF
    +
    +    ShowPosition
    +END SUB
    +
    +
    +SUB sldPosition_Change
    +    IF music = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    seekTo          = sldPosition.Value * MS_PER_SEC
    +    seekTicks       = 0
    +    lblTime.Caption = TimeText$(seekTo) + " / " + TimeText$(MusicLength(music))
    +END SUB
    +
    +
    +SUB sldVolume_Change
    +    IF music <> 0 THEN
    +        MusicVolume music, sldVolume.Value
    +    END IF
    +END SUB
    +
    +
    +' ============================================================
    +' What is shown
    +' ============================================================
    +
    +' The track's title (its own, or its file name), what it is and where it
    +' plays, and the window's title; then the buttons and the position.
    +SUB ShowTrack
    +    DIM title AS STRING
    +    DIM fileName AS STRING
    +
    +    IF current = NO_TRACK THEN
    +        lblTitle.Caption   = "Nothing is playing"
    +        lblInfo.Caption    = ""
    +        PlayerForm.Caption = APP_TITLE
    +    ELSE
    +        fileName = FileOpsBaseName$(trackPath(current))
    +        title    = ""
    +
    +        IF music <> 0 THEN
    +            title = TRIM$(MusicInfo$(music, "title"))
    +        END IF
    +
    +        IF title = "" THEN
    +            title = fileName
    +        END IF
    +
    +        lblTitle.Caption   = title
    +        lblInfo.Caption    = KindText$(fileName)
    +        PlayerForm.Caption = title + " - " + APP_TITLE
    +    END IF
    +
    +    sldPosition.Value = 0
    +    sldPosition.Max   = 0
    +
    +    IF music <> 0 THEN
    +        sldPosition.Max = MusicLength(music) \ MS_PER_SEC
    +    END IF
    +
    +    ShowState
    +    ShowPosition
    +END SUB
    +
    +
    +' "Module (XM) - Sound device": what the music is and where it plays.
    +FUNCTION KindText$(BYVAL fileName AS STRING)
    +    DIM kind AS STRING
    +
    +    IF music = 0 THEN
    +        KindText$ = ""
    +        EXIT FUNCTION
    +    END IF
    +
    +    SELECT CASE MusicInfo$(music, "kind")
    +        CASE "module"
    +            kind = "Module"
    +        CASE "midi"
    +            kind = "MIDI music"
    +        CASE ELSE
    +            kind = "Sound"
    +    END SELECT
    +
    +    KindText$ = kind + " (" + UCASE$(FileOpsExtension$(fileName)) + ") - " + MusicInfo$(music, "output")
    +END FUNCTION
    +
    +
    +' The Play button says what it does; the position slider and Stop work
    +' with a music loaded.
    +SUB ShowState
    +    IF playing AND NOT paused THEN
    +        btnPlay.Caption = "P&ause"
    +    ELSE
    +        btnPlay.Caption = "Pl&ay"
    +    END IF
    +
    +    sldPosition.Enabled = music <> 0
    +    btnStop.Enabled     = music <> 0
    +END SUB
    +
    +
    +' The slider and the time at the music's place.
    +SUB ShowPosition
    +    DIM position AS LONG
    +    DIM length AS LONG
    +    DIM text AS STRING
    +
    +    IF music <> 0 THEN
    +        position = MusicPosition(music)
    +        length   = MusicLength(music)
    +    END IF
    +
    +    IF sldPosition.Value <> position \ MS_PER_SEC THEN
    +        sldPosition.Value = position \ MS_PER_SEC
    +    END IF
    +
    +    text = TimeText$(position) + " / " + TimeText$(length)
    +
    +    IF lblTime.Caption <> text THEN
    +        lblTime.Caption = text
    +    END IF
    +END SUB
    +
    +
    +' Milliseconds as minutes and seconds: "3:07".
    +FUNCTION TimeText$(BYVAL ms AS LONG)
    +    DIM secs AS LONG
    +
    +    secs      = ms \ MS_PER_SEC
    +    TimeText$ = LTRIM$(STR$(secs \ SEC_PER_MIN)) + ":" + RIGHT$("0" + LTRIM$(STR$(secs MOD SEC_PER_MIN)), 2)
    +END FUNCTION
    +
    +
    +' ============================================================
    +' Settings
    +' ============================================================
    +
    +FUNCTION SettingsPath$()
    +    SettingsPath$ = FileOpsPathJoin$(App.User, SETTINGS_FILE)
    +END FUNCTION
    +
    +
    +' The volume, Repeat, Shuffle, the folder, and the list with its
    +' selected track, as the program last left them.
    +SUB LoadSettings
    +    DIM ini AS LONG
    +    DIM i AS LONG
    +    DIM path AS STRING
    +    DIM track AS LONG
    +    DIM repeatName AS STRING
    +
    +    ini = PrefsLoad(SettingsPath$())
    +
    +    IF ini = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    sldVolume.Value = PrefsGetInt(ini, INI_PLAYER, KEY_VOLUME, VOLUME_MAX)
    +    repeatName      = LCASE$(PrefsGet$(ini, INI_PLAYER, KEY_REPEAT, REPEAT_OFF))
    +
    +    mnuRepeatOff.Checked   = repeatName <> REPEAT_LIST AND repeatName <> REPEAT_TRACK
    +    mnuRepeatList.Checked  = repeatName = REPEAT_LIST
    +    mnuRepeatTrack.Checked = repeatName = REPEAT_TRACK
    +    mnuShuffle.Checked     = PrefsGetBool(ini, INI_PLAYER, KEY_SHUFFLE, False)
    +    lastFolder             = PrefsGet$(ini, INI_PLAYER, KEY_FOLDER, "")
    +
    +    i = 1
    +
    +    DO
    +        path = PrefsGet$(ini, INI_LIST, KEY_FILE + LTRIM$(STR$(i)), "")
    +
    +        IF path = "" THEN
    +            EXIT DO
    +        END IF
    +
    +        AppendTrack path
    +        i = i + 1
    +    LOOP
    +
    +    track = PrefsGetInt(ini, INI_PLAYER, KEY_TRACK, NO_TRACK)
    +
    +    IF track >= 0 AND track < trackCount THEN
    +        lstTracks.ListIndex = track
    +    END IF
    +
    +    PrefsClose ini
    +    BuildOrder
    +END SUB
    +
    +
    +' Written whole each time, so tracks taken out of the list are gone from
    +' the file too.
    +SUB SaveSettings
    +    DIM ini AS LONG
    +    DIM i AS LONG
    +    DIM repeatName AS STRING
    +
    +    ini = PrefsCreate()
    +
    +    IF ini = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    repeatName = REPEAT_OFF
    +
    +    IF mnuRepeatList.Checked THEN
    +        repeatName = REPEAT_LIST
    +    ELSEIF mnuRepeatTrack.Checked THEN
    +        repeatName = REPEAT_TRACK
    +    END IF
    +
    +    PrefsSetInt ini, INI_PLAYER, KEY_VOLUME, sldVolume.Value
    +    PrefsSet ini, INI_PLAYER, KEY_REPEAT, repeatName
    +    PrefsSetBool ini, INI_PLAYER, KEY_SHUFFLE, mnuShuffle.Checked
    +    PrefsSet ini, INI_PLAYER, KEY_FOLDER, lastFolder
    +    PrefsSetInt ini, INI_PLAYER, KEY_TRACK, lstTracks.ListIndex
    +
    +    FOR i = 0 TO trackCount - 1
    +        PrefsSet ini, INI_LIST, KEY_FILE + LTRIM$(STR$(i + 1)), trackPath(i)
    +    NEXT i
    +
    +    PrefsSaveAs ini, SettingsPath$()
    +    PrefsClose ini
    +END SUB
    +
    +
    +' ============================================================
    +' Menus, buttons, and the list
    +' ============================================================
    +
    +' A file played: added to the list unless it is there already.
    +SUB mnuOpen_Click
    +    DIM path AS STRING
    +    DIM index AS LONG
    +
    +    path = DlgFileOpenIn$("Open", OPEN_FILTER, lastFolder)
    +
    +    IF path = "" THEN
    +        EXIT SUB
    +    END IF
    +
    +    lastFolder = FileOpsPathParent$(path)
    +    index      = FindTrack(path)
    +
    +    IF index = NO_TRACK THEN
    +        index = AppendTrack(path)
    +        BuildOrder
    +    END IF
    +
    +    PlayTrack index
    +END SUB
    +
    +
    +SUB mnuAddFile_Click
    +    DIM path AS STRING
    +
    +    path = DlgFileOpenIn$("Add File", OPEN_FILTER, lastFolder)
    +
    +    IF path = "" THEN
    +        EXIT SUB
    +    END IF
    +
    +    lastFolder = FileOpsPathParent$(path)
    +    AppendTrack path
    +    BuildOrder
    +END SUB
    +
    +
    +' Every music and sound file in a folder, by name.
    +SUB mnuAddFolder_Click
    +    DIM folder AS STRING
    +    DIM listing AS LONG
    +    DIM i AS LONG
    +    DIM added AS LONG
    +
    +    folder = DlgFolder$("Add Folder", lastFolder)
    +
    +    IF folder = "" THEN
    +        EXIT SUB
    +    END IF
    +
    +    lastFolder = folder
    +    listing    = FileOpsList(folder, MUSIC_PATTERN)
    +
    +    IF listing = 0 THEN
    +        MsgBox "The folder " + folder + " cannot be read.", vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT SUB
    +    END IF
    +
    +    FOR i = 0 TO FileOpsListCount(listing) - 1
    +        IF NOT FileOpsListIsFolder(listing, i) THEN
    +            AppendTrack FileOpsPathJoin$(folder, FileOpsListName$(listing, i))
    +            added = added + 1
    +        END IF
    +    NEXT i
    +
    +    FileOpsListFree listing
    +
    +    IF added = 0 THEN
    +        MsgBox "There are no music or sound files in " + folder + ".", vbOKOnly + vbInformation, APP_TITLE
    +        EXIT SUB
    +    END IF
    +
    +    BuildOrder
    +END SUB
    +
    +
    +SUB mnuMoveUp_Click
    +    MoveTrack -1
    +END SUB
    +
    +
    +SUB mnuMoveDown_Click
    +    MoveTrack 1
    +END SUB
    +
    +
    +SUB mnuRemove_Click
    +    IF lstTracks.ListIndex >= 0 THEN
    +        RemoveTrack lstTracks.ListIndex
    +    END IF
    +END SUB
    +
    +
    +SUB mnuClear_Click
    +    FreeMusic
    +    ResetList
    +    lstTracks.Clear
    +    BuildOrder
    +    ShowTrack
    +END SUB
    +
    +
    +SUB mnuExit_Click
    +    Unload PlayerForm
    +END SUB
    +
    +
    +SUB mnuPlayPause_Click
    +    PlayOrPause
    +END SUB
    +
    +
    +SUB mnuStop_Click
    +    StopPlaying
    +END SUB
    +
    +
    +SUB mnuPrev_Click
    +    PlayPrevious
    +END SUB
    +
    +
    +SUB mnuNext_Click
    +    PlayNext
    +END SUB
    +
    +
    +SUB mnuRepeatOff_Click
    +    ApplyRepeat
    +END SUB
    +
    +
    +SUB mnuRepeatList_Click
    +    ApplyRepeat
    +END SUB
    +
    +
    +SUB mnuRepeatTrack_Click
    +    ApplyRepeat
    +END SUB
    +
    +
    +SUB mnuShuffle_Click
    +    BuildOrder
    +END SUB
    +
    +
    +SUB btnPrev_Click
    +    PlayPrevious
    +END SUB
    +
    +
    +SUB btnPlay_Click
    +    PlayOrPause
    +END SUB
    +
    +
    +SUB btnStop_Click
    +    StopPlaying
    +END SUB
    +
    +
    +SUB btnNext_Click
    +    PlayNext
    +END SUB
    +
    +
    +SUB lstTracks_DblClick
    +    IF lstTracks.ListIndex >= 0 THEN
    +        PlayTrack lstTracks.ListIndex
    +    END IF
    +END SUB
    +
    +
    +' Enter plays the selected track, Del takes it out of the list.
    +SUB lstTracks_KeyDown(KeyCode AS INTEGER, Shift AS INTEGER)
    +    IF Shift <> 0 OR lstTracks.ListIndex < 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    IF KeyCode = vbKeyReturn THEN
    +        PlayTrack lstTracks.ListIndex
    +    ELSEIF KeyCode = vbKeyDelete THEN
    +        RemoveTrack lstTracks.ListIndex
    +    END IF
    +END SUB
    +
    +
    +SUB mnuHelpContents_Click
    +    HelpView HELP_FILE
    +END SUB
    +
    +
    +SUB mnuAbout_Click
    +    DIM msg AS STRING
    +
    +    msg = "Media Player" + CHR$(10) + CHR$(10)
    +    msg = msg + "Plays music modules (MOD, S3M, XM, and IT), MIDI files, and WAV and VOC sounds." + CHR$(10) + CHR$(10)
    +    msg = msg + "Copyright 2026 Scott Duensing"
    +    MsgBox msg, vbOKOnly + vbInformation, "About Media Player"
    +END SUB
    diff --git a/src/apps/kpunch/progman/pmProps.c b/src/apps/kpunch/progman/pmProps.c
    index c755a529..93bd478b 100644
    --- a/src/apps/kpunch/progman/pmProps.c
    +++ b/src/apps/kpunch/progman/pmProps.c
    @@ -57,6 +57,7 @@ typedef struct {
         WidgetT        *icon;
         WidgetT        *hotkey;
         WidgetT        *minimized;
    +    WidgetT        *pause;
         WidgetT        *preview;
     } PropsDlgT;
     
    @@ -131,6 +132,7 @@ static void onBrowseProgram(WidgetT *w) {
         PropsDlgT        *pd        = (PropsDlgT *)w->userData;
         const FileFilterT filters[] = {
             { "Programs (*.app)" },
    +        { SHELL_DOS_FILTER },
             { "All Files (*.*)" }
         };
     
    @@ -184,6 +186,7 @@ static bool onOk(WindowT *dlg, void *userCtx) {
         it->hotkeyKey    = key;
         it->hotkeyMods   = mods;
         it->runMinimized = wgtCheckboxIsChecked(pd->minimized);
    +    it->pause        = wgtCheckboxIsChecked(pd->pause);
         return true;
     }
     
    @@ -228,11 +231,14 @@ bool pmPropsDialog(AppContextT *ctx, const PmModelT *model, int32_t group, int32
         pd.hotkey            = dvxDialogAddField(dlg, "&Shortcut key:", PP_LABEL_W, PM_HOTKEY_TEXT - 1, hotkey);
     
         WidgetT *lowRow = wgtHBox(dlg->widgetRoot);
    +    WidgetT *checks = wgtVBox(lowRow);
     
         lowRow->spacing = wgtPixels(PP_ROW_GAP);
    -    pd.minimized    = wgtCheckbox(lowRow, "Run &minimized");
    +    checks->weight  = WGT_WEIGHT_FILL;
    +    pd.minimized    = wgtCheckbox(checks, "Run &minimized");
    +    pd.pause        = wgtCheckbox(checks, "Wait for a &key when a DOS program ends");
         wgtCheckboxSetChecked(pd.minimized, item->runMinimized);
    -    pd.minimized->weight = WGT_WEIGHT_FILL;
    +    wgtCheckboxSetChecked(pd.pause, item->pause);
     
         int32_t  pitch  = SHELL_PROG_ICON * ctx->display.format.bytesPerPixel;
         uint8_t *blank  = (uint8_t *)calloc((size_t)pitch, SHELL_PROG_ICON);
    diff --git a/src/apps/kpunch/progman/progman.c b/src/apps/kpunch/progman/progman.c
    index 6e3d8c57..4ea17412 100644
    --- a/src/apps/kpunch/progman/progman.c
    +++ b/src/apps/kpunch/progman/progman.c
    @@ -1184,10 +1184,17 @@ static void showSystemInfo(void) {
     // minimized by the shell (shellMinimizeApp).
     static void startItem(const PmItemT *item) {
         PmItemT run = *item;
    -    int32_t id  = shellRunProgram(sAc, run.program, run.args, run.folder);
     
    -    if (id > 0 && run.runMinimized) {
    -        shellMinimizeApp(sAc, id);
    +    // A DOS program runs once the shell gets to it, waiting for a key at
    +    // its end when the item says so.
    +    if (shellIsDosProgram(run.program)) {
    +        shellRunDos(run.program, run.args, run.folder, run.pause);
    +    } else {
    +        int32_t id = shellRunProgram(sAc, run.program, run.args, run.folder);
    +
    +        if (id > 0 && run.runMinimized) {
    +            shellMinimizeApp(sAc, id);
    +        }
         }
     
         updateStatusText();
    diff --git a/src/apps/kpunch/progman/progman.dhs b/src/apps/kpunch/progman/progman.dhs
    index 60f97639..382aca5c 100644
    --- a/src/apps/kpunch/progman/progman.dhs
    +++ b/src/apps/kpunch/progman/progman.dhs
    @@ -132,16 +132,46 @@ Right-click an icon for the same commands.
       Property          What it is
       --------          ----------
       Name              The name shown under the icon. Leave it empty to use the program's own name.
    -  Program           The program to start. Click Browse... to pick it. It can also be a document, such as a text file: it then opens in the program set up for its type (see Running a Command).
    +  Program           The program to start. Click Browse... to pick it. It can be a DOS program (see DOS Programs), or a document, such as a text file: it then opens in the program set up for its type (see Running a Command).
       Arguments         Extra information given to the program when it starts, such as the name of a file to open.
    -  Working folder    The folder where files named in Arguments are looked for. Click Browse... beside it to pick it. For example, with the working folder LETTERS and the argument JUNE.TXT, the program gets LETTERS\JUNE.TXT.
    +  Working folder    The folder where files named in Arguments are looked for. Click Browse... beside it to pick it. For example, with the working folder LETTERS and the argument JUNE.TXT, the program gets LETTERS\JUNE.TXT. A DOS program runs in this folder (empty: the folder the program is in).
       Icon file         A different picture for the icon: a program file (its icon is used) or a picture (BMP, PNG, JPEG, or GIF). Leave it empty for the program's own icon. The small picture beside Run minimized shows the icon.
       Shortcut key      Keys that start the item from any program, whichever window is in front, such as Ctrl+Alt+N. They work while the Program Manager is running (it may be minimized), but not while a menu or a dialog is open. A shortcut is Ctrl together with Alt or Shift (or both), plus a letter, a digit, or F1 to F12. Each shortcut can belong to one item only.
       Run minimized     Start the program with its windows made into icons.
    +  Wait for a key    For a DOS program: when it ends, DVX shows "Press any key to return to DVX." and waits, so you can read what the program printed.
     .endtable
     
     Items in Installed Programs have no properties to change; Properties just shows the program's name, file, and description.
     
    +.topic progman.dos
    +.title DOS Programs
    +.toc 0 DOS Programs
    +.index DOS Programs
    +.index EXE
    +.index COM
    +.index BAT
    +.index Batch Files
    +.index Running DOS Programs
    +.index Wait for a key
    +
    +.h1 DOS Programs
    +
    +Program Manager can start ordinary DOS programs too: games, editors, and other programs written for DOS, and batch files. Such a program has the whole screen to itself while it runs; when it ends, DVX comes back with the desktop as it was.
    +
    +.h2 Adding a DOS Program
    +
    +Choose File > New Item, and type the program's file (for example C:\GAMES\PINBALL.EXE) in the Program box, or click Browse... and choose DOS programs in the Filter box. A DOS program is a file ending in .EXE, .COM, or .BAT. Give the item a name, and if the program needs them, Arguments and a Working folder (the folder it runs in; empty means the folder the program is in). Turn on Wait for a key when a DOS program ends for a program that prints something and stops, so you can read it before DVX comes back. The item gets a DOS icon unless you choose an Icon file.
    +
    +.h2 Running It
    +
    +Start the item as any other. DVX shows the programs that will close first and asks whether to go on: each one closes the way it would if you closed it, asking about unsaved work, so nothing is lost. If you choose No, or keep a program open, the DOS program does not run.
    +
    +Then DVX steps aside: the screen changes to the DOS screen, and the program runs with the keyboard, the mouse, and the sound card to itself. When it ends, DVX comes back, and the Program Manager is there again. Programs that were closed stay closed; start them again when you need them.
    +
    +DVX stays in memory while the program runs, so a program that needs a lot of memory may not start (DVX says why). Such a program is best started from DOS, after leaving DVX.
    +
    +DOS programs can also be run from File > Run..., opened from the File Manager (double-click one), and set to open documents on the File Types page of the Control Panel.
    +
     .topic progman.arrange
     .title Views and Arranging Icons
     .toc 0 Views and Arranging
    diff --git a/src/apps/kpunch/progman/userHelp.dhs b/src/apps/kpunch/progman/userHelp.dhs
    index 399590ff..3d21cdd7 100644
    --- a/src/apps/kpunch/progman/userHelp.dhs
    +++ b/src/apps/kpunch/progman/userHelp.dhs
    @@ -530,7 +530,7 @@ Programs that can print have Print... in their File menu, and programs that prin
     
     .h2 The Print Dialog
     
    -File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print. When the printout goes into a file, type the file's name, or click Browse... to pick it.
    +File > Print... opens the Print dialog. Choose where the printout goes (a printer port such as LPT1, a serial port such as COM1, or a file), how many copies you want, and (for a document of several pages) whether to print all of it or only some pages. Then choose Print. When the printout goes into a file, type the file's name, or click Browse... to pick it.
     
     While a printout is going to the printer, a small Printing window shows it is busy. You can keep working in other programs, and Cancel stops the printout.
     
    @@ -544,7 +544,9 @@ File > Page Setup... chooses how text is laid out on paper: the margins, how man
     
     .h2 Pictures
     
    -Pictures are printed in black and white, as large as fits on the page. Printing pictures needs your printer's model to be set; with the setting for text only, a program tells you so.
    +Pictures print as large as fits on the page: in color on a color printer (one with a color ribbon or cartridge), and in shades of grey on the others. Printing pictures needs your printer's model to be set (see Changing Settings); with the setting for text only, a program tells you so.
    +
    +Some programs draw whole pages of their own, such as reports with charts. On a PostScript printer the printer draws those pages itself, as sharp as it can print. On other printers their text prints crisper with the Printer Fonts add-on installed (see Adding and Removing Software).
     
     .topic user.programs
     .title Running Programs
    @@ -558,6 +560,8 @@ Pictures are printed in black and white, as large as fits on the page. Printing
     .index Whole screen
     .index Program Help
     .index About
    +.index DOS Programs
    +.index Running DOS Programs
     
     .h1 Running Programs
     
    @@ -571,13 +575,13 @@ To start a program file that has no icon, choose Run... from the Program Manager
     
     .h2 Opening Documents
     
    -A document can be opened the same way as a program: DVX starts the program set up for its kind of file (the letters after the dot in its name, such as TXT) and gives it the document, which the program opens right away. Which program opens which kind of file is set in the settings program (see Changing Settings).
    +A document can be opened the same way as a program: DVX starts the program set up for its kind of file (the letters after the dot in its name, such as TXT) and gives it the document, which the program opens right away: a picture opens in the Image Viewer, and a music or sound file (MOD, S3M, XM, IT, MID, RMI, WAV, or VOC) plays in Media Player. Which program opens which kind of file is set in the settings program (see Changing Settings).
     
     .h2 Running Several Programs
     
     You can run many programs at the same time, each in its own window. They all keep running while you work in another one.
     
    -Most programs can only run once at a time. If you start a program that is already running, DVX tells you so; find its window instead with Alt+Tab or the Task Manager. Some programs can be opened more than once, each copy in its own window.
    +Most programs can only run once at a time. If you start a program that is already running, DVX tells you so; find its window instead with Alt+Tab or the Task Manager. A few take the document you opened instead: a music file opened while Media Player is running is added to its list and plays. Some programs can be opened more than once, each copy in its own window.
     
     .h2 Switching Between Programs
     
    @@ -591,6 +595,10 @@ Most programs can only run once at a time. If you start a program that is alread
     
     Some programs, games especially, can fill the whole screen instead of a window (many use Alt+Enter to switch). Your other programs keep running while they do. Alt+Tab and Ctrl+Esc still work and bring the desktop back; when you return to the program, it fills the screen again.
     
    +.h2 Running DOS Programs
    +
    +DVX can start ordinary DOS programs too (files ending in .EXE, .COM, or .BAT): from an icon in the Program Manager, from Run..., or by double-clicking one in the File Manager. Such a program has the whole screen to itself. Before it starts, DVX names the programs that will close and asks whether to go on; each one closes as it would if you closed it, asking about unsaved work. When the DOS program ends, DVX comes back with the Program Manager. The Program Manager's own help (DOS Programs) tells how to add one as an icon.
    +
     .h2 Closing a Program
     
     Close the program's main window, or choose Exit from its File menu. A program that has unsaved work usually asks whether you want to save it first. See Working with Windows for all the ways to close a window.
    @@ -695,6 +703,8 @@ If DVX does not start after you installed something, hold down a Shift key while
     .index Themes
     .index Wallpaper
     .index Desktop Picture
    +.index Screen Saver
    +.index Screensaver
     .index Mouse Settings
     .index Video Mode
     .index Screen Resolution
    @@ -706,12 +716,14 @@ If DVX does not start after you installed something, hold down a Shift key while
     
     .h1 Changing Settings
     
    -The mouse, the screen colors, the desktop picture, the screen resolution, which program opens each kind of document, the network, the printer, joysticks, and the sounds are changed with the settings program, which has an icon in the Installed Programs group of the Program Manager. Start it like any other program. Its own help, opened with F1 or its Help menu, explains every setting.
    +The mouse, the screen colors, the desktop picture and the screen saver, the screen resolution, which program opens each kind of document, the network, the printer, joysticks, and the sounds are changed with the settings program, which has an icon in the Installed Programs group of the Program Manager. Start it like any other program. Its own help, opened with F1 or its Help menu, explains every setting.
     
     Settings you keep are saved, and DVX uses them again the next time it starts.
     
     DVX plays a short sound when it starts and when it ends, and when a message appears (a different one for errors, warnings, questions, and information). Each sound can be changed or turned off, and the volume set, in the settings program.
     
    +A screen saver can fill the screen with a moving picture when the computer has not been used for a while; pressing a key or moving the mouse brings your work back. Choose one, and how many minutes to wait, on the Desktop page of the settings program. There is none until you choose one.
    +
     .note tip
     If a new screen resolution leaves the screen unreadable, just wait: DVX goes back to the old one after 15 seconds unless you confirm the change.
     .endnote
    @@ -720,7 +732,7 @@ If a new screen resolution leaves the screen unreadable, just wait: DVX goes bac
     
     Everything DVX and its programs save for you goes in one folder, the USER folder inside the DVX folder (for example C:\DVX\USER): your settings, each program's settings and the files it saves (such as saved games), Terminal's downloads, and DVX's log. To back up your work, copy this one folder. Installing a new version of DVX never changes it.
     
    -The fonts, sounds, themes, and desktop pictures that come with DVX are in the SHARED folder. To add your own, put them in the folder of the same name in your USER folder: FONTS, SOUNDS, THEMES, or WPAPER. The settings program lists yours together with DVX's, and a file of yours with the same name as one of DVX's is used instead of it. Themes you save go there too.
    +The fonts, sounds, themes, desktop pictures, and screen savers that come with DVX are in the SHARED folder. To add your own, put them in the folder of the same name in your USER folder: FONTS, SOUNDS, THEMES, WPAPER, or SAVERS. The settings program lists yours together with DVX's, and a file of yours with the same name as one of DVX's is used instead of it. Themes you save go there too.
     
     To keep the USER folder somewhere else (on another disk, or one for each person who uses the computer), set DVXUSER to that folder before starting DVX, for example SET DVXUSER=D:\MYDVX in AUTOEXEC.BAT.
     
    @@ -898,7 +910,9 @@ You can pick another resolution at any time with the settings program (see Chang
     
     .h2 There is no sound
     
    -DVX finds the sound hardware by itself. A Sound Blaster needs its BLASTER setting in AUTOEXEC.BAT, for example SET BLASTER=A220 I5 D1 H5 (the card's manual gives the numbers). Without a sound card DVX uses the PC speaker, which plays tones and beeps but not recorded sounds. The settings program shows the sound hardware in use, lets you pick it yourself, and sets the volume; if a sound is set to none there, nothing is heard for it. Without any sound hardware DVX and its programs work the same, silently.
    +DVX finds the sound hardware by itself. A Sound Blaster needs its BLASTER setting in AUTOEXEC.BAT, for example SET BLASTER=A220 I5 D1 H5, and a Gravis UltraSound its ULTRASND setting, for example SET ULTRASND=220,1,1,11,5 (the card's manual or setup program gives the numbers; a Sound Blaster AWE's setting includes E620 for its music chip). A Windows Sound System card is found at its usual addresses; a sound device on the printer port (a Disney Sound Source or a Stereo-on-1) is found on its own, while a Covox Speech Thing must be chosen in the settings file (DVX.INI [audio] lptDac = covox). Without a sound card DVX uses the PC speaker, which plays tones and beeps but not recorded sounds. The settings program shows the sound hardware in use, lets you pick it yourself, and sets the volume; if a sound is set to none there, nothing is heard for it. Without any sound hardware DVX and its programs work the same, silently.
    +
    +MIDI music plays on the sound card's own recorded instruments (a Gravis UltraSound or a Sound Blaster AWE) when DVX's General MIDI SoundFont is installed (the Software program offers it) or the card's own instruments are there; otherwise on its music chip, or on DVX's own synthesizer. Music modules (MOD, S3M, XM, and IT files) play on a Gravis UltraSound's or a Sound Blaster AWE's own voices, which takes the computer little time; on other cards the computer mixes them, which takes much of a slow computer's time (the Control Panel's Sounds page can have them always mixed).
     
     .h2 An empty floppy or CD-ROM drive
     
    diff --git a/src/apps/kpunch/recorder/ICON32.BMP b/src/apps/kpunch/recorder/ICON32.BMP
    new file mode 100644
    index 00000000..2f9448d9
    --- /dev/null
    +++ b/src/apps/kpunch/recorder/ICON32.BMP
    @@ -0,0 +1,3 @@
    +version https://git-lfs.github.com/spec/v1
    +oid sha256:a32c5333170cc9af696bc02cf29754938b249600a6866db783a77186e141533a
    +size 3126
    diff --git a/src/apps/kpunch/recorder/recorder.dbp b/src/apps/kpunch/recorder/recorder.dbp
    new file mode 100644
    index 00000000..c897f3f9
    --- /dev/null
    +++ b/src/apps/kpunch/recorder/recorder.dbp
    @@ -0,0 +1,25 @@
    +[Project]
    +Name = Sound Recorder
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = Records sound from a microphone, a line input, or a CD into WAV files, and a MIDI keyboard into MIDI files
    +Icon = ICON32.BMP
    +
    +[Modules]
    +File0 = ../../../include/basic/commdlg.bas
    +File1 = ../../../include/basic/fileops.bas
    +File2 = ../../../include/basic/help.bas
    +File3 = ../../../include/basic/music.bas
    +File4 = ../../../include/basic/prefs.bas
    +File5 = ../../../include/basic/sound.bas
    +Count = 6
    +
    +[Forms]
    +File0 = recorder.frm
    +Count = 1
    +
    +[Settings]
    +StartupForm = RecorderForm
    +MultiInstance = False
    diff --git a/src/apps/kpunch/recorder/recorder.dhs b/src/apps/kpunch/recorder/recorder.dhs
    new file mode 100644
    index 00000000..f3c27a28
    --- /dev/null
    +++ b/src/apps/kpunch/recorder/recorder.dhs
    @@ -0,0 +1,87 @@
    +# 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.
    +
    +# Sound Recorder help.  Compiled to recorder.hlp next to recorder.app;
    +# F1 and Help > Contents in Sound Recorder open it.
    +
    +.topic recorder.overview
    +.title Sound Recorder
    +.toc 0 Sound Recorder
    +.default
    +.index Sound Recorder
    +.index Recording
    +.index Microphone
    +.index Line In
    +.index WAV
    +.index MIDI keyboard
    +.index MIDI file
    +
    +.h1 Sound Recorder
    +
    +Sound Recorder records sound from your sound card's microphone, line, or CD input, plays it back, and saves it as a WAV file, which Media Player and other programs can play. It also records what you play on a MIDI keyboard into a MIDI file.
    +
    +.h2 Making a Recording
    +
    +.list
    +.item Choose the input on the Options menu: Microphone, Line In (a tape player, a radio, or another device plugged into the sound card), or CD Audio (a music CD in the CD-ROM drive).
    +.item Click Record. The time counts up and the level bar shows how loud the sound is coming in: speak or play a little louder if it barely moves, and a little softer if it stays at the end.
    +.item Click Stop when you are done, then Play to hear it.
    +.item Choose File > Save As... to keep it. A recording you have not saved is lost when you record again, start a new one, or close Sound Recorder; you are asked first.
    +.endlist
    +
    +While Sound Recorder records, no other program can play sounds or music made by the sound card: most sound cards can record or play, but not both at once. Everything plays again when the recording stops.
    +
    +If an input cannot be chosen on the Options menu, your sound card does not have it. If Record cannot be clicked, the sound card cannot record at all.
    +
    +.h2 Recording a MIDI Keyboard
    +
    +A MIDI keyboard (or another instrument) plugged into the sound card's MIDI port can be recorded too. First set MIDI in to the MIDI port on the Sounds page of Control Panel, and start DVX again; then choose MIDI Keyboard on the Options menu (it cannot be chosen until then).
    +
    +Click Record and play. What you play sounds as you play it, on the computer's music synthesizer, and the level bar shows how hard you strike the keys. Click Stop when you are done, and Play to hear it again. Save As... keeps it as a MIDI file (.MID), which Media Player and other programs play with whatever instruments the computer has; it takes very little disk space. The quality chosen on the Options menu does not matter for a MIDI recording, and other programs can still play sounds while you record.
    +
    +.h2 Quality
    +
    +The Options menu also chooses how good the recording sounds, and how much disk space it takes:
    +
    +.table
    +  Quality     Sounds like                                   Space for a minute
    +  -------     -----------                                   ------------------
    +  Telephone   A telephone call; fine for speech             About 470 KB
    +  Radio       An AM radio; good for speech and most sounds  About 1.3 MB
    +  CD          A music CD, in stereo                         About 10 MB
    +.endtable
    +
    +Your sound card may record a little differently from what you choose (an older card cannot record in stereo, for example); the line under the buttons shows what it records.
    +
    +.h2 Files
    +
    +.table
    +  Command      What it does
    +  -------      ------------
    +  New          Start over with no recording.
    +  Open...      Open a WAV or MIDI file to play it.
    +  Save         Save the recording; Sound Recorder asks for its name.
    +  Save As...   Save the recording, or the file you opened, under a new name.
    +  Exit         Close Sound Recorder.
    +.endtable
    +
    +Sound Recorder remembers the quality, the input, and the folder you last used.
    diff --git a/src/apps/kpunch/recorder/recorder.frm b/src/apps/kpunch/recorder/recorder.frm
    new file mode 100644
    index 00000000..9b529a9e
    --- /dev/null
    +++ b/src/apps/kpunch/recorder/recorder.frm
    @@ -0,0 +1,918 @@
    +VERSION DVX 1.00
    +Begin Form RecorderForm
    +    Caption   = "Sound Recorder"
    +    Layout    = VBox
    +    AutoSize  = False
    +    Resizable = False
    +    Centered  = True
    +    Width     = 380
    +    Height    = 210
    +    Begin Menu mnuFile
    +        Caption = "&File"
    +        Begin Menu mnuNew
    +            Caption  = "&New"
    +            Shortcut = "Ctrl+N"
    +        End
    +        Begin Menu mnuOpen
    +            Caption  = "&Open..."
    +            Shortcut = "Ctrl+O"
    +        End
    +        Begin Menu mnuSave
    +            Caption  = "&Save"
    +            Shortcut = "Ctrl+S"
    +        End
    +        Begin Menu mnuSaveAs
    +            Caption = "Save &As..."
    +        End
    +        Begin Menu mnuFileSep
    +            Caption = "-"
    +        End
    +        Begin Menu mnuExit
    +            Caption = "E&xit"
    +        End
    +    End
    +    Begin Menu mnuOptions
    +        Caption = "&Options"
    +        Begin Menu mnuPhone
    +            Caption    = "&Telephone Quality"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +        Begin Menu mnuRadio
    +            Caption    = "&Radio Quality"
    +            RadioCheck = True
    +            Checked    = True
    +        End
    +        Begin Menu mnuCd
    +            Caption    = "&CD Quality"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +        Begin Menu mnuOptionsSep
    +            Caption = "-"
    +        End
    +        Begin Menu mnuInMic
    +            Caption    = "&Microphone"
    +            RadioCheck = True
    +            Checked    = True
    +        End
    +        Begin Menu mnuInLine
    +            Caption    = "&Line In"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +        Begin Menu mnuInCd
    +            Caption    = "C&D Audio"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +        Begin Menu mnuInMidi
    +            Caption    = "MIDI &Keyboard"
    +            RadioCheck = True
    +            Checked    = False
    +        End
    +    End
    +    Begin Menu mnuHelp
    +        Caption = "&Help"
    +        Begin Menu mnuHelpContents
    +            Caption      = "&Contents"
    +            ShortcutText = "F1"
    +        End
    +        Begin Menu mnuHelpSep
    +            Caption = "-"
    +        End
    +        Begin Menu mnuAbout
    +            Caption = "&About Sound Recorder..."
    +        End
    +    End
    +    Begin Label lblFile
    +        Caption  = "New recording"
    +        FontBold = True
    +        Weight   = 0
    +    End
    +    Begin Label lblState
    +        Caption = "Ready to record"
    +        Weight  = 0
    +    End
    +    Begin HBox rowLevel
    +        Weight = 0
    +        Begin Label lblTime
    +            Caption  = "0:00.0"
    +            MinWidth = 64
    +            Weight   = 0
    +        End
    +        Begin ProgressBar prgLevel
    +            Weight      = 1
    +            ToolTipText = "How loud the sound being recorded is"
    +        End
    +    End
    +    Begin HBox rowButtons
    +        Weight = 0
    +        Begin CommandButton btnRecord
    +            Caption  = "&Record"
    +            MinWidth = 80
    +            Weight   = 0
    +        End
    +        Begin CommandButton btnStop
    +            Caption  = "S&top"
    +            MinWidth = 80
    +            Weight   = 0
    +        End
    +        Begin CommandButton btnPlay
    +            Caption  = "&Play"
    +            MinWidth = 80
    +            Weight   = 0
    +        End
    +    End
    +    Begin Label lblFormat
    +        Caption = ""
    +        Weight  = 0
    +    End
    +    Begin Timer tmrTick
    +        Interval = 100
    +        Enabled  = False
    +    End
    +End
    +
    +' 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.
    +
    +' Sound Recorder: records sound from the sound card's microphone, line,
    +' or CD input into a WAV file, or what is played on a MIDI keyboard into
    +' a MIDI file, plays it back, and saves it.
    +'
    +' A recording goes into RECORDING.WAV (RECORDING.MID) in the program's
    +' own user folder (App.User) until it is saved: Save As copies it where
    +' the user says.  Most sound cards record or play, not both, so nothing
    +' else plays while it records sound.  While it records MIDI, what is
    +' played sounds as it comes (on the music synthesizer) and is kept in a
    +' MIDI recording, written out at Stop.  A timer shows the length and the
    +' level while recording (faster for MIDI, so what is played is heard
    +' soon) and notices the end of playing.  The quality and the input are
    +' kept in RECORDER.INI beside the recording.
    +
    +OPTION EXPLICIT
    +
    +CONST APP_TITLE   = "Sound Recorder"
    +CONST HELP_FILE   = "recorder.hlp"
    +CONST WAV_FILTER  = "Sounds (*.wav)|All Files (*.*)"
    +CONST MID_FILTER  = "MIDI music (*.mid)|All Files (*.*)"
    +CONST OPEN_FILTER = "Recordings (*.wav;*.mid)|All Files (*.*)"
    +CONST TEMP_FILE   = "RECORDING.WAV"
    +CONST TEMP_MIDI   = "RECORDING.MID"
    +CONST WAV_EXT     = ".wav"
    +CONST MID_EXT     = ".mid"
    +
    +' Settings: RECORDER.INI in App.User
    +CONST SETTINGS_FILE = "RECORDER.INI"
    +CONST INI_RECORDER  = "Recorder"
    +CONST KEY_QUALITY   = "Quality"
    +CONST KEY_INPUT     = "Input"
    +CONST KEY_FOLDER    = "Folder"
    +CONST QUALITY_PHONE = "telephone"
    +CONST QUALITY_RADIO = "radio"
    +CONST QUALITY_CD    = "cd"
    +CONST INPUT_MIDI    = -1      ' the MIDI keyboard (the others are RECORD_* inputs)
    +
    +' The qualities: telephone 8 kHz 8-bit mono, radio 22 kHz 8-bit mono, CD
    +' 44.1 kHz 16-bit stereo
    +CONST PHONE_RATE = 8000
    +CONST RADIO_RATE = 22050
    +CONST CD_RATE    = 44100
    +CONST BITS_LOW   = 8
    +CONST BITS_HIGH  = 16
    +CONST MONO       = 1
    +CONST STEREO     = 2
    +
    +CONST STATE_IDLE      = 0
    +CONST STATE_RECORDING = 1
    +CONST STATE_PLAYING   = 2
    +
    +CONST MS_PER_SEC   = 1000
    +CONST MS_PER_TENTH = 100
    +CONST SEC_PER_MIN  = 60
    +
    +' The timer: sound, and MIDI (what is played is heard this soon)
    +CONST TICK_SOUND_MS = 100
    +CONST TICK_MIDI_MS  = 20
    +
    +' Recording MIDI, the level: how hard the last note was played, falling
    +' away a little each tick
    +CONST VELOCITY_MAX = 127
    +CONST LEVEL_FALL   = 4
    +CONST PERCENT      = 100
    +CONST BYTE_MASK    = 255
    +CONST DATA2_SCALE  = 65536    ' a packed message's second data byte (the velocity)
    +
    +DIM SHARED state AS INTEGER
    +DIM SHARED voice AS LONG           ' playing back a sound (0: nothing)
    +DIM SHARED music AS LONG           ' playing back MIDI music (0: nothing)
    +DIM SHARED soundIsMidi AS BOOLEAN  ' the recording or file shown is MIDI music
    +DIM SHARED thru AS LONG            ' recording MIDI: where what is played sounds (0: nowhere)
    +DIM SHARED take AS LONG            ' recording MIDI: the messages so far
    +DIM SHARED level AS LONG           ' recording MIDI: the level shown
    +DIM SHARED currentFile AS STRING   ' the file shown: saved or opened ("" for a recording not saved)
    +DIM SHARED haveSound AS BOOLEAN    ' something to play
    +DIM SHARED dirty AS BOOLEAN        ' a recording not saved
    +DIM SHARED lengthMs AS LONG
    +DIM SHARED formatText AS STRING    ' what the recording is (rate, bits, channels)
    +DIM SHARED lastFolder AS STRING    ' where the file dialogs start
    +
    +
    +' ============================================================
    +' Window
    +' ============================================================
    +
    +SUB RecorderForm_Load
    +    LoadSettings
    +    ShowInputs
    +    ShowState
    +END SUB
    +
    +
    +' Recording and playing stop first; a recording not saved is asked about.
    +SUB RecorderForm_QueryUnload(Cancel AS INTEGER)
    +    StopAll
    +    Cancel = AskSave()
    +END SUB
    +
    +
    +SUB RecorderForm_Unload
    +    SaveSettings
    +    DropTemp
    +END SUB
    +
    +
    +' ============================================================
    +' Recording and playing
    +' ============================================================
    +
    +' A new recording replaces the one not saved (asked first).
    +SUB StartRecording
    +    DIM rate AS LONG
    +    DIM bits AS LONG
    +    DIM channels AS LONG
    +
    +    IF NOT mnuInMidi.Checked AND SoundCaps("record") = False THEN
    +        MsgBox "The sound card in this computer cannot record.", vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT SUB
    +    END IF
    +
    +    IF AskSave() THEN
    +        EXIT SUB
    +    END IF
    +
    +    StopAll
    +
    +    IF mnuInMidi.Checked THEN
    +        IF NOT MidiStart() THEN
    +            EXIT SUB
    +        END IF
    +
    +        formatText       = "MIDI"
    +        tmrTick.Interval = TICK_MIDI_MS
    +    ELSE
    +        QualityChosen rate, bits, channels
    +
    +        IF NOT RecordStart(FileOpsPathJoin$(App.User, TEMP_FILE), rate, bits, channels, InputChosen()) THEN
    +            MsgBox "Sound Recorder cannot record." + CHR$(10) + CHR$(10) + SoundError$(), vbOKOnly + vbExclamation, APP_TITLE
    +            EXIT SUB
    +        END IF
    +
    +        formatText = FormatOf$(RecordRate(), RecordBits(), RecordChannels())
    +    END IF
    +
    +    state           = STATE_RECORDING
    +    soundIsMidi     = mnuInMidi.Checked
    +    currentFile     = ""
    +    haveSound       = False
    +    dirty           = False
    +    lengthMs        = 0
    +    tmrTick.Enabled = True
    +    ShowState
    +END SUB
    +
    +
    +' The MIDI input, an empty recording, and a live music for what is
    +' played (none when nothing can play it: the recording goes on).
    +FUNCTION MidiStart() AS BOOLEAN
    +    MidiStart = False
    +
    +    IF NOT MidiInOpen() THEN
    +        MsgBox "Sound Recorder cannot record from the MIDI keyboard." + CHR$(10) + CHR$(10) + SoundError$(), vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT FUNCTION
    +    END IF
    +
    +    take = MidiRecordNew()
    +
    +    IF take = 0 THEN
    +        MidiInClose
    +        MsgBox "Sound Recorder cannot record." + CHR$(10) + CHR$(10) + MusicError$(), vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT FUNCTION
    +    END IF
    +
    +    thru      = MusicMidiOpen()
    +    level     = 0
    +    MidiStart = True
    +END FUNCTION
    +
    +
    +' The MIDI recording ends: what came last is taken, what is sounding
    +' stops, and the messages are written to RECORDING.MID.
    +SUB MidiStop
    +    MidiTake
    +    MidiInClose
    +
    +    IF thru <> 0 THEN
    +        MusicFree thru
    +        thru = 0
    +    END IF
    +
    +    lengthMs = MidiRecordLength(take)
    +
    +    IF MidiRecordSave(take, FileOpsPathJoin$(App.User, TEMP_MIDI)) THEN
    +        haveSound = MidiRecordCount(take) > 0
    +        dirty     = haveSound
    +    ELSE
    +        MsgBox "The recording could not be written." + CHR$(10) + CHR$(10) + MusicError$(), vbOKOnly + vbExclamation, APP_TITLE
    +    END IF
    +
    +    MidiRecordFree take
    +    take             = 0
    +    tmrTick.Interval = TICK_SOUND_MS
    +END SUB
    +
    +
    +' The messages that came: played as they come and kept.  A note's
    +' velocity becomes the level, which falls away between notes.
    +SUB MidiTake
    +    DIM midiMsg AS LONG
    +    DIM velocity AS LONG
    +
    +    level = level - LEVEL_FALL
    +
    +    IF level < 0 THEN
    +        level = 0
    +    END IF
    +
    +    midiMsg = MidiInMessage()
    +
    +    DO WHILE midiMsg <> MIDI_NONE
    +        IF thru <> 0 THEN
    +            MusicMidiSend thru, midiMsg
    +        END IF
    +
    +        MidiRecordAdd take, MidiInTime(), midiMsg
    +        velocity = (midiMsg \ DATA2_SCALE) AND BYTE_MASK
    +
    +        IF (midiMsg AND MIDI_KIND_MASK) = MIDI_NOTE_ON AND velocity > 0 THEN
    +            level = velocity * PERCENT \ VELOCITY_MAX
    +        END IF
    +
    +        midiMsg = MidiInMessage()
    +    LOOP
    +END SUB
    +
    +
    +' Plays the recording, or the file opened.
    +SUB StartPlaying
    +    DIM failure AS STRING
    +
    +    IF NOT haveSound THEN
    +        EXIT SUB
    +    END IF
    +
    +    StopAll
    +
    +    IF soundIsMidi THEN
    +        music = MusicLoad(SoundPath$())
    +
    +        IF music <> 0 THEN
    +            MusicPlay music, False
    +        ELSE
    +            failure = MusicError$()
    +        END IF
    +    ELSE
    +        voice = PlayWave(SoundPath$())
    +
    +        IF voice = 0 THEN
    +            failure = SoundError$()
    +        END IF
    +    END IF
    +
    +    IF music = 0 AND voice = 0 THEN
    +        MsgBox "Sound Recorder cannot play this recording." + CHR$(10) + CHR$(10) + failure, vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT SUB
    +    END IF
    +
    +    state           = STATE_PLAYING
    +    tmrTick.Enabled = True
    +    ShowState
    +END SUB
    +
    +
    +' Whether what Play started still plays.
    +FUNCTION StillPlaying() AS BOOLEAN
    +    IF soundIsMidi THEN
    +        StillPlaying = MusicPlaying(music)
    +    ELSE
    +        StillPlaying = SoundPlaying(voice)
    +    END IF
    +END FUNCTION
    +
    +
    +' Stop recording (the recording is kept) or playing.
    +SUB StopAll
    +    IF state = STATE_RECORDING AND soundIsMidi THEN
    +        MidiStop
    +    ELSEIF state = STATE_RECORDING THEN
    +        lengthMs = RecordLength()
    +
    +        IF RecordStop() THEN
    +            haveSound = True
    +            dirty     = lengthMs > 0
    +        ELSE
    +            MsgBox "The recording could not all be written." + CHR$(10) + CHR$(10) + SoundError$(), vbOKOnly + vbExclamation, APP_TITLE
    +        END IF
    +    ELSEIF state = STATE_PLAYING AND soundIsMidi THEN
    +        MusicFree music
    +        music = 0
    +    ELSEIF state = STATE_PLAYING THEN
    +        StopWave voice
    +        voice = 0
    +    END IF
    +
    +    state           = STATE_IDLE
    +    tmrTick.Enabled = False
    +    ShowState
    +END SUB
    +
    +
    +SUB tmrTick_Timer
    +    IF state = STATE_RECORDING AND soundIsMidi THEN
    +        MidiTake
    +        lblTime.Caption = TimeText$(MidiInClock())
    +        prgLevel.Value  = level
    +    ELSEIF state = STATE_RECORDING THEN
    +        lblTime.Caption = TimeText$(RecordLength())
    +        prgLevel.Value  = RecordLevel()
    +    ELSEIF state = STATE_PLAYING AND NOT StillPlaying() THEN
    +        voice = 0       ' it ended: the number may be another sound's by now
    +        StopAll
    +    END IF
    +END SUB
    +
    +
    +' ============================================================
    +' Files
    +' ============================================================
    +
    +' The recording not saved, or the file shown.
    +FUNCTION SoundPath$()
    +    IF currentFile <> "" THEN
    +        SoundPath$ = currentFile
    +    ELSEIF soundIsMidi THEN
    +        SoundPath$ = FileOpsPathJoin$(App.User, TEMP_MIDI)
    +    ELSE
    +        SoundPath$ = FileOpsPathJoin$(App.User, TEMP_FILE)
    +    END IF
    +END FUNCTION
    +
    +
    +' The recordings not saved go (the program is ending).
    +SUB DropTemp
    +    DropFile FileOpsPathJoin$(App.User, TEMP_FILE)
    +    DropFile FileOpsPathJoin$(App.User, TEMP_MIDI)
    +END SUB
    +
    +
    +SUB DropFile(path AS STRING)
    +    ON ERROR GOTO noFile
    +    KILL path
    +noFile:
    +END SUB
    +
    +
    +' Ask about a recording not saved: True to stay where we are (Cancel, or
    +' Save As cancelled), False to go on.
    +FUNCTION AskSave() AS BOOLEAN
    +    DIM result AS INTEGER
    +
    +    AskSave = False
    +
    +    IF NOT dirty THEN
    +        EXIT FUNCTION
    +    END IF
    +
    +    result = DlgPromptSave(APP_TITLE)
    +
    +    IF result = DVX_SAVE_YES THEN
    +        SaveAs
    +        AskSave = dirty
    +    ELSEIF result <> DVX_SAVE_NO THEN
    +        AskSave = True
    +    END IF
    +END FUNCTION
    +
    +
    +' Copies the recording to a WAV (MIDI) file the user names.
    +SUB SaveAs
    +    DIM path AS STRING
    +    DIM filter AS STRING
    +    DIM ext AS STRING
    +
    +    IF soundIsMidi THEN
    +        filter = MID_FILTER
    +        ext    = MID_EXT
    +    ELSE
    +        filter = WAV_FILTER
    +        ext    = WAV_EXT
    +    END IF
    +
    +    path = DlgFileSaveIn$("Save As", filter, lastFolder)
    +
    +    IF path = "" THEN
    +        EXIT SUB
    +    END IF
    +
    +    IF FileOpsExtension$(path) = "" THEN
    +        path = path + ext
    +    END IF
    +
    +    ON ERROR GOTO copyFailed
    +    FILECOPY SoundPath$(), path
    +    currentFile = path
    +    lastFolder  = FileOpsPathParent$(path)
    +    dirty       = False
    +    ShowState
    +    EXIT SUB
    +
    +copyFailed:
    +    MsgBox "Sound Recorder could not save " + path + ".", vbOKOnly + vbExclamation, APP_TITLE
    +END SUB
    +
    +
    +' A WAV or MIDI file to play (its kind and length read with MusicLoad).
    +SUB OpenSound(path AS STRING)
    +    DIM snd AS LONG
    +    DIM kind AS STRING
    +
    +    snd = MusicLoad(path)
    +
    +    IF snd = 0 THEN
    +        MsgBox "Sound Recorder cannot open " + FileOpsBaseName$(path) + "." + CHR$(10) + CHR$(10) + MusicError$(), vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT SUB
    +    END IF
    +
    +    kind     = MusicInfo$(snd, "kind")
    +    lengthMs = MusicLength(snd)
    +    MusicFree snd
    +
    +    IF kind <> "sound" AND kind <> "midi" THEN
    +        MsgBox "Sound Recorder cannot open " + FileOpsBaseName$(path) + "." + CHR$(10) + CHR$(10) + "It is not a WAV or MIDI file.", vbOKOnly + vbExclamation, APP_TITLE
    +        EXIT SUB
    +    END IF
    +
    +    soundIsMidi = kind = "midi"
    +    currentFile = path
    +    lastFolder  = FileOpsPathParent$(path)
    +    haveSound   = True
    +    dirty       = False
    +    formatText  = ""
    +    ShowState
    +END SUB
    +
    +
    +' ============================================================
    +' Showing it
    +' ============================================================
    +
    +' The buttons, the title, the state, and the length as they are now.
    +SUB ShowState
    +    DIM recordOk AS BOOLEAN
    +    DIM title AS STRING
    +
    +    recordOk = CanRecord()
    +
    +    btnRecord.Enabled = recordOk AND state <> STATE_RECORDING
    +    btnStop.Enabled   = state <> STATE_IDLE
    +    btnPlay.Enabled   = haveSound AND state = STATE_IDLE
    +    mnuSave.Enabled   = dirty
    +    mnuSaveAs.Enabled = haveSound
    +
    +    IF currentFile <> "" THEN
    +        title = FileOpsBaseName$(currentFile)
    +    ELSEIF haveSound THEN
    +        title = "New recording (not saved)"
    +    ELSE
    +        title = "New recording"
    +    END IF
    +
    +    lblFile.Caption      = title
    +    RecorderForm.Caption = title + " - " + APP_TITLE
    +
    +    IF state = STATE_RECORDING THEN
    +        lblState.Caption = "Recording from the " + InputName$() + "..."
    +    ELSEIF state = STATE_PLAYING THEN
    +        lblState.Caption = "Playing"
    +    ELSEIF NOT recordOk THEN
    +        lblState.Caption = "This computer's sound card cannot record."
    +    ELSE
    +        lblState.Caption = "Stopped"
    +    END IF
    +
    +    IF state <> STATE_RECORDING THEN
    +        lblTime.Caption = TimeText$(lengthMs)
    +        prgLevel.Value  = 0
    +    END IF
    +
    +    lblFormat.Caption = formatText
    +END SUB
    +
    +
    +' Inputs the computer does not have are greyed (MIDI Keyboard: no MIDI
    +' input in the settings); the one chosen moves to the microphone.
    +SUB ShowInputs
    +    DIM sources AS LONG
    +
    +    sources = SoundCaps("recordsources")
    +
    +    mnuInMic.Enabled  = (sources AND 2 ^ RECORD_MIC) <> 0
    +    mnuInLine.Enabled = (sources AND 2 ^ RECORD_LINE) <> 0
    +    mnuInCd.Enabled   = (sources AND 2 ^ RECORD_CD) <> 0
    +    mnuInMidi.Enabled = VAL(MidiPortInfo$("input")) <> 0
    +
    +    IF (mnuInLine.Checked AND NOT mnuInLine.Enabled) OR (mnuInCd.Checked AND NOT mnuInCd.Enabled) OR (mnuInMidi.Checked AND NOT mnuInMidi.Enabled) THEN
    +        mnuInMic.Checked  = True
    +        mnuInLine.Checked = False
    +        mnuInCd.Checked   = False
    +        mnuInMidi.Checked = False
    +    END IF
    +END SUB
    +
    +
    +' The input chosen can record: the MIDI input is there, or the sound
    +' card records.
    +FUNCTION CanRecord() AS BOOLEAN
    +    IF mnuInMidi.Checked THEN
    +        CanRecord = mnuInMidi.Enabled
    +    ELSE
    +        CanRecord = SoundCaps("record") <> 0
    +    END IF
    +END FUNCTION
    +
    +
    +' "22,050 Hz, 8-bit, mono"
    +FUNCTION FormatOf$(rate AS LONG, bits AS LONG, channels AS LONG)
    +    DIM kind AS STRING
    +
    +    IF channels = STEREO THEN
    +        kind = "stereo"
    +    ELSE
    +        kind = "mono"
    +    END IF
    +
    +    FormatOf$ = FORMAT$(rate, "#,##0") + " Hz, " + LTRIM$(STR$(bits)) + "-bit, " + kind
    +END FUNCTION
    +
    +
    +' m:ss.t
    +FUNCTION TimeText$(ms AS LONG)
    +    DIM secs AS LONG
    +
    +    secs      = ms \ MS_PER_SEC
    +    TimeText$ = LTRIM$(STR$(secs \ SEC_PER_MIN)) + ":" + RIGHT$("0" + LTRIM$(STR$(secs MOD SEC_PER_MIN)), 2) + "." + LTRIM$(STR$((ms MOD MS_PER_SEC) \ MS_PER_TENTH))
    +END FUNCTION
    +
    +
    +' ============================================================
    +' The choices
    +' ============================================================
    +
    +SUB QualityChosen(rate AS LONG, bits AS LONG, channels AS LONG)
    +    IF mnuPhone.Checked THEN
    +        rate     = PHONE_RATE
    +        bits     = BITS_LOW
    +        channels = MONO
    +    ELSEIF mnuCd.Checked THEN
    +        rate     = CD_RATE
    +        bits     = BITS_HIGH
    +        channels = STEREO
    +    ELSE
    +        rate     = RADIO_RATE
    +        bits     = BITS_LOW
    +        channels = MONO
    +    END IF
    +END SUB
    +
    +
    +FUNCTION InputChosen() AS LONG
    +    IF mnuInLine.Checked THEN
    +        InputChosen = RECORD_LINE
    +    ELSEIF mnuInCd.Checked THEN
    +        InputChosen = RECORD_CD
    +    ELSE
    +        InputChosen = RECORD_MIC
    +    END IF
    +END FUNCTION
    +
    +
    +FUNCTION InputName$()
    +    IF mnuInMidi.Checked THEN
    +        InputName$ = "MIDI keyboard"
    +    ELSEIF mnuInLine.Checked THEN
    +        InputName$ = "line input"
    +    ELSEIF mnuInCd.Checked THEN
    +        InputName$ = "CD"
    +    ELSE
    +        InputName$ = "microphone"
    +    END IF
    +END FUNCTION
    +
    +
    +' ============================================================
    +' Settings
    +' ============================================================
    +
    +FUNCTION SettingsPath$()
    +    SettingsPath$ = FileOpsPathJoin$(App.User, SETTINGS_FILE)
    +END FUNCTION
    +
    +
    +SUB LoadSettings
    +    DIM ini AS LONG
    +    DIM quality AS STRING
    +    DIM inputSource AS LONG
    +
    +    ini = PrefsLoad(SettingsPath$())
    +
    +    IF ini = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    quality     = LCASE$(PrefsGet$(ini, INI_RECORDER, KEY_QUALITY, QUALITY_RADIO))
    +    inputSource = PrefsGetInt(ini, INI_RECORDER, KEY_INPUT, RECORD_MIC)
    +    lastFolder  = PrefsGet$(ini, INI_RECORDER, KEY_FOLDER, "")
    +
    +    mnuPhone.Checked  = quality = QUALITY_PHONE
    +    mnuCd.Checked     = quality = QUALITY_CD
    +    mnuRadio.Checked  = NOT mnuPhone.Checked AND NOT mnuCd.Checked
    +    mnuInLine.Checked = inputSource = RECORD_LINE
    +    mnuInCd.Checked   = inputSource = RECORD_CD
    +    mnuInMidi.Checked = inputSource = INPUT_MIDI
    +    mnuInMic.Checked  = NOT mnuInLine.Checked AND NOT mnuInCd.Checked AND NOT mnuInMidi.Checked
    +    PrefsClose ini
    +END SUB
    +
    +
    +SUB SaveSettings
    +    DIM ini AS LONG
    +    DIM quality AS STRING
    +    DIM inputSource AS LONG
    +
    +    ini = PrefsCreate()
    +
    +    IF ini = 0 THEN
    +        EXIT SUB
    +    END IF
    +
    +    quality     = QUALITY_RADIO
    +    inputSource = InputChosen()
    +
    +    IF mnuPhone.Checked THEN
    +        quality = QUALITY_PHONE
    +    ELSEIF mnuCd.Checked THEN
    +        quality = QUALITY_CD
    +    END IF
    +
    +    IF mnuInMidi.Checked THEN
    +        inputSource = INPUT_MIDI
    +    END IF
    +
    +    PrefsSet ini, INI_RECORDER, KEY_QUALITY, quality
    +    PrefsSetInt ini, INI_RECORDER, KEY_INPUT, inputSource
    +    PrefsSet ini, INI_RECORDER, KEY_FOLDER, lastFolder
    +    PrefsSaveAs ini, SettingsPath$()
    +    PrefsClose ini
    +END SUB
    +
    +
    +' ============================================================
    +' Menus and buttons
    +' ============================================================
    +
    +SUB mnuNew_Click
    +    IF AskSave() THEN
    +        EXIT SUB
    +    END IF
    +
    +    StopAll
    +    currentFile = ""
    +    haveSound   = False
    +    dirty       = False
    +    lengthMs    = 0
    +    formatText  = ""
    +    ShowState
    +END SUB
    +
    +
    +SUB mnuOpen_Click
    +    DIM path AS STRING
    +
    +    IF AskSave() THEN
    +        EXIT SUB
    +    END IF
    +
    +    path = DlgFileOpenIn$("Open", OPEN_FILTER, lastFolder)
    +
    +    IF path <> "" THEN
    +        StopAll
    +        OpenSound path
    +    END IF
    +END SUB
    +
    +
    +' A recording not saved yet is saved under a name the user gives.
    +SUB mnuSave_Click
    +    SaveAs
    +END SUB
    +
    +
    +SUB mnuSaveAs_Click
    +    SaveAs
    +END SUB
    +
    +
    +SUB mnuExit_Click
    +    Unload RecorderForm
    +END SUB
    +
    +
    +SUB btnRecord_Click
    +    StartRecording
    +END SUB
    +
    +
    +' The input chosen decides whether Record can be used.
    +SUB mnuInMic_Click
    +    ShowState
    +END SUB
    +
    +
    +SUB mnuInLine_Click
    +    ShowState
    +END SUB
    +
    +
    +SUB mnuInCd_Click
    +    ShowState
    +END SUB
    +
    +
    +SUB mnuInMidi_Click
    +    ShowState
    +END SUB
    +
    +
    +SUB btnStop_Click
    +    StopAll
    +END SUB
    +
    +
    +SUB btnPlay_Click
    +    StartPlaying
    +END SUB
    +
    +
    +SUB mnuHelpContents_Click
    +    HelpView HELP_FILE
    +END SUB
    +
    +
    +SUB mnuAbout_Click
    +    DIM msg AS STRING
    +
    +    msg = APP_TITLE + CHR$(10) + CHR$(10)
    +    msg = msg + "Records sound from a microphone, a line input, or a CD into WAV files, and what is played on a MIDI keyboard into MIDI files." + CHR$(10) + CHR$(10)
    +    msg = msg + "Copyright 2026 Scott Duensing"
    +    MsgBox msg, vbOKOnly + vbInformation, "About " + APP_TITLE
    +END SUB
    diff --git a/src/common.mk b/src/common.mk
    index 4a9613ec..bb2ee279 100644
    --- a/src/common.mk
    +++ b/src/common.mk
    @@ -89,6 +89,9 @@ endif
     ifeq ($(origin FONT_SOURCES),undefined)
     FONT_SOURCES     := $(DVX_TOOLCHAINS)/fonts
     endif
    +ifeq ($(origin SOUNDFONT_SOURCES),undefined)
    +SOUNDFONT_SOURCES := $(DVX_TOOLCHAINS)/soundfonts
    +endif
     ifeq ($(origin UPX),undefined)
     UPX              := $(DVX_TOOLCHAINS)/upx/upx
     endif
    @@ -136,7 +139,7 @@ PKG_MINIZ_FLAGS   = -DMINIZ_NO_ARCHIVE_APIS -DMINIZ_NO_STDIO -DMINIZ_NO_TIME -DM
     DVX_DRIVERS       = $(notdir $(patsubst %/,%,$(wildcard drivers/*/)))
     
     define DVX_DRIVER_RULE
    -$(DRVDIR)/$1.drv: $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard drivers/$1/*.c)) | $(DRVDIR)
    +$(DRVDIR)/$1.drv: $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard drivers/$1/*.c)) $(DRV_EXTRA_$1) | $(DRVDIR)
     	$$(DXE3GEN) -o $(DRVDIR)/$1.dxe -U $$^
     	mv $(DRVDIR)/$1.dxe $$@
     endef
    diff --git a/src/include/basic/fileops.bas b/src/include/basic/fileops.bas
    index 10ce25c8..db0fea9e 100644
    --- a/src/include/basic/fileops.bas
    +++ b/src/include/basic/fileops.bas
    @@ -73,6 +73,7 @@ CONST FILEOPS_KIND_SOUNDS = 1
     CONST FILEOPS_KIND_THEMES = 2
     CONST FILEOPS_KIND_WPAPER = 3
     CONST FILEOPS_KIND_BANKS  = 4   ' MIDI instrument banks (OP2)
    +CONST FILEOPS_KIND_SAVERS = 5   ' screen savers (programs; system.bas SysSetScreenSaver)
     
     ' FileOpsDriveType kinds
     CONST FILEOPS_DRIVE_FIXED     = 0  ' a hard disk
    diff --git a/src/include/basic/music.bas b/src/include/basic/music.bas
    index eae6b703..09c3fcb6 100644
    --- a/src/include/basic/music.bas
    +++ b/src/include/basic/music.bas
    @@ -22,19 +22,24 @@
     
     ' music.bas -- Music Library for DVX BASIC
     '
    -' Plays music through whatever sound hardware the computer has.  Two
    -' kinds of music files:
    +' Plays music through whatever sound hardware the computer has.  Three
    +' kinds of files:
     '
    -'   Tracker modules (MOD, S3M, XM, and IT files) play wherever the computer
    -'   can play sampled sound.
    +'   Tracker modules (MOD, S3M, XM, and IT files) play on a wavetable sound
    +'   card's own voices, or wherever the computer can play sampled sound.
     '   MIDI files (.MID, type 0 and 1) play on the computer's music
     '   synthesizer, or on a built-in synthesizer that sounds like it when
     '   there is none.
    +'   Sound files (WAV and VOC) play wherever the computer can play sampled
    +'   sound; a long WAV recording is read from the disk as it plays.
     '
     ' You can also play notes yourself, like a keyboard: open a live music
     ' with MusicMidiOpen and send it notes.  MIDI channels are 1 to 16 (10 is
     ' the drums), notes, velocities (how hard a key is struck), instruments,
    -' and controller values 0 to 127 (middle C is note 60).
    +' and controller values 0 to 127 (middle C is note 60).  What a MIDI
    +' keyboard plays (MidiInOpen) can be played on it as it comes
    +' (MusicMidiSend) and kept in a MIDI recording, saved as a .MID file
    +' (MidiRecordNew and the rest).
     '
     ' MusicLoad and MusicMidiOpen return a music number for the other
     ' functions, or 0 when the music could not be loaded (MusicError$ says
    @@ -50,10 +55,49 @@ CONST MUSIC_FAILED = 0
     ' The drum channel
     CONST MUSIC_DRUMS = 10
     
    +' MIDI input (MidiInMessage): the kinds of message (the status AND
    +' MIDI_KIND_MASK; the channel, as MusicNoteOn numbers it, is (the
    +' status AND MIDI_CHANNEL_MASK) + 1), and what MidiInMessage gives when
    +' no message waits
    +CONST MIDI_NOTE_OFF         = &H80
    +CONST MIDI_NOTE_ON          = &H90    ' a velocity of 0 is a note off too
    +CONST MIDI_KEY_PRESSURE     = &HA0
    +CONST MIDI_CONTROL          = &HB0
    +CONST MIDI_PROGRAM          = &HC0
    +CONST MIDI_CHANNEL_PRESSURE = &HD0
    +CONST MIDI_PITCH_BEND       = &HE0
    +CONST MIDI_KIND_MASK        = &HF0
    +CONST MIDI_CHANNEL_MASK     = &H0F
    +CONST MIDI_NONE             = -1
    +
     DECLARE LIBRARY "music"
    -    ' Load a module or MIDI file.  Nothing plays until MusicPlay.
    +    ' Load a module, a MIDI file, or a WAV or VOC file.  Nothing plays
    +    ' until MusicPlay.
         DECLARE FUNCTION MusicLoad(BYVAL fileName AS STRING) AS LONG
     
    +    ' A MIDI music made from a music macro string (as PLAY takes it): its
    +    ' notes play on the General MIDI instruments its I commands name, the
    +    ' piano before any I.  Nothing plays until MusicPlay.
    +    DECLARE FUNCTION MusicLoadMacro(BYVAL macro AS STRING) AS LONG
    +
    +    ' Play a MIDI music on another instrument bank than the one Control
    +    ' Panel chose: its name in the banks folders ("" = DVX's standard
    +    ' bank).  A SoundFont (.SF2 or .SBK) or an UltraSound's ULTRASND.INI
    +    ' is for wavetable music, any other bank for FM music.  Before the music first plays; MusicError$ says
    +    ' when the bank is missing or damaged.
    +    DECLARE SUB MusicSetBank(BYVAL music AS LONG, BYVAL bank AS STRING)
    +
    +    ' A Gravis UltraSound's own instruments, as MusicSetBank takes them
    +    ' (the ULTRASND.INI in the folder its ULTRADIR setting names), or ""
    +    ' when there are none.
    +    DECLARE FUNCTION MusicPatchSet$() AS STRING
    +
    +    ' A Sound Blaster AWE's own General MIDI instruments, which play from
    +    ' its sound ROM, as MusicSetBank takes them (SYNTHGM.SF2 or
    +    ' SYNTHGM.SBK in SFBANK in the folder its SOUND setting names), or ""
    +    ' when there are none.
    +    DECLARE FUNCTION MusicRomBank$() AS STRING
    +
         ' Play from where the music is (the start after loading or stopping);
         ' with loopIt True it starts over at the end until stopped.
         DECLARE SUB MusicPlay(BYVAL music AS LONG, BYVAL loopIt AS LONG)
    @@ -88,11 +132,12 @@ DECLARE LIBRARY "music"
         DECLARE FUNCTION MusicGetVolume(BYVAL music AS LONG) AS LONG
     
         ' About the music, by name (any case): "title" (a module's own
    -    ' title), "output" (where it plays, for people, like "OPL3 FM
    -    ' synthesizer at 388h"), "kind" ("module", "midi", or "live"), "where"
    -    ' ("stream", "mpu401", "fm", or "software"; "" before it first plays),
    -    ' "paused" and "looping" (True or False as text: VAL gives the
    -    ' value).
    +    ' title, or the name a MIDI file gives itself), "output" (where it
    +    ' plays, for people, like "OPL3 FM synthesizer at 388h"), "kind"
    +    ' ("module", "midi", "live", or "sound"), "where"
    +    ' ("stream", "mpu401", "fm", "software", "wave", or "softwave"; ""
    +    ' before it first plays), "paused" and "looping" (True or False as
    +    ' text: VAL gives the value).
         DECLARE FUNCTION MusicInfo$(BYVAL music AS LONG, BYVAL field AS STRING) AS STRING
     
         ' A live music for playing notes yourself (it is playing already).
    @@ -113,15 +158,41 @@ DECLARE LIBRARY "music"
         ' Bend the pitch of a channel's notes: -8192 to 8191 (0 = no bend;
         ' the full range is two semitones up or down).
         DECLARE SUB MusicPitchBend(BYVAL music AS LONG, BYVAL channel AS LONG, BYVAL bend AS LONG)
    +
    +    ' Play a message packed as MidiInMessage gives it: what a keyboard
    +    ' plays sounds as it comes (MIDI thru).
    +    DECLARE SUB MusicMidiSend(BYVAL music AS LONG, BYVAL midiMsg AS LONG)
    +
    +    ' A MIDI recording keeps messages (packed as MidiInMessage gives
    +    ' them) with their times and saves them as a MIDI file that MusicLoad
    +    ' plays.  MidiRecordNew returns its number (0 when there is no room);
    +    ' everything your program records goes when it ends.
    +    DECLARE FUNCTION MidiRecordNew() AS LONG
    +
    +    ' Add a message at milliseconds from the recording's start (MidiInTime
    +    ' gives that when the MIDI input was opened at the start).
    +    DECLARE SUB MidiRecordAdd(BYVAL recording AS LONG, BYVAL milliseconds AS LONG, BYVAL midiMsg AS LONG)
    +
    +    ' How many messages it has, and when the last came (milliseconds).
    +    DECLARE FUNCTION MidiRecordCount(BYVAL recording AS LONG) AS LONG
    +    DECLARE FUNCTION MidiRecordLength(BYVAL recording AS LONG) AS LONG
    +
    +    ' Save it as a MIDI file (False when it could not be written:
    +    ' MusicError$ says why).  A note still held at the end is let go.
    +    DECLARE FUNCTION MidiRecordSave(BYVAL recording AS LONG, BYVAL fileName AS STRING) AS LONG
    +
    +    DECLARE SUB MidiRecordFree(BYVAL recording AS LONG)
     END DECLARE
     
     ' ---- The MIDI port itself: raw MIDI bytes (system exclusive messages,
    -' your own sequencer).  One program at a time has the port; it is used
    -' only when the settings send MIDI music to the MIDI port. ----
    +' your own sequencer), and what a keyboard plays into its input.  One
    +' program at a time has the port, and one reads its input; it is used
    +' only when the settings send MIDI music or take MIDI input there. ----
     DECLARE LIBRARY "audio"
         ' The MIDI port by name (any case): "description" (like "MPU-401
         ' MIDI port at 330h", "" when there is none), "present", "owned" (a
    -    ' program has it) -- True or False as text.
    +    ' program has it), "input" (it brings MIDI in: MidiInOpen works),
    +    ' "inputowned" (a program reads it) -- True or False as text.
         DECLARE FUNCTION MidiPortInfo$(BYVAL field AS STRING) AS STRING
     
         ' Take the port for this program (False when another program has it
    @@ -134,4 +205,26 @@ DECLARE LIBRARY "audio"
     
         ' Give the port back.
         DECLARE SUB MidiRelease()
    +
    +    ' Read what a keyboard or another instrument plays into the MIDI
    +    ' input (True), until MidiInClose; False when there is no MIDI input
    +    ' or another program reads it (SoundError$ says which).
    +    DECLARE FUNCTION MidiInOpen() AS LONG
    +    DECLARE SUB MidiInClose()
    +
    +    ' The next message that came, -1 when none waits.  It is packed: the
    +    ' status is MidiMsg AND 255 (the kind, MIDI_NOTE_ON and the rest, plus
    +    ' the channel less one), the first data byte (MidiMsg \ 256) AND 255
    +    ' (the note), the second (MidiMsg \ 65536) AND 255 (the velocity).
    +    ' Running status is filled in; clock and active-sensing bytes and
    +    ' system exclusive messages are left out.
    +    DECLARE FUNCTION MidiInMessage() AS LONG
    +
    +    ' When the message MidiInMessage gave last came (milliseconds since
    +    ' MidiInOpen), and the bytes lost because the program read too late.
    +    DECLARE FUNCTION MidiInTime() AS LONG
    +    DECLARE FUNCTION MidiInLost() AS LONG
    +
    +    ' Milliseconds since MidiInOpen now (on the same clock as MidiInTime).
    +    DECLARE FUNCTION MidiInClock() AS LONG
     END DECLARE
    diff --git a/src/include/basic/pkg.bas b/src/include/basic/pkg.bas
    index 9a525647..c8acb84f 100644
    --- a/src/include/basic/pkg.bas
    +++ b/src/include/basic/pkg.bas
    @@ -41,7 +41,7 @@
     CONST PKG_FIELD_ID          = 0
     CONST PKG_FIELD_NAME        = 1
     CONST PKG_FIELD_VERSION     = 2     ' major.minor.patch
    -CONST PKG_FIELD_KIND        = 3     ' app, library, widget, font, bank, theme, wallpaper, sound, help, bundle, system
    +CONST PKG_FIELD_KIND        = 3     ' app, library, widget, font, bank, theme, wallpaper, sound, saver, help, bundle, system
     CONST PKG_FIELD_SUMMARY     = 4
     CONST PKG_FIELD_PUBLISHER   = 5
     CONST PKG_FIELD_AUTHOR      = 6
    @@ -87,6 +87,10 @@ DECLARE LIBRARY "pkg"
         ' The installed package's files.  Rows: name, size.
         DECLARE FUNCTION PkgFilesRows$(BYVAL s AS LONG, BYVAL id AS STRING) AS STRING
     
    +    ' The id of the installed package that put a file there (its name from
    +    ' the DVX folder, such as "SHARED\BANKS\KPUNCH\DMXOPL3.OP2"), or "".
    +    DECLARE FUNCTION PkgFileOwner$(BYVAL s AS LONG, BYVAL fileName AS STRING) AS STRING
    +
         ' What it needs.  Rows: id, the oldest version that will do, the
         ' version installed ("" when none).
         DECLARE FUNCTION PkgDependsRows$(BYVAL s AS LONG, BYVAL id AS STRING) AS STRING
    diff --git a/src/include/basic/print.bas b/src/include/basic/print.bas
    index e42cd0c4..a426e3e6 100644
    --- a/src/include/basic/print.bas
    +++ b/src/include/basic/print.bas
    @@ -54,6 +54,10 @@ DECLARE LIBRARY "print"
         ' True when the printer model in the settings can print pictures.
         DECLARE FUNCTION PrintCanPrintPictures() AS LONG
     
    +    ' True when the printer model in the settings prints in color (on the
    +    ' others, pictures print in shades of grey).
    +    DECLARE FUNCTION PrintCanPrintColor() AS LONG
    +
         ' ---- The printer settings (what the Control Panel's Printer page
         ' edits). prefs is a handle from prefs.bas: PrefsLoad(PrefsDvxIni$())
         ' for DVX's own settings. Models and destinations are numbered from 0.
    @@ -72,6 +76,16 @@ DECLARE LIBRARY "print"
         ' name).
         DECLARE FUNCTION PrintDestIsFile(BYVAL dest AS LONG) AS LONG
     
    +    ' True for a serial port (COM1 to COM4), which needs a speed and a
    +    ' handshake (PrintSerialStore).
    +    DECLARE FUNCTION PrintDestIsSerial(BYVAL dest AS LONG) AS LONG
    +
    +    ' The serial speeds offered (index 0 up; 0 past the last), and the
    +    ' handshakes: how many, and the name of each (like "XON / XOFF").
    +    DECLARE FUNCTION PrintBaudRate(BYVAL index AS LONG) AS LONG
    +    DECLARE FUNCTION PrintHandshakeCount() AS LONG
    +    DECLARE FUNCTION PrintHandshakeLabel$(BYVAL handshake AS LONG) AS STRING
    +
         ' The printer model, destination, and file name in the settings of
         ' prefs (the usual settings when they are missing).
         DECLARE FUNCTION PrintSettingModel(BYVAL prefs AS LONG) AS LONG
    @@ -88,6 +102,13 @@ DECLARE LIBRARY "print"
         ' a number out of range.
         DECLARE FUNCTION PrintSettingsStore(BYVAL prefs AS LONG, BYVAL model AS LONG, BYVAL dest AS LONG, BYVAL filePath AS STRING) AS LONG
     
    +    ' A serial printer's speed (like 9600) and handshake number in the
    +    ' settings of prefs, and storing them the same way; False for a speed
    +    ' PrintBaudRate does not offer or a handshake out of range.
    +    DECLARE FUNCTION PrintSettingBaud(BYVAL prefs AS LONG) AS LONG
    +    DECLARE FUNCTION PrintSettingHandshake(BYVAL prefs AS LONG) AS LONG
    +    DECLARE FUNCTION PrintSerialStore(BYVAL prefs AS LONG, BYVAL baud AS LONG, BYVAL handshake AS LONG) AS LONG
    +
         ' ---- The page setup without the Page Setup dialog. Margins count
         ' lines (top, bottom) and characters (left, right). In the header and
         ' footer: &f document name, &p page, &n number of pages, &d date,
    diff --git a/src/include/basic/shell.bas b/src/include/basic/shell.bas
    index 067c085b..02e0d602 100644
    --- a/src/include/basic/shell.bas
    +++ b/src/include/basic/shell.bas
    @@ -50,26 +50,43 @@ CONST SHELL_VIEW_LARGE = 0     ' large icons
     CONST SHELL_VIEW_SMALL = 1     ' small icons
     CONST SHELL_VIEW_LIST  = 2     ' a list
     
    +' What ShellRunProgram and ShellRunCommand return for a DOS program: it
    +' runs once the calling procedure has returned (no program id)
    +CONST SHELL_RUN_DOS = -1
    +
     DECLARE LIBRARY "dvxshell"
         ' ---- Starting programs and documents ----
     
         ' Start a program (.app) with arguments ("" for none) -- what it gets
    -    ' as COMMAND$ -- or open a document (anything else) in the program
    -    ' set to open its type.  Returns True when it started (if it cannot
    -    ' start, DVX says why).  The same as ShellRunProgram with no folder.
    +    ' as COMMAND$ -- run a DOS program (.EXE, .COM, or .BAT: see
    +    ' ShellRunDos), or open a document (anything else) in the program set
    +    ' to open its type.  Returns True when it started, or will (if it
    +    ' cannot, DVX says why).  The same as ShellRunProgram with no folder.
         DECLARE FUNCTION ShellRun(BYVAL program AS STRING, BYVAL args AS STRING) AS LONG
     
         ' Start a program, or open a document in the program set to open its
         ' type, the way Program Manager starts an item: file names in args
         ' (and a document named without a folder) are looked for in folder
    -    ' first ("" for none).  Returns the new program's id, or 0 when it did
    -    ' not start (DVX says why).
    +    ' first ("" for none).  Returns the new program's id, SHELL_RUN_DOS
    +    ' for a DOS program (in folder), or 0 when it did not start (DVX says
    +    ' why).
         DECLARE FUNCTION ShellRunProgram(BYVAL program AS STRING, BYVAL args AS STRING, BYVAL folder AS STRING) AS LONG
     
         ' Run a command line: a program (in double quotes when its name has a
    -    ' blank) followed by its arguments.  The new program's id, or 0.
    +    ' blank) followed by its arguments.  The new program's id,
    +    ' SHELL_RUN_DOS, or 0.
         DECLARE FUNCTION ShellRunCommand(BYVAL commandLine AS STRING) AS LONG
     
    +    ' Run a DOS program (an .EXE, .COM, or .BAT file, or a command DOS
    +    ' knows) with DVX stepped aside, and come back to the desktop when it
    +    ' ends.  It runs once the calling procedure has returned: DVX first
    +    ' names the programs running besides the desktop -- this one too --
    +    ' and asks them to close (No, or unsaved work kept, cancels the run).
    +    ' args goes after the program; folder is where it runs ("": its own
    +    ' folder); pause True waits for a key when it ends, so what it printed
    +    ' can be read.
    +    DECLARE SUB ShellRunDos(BYVAL program AS STRING, BYVAL args AS STRING, BYVAL folder AS STRING, BYVAL pause AS LONG)
    +
         ' Show the Run dialog.  history is the commands to offer, newest
         ' first, one per line (separated by CHR$(10)).  Returns the command
         ' line the user chose, or "" when the dialog was cancelled; run it
    @@ -111,6 +128,10 @@ DECLARE LIBRARY "dvxshell"
         ' True when path names a program (a .app file).
         DECLARE FUNCTION ShellIsProgram(BYVAL path AS STRING) AS LONG
     
    +    ' True when path names a DOS program (an .EXE, .COM, or .BAT file,
    +    ' which ShellRun and ShellRunDos run with DVX stepped aside).
    +    DECLARE FUNCTION ShellIsDosProgram(BYVAL path AS STRING) AS LONG
    +
         ' The folder the installed programs are in (relative to DVX's folder):
         ' where ShellProgramScan("") looks, and where a Browse for a program
         ' starts (DlgFileOpenIn$).
    @@ -297,14 +318,15 @@ DECLARE LIBRARY "dvxshell"
         ' The items of a group.
         DECLARE FUNCTION ShellItemCount(BYVAL groups AS LONG, BYVAL group AS LONG) AS LONG
     
    -    ' Add an item (a name and a program, or a document opened by its file
    -    ' type) at the end of a group; its index, or -1.
    +    ' Add an item (a name and a program, a DOS program, or a document
    +    ' opened by its file type) at the end of a group; its index, or -1.
         DECLARE FUNCTION ShellItemAdd(BYVAL groups AS LONG, BYVAL group AS LONG, BYVAL itemName AS STRING, BYVAL program AS STRING) AS LONG
     
         ' An item's field as text: "name", "program", "args", "folder" (where
         ' its file arguments are looked for), "icon" (a picture file, "" for
         ' the program's own), "key" (its shortcut, "" for none), "minimized"
    -    ' ("1" to start it minimized, else "0"), "col" and "row" (its grid
    +    ' ("1" to start it minimized, else "0"), "pause" ("1": a DOS program
    +    ' waits for a key when it ends, else "0"), "col" and "row" (its grid
         ' cell, -1 when not placed), and "description" (its tooltip).
         DECLARE FUNCTION ShellItemGet$(BYVAL groups AS LONG, BYVAL group AS LONG, BYVAL item AS LONG, BYVAL field AS STRING) AS STRING
     
    diff --git a/src/include/basic/sound.bas b/src/include/basic/sound.bas
    index f563f662..0b086c3c 100644
    --- a/src/include/basic/sound.bas
    +++ b/src/include/basic/sound.bas
    @@ -78,6 +78,12 @@ CONST SOUND_STREAM_MS_MIN     = 20
     CONST SOUND_STREAM_MS_DEFAULT = 250
     CONST SOUND_STREAM_MS_MAX     = 2000
     
    +' The inputs a recording comes from (RecordStart); SoundCaps
    +' ("recordsources") has the bit 2 ^ input set for each the card has
    +CONST RECORD_MIC  = 0
    +CONST RECORD_LINE = 1
    +CONST RECORD_CD   = 2
    +
     ' The FM synthesizer chip: the most voices it can have, its own sample
     ' rate (frequency numbers are based on it: hertz = fnum * FM_CHIP_HZ *
     ' 2 ^ block / 2 ^ 20), the frequency number and block limits, and where
    @@ -157,8 +163,10 @@ DECLARE LIBRARY "audio"
     
         ' What the sound device can do, by name (any case): "dac" (plays
         ' recorded sound), "synth" (has a music synthesizer), "tone" (plays
    -    ' tones on a tone generator), "running" -- True or False; "voices"
    -    ' (sounds at once), "rate" (hertz), "bits", "channels" -- numbers.
    +    ' tones on a tone generator), "running", "record" (records sound) --
    +    ' True or False; "voices" (sounds at once), "rate" (hertz), "bits",
    +    ' "channels", "recordsources" (the inputs it records from: bit
    +    ' 2 ^ RECORD_... set for each) -- numbers.
         DECLARE FUNCTION SoundCaps(BYVAL field AS STRING) AS LONG
     
         ' Play one of DVX's event sounds (SOUND_EVENT_...), the one the
    @@ -196,6 +204,11 @@ DECLARE LIBRARY "audio"
         DECLARE SUB SoundSetPitch(BYVAL voice AS LONG, BYVAL percent AS LONG)
         DECLARE SUB SoundSetFrequency(BYVAL voice AS LONG, BYVAL frequency AS LONG)
     
    +    ' Hold a sound's or a stream's voice where it is (silent, not moving
    +    ' on), and let it go on from there.  Tones cannot be paused.
    +    DECLARE SUB SoundPause(BYVAL voice AS LONG)
    +    DECLARE SUB SoundResume(BYVAL voice AS LONG)
    +
         ' ---- Streams: sound your program makes as it goes ----
     
         ' Open a stream of rate hertz, 8 or 16 bits, 1 or 2 channels, holding
    @@ -211,13 +224,52 @@ DECLARE LIBRARY "audio"
         DECLARE FUNCTION SoundStreamSpace(BYVAL stream AS LONG) AS LONG
         DECLARE FUNCTION SoundStreamQueued(BYVAL stream AS LONG) AS LONG
     
    -    ' The stream's voice, for SoundSetVolume, SoundSetPan, and
    -    ' SoundSetPitch.
    +    ' The stream's voice, for SoundSetVolume, SoundSetPan, SoundSetPitch,
    +    ' SoundPause, and SoundResume.
         DECLARE FUNCTION SoundStreamVoice(BYVAL stream AS LONG) AS LONG
     
    +    ' Drop what the stream holds and has not played yet (to jump to
    +    ' another place in what you play: what you write next is heard at
    +    ' once).
    +    DECLARE SUB SoundStreamFlush(BYVAL stream AS LONG)
    +
         ' Stop the stream and close it.
         DECLARE SUB SoundStreamClose(BYVAL stream AS LONG)
     
    +    ' ---- Recording ----
    +    '
    +    ' Sound from the card's microphone, line, or CD input into a WAV
    +    ' file.  Most sound cards record or play, not both, so while a
    +    ' program records nothing plays, and one program records at a time.
    +    ' SoundCaps("record") says whether the card can.
    +
    +    ' Start recording from input (RECORD_MIC, RECORD_LINE, RECORD_CD)
    +    ' into the WAV file fileName (made, or replaced) at rate, bits (8 or
    +    ' 16), and channels (1 or 2); 0 for 22,050 Hz, 16-bit, mono.  The
    +    ' card records at its nearest: RecordRate, RecordBits, and
    +    ' RecordChannels say what it chose.  True when it started; False with
    +    ' SoundError$ saying why.
    +    DECLARE FUNCTION RecordStart(BYVAL fileName AS STRING, BYVAL rate AS LONG, BYVAL bits AS LONG, BYVAL channels AS LONG, BYVAL inputSource AS LONG) AS LONG
    +
    +    ' Stop recording: the file is complete.  True when it was all
    +    ' written.
    +    DECLARE FUNCTION RecordStop() AS LONG
    +
    +    ' True while the program records.
    +    DECLARE FUNCTION Recording() AS LONG
    +
    +    ' How long the recording is so far (milliseconds), the loudest sound
    +    ' since the last call (percent: a level meter), and the samples lost
    +    ' because DVX was too busy to take them in time.
    +    DECLARE FUNCTION RecordLength() AS LONG
    +    DECLARE FUNCTION RecordLevel() AS LONG
    +    DECLARE FUNCTION RecordLost() AS LONG
    +
    +    ' The format the card records in.
    +    DECLARE FUNCTION RecordRate() AS LONG
    +    DECLARE FUNCTION RecordBits() AS LONG
    +    DECLARE FUNCTION RecordChannels() AS LONG
    +
         ' ---- The FM synthesizer chip (AdLib-style instruments) ----
     
         ' The chip by name (any case): "description" (like "OPL3 FM
    @@ -238,6 +290,44 @@ DECLARE LIBRARY "audio"
         DECLARE FUNCTION FmRawWrite(BYVAL reg AS LONG, BYVAL value AS LONG) AS LONG
         DECLARE SUB FmRawRelease()
     
    +    ' ---- The wavetable synthesizer (sampled instruments on the card) ----
    +
    +    ' The synthesizer by name (any case): "description" (like "Gravis
    +    ' UltraSound wavetable at 220h (26 voices, 1019 KB)", "" when there is
    +    ' none), "voices", "free" (voices no program has), "memory" (bytes of
    +    ' sample memory), "freememory" (the largest free block), and what it
    +    ' does itself: "envelopes", "lfos", "filter", "effects", "8bit",
    +    ' "rom" -- numbers and True / False as text.
    +    DECLARE FUNCTION WtInfo$(BYVAL field AS STRING) AS STRING
    +
    +    ' Sample memory for this program: bytes of it for samples of bits (8
    +    ' or 16 a sample); its address, or -1 when there is no room.  WtUpload
    +    ' copies 16-bit samples (signed, mono, two bytes each) there, stored
    +    ' at bits (True when it worked); WtMemFree gives the memory back.
    +    DECLARE FUNCTION WtMemAlloc(BYVAL bytes AS LONG, BYVAL bits AS LONG) AS LONG
    +    DECLARE FUNCTION WtUpload(BYVAL address AS LONG, BYVAL pcm AS BINARY STRING, BYVAL bits AS LONG) AS LONG
    +    DECLARE SUB WtMemFree(BYVAL address AS LONG)
    +
    +    ' Take a voice (0 when none is free) and start it on a sample in the
    +    ' memory: from address, endFrame samples long, playing from startFrame
    +    ' (0: its first), its loop from loopStart up to loopEnd (loopMode 0
    +    ' plays once, 1 loops, 3 loops until released), stored at bits; pitch in cents (0 plays 44,100 samples a
    +    ' second, 1200 an octave higher), attenuation in centibels (0 loudest,
    +    ' 60 half as loud, 1440 silent), pan -100 (left) to 100 (right).  True
    +    ' when it started.  WtSetPitch, WtSetAttenuation and WtSetPan change
    +    ' it, WtRelease lets a note looping until released play out, WtStop
    +    ' silences it, WtActive is True while it sounds, and WtFree gives the
    +    ' voice back.
    +    DECLARE FUNCTION WtAlloc() AS LONG
    +    DECLARE FUNCTION WtStart(BYVAL voice AS LONG, BYVAL address AS LONG, BYVAL startFrame AS LONG, BYVAL endFrame AS LONG, BYVAL loopStart AS LONG, BYVAL loopEnd AS LONG, BYVAL loopMode AS LONG, BYVAL bits AS LONG, BYVAL pitch AS LONG, BYVAL attenuation AS LONG, BYVAL pan AS LONG) AS LONG
    +    DECLARE FUNCTION WtSetPitch(BYVAL voice AS LONG, BYVAL pitch AS LONG) AS LONG
    +    DECLARE FUNCTION WtSetAttenuation(BYVAL voice AS LONG, BYVAL attenuation AS LONG) AS LONG
    +    DECLARE FUNCTION WtSetPan(BYVAL voice AS LONG, BYVAL pan AS LONG) AS LONG
    +    DECLARE SUB WtRelease(BYVAL voice AS LONG)
    +    DECLARE SUB WtStop(BYVAL voice AS LONG)
    +    DECLARE FUNCTION WtActive(BYVAL voice AS LONG) AS LONG
    +    DECLARE SUB WtFree(BYVAL voice AS LONG)
    +
         ' ---- DVX's own sounds and sound settings ----
     
         ' How many events DVX plays a sound for (numbered 0 to
    @@ -288,15 +378,31 @@ DECLARE LIBRARY "audio"
         DECLARE FUNCTION SoundMidiCount() AS LONG
         DECLARE FUNCTION SoundMidiLabel$(BYVAL index AS LONG) AS STRING
     
    +    ' Where MIDI input can come from: how many choices, and each one's
    +    ' name for people ("None", "MIDI port").
    +    DECLARE FUNCTION SoundMidiInCount() AS LONG
    +    DECLARE FUNCTION SoundMidiInLabel$(BYVAL index AS LONG) AS STRING
    +
    +    ' Where tracker modules (MOD, S3M, XM, IT) can play, the same way
    +    ' ("Wavetable synthesizer when free", "Mixed in software").
    +    DECLARE FUNCTION SoundModulesCount() AS LONG
    +    DECLARE FUNCTION SoundModulesLabel$(BYVAL index AS LONG) AS STRING
    +
         ' The settings' sound driver (SoundDriverAuto$ when none is set),
    -    ' MIDI choice (an index for SoundMidiLabel$), and master volume
    -    ' (percent).
    +    ' MIDI choice (an index for SoundMidiLabel$), modules choice (an
    +    ' index for SoundModulesLabel$), and master volume (percent).
         DECLARE FUNCTION SoundSettingDriver$(BYVAL prefs AS LONG) AS STRING
         DECLARE FUNCTION SoundSettingMidi(BYVAL prefs AS LONG) AS LONG
    +    DECLARE FUNCTION SoundSettingModules(BYVAL prefs AS LONG) AS LONG
         DECLARE FUNCTION SoundSettingVolume(BYVAL prefs AS LONG) AS LONG
     
    -    ' Store the sound driver, MIDI choice, and master volume in the
    -    ' settings.  A new driver or MIDI choice takes effect when DVX next
    -    ' starts.
    -    DECLARE SUB SoundSettingsStore(BYVAL prefs AS LONG, BYVAL driver AS STRING, BYVAL midi AS LONG, BYVAL volume AS LONG)
    +    ' Store the sound driver, MIDI choice, modules choice, and master
    +    ' volume in the settings.  A new driver or MIDI choice takes effect
    +    ' when DVX next starts; a modules choice at the next module.
    +    DECLARE SUB SoundSettingsStore(BYVAL prefs AS LONG, BYVAL driver AS STRING, BYVAL midi AS LONG, BYVAL modules AS LONG, BYVAL volume AS LONG)
    +
    +    ' The settings' MIDI input choice (an index for SoundMidiInLabel$),
    +    ' and storing it; a new choice takes effect when DVX next starts.
    +    DECLARE FUNCTION SoundSettingMidiIn(BYVAL prefs AS LONG) AS LONG
    +    DECLARE SUB SoundSettingMidiInStore(BYVAL prefs AS LONG, BYVAL midiIn AS LONG)
     END DECLARE
    diff --git a/src/include/basic/system.bas b/src/include/basic/system.bas
    index 52bfad26..0081ccf2 100644
    --- a/src/include/basic/system.bas
    +++ b/src/include/basic/system.bas
    @@ -24,8 +24,9 @@
     ' system.bas -- System Settings Library for DVX BASIC
     ' Changes what Control Panel changes, for the whole desktop and at once:
     ' the screen mode, the system colors and color themes, the wallpaper,
    -' text smoothing, and the mouse. Together with prefs.bas (DVX.INI), it
    -' lets a program save these settings so DVX starts with them. Also reads
    +' the screen saver, text smoothing, and the mouse. Together with
    +' prefs.bas (DVX.INI), it lets a program save these settings so DVX
    +' starts with them. Also reads
     ' the system font's character pictures (glyphs), the memory, the mouse
     ' pointer, and the Shift / Ctrl / Alt keys anywhere on the screen, moves
     ' the pointer, types keys into the window in front, and saves the screen
    @@ -54,6 +55,12 @@ CONST SYS_VIDEO_DEFAULT_WIDTH  = 640
     CONST SYS_VIDEO_DEFAULT_HEIGHT = 480
     CONST SYS_VIDEO_DEFAULT_BPP    = 16
     
    +' The screen saver's wait in minutes: its range and DVX's default
    +' (SysSetScreenSaver)
    +CONST SCREEN_SAVER_WAIT_MIN     = 1
    +CONST SCREEN_SAVER_WAIT_MAX     = 60
    +CONST SCREEN_SAVER_WAIT_DEFAULT = 10
    +
     DECLARE LIBRARY "dvxshell"
         ' ---- Screen mode ----
     
    @@ -137,6 +144,36 @@ DECLARE LIBRARY "dvxshell"
         ' loaded.
         DECLARE FUNCTION SysWallpaperLoad(BYVAL prefs AS LONG) AS LONG
     
    +    ' ---- Screen saver ----
    +
    +    ' The screen saver: a program in the SAVERS folders that DVX starts
    +    ' after the wait with no key or mouse input, and ends at the next.
    +    ' Its file as SAVERS knows it ("" for none; FileOpsKindList with
    +    ' FILEOPS_KIND_SAVERS lists them) and the wait in minutes.
    +    DECLARE FUNCTION SysScreenSaver$() AS STRING
    +    DECLARE FUNCTION SysScreenSaverWait() AS LONG
    +
    +    ' Choose the screen saver (a file in SAVERS, any other path, or ""
    +    ' for none) and the wait in minutes (kept within
    +    ' SCREEN_SAVER_WAIT_MIN and SCREEN_SAVER_WAIT_MAX).  At once.
    +    DECLARE SUB SysSetScreenSaver(BYVAL fileName AS STRING, BYVAL minutes AS LONG)
    +
    +    ' Start the screen saver now, as Control Panel's Test does.  False
    +    ' when none is chosen or one runs already.
    +    DECLARE FUNCTION SysScreenSaverStart() AS LONG
    +
    +    ' True while a screen saver runs.
    +    DECLARE FUNCTION SysScreenSaverRunning() AS LONG
    +
    +    ' Store the screen saver and its wait in [desktop] of a prefs.bas
    +    ' handle, or use the ones it has (none and the default wait when it
    +    ' names none).
    +    DECLARE SUB SysScreenSaverStore(BYVAL prefs AS LONG)
    +    DECLARE SUB SysScreenSaverLoad(BYVAL prefs AS LONG)
    +
    +    ' Seconds since the last key or mouse input, anywhere on the screen.
    +    DECLARE FUNCTION SysIdleSeconds() AS LONG
    +
         ' ---- Text ----
     
         ' Smooth the edges of text (True) or not (False). Text is smoothed
    diff --git a/src/installer/mkDist.sh b/src/installer/mkDist.sh
    index 59bb0a4c..3db17ed9 100755
    --- a/src/installer/mkDist.sh
    +++ b/src/installer/mkDist.sh
    @@ -41,6 +41,10 @@
     #                           files but the fonts (Setup's optional tiers),
     #                           for the package server, where the operator
     #                           signs it (server/README.md)
    +#   dist/addons/NAME.DPK    DVX's own add-ons, from src/addons/*/NAME
    +#                           (PACKAGE.INI and the files; files the build
    +#                           makes, the fonts of one with a fonts.ini, from
    +#                           obj/addons/NAME), for the package server too
     #
     # FLOPPY: 360K, 720K, 1.2M, 1.44M (default), 2.88M -- images of that
     # format -- or a number of bytes: the room on each disk, and folders
    @@ -182,6 +186,23 @@ tar -C "${STAGE}" --exclude="./${FONTS_DIR}" -cf - . | tar -C "${UPD_STAGE}" -xf
     "${TOOL}" build "${UPD_TEMPLATE}" "${UPD_STAGE}" "${DIST}/DVXUPD.DPK" > /dev/null || fail "DVX's update package could not be built"
     rm -rf "${UPD_STAGE}" "${UPD_TEMPLATE}"
     
    +# DVX's own add-ons (src/addons/*/*/PACKAGE.INI), each from its folder
    +mkdir -p "${DIST}/addons"
    +
    +for template in "${ROOT}"/src/addons/*/*/PACKAGE.INI; do
    +    folder="$(dirname "${template}")"
    +    base="$(basename "${folder}")"
    +    name="$(echo "${base}" | tr '[:lower:]' '[:upper:]')"
    +
    +    # An add-on whose files the build makes (fonts.ini: src/tools/Makefile)
    +    # is packed from obj/addons/NAME
    +    if [ -d "${ROOT}/obj/addons/${base}" ]; then
    +        folder="${ROOT}/obj/addons/${base}"
    +    fi
    +
    +    "${TOOL}" build "${template}" "${folder}" "${DIST}/addons/${name}.DPK" > /dev/null || fail "the add-on ${folder} could not be built"
    +done
    +
     rm -rf "${STAGE}"
     
     # The SDK: bin/sdk and the references (make all writes docs/) in DVX-SDK/
    @@ -197,3 +218,5 @@ echo "mkDist: CD: ${DIST}/DVX.ISO (SETUP.EXE $(stat -c %s "${DIST}/cd/SETUP.EXE"
     echo "mkDist: floppies (${FLOPPY}): ${DISKS} disk(s) in ${DIST}/floppy, ${ROOM1} bytes of the package on Disk 1"
     echo "mkDist: SDK: ${DIST}/sdk.zip ($(stat -c %s "${DIST}/sdk.zip") bytes)"
     echo "mkDist: DVX's update: ${DIST}/DVXUPD.DPK ($(stat -c %s "${DIST}/DVXUPD.DPK") bytes)"
    +addonFiles=("${DIST}/addons/"*)
    +echo "mkDist: add-ons: ${addonFiles[*]##*/}"
    diff --git a/src/installer/setup.c b/src/installer/setup.c
    index e462af0b..16c43b66 100644
    --- a/src/installer/setup.c
    +++ b/src/installer/setup.c
    @@ -342,7 +342,7 @@ static const SetupRgbT sShadow       = { 0, 0, 0 };
     static const SetupBoardT sCopyBoards[] = {
         { "Windows, your way",       { "Move, size, stack, and tile windows with the mouse or keys.", "Program Manager starts everything; Alt+Tab switches.", "Themes and wallpaper make the desktop your own." }, SetupTypeHeadE, false },
         { "Build your own programs", { "DVX BASIC: draw a form, add controls, write some code.", "Press F5 to run it, then make it a program of its own.", "Forms, graphics, files, sound, and databases built in." }, SetupTypeHeadE, false },
    -    { "Games, sound, and music",  { "Sound effects and music while you work or play.", "Joysticks and game pads, from BASIC and from C.", "Full-screen mode gives a game the whole display." }, SetupTypeHeadE, false },
    +    { "Games, sound, and music",  { "Media Player plays modules, MIDI, WAV, and VOC files.", "Joysticks and game pads, from BASIC and from C.", "Full-screen mode gives a game the whole display." }, SetupTypeHeadE, false },
         { "Help on every screen",    { "Press F1 anywhere for help about what you see.", "The Help Viewer holds every guide, and searches them.", "" }, SetupTypeHeadE, false },
         { "Files and the world",     { "File Manager copies, moves, and opens your files.", "Terminal, networking, and file transfer connect you.", "" }, SetupTypeHeadE, false }
     };
    diff --git a/src/libs/kpunch/audio/Makefile b/src/libs/kpunch/audio/Makefile
    index 6dc26bc8..47242b90 100644
    --- a/src/libs/kpunch/audio/Makefile
    +++ b/src/libs/kpunch/audio/Makefile
    @@ -24,7 +24,11 @@
     #
     # audio.lib (see audio.h) and its sound drivers: every drivers//
     # directory is one driver, built from all its sources into
    -# drivers/.drv beside the library, where audio.lib finds it.
    +# drivers/.drv beside the library, where audio.lib finds it.  The
    +# ISA card code the DMA drivers share (isa/: buffers, DMA, interrupts), the
    +# AD1848 codec's (codec/: Windows Sound System boards, the UltraSound
    +# MAX), the GUS's GF1 code (gus/) and the synthesizers' arithmetic (math/)
    +# are linked into each driver that uses them (DRV_EXTRA_).
     #
     # The mixer and the drivers run code at interrupt time from pinned
     # memory (audioLockSpan pins from a module's first listed function to the
    @@ -45,9 +49,19 @@ TARGETDIR = $(LIBSDIR)/kpunch/audio
     TARGET    = $(TARGETDIR)/audio.lib
     
     DRVDIR      = $(TARGETDIR)/drivers
    -DRV_OBJS    = $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard drivers/*/*.c))
    +ISA_OBJS    = $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard isa/*.c))
    +CODEC_OBJS  = $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard codec/*.c))
    +GUS_OBJS    = $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard gus/*.c))
    +MATH_OBJS   = $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard math/*.c))
    +DRV_OBJS    = $(patsubst %.c,$(OBJDIR)/%.o,$(wildcard drivers/*/*.c)) $(ISA_OBJS) $(CODEC_OBJS) $(GUS_OBJS) $(MATH_OBJS)
     DRV_TARGETS = $(patsubst %,$(DRVDIR)/%.drv,$(DVX_DRIVERS))
     
    +DRV_EXTRA_sb    = $(ISA_OBJS)
    +DRV_EXTRA_wss   = $(ISA_OBJS) $(CODEC_OBJS)
    +DRV_EXTRA_gus   = $(ISA_OBJS) $(CODEC_OBJS) $(GUS_OBJS) $(MATH_OBJS)
    +DRV_EXTRA_guswt = $(GUS_OBJS) $(MATH_OBJS)
    +DRV_EXTRA_awe   = $(OBJDIR)/drivers/sb/sbCalc.o $(MATH_OBJS)
    +
     .PHONY: all clean
     
     all: $(TARGET) $(TARGETDIR)/audio.dep $(DRV_TARGETS)
    diff --git a/src/libs/kpunch/audio/audio.c b/src/libs/kpunch/audio/audio.c
    index 12ff624f..ff6ca260 100644
    --- a/src/libs/kpunch/audio/audio.c
    +++ b/src/libs/kpunch/audio/audio.c
    @@ -154,6 +154,7 @@ typedef struct {
         bool          quiet;                        // no voice since quietSince
         bool          suspended;                    // output closed for silence (the driver stays chosen)
         bool          fmTones;                      // square / triangle tones on FM voices
    +    bool          recording;                    // the card records (audioDevRecordBegin): no output, no new voice
     } AudioDevT;
     
     static const char *sErrorText[AUDIO_ERR_COUNT] = {
    @@ -167,8 +168,11 @@ static const char *sErrorText[AUDIO_ERR_COUNT] = {
         "Too many sounds are playing",
         "No sound device could be started",
         "No such sound is playing",
    -    "The synthesizer is being used by another program",
    -    "There is no such synthesizer"
    +    "The synthesizer or sound device is being used by another program (or is recording)",
    +    "There is no such synthesizer",
    +    "There is not enough synthesizer memory",
    +    "The sound device cannot record from that input",
    +    "The file could not be written"
     };
     
     static AudioDevT     sDev;
    @@ -194,6 +198,8 @@ void                            audioAppEnded(int32_t appId);
     int32_t                         audioAppEnter(int32_t appId);
     AudioCtxT                      *audioBasicContext(void);
     void                            audioClose(AudioCtxT *ctx);
    +int32_t                         audioDevRecordBegin(int32_t source, const AudioDriverT **drv);
    +void                            audioDevRecordEnd(void);
     int32_t                         audioDriverNames(char names[][AUDIO_NAME_MAX], int32_t max);
     const char                     *audioErrorText(int32_t err);
     void                            audioGetInfo(AudioInfoT *info);
    @@ -223,13 +229,16 @@ void                            audioSetVolume(AudioCtxT *ctx, int32_t volume);
     void                            audioShutdown(void);
     void                            audioStopAll(AudioCtxT *ctx);
     void                            audioStreamClose(AudioStreamT *stream);
    +void                            audioStreamFlush(AudioStreamT *stream);
     int32_t                         audioStreamOpen(AudioCtxT *ctx, const AudioFormatT *fmt, uint32_t bufferMs, AudioFillFnT fill, void *userData, AudioStreamT **out);
     uint32_t                        audioStreamQueued(const AudioStreamT *stream);
     uint32_t                        audioStreamSpace(const AudioStreamT *stream);
     int32_t                         audioStreamVoice(const AudioStreamT *stream);
     uint32_t                        audioStreamWrite(AudioStreamT *stream, const void *pcm, uint32_t frames);
    +void                            audioSuspend(bool suspending);
     int32_t                         audioTone(AudioCtxT *ctx, AudioWaveE wave, uint32_t hz, int32_t volume, uint32_t ms);
     bool                            audioVoiceIsPlaying(const AudioCtxT *ctx, int32_t voice);
    +int32_t                         audioVoicePause(AudioCtxT *ctx, int32_t voice, bool paused);
     int32_t                         audioVoiceSetFrequency(AudioCtxT *ctx, int32_t voice, uint32_t hz);
     int32_t                         audioVoiceSetPan(AudioCtxT *ctx, int32_t voice, int32_t pan);
     int32_t                         audioVoiceSetPitch(AudioCtxT *ctx, int32_t voice, int32_t pitch);
    @@ -259,6 +268,7 @@ static const AudioDriverT      *soundOf(const DvxDriverModT *mod);
     static void                     stepUpdate(int32_t slot);
     static void                     streamFill(AudioStreamT *s);
     static const AudioSynthDriverT *synthOf(const DvxDriverModT *mod);
    +static bool                     synthTry(const AudioSynthDriverT *syn, const AudioDrvConfigT *cfg);
     static int32_t                  voiceBegin(AudioCtxT *owner, int32_t volume, int32_t pan, uint8_t kind);
     static int32_t                  voiceGo(int32_t slot);
     
    @@ -334,6 +344,9 @@ void audioClose(AudioCtxT *ctx) {
             audioBasForget(ctx);
         }
     
    +    audioRecCtxClosed(ctx);
    +    audioMidiInCtxClosed(ctx);
    +
         for (int32_t i = 0; i < AUDIO_VOICES_MAX; i++) {
             if (sVoices[i].owner == ctx) {
                 audioMixer.voices[i].state = MIX_FREE;
    @@ -384,6 +397,7 @@ void audioClose(AudioCtxT *ctx) {
         }
     
         audioSynthCtxClosed(ctx);
    +    audioWtCtxClosed(ctx);
     
         for (AudioCtxT **cp = &sCtxs; *cp; cp = &(*cp)->next) {
             if (*cp == ctx) {
    @@ -400,6 +414,46 @@ void audioClose(AudioCtxT *ctx) {
     }
     
     
    +// The card for recording from source: its output closed (as the silence
    +// timeout does) and new voices refused until audioDevRecordEnd.
    +int32_t audioDevRecordBegin(int32_t source, const AudioDriverT **drv) {
    +    if (!sDev.mod.desc) {
    +        return AUDIO_ERR_NO_DEVICE;
    +    }
    +
    +    *drv = soundOf(&sDev.mod);
    +
    +    if (!(*drv)->recordStart || !(*drv)->recordStop || !(sDev.info.recSources & (1U << source))) {
    +        return AUDIO_ERR_NO_RECORD;
    +    }
    +
    +    if (!sDev.suspended) {
    +        (*drv)->close();
    +        sDev.suspended = true;
    +    }
    +
    +    sDev.recording = true;
    +    return AUDIO_OK;
    +}
    +
    +
    +// The voices the recording held play on: the output opens again when
    +// one is there (else at the next voice).
    +void audioDevRecordEnd(void) {
    +    sDev.recording = false;
    +
    +    for (int32_t i = 0; i < AUDIO_VOICES_MAX; i++) {
    +        if (audioMixer.voices[i].state != MIX_FREE) {
    +            if (!devResume()) {
    +                dvxLog("Audio: the output did not open again after recording");
    +            }
    +
    +            return;
    +        }
    +    }
    +}
    +
    +
     int32_t audioDriverNames(char names[][AUDIO_NAME_MAX], int32_t max) {
         DvxDriverModT *mods  = dvxDriversLoad(AUDIO_DRV_DIR, &sDrvClass);
         int32_t  count = 0;
    @@ -440,8 +494,10 @@ void audioGetInfo(AudioInfoT *info) {
         info->voiceLimit = sDev.voiceLimit;
         info->running    = true;
         info->hasDac     = sDev.info.hasDac;
    -    info->hasSynth   = sDev.info.hasSynth || sDev.synths[AUDIO_SYNTH_FM].desc || sDev.synths[AUDIO_SYNTH_MIDI].desc;
    +    info->hasSynth   = sDev.info.hasSynth || sDev.synths[AUDIO_SYNTH_FM].desc || sDev.synths[AUDIO_SYNTH_MIDI].desc || sDev.synths[AUDIO_SYNTH_WAVETABLE].desc;
         info->hasTone    = audioMixer.hwTone != NULL;
    +    info->recSources = soundOf(&sDev.mod)->recordStart ? sDev.info.recSources : 0;
    +    info->canRecord  = info->recSources != 0;
     }
     
     
    @@ -709,6 +765,7 @@ void audioPump(void) {
         reclaimSlots();
         devIdle();
         deliverNotes();
    +    audioRecIdle();
     
         AudioStreamT *s = sStreams;
     
    @@ -903,6 +960,19 @@ void audioStreamClose(AudioStreamT *stream) {
     }
     
     
    +// With interrupts off: the mixer moves the tail.
    +void audioStreamFlush(AudioStreamT *stream) {
    +    if (!stream) {
    +        return;
    +    }
    +
    +    uint32_t flags = audioIrqOff();
    +
    +    stream->ring.head = stream->ring.tail;
    +    audioIrqRestore(flags);
    +}
    +
    +
     int32_t audioStreamOpen(AudioCtxT *ctx, const AudioFormatT *fmt, uint32_t bufferMs, AudioFillFnT fill, void *userData, AudioStreamT **out) {
         *out = NULL;
     
    @@ -1020,6 +1090,60 @@ uint32_t audioStreamWrite(AudioStreamT *stream, const void *pcm, uint32_t frames
     }
     
     
    +// DVX steps aside for a DOS program (platformDosRun): every sample and
    +// tone stops, the output closes (the next voice opens it again, as after
    +// a silence: devResume) and the synthesizers are closed, which silences
    +// them; back from it, the synthesizers are opened again, since the program
    +// may have reset or reprogrammed the card.  Only the desktop runs by then,
    +// and music.lib, loaded after audio.lib, has let go of what it kept on the
    +// synthesizers first.
    +void audioSuspend(bool suspending) {
    +    if (!sDev.mod.desc) {
    +        return;
    +    }
    +
    +    if (suspending) {
    +        audioRecSuspend();
    +        audioMidiInSuspend();
    +
    +        for (AudioCtxT *ctx = sCtxs; ctx; ctx = ctx->next) {
    +            audioStopAll(ctx);
    +        }
    +
    +        if (!sDev.suspended) {
    +            soundOf(&sDev.mod)->close();
    +            sDev.suspended = true;
    +        }
    +
    +        for (int32_t k = 0; k < AUDIO_SYNTH_KINDS; k++) {
    +            if (sDev.synths[k].desc) {
    +                synthOf(&sDev.synths[k])->close();
    +            }
    +        }
    +
    +        dvxLog("Audio: sound device let go for a DOS program");
    +        return;
    +    }
    +
    +    PrefsHandleT   *prefs = prefsLoad(platformDvxIniPath());
    +    AudioDrvConfigT cfg;
    +
    +    devConfig(prefs, &cfg);
    +
    +    for (int32_t k = 0; k < AUDIO_SYNTH_KINDS; k++) {
    +        AudioSynthDrvInfoT info;
    +
    +        memset(&info, 0, sizeof(info));
    +
    +        if (sDev.synths[k].desc && !synthOf(&sDev.synths[k])->open(&cfg, &info)) {
    +            dvxLog("Audio: synth driver '%s' could not open again", sDev.synths[k].desc->name);
    +        }
    +    }
    +
    +    prefsClose(prefs);
    +}
    +
    +
     int32_t audioTone(AudioCtxT *ctx, AudioWaveE wave, uint32_t hz, int32_t volume, uint32_t ms) {
         if (!ctx || (wave != AUDIO_WAVE_SQUARE && wave != AUDIO_WAVE_TRIANGLE && wave != AUDIO_WAVE_NOISE) || hz < AUDIO_TONE_MIN_HZ || hz > AUDIO_TONE_MAX_HZ) {
             return AUDIO_ERR_ARG;
    @@ -1062,6 +1186,22 @@ bool audioVoiceIsPlaying(const AudioCtxT *ctx, int32_t voice) {
     }
     
     
    +int32_t audioVoicePause(AudioCtxT *ctx, int32_t voice, bool paused) {
    +    int32_t slot = lookupVoice(ctx, voice);
    +
    +    if (slot < 0) {
    +        return AUDIO_ERR_VOICE;
    +    }
    +
    +    if (audioMixer.voices[slot].kind != MIX_KIND_SAMPLE && audioMixer.voices[slot].kind != MIX_KIND_STREAM) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    audioMixer.voices[slot].paused = paused;
    +    return AUDIO_OK;
    +}
    +
    +
     int32_t audioVoiceSetFrequency(AudioCtxT *ctx, int32_t voice, uint32_t hz) {
         int32_t slot = lookupVoice(ctx, voice);
     
    @@ -1195,7 +1335,9 @@ static void devClose(void) {
     static void devCloseSynths(void) {
         audioFmDetach();
         audioMidiDetach();
    +    audioWtDetach();
         audioSynthLock(false);
    +    audioWtLock(false);
     
         for (int32_t k = 0; k < AUDIO_SYNTH_KINDS; k++) {
             if (sDev.synths[k].desc) {
    @@ -1326,33 +1468,24 @@ static bool devOpen(void) {
     // (highest priority first) that finds its hardware.  Kept modules are
     // taken out of mods so dvxDriversFree leaves them loaded.
     static void devOpenSynths(DvxDriverModT *mods, const AudioDrvConfigT *cfg) {
    -    for (int32_t i = 0; i < (int32_t)arrlen(mods); i++) {
    -        const AudioSynthDriverT *syn = synthOf(&mods[i]);
    -        AudioSynthDrvInfoT       info;
    +    const AudioSynthDriverT *own = sDev.info.synth;
     
    -        if (!syn || sDev.synths[syn->kind].desc) {
    -            continue;
    -        }
    -
    -        memset(&info, 0, sizeof(info));
    -
    -        if (!syn->open(cfg, &info)) {
    -            dvxLog("Audio: synth driver '%s' found no device", syn->hdr.name);
    -            continue;
    -        }
    -
    -        sDev.synths[syn->kind] = dvxDriverKeep(mods, i);
    -
    -        if (syn->kind == AUDIO_SYNTH_FM) {
    -            audioFmAttach(syn, &info);
    -        } else {
    -            audioMidiAttach(syn, &info);
    -        }
    -
    -        dvxLog("Audio: using '%s': %s", syn->hdr.name, info.description);
    +    // The sound card's own synthesizer first; it has no module of its own
    +    if (own && synthTry(own, cfg)) {
    +        sDev.synths[own->kind].handle = NULL;
    +        sDev.synths[own->kind].desc   = &own->hdr;
    +        sDev.synths[own->kind].kind   = DRV_KIND_SYNTH;
         }
     
    -    if (!audioSynthLock(true)) {
    +    for (int32_t i = 0; i < (int32_t)arrlen(mods); i++) {
    +        const AudioSynthDriverT *syn = synthOf(&mods[i]);
    +
    +        if (syn && !sDev.synths[syn->kind].desc && synthTry(syn, cfg)) {
    +            sDev.synths[syn->kind] = dvxDriverKeep(mods, i);
    +        }
    +    }
    +
    +    if (!audioSynthLock(true) || !audioWtLock(true)) {
             dvxLog("Audio: could not pin the synthesizer code in memory");
         }
     }
    @@ -1458,7 +1591,9 @@ static bool driverValid(const DvxDriverHeaderT *desc, int32_t kind) {
     
         const AudioSynthDriverT *syn = (const AudioSynthDriverT *)desc;
     
    -    return syn->open && syn->close && ((syn->kind == AUDIO_SYNTH_FM && syn->write) || (syn->kind == AUDIO_SYNTH_MIDI && syn->put));
    +    bool wave = syn->kind == AUDIO_SYNTH_WAVETABLE && syn->upload && syn->start && syn->update && syn->release && syn->stop && syn->active;
    +
    +    return syn->open && syn->close && ((syn->kind == AUDIO_SYNTH_FM && syn->write) || (syn->kind == AUDIO_SYNTH_MIDI && syn->put) || wave);
     }
     
     
    @@ -1647,12 +1782,37 @@ static void streamFill(AudioStreamT *s) {
     }
     
     
    -// The synth driver of a module (NULL: none, or a sound driver).
     static const AudioSynthDriverT *synthOf(const DvxDriverModT *mod) {
         return mod->kind == DRV_KIND_SYNTH ? (const AudioSynthDriverT *)mod->desc : NULL;
     }
     
     
    +// The synth driver of a module (NULL: none, or a sound driver).
    +// Open a synthesizer and attach it as its kind; false when it found no
    +// device.
    +static bool synthTry(const AudioSynthDriverT *syn, const AudioDrvConfigT *cfg) {
    +    AudioSynthDrvInfoT info;
    +
    +    memset(&info, 0, sizeof(info));
    +
    +    if (!syn->open(cfg, &info)) {
    +        dvxLog("Audio: synth driver '%s' found no device", syn->hdr.name);
    +        return false;
    +    }
    +
    +    if (syn->kind == AUDIO_SYNTH_FM) {
    +        audioFmAttach(syn, &info);
    +    } else if (syn->kind == AUDIO_SYNTH_MIDI) {
    +        audioMidiAttach(syn, &info, audioSettingMidiIn(cfg->prefs) != 0);
    +    } else {
    +        audioWtAttach(syn, &info);
    +    }
    +
    +    dvxLog("Audio: using '%s': %s", syn->hdr.name, info.description);
    +    return true;
    +}
    +
    +
     // Claim a free slot for a new voice of owner and fill in everything but
     // the source; the voice limit may drop the quietest sample or tone first
     // (or refuse this one when it would be the quietest).  Returns the slot
    @@ -1662,6 +1822,10 @@ static int32_t voiceBegin(AudioCtxT *owner, int32_t volume, int32_t pan, uint8_t
             return AUDIO_ERR_ARG;
         }
     
    +    if (sDev.recording) {
    +        return AUDIO_ERR_BUSY;
    +    }
    +
         if (sDev.suspended && !devResume()) {
             return AUDIO_ERR_NO_DEVICE;
         }
    diff --git a/src/libs/kpunch/audio/audio.dhs b/src/libs/kpunch/audio/audio.dhs
    index 5d75ecf8..c6f82cc0 100644
    --- a/src/libs/kpunch/audio/audio.dhs
    +++ b/src/libs/kpunch/audio/audio.dhs
    @@ -34,6 +34,13 @@
     .index audioTone
     .index audioStreamOpen
     .index audioStreamWrite
    +.index audioStreamFlush
    +.index audioVoicePause
    +.index audioWavParse
    +.index audioDecode
    +.index SoundPause
    +.index SoundResume
    +.index SoundStreamFlush
     .index audioSampleLoad
     .index audioGetInfo
     .index AudioInfoT
    @@ -49,6 +56,12 @@
     .index audioFmWrite
     .index audioFmRawAcquire
     .index audioMidiAcquire
    +.index audioMidiInOpen
    +.index audioMidiInRead
    +.index AudioMidiMsgT
    +.index MIDI input
    +.index MidiInOpen
    +.index MidiInMessage
     .index AudioSynthDriverT
     .index OPL
     .index MPU-401
    @@ -63,8 +76,37 @@
     .index audioDeskSetFile
     .index audioSettingsStore
     .index audioMidiChoices
    +.index audioModulesChoices
    +.index audioSettingModules
     .index audioPercentToVolume
     .index SoundSettingsStore
    +.index SoundModulesLabel$
    +.index audioWtAlloc
    +.index audioWtStart
    +.index audioWtUpload
    +.index audioWtMemAlloc
    +.index AudioWtVoiceT
    +.index audioWtNoteInit
    +.index AUDIO_WT_PITCH_RATE
    +.index Wavetable synthesizer
    +.index Windows Sound System
    +.index Gravis UltraSound
    +.index Sound Blaster AWE32
    +.index EMU8000
    +.index Covox
    +.index Disney Sound Source
    +.index Stereo-on-1
    +.index WtInfo$
    +.index WtStart
    +.index audioRecOpen
    +.index audioRecFile
    +.index audioRecRead
    +.index audioRecClose
    +.index audioRecPush
    +.index audioWavHeader
    +.index Recording
    +.index RecordStart
    +.index RecordStop
     
     .h1 Sound Library
     
    @@ -104,7 +146,7 @@ audioSampleLoad (file), audioSampleLoadMem (memory), and audioSampleCreate (raw
       pitch                AUDIO_PITCH_MIN (64) .. AUDIO_PITCH_MAX (1024), AUDIO_PITCH_NORMAL (256) = recorded speed
     .endtable
     
    -audioVoiceSetVolume, audioVoiceSetPan, audioVoiceSetPitch, audioVoiceStop, and audioVoiceIsPlaying take the context and the voice id; a voice id of another context, or of a voice that has ended, gives AUDIO_ERR_VOICE. audioStopAll stops every sample and tone of a context.
    +audioVoiceSetVolume, audioVoiceSetPan, audioVoiceSetPitch, audioVoicePause, audioVoiceStop, and audioVoiceIsPlaying take the context and the voice id; a voice id of another context, or of a voice that has ended, gives AUDIO_ERR_VOICE. audioVoicePause(ctx, voice, paused) holds a sample's or a stream's voice where it is (it stays playing, silent, and takes nothing from its stream) or lets it go on; a tone or a note queue gives AUDIO_ERR_ARG. audioStopAll stops every sample and tone of a context.
     
     audioSetEndFn(ctx, fn, userData) asks for a call when a voice of ctx ends, with the reason: AUDIO_END_FINISHED (played out), AUDIO_END_STOPPED (stopped, or its sample or stream went away), or AUDIO_END_DROPPED (the voice limit made room for a louder sound). The call comes from audioPump or the shell's idle time, as the program that owns the context, never from an interrupt. The mixer runs up to one buffer (about 50 ms) ahead of what is heard, so a notification can come that much before the sound has finished playing.
     
    @@ -133,7 +175,25 @@ AudioStreamT *stream = NULL;
     audioStreamOpen(audio, &fmt, 250, fillMusic, &player, &stream);
     .endcode
     
    -audioStreamOpen(ctx, fmt, bufferMs, fill, userData, &stream) opens a stream of any rate from AUDIO_RATE_MIN (4000) to AUDIO_RATE_MAX (48000), 8 or 16 bits, mono or stereo, with a buffer of bufferMs milliseconds (0: 250). It plays at once on its own voice (audioStreamVoice), silence until samples arrive. Without fill the program writes samples with audioStreamWrite, which takes as many frames as fit and returns how many; audioStreamSpace and audioStreamQueued tell how much room and how much sound are left. With fill, the library asks for samples whenever the buffer has room: fill(stream, buf, frames, userData) writes up to frames frames in the stream's format and returns how many. Fill functions run from audioPump and the shell's idle time; a program that keeps the desktop busy every frame should call audioPump once a frame. A stream that runs dry plays silence and counts underruns. audioStreamClose stops and frees it.
    +audioStreamOpen(ctx, fmt, bufferMs, fill, userData, &stream) opens a stream of any rate from AUDIO_RATE_MIN (4000) to AUDIO_RATE_MAX (48000), 8 or 16 bits, mono or stereo, with a buffer of bufferMs milliseconds (0: 250). It plays at once on its own voice (audioStreamVoice), silence until samples arrive. Without fill the program writes samples with audioStreamWrite, which takes as many frames as fit and returns how many; audioStreamSpace and audioStreamQueued tell how much room and how much sound are left. With fill, the library asks for samples whenever the buffer has room: fill(stream, buf, frames, userData) writes up to frames frames in the stream's format and returns how many. Fill functions run from audioPump and the shell's idle time; a program that keeps the desktop busy every frame should call audioPump once a frame. A stream that runs dry plays silence and counts underruns. audioStreamFlush drops what the stream holds and has not played yet (a seek or a stop is heard at once; the two frames the mixer has read ahead still play). audioStreamClose stops and frees it.
    +
    +audioDrv.h also declares, for audio.lib and the libraries built on it (music.lib), audioDecode (a WAV or VOC file in memory as 16-bit frames), audioConvert (8- or 16-bit frames as 16-bit ones), and audioWavParse(head, headBytes, fileBytes, &fmt, &dataAt, &dataBytes) (a WAV file's format and where its frames are, from its first bytes: the frames are clamped to the file and whole; AUDIO_ERR_FORMAT for anything but a RIFF WAVE file, AUDIO_ERR_UNSUPPORTED, AUDIO_ERR_CORRUPT).
    +
    +.h2 Recording
    +
    +.code
    +AudioFormatT fmt = { 22050, 16, 1 };
    +AudioRecT   *rec = NULL;
    +
    +if (audioRecFile(audio, "TALK.WAV", &fmt, AUDIO_REC_MIC, &rec) == AUDIO_OK) {
    +    // ... later
    +    audioRecClose(rec);
    +}
    +.endcode
    +
    +audioRecOpen(ctx, fmt, source, bufferMs, &rec) records from the card's microphone, line, or CD input (AUDIO_REC_MIC, AUDIO_REC_LINE, AUDIO_REC_CD; AudioInfoT.canRecord, and recSources with bit 1 << source for each the card has) into a buffer of bufferMs (0: AUDIO_REC_MS_DEFAULT, 2 seconds; AUDIO_REC_MS_MIN to AUDIO_REC_MS_MAX). fmt asks for a rate, 8 or 16 bits, and mono or stereo (0: AUDIO_RATE_DEFAULT, AUDIO_REC_BITS_DEFAULT, mono) and comes back as what the card records: its nearest rate, and its own bits and channels when it cannot do the ones asked (a card before the SB Pro records 8-bit mono). The program takes the frames with audioRecRead(rec, pcm, frames) before the buffer fills; audioRecQueued says how many wait, audioRecLost how many were dropped because the buffer was full (whole blocks, so the frames stay in order), audioRecFrames how many were taken, and audioRecPeak the loudest sample since it was last asked, in percent (a level meter). audioRecFile(ctx, path, fmt, source, &rec) records into a WAV file instead: the library writes the frames as they come (audioPump and the shell's idle time) and the file's sizes at audioRecClose; the file starts with an empty header, so one cut short still reads. Everything that can be checked (the arguments, another recording, the card and its input) is checked before the file is made, so a recording that cannot start leaves a file of that name alone. audioRecClose stops and returns AUDIO_ERR_WRITE when the file could not all be written. audioWavHeader(out, fmt, dataBytes) writes the AUDIO_WAV_HEADER_BYTES of a WAV header for programs writing their own files.
    +
    +The Sound Blasters (sb), Windows Sound System boards (wss), and the Gravis UltraSound (gus) record; the DACs on the printer port, the PC speaker, and null do not. Most cards record or play, not both, so recording takes the card: its output closes (as after a silence), voices already playing hold where they are, and a new voice gets AUDIO_ERR_BUSY (the desktop's sounds are skipped) until the recording ends and the held voices play on. One recording at a time for all of DVX (another gets AUDIO_ERR_BUSY). Closing the context (the program's end) and a DOS program run with DVX stepped aside (audioSuspend) end the recording.
     
     .h2 FM Synthesizer
     
    @@ -152,10 +212,32 @@ audioFmRawAcquire(ctx) gives one program the whole chip: every voice the others
     
     audioFmAlloc, audioFmFree, audioFmWrite, and audioFmRawWrite may also be called from a timer callback (platformTimerAdd); music.lib plays MIDI that way.
     
    +.h2 Wavetable Synthesizer
    +
    +.code
    +uint32_t      addr = 0;
    +int32_t       v    = audioWtAlloc(audio);       // AUDIO_ERR_NO_VOICE: all taken
    +AudioWtVoiceT note;
    +
    +audioWtMemAlloc(audio, frames * 2, 16, &addr);
    +audioWtUpload(audio, addr, pcm, frames, 16);
    +audioWtNoteInit(¬e);                         // a plain note: no envelope, the filter open
    +note.addr = addr;
    +note.end  = frames;
    +note.bits = 16;
    +audioWtStart(audio, v, ¬e);                  // pitch 0: AUDIO_WT_PITCH_RATE (44,100) frames a second
    +.endcode
    +
    +A wavetable synthesizer (a Gravis UltraSound's GF1, a Sound Blaster AWE32's or AWE64's EMU8000) plays samples from its own memory, and its voices and memory are shared by every program. audioWtGetInfo tells the chip (description, voices, free voices, memory, the largest free block, what it does itself: AUDIO_WT_ENVELOPES, AUDIO_WT_LFOS, AUDIO_WT_FILTER, AUDIO_WT_EFFECTS, AUDIO_WT_8BIT for 8-bit samples, AUDIO_WT_BANK_256K when a 16-bit sample must not cross a 256 KB boundary, AUDIO_WT_ROM for a General MIDI sample ROM; and its level, the centibels a voice at full volume is louder than full-scale digital sound, measured: 0 on a GUS, whose digital sound plays on its own voices, 76 on an AWE). audioWtMemAlloc(ctx, bytes, bits, &addr) takes memory for samples of bits bits (AUDIO_ERR_NO_ROOM when no free block is large enough), audioWtUpload(ctx, addr, frames, count, bits) copies 16-bit frames into it at bits, audioWtMemFree gives it back. audioWtAlloc(ctx) takes a voice (the least recently given back first), audioWtStart(ctx, voice, note) starts it on a note (AudioWtVoiceT: the sample's address, or AUDIO_WT_ADDR_ROM with an address in the ROM, and in frames from there the one it starts from (offset: before the loop or inside it), its loop, and its end; pitch in cents, 0 for AUDIO_WT_PITCH_RATE (44,100) frames a second; attenuation in centibels; pan; the filter, reverb and chorus; both envelopes in timecents; both LFOs; audioWtNoteInit makes a plain one, with no envelope or LFO shaping it, the filter open, no effects, centred, at full volume, for the caller to set the sample on), audioWtUpdate changes what its what bits say (AUDIO_WT_SET_PITCH, _VOLUME, _PAN, _FILTER, _EFFECTS, _LFO), audioWtRelease lets the key up, audioWtStop stops it at once, audioWtActive is false once it is silent, audioWtFree gives it back. The chip does what its flags say; the program does the rest by updating the voice as the note goes on. Closing the context gives back its voices and its memory. The voice calls may be made from a timer callback (music.lib plays SoundFont MIDI and modules that way); the memory calls may not.
    +
     .h2 MIDI Port
     
     With DVX.INI [audio] midi = mpu401 the mpu401 driver opens an MPU-401 (or compatible, such as a Sound Blaster 16's) in UART mode, for an external synthesizer such as a Roland MT-32 or SC-55. The port belongs to one program at a time: audioMidiAcquire(ctx) (AUDIO_ERR_BUSY when another has it, AUDIO_ERR_NO_SYNTH without a port), audioMidiPut(ctx, byte) sends a byte and returns AUDIO_ERR_BUSY when the port cannot take one yet (send it again later; it may be called from a timer callback), audioMidiRelease gives it back (closing the context does too). audioMidiGetInfo tells whether there is a port and whether it is in use. music.lib uses the port for MIDI music.
     
    +.h2 MIDI Input
    +
    +With DVX.INI [audio] midiIn = mpu401 the same port also brings in what a MIDI keyboard or another instrument plays (the port is shared with MIDI music when midi = mpu401 too). One program reads it at a time: audioMidiInOpen(ctx) (AUDIO_ERR_NO_SYNTH when there is no MIDI input, AUDIO_ERR_BUSY when another program reads it), audioMidiInClose(ctx) (closing the context does it too, and a DOS program run with DVX stepped aside closes it). A timer reads the port AUDIO_MIDI_IN_HZ (1,000) times a second into a buffer of AUDIO_MIDI_IN_BYTES with the time each byte came; audioMidiInRead(ctx, &msg) gives the next whole message, oldest first (false when none waits): AudioMidiMsgT has its status (a channel message 80h..EFh, the kind and the channel, or system common F1h..F3h), data1, data2, length (1 to 3 bytes), and ms, when its first byte came in milliseconds since audioMidiInOpen. Running status is filled in; clock and active-sensing bytes are dropped and system exclusive messages skipped. audioMidiInClock(ctx) is the time now on that clock, and audioMidiInLost(ctx) the bytes dropped because the buffer was full (the program read too late). audioMidiGetInfo says whether there is MIDI input (inPresent) and whether a program reads it (inOwned). music.lib plays what comes in (musicMidiSend) and records it into MIDI files (musicMidiRecordNew); the Sound Recorder does both.
    +
     .h2 Volume and the Voice Limit
     
     audioSetMasterVolume sets the volume of all sound (DVX.INI [audio] volume, in percent, sets it when the device starts); audioSetVolume sets the volume of everything one context plays. At most the voice limit (DVX.INI [audio] voices, 1 to AUDIO_VOICES_MAX, default 16) plays at once. Starting another sample or tone then stops the quietest one playing (it ends with AUDIO_END_DROPPED); when the new sound would itself be the quietest it is not started (AUDIO_ERR_NO_VOICE). Streams are never dropped.
    @@ -166,7 +248,7 @@ WAV: uncompressed PCM, 8-bit unsigned or 16-bit signed, mono or stereo, 4000 to
     
     .h2 The Mixer
     
    -All sound is mixed in 16-bit integers at the rate the device achieved (the mixer never assumes the rate it asked for: a Sound Blaster Pro asked for 22,050 Hz plays at 22,222 Hz), and every source is resampled to it with linear interpolation. Gains combine volume, pan, context volume, and master volume; the sum is clipped to 16 bits. The mixer runs from the sound card's own interrupt (the Sound Blaster, at the end of each half buffer of about 23 ms), or from the timer service for devices without one, so sound keeps playing however busy the programs are. It uses no floating point and touches only memory pinned for interrupts (sample and stream buffers are pinned when they are made).
    +All sound is mixed in 16-bit integers at the rate the device achieved (the mixer never assumes the rate it asked for: a Sound Blaster Pro asked for 22,050 Hz plays at 22,222 Hz), and every source is resampled to it with linear interpolation. Gains combine volume, pan, context volume, and master volume; the sum is clipped to 16 bits. The mixer runs from the sound card's own interrupt (at the end of each half buffer of about 23 ms), or from the timer service for devices without one, so sound keeps playing however busy the programs are. It uses no floating point and touches only memory pinned for interrupts (sample and stream buffers are pinned when they are made). A 486's multiplies are slow, so the mixer does none whose result is known: a sound at the device's rate is copied frame for frame without interpolating, a mono one is worked out once for both sides, and a centred mono one scaled once. Measured on an emulated 486DX2-66 at 22,050 Hz, eight mono sounds at the device's rate take about 19 percent of the CPU, eight at half of it (interpolated) about 30 percent.
     
     .h2 Settings
     
    @@ -175,7 +257,7 @@ DVX.INI section [audio]:
     .table
       Key          Meaning
       ---          -------
    -  driver       auto (default: find the hardware), or a driver name: sb, speaker, null
    +  driver       auto (default: find the hardware), or a driver name: wss, sb, gus, lpt, speaker, null
       rate         mixing rate asked for, 4000 to 48000 (default 22050)
       bits         0 (the device's best), 8, or 16
       channels     0 (the device's best), 1, or 2
    @@ -184,13 +266,21 @@ DVX.INI section [audio]:
       speakerPcm   true: sampled sound on the PC speaker (see below); default false
       fmTones      false: tones always in the mixer, never on FM voices; default true
       oplPort      the FM chip's port, hex (default 388)
    -  midi         where MIDI music plays: auto (default: the FM chip, else music.lib's software synthesizer), mpu401, fm, software
    -  mpuPort      the MPU-401's port, hex (default 330); the port is only looked for with midi = mpu401
    +  midi         where MIDI music plays (music.lib): auto (default: a wavetable synthesizer with its SoundFont, else the FM chip, else the software synthesizer), mpu401, fm, software, softwave (the SoundFont in software)
    +  soundFont    the SoundFont for wavetable MIDI (music.lib): a name in the banks folders or a path, or an UltraSound's ULTRASND.INI; not set, the standard one, KPUNCH\DVXGM.SF2 (Control Panel's Sounds page picks it)
    +  wssPort      the Windows Sound System board's port, hex (530, 604, E80, or F40; not set: each is tried)
    +  wssIrq       its IRQ: 7 (default), 9, 10, or 11
    +  wssDma       its DMA channel: 0, 1 (default), or 3
    +  gusVoices    voices the Gravis UltraSound runs, 14 to 32 (default 28: 22,050 Hz output; fewer, a higher rate)
    +  lptDac       the parallel port DAC: auto (default: a Disney Sound Source, else a Stereo-on-1), covox, disney, stereo
    +  lptPort      its port, hex (not set: LPT1)
    +  midiIn       where MIDI input comes from: none (default) or mpu401 (the MPU-401 port)
    +  mpuPort      the MPU-401's port, hex (default 330); the port is only looked for with midi or midiIn = mpu401
       mpuMode      what is on the MPU-401 (music.lib): gm (default), mt32, or raw
       midiBank     the OP2 instrument bank for FM MIDI (music.lib): its name in SHARED\BANKS or the user's BANKS folder (an add-on's as VENDOR\NAME), or a path from the DVX folder; not set, the bundled one (Control Panel's Sounds page picks it)
     .endtable
     
    -A settings screen reads and writes the keys it offers through the library, so the key names live in one place (audio.h, Settings; prefs is a copy of DVX.INI the caller loads and saves): audioSettingDriver(prefs) (AUDIO_DRIVER_AUTO when missing), audioSettingMidi(prefs) (an index in audioMidiChoices, auto when missing or unknown), audioSettingVolume(prefs) (percent, clamped to 0..100, 100 when missing), and audioSettingsStore(prefs, driver, midi, volume) writes all three (an empty driver stores auto, a midi index out of range stores auto). audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] holds the midi choices in list order, each with its label for people and the name stored (a PrefsChoiceT, dvxPrefs.h). audioPercentToVolume(percent) turns a percent into 0..AUDIO_VOLUME_MAX for audioSetMasterVolume. The device reads the driver and volume through the same functions when it starts. The Control Panel's Sounds page uses all of these.
    +A settings screen reads and writes the keys it offers through the library, so the key names live in one place (audio.h, Settings; prefs is a copy of DVX.INI the caller loads and saves): audioSettingDriver(prefs) (AUDIO_DRIVER_AUTO when missing), audioSettingMidi(prefs) (an index in audioMidiChoices, auto when missing or unknown), audioSettingMidiIn(prefs) (an index in audioMidiInChoices, none when missing or unknown), audioSettingModules(prefs) (an index in audioModulesChoices, wavetable when missing or unknown), audioSettingVolume(prefs) (percent, clamped to 0..100, 100 when missing), and audioSettingsStore(prefs, driver, midi, modules, volume) writes all four (an empty driver stores auto, a midi index out of range stores auto, a modules index out of range wavetable); audioSettingMidiInStore(prefs, midiIn) writes midiIn (an index out of range stores none), which takes effect when the device next starts. audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] holds the midi choices in list order, each with its label for people and the name stored (a PrefsChoiceT, dvxPrefs.h); audioMidiInChoices[AUDIO_MIDI_IN_CHOICE_COUNT] the midiIn choices, audioModulesChoices[AUDIO_MODULES_CHOICE_COUNT] the modules choices (wavetable: on a wavetable synthesizer's voices when it is free and the module fits there; software: always mixed in software). audioPercentToVolume(percent) turns a percent into 0..AUDIO_VOLUME_MAX for audioSetMasterVolume. The device reads the driver and volume through the same functions when it starts. The Control Panel's Sounds page uses all of these.
     
     .h2 Desktop Sounds
     
    @@ -215,29 +305,34 @@ The library plays the sounds DVX makes for its events (dvxPlaySound in dvxApp.h)
     .table
       Driver       Device
       ------       ------
    -  sb           Sound Blaster family by DSP version: 1.x (8-bit single-cycle), 1.5 / 2.0 (8-bit auto-init, high-speed above 23,000 Hz on 2.0), Pro and Pro 2 (8-bit stereo at 11,025 or 22,050 Hz), 16 (8- or 16-bit, mono or stereo, any rate from 5,000 to 44,100 Hz). The port, IRQ, and DMA channels come from the BLASTER variable (e.g. SET BLASTER=A220 I5 D1 H5); without it an SB16 is found at 220h or 240h and asked for its IRQ and DMA. If no interrupt arrives from the card within half a second (a wrong IRQ), the next driver is tried.
    +  sb           Sound Blaster family by DSP version: 1.x (8-bit single-cycle), 1.5 / 2.0 (8-bit auto-init, high-speed above 23,000 Hz on 2.0), Pro and Pro 2 (8-bit stereo at 11,025 or 22,050 Hz), 16 (8- or 16-bit, mono or stereo, any rate from 5,000 to 44,100 Hz). It records with the same modes' input commands (sbRecordPlan): before the Pro the microphone only, 8-bit mono up to 13,000 Hz (15,000 on DSP 2.01, high-speed above 13,000); the Pro and the 16 also the line and CD inputs (mixer 0Ch; 3Dh / 3Eh with the microphone at full on the 16), recording what they play; the mixer registers it changes are put back when it stops. The port, IRQ, and DMA channels come from the BLASTER variable (e.g. SET BLASTER=A220 I5 D1 H5); without it an SB16 is found at 220h or 240h and asked for its IRQ and DMA. If no interrupt arrives from the card within half a second (a wrong IRQ), the next driver is tried.
       speaker      The PC speaker. By default tones only: the timer chip plays the loudest square or triangle tone as a square wave, with no interrupts and no CPU cost; sampled sound and noise are not heard. With speakerPcm = true it plays everything by pulse-width modulation: the timer interrupt runs at 22,096 Hz and each interrupt sets the next pulse (about 5.7 bits). That costs a lot of CPU time: 22,096 interrupts a second plus the mixing. A 486DX2-66 spends a large share of its time on it (estimated 10 to 15 percent for the interrupts alone on plain DOS, much more under a memory manager or Windows) and can lose interrupts, which slows the sound and the DVX clock down. Meant for Pentium-class machines.
    +  wss          A Windows Sound System board (an AD1848 codec or its kin: Microsoft's, Crystal CS4231 / CS4248, Aztech, OPTi in WSS mode) at wssPort: 8- or 16-bit, mono or stereo, the codec's nearest rate, double-buffered by auto-init DMA on wssIrq and wssDma (set through the board's configuration register; a Plug and Play board set up by its own utility must be on the ones DVX.INI names). It records the same way (the codec's capture on the same DMA channel): the line input, AUX1 (the CD on WSS boards), or the microphone with its 20 dB boost, putting back the inputs it found. Tried before the Sound Blaster, which such a board often also has.
    +  gus          A Gravis UltraSound, found through the ULTRASND variable (port, DMA channels, IRQs: SET ULTRASND=220,1,1,11,5): digital sound on two of its voices from a ring at the top of its memory, refilled by DMA at each half, and its wavetable synthesizer on the rest (gusVoices active in all). It records from the microphone or the line input on ULTRASND's second DMA channel (the first when the second is not given): an UltraSound MAX (board version 10 or 11) through its CS4231 codec, as a Windows Sound System board records (8- or 16-bit, mono or stereo, the codec's rates), any other board with the GF1's own sampling (8-bit, mono or stereo, 5,510 to 44,100 Hz, each half started from the terminal count of the one before, since the GF1 does not run on by itself); no emulator DVX is tested on records on a GUS, so this follows Linux's drivers. Tried after a Sound Blaster beside it, which then plays the digital sound while the GUS plays music (guswt).
    +  lpt          A DAC on the parallel port (lptDac, lptPort): a Covox Speech Thing (used only when lptDac = covox: it cannot be found), a Disney Sound Source (a FIFO at a fixed 7 kHz, topped up by the timer), or a Stereo-on-1. 8-bit at most 11,048 Hz; the Covox and the Stereo-on-1 take a timer interrupt for every frame.
       null         No sound device: plays nothing, but voices still run their course and end, so programs behave the same.
    -  opl          (synthesizer) The OPL2 or OPL3 FM chip of an AdLib or Sound Blaster at 388h (oplPort), found by the AdLib timer test; the status register tells an OPL2 from an OPL3. Register writes are timed by status reads (3.3 and 23 us on an OPL2) with interrupts off around each index/data pair. Closing zeroes every register.
    -  mpu401       (synthesizer) An MPU-401 MIDI port in UART mode at mpuPort, only with midi = mpu401: reset (FFh, answered FEh; tried twice, since a port left in UART mode takes the first FFh as data), then UART mode (3Fh). Bytes are sent when the port says it is ready (a short wait, else the caller sends later); input the port has (active sensing) is read and dropped. Closing sends All Notes Off on every channel and resets the port.
    +  opl          (synthesizer) The OPL2 or OPL3 FM chip of an AdLib or Sound Blaster at 388h (oplPort), found by the AdLib timer test (timer 1 must overflow at its fastest count and not at its slowest, which also tells the AdLib-compatible timers of a Gravis UltraSound, which has no FM, from a chip); the status register tells an OPL2 from an OPL3. Register writes are timed by status reads (3.3 and 23 us on an OPL2) with interrupts off around each index/data pair. Closing zeroes every register.
    +  awe          (synthesizer) The EMU8000 of a Sound Blaster AWE32 or AWE64 (BLASTER's E, else A + 400h), set up as Creative's programmer's guide gives: 30 voices (two carry the FM chip's sound), its memory in 512 KB steps, envelopes, LFOs, filter, reverb and chorus in hardware, and its 1 MB General MIDI sample ROM.
    +  gus, guswt   (synthesizer) The Gravis UltraSound's voices playing samples from its memory, with the library running the envelopes and LFOs: gus.drv's own when it plays the digital sound too, else guswt.drv (the GUS beside another sound device, all its voices for music).
    +  mpu401       (synthesizer) An MPU-401 MIDI port in UART mode at mpuPort, only with midi or midiIn = mpu401: reset (FFh, answered FEh; tried twice, since a port left in UART mode takes the first FFh as data), then UART mode (3Fh). Bytes are sent when the port says it is ready (a short wait, else the caller sends later); input the port has waiting then is kept for get, the MIDI input's timer (64 bytes; more is dropped while nobody reads it). Closing sends All Notes Off on every channel and resets the port.
     .endtable
     
     audioDriverNames(names, max) lists the short names of the sound drivers installed, the one tried first first (for a settings screen; AUDIO_DRIVERS_MAX is the most DVX's own callers ask for).
     
    -Sound drivers are tried in this order: the one DVX.INI names, then from the highest priority down (Sound Blaster, PC speaker, null). The first that starts is used. Synthesizer drivers are opened with the sound device, beside it: of each kind (FM chip, MIDI port) the first that finds its hardware is kept, and they close with the device. The sound device's description and hasSynth in AudioInfoT tell whether there is a synthesizer.
    +Sound drivers are tried in this order: the one DVX.INI names, then from the highest priority down (Windows Sound System, Sound Blaster, Gravis UltraSound, parallel port DAC, PC speaker, null). The first that starts is used. Synthesizer drivers are opened with the sound device, beside it: the sound device's own first (the GUS's), then of each kind (FM chip, MIDI port, wavetable) the first that finds its hardware, and they close with the device. The sound device's description and hasSynth in AudioInfoT tell whether there is a synthesizer.
     
     .h2 Writing a Driver
     
    -A driver is a driver module (see dvxDrv.h in the API reference): .drv in bin/libs/kpunch/audio/drivers, built by the audio Makefile from each drivers// folder, that exports one AudioDriverT named audioDriver, written with AUDIO_DRIVER(name, AUDIO_PRIORITY_*, .open = ..., .play = ..., .close = ...). The descriptor carries DVX_ABI_VERSION; a driver built for another version is refused. audio.lib loads the drivers when the first program opens sound, keeps the one that starts (and the synthesizers that find their hardware) and unloads the rest. open detects the hardware and fills AudioDrvInfoT (description, achieved rate, bits, channels, hasDac, hasSynth, a hardware tone function) without making a sound; play starts output; close stops it and releases everything, after which the mixer is not called again. After a few seconds of silence audio.lib calls close and later open and play again on the same driver, so open must work more than once and must not make a sound (the driver's module stays loaded in between). While playing, the driver calls audioMixRender(buffer, frames) for each block of output from its own interrupt, or calls audioClockStart to have the timer service drive the mixer (the output is thrown away) when it only plays tones or nothing. Code and data an interrupt touches must be pinned: audioLockSpan pins a module's listed functions and data (a module's code comes before its data, so one span covers them), and the library is built without hot/cold function splitting and without loops turned into library calls. Interrupt code must not call the C library, DVX, or DOS and must not use floating point. audio.lib stops the device from a shutdown hook if DVX ends without closing it.
    +A driver is a driver module (see dvxDrv.h in the API reference): .drv in bin/libs/kpunch/audio/drivers, built by the audio Makefile from each drivers// folder, that exports one AudioDriverT named audioDriver, written with AUDIO_DRIVER(name, AUDIO_PRIORITY_*, .open = ..., .play = ..., .close = ...). The descriptor carries DVX_ABI_VERSION; a driver built for another version is refused. audio.lib loads the drivers when the first program opens sound, keeps the one that starts (and the synthesizers that find their hardware) and unloads the rest. open detects the hardware and fills AudioDrvInfoT (description, achieved rate, bits, channels, hasDac, hasSynth, a hardware tone function) without making a sound; play starts output; close stops it and releases everything, after which the mixer is not called again. After a few seconds of silence audio.lib calls close and later open and play again on the same driver, so open must work more than once and must not make a sound (the driver's module stays loaded in between). A driver whose card records also sets AudioDrvInfoT.recSources and gives recordStart(want, source, got) and recordStop: recordStart is called with the output closed and takes the card for recording (its own DMA buffer and interrupt; the format the card records in got), its interrupt then calls audioRecPush(src, bytes) with each block recorded (whole frames; a block that does not fit is dropped and counted), and recordStop gives everything back as close does (sb.drv, wss.drv, and gus.drv use their close for both; the AD1848 codec's routines a Windows Sound System board and an UltraSound MAX share are codec/codecHw.c). While playing, the driver calls audioMixRender(buffer, frames) for each block of output from its own interrupt, or calls audioClockStart to have the timer service drive the mixer (the output is thrown away) when it only plays tones or nothing. Code and data an interrupt touches must be pinned: audioLockSpan pins a module's listed functions and data (a module's code comes before its data, so one span covers them), and the library is built without hot/cold function splitting and without loops turned into library calls. Interrupt code must not call the C library, DVX, or DOS and must not use floating point. audio.lib stops the device from a shutdown hook if DVX ends without closing it.
     
    -A synthesizer driver exports an AudioSynthDriverT named audioSynth instead, written with AUDIO_SYNTH_DRIVER(name, priority, .kind = AUDIO_SYNTH_FM or AUDIO_SYNTH_MIDI, .open = ..., .close = ..., .write = ... or .put = ...). open detects the hardware, fills AudioSynthDrvInfoT (description; for FM the voices and whether it is stereo) and leaves it silent; close silences it. write(reg, value) sets an FM register (bank x AUDIO_FM_BANK_REGS + index) and must keep the index/data pair together (audioIrqOff / audioIrqRestore); put(byte) sends a MIDI byte, returning false when the port is not ready. Both run at interrupt time too (timer callbacks), so they follow the rules above and are pinned by the driver.
    +A synthesizer driver exports an AudioSynthDriverT named audioSynth instead, written with AUDIO_SYNTH_DRIVER(name, priority, .kind = AUDIO_SYNTH_FM, AUDIO_SYNTH_MIDI, or AUDIO_SYNTH_WAVETABLE, .open = ..., .close = ..., then .write, .put, or the wavetable functions). open detects the hardware, fills AudioSynthDrvInfoT (description; for FM the voices and whether it is stereo; for a wavetable synthesizer its voices, memory, AUDIO_WT_* flags, and level, 0 when it is as loud as the digital sound or not known) and leaves it silent; close silences it. write(reg, value) sets an FM register (bank x AUDIO_FM_BANK_REGS + index) and must keep the index/data pair together (audioIrqOff / audioIrqRestore); put(byte) sends a MIDI byte, returning false when the port is not ready; a MIDI driver whose port receives also has get(&byte), the next byte the port has (false when none), which the MIDI input's timer calls (mpu401.drv keeps what put finds waiting for get). A wavetable driver has upload(addr, frames, count, bits) (program time; addresses as audioWtMemAlloc gives them) and start, update, release, stop, and active for its voices (numbered from 0, as audio.lib hands them out); it plays a note as AudioWtVoiceT gives it, in its own units. write, put, and the voice functions run at interrupt time too (timer callbacks), so they follow the rules above and are pinned by the driver. A sound driver whose card also has a synthesizer (the GUS) points AudioDrvInfoT.synth at its own AudioSynthDriverT, which is opened before the others.
     
     .h2 BASIC
     
     src/include/basic/sound.bas (DECLARE LIBRARY "audio"): PlayWave(fileName), PlayWaveLoop(fileName), PlayTone(frequency, milliseconds, volume), PlayNoise(frequency, milliseconds, volume) return a voice number (0 on failure: SoundError$ says why); StopWave(voice), StopAllSounds, SoundPlaying(voice), SoundVolume(percent), SoundInfo$ (the device, e.g. "Sound Blaster 16 (DSP 4.05) at 220h, 22050 Hz, 16-bit stereo"). Volumes are percent. Each BASIC program plays through its own context, closed when it ends.
     
    -For a settings program (the Control Panel's Sounds page): SoundEventCount, SoundEventLabel$(eventIdx), SoundEventKey$(eventIdx) (audioDeskLabel / audioDeskKey; events numbered from 0 in DvxSoundE order); SoundEventFile$(prefs, eventIdx) and SoundSetEventFile(prefs, eventIdx, fileName) (audioDeskFile / audioDeskSetFile); SoundEventsReload (audioDeskReload); SoundTestFile(fileName) (audioDeskPlayFile, a name alone found in the sound folders as the events' files are: a voice number, or 0 with SoundError$); SoundFolder$ (SHARED\SOUNDS, the sounds DVX ships; FileOpsKindDir$ and FileOpsKindList give the user's too); SoundMasterVolume and SoundSetMasterVolume(percent) (audioGetMasterVolume / audioSetMasterVolume in percent); SoundDriverCount (reads audioDriverNames, at most AUDIO_DRIVERS_MAX) and SoundDriverName$(index); SoundDriverAuto$ (AUDIO_DRIVER_AUTO); SoundMidiCount and SoundMidiLabel$(index) (audioMidiChoices); SoundSettingDriver$(prefs), SoundSettingMidi(prefs), SoundSettingVolume(prefs), and SoundSettingsStore(prefs, driver, midi, volume) (the audioSetting functions). prefs is a prefs.bas handle (prefsBasHandle, dvxPrefs.h); a handle that is not open reads the defaults and stores nothing.
    +For a settings program (the Control Panel's Sounds page): SoundEventCount, SoundEventLabel$(eventIdx), SoundEventKey$(eventIdx) (audioDeskLabel / audioDeskKey; events numbered from 0 in DvxSoundE order); SoundEventFile$(prefs, eventIdx) and SoundSetEventFile(prefs, eventIdx, fileName) (audioDeskFile / audioDeskSetFile); SoundEventsReload (audioDeskReload); SoundTestFile(fileName) (audioDeskPlayFile, a name alone found in the sound folders as the events' files are: a voice number, or 0 with SoundError$); SoundFolder$ (SHARED\SOUNDS, the sounds DVX ships; FileOpsKindDir$ and FileOpsKindList give the user's too); SoundMasterVolume and SoundSetMasterVolume(percent) (audioGetMasterVolume / audioSetMasterVolume in percent); SoundDriverCount (reads audioDriverNames, at most AUDIO_DRIVERS_MAX) and SoundDriverName$(index); SoundDriverAuto$ (AUDIO_DRIVER_AUTO); SoundMidiCount and SoundMidiLabel$(index) (audioMidiChoices); SoundMidiInCount and SoundMidiInLabel$(index) (audioMidiInChoices); SoundModulesCount and SoundModulesLabel$(index) (audioModulesChoices); SoundSettingDriver$(prefs), SoundSettingMidi(prefs), SoundSettingMidiIn(prefs), SoundSettingModules(prefs), SoundSettingVolume(prefs), SoundSettingsStore(prefs, driver, midi, modules, volume), and SoundSettingMidiInStore(prefs, midiIn) (the audioSetting functions). prefs is a prefs.bas handle (prefsBasHandle, dvxPrefs.h); a handle that is not open reads the defaults and stores nothing.
     
    -The rest of audio.h, in percent: PlayTriangle (audioTone with AUDIO_WAVE_TRIANGLE); SoundGetVolume (audioGetVolume); SoundCaps(field) (audioGetInfo: "dac", "synth", "tone", "running" True / False; "voices" = voiceLimit, "rate", "bits", "channels"; any case, 0 for another field); SoundPlayEvent(eventIdx, wait) (dvxPlaySound / dvxPlaySoundWait, so the hook audioDeskPlay plays it; CONSTs SOUND_EVENT_* in DvxSoundE order). Samples: SoundLoad(fileName) (audioSampleLoad) and SoundCreate(pcm AS BINARY STRING, rate, bits, channels) (audioSampleCreate; frames = bytes / frame size, a trailing partial frame ignored) return a sound number; SoundFree (audioSampleFree), SoundLength (audioSampleInfo, in milliseconds), SoundPlay(sound, volume, pan, loopIt) (audioPlay; pan -100..100 scaled to AUDIO_PAN_LEFT..AUDIO_PAN_RIGHT). Voices: SoundSetVolume, SoundSetPan, SoundSetPitch(voice, percent) (25..400 percent of AUDIO_PITCH_NORMAL, clamped), and SoundSetFrequency(voice, hz) (the audioVoiceSet functions; the result is kept for SoundError$). Streams (push only): SoundStreamOpen(rate, bits, channels, bufferMs) (audioStreamOpen without a fill function), SoundStreamWrite(stream, pcm AS BINARY STRING) (audioStreamWrite of the whole frames; returns the bytes taken), SoundStreamSpace / SoundStreamQueued (frames), SoundStreamVoice, SoundStreamClose. FM: FmInfo$(field) (audioFmGetInfo: "description", "voices", "free", "stereo", "owned" as text), FmAlloc, FmWrite(voice, reg, value) (True / False), FmFree, FmRawAcquire, FmRawWrite(reg, value), FmRawRelease, with CONSTs FM_* for the AUDIO_FM_* values and FM_BANK_REGS (AUDIO_FM_BANK_REGS). The MIDI port, declared in music.bas: MidiPortInfo$(field) (audioMidiGetInfo: "description", "present", "owned"), MidiAcquire, MidiPut(byteValue) (False on AUDIO_ERR_BUSY: send it again), MidiRelease. CONSTs SOUND_PAN_*, SOUND_PITCH_* (percent), SOUND_RATE_* (AUDIO_RATE_*), and SOUND_STREAM_MS_* (AUDIO_STREAM_MS_*).
    +The rest of audio.h, in percent: PlayTriangle (audioTone with AUDIO_WAVE_TRIANGLE); SoundGetVolume (audioGetVolume); SoundCaps(field) (audioGetInfo: "dac", "synth", "tone", "running", "record" True / False; "voices" = voiceLimit, "rate", "bits", "channels", "recordsources"; any case, 0 for another field); SoundPlayEvent(eventIdx, wait) (dvxPlaySound / dvxPlaySoundWait, so the hook audioDeskPlay plays it; CONSTs SOUND_EVENT_* in DvxSoundE order). Samples: SoundLoad(fileName) (audioSampleLoad) and SoundCreate(pcm AS BINARY STRING, rate, bits, channels) (audioSampleCreate; frames = bytes / frame size, a trailing partial frame ignored) return a sound number; SoundFree (audioSampleFree), SoundLength (audioSampleInfo, in milliseconds), SoundPlay(sound, volume, pan, loopIt) (audioPlay; pan -100..100 scaled to AUDIO_PAN_LEFT..AUDIO_PAN_RIGHT). Voices: SoundSetVolume, SoundSetPan, SoundSetPitch(voice, percent) (25..400 percent of AUDIO_PITCH_NORMAL, clamped), and SoundSetFrequency(voice, hz) (the audioVoiceSet functions), SoundPause(voice) and SoundResume(voice) (audioVoicePause; a sound's or a stream's voice); the result is kept for SoundError$. Streams (push only): SoundStreamOpen(rate, bits, channels, bufferMs) (audioStreamOpen without a fill function), SoundStreamWrite(stream, pcm AS BINARY STRING) (audioStreamWrite of the whole frames; returns the bytes taken), SoundStreamSpace / SoundStreamQueued (frames), SoundStreamVoice, SoundStreamFlush (audioStreamFlush), SoundStreamClose. Recording, one per program (audioRecFile): RecordStart(fileName, rate, bits, channels, inputSource) (True / False with SoundError$; 0 for the defaults), RecordStop (audioRecClose; True when the file was all written), Recording, RecordLength (milliseconds recorded, written or waiting), RecordLevel (audioRecPeak), RecordLost, RecordRate, RecordBits, RecordChannels (audioRecFormat), with CONSTs RECORD_MIC, RECORD_LINE, RECORD_CD (AUDIO_REC_*) and SoundCaps "record" and "recordsources". FM: FmInfo$(field) (audioFmGetInfo: "description", "voices", "free", "stereo", "owned" as text), FmAlloc, FmWrite(voice, reg, value) (True / False), FmFree, FmRawAcquire, FmRawWrite(reg, value), FmRawRelease, with CONSTs FM_* for the AUDIO_FM_* values and FM_BANK_REGS (AUDIO_FM_BANK_REGS). Wavetable: WtInfo$(field) (audioWtGetInfo: "description", "voices", "free", "memory", "freememory", "level", and the flags "envelopes", "lfos", "filter", "effects", "8bit", "rom" as text), WtMemAlloc(bytes, bits) (an address, -1 for no room), WtUpload(address, pcm AS BINARY STRING, bits) (16-bit mono samples), WtMemFree, WtAlloc, WtStart(voice, address, startFrame, endFrame, loopStart, loopEnd, loopMode, bits, pitch, attenuation, pan) (a plain note: no envelope or LFO, the filter open; pitch in cents, attenuation in centibels, pan -100..100 scaled to AUDIO_WT_PAN_MAX), WtSetPitch, WtSetAttenuation, WtSetPan (audioWtUpdate of the note kept for the voice; True / False), WtRelease, WtStop, WtActive, WtFree. The MIDI port, declared in music.bas: MidiPortInfo$(field) (audioMidiGetInfo: "description", "present", "owned", "input", "inputowned"), MidiAcquire, MidiPut(byteValue) (False on AUDIO_ERR_BUSY: send it again), MidiRelease; its input: MidiInOpen (True / False with SoundError$), MidiInClose, MidiInMessage (audioMidiInRead: the message packed as status + data1 * 256 + data2 * 65536, AUDIO_MIDI_PACKED_*, or MIDI_NONE, -1), MidiInTime (the ms of the message MidiInMessage gave last), MidiInClock (audioMidiInClock), MidiInLost, with CONSTs MIDI_NOTE_OFF to MIDI_PITCH_BEND, MIDI_KIND_MASK, and MIDI_CHANNEL_MASK. CONSTs SOUND_PAN_*, SOUND_PITCH_* (percent), SOUND_RATE_* (AUDIO_RATE_*), and SOUND_STREAM_MS_* (AUDIO_STREAM_MS_*).
     
     Sound and stream numbers are 1..64 per kind, valid only for the program that got them; audioClose of a BASIC context forgets its numbers (audioBasForget) as it frees the samples and streams. BINARY STRING parameters arrive as a pointer and a byte count (two C parameters; see the DECLARE LIBRARY rules in the BASIC reference).
    diff --git a/src/libs/kpunch/audio/audio.h b/src/libs/kpunch/audio/audio.h
    index 7f80e085..f7237ccc 100644
    --- a/src/libs/kpunch/audio/audio.h
    +++ b/src/libs/kpunch/audio/audio.h
    @@ -42,6 +42,9 @@
     //             two-operator voices shared by every program (tones use
     //             them too), or the whole chip for one program at a time.
     //   MIDI      the MIDI port (MPU-401), for one program at a time.
    +//   Wave-     a sample-playing synthesizer (an AWE32's EMU8000, a Gravis
    +//   table     UltraSound), when there is one: voices and sample memory
    +//             shared by every program (music.lib plays SoundFonts on it).
     //   Desktop   the sounds DVX plays for its own events (starting, message
     //   sounds    boxes, ...), chosen in DVX.INI [sounds].
     //
    @@ -131,11 +134,15 @@
     #define AUDIO_ERR_VOICE         (-9)    // no such voice in this context (ended, or never was)
     #define AUDIO_ERR_BUSY          (-10)   // the synthesizer is in use by another program, or not ready
     #define AUDIO_ERR_NO_SYNTH      (-11)   // no such synthesizer (no FM chip, no MIDI port)
    -#define AUDIO_ERR_COUNT         12      // AUDIO_OK and the eleven errors
    +#define AUDIO_ERR_NO_ROOM       (-12)   // not enough synthesizer sample memory
    +#define AUDIO_ERR_NO_RECORD     (-13)   // the sound device cannot record (or not from that source)
    +#define AUDIO_ERR_WRITE         (-14)   // the file could not be written
    +#define AUDIO_ERR_COUNT         15      // AUDIO_OK and the fourteen errors
     
     // Opaque handles
     typedef struct AudioCtxT    AudioCtxT;
     typedef struct AudioNotesT  AudioNotesT;
    +typedef struct AudioRecT    AudioRecT;
     typedef struct AudioSampleT AudioSampleT;
     typedef struct AudioStreamT AudioStreamT;
     
    @@ -173,6 +180,8 @@ typedef struct {
         bool     hasDac;                        // plays sampled sound (else tones only, or nothing)
         bool     hasSynth;                      // has a hardware music synthesizer
         bool     hasTone;                       // plays tones on a hardware tone generator
    +    bool     canRecord;                     // records sound (audioRecOpen)
    +    uint32_t recSources;                    // what it records from: bit 1 << AUDIO_REC_* for each
     } AudioInfoT;
     
     // Mixer counters since the device started (audioGetStats).
    @@ -276,6 +285,11 @@ int32_t audioVoiceSetPitch(AudioCtxT *ctx, int32_t voice, int32_t pitch);
     int32_t audioVoiceSetVolume(AudioCtxT *ctx, int32_t voice, int32_t volume);
     int32_t audioVoiceStop(AudioCtxT *ctx, int32_t voice);
     
    +// Hold a sample's or a stream's voice where it is (paused true: it is
    +// silent and does not move on; a stream's fill is not called) and let it
    +// go on (false); AUDIO_ERR_ARG for a tone or a note queue.
    +int32_t audioVoicePause(AudioCtxT *ctx, int32_t voice, bool paused);
    +
     // True while the voice of ctx is still playing.
     bool audioVoiceIsPlaying(const AudioCtxT *ctx, int32_t voice);
     
    @@ -297,12 +311,84 @@ void audioStreamClose(AudioStreamT *stream);
     uint32_t audioStreamQueued(const AudioStreamT *stream);
     uint32_t audioStreamSpace(const AudioStreamT *stream);
     
    +// Drop the frames buffered and not yet played (a seek or a stop is heard
    +// at once; a fill function fills the room again).  NULL is ignored.
    +void audioStreamFlush(AudioStreamT *stream);
    +
     // The stream's voice id (for volume, pan and pitch).
     int32_t audioStreamVoice(const AudioStreamT *stream);
     
     // Append up to frames frames in the stream's format; returns how many fit.
     uint32_t audioStreamWrite(AudioStreamT *stream, const void *pcm, uint32_t frames);
     
    +// ============================================================
    +// Recording
    +// ============================================================
    +//
    +// Sound from the card's microphone, line, or CD input.  Most sound cards
    +// record or play, not both, so while a program records nothing plays:
    +// voices already playing hold where they are and go on afterwards, and a
    +// new one is refused (AUDIO_ERR_BUSY).  One recording at a time for the
    +// whole of DVX (another gets AUDIO_ERR_BUSY too).  Closing the context
    +// (the program's end) closes its recording.
    +
    +// Sources (audioRecOpen; AudioInfoT.recSources has bit 1 << source set
    +// for each the card has)
    +#define AUDIO_REC_MIC           0
    +#define AUDIO_REC_LINE          1
    +#define AUDIO_REC_CD            2
    +#define AUDIO_REC_SOURCES       3
    +
    +// A recording's buffer (milliseconds): the default and the limits
    +#define AUDIO_REC_MS_DEFAULT    2000
    +#define AUDIO_REC_MS_MIN        250
    +#define AUDIO_REC_MS_MAX        10000
    +
    +// What a 0 in the format asked for means (with AUDIO_RATE_DEFAULT, mono)
    +#define AUDIO_REC_BITS_DEFAULT  16
    +
    +// Size of the header audioWavHeader writes
    +#define AUDIO_WAV_HEADER_BYTES  44
    +
    +// Start recording from source in fmt (rate; bits 8 or 16; channels 1 or
    +// 2; 0 for AUDIO_RATE_DEFAULT, AUDIO_REC_BITS_DEFAULT, mono) into a
    +// buffer of bufferMs (0: AUDIO_REC_MS_DEFAULT).  fmt is set to what the
    +// card records: the nearest rate it has, and its bits and channels when
    +// it cannot do the ones asked.  Take the frames with audioRecRead before
    +// the buffer fills (audioRecLost counts what was dropped).
    +int32_t  audioRecOpen(AudioCtxT *ctx, AudioFormatT *fmt, int32_t source, uint32_t bufferMs, AudioRecT **out);
    +
    +// The same into a WAV file at path (made, or replaced): the frames are
    +// written as they come (audioPump, the shell's idle time) and the file's
    +// sizes at audioRecClose; audioRecRead takes nothing from it.
    +int32_t  audioRecFile(AudioCtxT *ctx, const char *path, AudioFormatT *fmt, int32_t source, AudioRecT **out);
    +
    +// Stop recording (a file is completed and closed) and give the card back
    +// for playing.  AUDIO_OK, or AUDIO_ERR_WRITE when a recording into a file
    +// could not all be written.  NULL is ignored.
    +int32_t  audioRecClose(AudioRecT *rec);
    +
    +// The recording's format, and the frames recorded so far (taken by
    +// audioRecRead, or written to the file).
    +void     audioRecFormat(const AudioRecT *rec, AudioFormatT *fmt);
    +uint32_t audioRecFrames(const AudioRecT *rec);
    +
    +// Frames dropped because the buffer was full; frames recorded and not
    +// yet taken.
    +uint32_t audioRecLost(const AudioRecT *rec);
    +uint32_t audioRecQueued(const AudioRecT *rec);
    +
    +// The loudest sample taken since the last call, 0..100 percent of full
    +// scale: a level meter.
    +int32_t  audioRecPeak(AudioRecT *rec);
    +
    +// Take up to frames frames in the recording's format; returns how many.
    +uint32_t audioRecRead(AudioRecT *rec, void *pcm, uint32_t frames);
    +
    +// Write the AUDIO_WAV_HEADER_BYTES of a WAV file's header for dataBytes
    +// of sound in fmt into out (for a program writing its own WAV files).
    +void     audioWavHeader(uint8_t *out, const AudioFormatT *fmt, uint32_t dataBytes);
    +
     // ============================================================
     // Note queues
     // ============================================================
    @@ -428,16 +514,19 @@ int32_t audioFmRawWrite(AudioCtxT *ctx, uint32_t reg, uint8_t value);
     // MIDI port
     // ============================================================
     //
    -// The MIDI port (an MPU-401 in UART mode, used only when DVX.INI [audio]
    -// midi = mpu401) belongs to one program at a time.  audioMidiPut may
    -// also be called from a timer callback; it returns AUDIO_ERR_BUSY when
    -// the port cannot take a byte yet (send it again later).
    +// The MIDI port (an MPU-401 in UART mode, used when DVX.INI [audio]
    +// midi or midiIn = mpu401) belongs to one program at a time for output
    +// and one for input (it may be another).  audioMidiPut may also be
    +// called from a timer callback; it returns AUDIO_ERR_BUSY when the port
    +// cannot take a byte yet (send it again later).
     
     // The MIDI port (audioMidiGetInfo).
     typedef struct {
         char description[AUDIO_DESC_MAX];   // e.g. "MPU-401 MIDI port at 330h"; "" when none
         bool present;
         bool owned;                         // a program is using it
    +    bool inPresent;                     // it receives MIDI (midiIn): audioMidiInOpen works
    +    bool inOwned;                       // a program is reading its input
     } AudioMidiInfoT;
     
     int32_t audioMidiAcquire(AudioCtxT *ctx);
    @@ -445,6 +534,207 @@ void    audioMidiGetInfo(AudioMidiInfoT *info);
     int32_t audioMidiPut(AudioCtxT *ctx, uint8_t byte);
     int32_t audioMidiRelease(AudioCtxT *ctx);
     
    +// ============================================================
    +// MIDI input
    +// ============================================================
    +//
    +// What a keyboard or another instrument plays into the MIDI port's input
    +// (DVX.INI [audio] midiIn = mpu401), as whole messages with the time each
    +// came.  One program reads it at a time; closing its context closes it.
    +// A timer reads the port AUDIO_MIDI_IN_HZ times a second into a buffer of
    +// AUDIO_MIDI_IN_BYTES bytes (a MIDI cable brings at most about 3 a
    +// millisecond); the program takes the messages from it.  Running status
    +// is filled in, real-time bytes (clock, active sensing) are dropped, and
    +// system exclusive messages are skipped.
    +
    +#define AUDIO_MIDI_IN_HZ        1000
    +#define AUDIO_MIDI_IN_BYTES     1024
    +
    +// A message as BASIC passes it in a LONG (MidiInMessage, and music.lib's
    +// MusicMidiSend and MidiRecordAdd): status + data1 * 256 + data2 *
    +// 65536, or AUDIO_MIDI_PACKED_NONE for none.
    +#define AUDIO_MIDI_PACKED_NONE          (-1)
    +#define AUDIO_MIDI_PACKED_DATA1_SHIFT   8
    +#define AUDIO_MIDI_PACKED_DATA2_SHIFT   16
    +
    +// A MIDI message (audioMidiInRead).
    +typedef struct {
    +    uint32_t ms;                        // when it came: milliseconds since audioMidiInOpen
    +    uint8_t  status;                    // 80h..EFh a channel message (kind | channel), F1h..F3h system common
    +    uint8_t  data1;
    +    uint8_t  data2;
    +    uint8_t  length;                    // status and data bytes: 1 to 3
    +} AudioMidiMsgT;
    +
    +// Stop reading the MIDI input for ctx (nothing when ctx does not read
    +// it); start reading it: AUDIO_ERR_NO_SYNTH when there is none (midiIn
    +// not set, or no port), AUDIO_ERR_BUSY when another program reads it.
    +void     audioMidiInClose(AudioCtxT *ctx);
    +int32_t  audioMidiInOpen(AudioCtxT *ctx);
    +
    +// The next message that came, oldest first; false when none waits (or
    +// ctx is not reading).
    +bool     audioMidiInRead(AudioCtxT *ctx, AudioMidiMsgT *msg);
    +
    +// Bytes dropped because the buffer was full (the program read too
    +// late).
    +uint32_t audioMidiInLost(const AudioCtxT *ctx);
    +
    +// Milliseconds since audioMidiInOpen now, on the clock the messages' times
    +// are on (0 when ctx is not reading).
    +uint32_t audioMidiInClock(const AudioCtxT *ctx);
    +
    +// ============================================================
    +// Wavetable synthesizer
    +// ============================================================
    +//
    +// A synthesizer that plays samples from its own memory: its voices and
    +// its sample memory are shared by every program.  A program takes memory
    +// (audioWtMemAlloc), uploads samples into it (audioWtUpload), takes a
    +// voice (audioWtAlloc) and starts it on a sample (audioWtStart) with
    +// everything the note needs, in SoundFont units (AudioWtVoiceT).  The
    +// chip does what its flags say it can (AUDIO_WT_*: envelopes, LFOs, the
    +// filter, the effects); the program does the rest, by changing the
    +// voice's pitch and volume as the note goes on (audioWtUpdate) and
    +// stopping it when its own envelope has ended.  Closing the context gives
    +// back its voices and its memory.
    +//
    +// audioWtAlloc, audioWtFree, audioWtStart, audioWtUpdate,
    +// audioWtRelease, audioWtStop and audioWtActive may also be called from a
    +// timer callback, as music playback does; the memory calls may not.
    +
    +// What a wavetable synthesizer does itself (AudioWtInfoT flags)
    +#define AUDIO_WT_ENVELOPES      0x01    // the volume and modulation envelopes
    +#define AUDIO_WT_LFOS           0x02    // the modulation and vibrato LFOs
    +#define AUDIO_WT_FILTER         0x04    // a resonant low-pass filter
    +#define AUDIO_WT_EFFECTS        0x08    // reverb and chorus sends
    +#define AUDIO_WT_8BIT           0x10    // plays 8-bit samples (each frame one byte of memory)
    +#define AUDIO_WT_BANK_256K      0x20    // a 16-bit sample must not cross a 256 KB boundary of the memory
    +#define AUDIO_WT_ROM            0x40    // a General MIDI sample ROM (a SoundFont's ROM samples play from it)
    +
    +// How a sample loops (AudioWtVoiceT loop)
    +#define AUDIO_WT_LOOP_NONE      0       // plays once
    +#define AUDIO_WT_LOOP_ON        1       // loops for as long as it sounds
    +#define AUDIO_WT_LOOP_RELEASE   3       // loops while the key is down, then plays out
    +
    +// Envelope stages (AudioWtVoiceT volEnv / modEnv)
    +#define AUDIO_WT_ENV_DELAY      0
    +#define AUDIO_WT_ENV_ATTACK     1
    +#define AUDIO_WT_ENV_HOLD       2
    +#define AUDIO_WT_ENV_DECAY      3
    +#define AUDIO_WT_ENV_SUSTAIN    4
    +#define AUDIO_WT_ENV_RELEASE    5
    +#define AUDIO_WT_ENV_STAGES     6
    +
    +// What audioWtUpdate changes
    +#define AUDIO_WT_SET_PITCH      0x01
    +#define AUDIO_WT_SET_VOLUME     0x02
    +#define AUDIO_WT_SET_PAN        0x04
    +#define AUDIO_WT_SET_FILTER     0x08
    +#define AUDIO_WT_SET_EFFECTS    0x10
    +#define AUDIO_WT_SET_LFO        0x20    // the LFOs' depths
    +
    +// Units: pitch in cents (0 plays the sample at AUDIO_WT_PITCH_RATE
    +// frames a second, 1200 an octave higher); loudness as attenuation in
    +// centibels (0 full, AUDIO_WT_ATTEN_MAX silent); times in timecents (1200
    +// log2 seconds); LFO rates in absolute cents (8.176 Hz x 2^(cents /
    +// 1200)).
    +#define AUDIO_WT_PITCH_RATE     44100
    +#define AUDIO_WT_ATTEN_MAX      1440
    +#define AUDIO_WT_VOICES_MAX     32      // the most voices a synthesizer gives
    +#define AUDIO_WT_PAN_MAX        500     // -500 left .. 500 right
    +
    +// A note on a voice.  The sample is in the synthesizer's memory from
    +// addr (bytes), or in its ROM with AUDIO_WT_ADDR_ROM (bytes from the
    +// ROM's start); its frames from there: the note starts offset in (before
    +// the loop or inside it), the loop loopStart to loopEnd - 1, the end one
    +// past the last.  The envelopes and LFOs are used where the chip has them
    +// (AUDIO_WT_ENVELOPES, _LFOS).
    +#define AUDIO_WT_ADDR_ROM       0x80000000U
    +
    +// A plain note's values (audioWtNoteInit): the shortest time (an
    +// envelope stage or an LFO delay that is not there), the filter open
    +#define AUDIO_WT_TC_NONE        (-12000)
    +#define AUDIO_WT_FC_OPEN        13500
    +
    +typedef struct {
    +    uint32_t addr;
    +    uint32_t offset;
    +    uint32_t loopStart;
    +    uint32_t loopEnd;
    +    uint32_t end;
    +    int32_t  pitch;                         // cents
    +    int32_t  attenuation;                   // centibels
    +    int32_t  pan;                           // -AUDIO_WT_PAN_MAX .. AUDIO_WT_PAN_MAX
    +    int32_t  filterFc;                      // absolute cents (13500: open)
    +    int32_t  filterQ;                       // centibels
    +    int32_t  reverb;                        // tenths of a percent
    +    int32_t  chorus;
    +    int16_t  volEnv[AUDIO_WT_ENV_STAGES];   // timecents; sustain in centibels below the peak
    +    int16_t  modEnv[AUDIO_WT_ENV_STAGES];   // timecents; sustain in tenths of a percent below the peak
    +    int16_t  modEnvToPitch;                 // cents
    +    int16_t  modEnvToFilter;                // cents
    +    int16_t  modLfoDelay;                   // timecents
    +    int16_t  modLfoFreq;                    // absolute cents
    +    int16_t  modLfoToPitch;                 // cents
    +    int16_t  modLfoToFilter;                // cents
    +    int16_t  modLfoToVolume;                // centibels
    +    int16_t  vibLfoDelay;                   // timecents
    +    int16_t  vibLfoFreq;                    // absolute cents
    +    int16_t  vibLfoToPitch;                 // cents
    +    uint8_t  bits;                          // 8 or 16
    +    uint8_t  loop;                          // AUDIO_WT_LOOP_*
    +} AudioWtVoiceT;
    +
    +// The wavetable synthesizer (audioWtGetInfo).
    +typedef struct {
    +    char     description[AUDIO_DESC_MAX];   // e.g. "AWE32 EMU8000 with 512 KB"; "" when none
    +    uint32_t voices;
    +    uint32_t freeVoices;
    +    uint32_t memory;                        // bytes of sample memory
    +    uint32_t freeMemory;                    // the largest free block
    +    uint32_t flags;                         // AUDIO_WT_*
    +    int32_t  level;                         // centibels a voice at full volume is louder than full-scale digital sound (measured; 0: as loud, or not known)
    +    bool     present;
    +} AudioWtInfoT;
    +
    +void    audioWtGetInfo(AudioWtInfoT *info);
    +
    +// v made a plain note: no envelope or LFO shapes it (the shortest times,
    +// full sustain, no depths), the filter open, no effects, centred, at full
    +// volume, at AUDIO_WT_PITCH_RATE; the caller sets the sample (addr,
    +// offset, loop, end, bits) and anything else it wants.
    +void    audioWtNoteInit(AudioWtVoiceT *v);
    +
    +// Take a free voice for ctx (its id, > 0), the least recently freed
    +// first; AUDIO_ERR_NO_VOICE when all are taken, AUDIO_ERR_NO_SYNTH
    +// without the synthesizer.  Free stops it and gives it back.
    +int32_t audioWtAlloc(AudioCtxT *ctx);
    +int32_t audioWtFree(const AudioCtxT *ctx, int32_t voice);
    +
    +// audioWtStart starts the voice on a note; Update changes it (what:
    +// AUDIO_WT_SET_*); Release lets the key up (the chip's envelopes release,
    +// a sample that loops while the key is down plays out); Stop stops it at
    +// once.  audioWtActive is false once the voice has fallen silent (its
    +// envelope ended, or its sample did).
    +bool    audioWtActive(const AudioCtxT *ctx, int32_t voice);
    +int32_t audioWtRelease(const AudioCtxT *ctx, int32_t voice);
    +int32_t audioWtStart(const AudioCtxT *ctx, int32_t voice, const AudioWtVoiceT *v);
    +int32_t audioWtStop(const AudioCtxT *ctx, int32_t voice);
    +int32_t audioWtUpdate(const AudioCtxT *ctx, int32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +
    +// bytes of sample memory for ctx at *addr, for samples of bits bits (a
    +// 16-bit one kept inside a 256 KB boundary where the chip needs it);
    +// AUDIO_ERR_NO_ROOM when no free block is large enough.  Free gives it
    +// back.
    +int32_t audioWtMemAlloc(AudioCtxT *ctx, uint32_t bytes, uint32_t bits, uint32_t *addr);
    +int32_t audioWtMemFree(AudioCtxT *ctx, uint32_t addr);
    +
    +// count frames into ctx's memory at addr, stored as bits-bit samples
    +// (from 16-bit ones).  AUDIO_ERR_ARG when they do not lie in a block of
    +// ctx's.
    +int32_t audioWtUpload(AudioCtxT *ctx, uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
    +
     // ============================================================
     // Desktop sounds
     // ============================================================
    @@ -498,9 +788,19 @@ int32_t audioDriverNames(char names[][AUDIO_NAME_MAX], int32_t max);
     
     // [audio] midi: where MIDI music plays, in dropdown order (label for
     // people, iniName stored; value unused).  Default AUDIO_MIDI_AUTO.
    -#define AUDIO_MIDI_CHOICE_COUNT 4
    +#define AUDIO_MIDI_CHOICE_COUNT 5
     extern const PrefsChoiceT audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT];
     
    +// [audio] midiIn: where MIDI input comes from, the same way.  Default
    +// AUDIO_MIDI_IN_NONE.
    +#define AUDIO_MIDI_IN_CHOICE_COUNT 2
    +extern const PrefsChoiceT audioMidiInChoices[AUDIO_MIDI_IN_CHOICE_COUNT];
    +
    +// [audio] modules: where tracker modules play, the same way.  Default
    +// AUDIO_MODULES_WAVETABLE.
    +#define AUDIO_MODULES_CHOICE_COUNT 2
    +extern const PrefsChoiceT audioModulesChoices[AUDIO_MODULES_CHOICE_COUNT];
    +
     // The sound file of an event in [sounds] ("" for none or an event out of
     // range), and setting it ("" or NULL: none).
     const char *audioDeskFile(PrefsHandleT *prefs, DvxSoundE event);
    @@ -510,15 +810,25 @@ void        audioDeskSetFile(PrefsHandleT *prefs, DvxSoundE event, const char *p
     int32_t audioPercentToVolume(int32_t percent);
     
     // [audio] driver (AUDIO_DRIVER_AUTO when missing), midi (the index in
    -// audioMidiChoices; auto when missing or unknown) and volume (percent,
    -// 0..100, 100 when missing).
    +// audioMidiChoices; auto when missing or unknown), midiIn (the index in
    +// audioMidiInChoices; none when missing or unknown), modules (the index
    +// in audioModulesChoices; wavetable when missing or unknown), and volume
    +// (percent, 0..100, 100 when missing).
     const char *audioSettingDriver(PrefsHandleT *prefs);
     int32_t     audioSettingMidi(PrefsHandleT *prefs);
    +int32_t     audioSettingMidiIn(PrefsHandleT *prefs);
    +int32_t     audioSettingModules(PrefsHandleT *prefs);
     int32_t     audioSettingVolume(PrefsHandleT *prefs);
     
     // Store [audio] driver, midi (an audioMidiChoices index; out of range:
    -// auto) and volume (percent, clamped).
    -void audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, int32_t volume);
    +// auto), modules (an audioModulesChoices index; out of range: wavetable)
    +// and volume (percent, clamped).
    +void audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, int32_t modules, int32_t volume);
    +
    +// [audio] midiIn (an audioMidiInChoices index, the index read by
    +// audioSettingMidiIn; out of range: none).  Takes effect when the sound
    +// device next starts (DVX's next start).
    +void audioSettingMidiInStore(PrefsHandleT *prefs, int32_t midiIn);
     
     // ============================================================
     // BASIC (src/include/basic/sound.bas)
    @@ -528,9 +838,12 @@ void audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, i
     // and pitches are in percent (volume 0..100, pan -100..100, pitch
     // 25..400); a voice, sound or stream number is 0 on failure (SoundError$
     // says why).  Sounds and streams are numbered per program and freed with
    -// its context.  SoundCreate and SoundStreamWrite take BINARY STRING PCM
    -// (bytes and byte count).  The Midi... functions are declared in
    -// music.bas.
    +// its context.  SoundCreate, SoundStreamWrite and WtUpload take BINARY
    +// STRING PCM (bytes and byte count).  The Wt... functions are the
    +// wavetable synthesizer's in its own units (cents, centibels; pan
    +// -100..100; WtMemAlloc -1 for no room), each started note plain (no
    +// envelope or LFO, the filter open).  The Midi... functions are declared
    +// in music.bas.
     int32_t     FmAlloc(void);
     void        FmFree(int32_t voice);
     const char *FmInfo(const char *field);
    @@ -539,6 +852,12 @@ void        FmRawRelease(void);
     int32_t     FmRawWrite(int32_t reg, int32_t value);
     int32_t     FmWrite(int32_t voice, int32_t reg, int32_t value);
     int32_t     MidiAcquire(void);
    +int32_t     MidiInClock(void);
    +void        MidiInClose(void);
    +int32_t     MidiInLost(void);
    +int32_t     MidiInMessage(void);
    +int32_t     MidiInOpen(void);
    +int32_t     MidiInTime(void);
     const char *MidiPortInfo(const char *field);
     int32_t     MidiPut(int32_t byteValue);
     void        MidiRelease(void);
    @@ -547,6 +866,15 @@ int32_t     PlayTone(int32_t frequency, int32_t milliseconds, int32_t volume);
     int32_t     PlayTriangle(int32_t frequency, int32_t milliseconds, int32_t volume);
     int32_t     PlayWave(const char *fileName);
     int32_t     PlayWaveLoop(const char *fileName);
    +int32_t     RecordBits(void);
    +int32_t     RecordChannels(void);
    +int32_t     Recording(void);
    +int32_t     RecordLength(void);
    +int32_t     RecordLevel(void);
    +int32_t     RecordLost(void);
    +int32_t     RecordRate(void);
    +int32_t     RecordStart(const char *fileName, int32_t rate, int32_t bits, int32_t channels, int32_t source);
    +int32_t     RecordStop(void);
     int32_t     SoundCaps(const char *field);
     int32_t     SoundCreate(const uint8_t *pcm, int32_t len, int32_t rate, int32_t bits, int32_t channels);
     const char *SoundDriverAuto(void);
    @@ -566,10 +894,16 @@ int32_t     SoundLength(int32_t sound);
     int32_t     SoundLoad(const char *fileName);
     int32_t     SoundMasterVolume(void);
     int32_t     SoundMidiCount(void);
    +int32_t     SoundMidiInCount(void);
    +const char *SoundMidiInLabel(int32_t index);
     const char *SoundMidiLabel(int32_t index);
    +int32_t     SoundModulesCount(void);
    +const char *SoundModulesLabel(int32_t index);
    +void        SoundPause(int32_t voice);
     int32_t     SoundPlay(int32_t sound, int32_t volume, int32_t pan, int32_t loopIt);
     void        SoundPlayEvent(int32_t eventIdx, int32_t wait);
     int32_t     SoundPlaying(int32_t voice);
    +void        SoundResume(int32_t voice);
     void        SoundSetEventFile(int32_t prefs, int32_t eventIdx, const char *fileName);
     void        SoundSetFrequency(int32_t voice, int32_t frequency);
     void        SoundSetMasterVolume(int32_t percent);
    @@ -577,10 +911,14 @@ void        SoundSetPan(int32_t voice, int32_t pan);
     void        SoundSetPitch(int32_t voice, int32_t percent);
     const char *SoundSettingDriver(int32_t prefs);
     int32_t     SoundSettingMidi(int32_t prefs);
    -void        SoundSettingsStore(int32_t prefs, const char *driver, int32_t midi, int32_t volume);
    +int32_t     SoundSettingMidiIn(int32_t prefs);
    +void        SoundSettingMidiInStore(int32_t prefs, int32_t midiIn);
    +int32_t     SoundSettingModules(int32_t prefs);
    +void        SoundSettingsStore(int32_t prefs, const char *driver, int32_t midi, int32_t modules, int32_t volume);
     int32_t     SoundSettingVolume(int32_t prefs);
     void        SoundSetVolume(int32_t voice, int32_t volume);
     void        SoundStreamClose(int32_t stream);
    +void        SoundStreamFlush(int32_t stream);
     int32_t     SoundStreamOpen(int32_t rate, int32_t bits, int32_t channels, int32_t bufferMs);
     int32_t     SoundStreamQueued(int32_t stream);
     int32_t     SoundStreamSpace(int32_t stream);
    @@ -590,5 +928,18 @@ int32_t     SoundTestFile(const char *fileName);
     void        SoundVolume(int32_t volume);
     void        StopAllSounds(void);
     void        StopWave(int32_t voice);
    +int32_t     WtActive(int32_t voice);
    +int32_t     WtAlloc(void);
    +void        WtFree(int32_t voice);
    +const char *WtInfo(const char *field);
    +int32_t     WtMemAlloc(int32_t bytes, int32_t bits);
    +void        WtMemFree(int32_t address);
    +void        WtRelease(int32_t voice);
    +int32_t     WtSetAttenuation(int32_t voice, int32_t attenuation);
    +int32_t     WtSetPan(int32_t voice, int32_t pan);
    +int32_t     WtSetPitch(int32_t voice, int32_t pitch);
    +int32_t     WtStart(int32_t voice, int32_t address, int32_t startFrame, int32_t endFrame, int32_t loopStart, int32_t loopEnd, int32_t loopMode, int32_t bits, int32_t pitch, int32_t attenuation, int32_t pan);
    +void        WtStop(int32_t voice);
    +int32_t     WtUpload(int32_t address, const uint8_t *pcm, int32_t len, int32_t bits);
     
     #endif // AUDIO_LIB_H
    diff --git a/src/libs/kpunch/audio/audioBas.c b/src/libs/kpunch/audio/audioBas.c
    index d76fa541..593c839c 100644
    --- a/src/libs/kpunch/audio/audioBas.c
    +++ b/src/libs/kpunch/audio/audioBas.c
    @@ -40,6 +40,7 @@
     #include "dvxBas.h"
     
     #include 
    +#include 
     #include 
     
     #define PERCENT_MAX     100
    @@ -48,6 +49,12 @@
     #define MS_PER_SEC      1000
     #define BITS_PER_BYTE   8
     #define BYTE_MAX_VALUE  0xFF
    +#define SAMPLE_BYTES    2       // WtUpload's samples: 16-bit
    +
    +#define BAS_WT_MEM_NONE     (-1)    // WtMemAlloc: no room
    +#define BAS_WT_BITS_8       8
    +#define BAS_WT_BITS_16      16
    +#define BAS_WT_RUN          256     // WtUpload's samples copied at a time
     
     // Percent ranges of the BASIC pan and pitch (sound.bas SOUND_PAN_* and
     // SOUND_PITCH_*), from the library's own units.
    @@ -66,6 +73,8 @@ typedef enum {
         CapRateE,
         CapBitsE,
         CapChannelsE,
    +    CapRecordE,
    +    CapRecordSourcesE,
         CapCountE
     } SoundCapE;
     
    @@ -79,14 +88,42 @@ typedef enum {
         FmFieldCountE
     } FmFieldE;
     
    +// WtInfo$ fields: the description and counts, then what the chip does
    +// itself (its AUDIO_WT_* flags, sWtFlags)
    +typedef enum {
    +    WtDescriptionE,
    +    WtVoicesE,
    +    WtFreeE,
    +    WtMemoryE,
    +    WtFreeMemoryE,
    +    WtLevelE,
    +    WtEnvelopesE,
    +    WtLfosE,
    +    WtFilterE,
    +    WtEffectsE,
    +    Wt8BitE,
    +    WtRomE,
    +    WtFieldCountE
    +} WtFieldE;
    +
     // MidiPortInfo$ fields
     typedef enum {
         MidiDescriptionE,
         MidiPresentE,
         MidiOwnedE,
    +    MidiInputE,
    +    MidiInputOwnedE,
         MidiFieldCountE
     } MidiFieldE;
     
    +// A note a BASIC program started on a wavetable voice, for audioWtUpdate
    +// (which takes the whole note) when it changes
    +typedef struct {
    +    AudioWtVoiceT    note;
    +    const AudioCtxT *ctx;           // NULL: free
    +    int32_t          voice;
    +} BasWtNoteT;
    +
     // A numbered sound or stream of a BASIC program.
     typedef struct {
         void            *obj;           // AudioSampleT / AudioStreamT; NULL: free
    @@ -95,16 +132,22 @@ typedef struct {
         uint32_t         frameBytes;    // streams: bytes per frame of its format
     } BasSlotT;
     
    -static const char *const sCapNames[CapCountE]        = { "dac", "synth", "tone", "voices", "running", "rate", "bits", "channels" };
    +static const char *const sCapNames[CapCountE]        = { "dac", "synth", "tone", "voices", "running", "rate", "bits", "channels", "record", "recordsources" };
     static const char *const sFmNames[FmFieldCountE]     = { "description", "voices", "free", "stereo", "owned" };
    -static const char *const sMidiNames[MidiFieldCountE] = { "description", "present", "owned" };
    +static const char *const sMidiNames[MidiFieldCountE] = { "description", "present", "owned", "input", "inputowned" };
    +static const char *const sWtNames[WtFieldCountE]     = { "description", "voices", "free", "memory", "freememory", "level", "envelopes", "lfos", "filter", "effects", "8bit", "rom" };
    +static const uint32_t    sWtFlags[WtFieldCountE]     = { [WtEnvelopesE] = AUDIO_WT_ENVELOPES, [WtLfosE] = AUDIO_WT_LFOS, [WtFilterE] = AUDIO_WT_FILTER, [WtEffectsE] = AUDIO_WT_EFFECTS, [Wt8BitE] = AUDIO_WT_8BIT, [WtRomE] = AUDIO_WT_ROM };
     
    -static char     sInfo[BAS_INFO_MAX];
    -static int32_t  sLastError = AUDIO_OK;
    -static char     sDrivers[AUDIO_DRIVERS_MAX][AUDIO_NAME_MAX];
    -static int32_t  sDriverCount = -1;    // -1: not read yet
    -static BasSlotT sSamples[BAS_SLOTS];
    -static BasSlotT sStreams[BAS_SLOTS];
    +static char       sInfo[BAS_INFO_MAX];
    +static int32_t    sLastError = AUDIO_OK;
    +static char       sDrivers[AUDIO_DRIVERS_MAX][AUDIO_NAME_MAX];
    +static int32_t    sDriverCount = -1;    // -1: not read yet
    +static BasSlotT   sSamples[BAS_SLOTS];
    +static BasSlotT   sStreams[BAS_SLOTS];
    +static BasWtNoteT sWtNotes[AUDIO_WT_VOICES_MAX];
    +static uint32_t   sMidiInMs = 0;     // the time of the message MidiInMessage gave last
    +static AudioRecT *sRec    = NULL;    // the recording a BASIC program runs (one at a time in DVX)
    +static AudioCtxT *sRecCtx = NULL;    // ... and that program's context
     
     // ============================================================
     // Prototypes
    @@ -116,6 +159,7 @@ static int32_t     basField(const char *field, const char *const *names, int32_t
     static int32_t     basNumber(BasSlotT *slots, void *obj, const AudioCtxT *ctx, uint32_t frameBytes);
     static int32_t     basOk(int32_t rc);
     static int32_t     basPercent(int32_t volume);
    +static AudioRecT  *basRec(AudioFormatT *fmt);
     static int32_t     basResult(int32_t rc);
     static int32_t     basScale(int32_t percent, int32_t lo, int32_t hi, int32_t unit);
     static BasSlotT   *basSlot(BasSlotT *slots, int32_t handle);
    @@ -124,6 +168,8 @@ static int32_t     basTone(AudioWaveE wave, int32_t frequency, int32_t milliseco
     static int32_t     basTruth(bool value);
     static void        basVoiceResult(int32_t rc);
     static int32_t     basWave(const char *fileName, bool loop);
    +static BasWtNoteT *basWtNote(const AudioCtxT *ctx, int32_t voice);
    +static int32_t     basWtSet(int32_t voice, uint32_t what, int32_t value);
     int32_t            FmAlloc(void);
     void               FmFree(int32_t voice);
     const char        *FmInfo(const char *field);
    @@ -132,6 +178,12 @@ void               FmRawRelease(void);
     int32_t            FmRawWrite(int32_t reg, int32_t value);
     int32_t            FmWrite(int32_t voice, int32_t reg, int32_t value);
     int32_t            MidiAcquire(void);
    +int32_t            MidiInClock(void);
    +void               MidiInClose(void);
    +int32_t            MidiInLost(void);
    +int32_t            MidiInMessage(void);
    +int32_t            MidiInOpen(void);
    +int32_t            MidiInTime(void);
     const char        *MidiPortInfo(const char *field);
     int32_t            MidiPut(int32_t byteValue);
     void               MidiRelease(void);
    @@ -140,6 +192,15 @@ int32_t            PlayTone(int32_t frequency, int32_t milliseconds, int32_t vol
     int32_t            PlayTriangle(int32_t frequency, int32_t milliseconds, int32_t volume);
     int32_t            PlayWave(const char *fileName);
     int32_t            PlayWaveLoop(const char *fileName);
    +int32_t            RecordBits(void);
    +int32_t            RecordChannels(void);
    +int32_t            Recording(void);
    +int32_t            RecordLength(void);
    +int32_t            RecordLevel(void);
    +int32_t            RecordLost(void);
    +int32_t            RecordRate(void);
    +int32_t            RecordStart(const char *fileName, int32_t rate, int32_t bits, int32_t channels, int32_t source);
    +int32_t            RecordStop(void);
     int32_t            SoundCaps(const char *field);
     int32_t            SoundCreate(const uint8_t *pcm, int32_t len, int32_t rate, int32_t bits, int32_t channels);
     const char        *SoundDriverAuto(void);
    @@ -159,10 +220,16 @@ int32_t            SoundLength(int32_t sound);
     int32_t            SoundLoad(const char *fileName);
     int32_t            SoundMasterVolume(void);
     int32_t            SoundMidiCount(void);
    +int32_t            SoundMidiInCount(void);
    +const char        *SoundMidiInLabel(int32_t index);
     const char        *SoundMidiLabel(int32_t index);
    +int32_t            SoundModulesCount(void);
    +const char        *SoundModulesLabel(int32_t index);
    +void               SoundPause(int32_t voice);
     int32_t            SoundPlay(int32_t sound, int32_t volume, int32_t pan, int32_t loopIt);
     void               SoundPlayEvent(int32_t eventIdx, int32_t wait);
     int32_t            SoundPlaying(int32_t voice);
    +void               SoundResume(int32_t voice);
     void               SoundSetEventFile(int32_t prefs, int32_t eventIdx, const char *fileName);
     void               SoundSetFrequency(int32_t voice, int32_t frequency);
     void               SoundSetMasterVolume(int32_t percent);
    @@ -170,10 +237,14 @@ void               SoundSetPan(int32_t voice, int32_t pan);
     void               SoundSetPitch(int32_t voice, int32_t percent);
     const char        *SoundSettingDriver(int32_t prefs);
     int32_t            SoundSettingMidi(int32_t prefs);
    -void               SoundSettingsStore(int32_t prefs, const char *driver, int32_t midi, int32_t volume);
    +int32_t            SoundSettingMidiIn(int32_t prefs);
    +void               SoundSettingMidiInStore(int32_t prefs, int32_t midiIn);
    +int32_t            SoundSettingModules(int32_t prefs);
    +void               SoundSettingsStore(int32_t prefs, const char *driver, int32_t midi, int32_t modules, int32_t volume);
     int32_t            SoundSettingVolume(int32_t prefs);
     void               SoundSetVolume(int32_t voice, int32_t volume);
     void               SoundStreamClose(int32_t stream);
    +void               SoundStreamFlush(int32_t stream);
     int32_t            SoundStreamOpen(int32_t rate, int32_t bits, int32_t channels, int32_t bufferMs);
     int32_t            SoundStreamQueued(int32_t stream);
     int32_t            SoundStreamSpace(int32_t stream);
    @@ -183,11 +254,29 @@ int32_t            SoundTestFile(const char *fileName);
     void               SoundVolume(int32_t volume);
     void               StopAllSounds(void);
     void               StopWave(int32_t voice);
    +int32_t            WtActive(int32_t voice);
    +int32_t            WtAlloc(void);
    +void               WtFree(int32_t voice);
    +const char        *WtInfo(const char *field);
    +int32_t            WtMemAlloc(int32_t bytes, int32_t bits);
    +void               WtMemFree(int32_t address);
    +void               WtRelease(int32_t voice);
    +int32_t            WtSetAttenuation(int32_t voice, int32_t attenuation);
    +int32_t            WtSetPan(int32_t voice, int32_t pan);
    +int32_t            WtSetPitch(int32_t voice, int32_t pitch);
    +int32_t            WtStart(int32_t voice, int32_t address, int32_t startFrame, int32_t endFrame, int32_t loopStart, int32_t loopEnd, int32_t loopMode, int32_t bits, int32_t pitch, int32_t attenuation, int32_t pan);
    +void               WtStop(int32_t voice);
    +int32_t            WtUpload(int32_t address, const uint8_t *pcm, int32_t len, int32_t bits);
     
     
     // ctx is closing (it frees its samples and streams itself): forget their
     // numbers.
     void audioBasForget(const AudioCtxT *ctx) {
    +    if (sRecCtx == ctx) {
    +        sRec    = NULL;
    +        sRecCtx = NULL;
    +    }
    +
         for (int32_t i = 0; i < BAS_SLOTS; i++) {
             if (sSamples[i].ctx == ctx) {
                 sSamples[i].obj = NULL;
    @@ -199,6 +288,12 @@ void audioBasForget(const AudioCtxT *ctx) {
                 sStreams[i].ctx = NULL;
             }
         }
    +
    +    for (int32_t i = 0; i < AUDIO_WT_VOICES_MAX; i++) {
    +        if (sWtNotes[i].ctx == ctx) {
    +            sWtNotes[i].ctx = NULL;
    +        }
    +    }
     }
     
     
    @@ -257,6 +352,19 @@ static int32_t basPercent(int32_t volume) {
     }
     
     
    +// The calling program's recording and its format, or NULL (fmt zeroed).
    +static AudioRecT *basRec(AudioFormatT *fmt) {
    +    memset(fmt, 0, sizeof(*fmt));
    +
    +    if (!sRec || sRecCtx != audioBasicContext()) {
    +        return NULL;
    +    }
    +
    +    audioRecFormat(sRec, fmt);
    +    return sRec;
    +}
    +
    +
     // A voice id, or 0 with the error kept for SoundError$.
     static int32_t basResult(int32_t rc) {
         sLastError = rc < 0 ? rc : AUDIO_OK;
    @@ -330,6 +438,40 @@ static int32_t basWave(const char *fileName, bool loop) {
     }
     
     
    +// The note voice of ctx plays, or NULL.
    +static BasWtNoteT *basWtNote(const AudioCtxT *ctx, int32_t voice) {
    +    for (int32_t i = 0; ctx && i < AUDIO_WT_VOICES_MAX; i++) {
    +        if (sWtNotes[i].ctx == ctx && sWtNotes[i].voice == voice) {
    +            return &sWtNotes[i];
    +        }
    +    }
    +
    +    return NULL;
    +}
    +
    +
    +// One value of a started note changed (what: AUDIO_WT_SET_PITCH, _VOLUME,
    +// _PAN), True when it worked.
    +static int32_t basWtSet(int32_t voice, uint32_t what, int32_t value) {
    +    AudioCtxT  *ctx = audioBasicContext();
    +    BasWtNoteT *w   = basWtNote(ctx, voice);
    +
    +    if (!w) {
    +        return basOk(AUDIO_ERR_VOICE);
    +    }
    +
    +    if (what == AUDIO_WT_SET_PITCH) {
    +        w->note.pitch = value;
    +    } else if (what == AUDIO_WT_SET_VOLUME) {
    +        w->note.attenuation = value < 0 ? 0 : (value > AUDIO_WT_ATTEN_MAX ? AUDIO_WT_ATTEN_MAX : value);
    +    } else {
    +        w->note.pan = basScale(value, BAS_PAN_MIN, BAS_PAN_MAX, AUDIO_WT_PAN_MAX);
    +    }
    +
    +    return basOk(audioWtUpdate(ctx, voice, &w->note, what));
    +}
    +
    +
     // A shared two-operator FM voice (FmFree gives it back), or 0.
     int32_t FmAlloc(void) {
         AudioCtxT *ctx = audioBasicContext();
    @@ -433,6 +575,52 @@ int32_t MidiAcquire(void) {
     }
     
     
    +// Milliseconds since MidiInOpen, on the clock MidiInTime is on.
    +int32_t MidiInClock(void) {
    +    return (int32_t)audioMidiInClock(audioBasicContext());
    +}
    +
    +
    +void MidiInClose(void) {
    +    audioMidiInClose(audioBasicContext());
    +}
    +
    +
    +// Bytes the MIDI input dropped because the program read too late.
    +int32_t MidiInLost(void) {
    +    return (int32_t)audioMidiInLost(audioBasicContext());
    +}
    +
    +
    +// The next message from the MIDI input as status + data1 * 256 + data2 *
    +// 65536 (MidiInTime is when it came), or -1 when none waits.
    +int32_t MidiInMessage(void) {
    +    AudioMidiMsgT msg;
    +
    +    if (!audioMidiInRead(audioBasicContext(), &msg)) {
    +        return AUDIO_MIDI_PACKED_NONE;
    +    }
    +
    +    sMidiInMs = msg.ms;
    +    return (int32_t)msg.status | ((int32_t)msg.data1 << AUDIO_MIDI_PACKED_DATA1_SHIFT) | ((int32_t)msg.data2 << AUDIO_MIDI_PACKED_DATA2_SHIFT);
    +}
    +
    +
    +// Read the MIDI input (True), until MidiInClose; False with SoundError$.
    +int32_t MidiInOpen(void) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    return basOk(ctx ? audioMidiInOpen(ctx) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
    +// When the message MidiInMessage gave last came (milliseconds since
    +// MidiInOpen).
    +int32_t MidiInTime(void) {
    +    return (int32_t)sMidiInMs;
    +}
    +
    +
     // The MIDI port: "description" (text, "" when there is none), "present",
     // "owned" (True / False as text); "" for another field.
     const char *MidiPortInfo(const char *field) {
    @@ -452,6 +640,12 @@ const char *MidiPortInfo(const char *field) {
             case MidiOwnedE:
                 return basText(basTruth(info.owned));
     
    +        case MidiInputE:
    +            return basText(basTruth(info.inPresent));
    +
    +        case MidiInputOwnedE:
    +            return basText(basTruth(info.inOwned));
    +
             default:
                 return "";
         }
    @@ -507,9 +701,119 @@ int32_t PlayWaveLoop(const char *fileName) {
     }
     
     
    +// The recording's sample size and channels (0 when the program records
    +// nothing).
    +int32_t RecordBits(void) {
    +    AudioFormatT fmt;
    +
    +    basRec(&fmt);
    +    return (int32_t)fmt.bits;
    +}
    +
    +
    +int32_t RecordChannels(void) {
    +    AudioFormatT fmt;
    +
    +    basRec(&fmt);
    +    return (int32_t)fmt.channels;
    +}
    +
    +
    +// True while the program records.
    +int32_t Recording(void) {
    +    AudioFormatT fmt;
    +
    +    return basTruth(basRec(&fmt) != NULL);
    +}
    +
    +
    +// Milliseconds recorded so far (written to the file, or about to be).
    +int32_t RecordLength(void) {
    +    AudioFormatT fmt;
    +    AudioRecT   *rec = basRec(&fmt);
    +
    +    return rec ? (int32_t)((uint64_t)(audioRecFrames(rec) + audioRecQueued(rec)) * MS_PER_SEC / fmt.rate) : 0;
    +}
    +
    +
    +// The loudest sample since the last call, percent (a level meter).
    +int32_t RecordLevel(void) {
    +    AudioFormatT fmt;
    +    AudioRecT   *rec = basRec(&fmt);
    +
    +    return rec ? audioRecPeak(rec) : 0;
    +}
    +
    +
    +// Frames dropped because DVX was too busy to write them in time.
    +int32_t RecordLost(void) {
    +    AudioFormatT fmt;
    +    AudioRecT   *rec = basRec(&fmt);
    +
    +    return rec ? (int32_t)audioRecLost(rec) : 0;
    +}
    +
    +
    +// The rate the card records at (the nearest it has to the one asked).
    +int32_t RecordRate(void) {
    +    AudioFormatT fmt;
    +
    +    basRec(&fmt);
    +    return (int32_t)fmt.rate;
    +}
    +
    +
    +// Record from source (RECORD_MIC, RECORD_LINE, RECORD_CD) into a WAV file
    +// in rate, bits and channels (0: the default).  True when it started;
    +// False with SoundError$ saying why.
    +int32_t RecordStart(const char *fileName, int32_t rate, int32_t bits, int32_t channels, int32_t source) {
    +    AudioCtxT   *ctx = audioBasicContext();
    +    AudioRecT   *rec = NULL;
    +    AudioFormatT fmt;
    +
    +    if (!ctx) {
    +        return basOk(AUDIO_ERR_NO_DEVICE);
    +    }
    +
    +    if (rate < 0 || bits < 0 || channels < 0) {
    +        return basOk(AUDIO_ERR_ARG);
    +    }
    +
    +    fmt.rate     = (uint32_t)rate;
    +    fmt.bits     = (uint32_t)bits;
    +    fmt.channels = (uint32_t)channels;
    +
    +    int32_t rc = audioRecFile(ctx, fileName, &fmt, source, &rec);
    +
    +    if (rc == AUDIO_OK) {
    +        sRec    = rec;
    +        sRecCtx = ctx;
    +    }
    +
    +    return basOk(rc);
    +}
    +
    +
    +// Stop recording; the file is complete.  True when it was all written.
    +int32_t RecordStop(void) {
    +    AudioFormatT fmt;
    +    AudioRecT   *rec = basRec(&fmt);
    +
    +    if (!rec) {
    +        return basOk(AUDIO_ERR_ARG);
    +    }
    +
    +    sRec    = NULL;
    +    sRecCtx = NULL;
    +    return basOk(audioRecClose(rec));
    +}
    +
    +
     // What the sound device can do (opens the program's context, so the
    -// device is started): "dac", "synth", "tone", "running" True / False;
    -// "voices", "rate", "bits", "channels" numbers; 0 for another field.
    +// device is started): "dac", "synth", "tone", "running", "record" True /
    +// False; "voices", "rate", "bits", "channels" numbers; "recordsources"
    +// the inputs it records from (bit RECORD_MIC, RECORD_LINE, RECORD_CD
    +// set for each: 1, 2, 4); 0 for another field.
     int32_t SoundCaps(const char *field) {
         AudioInfoT info;
     
    @@ -541,6 +845,12 @@ int32_t SoundCaps(const char *field) {
             case CapChannelsE:
                 return (int32_t)info.channels;
     
    +        case CapRecordE:
    +            return basTruth(info.canRecord);
    +
    +        case CapRecordSourcesE:
    +            return (int32_t)info.recSources;
    +
             default:
                 return 0;
         }
    @@ -736,12 +1046,43 @@ int32_t SoundMidiCount(void) {
     }
     
     
    +int32_t SoundMidiInCount(void) {
    +    return AUDIO_MIDI_IN_CHOICE_COUNT;
    +}
    +
    +
    +// Where MIDI input can come from, for people ("" out of range).
    +const char *SoundMidiInLabel(int32_t index) {
    +    return (index >= 0 && index < AUDIO_MIDI_IN_CHOICE_COUNT) ? audioMidiInChoices[index].label : "";
    +}
    +
    +
     // Where MIDI music can play, for people ("" out of range).
     const char *SoundMidiLabel(int32_t index) {
         return (index >= 0 && index < AUDIO_MIDI_CHOICE_COUNT) ? audioMidiChoices[index].label : "";
     }
     
     
    +int32_t SoundModulesCount(void) {
    +    return AUDIO_MODULES_CHOICE_COUNT;
    +}
    +
    +
    +// Where tracker modules can play, for people ("" out of range).
    +const char *SoundModulesLabel(int32_t index) {
    +    return (index >= 0 && index < AUDIO_MODULES_CHOICE_COUNT) ? audioModulesChoices[index].label : "";
    +}
    +
    +
    +// Hold a sound's or a stream's voice where it is (SoundResume lets it go
    +// on); the result is kept for SoundError$.
    +void SoundPause(int32_t voice) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    basVoiceResult(ctx ? audioVoicePause(ctx, voice, true) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
     // Play a loaded sound on a new voice: volume percent, pan -100 (left) ..
     // 100 (right).
     int32_t SoundPlay(int32_t sound, int32_t volume, int32_t pan, int32_t loopIt) {
    @@ -773,6 +1114,13 @@ int32_t SoundPlaying(int32_t voice) {
     }
     
     
    +void SoundResume(int32_t voice) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    basVoiceResult(ctx ? audioVoicePause(ctx, voice, false) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
     void SoundSetEventFile(int32_t prefs, int32_t eventIdx, const char *fileName) {
         audioDeskSetFile(prefsBasHandle(prefs), (DvxSoundE)eventIdx, fileName);
     }
    @@ -823,11 +1171,30 @@ int32_t SoundSettingMidi(int32_t prefs) {
     }
     
     
    -void SoundSettingsStore(int32_t prefs, const char *driver, int32_t midi, int32_t volume) {
    +int32_t SoundSettingMidiIn(int32_t prefs) {
    +    return audioSettingMidiIn(prefsBasHandle(prefs));
    +}
    +
    +
    +void SoundSettingMidiInStore(int32_t prefs, int32_t midiIn) {
         PrefsHandleT *h = prefsBasHandle(prefs);
     
         if (h) {
    -        audioSettingsStore(h, driver, midi, volume);
    +        audioSettingMidiInStore(h, midiIn);
    +    }
    +}
    +
    +
    +int32_t SoundSettingModules(int32_t prefs) {
    +    return audioSettingModules(prefsBasHandle(prefs));
    +}
    +
    +
    +void SoundSettingsStore(int32_t prefs, const char *driver, int32_t midi, int32_t modules, int32_t volume) {
    +    PrefsHandleT *h = prefsBasHandle(prefs);
    +
    +    if (h) {
    +        audioSettingsStore(h, driver, midi, modules, volume);
         }
     }
     
    @@ -856,6 +1223,16 @@ void SoundStreamClose(int32_t stream) {
     }
     
     
    +// What the stream holds and has not played yet dropped.
    +void SoundStreamFlush(int32_t stream) {
    +    BasSlotT *s = basSlot(sStreams, stream);
    +
    +    if (s) {
    +        audioStreamFlush((AudioStreamT *)s->obj);
    +    }
    +}
    +
    +
     // A stream the program writes PCM into (SoundStreamWrite); it plays at
     // once on its own voice (SoundStreamVoice).  bufferMs 0: the default.
     int32_t SoundStreamOpen(int32_t rate, int32_t bits, int32_t channels, int32_t bufferMs) {
    @@ -958,3 +1335,185 @@ void StopWave(int32_t voice) {
             audioVoiceStop(ctx, voice);
         }
     }
    +
    +
    +int32_t WtActive(int32_t voice) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    return basTruth(ctx && audioWtActive(ctx, voice));
    +}
    +
    +
    +int32_t WtAlloc(void) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    return basResult(ctx ? audioWtAlloc(ctx) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
    +void WtFree(int32_t voice) {
    +    AudioCtxT  *ctx = audioBasicContext();
    +    BasWtNoteT *w   = basWtNote(ctx, voice);
    +
    +    if (w) {
    +        w->ctx = NULL;
    +    }
    +
    +    basVoiceResult(ctx ? audioWtFree(ctx, voice) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
    +const char *WtInfo(const char *field) {
    +    AudioWtInfoT info;
    +    int32_t      f;
    +
    +    audioBasicContext();
    +    audioWtGetInfo(&info);
    +    f = basField(field, sWtNames, WtFieldCountE);
    +
    +    switch ((WtFieldE)f) {
    +        case WtDescriptionE:
    +            snprintf(sInfo, sizeof(sInfo), "%s", info.description);
    +            return sInfo;
    +
    +        case WtVoicesE:
    +            return basText((int32_t)info.voices);
    +
    +        case WtFreeE:
    +            return basText((int32_t)info.freeVoices);
    +
    +        case WtMemoryE:
    +            return basText((int32_t)info.memory);
    +
    +        case WtFreeMemoryE:
    +            return basText((int32_t)info.freeMemory);
    +
    +        case WtLevelE:
    +            return basText(info.level);
    +
    +        default:
    +            return f < 0 ? "" : basText(basTruth((info.flags & sWtFlags[f]) != 0));
    +    }
    +}
    +
    +
    +// Sample memory's address, or BAS_WT_MEM_NONE (0 is an address).
    +int32_t WtMemAlloc(int32_t bytes, int32_t bits) {
    +    AudioCtxT *ctx  = audioBasicContext();
    +    uint32_t   addr = 0;
    +    int32_t    rc   = AUDIO_ERR_NO_DEVICE;
    +
    +    if (ctx) {
    +        rc = bytes > 0 && bits > 0 ? audioWtMemAlloc(ctx, (uint32_t)bytes, (uint32_t)bits, &addr) : AUDIO_ERR_ARG;
    +    }
    +
    +    basVoiceResult(rc);
    +    return rc == AUDIO_OK ? (int32_t)addr : BAS_WT_MEM_NONE;
    +}
    +
    +
    +void WtMemFree(int32_t address) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    basVoiceResult(ctx && address >= 0 ? audioWtMemFree(ctx, (uint32_t)address) : AUDIO_ERR_ARG);
    +}
    +
    +
    +void WtRelease(int32_t voice) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    basVoiceResult(ctx ? audioWtRelease(ctx, voice) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
    +int32_t WtSetAttenuation(int32_t voice, int32_t attenuation) {
    +    return basWtSet(voice, AUDIO_WT_SET_VOLUME, attenuation);
    +}
    +
    +
    +int32_t WtSetPan(int32_t voice, int32_t pan) {
    +    return basWtSet(voice, AUDIO_WT_SET_PAN, pan);
    +}
    +
    +
    +int32_t WtSetPitch(int32_t voice, int32_t pitch) {
    +    return basWtSet(voice, AUDIO_WT_SET_PITCH, pitch);
    +}
    +
    +
    +// A plain note (audioWtNoteInit) on a sample in the memory, kept for the
    +// Set functions.
    +int32_t WtStart(int32_t voice, int32_t address, int32_t startFrame, int32_t endFrame, int32_t loopStart, int32_t loopEnd, int32_t loopMode, int32_t bits, int32_t pitch, int32_t attenuation, int32_t pan) {
    +    AudioCtxT  *ctx = audioBasicContext();
    +    BasWtNoteT *w   = basWtNote(ctx, voice);
    +
    +    if (!ctx) {
    +        return basOk(AUDIO_ERR_NO_DEVICE);
    +    }
    +
    +    if (address < 0 || startFrame < 0 || startFrame > endFrame || loopStart < 0 || loopEnd < loopStart || loopEnd > endFrame || (loopMode != AUDIO_WT_LOOP_NONE && loopMode != AUDIO_WT_LOOP_ON && loopMode != AUDIO_WT_LOOP_RELEASE) || (bits != BAS_WT_BITS_8 && bits != BAS_WT_BITS_16)) {
    +        return basOk(AUDIO_ERR_ARG);
    +    }
    +
    +    for (int32_t i = 0; !w && i < AUDIO_WT_VOICES_MAX; i++) {
    +        w = sWtNotes[i].ctx ? NULL : &sWtNotes[i];
    +    }
    +
    +    if (!w) {
    +        return basOk(AUDIO_ERR_NO_VOICE);
    +    }
    +
    +    AudioWtVoiceT *n = &w->note;
    +
    +    audioWtNoteInit(n);
    +    n->addr        = (uint32_t)address;
    +    n->offset      = (uint32_t)startFrame;
    +    n->end         = (uint32_t)endFrame;
    +    n->loopStart   = (uint32_t)loopStart;
    +    n->loopEnd     = (uint32_t)loopEnd;
    +    n->loop        = (uint8_t)loopMode;
    +    n->bits        = (uint8_t)bits;
    +    n->pitch       = pitch;
    +    n->attenuation = attenuation < 0 ? 0 : (attenuation > AUDIO_WT_ATTEN_MAX ? AUDIO_WT_ATTEN_MAX : attenuation);
    +    n->pan         = basScale(pan, BAS_PAN_MIN, BAS_PAN_MAX, AUDIO_WT_PAN_MAX);
    +
    +    int32_t rc = audioWtStart(ctx, voice, n);
    +
    +    w->ctx   = rc == AUDIO_OK ? ctx : NULL;
    +    w->voice = voice;
    +    return basOk(rc);
    +}
    +
    +
    +void WtStop(int32_t voice) {
    +    AudioCtxT *ctx = audioBasicContext();
    +
    +    basVoiceResult(ctx ? audioWtStop(ctx, voice) : AUDIO_ERR_NO_DEVICE);
    +}
    +
    +
    +// 16-bit samples (signed, little-endian, mono) into the memory at
    +// address, stored at bits: copied into whole samples a run at a time.
    +int32_t WtUpload(int32_t address, const uint8_t *pcm, int32_t len, int32_t bits) {
    +    AudioCtxT *ctx    = audioBasicContext();
    +    uint32_t   frames = len > 0 ? (uint32_t)len / SAMPLE_BYTES : 0;
    +    int32_t    rc     = AUDIO_OK;
    +    int16_t    run[BAS_WT_RUN];
    +
    +    if (!ctx) {
    +        return basOk(AUDIO_ERR_NO_DEVICE);
    +    }
    +
    +    if (address < 0 || frames == 0 || (bits != BAS_WT_BITS_8 && bits != BAS_WT_BITS_16)) {
    +        return basOk(AUDIO_ERR_ARG);
    +    }
    +
    +    for (uint32_t done = 0; done < frames && rc == AUDIO_OK; done += BAS_WT_RUN) {
    +        uint32_t n = frames - done < BAS_WT_RUN ? frames - done : BAS_WT_RUN;
    +
    +        memcpy(run, pcm + done * SAMPLE_BYTES, n * SAMPLE_BYTES);
    +        rc = audioWtUpload(ctx, (uint32_t)address + done * ((uint32_t)bits / BITS_PER_BYTE), run, n, (uint32_t)bits);
    +    }
    +
    +    return basOk(rc);
    +}
    diff --git a/src/libs/kpunch/audio/audioDrv.h b/src/libs/kpunch/audio/audioDrv.h
    index 43eac24e..790a83fe 100644
    --- a/src/libs/kpunch/audio/audioDrv.h
    +++ b/src/libs/kpunch/audio/audioDrv.h
    @@ -40,7 +40,18 @@
     // interrupt or from the timer service (audioClockStart does that for a
     // device that plays nothing it has to feed).  close stops output and
     // releases everything; after it returns audioMixRender is not called
    -// again.  A driver registers a shutdown hook (platformShutdownHookAdd) if
    +// again.
    +//
    +// Recording (audio.h): a driver whose card can record gives recordStart
    +// and recordStop (NULL otherwise) and the sources it records from in
    +// AudioDrvInfoT.recSources.  recordStart is called with the output
    +// closed (open found the card earlier; close, or no play, followed): it
    +// takes the card for recording (its own DMA buffer and interrupt), sets
    +// the source and the format (got: what the card records, the nearest it
    +// can to want), and from then on its interrupt calls audioRecPush with
    +// each block recorded.  recordStop gives everything back as close
    +// does; audio.lib opens the output again (open, play) when something
    +// plays.  A driver registers a shutdown hook (platformShutdownHookAdd) if
     // its hardware would keep running when DVX ends without closing it.
     //
     // Interrupt-time rules: code and data an interrupt touches must be pinned
    @@ -70,21 +81,42 @@
     #define AUDIO_INI_VOLUME        "volume"
     #define AUDIO_DRIVER_AUTO       "auto"
     
    -// DVX.INI [audio] midi: where MIDI music plays.  auto: the FM chip when
    -// there is one, else the software synthesizer; mpu401: the MPU-401 MIDI
    -// port (at mpuPort, hex, default AUDIO_MPU_PORT_DEFAULT; the port is only
    -// looked for with this setting, since an unused port would play nothing);
    -// fm, software: those.
    +// DVX.INI [audio] midi: where MIDI music plays.  auto: a wavetable
    +// synthesizer with a SoundFont when there is one, else the FM chip, else
    +// the FM software synthesizer; mpu401: the MPU-401 MIDI port (at mpuPort,
    +// hex, default AUDIO_MPU_PORT_DEFAULT; the port is only looked for with
    +// this setting, since an unused port would play nothing); fm: the FM chip
    +// (else the FM software synthesizer); software: the FM software
    +// synthesizer; softwave: the SoundFont software synthesizer (music.lib
    +// decides all but mpu401).
     #define AUDIO_INI_MIDI          "midi"
     #define AUDIO_INI_MPU_PORT      "mpuPort"
     #define AUDIO_MIDI_AUTO         "auto"
     #define AUDIO_MIDI_MPU401       "mpu401"
     #define AUDIO_MIDI_FM           "fm"
     #define AUDIO_MIDI_SOFTWARE     "software"
    +#define AUDIO_MIDI_SOFTWAVE     "softwave"
     #define AUDIO_MPU_PORT_DEFAULT  "330"
     
    +// DVX.INI [audio] midiIn: where MIDI input comes from (audio.h, "MIDI
    +// input"): none, or mpu401 (the MPU-401 port at mpuPort, opened for it
    +// even when music plays elsewhere).
    +#define AUDIO_INI_MIDI_IN       "midiIn"
    +#define AUDIO_MIDI_IN_NONE      "none"
    +
    +// DVX.INI [audio] modules: where tracker modules play.  wavetable: on a
    +// wavetable synthesizer's voices when it is free and the module fits
    +// there, else mixed in software; software: always mixed in software
    +// (music.lib decides).
    +#define AUDIO_INI_MODULES       "modules"
    +#define AUDIO_MODULES_WAVETABLE "wavetable"
    +#define AUDIO_MODULES_SOFTWARE  "software"
    +
     // Driver priorities (tried from the highest down)
    +#define AUDIO_PRIORITY_CODEC    110     // a WSS codec: before the Sound Blaster mode the same board may have
     #define AUDIO_PRIORITY_CARD     100     // sound cards
    +#define AUDIO_PRIORITY_GUS      90      // a GUS: after a Sound Blaster beside it, which leaves it all its voices for music
    +#define AUDIO_PRIORITY_LPT      50      // DACs on the parallel port
     #define AUDIO_PRIORITY_SPEAKER  20      // the PC speaker
     #define AUDIO_PRIORITY_NULL     0       // plays nothing
     
    @@ -105,15 +137,21 @@ typedef struct {
     // time.
     typedef void (*AudioToneFnT)(uint32_t hz);
     
    -// What a driver's open found.
    +struct AudioSynthDriverT;
    +
    +// What a driver's open found.  synth: a synthesizer on the same card that
    +// only this driver can run (a GUS's other voices beside its digital
    +// sound), opened with the synth drivers and before them; NULL for none.
     typedef struct {
    -    char         description[AUDIO_DESC_MAX];   // for people (audioGetInfo)
    -    uint32_t     rate;                          // achieved output rate (Hz)
    -    uint32_t     bits;                          // 8 (unsigned) or 16 (signed)
    -    uint32_t     channels;                      // 1 or 2
    -    AudioToneFnT tone;                          // hardware tones, or NULL
    -    bool         hasDac;                        // plays sampled sound
    -    bool         hasSynth;                      // has a hardware synthesizer
    +    char                            description[AUDIO_DESC_MAX];   // for people (audioGetInfo)
    +    uint32_t                        rate;                          // achieved output rate (Hz)
    +    uint32_t                        bits;                          // 8 (unsigned) or 16 (signed)
    +    uint32_t                        channels;                      // 1 or 2
    +    AudioToneFnT                    tone;                          // hardware tones, or NULL
    +    const struct AudioSynthDriverT *synth;                         // the card's own synthesizer, or NULL
    +    bool                            hasDac;                        // plays sampled sound
    +    bool                            hasSynth;                      // has a hardware synthesizer
    +    uint32_t                        recSources;                    // recordStart's sources: bit 1 << AUDIO_REC_* (0: cannot record)
     } AudioDrvInfoT;
     
     // The descriptor every driver exports as AUDIO_DRV_SYMBOL.
    @@ -124,6 +162,8 @@ typedef struct {
         bool           (*open)(const AudioDrvConfigT *cfg, AudioDrvInfoT *info);
         bool           (*play)(void);
         void           (*close)(void);
    +    bool           (*recordStart)(const AudioFormatT *want, int32_t source, AudioFormatT *got);
    +    void           (*recordStop)(void);
     } AudioDriverT;
     
     // Write a driver's descriptor: AUDIO_DRIVER("x", AUDIO_PRIORITY_CARD,
    @@ -146,21 +186,29 @@ typedef struct {
     // A module in AUDIO_DRV_DIR may export an AudioSynthDriverT named
     // AUDIO_SYNTH_SYMBOL instead (write it with AUDIO_SYNTH_DRIVER(...)): a
     // music synthesizer beside the sound device - an FM chip (AUDIO_SYNTH_FM,
    -// programmed by register writes) or a MIDI port (AUDIO_SYNTH_MIDI, sent
    -// bytes).  When the sound device starts, the synth drivers of each kind
    -// are opened from the highest priority down and the first that finds its
    -// hardware is kept (it is closed with the device).  open detects and must
    -// leave the hardware silent; close silences it.  write and put are called
    -// at interrupt time as well (music playback runs from the timer service),
    -// so they follow the interrupt-time rules above, and write must make its
    -// index/data pair atomic (interrupts off around it).
    +// programmed by register writes), a MIDI port (AUDIO_SYNTH_MIDI, sent
    +// bytes), or a wavetable synthesizer (AUDIO_SYNTH_WAVETABLE: samples
    +// uploaded to its memory, voices started on them; audio.h, "Wavetable
    +// synthesizer").  When the sound device starts, the synth drivers of each
    +// kind are opened from the highest priority down and the first that
    +// finds its hardware is kept (it is closed with the device).  open
    +// detects and must leave the hardware silent; close silences it.  write
    +// and put, get (MIDI input, polled from the timer service), and a
    +// wavetable driver's voice functions (start, update, release, stop,
    +// active), are called at interrupt time as well (music playback runs
    +// from the timer service), so they follow the interrupt-time rules
    +// above, and write must make its index/data pair atomic (interrupts off
    +// around it).  A MIDI driver with get keeps what put has to read past
    +// for get, so no input is lost while output goes on.  A wavetable driver's upload runs at
    +// program time.
     
     #define AUDIO_SYNTH_SYMBOL      "audioSynth"
     
     // Synthesizer kinds
     #define AUDIO_SYNTH_FM          0       // OPL2 / OPL3 FM chip
     #define AUDIO_SYNTH_MIDI        1       // MIDI port (MPU-401)
    -#define AUDIO_SYNTH_KINDS       2
    +#define AUDIO_SYNTH_WAVETABLE   2       // sample-playing synthesizer (EMU8000, GF1)
    +#define AUDIO_SYNTH_KINDS       3
     
     // FM register numbers passed to write: bank (0, or 1 on an OPL3) times
     // AUDIO_FM_BANK_REGS plus the register index.
    @@ -169,20 +217,30 @@ typedef struct {
     // What a synth driver's open found.
     typedef struct {
         char     description[AUDIO_DESC_MAX];   // for people, e.g. "OPL3 FM synthesizer at 388h"
    -    uint32_t voices;                        // FM: two-operator voices (9 on an OPL2, 18 on an OPL3)
    +    uint32_t voices;                        // FM: two-operator voices (9 on an OPL2, 18 on an OPL3); wavetable: voices
    +    uint32_t memory;                        // wavetable: bytes of sample memory
    +    uint32_t flags;                         // wavetable: what the chip does itself (AUDIO_WT_*)
    +    int32_t  level;                         // wavetable: AudioWtInfoT's level
         bool     stereo;                        // FM: each voice can play left, right or both (OPL3)
     } AudioSynthDrvInfoT;
     
     // The descriptor every synth driver exports as AUDIO_SYNTH_SYMBOL.
     // hdr.name is a short name below AUDIO_NAME_MAX; a higher hdr.priority is
     // tried first.
    -typedef struct {
    +typedef struct AudioSynthDriverT {
         DvxDriverHeaderT hdr;
         int32_t          kind;                                                      // AUDIO_SYNTH_*
         bool           (*open)(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
         void           (*close)(void);
         void           (*write)(uint32_t reg, uint8_t value);                       // FM: set a register
         bool           (*put)(uint8_t byte);                                        // MIDI: send a byte; false when the port is not ready yet
    +    bool           (*get)(uint8_t *byte);                                       // MIDI: a byte the port received; false when none waits
    +    bool           (*upload)(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);  // wavetable: frames into memory at addr (program time)
    +    void           (*start)(uint32_t voice, const AudioWtVoiceT *v);                               // wavetable: the voices (0 .. voices - 1)
    +    void           (*update)(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +    void           (*release)(uint32_t voice);
    +    void           (*stop)(uint32_t voice);
    +    bool           (*active)(uint32_t voice);
     } AudioSynthDriverT;
     
     // Write a synth driver's descriptor: AUDIO_SYNTH_DRIVER("x", priority,
    @@ -194,6 +252,11 @@ typedef struct {
     // stereo interleaved).  Interrupt-safe; never blocks.
     void audioMixRender(void *dst, uint32_t frames);
     
    +// A block the card recorded: bytes of whole frames in the format
    +// recordStart gave.  Interrupt-safe; never blocks; a block that does not
    +// fit in the recording's buffer is dropped (counted lost).
    +void audioRecPush(const void *src, uint32_t bytes);
    +
     // Drive the mixer from the timer service (AUDIO_CLOCK_HZ) for a device
     // that has no interrupt of its own to do it; the output is thrown away.
     // Tones still reach a hardware tone generator (AudioDrvInfoT tone).
    @@ -206,6 +269,18 @@ void audioClockStop(void);
     // covers every function listed).  Returns false when pinning failed.
     bool audioLockSpan(const AudioLockItemT *items, int32_t count, bool lock);
     
    +// Sound files (audioLoad.c), for audio.lib and the libraries built on it
    +// (music.lib streams WAV and VOC files).  audioDecode: a WAV or VOC
    +// image as 16-bit frames (malloc'd, frames * fmt->channels values;
    +// fmt->bits is what the file had); AUDIO_ERR_FORMAT when it is neither.
    +// audioWavParse: a WAV file's format and where its sound is (*dataAt,
    +// *dataBytes: whole frames, no further than the file's fileBytes), from
    +// its first headBytes bytes, which must reach the data chunk's header.
    +// audioConvert: count 8-bit unsigned or 16-bit signed samples as int16.
    +void    audioConvert(const void *src, int16_t *dst, uint32_t count, uint32_t bits);
    +int32_t audioDecode(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t *frames, AudioFormatT *fmt);
    +int32_t audioWavParse(const uint8_t *head, uint32_t headBytes, uint32_t fileBytes, AudioFormatT *fmt, uint32_t *dataAt, uint32_t *dataBytes);
    +
     // Interrupts off / back as they were, around the few instructions that
     // must not be split by an interrupt (an FM chip's index/data pair, a
     // table an interrupt also changes).  Inline, so code that runs at
    diff --git a/src/libs/kpunch/audio/audioInt.h b/src/libs/kpunch/audio/audioInt.h
    index 9e5a08be..5d51a835 100644
    --- a/src/libs/kpunch/audio/audioInt.h
    +++ b/src/libs/kpunch/audio/audioInt.h
    @@ -131,6 +131,7 @@ typedef struct {
         uint8_t           wave;        // AudioWaveE
         volatile uint8_t  fmSlot;      // tones on the FM chip: its channel + 1 (0: the mixer makes the tone)
         bool              loop;        // samples: start over at the end
    +    volatile bool     paused;      // samples, streams: held where they are (audioVoicePause)
         bool              draining;    // the source ran out: cur is its last frame
         bool              timed;       // tones: stop when remain reaches 0
     } MixVoiceT;
    @@ -152,6 +153,23 @@ typedef struct {
     
     extern AudioMixT audioMixer;
     
    +// The recording's ring (audioRec.c): bytes in the recording's format, a
    +// power of two long; head counts the bytes the interrupt wrote
    +// (audioRecPush), tail those the program side took.  A block that does
    +// not fit is dropped whole (lost counts its bytes), so the ring never
    +// holds part of a frame.  audioRecRing is the recording running (NULL:
    +// none), pinned with the mixer.
    +typedef struct {
    +    uint8_t          *data;
    +    uint32_t          mask;        // capacity in bytes - 1
    +    uint32_t          frameBytes;  // the recording's frame size (program side)
    +    volatile uint32_t head;
    +    volatile uint32_t tail;
    +    volatile uint32_t lost;
    +} AudioRecRingT;
    +
    +extern AudioRecRingT *volatile audioRecRing;
    +
     // Mixer (audioMix.c).  Prime loads a voice's first two source frames
     // before it is marked playing; Lock pins the mixer's code and data;
     // Reset empties it for a device with this format.
    @@ -172,20 +190,33 @@ void    audioFmChannelOff(uint32_t channel);
     void    audioFmDetach(void);
     int32_t audioFmToneStart(AudioCtxT *ctx, AudioWaveE wave);
     void    audioFmToneUpdate(AudioCtxT *ctx, int32_t voice, uint32_t hz, int32_t gainL, int32_t gainR);
    -void    audioMidiAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info);
    +void    audioMidiAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info, bool inWanted);
     void    audioMidiDetach(void);
    +
    +// The MIDI port's input: its driver's get, NULL when there is none or
    +// DVX.INI [audio] midiIn did not choose it (audioSynth.c, attached with
    +// inWanted).  audioMidiIn.c reads it from a timer; Owned says a program
    +// does, CtxClosed ends a closing context's reading, Suspend ends any
    +// (a DOS program runs).
    +typedef bool (*AudioMidiGetFnT)(uint8_t *byte);
    +
    +void            audioMidiInCtxClosed(const AudioCtxT *ctx);
    +bool            audioMidiInOwned(void);
    +AudioMidiGetFnT audioMidiInPort(void);
    +void            audioMidiInSuspend(void);
     void    audioSynthCtxClosed(AudioCtxT *ctx);
     bool    audioSynthLock(bool lock);
     
    -// Sound files (audioLoad.c): decode a WAV or VOC image into int16 PCM
    -// (malloc'd, frames * fmt->channels values; fmt->bits is what the file
    -// had).  Convert turns count 8-bit unsigned or 16-bit signed samples into
    -// int16.
    -void    audioConvert(const void *src, int16_t *dst, uint32_t count, uint32_t bits);
    -int32_t audioDecode(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t *frames, AudioFormatT *fmt);
    +// The wavetable synthesizer (audioWt.c): the same, for the wavetable
    +// driver the device opened.
    +void    audioWtAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info);
    +void    audioWtCtxClosed(AudioCtxT *ctx);
    +void    audioWtDetach(void);
    +bool    audioWtLock(bool lock);
     
    -// The library's end of a program's life, of the shell's idle time and of
    -// its own unloading (audio.c; audioShell.c hooks them up to the shell),
    +// The library's end of a program's life, of the shell's idle time, of a
    +// DOS program run with DVX stepped aside (audioSuspend, a platform suspend
    +// hook) and of its own unloading (audio.c; audioShell.c hooks them up),
     // the calling BASIC program's context, and making appId the current
     // program (returns the one before).  audioBasForget (audioBas.c) drops the
     // BASIC numbers of a closing BASIC context's sounds and streams.
    @@ -195,6 +226,19 @@ void       audioBasForget(const AudioCtxT *ctx);
     AudioCtxT *audioBasicContext(void);
     void       audioIdle(void *unused);
     void       audioShutdown(void);
    +void       audioSuspend(bool suspending);
    +
    +// Recording (audioRec.c): a closing context's recording ends with it;
    +// the pump writes a recording into a file; a DOS program ends it.  And
    +// the device's side (audio.c): Begin takes the card for recording from
    +// source (AUDIO_ERR_NO_DEVICE, AUDIO_ERR_NO_RECORD): the output is
    +// closed and new voices are refused until End, which plays on the
    +// voices it held.
    +int32_t    audioDevRecordBegin(int32_t source, const AudioDriverT **drv);
    +void       audioDevRecordEnd(void);
    +void       audioRecCtxClosed(const AudioCtxT *ctx);
    +void       audioRecIdle(void);
    +void       audioRecSuspend(void);
     
     // Desktop sounds (audioDesk.c): Init connects them to DVX (the sound
     // hook), Shutdown disconnects them before the library closes its
    diff --git a/src/libs/kpunch/audio/audioLoad.c b/src/libs/kpunch/audio/audioLoad.c
    index f8f651b9..3992a98b 100644
    --- a/src/libs/kpunch/audio/audioLoad.c
    +++ b/src/libs/kpunch/audio/audioLoad.c
    @@ -27,7 +27,8 @@
     // for streams too.
     //
     // WAV: a RIFF "WAVE" file.  Chunks are walked in order (odd sizes padded)
    -// and all but 'fmt ' and 'data' are skipped.  Accepted: PCM (format tag
    +// and all but 'fmt ' and 'data' are skipped (audioWavParse, which also
    +// serves a caller holding only the start of the file).  Accepted: PCM (format tag
     // 1, or WAVE_FORMAT_EXTENSIBLE with the PCM sub-format), 8-bit unsigned
     // or 16-bit signed, mono or stereo, AUDIO_RATE_MIN..AUDIO_RATE_MAX.  A
     // data chunk that claims more bytes than the file has plays what is
    @@ -115,6 +116,7 @@ typedef struct {
     
     void            audioConvert(const void *src, int16_t *dst, uint32_t count, uint32_t bits);
     int32_t         audioDecode(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t *frames, AudioFormatT *fmt);
    +int32_t         audioWavParse(const uint8_t *head, uint32_t headBytes, uint32_t fileBytes, AudioFormatT *fmt, uint32_t *dataAt, uint32_t *dataBytes);
     static int32_t  checkFormat(const AudioFormatT *fmt);
     static int32_t  decodeVoc(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t *frames, AudioFormatT *fmt);
     static int32_t  decodeWav(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t *frames, AudioFormatT *fmt);
    @@ -158,6 +160,79 @@ int32_t audioDecode(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t
     }
     
     
    +// The chunks walked from the start (a chunk that claims more than the
    +// file has is damaged, but for the data chunk: a recorder that stopped
    +// early leaves one, and what is there plays).  The 'fmt ' chunk must lie
    +// within head; the data chunk only starts there.
    +int32_t audioWavParse(const uint8_t *head, uint32_t headBytes, uint32_t fileBytes, AudioFormatT *fmt, uint32_t *dataAt, uint32_t *dataBytes) {
    +    const uint8_t *fmtChunk = NULL;
    +    uint32_t       fmtBytes = 0;
    +    bool           haveData = false;
    +    uint32_t       pos      = RIFF_HEADER_BYTES;
    +
    +    *dataAt    = 0;
    +    *dataBytes = 0;
    +
    +    if (headBytes < RIFF_HEADER_BYTES || headBytes > fileBytes || memcmp(head, "RIFF", CHUNK_ID_BYTES) != 0 || memcmp(head + RIFF_FORM_OFFSET, "WAVE", CHUNK_ID_BYTES) != 0) {
    +        return AUDIO_ERR_FORMAT;
    +    }
    +
    +    while (pos + CHUNK_HEADER_BYTES <= headBytes && !(fmtChunk && haveData)) {
    +        const uint8_t *id    = head + pos;
    +        uint32_t       len   = rd32(head + pos + CHUNK_SIZE_OFFSET);
    +        uint32_t       avail = fileBytes - pos - CHUNK_HEADER_BYTES;
    +
    +        if (memcmp(id, "data", CHUNK_ID_BYTES) == 0) {
    +            *dataAt    = pos + CHUNK_HEADER_BYTES;
    +            *dataBytes = len < avail ? len : avail;
    +            haveData   = true;
    +        } else if (len > avail) {
    +            return AUDIO_ERR_CORRUPT;
    +        } else if (memcmp(id, "fmt ", CHUNK_ID_BYTES) == 0) {
    +            if (len > headBytes - pos - CHUNK_HEADER_BYTES) {
    +                return AUDIO_ERR_CORRUPT;
    +            }
    +
    +            fmtChunk = id + CHUNK_HEADER_BYTES;
    +            fmtBytes = len;
    +        }
    +
    +        if (len >= avail) {
    +            break;
    +        }
    +
    +        pos += CHUNK_HEADER_BYTES + len + (len & 1);
    +    }
    +
    +    if (!fmtChunk || !haveData || fmtBytes < WAV_FMT_MIN_BYTES) {
    +        return AUDIO_ERR_CORRUPT;
    +    }
    +
    +    uint32_t tag = rd16(fmtChunk);
    +
    +    if (tag == WAV_TAG_EXTENSIBLE && fmtBytes >= WAV_EXT_FMT_BYTES) {
    +        tag = rd16(fmtChunk + WAV_EXT_SUBFORMAT);
    +    }
    +
    +    fmt->channels = rd16(fmtChunk + WAV_FMT_CHANNELS);
    +    fmt->rate     = rd32(fmtChunk + WAV_FMT_RATE);
    +    fmt->bits     = rd16(fmtChunk + WAV_FMT_BITS);
    +
    +    if (tag != WAV_TAG_PCM || checkFormat(fmt) != AUDIO_OK) {
    +        return AUDIO_ERR_UNSUPPORTED;
    +    }
    +
    +    uint32_t frameBytes = fmt->channels * fmt->bits / BITS_PER_BYTE;
    +
    +    if (rd16(fmtChunk + WAV_FMT_BLOCK_ALIGN) != frameBytes || *dataBytes < frameBytes) {
    +        return AUDIO_ERR_CORRUPT;
    +    }
    +
    +    *dataBytes = *dataBytes / frameBytes * frameBytes;
    +    return AUDIO_OK;
    +}
    +
    +
     static int32_t checkFormat(const AudioFormatT *fmt) {
         if (fmt->channels < 1 || fmt->channels > CHANNELS_MAX || (fmt->bits != BITS_8 && fmt->bits != BITS_16) || fmt->rate < AUDIO_RATE_MIN || fmt->rate > AUDIO_RATE_MAX) {
             return AUDIO_ERR_UNSUPPORTED;
    @@ -317,59 +392,15 @@ static int32_t decodeVoc(const uint8_t *data, uint32_t size, int16_t **pcm, uint
     
     
     static int32_t decodeWav(const uint8_t *data, uint32_t size, int16_t **pcm, uint32_t *frames, AudioFormatT *fmt) {
    -    const uint8_t *fmtChunk  = NULL;
    -    const uint8_t *dataChunk = NULL;
    -    uint32_t       fmtBytes  = 0;
    -    uint32_t       dataBytes = 0;
    -    uint32_t       pos       = RIFF_HEADER_BYTES;
    +    uint32_t dataAt;
    +    uint32_t dataBytes;
    +    int32_t  rc = audioWavParse(data, size, size, fmt, &dataAt, &dataBytes);
     
    -    while (size - pos >= CHUNK_HEADER_BYTES && !(fmtChunk && dataChunk)) {
    -        const uint8_t *id    = data + pos;
    -        uint32_t       len   = rd32(data + pos + CHUNK_SIZE_OFFSET);
    -        uint32_t       avail = size - pos - CHUNK_HEADER_BYTES;
    -
    -        if (memcmp(id, "data", CHUNK_ID_BYTES) == 0) {
    -            dataChunk = id + CHUNK_HEADER_BYTES;
    -            dataBytes = len < avail ? len : avail;
    -        } else if (len > avail) {
    -            return AUDIO_ERR_CORRUPT;
    -        } else if (memcmp(id, "fmt ", CHUNK_ID_BYTES) == 0) {
    -            fmtChunk = id + CHUNK_HEADER_BYTES;
    -            fmtBytes = len;
    -        }
    -
    -        if (len >= avail) {
    -            break;
    -        }
    -
    -        pos += CHUNK_HEADER_BYTES + len + (len & 1);
    +    if (rc != AUDIO_OK) {
    +        return rc;
         }
     
    -    if (!fmtChunk || !dataChunk || fmtBytes < WAV_FMT_MIN_BYTES) {
    -        return AUDIO_ERR_CORRUPT;
    -    }
    -
    -    uint32_t tag = rd16(fmtChunk);
    -
    -    if (tag == WAV_TAG_EXTENSIBLE && fmtBytes >= WAV_EXT_FMT_BYTES) {
    -        tag = rd16(fmtChunk + WAV_EXT_SUBFORMAT);
    -    }
    -
    -    fmt->channels = rd16(fmtChunk + WAV_FMT_CHANNELS);
    -    fmt->rate     = rd32(fmtChunk + WAV_FMT_RATE);
    -    fmt->bits     = rd16(fmtChunk + WAV_FMT_BITS);
    -
    -    if (tag != WAV_TAG_PCM || checkFormat(fmt) != AUDIO_OK) {
    -        return AUDIO_ERR_UNSUPPORTED;
    -    }
    -
    -    uint32_t frameBytes = fmt->channels * fmt->bits / BITS_PER_BYTE;
    -
    -    if (rd16(fmtChunk + WAV_FMT_BLOCK_ALIGN) != frameBytes || dataBytes < frameBytes) {
    -        return AUDIO_ERR_CORRUPT;
    -    }
    -
    -    uint32_t count = dataBytes / frameBytes * fmt->channels;
    +    uint32_t count = dataBytes / (fmt->bits / BITS_PER_BYTE);
     
         *pcm = (int16_t *)malloc(count * sizeof(int16_t));
     
    @@ -377,7 +408,7 @@ static int32_t decodeWav(const uint8_t *data, uint32_t size, int16_t **pcm, uint
             return AUDIO_ERR_NO_MEMORY;
         }
     
    -    audioConvert(dataChunk, *pcm, count, fmt->bits);
    +    audioConvert(data + dataAt, *pcm, count, fmt->bits);
         *frames = count / fmt->channels;
         return AUDIO_OK;
     }
    diff --git a/src/libs/kpunch/audio/audioMidiIn.c b/src/libs/kpunch/audio/audioMidiIn.c
    new file mode 100644
    index 00000000..0c54a8c3
    --- /dev/null
    +++ b/src/libs/kpunch/audio/audioMidiIn.c
    @@ -0,0 +1,294 @@
    +// 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.
    +
    +// audioMidiIn.c  -- MIDI input (see audio.h, "MIDI input")
    +//
    +// A timer at AUDIO_MIDI_IN_HZ (one call a millisecond) reads what the
    +// MIDI port received (its driver's get) into a ring of bytes and the
    +// times they came, counted by the timer itself; the program side puts
    +// the bytes together into messages (audioMidiInRead): running status
    +// filled in, the data bytes each status takes, real-time bytes (which
    +// may come between any two) dropped, system exclusive skipped to its
    +// end.  The timer and the ring are pinned: the timer runs inside the
    +// timer interrupt.
    +
    +#include "audioInt.h"
    +
    +#include 
    +
    +#define MIDI_STATUS_BIT     0x80
    +#define MIDI_KIND_MASK      0xF0
    +#define MIDI_PROGRAM        0xC0    // program change: one data byte
    +#define MIDI_PRESSURE       0xD0    // channel pressure: one data byte
    +#define MIDI_SYSTEM         0xF0    // system messages from here
    +#define MIDI_SYSEX          0xF0
    +#define MIDI_TIME_CODE      0xF1    // one data byte
    +#define MIDI_SONG_POSITION  0xF2    // two
    +#define MIDI_SONG_SELECT    0xF3    // one
    +#define MIDI_SYSEX_END      0xF7
    +#define MIDI_REALTIME       0xF8    // real-time bytes from here
    +#define MIDI_MSG_MAX        3
    +#define POLL_BYTES_MAX      8       // read per timer call at most (a cable brings about 3 a millisecond)
    +#define MS_PER_SEC          1000
    +#define TIMER_NONE          (-1)
    +
    +// The bytes the timer read and when (AUDIO_MIDI_IN_BYTES, a power of two)
    +typedef struct {
    +    uint8_t           data[AUDIO_MIDI_IN_BYTES];
    +    uint32_t          time[AUDIO_MIDI_IN_BYTES];
    +    AudioMidiGetFnT   get;
    +    volatile uint32_t head;         // bytes the timer stored
    +    volatile uint32_t tail;         // bytes the program side took
    +    volatile uint32_t lost;         // bytes dropped: the ring was full
    +    volatile uint32_t ms;           // the timer's milliseconds since open
    +} MidiInRingT;
    +
    +// The message being put together (program side)
    +typedef struct {
    +    uint8_t  bytes[MIDI_MSG_MAX];
    +    uint32_t ms;                    // when its first byte came
    +    uint8_t  have;
    +    uint8_t  need;                  // data bytes its status takes
    +    uint8_t  running;               // the running status (0: none)
    +    bool     sysex;                 // inside a system exclusive message
    +} MidiInMsgT;
    +
    +static MidiInRingT sIn;
    +static MidiInMsgT  sMsg;
    +static AudioCtxT  *sInOwner  = NULL;
    +static int32_t     sInTimer  = TIMER_NONE;
    +static bool        sInLocked = false;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint32_t        audioMidiInClock(const AudioCtxT *ctx);
    +void            audioMidiInClose(AudioCtxT *ctx);
    +void            audioMidiInCtxClosed(const AudioCtxT *ctx);
    +uint32_t        audioMidiInLost(const AudioCtxT *ctx);
    +int32_t         audioMidiInOpen(AudioCtxT *ctx);
    +bool            audioMidiInOwned(void);
    +bool            audioMidiInRead(AudioCtxT *ctx, AudioMidiMsgT *msg);
    +void            audioMidiInSuspend(void);
    +static uint8_t  dataBytes(uint8_t status);
    +static void     inPoll(void *unused);
    +static bool     msgDone(AudioMidiMsgT *msg);
    +
    +
    +uint32_t audioMidiInClock(const AudioCtxT *ctx) {
    +    return (ctx && ctx == sInOwner) ? sIn.ms : 0;
    +}
    +
    +
    +void audioMidiInClose(AudioCtxT *ctx) {
    +    if (!ctx || ctx != sInOwner) {
    +        return;
    +    }
    +
    +    platformTimerRemove(sInTimer);
    +    sInTimer = TIMER_NONE;
    +    sInOwner = NULL;
    +    dvxLog("Audio: MIDI input closed (%lu bytes lost)", (unsigned long)sIn.lost);
    +}
    +
    +
    +void audioMidiInCtxClosed(const AudioCtxT *ctx) {
    +    if (ctx && ctx == sInOwner) {
    +        audioMidiInClose(sInOwner);
    +    }
    +}
    +
    +
    +uint32_t audioMidiInLost(const AudioCtxT *ctx) {
    +    return (ctx && ctx == sInOwner) ? sIn.lost : 0;
    +}
    +
    +
    +// The ring and the timer's code stay pinned once pinned (a few KB).
    +int32_t audioMidiInOpen(AudioCtxT *ctx) {
    +    if (!ctx) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    AudioMidiGetFnT get = audioMidiInPort();
    +
    +    if (!get) {
    +        return AUDIO_ERR_NO_SYNTH;
    +    }
    +
    +    if (sInOwner) {
    +        return sInOwner == ctx ? AUDIO_OK : AUDIO_ERR_BUSY;
    +    }
    +
    +    if (!sInLocked) {
    +        const AudioLockItemT items[] = {
    +            { (const void *)inPoll, 0 },
    +            { &sIn,                 sizeof(sIn) }
    +        };
    +
    +        if (!audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), true)) {
    +            return AUDIO_ERR_NO_MEMORY;
    +        }
    +
    +        sInLocked = true;
    +    }
    +
    +    memset(&sIn, 0, sizeof(sIn));
    +    memset(&sMsg, 0, sizeof(sMsg));
    +    sIn.get  = get;
    +    sInTimer = platformTimerAdd(AUDIO_MIDI_IN_HZ, inPoll, NULL, 0);
    +
    +    if (sInTimer < 0) {
    +        sInTimer = TIMER_NONE;
    +        return AUDIO_ERR_BUSY;
    +    }
    +
    +    sInOwner = ctx;
    +    dvxLog("Audio: MIDI input open");
    +    return AUDIO_OK;
    +}
    +
    +
    +bool audioMidiInOwned(void) {
    +    return sInOwner != NULL;
    +}
    +
    +
    +// The bytes that came, put together into the next whole message.
    +bool audioMidiInRead(AudioCtxT *ctx, AudioMidiMsgT *msg) {
    +    if (!ctx || ctx != sInOwner || !msg) {
    +        return false;
    +    }
    +
    +    while (sIn.tail != sIn.head) {
    +        uint32_t at   = sIn.tail & (AUDIO_MIDI_IN_BYTES - 1);
    +        uint8_t  byte = sIn.data[at];
    +        uint32_t ms   = sIn.time[at];
    +
    +        sIn.tail++;
    +
    +        if (byte >= MIDI_REALTIME) {
    +            continue;
    +        }
    +
    +        if (byte & MIDI_STATUS_BIT) {
    +            sMsg.sysex   = byte == MIDI_SYSEX;
    +            sMsg.running = byte < MIDI_SYSTEM ? byte : 0;
    +            sMsg.have    = 0;
    +
    +            if (byte == MIDI_SYSEX || byte == MIDI_SYSEX_END) {
    +                continue;
    +            }
    +
    +            sMsg.bytes[sMsg.have++] = byte;
    +            sMsg.need               = dataBytes(byte);
    +            sMsg.ms                 = ms;
    +
    +            if (sMsg.need == 0) {
    +                return msgDone(msg);
    +            }
    +
    +            continue;
    +        }
    +
    +        if (sMsg.sysex) {
    +            continue;
    +        }
    +
    +        // A data byte with no status before it takes the running status.
    +        if (sMsg.have == 0) {
    +            if (!sMsg.running) {
    +                continue;
    +            }
    +
    +            sMsg.bytes[sMsg.have++] = sMsg.running;
    +            sMsg.need               = dataBytes(sMsg.running);
    +            sMsg.ms                 = ms;
    +        }
    +
    +        sMsg.bytes[sMsg.have++] = byte;
    +
    +        if (sMsg.have == sMsg.need + 1) {
    +            return msgDone(msg);
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +// A DOS program is about to run (audioSuspend): reading stops.
    +void audioMidiInSuspend(void) {
    +    audioMidiInClose(sInOwner);
    +}
    +
    +
    +// The data bytes after a status byte (system exclusive aside).
    +static uint8_t dataBytes(uint8_t status) {
    +    uint8_t kind = status & MIDI_KIND_MASK;
    +
    +    if (status < MIDI_SYSTEM) {
    +        return (kind == MIDI_PROGRAM || kind == MIDI_PRESSURE) ? 1 : 2;
    +    }
    +
    +    if (status == MIDI_TIME_CODE || status == MIDI_SONG_SELECT) {
    +        return 1;
    +    }
    +
    +    return status == MIDI_SONG_POSITION ? 2 : 0;
    +}
    +
    +
    +// The timer: what the port has, with the time it came.  Interrupt time.
    +static void inPoll(void *unused) {
    +    uint8_t byte;
    +
    +    (void)unused;
    +    sIn.ms += MS_PER_SEC / AUDIO_MIDI_IN_HZ;
    +
    +    for (int32_t i = 0; i < POLL_BYTES_MAX && sIn.get(&byte); i++) {
    +        if (sIn.head - sIn.tail >= AUDIO_MIDI_IN_BYTES) {
    +            sIn.lost++;
    +            continue;
    +        }
    +
    +        uint32_t at = sIn.head & (AUDIO_MIDI_IN_BYTES - 1);
    +
    +        sIn.data[at] = byte;
    +        sIn.time[at] = sIn.ms;
    +        sIn.head++;
    +    }
    +}
    +
    +
    +// The message put together, handed out; the next starts empty (running
    +// status stays).
    +static bool msgDone(AudioMidiMsgT *msg) {
    +    msg->ms     = sMsg.ms;
    +    msg->status = sMsg.bytes[0];
    +    msg->data1  = sMsg.have > 1 ? sMsg.bytes[1] : 0;
    +    msg->data2  = sMsg.have > 2 ? sMsg.bytes[2] : 0;
    +    msg->length = sMsg.have;
    +    sMsg.have   = 0;
    +    return true;
    +}
    diff --git a/src/libs/kpunch/audio/audioMix.c b/src/libs/kpunch/audio/audioMix.c
    index b7b4794d..70b5b8da 100644
    --- a/src/libs/kpunch/audio/audioMix.c
    +++ b/src/libs/kpunch/audio/audioMix.c
    @@ -70,7 +70,8 @@ typedef struct {
         uint8_t  buf[CLOCK_MAX_FRAMES * CLOCK_FRAME_BYTES];    // output nobody plays
     } MixClockT;
     
    -AudioMixT audioMixer;
    +AudioMixT       audioMixer;
    +AudioRecRingT *volatile audioRecRing = NULL;
     
     static MixClockT sClock = { .timerId = CLOCK_TIMER_NONE };
     
    @@ -85,12 +86,15 @@ bool           audioMixLock(bool lock);
     void           audioMixPrime(MixVoiceT *v);
     void           audioMixRender(void *dst, uint32_t frames);
     void           audioMixReset(uint32_t rate, uint32_t bits, uint32_t channels, AudioToneFnT hwTone);
    +void           audioRecPush(const void *src, uint32_t bytes);
     static int32_t clipSample(int32_t s);
     static void    clockTick(void *unused);
     static void    endVoice(MixVoiceT *v, AudioEndE reason);
     static bool    loadNext(MixVoiceT *v);
     static void    mixNotes(MixVoiceT *v, int32_t *acc, uint32_t n);
    +static void    mixSample(MixVoiceT *v, int32_t *acc, uint32_t n);
     static void    mixSource(MixVoiceT *v, int32_t *acc, uint32_t n);
    +static void    mixStraight(MixVoiceT *v, int32_t *acc, uint32_t n);
     static void    mixTone(MixVoiceT *v, int32_t *acc, uint32_t n);
     static int32_t toneSample(MixVoiceT *v);
     static void    toneSelect(void);
    @@ -142,18 +146,22 @@ bool audioLockSpan(const AudioLockItemT *items, int32_t count, bool lock) {
     bool audioMixLock(bool lock) {
         const AudioLockItemT items[] = {
             { (const void *)audioMixRender, 0 },
    +        { (const void *)audioRecPush,   0 },
             { (const void *)clipSample,     0 },
             { (const void *)clockTick,      0 },
             { (const void *)endVoice,       0 },
             { (const void *)loadNext,       0 },
             { (const void *)mixNotes,       0 },
             { (const void *)mixSource,      0 },
    +        { (const void *)mixStraight,    0 },
    +        { (const void *)mixSample,      0 },
             { (const void *)mixTone,        0 },
             { (const void *)toneSample,     0 },
             { (const void *)toneSelect,     0 },
             { (const void *)writeOut,       0 },
             { &audioMixer,                  sizeof(audioMixer) },
    -        { &sClock,                      sizeof(sClock) }
    +        { &sClock,                      sizeof(sClock) },
    +        { (const void *)&audioRecRing, sizeof(audioRecRing) }
         };
     
         return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    @@ -198,7 +206,7 @@ void audioMixRender(void *dst, uint32_t frames) {
             for (int32_t i = 0; i < AUDIO_VOICES_MAX; i++) {
                 MixVoiceT *v = &audioMixer.voices[i];
     
    -            if (v->state != MIX_PLAYING) {
    +            if (v->state != MIX_PLAYING || v->paused) {
                     continue;
                 }
     
    @@ -206,6 +214,8 @@ void audioMixRender(void *dst, uint32_t frames) {
                     mixTone(v, audioMixer.acc, n);
                 } else if (v->kind == MIX_KIND_NOTES) {
                     mixNotes(v, audioMixer.acc, n);
    +            } else if (v->kind == MIX_KIND_SAMPLE) {
    +                mixSample(v, audioMixer.acc, n);
                 } else {
                     mixSource(v, audioMixer.acc, n);
                 }
    @@ -240,6 +250,31 @@ void audioMixReset(uint32_t rate, uint32_t bits, uint32_t channels, AudioToneFnT
     }
     
     
    +// Copied whole into the recording's ring, or dropped whole when it does
    +// not fit (lost), so the ring never holds part of a frame.
    +void audioRecPush(const void *src, uint32_t bytes) {
    +    AudioRecRingT *r = audioRecRing;
    +
    +    if (!r) {
    +        return;
    +    }
    +
    +    uint32_t       head = r->head;
    +    const uint8_t *s    = (const uint8_t *)src;
    +
    +    if (bytes > r->mask + 1 - (head - r->tail)) {
    +        r->lost += bytes;
    +        return;
    +    }
    +
    +    for (uint32_t i = 0; i < bytes; i++) {
    +        r->data[(head + i) & r->mask] = s[i];
    +    }
    +
    +    r->head = head + bytes;
    +}
    +
    +
     static int32_t clipSample(int32_t s) {
         if (s > MIX_SAMPLE_MAX) {
             audioMixer.clipped++;
    @@ -277,7 +312,9 @@ static void endVoice(MixVoiceT *v, AudioEndE reason) {
     
     // Fetch the source frame after next into next.  False when a sample that
     // does not loop has no more frames; a stream with nothing buffered gives
    -// silence (counted as an underrun once it has had samples).
    +// silence (counted as an underrun once it has had samples).  A source has
    +// one or two channels (audio.c checks): a frame's index is a shift, not
    +// a multiply.
     static bool loadNext(MixVoiceT *v) {
         if (v->kind == MIX_KIND_STREAM) {
             AudioRingT *r = v->ring;
    @@ -293,7 +330,7 @@ static bool loadNext(MixVoiceT *v) {
                 return true;
             }
     
    -        const int16_t *f = &r->data[(r->tail & r->mask) * r->channels];
    +        const int16_t *f = &r->data[(r->tail & r->mask) << (r->channels - 1)];
     
             v->nextL = f[0];
             v->nextR = f[r->channels - 1];
    @@ -309,7 +346,7 @@ static bool loadNext(MixVoiceT *v) {
             v->pos = 0;
         }
     
    -    const int16_t *f = &v->data[v->pos * v->channels];
    +    const int16_t *f = &v->data[v->pos << (v->channels - 1)];
     
         v->nextL = f[0];
         v->nextR = f[v->channels - 1];
    @@ -360,36 +397,208 @@ static void mixNotes(MixVoiceT *v, int32_t *acc, uint32_t n) {
     }
     
     
    -static void mixSource(MixVoiceT *v, int32_t *acc, uint32_t n) {
    -    int32_t  gainL = v->gainL;
    -    int32_t  gainR = v->gainR;
    -    uint32_t step  = v->step;
    +// A sample (see the top): as mixSource, with its data walked by a
    +// pointer instead of a loadNext call a source frame; what it mixes and
    +// where it leaves the voice are the same.  One at the output rate is
    +// mixStraight's.
    +static void mixSample(MixVoiceT *v, int32_t *acc, uint32_t n) {
    +    uint32_t step = v->step;
    +
    +    if (step == MIX_FRAC_ONE && v->frac == 0) {
    +        mixStraight(v, acc, n);
    +        return;
    +    }
    +
    +    int32_t        gainL    = v->gainL;
    +    int32_t        gainR    = v->gainR;
    +    uint32_t       frac     = v->frac;
    +    uint32_t       shift    = v->channels - 1u;
    +    const int16_t *data     = v->data;
    +    const int16_t *end      = data + (v->frames << shift);
    +    const int16_t *p        = data + (v->pos << shift);
    +    int32_t        curL     = v->curL;
    +    int32_t        curR     = v->curR;
    +    int32_t        nextL    = v->nextL;
    +    int32_t        nextR    = v->nextR;
    +    bool           draining = v->draining;
    +    bool           mono     = shift == 0;
    +    bool           shared   = mono && gainL == gainR;
     
         for (uint32_t i = 0; i < n; i++) {
    -        int32_t w = (int32_t)(v->frac >> (MIX_FRAC_BITS - MIX_LERP_SHIFT));
    -        int32_t l = v->curL + (((v->nextL - v->curL) * w) >> MIX_LERP_SHIFT);
    -        int32_t r = v->curR + (((v->nextR - v->curR) * w) >> MIX_LERP_SHIFT);
    +        int32_t w = (int32_t)(frac >> (MIX_FRAC_BITS - MIX_LERP_SHIFT));
    +        int32_t l = curL;
    +        int32_t r = curR;
     
    -        acc[i * MIX_ACC_CHANNELS]     += l * gainL;
    -        acc[i * MIX_ACC_CHANNELS + 1] += r * gainR;
    -        v->frac                       += step;
    +        if (w) {
    +            l += ((nextL - curL) * w) >> MIX_LERP_SHIFT;
    +            r  = mono ? l : r + (((nextR - curR) * w) >> MIX_LERP_SHIFT);
    +        }
     
    -        while (v->frac >= MIX_FRAC_ONE) {
    -            v->frac -= MIX_FRAC_ONE;
    +        if (shared) {
    +            int32_t g = l * gainL;
    +
    +            acc[i * MIX_ACC_CHANNELS]     += g;
    +            acc[i * MIX_ACC_CHANNELS + 1] += g;
    +        } else {
    +            acc[i * MIX_ACC_CHANNELS]     += l * gainL;
    +            acc[i * MIX_ACC_CHANNELS + 1] += r * gainR;
    +        }
    +
    +        frac += step;
    +
    +        while (frac >= MIX_FRAC_ONE) {
    +            frac -= MIX_FRAC_ONE;
    +
    +            if (draining) {
    +                endVoice(v, AUDIO_END_FINISHED);
    +                return;
    +            }
    +
    +            curL = nextL;
    +            curR = nextR;
    +
    +            if (p >= end) {
    +                if (!v->loop) {
    +                    draining = true;
    +                    continue;
    +                }
    +
    +                p = data;
    +            }
    +
    +            nextL  = p[0];
    +            nextR  = p[shift];
    +            p     += shift + 1;
    +        }
    +    }
    +
    +    v->frac     = frac;
    +    v->pos      = (uint32_t)(p - data) >> shift;
    +    v->curL     = curL;
    +    v->curR     = curR;
    +    v->nextL    = nextL;
    +    v->nextR    = nextR;
    +    v->draining = draining;
    +}
    +
    +
    +// A stream (see the top), its position kept in locals for the chunk.  The
    +// multiplies are what a 486 feels, so none is done whose result is known:
    +// a weight of 0 (a stream at the output rate never has another) leaves
    +// cur as it is, a mono source's right is its left, and a mono source with
    +// equal gains (centred) is scaled once.
    +static void mixSource(MixVoiceT *v, int32_t *acc, uint32_t n) {
    +    int32_t  gainL  = v->gainL;
    +    int32_t  gainR  = v->gainR;
    +    uint32_t step   = v->step;
    +    uint32_t frac   = v->frac;
    +    int32_t  curL   = v->curL;
    +    int32_t  curR   = v->curR;
    +    bool     mono   = v->channels == 1;
    +    bool     shared = mono && gainL == gainR;
    +
    +    for (uint32_t i = 0; i < n; i++) {
    +        int32_t w = (int32_t)(frac >> (MIX_FRAC_BITS - MIX_LERP_SHIFT));
    +        int32_t l = curL;
    +        int32_t r = curR;
    +
    +        if (w) {
    +            l += ((v->nextL - curL) * w) >> MIX_LERP_SHIFT;
    +            r  = mono ? l : r + (((v->nextR - curR) * w) >> MIX_LERP_SHIFT);
    +        }
    +
    +        if (shared) {
    +            int32_t g = l * gainL;
    +
    +            acc[i * MIX_ACC_CHANNELS]     += g;
    +            acc[i * MIX_ACC_CHANNELS + 1] += g;
    +        } else {
    +            acc[i * MIX_ACC_CHANNELS]     += l * gainL;
    +            acc[i * MIX_ACC_CHANNELS + 1] += r * gainR;
    +        }
    +
    +        frac += step;
    +
    +        while (frac >= MIX_FRAC_ONE) {
    +            frac -= MIX_FRAC_ONE;
     
                 if (v->draining) {
                     endVoice(v, AUDIO_END_FINISHED);
                     return;
                 }
     
    -            v->curL = v->nextL;
    -            v->curR = v->nextR;
    +            curL = v->nextL;
    +            curR = v->nextR;
     
                 if (!loadNext(v)) {
                     v->draining = true;
                 }
             }
         }
    +
    +    v->frac = frac;
    +    v->curL = curL;
    +    v->curR = curR;
    +}
    +
    +
    +// A sample at the output rate (step 1.0 and no fraction: each output
    +// frame is a source frame, no weights): mixSample's loop without the
    +// position arithmetic, which a 486 feels at every frame (registers run
    +// out).  Its fetch is mixSample's.
    +static void mixStraight(MixVoiceT *v, int32_t *acc, uint32_t n) {
    +    int32_t        gainL    = v->gainL;
    +    int32_t        gainR    = v->gainR;
    +    uint32_t       shift    = v->channels - 1u;
    +    const int16_t *data     = v->data;
    +    const int16_t *end      = data + (v->frames << shift);
    +    const int16_t *p        = data + (v->pos << shift);
    +    int32_t        curL     = v->curL;
    +    int32_t        curR     = v->curR;
    +    int32_t        nextL    = v->nextL;
    +    int32_t        nextR    = v->nextR;
    +    bool           draining = v->draining;
    +    bool           shared   = shift == 0 && gainL == gainR;
    +
    +    for (uint32_t i = 0; i < n; i++) {
    +        if (shared) {
    +            int32_t g = curL * gainL;
    +
    +            acc[i * MIX_ACC_CHANNELS]     += g;
    +            acc[i * MIX_ACC_CHANNELS + 1] += g;
    +        } else {
    +            acc[i * MIX_ACC_CHANNELS]     += curL * gainL;
    +            acc[i * MIX_ACC_CHANNELS + 1] += curR * gainR;
    +        }
    +
    +        if (draining) {
    +            endVoice(v, AUDIO_END_FINISHED);
    +            return;
    +        }
    +
    +        curL = nextL;
    +        curR = nextR;
    +
    +        if (p >= end) {
    +            if (!v->loop) {
    +                draining = true;
    +                continue;
    +            }
    +
    +            p = data;
    +        }
    +
    +        nextL  = p[0];
    +        nextR  = p[shift];
    +        p     += shift + 1;
    +    }
    +
    +    v->pos      = (uint32_t)(p - data) >> shift;
    +    v->curL     = curL;
    +    v->curR     = curR;
    +    v->nextL    = nextL;
    +    v->nextR    = nextR;
    +    v->draining = draining;
     }
     
     
    diff --git a/src/libs/kpunch/audio/audioRec.c b/src/libs/kpunch/audio/audioRec.c
    new file mode 100644
    index 00000000..45a26ec0
    --- /dev/null
    +++ b/src/libs/kpunch/audio/audioRec.c
    @@ -0,0 +1,473 @@
    +// 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.
    +
    +// audioRec.c  -- recording (see audio.h, "Recording")
    +//
    +// One recording at a time, for the whole of DVX.  The driver's interrupt
    +// hands each block it recorded to audioRecPush (audioMix.c, pinned with
    +// the mixer), which only copies the bytes into the recording's ring; this
    +// side takes them out (audioRecRead, or, for a recording into a file,
    +// audioRecIdle writing them there from audioPump) and finds the loudest
    +// sample on the way.
    +//
    +// The card records or plays, not both: audio.c's audioDevRecordBegin
    +// closes the output and keeps new voices out while the recording runs;
    +// audioRecClose gives the card back (the driver's recordStop) and the
    +// voices that were held play on.
    +
    +#include "audioInt.h"
    +
    +#include 
    +#include 
    +
    +// The ring holds the buffer asked for and a quarter more (the card's rate
    +// may be a little above the one asked)
    +#define RING_MARGIN_NUM      5
    +#define RING_MARGIN_DEN      4
    +
    +#define FILE_COPY_BYTES      4096        // moved to the file per write
    +#define MS_PER_SEC           1000
    +#define BITS_PER_BYTE        8
    +#define BITS_8               8
    +#define BITS_16              16
    +#define CHANNELS_MONO        1
    +#define CHANNELS_STEREO      2
    +#define PEAK_FULL            32767
    +#define PERCENT              100
    +#define U8_BIAS              128
    +#define U8_SCALE             256         // an 8-bit sample's step in 16-bit terms
    +#define BYTE_SHIFT           8
    +#define BYTE_MASK            0xFF
    +#define WORD_SHIFT           16
    +#define WORD_MASK            0xFFFF
    +#define WORD_BYTES           2
    +
    +// WAV: RIFF, the fmt chunk (PCM), the data chunk's header
    +#define WAV_RIFF_EXTRA       36          // the RIFF size is the data bytes and this
    +#define WAV_FMT_BYTES        16
    +#define WAV_FORMAT_PCM       1
    +#define WAV_AT_RIFF_SIZE     4
    +#define WAV_AT_WAVE          8
    +#define WAV_AT_FMT           12
    +#define WAV_AT_FMT_SIZE      16
    +#define WAV_AT_FORMAT        20
    +#define WAV_AT_CHANNELS      22
    +#define WAV_AT_RATE          24
    +#define WAV_AT_BYTE_RATE     28
    +#define WAV_AT_ALIGN         32
    +#define WAV_AT_BITS          34
    +#define WAV_AT_DATA          36
    +#define WAV_AT_DATA_SIZE     40
    +#define FOURCC_BYTES         4
    +
    +struct AudioRecT {
    +    AudioRecRingT ring;                 // first: pinned with the struct (the interrupt writes it)
    +    AudioCtxT    *owner;
    +    AudioFormatT  fmt;
    +    FILE         *fp;                   // audioRecFile: where the frames go (else NULL)
    +    uint32_t      fileBytes;            // sound bytes written to fp
    +    uint32_t      taken;                // frames read or written so far
    +    int32_t       peak;                 // the loudest sample taken since audioRecPeak (0..PEAK_FULL)
    +    int32_t       error;                // AUDIO_ERR_WRITE: a write to fp failed
    +};
    +
    +static AudioRecT          *sRec    = NULL;
    +static const AudioDriverT *sRecDrv = NULL;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +int32_t           audioRecClose(AudioRecT *rec);
    +void              audioRecCtxClosed(const AudioCtxT *ctx);
    +int32_t           audioRecFile(AudioCtxT *ctx, const char *path, AudioFormatT *fmt, int32_t source, AudioRecT **out);
    +void              audioRecFormat(const AudioRecT *rec, AudioFormatT *fmt);
    +uint32_t          audioRecFrames(const AudioRecT *rec);
    +void              audioRecIdle(void);
    +uint32_t          audioRecLost(const AudioRecT *rec);
    +int32_t           audioRecOpen(AudioCtxT *ctx, AudioFormatT *fmt, int32_t source, uint32_t bufferMs, AudioRecT **out);
    +int32_t           audioRecPeak(AudioRecT *rec);
    +uint32_t          audioRecQueued(const AudioRecT *rec);
    +uint32_t          audioRecRead(AudioRecT *rec, void *pcm, uint32_t frames);
    +void              audioRecSuspend(void);
    +void              audioWavHeader(uint8_t *out, const AudioFormatT *fmt, uint32_t dataBytes);
    +static uint32_t   frameBytesOf(const AudioFormatT *fmt);
    +static void       put16(uint8_t *p, uint32_t v);
    +static void       put32(uint8_t *p, uint32_t v);
    +static void       putTag(uint8_t *p, const char *tag);
    +static AudioRecT *recAlloc(uint32_t bytes);
    +static int32_t    recBegin(AudioCtxT *ctx, AudioFormatT *fmt, int32_t source, uint32_t bufferMs, const char *path, AudioRecT **out);
    +static void       recDrain(AudioRecT *rec);
    +static void       recFree(AudioRecT *rec);
    +static void       recPeak(AudioRecT *rec, const uint8_t *p, uint32_t bytes);
    +static uint32_t   recTake(AudioRecT *rec, uint8_t *dst, uint32_t bytes);
    +
    +
    +// The card stops first, so nothing is pushed while the ring is emptied
    +// and freed; the held voices play on (audioDevRecordEnd).
    +int32_t audioRecClose(AudioRecT *rec) {
    +    if (!rec) {
    +        return AUDIO_OK;
    +    }
    +
    +    sRecDrv->recordStop();
    +    audioRecRing = NULL;
    +
    +    if (rec->fp) {
    +        uint8_t head[AUDIO_WAV_HEADER_BYTES];
    +
    +        recDrain(rec);
    +        audioWavHeader(head, &rec->fmt, rec->fileBytes);
    +
    +        if (fseek(rec->fp, 0, SEEK_SET) != 0 || fwrite(head, sizeof(head), 1, rec->fp) != 1) {
    +            rec->error = AUDIO_ERR_WRITE;
    +        }
    +
    +        if (fclose(rec->fp) != 0) {
    +            rec->error = AUDIO_ERR_WRITE;
    +        }
    +
    +        rec->fp = NULL;
    +    }
    +
    +    int32_t err = rec->error;
    +
    +    dvxLog("Audio: recording ended: %lu frames, %lu lost", (unsigned long)rec->taken, (unsigned long)audioRecLost(rec));
    +    recFree(rec);
    +    audioDevRecordEnd();
    +    return err;
    +}
    +
    +
    +// A closing context's recording goes with it.
    +void audioRecCtxClosed(const AudioCtxT *ctx) {
    +    if (sRec && sRec->owner == ctx) {
    +        audioRecClose(sRec);
    +    }
    +}
    +
    +
    +int32_t audioRecFile(AudioCtxT *ctx, const char *path, AudioFormatT *fmt, int32_t source, AudioRecT **out) {
    +    *out = NULL;
    +
    +    if (!path || !path[0]) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    return recBegin(ctx, fmt, source, 0, path, out);
    +}
    +
    +
    +void audioRecFormat(const AudioRecT *rec, AudioFormatT *fmt) {
    +    *fmt = rec->fmt;
    +}
    +
    +
    +uint32_t audioRecFrames(const AudioRecT *rec) {
    +    return rec->taken;
    +}
    +
    +
    +// The shell's idle time (audioPump): a recording into a file is written.
    +void audioRecIdle(void) {
    +    if (sRec && sRec->fp) {
    +        recDrain(sRec);
    +    }
    +}
    +
    +
    +uint32_t audioRecLost(const AudioRecT *rec) {
    +    return rec->ring.lost / rec->ring.frameBytes;
    +}
    +
    +
    +int32_t audioRecOpen(AudioCtxT *ctx, AudioFormatT *fmt, int32_t source, uint32_t bufferMs, AudioRecT **out) {
    +    *out = NULL;
    +    return recBegin(ctx, fmt, source, bufferMs, NULL, out);
    +}
    +
    +
    +int32_t audioRecPeak(AudioRecT *rec) {
    +    int32_t peak = rec->peak;
    +
    +    rec->peak = 0;
    +    return peak * PERCENT / PEAK_FULL;
    +}
    +
    +
    +uint32_t audioRecQueued(const AudioRecT *rec) {
    +    return (rec->ring.head - rec->ring.tail) / rec->ring.frameBytes;
    +}
    +
    +
    +uint32_t audioRecRead(AudioRecT *rec, void *pcm, uint32_t frames) {
    +    if (!rec || !pcm || rec->fp) {
    +        return 0;
    +    }
    +
    +    uint32_t got = recTake(rec, (uint8_t *)pcm, frames * rec->ring.frameBytes) / rec->ring.frameBytes;
    +
    +    rec->taken += got;
    +    return got;
    +}
    +
    +
    +// A DOS program is about to run (audioSuspend): the recording ends.
    +void audioRecSuspend(void) {
    +    audioRecClose(sRec);
    +}
    +
    +
    +void audioWavHeader(uint8_t *out, const AudioFormatT *fmt, uint32_t dataBytes) {
    +    uint32_t align = frameBytesOf(fmt);
    +
    +    putTag(out, "RIFF");
    +    put32(out + WAV_AT_RIFF_SIZE, dataBytes + WAV_RIFF_EXTRA);
    +    putTag(out + WAV_AT_WAVE, "WAVE");
    +    putTag(out + WAV_AT_FMT, "fmt ");
    +    put32(out + WAV_AT_FMT_SIZE, WAV_FMT_BYTES);
    +    put16(out + WAV_AT_FORMAT, WAV_FORMAT_PCM);
    +    put16(out + WAV_AT_CHANNELS, fmt->channels);
    +    put32(out + WAV_AT_RATE, fmt->rate);
    +    put32(out + WAV_AT_BYTE_RATE, fmt->rate * align);
    +    put16(out + WAV_AT_ALIGN, align);
    +    put16(out + WAV_AT_BITS, fmt->bits);
    +    putTag(out + WAV_AT_DATA, "data");
    +    put32(out + WAV_AT_DATA_SIZE, dataBytes);
    +}
    +
    +
    +static uint32_t frameBytesOf(const AudioFormatT *fmt) {
    +    return fmt->channels * fmt->bits / BITS_PER_BYTE;
    +}
    +
    +
    +static void put16(uint8_t *p, uint32_t v) {
    +    p[0] = (uint8_t)(v & BYTE_MASK);
    +    p[1] = (uint8_t)((v >> BYTE_SHIFT) & BYTE_MASK);
    +}
    +
    +
    +static void put32(uint8_t *p, uint32_t v) {
    +    put16(p, v & WORD_MASK);
    +    put16(p + WORD_BYTES, v >> WORD_SHIFT);
    +}
    +
    +
    +// A chunk's four-character tag (no terminator).
    +static void putTag(uint8_t *p, const char *tag) {
    +    for (int32_t i = 0; i < FOURCC_BYTES; i++) {
    +        p[i] = (uint8_t)tag[i];
    +    }
    +}
    +
    +
    +// A recording with a ring of bytes, both pinned (the interrupt writes
    +// them); NULL when out of memory.
    +static AudioRecT *recAlloc(uint32_t bytes) {
    +    AudioRecT *rec  = (AudioRecT *)calloc(1, sizeof(AudioRecT));
    +    uint8_t   *data = (uint8_t *)malloc(bytes);
    +
    +    if (!rec || !data || platformLockRegion(rec, sizeof(*rec), false) != 0) {
    +        free(data);
    +        free(rec);
    +        return NULL;
    +    }
    +
    +    if (platformLockRegion(data, bytes, false) != 0) {
    +        platformUnlockRegion(rec, sizeof(*rec), false);
    +        free(data);
    +        free(rec);
    +        return NULL;
    +    }
    +
    +    rec->ring.data = data;
    +    rec->ring.mask = bytes - 1;
    +    return rec;
    +}
    +
    +
    +// Fill in what a 0 asks for and check everything that can be checked --
    +// the arguments, no other recording, the card's record source -- before
    +// the file (path, NULL for none) is made, so a recording that cannot
    +// start leaves a file of that name alone.  The ring is set up for the
    +// format asked before the card starts, so no block it records is lost.
    +// The file starts with a header for no sound yet: one cut short (DVX
    +// ended) still reads as an empty WAV file.
    +static int32_t recBegin(AudioCtxT *ctx, AudioFormatT *fmt, int32_t source, uint32_t bufferMs, const char *path, AudioRecT **out) {
    +    AudioFormatT want = *fmt;
    +
    +    want.rate     = want.rate ? want.rate : AUDIO_RATE_DEFAULT;
    +    want.bits     = want.bits ? want.bits : AUDIO_REC_BITS_DEFAULT;
    +    want.channels = want.channels ? want.channels : CHANNELS_MONO;
    +
    +    if (!ctx || source < 0 || source >= AUDIO_REC_SOURCES || want.rate < AUDIO_RATE_MIN || want.rate > AUDIO_RATE_MAX || (want.bits != BITS_8 && want.bits != BITS_16) || want.channels > CHANNELS_STEREO) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    if (sRec) {
    +        return AUDIO_ERR_BUSY;
    +    }
    +
    +    const AudioDriverT *drv = NULL;
    +    int32_t             rc  = audioDevRecordBegin(source, &drv);
    +
    +    if (rc != AUDIO_OK) {
    +        return rc;
    +    }
    +
    +    uint32_t ms    = bufferMs ? bufferMs : AUDIO_REC_MS_DEFAULT;
    +    uint32_t bytes = 1;
    +
    +    ms = ms < AUDIO_REC_MS_MIN ? AUDIO_REC_MS_MIN : (ms > AUDIO_REC_MS_MAX ? AUDIO_REC_MS_MAX : ms);
    +
    +    uint64_t need = (uint64_t)want.rate * frameBytesOf(&want) * ms * RING_MARGIN_NUM / ((uint64_t)MS_PER_SEC * RING_MARGIN_DEN);
    +
    +    while (bytes < need) {
    +        bytes <<= 1;
    +    }
    +
    +    FILE      *fp  = path ? fopen(path, "wb") : NULL;
    +    AudioRecT *rec = (!path || fp) ? recAlloc(bytes) : NULL;
    +
    +    if (!rec) {
    +        if (fp) {
    +            fclose(fp);
    +            remove(path);
    +        }
    +
    +        audioDevRecordEnd();
    +        return path && !fp ? AUDIO_ERR_WRITE : AUDIO_ERR_NO_MEMORY;
    +    }
    +
    +    rec->owner   = ctx;
    +    rec->fp      = fp;
    +    sRec         = rec;
    +    sRecDrv      = drv;
    +    audioRecRing = &rec->ring;
    +
    +    if (!drv->recordStart(&want, source, &rec->fmt)) {
    +        audioRecRing = NULL;
    +        recFree(rec);
    +
    +        if (fp) {
    +            fclose(fp);
    +            remove(path);
    +        }
    +
    +        audioDevRecordEnd();
    +        dvxLog("Audio: the sound device did not start recording");
    +        return AUDIO_ERR_NO_RECORD;
    +    }
    +
    +    rec->ring.frameBytes = frameBytesOf(&rec->fmt);
    +
    +    if (fp) {
    +        uint8_t head[AUDIO_WAV_HEADER_BYTES];
    +
    +        audioWavHeader(head, &rec->fmt, 0);
    +
    +        if (fwrite(head, sizeof(head), 1, fp) != 1) {
    +            rec->fp = NULL;
    +            audioRecClose(rec);
    +            fclose(fp);
    +            remove(path);
    +            return AUDIO_ERR_WRITE;
    +        }
    +    }
    +
    +    *fmt = rec->fmt;
    +    *out = rec;
    +    dvxLog("Audio: recording at %lu Hz, %lu-bit, %s", (unsigned long)rec->fmt.rate, (unsigned long)rec->fmt.bits, rec->fmt.channels == CHANNELS_STEREO ? "stereo" : "mono");
    +    return AUDIO_OK;
    +}
    +
    +
    +// Everything recorded so far into the file.
    +static void recDrain(AudioRecT *rec) {
    +    static uint8_t buf[FILE_COPY_BYTES];
    +    uint32_t       n;
    +
    +    while ((n = recTake(rec, buf, sizeof(buf))) > 0) {
    +        if (rec->error == AUDIO_OK && fwrite(buf, n, 1, rec->fp) != 1) {
    +            rec->error = AUDIO_ERR_WRITE;
    +        }
    +
    +        rec->fileBytes += n;
    +        rec->taken     += n / rec->ring.frameBytes;
    +    }
    +}
    +
    +
    +static void recFree(AudioRecT *rec) {
    +    platformUnlockRegion(rec->ring.data, rec->ring.mask + 1, false);
    +    platformUnlockRegion(rec, sizeof(*rec), false);
    +    free(rec->ring.data);
    +    free(rec);
    +
    +    if (sRec == rec) {
    +        sRec = NULL;
    +    }
    +}
    +
    +
    +// The loudest sample of bytes of the recording's format at p, kept.
    +static void recPeak(AudioRecT *rec, const uint8_t *p, uint32_t bytes) {
    +    int32_t peak = rec->peak;
    +
    +    if (rec->fmt.bits == BITS_16) {
    +        for (uint32_t i = 0; i + 1 < bytes; i += WORD_BYTES) {
    +            int32_t v = (int16_t)(p[i] | (p[i + 1] << BYTE_SHIFT));
    +
    +            v    = v < 0 ? -v : v;
    +            peak = v > peak ? v : peak;
    +        }
    +    } else {
    +        for (uint32_t i = 0; i < bytes; i++) {
    +            int32_t v = ((int32_t)p[i] - U8_BIAS) * U8_SCALE;
    +
    +            v    = v < 0 ? -v : v;
    +            peak = v > peak ? v : peak;
    +        }
    +    }
    +
    +    rec->peak = peak > PEAK_FULL ? PEAK_FULL : peak;
    +}
    +
    +
    +// Up to bytes of whole frames out of the ring into dst.
    +static uint32_t recTake(AudioRecT *rec, uint8_t *dst, uint32_t bytes) {
    +    AudioRecRingT *r     = &rec->ring;
    +    uint32_t       tail  = r->tail;
    +    uint32_t       avail = r->head - tail;
    +    uint32_t       n     = bytes < avail ? bytes : avail;
    +
    +    n -= n % r->frameBytes;
    +
    +    for (uint32_t i = 0; i < n; i++) {
    +        dst[i] = r->data[(tail + i) & r->mask];
    +    }
    +
    +    recPeak(rec, dst, n);
    +    r->tail = tail + n;
    +    return n;
    +}
    diff --git a/src/libs/kpunch/audio/audioSet.c b/src/libs/kpunch/audio/audioSet.c
    index 7a869d16..05c53016 100644
    --- a/src/libs/kpunch/audio/audioSet.c
    +++ b/src/libs/kpunch/audio/audioSet.c
    @@ -29,10 +29,21 @@
     #define PERCENT_MAX     100
     
     const PrefsChoiceT audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] = {
    -    { "Automatic",            AUDIO_MIDI_AUTO,     0 },
    -    { "MIDI port (MPU-401)",  AUDIO_MIDI_MPU401,   1 },
    -    { "FM synthesizer chip",  AUDIO_MIDI_FM,       2 },
    -    { "Built-in synthesizer", AUDIO_MIDI_SOFTWARE, 3 }
    +    { "Automatic",                      AUDIO_MIDI_AUTO,     0 },
    +    { "MIDI port (MPU-401)",            AUDIO_MIDI_MPU401,   1 },
    +    { "FM synthesizer chip",            AUDIO_MIDI_FM,       2 },
    +    { "Built-in synthesizer",           AUDIO_MIDI_SOFTWARE, 3 },
    +    { "Built-in SoundFont synthesizer", AUDIO_MIDI_SOFTWAVE, 4 }
    +};
    +
    +const PrefsChoiceT audioMidiInChoices[AUDIO_MIDI_IN_CHOICE_COUNT] = {
    +    { "None",      AUDIO_MIDI_IN_NONE, 0 },
    +    { "MIDI port", AUDIO_MIDI_MPU401,  1 }
    +};
    +
    +const PrefsChoiceT audioModulesChoices[AUDIO_MODULES_CHOICE_COUNT] = {
    +    { "Wavetable synthesizer when free", AUDIO_MODULES_WAVETABLE, 0 },
    +    { "Mixed in software",               AUDIO_MODULES_SOFTWARE,  1 }
     };
     
     // ============================================================
    @@ -42,7 +53,10 @@ const PrefsChoiceT audioMidiChoices[AUDIO_MIDI_CHOICE_COUNT] = {
     int32_t        audioPercentToVolume(int32_t percent);
     const char    *audioSettingDriver(PrefsHandleT *prefs);
     int32_t        audioSettingMidi(PrefsHandleT *prefs);
    -void           audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, int32_t volume);
    +int32_t        audioSettingMidiIn(PrefsHandleT *prefs);
    +void           audioSettingMidiInStore(PrefsHandleT *prefs, int32_t midiIn);
    +int32_t        audioSettingModules(PrefsHandleT *prefs);
    +void           audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, int32_t modules, int32_t volume);
     int32_t        audioSettingVolume(PrefsHandleT *prefs);
     static int32_t clampPercent(int32_t percent);
     
    @@ -62,14 +76,38 @@ int32_t audioSettingMidi(PrefsHandleT *prefs) {
     }
     
     
    -void audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, int32_t volume) {
    +int32_t audioSettingMidiIn(PrefsHandleT *prefs) {
    +    return prefsGetChoice(prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI_IN, audioMidiInChoices, AUDIO_MIDI_IN_CHOICE_COUNT, AUDIO_MIDI_IN_NONE);
    +}
    +
    +
    +void audioSettingMidiInStore(PrefsHandleT *prefs, int32_t midiIn) {
    +    if (midiIn < 0 || midiIn >= AUDIO_MIDI_IN_CHOICE_COUNT) {
    +        midiIn = prefsChoiceIndex(audioMidiInChoices, AUDIO_MIDI_IN_CHOICE_COUNT, AUDIO_MIDI_IN_NONE);
    +    }
    +
    +    prefsSetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI_IN, audioMidiInChoices[midiIn].iniName);
    +}
    +
    +
    +int32_t audioSettingModules(PrefsHandleT *prefs) {
    +    return prefsGetChoice(prefs, AUDIO_INI_SECTION, AUDIO_INI_MODULES, audioModulesChoices, AUDIO_MODULES_CHOICE_COUNT, AUDIO_MODULES_WAVETABLE);
    +}
    +
    +
    +void audioSettingsStore(PrefsHandleT *prefs, const char *driver, int32_t midi, int32_t modules, int32_t volume) {
         if (midi < 0 || midi >= AUDIO_MIDI_CHOICE_COUNT) {
             midi = prefsChoiceIndex(audioMidiChoices, AUDIO_MIDI_CHOICE_COUNT, AUDIO_MIDI_AUTO);
         }
     
    +    if (modules < 0 || modules >= AUDIO_MODULES_CHOICE_COUNT) {
    +        modules = prefsChoiceIndex(audioModulesChoices, AUDIO_MODULES_CHOICE_COUNT, AUDIO_MODULES_WAVETABLE);
    +    }
    +
         prefsSetInt(prefs, AUDIO_INI_SECTION, AUDIO_INI_VOLUME, clampPercent(volume));
         prefsSetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_DRIVER, (driver && driver[0]) ? driver : AUDIO_DRIVER_AUTO);
         prefsSetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, audioMidiChoices[midi].iniName);
    +    prefsSetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MODULES, audioModulesChoices[modules].iniName);
     }
     
     
    diff --git a/src/libs/kpunch/audio/audioShell.c b/src/libs/kpunch/audio/audioShell.c
    index e6a0feaf..939e0d06 100644
    --- a/src/libs/kpunch/audio/audioShell.c
    +++ b/src/libs/kpunch/audio/audioShell.c
    @@ -25,7 +25,8 @@
     // Loaded as a module of the shell: the constructor asks the shell for
     // its idle time (end notifications and pull streams) and to be told when
     // a program ends, however it ends (its contexts are closed then), and
    -// connects the desktop sounds; the destructor closes everything
    +// when DVX steps aside for a DOS program (the sound device is let go),
    +// and connects the desktop sounds; the destructor closes everything
     // (stopping the sound device) and takes the hooks back.  Registered while no program is current, so they are the
     // library's own and outlive every program.  Not in the host tests, which
     // call audioIdle and audioAppEnded directly.
    @@ -40,12 +41,14 @@ static void audioShellShutdown(void) __attribute__((destructor));
     static void audioShellInit(void) {
         shellRegisterIdle(audioIdle, NULL);
         shellRegisterAppCleanup(audioAppEnded);
    +    platformSuspendHookAdd(audioSuspend);
         audioDeskInit();
     }
     
     
     static void audioShellShutdown(void) {
         audioShutdown();
    +    platformSuspendHookRemove(audioSuspend);
         shellUnregisterAppCleanup(audioAppEnded);
         shellUnregisterIdle(audioIdle, NULL);
     }
    diff --git a/src/libs/kpunch/audio/audioSynth.c b/src/libs/kpunch/audio/audioSynth.c
    index 66c80bf4..319bb790 100644
    --- a/src/libs/kpunch/audio/audioSynth.c
    +++ b/src/libs/kpunch/audio/audioSynth.c
    @@ -106,7 +106,9 @@ typedef struct {
     
     typedef struct {
         bool      (*put)(uint8_t byte);     // NULL: no MIDI port
    +    bool      (*get)(uint8_t *byte);    // NULL: no MIDI input
         AudioCtxT  *owner;
    +    bool        inWanted;               // DVX.INI chose its input (midiIn)
         char        description[AUDIO_DESC_MAX];
     } MidiPortT;
     
    @@ -131,7 +133,7 @@ int32_t        audioFmToneStart(AudioCtxT *ctx, AudioWaveE wave);
     void           audioFmToneUpdate(AudioCtxT *ctx, int32_t voice, uint32_t hz, int32_t gainL, int32_t gainR);
     int32_t        audioFmWrite(AudioCtxT *ctx, int32_t voice, uint8_t reg, uint8_t value);
     int32_t        audioMidiAcquire(AudioCtxT *ctx);
    -void           audioMidiAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info);
    +void           audioMidiAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info, bool inWanted);
     void           audioMidiDetach(void);
     void           audioMidiGetInfo(AudioMidiInfoT *info);
     int32_t        audioMidiPut(AudioCtxT *ctx, uint8_t byte);
    @@ -413,11 +415,13 @@ int32_t audioMidiAcquire(AudioCtxT *ctx) {
     }
     
     
    -void audioMidiAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info) {
    +void audioMidiAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info, bool inWanted) {
         memset(&sMidi, 0, sizeof(sMidi));
     
         if (drv) {
    -        sMidi.put = drv->put;
    +        sMidi.put      = drv->put;
    +        sMidi.get      = drv->get;
    +        sMidi.inWanted = inWanted;
             snprintf(sMidi.description, sizeof(sMidi.description), "%s", info->description);
         }
     }
    @@ -431,8 +435,17 @@ void audioMidiDetach(void) {
     void audioMidiGetInfo(AudioMidiInfoT *info) {
         memset(info, 0, sizeof(*info));
         snprintf(info->description, sizeof(info->description), "%s", sMidi.description);
    -    info->present = sMidi.put != NULL;
    -    info->owned   = sMidi.owner != NULL;
    +    info->present   = sMidi.put != NULL;
    +    info->owned     = sMidi.owner != NULL;
    +    info->inPresent = audioMidiInPort() != NULL;
    +    info->inOwned   = audioMidiInOwned();
    +}
    +
    +
    +// The port's input (audioMidiIn.c reads it), or NULL: none, or MIDI input
    +// not chosen (DVX.INI [audio] midiIn).
    +AudioMidiGetFnT audioMidiInPort(void) {
    +    return sMidi.inWanted ? sMidi.get : NULL;
     }
     
     
    diff --git a/src/libs/kpunch/audio/audioWt.c b/src/libs/kpunch/audio/audioWt.c
    new file mode 100644
    index 00000000..ef812a9e
    --- /dev/null
    +++ b/src/libs/kpunch/audio/audioWt.c
    @@ -0,0 +1,430 @@
    +// 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.
    +
    +// audioWt.c  -- the wavetable synthesizer: voices and sample memory
    +// shared by every program
    +//
    +// See audio.h ("Wavetable synthesizer").  The voice functions (alloc,
    +// free, start, update, release, stop, active) may run at interrupt time
    +// (music playback's timer callback), so they keep to the interrupt rules:
    +// no calls out of the file but the driver's, interrupts off while they
    +// change the voice table, everything pinned (audioWtLock).  The memory
    +// functions run at program time only.
    +//
    +// A voice id is generation * WT_ID_STRIDE + voice + 1, so an id of a voice
    +// given back never reaches the voice's next owner; free voices go out
    +// least recently freed first.  Sample memory is a list of the blocks in
    +// use, in address order; a new block goes in the first gap it fits,
    +// aligned, and a 16-bit one inside a 256 KB boundary where the chip asks.
    +
    +#include "audioInt.h"
    +
    +#include 
    +#include 
    +
    +#define WT_ID_STRIDE        64
    +#define WT_GEN_MAX          (INT32_MAX / WT_ID_STRIDE - 1)
    +#define WT_BLOCKS_MAX       1024
    +#define WT_ALIGN            32          // bytes a block starts on
    +#define WT_BANK_BYTES       0x40000U    // AUDIO_WT_BANK_256K
    +#define BITS_8              8
    +#define BITS_16             16
    +#define BYTE_BITS           8
    +
    +typedef struct {
    +    AudioCtxT *owner;       // NULL: free
    +    int32_t    id;
    +    uint32_t   freedAt;     // allocation clock when given back (0: never used)
    +} WtVoiceT;
    +
    +typedef struct {
    +    AudioCtxT *owner;
    +    uint32_t   addr;
    +    uint32_t   bytes;
    +} WtBlockT;
    +
    +typedef struct {
    +    const AudioSynthDriverT *drv;       // NULL: no wavetable synthesizer
    +    WtVoiceT                 voices[AUDIO_WT_VOICES_MAX];
    +    uint32_t                 count;
    +    uint32_t                 memory;
    +    uint32_t                 flags;
    +    int32_t                  level;
    +    uint32_t                 clock;
    +    uint32_t                 generation;
    +    char                     description[AUDIO_DESC_MAX];
    +} WtChipT;
    +
    +static WtChipT  sWt;
    +static WtBlockT sBlocks[WT_BLOCKS_MAX];     // in use, by address
    +static uint32_t sBlockCount;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +bool           audioWtActive(const AudioCtxT *ctx, int32_t voice);
    +int32_t        audioWtAlloc(AudioCtxT *ctx);
    +void           audioWtAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info);
    +void           audioWtCtxClosed(AudioCtxT *ctx);
    +void           audioWtDetach(void);
    +int32_t        audioWtFree(const AudioCtxT *ctx, int32_t voice);
    +void           audioWtGetInfo(AudioWtInfoT *info);
    +bool           audioWtLock(bool lock);
    +int32_t        audioWtMemAlloc(AudioCtxT *ctx, uint32_t bytes, uint32_t bits, uint32_t *addr);
    +int32_t        audioWtMemFree(AudioCtxT *ctx, uint32_t addr);
    +void           audioWtNoteInit(AudioWtVoiceT *v);
    +int32_t        audioWtRelease(const AudioCtxT *ctx, int32_t voice);
    +int32_t        audioWtStart(const AudioCtxT *ctx, int32_t voice, const AudioWtVoiceT *v);
    +int32_t        audioWtStop(const AudioCtxT *ctx, int32_t voice);
    +int32_t        audioWtUpdate(const AudioCtxT *ctx, int32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +int32_t        audioWtUpload(AudioCtxT *ctx, uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
    +static int32_t blockOf(const AudioCtxT *ctx, uint32_t addr, uint32_t bytes);
    +static void    giveBack(uint32_t voice);
    +static int32_t lookup(const AudioCtxT *ctx, int32_t voice);
    +static void    removeBlock(uint32_t i);
    +
    +
    +// Interrupt-safe.
    +bool audioWtActive(const AudioCtxT *ctx, int32_t voice) {
    +    int32_t i = lookup(ctx, voice);
    +
    +    return i >= 0 && sWt.drv->active((uint32_t)i);
    +}
    +
    +
    +// Interrupt-safe.
    +int32_t audioWtAlloc(AudioCtxT *ctx) {
    +    if (!ctx) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    if (!sWt.drv) {
    +        return AUDIO_ERR_NO_SYNTH;
    +    }
    +
    +    uint32_t flags = audioIrqOff();
    +    int32_t  best  = -1;
    +    int32_t  rc    = AUDIO_ERR_NO_VOICE;
    +
    +    for (uint32_t i = 0; i < sWt.count; i++) {
    +        const WtVoiceT *v = &sWt.voices[i];
    +
    +        if (!v->owner && (best < 0 || v->freedAt < sWt.voices[best].freedAt)) {
    +            best = (int32_t)i;
    +        }
    +    }
    +
    +    if (best >= 0) {
    +        if (++sWt.generation > WT_GEN_MAX) {
    +            sWt.generation = 1;
    +        }
    +
    +        sWt.voices[best].owner = ctx;
    +        sWt.voices[best].id    = (int32_t)(sWt.generation * WT_ID_STRIDE) + best + 1;
    +        rc                     = sWt.voices[best].id;
    +    }
    +
    +    audioIrqRestore(flags);
    +    return rc;
    +}
    +
    +
    +void audioWtAttach(const AudioSynthDriverT *drv, const AudioSynthDrvInfoT *info) {
    +    memset(&sWt, 0, sizeof(sWt));
    +    sBlockCount = 0;
    +    sWt.drv     = drv;
    +    sWt.count   = info->voices < AUDIO_WT_VOICES_MAX ? info->voices : AUDIO_WT_VOICES_MAX;
    +    sWt.memory  = info->memory;
    +    sWt.flags   = info->flags;
    +    sWt.level   = info->level;
    +    snprintf(sWt.description, sizeof(sWt.description), "%s", info->description);
    +}
    +
    +
    +// Everything ctx held, given back.
    +void audioWtCtxClosed(AudioCtxT *ctx) {
    +    uint32_t flags = audioIrqOff();
    +
    +    for (uint32_t i = 0; i < sWt.count; i++) {
    +        if (sWt.voices[i].owner == ctx) {
    +            giveBack(i);
    +        }
    +    }
    +
    +    audioIrqRestore(flags);
    +
    +    for (uint32_t i = sBlockCount; i > 0; i--) {
    +        if (sBlocks[i - 1].owner == ctx) {
    +            removeBlock(i - 1);
    +        }
    +    }
    +}
    +
    +
    +void audioWtDetach(void) {
    +    memset(&sWt, 0, sizeof(sWt));
    +    sBlockCount = 0;
    +}
    +
    +
    +// Interrupt-safe.
    +int32_t audioWtFree(const AudioCtxT *ctx, int32_t voice) {
    +    uint32_t flags = audioIrqOff();
    +    int32_t  i     = lookup(ctx, voice);
    +
    +    if (i >= 0) {
    +        giveBack((uint32_t)i);
    +    }
    +
    +    audioIrqRestore(flags);
    +    return i >= 0 ? AUDIO_OK : AUDIO_ERR_VOICE;
    +}
    +
    +
    +void audioWtGetInfo(AudioWtInfoT *info) {
    +    uint32_t largest = 0;
    +    uint32_t at      = 0;
    +
    +    memset(info, 0, sizeof(*info));
    +
    +    if (!sWt.drv) {
    +        return;
    +    }
    +
    +    // The largest gap between the blocks in use
    +    for (uint32_t i = 0; i <= sBlockCount; i++) {
    +        uint32_t next = i < sBlockCount ? sBlocks[i].addr : sWt.memory;
    +
    +        largest = next - at > largest ? next - at : largest;
    +        at      = i < sBlockCount ? sBlocks[i].addr + sBlocks[i].bytes : at;
    +    }
    +
    +    snprintf(info->description, sizeof(info->description), "%s", sWt.description);
    +    info->voices     = sWt.count;
    +    info->memory     = sWt.memory;
    +    info->freeMemory = largest;
    +    info->flags      = sWt.flags;
    +    info->level      = sWt.level;
    +    info->present    = true;
    +
    +    for (uint32_t i = 0; i < sWt.count; i++) {
    +        info->freeVoices += sWt.voices[i].owner ? 0 : 1;
    +    }
    +}
    +
    +
    +bool audioWtLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)audioWtActive,  0 },
    +        { (const void *)audioWtAlloc,   0 },
    +        { (const void *)audioWtFree,    0 },
    +        { (const void *)audioWtRelease, 0 },
    +        { (const void *)audioWtStart,   0 },
    +        { (const void *)audioWtStop,    0 },
    +        { (const void *)audioWtUpdate,  0 },
    +        { (const void *)giveBack,       0 },
    +        { (const void *)lookup,         0 },
    +        { &sWt,                         sizeof(sWt) }
    +    };
    +
    +    return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +}
    +
    +
    +// First fit: each gap between the blocks in use, from its aligned start
    +// (moved to the next 256 KB boundary when a 16-bit sample would cross
    +// one and the chip cares).
    +int32_t audioWtMemAlloc(AudioCtxT *ctx, uint32_t bytes, uint32_t bits, uint32_t *addr) {
    +    bool bankRule = (sWt.flags & AUDIO_WT_BANK_256K) && bits == BITS_16;
    +
    +    if (!ctx || !addr || bytes == 0 || (bits != BITS_8 && bits != BITS_16) || (bits == BITS_8 && !(sWt.flags & AUDIO_WT_8BIT))) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    if (!sWt.drv) {
    +        return AUDIO_ERR_NO_SYNTH;
    +    }
    +
    +    if (sBlockCount >= WT_BLOCKS_MAX || (bankRule && bytes > WT_BANK_BYTES)) {
    +        return AUDIO_ERR_NO_ROOM;
    +    }
    +
    +    uint32_t gapStart = 0;
    +
    +    for (uint32_t i = 0; i <= sBlockCount; i++) {
    +        uint32_t gapEnd = i < sBlockCount ? sBlocks[i].addr : sWt.memory;
    +        uint32_t start  = (gapStart + WT_ALIGN - 1) / WT_ALIGN * WT_ALIGN;
    +
    +        if (bankRule && start / WT_BANK_BYTES != (start + bytes - 1) / WT_BANK_BYTES) {
    +            start = (start / WT_BANK_BYTES + 1) * WT_BANK_BYTES;
    +        }
    +
    +        if (start <= gapEnd && bytes <= gapEnd - start) {
    +            memmove(&sBlocks[i + 1], &sBlocks[i], (sBlockCount - i) * sizeof(WtBlockT));
    +            sBlocks[i].owner = ctx;
    +            sBlocks[i].addr  = start;
    +            sBlocks[i].bytes = bytes;
    +            sBlockCount++;
    +            *addr = start;
    +            return AUDIO_OK;
    +        }
    +
    +        gapStart = i < sBlockCount ? sBlocks[i].addr + sBlocks[i].bytes : gapStart;
    +    }
    +
    +    return AUDIO_ERR_NO_ROOM;
    +}
    +
    +
    +int32_t audioWtMemFree(AudioCtxT *ctx, uint32_t addr) {
    +    for (uint32_t i = 0; i < sBlockCount; i++) {
    +        if (sBlocks[i].owner == ctx && sBlocks[i].addr == addr) {
    +            removeBlock(i);
    +            return AUDIO_OK;
    +        }
    +    }
    +
    +    return AUDIO_ERR_ARG;
    +}
    +
    +
    +void audioWtNoteInit(AudioWtVoiceT *v) {
    +    memset(v, 0, sizeof(*v));
    +    v->filterFc    = AUDIO_WT_FC_OPEN;
    +    v->modLfoDelay = AUDIO_WT_TC_NONE;
    +    v->vibLfoDelay = AUDIO_WT_TC_NONE;
    +
    +    for (int32_t i = 0; i < AUDIO_WT_ENV_STAGES; i++) {
    +        v->volEnv[i] = i == AUDIO_WT_ENV_SUSTAIN ? 0 : AUDIO_WT_TC_NONE;
    +        v->modEnv[i] = v->volEnv[i];
    +    }
    +}
    +
    +
    +// Interrupt-safe.
    +int32_t audioWtRelease(const AudioCtxT *ctx, int32_t voice) {
    +    int32_t i = lookup(ctx, voice);
    +
    +    if (i < 0) {
    +        return AUDIO_ERR_VOICE;
    +    }
    +
    +    sWt.drv->release((uint32_t)i);
    +    return AUDIO_OK;
    +}
    +
    +
    +// Interrupt-safe.
    +int32_t audioWtStart(const AudioCtxT *ctx, int32_t voice, const AudioWtVoiceT *v) {
    +    int32_t i = lookup(ctx, voice);
    +
    +    if (i < 0 || !v) {
    +        return AUDIO_ERR_VOICE;
    +    }
    +
    +    sWt.drv->start((uint32_t)i, v);
    +    return AUDIO_OK;
    +}
    +
    +
    +// Interrupt-safe.
    +int32_t audioWtStop(const AudioCtxT *ctx, int32_t voice) {
    +    int32_t i = lookup(ctx, voice);
    +
    +    if (i < 0) {
    +        return AUDIO_ERR_VOICE;
    +    }
    +
    +    sWt.drv->stop((uint32_t)i);
    +    return AUDIO_OK;
    +}
    +
    +
    +// Interrupt-safe.
    +int32_t audioWtUpdate(const AudioCtxT *ctx, int32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    int32_t i = lookup(ctx, voice);
    +
    +    if (i < 0 || !v) {
    +        return AUDIO_ERR_VOICE;
    +    }
    +
    +    sWt.drv->update((uint32_t)i, v, what);
    +    return AUDIO_OK;
    +}
    +
    +
    +int32_t audioWtUpload(AudioCtxT *ctx, uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits) {
    +    if (!sWt.drv) {
    +        return AUDIO_ERR_NO_SYNTH;
    +    }
    +
    +    if (!frames || (bits != BITS_8 && bits != BITS_16) || blockOf(ctx, addr, count * (bits / BYTE_BITS)) < 0) {
    +        return AUDIO_ERR_ARG;
    +    }
    +
    +    return sWt.drv->upload(addr, frames, count, bits) ? AUDIO_OK : AUDIO_ERR_ARG;
    +}
    +
    +
    +// The block of ctx holding addr .. addr + bytes - 1 (its index), -1 when
    +// none does.
    +static int32_t blockOf(const AudioCtxT *ctx, uint32_t addr, uint32_t bytes) {
    +    for (uint32_t i = 0; i < sBlockCount; i++) {
    +        const WtBlockT *b = &sBlocks[i];
    +
    +        if (b->owner == ctx && addr >= b->addr && bytes <= b->bytes && addr - b->addr <= b->bytes - bytes) {
    +            return (int32_t)i;
    +        }
    +    }
    +
    +    return -1;
    +}
    +
    +
    +// Stop a voice and mark it free (interrupts off).
    +static void giveBack(uint32_t voice) {
    +    sWt.drv->stop(voice);
    +    sWt.voices[voice].owner   = NULL;
    +    sWt.voices[voice].freedAt = ++sWt.clock;
    +}
    +
    +
    +static int32_t lookup(const AudioCtxT *ctx, int32_t voice) {
    +    if (!ctx || voice <= 0 || !sWt.drv) {
    +        return -1;
    +    }
    +
    +    int32_t i = (voice - 1) % WT_ID_STRIDE;
    +
    +    if ((uint32_t)i >= sWt.count || sWt.voices[i].owner != ctx || sWt.voices[i].id != voice) {
    +        return -1;
    +    }
    +
    +    return i;
    +}
    +
    +
    +static void removeBlock(uint32_t i) {
    +    memmove(&sBlocks[i], &sBlocks[i + 1], (sBlockCount - i - 1) * sizeof(WtBlockT));
    +    sBlockCount--;
    +}
    diff --git a/src/libs/kpunch/audio/codec/codecCalc.c b/src/libs/kpunch/audio/codec/codecCalc.c
    new file mode 100644
    index 00000000..fa43209a
    --- /dev/null
    +++ b/src/libs/kpunch/audio/codec/codecCalc.c
    @@ -0,0 +1,88 @@
    +// The MIT License (MIT)
    +//
    +// Copyright (C) 2026 Scott Duensing
    +//
    +// Permission is hereby granted, free of charge, to any person obtaining a copy
    +// of this software and associated documentation files (the "Software"), to
    +// deal in the Software without restriction, including without limitation the
    +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    +// sell copies of the Software, and to permit persons to whom the Software is
    +// furnished to do so, subject to the following conditions:
    +//
    +// The above copyright notice and this permission notice shall be included in
    +// all copies or substantial portions of the Software.
    +//
    +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +// IN THE SOFTWARE.
    +
    +// codecCalc.c  -- the AD1848 codec's arithmetic (see codecCalc.h)
    +//
    +// The codec divides one of two crystals, 24.576 MHz and 16.9344 MHz, by
    +// one of eight dividers; the AD1848 data sheet lists the fourteen rates
    +// that work (two of the 24.576 MHz ones are not supported).
    +
    +#include "codecCalc.h"
    +
    +#define CSS_XTAL2           1       // I8 bit 0: the 16.9344 MHz crystal
    +#define CFS_SHIFT           1
    +#define BITS_16             16
    +
    +typedef struct {
    +    uint32_t rate;
    +    uint8_t  cfs;           // divider select
    +    uint8_t  css;           // crystal select
    +} CodecRateT;
    +
    +// The rates (24.576 MHz / 3072, 1536, 896, 768, 512, 2560; 16.9344 MHz /
    +// 3072, 1536, 896, 768, 448, 384, 512, 2560)
    +static const CodecRateT sRates[] = {
    +    {  5513, 0, CSS_XTAL2 },
    +    {  6615, 7, CSS_XTAL2 },
    +    {  8000, 0, 0 },
    +    {  9600, 7, 0 },
    +    { 11025, 1, CSS_XTAL2 },
    +    { 16000, 1, 0 },
    +    { 18900, 2, CSS_XTAL2 },
    +    { 22050, 3, CSS_XTAL2 },
    +    { 27429, 2, 0 },
    +    { 32000, 3, 0 },
    +    { 33075, 6, CSS_XTAL2 },
    +    { 37800, 4, CSS_XTAL2 },
    +    { 44100, 5, CSS_XTAL2 },
    +    { 48000, 6, 0 }
    +};
    +
    +#define RATE_COUNT          ((int32_t)(sizeof(sRates) / sizeof(sRates[0])))
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint8_t codecFormat(uint32_t bits, uint32_t channels);
    +uint8_t codecRateCode(uint32_t want, uint32_t *rate);
    +
    +
    +uint8_t codecFormat(uint32_t bits, uint32_t channels) {
    +    return (uint8_t)((bits == BITS_16 ? CODEC_FMT_16BIT : CODEC_FMT_8BIT) | (channels > 1 ? CODEC_FMT_STEREO : 0));
    +}
    +
    +
    +// Nearest; the faster of two as near.
    +uint8_t codecRateCode(uint32_t want, uint32_t *rate) {
    +    int32_t best = 0;
    +
    +    for (int32_t i = 1; i < RATE_COUNT; i++) {
    +        uint32_t d     = sRates[i].rate > want ? sRates[i].rate - want : want - sRates[i].rate;
    +        uint32_t dBest = sRates[best].rate > want ? sRates[best].rate - want : want - sRates[best].rate;
    +
    +        best = d <= dBest ? i : best;
    +    }
    +
    +    *rate = sRates[best].rate;
    +    return (uint8_t)(sRates[best].cfs << CFS_SHIFT | sRates[best].css);
    +}
    diff --git a/src/libs/kpunch/audio/codec/codecCalc.h b/src/libs/kpunch/audio/codec/codecCalc.h
    new file mode 100644
    index 00000000..3deaa6ec
    --- /dev/null
    +++ b/src/libs/kpunch/audio/codec/codecCalc.h
    @@ -0,0 +1,48 @@
    +// 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.
    +
    +// codecCalc.h  -- the AD1848 codec's arithmetic (no hardware access)
    +//
    +// The codec (the AD1848 and its kin, the CS4231 and CS4248) on Windows
    +// Sound System boards and the Gravis UltraSound MAX: its rates (two
    +// crystals, eight dividers each) and its data format byte, kept apart
    +// from codecHw.c so the host tests run them.
    +#ifndef CODEC_CALC_H
    +#define CODEC_CALC_H
    +
    +#include 
    +#include 
    +
    +// I8 (clock and data format): the format bits
    +#define CODEC_FMT_8BIT      0x00    // unsigned
    +#define CODEC_FMT_16BIT     0x40    // signed, little-endian
    +#define CODEC_FMT_STEREO    0x10
    +
    +// The I8 format bits for 8 or 16 bits, 1 or 2 channels.
    +uint8_t codecFormat(uint32_t bits, uint32_t channels);
    +
    +// The rate nearest want the codec plays or records: its I8 clock bits
    +// (CFS << 1 | CSS) and *rate, that rate in Hz (rounded where the crystal
    +// gives a fraction).
    +uint8_t codecRateCode(uint32_t want, uint32_t *rate);
    +
    +#endif // CODEC_CALC_H
    diff --git a/src/libs/kpunch/audio/codec/codecHw.c b/src/libs/kpunch/audio/codec/codecHw.c
    new file mode 100644
    index 00000000..3ff81b1a
    --- /dev/null
    +++ b/src/libs/kpunch/audio/codec/codecHw.c
    @@ -0,0 +1,225 @@
    +// 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.
    +
    +// codecHw.c  -- the AD1848 codec and its kin (see codecHw.h)
    +
    +#include "codecHw.h"
    +#include "audioDrv.h"
    +
    +#include 
    +
    +// Direct registers
    +#define R_INDEX             0
    +#define R_DATA              1
    +#define R_STATUS            2
    +#define INDEX_INIT          0x80    // busy initializing: reads this, ignores writes
    +#define INDEX_MCE           0x40    // mode change enable
    +#define STATUS_INT          0x01
    +
    +// Indirect registers and their bits
    +#define I_INPUT_LEFT        0       // ADC input control (also the read and write test)
    +#define I_INPUT_RIGHT       1
    +#define I_DAC_LEFT          6
    +#define I_DAC_RIGHT         7
    +#define I_FORMAT            8
    +#define I_INTERFACE         9
    +#define I_PIN               10
    +#define I_INIT              11
    +#define I_MODE_ID           12
    +#define I_COUNT_HIGH        14
    +#define I_COUNT_LOW         15
    +#define DAC_UNMUTED_FULL    0x00
    +#define IF_SDC              0x04    // single DMA channel
    +#define IF_ACAL             0x08    // calibrate at the end of a mode change
    +#define PIN_IEN             0x02    // interrupts on
    +#define INIT_ACI            0x20    // calibrating
    +#define ID_CRYSTAL          0x80    // CS4248 / CS4231 family (an AD1848 reads 0)
    +#define TEST_A              0xAA
    +#define TEST_B              0x45
    +#define BYTE_BITS           8
    +#define BYTE_MASK           0xFF
    +
    +// Waits: port reads take about a microsecond on the ISA bus
    +#define INIT_POLL_MAX       100000  // the codec busy after a reset or a mode change
    +#define ACI_RISE_POLL       2000    // calibration starts within a few samples
    +#define ACI_POLL_MAX        200000  // and takes a few hundred
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +void           codecDacOn(CodecT *c);
    +bool           codecFound(CodecT *c, uint16_t port);
    +void           codecInputSet(CodecT *c, uint8_t input);
    +bool           codecIrq(const CodecT *c);
    +bool           codecIsCrystal(const CodecT *c);
    +bool           codecLock(bool lock);
    +static uint8_t codecRead(const CodecT *c, uint8_t reg);
    +static bool    codecReady(uint16_t port);
    +void           codecSetFormat(const CodecT *c, uint8_t format);
    +void           codecStart(const CodecT *c, uint32_t frames, uint8_t what);
    +void           codecStop(CodecT *c);
    +static void    codecWrite(const CodecT *c, uint8_t reg, uint8_t value);
    +
    +
    +void codecDacOn(CodecT *c) {
    +    c->savedDacLeft  = codecRead(c, I_DAC_LEFT);
    +    c->savedDacRight = codecRead(c, I_DAC_RIGHT);
    +    c->dacSaved      = true;
    +    codecWrite(c, I_DAC_LEFT, DAC_UNMUTED_FULL);
    +    codecWrite(c, I_DAC_RIGHT, DAC_UNMUTED_FULL);
    +}
    +
    +
    +bool codecFound(CodecT *c, uint16_t port) {
    +    if (!codecReady(port) || inportb(port + R_INDEX) == BYTE_MASK) {
    +        return false;
    +    }
    +
    +    c->port       = port;
    +    c->dacSaved   = false;
    +    c->inputSaved = false;
    +
    +    uint8_t a = codecRead(c, I_INPUT_LEFT);
    +    uint8_t b = codecRead(c, I_INPUT_RIGHT);
    +
    +    codecWrite(c, I_INPUT_LEFT, TEST_A);
    +    codecWrite(c, I_INPUT_RIGHT, TEST_B);
    +
    +    bool ok = codecRead(c, I_INPUT_LEFT) == TEST_A && codecRead(c, I_INPUT_RIGHT) == TEST_B;
    +
    +    codecWrite(c, I_INPUT_LEFT, TEST_B);
    +    codecWrite(c, I_INPUT_RIGHT, TEST_A);
    +    ok = ok && codecRead(c, I_INPUT_LEFT) == TEST_B && codecRead(c, I_INPUT_RIGHT) == TEST_A;
    +    codecWrite(c, I_INPUT_LEFT, a);
    +    codecWrite(c, I_INPUT_RIGHT, b);
    +    return ok;
    +}
    +
    +
    +void codecInputSet(CodecT *c, uint8_t input) {
    +    c->savedInLeft  = codecRead(c, I_INPUT_LEFT);
    +    c->savedInRight = codecRead(c, I_INPUT_RIGHT);
    +    c->inputSaved   = true;
    +    codecWrite(c, I_INPUT_LEFT, input);
    +    codecWrite(c, I_INPUT_RIGHT, input);
    +}
    +
    +
    +bool codecIrq(const CodecT *c) {
    +    if (!(inportb(c->port + R_STATUS) & STATUS_INT)) {
    +        return false;
    +    }
    +
    +    outportb(c->port + R_STATUS, 0);
    +    return true;
    +}
    +
    +
    +bool codecIsCrystal(const CodecT *c) {
    +    return (codecRead(c, I_MODE_ID) & ID_CRYSTAL) != 0;
    +}
    +
    +
    +bool codecLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)codecIrq, 0 }
    +    };
    +
    +    return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +}
    +
    +
    +static uint8_t codecRead(const CodecT *c, uint8_t reg) {
    +    outportb(c->port + R_INDEX, reg);
    +    return inportb(c->port + R_DATA);
    +}
    +
    +
    +// The codec is past initializing (bounded wait).
    +static bool codecReady(uint16_t port) {
    +    for (int32_t i = 0; i < INIT_POLL_MAX; i++) {
    +        if (inportb(port + R_INDEX) != INDEX_INIT) {
    +            return true;
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +// ACI rises within a few samples of the mode change ending and falls when
    +// the calibration is done.
    +void codecSetFormat(const CodecT *c, uint8_t format) {
    +    uint16_t port = c->port;
    +
    +    outportb(port + R_INDEX, INDEX_MCE | I_FORMAT);
    +    outportb(port + R_DATA, format);
    +    codecReady(port);
    +    outportb(port + R_INDEX, INDEX_MCE | I_INTERFACE);
    +    outportb(port + R_DATA, IF_ACAL | IF_SDC);
    +    outportb(port + R_INDEX, I_INIT);
    +    codecReady(port);
    +
    +    for (int32_t i = 0; i < ACI_RISE_POLL && !(inportb(port + R_DATA) & INIT_ACI); i++) {
    +    }
    +
    +    for (int32_t i = 0; i < ACI_POLL_MAX && (inportb(port + R_DATA) & INIT_ACI); i++) {
    +    }
    +}
    +
    +
    +// The counter holds the frames less one.
    +void codecStart(const CodecT *c, uint32_t frames, uint8_t what) {
    +    uint32_t count = frames - 1;
    +
    +    codecWrite(c, I_COUNT_LOW, (uint8_t)(count & BYTE_MASK));
    +    codecWrite(c, I_COUNT_HIGH, (uint8_t)(count >> BYTE_BITS));
    +    codecWrite(c, I_PIN, PIN_IEN);
    +    outportb(c->port + R_STATUS, 0);
    +    codecWrite(c, I_INTERFACE, (uint8_t)(codecRead(c, I_INTERFACE) | what));
    +}
    +
    +
    +void codecStop(CodecT *c) {
    +    codecWrite(c, I_INTERFACE, (uint8_t)(codecRead(c, I_INTERFACE) & ~(CODEC_PLAY | CODEC_CAPTURE)));
    +    codecWrite(c, I_PIN, 0);
    +    outportb(c->port + R_STATUS, 0);
    +
    +    if (c->dacSaved) {
    +        codecWrite(c, I_DAC_LEFT, c->savedDacLeft);
    +        codecWrite(c, I_DAC_RIGHT, c->savedDacRight);
    +        c->dacSaved = false;
    +    }
    +
    +    if (c->inputSaved) {
    +        codecWrite(c, I_INPUT_LEFT, c->savedInLeft);
    +        codecWrite(c, I_INPUT_RIGHT, c->savedInRight);
    +        c->inputSaved = false;
    +    }
    +}
    +
    +
    +static void codecWrite(const CodecT *c, uint8_t reg, uint8_t value) {
    +    outportb(c->port + R_INDEX, reg);
    +    outportb(c->port + R_DATA, value);
    +}
    diff --git a/src/libs/kpunch/audio/codec/codecHw.h b/src/libs/kpunch/audio/codec/codecHw.h
    new file mode 100644
    index 00000000..ded916f4
    --- /dev/null
    +++ b/src/libs/kpunch/audio/codec/codecHw.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.
    +
    +// codecHw.h  -- the AD1848 codec and its kin, shared by the drivers of the
    +// boards built on one
    +//
    +// wss.drv (Windows Sound System boards) and gus.drv (the UltraSound
    +// MAX's CS4231) each link their own copy.  The codec has four direct
    +// registers at its port (index, data, status, PIO data) and its
    +// indirect ones behind the index.  These routines set the data format in
    +// a mode change with the codec's own calibration, run playing or capture
    +// over one DMA channel (single-DMA mode, the codec's sample counter
    +// interrupting at the end of each block), choose the ADC's input, and
    +// put back the DAC levels and inputs they found.  The driver owns its DMA
    +// buffer and interrupt, and waits for the first interrupt itself.  Facts
    +// from the AD1848 and CS4231 data sheets.
    +#ifndef CODEC_HW_H
    +#define CODEC_HW_H
    +
    +#include "codecCalc.h"
    +
    +#include 
    +#include 
    +
    +// The ADC's source (I0 / I1 bits 7-6), and the microphone's 20 dB boost
    +#define CODEC_INPUT_LINE        0x00
    +#define CODEC_INPUT_AUX1        0x40
    +#define CODEC_INPUT_MIC         0x80
    +#define CODEC_INPUT_MIC_BOOST   0x20
    +
    +// What codecStart starts (I9)
    +#define CODEC_PLAY              0x01
    +#define CODEC_CAPTURE           0x02
    +
    +typedef struct {
    +    uint16_t port;              // the index register; data, status follow
    +    uint8_t  savedDacLeft;      // I6, I7 as found (codecDacOn)
    +    uint8_t  savedDacRight;
    +    uint8_t  savedInLeft;       // I0, I1 as found (codecInputSet)
    +    uint8_t  savedInRight;
    +    bool     dacSaved;
    +    bool     inputSaved;
    +} CodecT;
    +
    +// A codec answers at port (not busy, not an empty bus) and its first two
    +// indirect registers keep two patterns each way round (put back after);
    +// c is set up for it then.
    +bool    codecFound(CodecT *c, uint16_t port);
    +
    +// A Crystal part (CS4231, CS4248): an AD1848 reads 0 there.
    +bool    codecIsCrystal(const CodecT *c);
    +
    +// The format byte (codecFormat | codecRateCode) in a mode change,
    +// single-DMA mode, the codec calibrating as it ends (bounded waits).
    +void    codecSetFormat(const CodecT *c, uint8_t format);
    +
    +// The DAC unmuted at full, its levels kept for codecStop.
    +void    codecDacOn(CodecT *c);
    +
    +// Both ADC inputs from input (CODEC_INPUT_*), the gain at 0 dB; the
    +// inputs kept for codecStop.
    +void    codecInputSet(CodecT *c, uint8_t input);
    +
    +// The sample counter set to frames (an interrupt at the end of every
    +// block of them), the interrupt on and cleared, and what (CODEC_PLAY or
    +// CODEC_CAPTURE) started.
    +void    codecStart(const CodecT *c, uint32_t frames, uint8_t what);
    +
    +// Playing and capture stopped, the interrupt off and cleared, and the DAC
    +// levels and inputs put back where codecDacOn and codecInputSet changed
    +// them.
    +void    codecStop(CodecT *c);
    +
    +// The codec's interrupt: true when it raised it, acknowledged then.
    +// Interrupt time.
    +bool    codecIrq(const CodecT *c);
    +
    +// Pin or release this file's interrupt-time code.
    +bool    codecLock(bool lock);
    +
    +#endif // CODEC_HW_H
    diff --git a/src/libs/kpunch/audio/drivers/awe/aweCalc.c b/src/libs/kpunch/audio/drivers/awe/aweCalc.c
    new file mode 100644
    index 00000000..75c26ed3
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/awe/aweCalc.c
    @@ -0,0 +1,191 @@
    +// 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.
    +
    +// aweCalc.c  -- the EMU8000's units (see aweCalc.h)
    +//
    +// Times go through milliseconds (1000 x 2^(tc / 1200), drvPow2) where the
    +// guide's register is linear in them; the attack and decay codes are
    +// logarithmic, so below the guide's knee they come straight from the
    +// timecents: an attack code is 8.42 - tc / 75 under 371 ms (32 + 16
    +// log2(360 / ms)) and 11878 / ms above; a decay code 39 + (37 - tc) / 75
    +// under 2828 ms (Creative's library, the guide's 100 ms = 0x5C) and
    +// 45228 / ms above.
    +
    +#include "aweCalc.h"
    +#include "../../math/drvMath.h"
    +
    +#define IP_PER_OCTAVE_NUM   1024    // 4096 IP steps per 1200 cents
    +#define IP_PER_OCTAVE_DEN   300
    +#define IP_MAX              0xFFFF
    +#define ATTEN_NUM           4       // 0.375 dB = 3.75 cB a step
    +#define ATTEN_DEN           15
    +#define BYTE_MAX            0xFF
    +#define CUTOFF_BASE         4721    // 125 Hz in absolute cents
    +#define CUTOFF_NUM          8       // 28.125 cents a step
    +#define CUTOFF_DEN          225
    +#define Q_CB_STEP           12
    +#define Q_MAX               15
    +#define PAN_HALF            500
    +#define PAN_SPAN            1000
    +#define SEND_SPAN           1000
    +#define TC_NONE             (-12000)    // 1 ms and less: no delay
    +#define MS_PER_SEC          1000
    +#define DELAY_NUM           40      // 725 us steps: ms x 1000 / 725
    +#define DELAY_DEN           29
    +#define HOLD_MAX            0x7F
    +#define HOLD_MS_STEP        92
    +#define CODE_MIN            1
    +#define CODE_MAX            127
    +#define ATTACK_KNEE_TC      (-1717)     // 371 ms
    +#define ATTACK_FAST_NUM     25251       // 8.417 x 3000
    +#define ATTACK_FAST_TC      40          // tc / 75 = tc x 40 / 3000
    +#define ATTACK_FAST_DEN     3000
    +#define ATTACK_SLOW_MS      11878       // code x ms
    +#define DECAY_KNEE_TC       1800        // 2828 ms
    +#define DECAY_FAST_BASE     2962        // 39 x 75 + 37
    +#define DECAY_FAST_DEN      75
    +#define DECAY_SLOW_MS       45228
    +#define SUSTAIN_TOP         0x7F
    +#define SUSV_NUM            2           // 0.75 dB = 7.5 cB a step
    +#define SUSV_DEN            15
    +#define MOD_SUSTAIN_SPAN    1000
    +#define LFO_REG_X100        19467       // 8.176 Hz / 0.042 Hz x 100
    +#define LFO_PERCENT         100
    +#define LFO_CENTS_MAX       600         // past 10.72 Hz the register is full anyway
    +#define DEPTH_MIN           (-128)
    +#define DEPTH_MAX           127
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint8_t         aweAttack(int32_t timecents);
    +uint8_t         aweAtten(int32_t centibels);
    +uint8_t         aweCutoff(int32_t absCents);
    +uint8_t         aweDecay(int32_t timecents);
    +uint16_t        aweDelay(int32_t timecents);
    +uint8_t         aweDepth(int32_t value, int32_t stepNum, int32_t stepDen);
    +uint8_t         aweHold(int32_t timecents);
    +uint8_t         aweLfoFreq(int32_t absCents);
    +uint8_t         aweModSustain(int32_t tenths);
    +uint8_t         awePan(int32_t pan);
    +uint16_t        awePitch(int32_t cents);
    +uint8_t         aweQ(int32_t centibels);
    +uint8_t         aweSend(int32_t tenths);
    +uint8_t         aweVolSustain(int32_t centibels);
    +static int32_t  clampInt(int32_t v, int32_t lo, int32_t hi);
    +static uint32_t msOf(int32_t timecents);
    +
    +
    +uint8_t aweAttack(int32_t timecents) {
    +    if (timecents < ATTACK_KNEE_TC) {
    +        return (uint8_t)clampInt((ATTACK_FAST_NUM - timecents * ATTACK_FAST_TC) / ATTACK_FAST_DEN, CODE_MIN, CODE_MAX);
    +    }
    +
    +    return (uint8_t)clampInt((int32_t)(((uint32_t)ATTACK_SLOW_MS * drvPow2(-timecents) / DRV_POW2_ONE) / MS_PER_SEC), CODE_MIN, CODE_MAX);
    +}
    +
    +
    +uint8_t aweAtten(int32_t centibels) {
    +    return (uint8_t)clampInt(centibels * ATTEN_NUM / ATTEN_DEN, 0, BYTE_MAX);
    +}
    +
    +
    +uint8_t aweCutoff(int32_t absCents) {
    +    return (uint8_t)clampInt((absCents - CUTOFF_BASE) * CUTOFF_NUM / CUTOFF_DEN, 0, BYTE_MAX);
    +}
    +
    +
    +uint8_t aweDecay(int32_t timecents) {
    +    if (timecents < DECAY_KNEE_TC) {
    +        return (uint8_t)clampInt((DECAY_FAST_BASE - timecents) / DECAY_FAST_DEN, CODE_MIN, CODE_MAX);
    +    }
    +
    +    return (uint8_t)clampInt((int32_t)(((uint32_t)DECAY_SLOW_MS * drvPow2(-timecents) / DRV_POW2_ONE) / MS_PER_SEC), CODE_MIN, CODE_MAX);
    +}
    +
    +
    +uint16_t aweDelay(int32_t timecents) {
    +    if (timecents <= TC_NONE) {
    +        return AWE_DELAY_NONE;
    +    }
    +
    +    return (uint16_t)clampInt(AWE_DELAY_NONE - (int32_t)(msOf(timecents) * DELAY_NUM / DELAY_DEN), 0, AWE_DELAY_NONE);
    +}
    +
    +
    +uint8_t aweDepth(int32_t value, int32_t stepNum, int32_t stepDen) {
    +    return (uint8_t)(int8_t)clampInt(value * stepDen / stepNum, DEPTH_MIN, DEPTH_MAX);
    +}
    +
    +
    +uint8_t aweHold(int32_t timecents) {
    +    return (uint8_t)clampInt(HOLD_MAX - (int32_t)(msOf(timecents) / HOLD_MS_STEP), 0, HOLD_MAX);
    +}
    +
    +
    +uint8_t aweLfoFreq(int32_t absCents) {
    +    uint32_t pow = drvPow2(absCents < LFO_CENTS_MAX ? absCents : LFO_CENTS_MAX);
    +
    +    return (uint8_t)clampInt((int32_t)((uint32_t)LFO_REG_X100 * (pow >> 1) / (DRV_POW2_ONE >> 1) / LFO_PERCENT), 0, BYTE_MAX);
    +}
    +
    +
    +uint8_t aweModSustain(int32_t tenths) {
    +    return (uint8_t)clampInt(SUSTAIN_TOP - tenths * SUSTAIN_TOP / MOD_SUSTAIN_SPAN, 0, SUSTAIN_TOP);
    +}
    +
    +
    +uint8_t awePan(int32_t pan) {
    +    return (uint8_t)((PAN_HALF - clampInt(pan, -PAN_HALF, PAN_HALF)) * BYTE_MAX / PAN_SPAN);
    +}
    +
    +
    +uint16_t awePitch(int32_t cents) {
    +    return (uint16_t)clampInt(AWE_IP_UNITY + cents * IP_PER_OCTAVE_NUM / IP_PER_OCTAVE_DEN, 0, IP_MAX);
    +}
    +
    +
    +uint8_t aweQ(int32_t centibels) {
    +    return (uint8_t)clampInt(centibels / Q_CB_STEP, 0, Q_MAX);
    +}
    +
    +
    +uint8_t aweSend(int32_t tenths) {
    +    return (uint8_t)clampInt(tenths * BYTE_MAX / SEND_SPAN, 0, BYTE_MAX);
    +}
    +
    +
    +uint8_t aweVolSustain(int32_t centibels) {
    +    return (uint8_t)clampInt(SUSTAIN_TOP - centibels * SUSV_NUM / SUSV_DEN, 0, SUSTAIN_TOP);
    +}
    +
    +
    +static int32_t clampInt(int32_t v, int32_t lo, int32_t hi) {
    +    return v < lo ? lo : (v > hi ? hi : v);
    +}
    +
    +
    +// 1000 x 2^(tc / 1200) ms (tc at most +8000: about 100 s).
    +static uint32_t msOf(int32_t timecents) {
    +    return (uint32_t)(((uint64_t)MS_PER_SEC * drvPow2(timecents)) >> DRV_POW2_SHIFT);
    +}
    diff --git a/src/libs/kpunch/audio/drivers/awe/aweCalc.h b/src/libs/kpunch/audio/drivers/awe/aweCalc.h
    new file mode 100644
    index 00000000..dd4145d7
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/awe/aweCalc.h
    @@ -0,0 +1,77 @@
    +// 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.
    +
    +// aweCalc.h  -- the EMU8000's units (no hardware access)
    +//
    +// What awe.drv writes to the chip's registers for a note's SoundFont
    +// values: kept apart so the host tests check them.  Integer only (they
    +// run at interrupt time, from music playback): 2^x from drvPow2, no
    +// division by 64-bit values.  Units from the EMU8000 Programmer's Guide
    +// and the reference summary (section 5.4 and 7).
    +#ifndef AWE_CALC_H
    +#define AWE_CALC_H
    +
    +#include 
    +
    +#define AWE_IP_UNITY        0xE000  // IP: 44,100 frames a second at their own pitch
    +#define AWE_DELAY_NONE      0x8000  // ENVVOL, ENVVAL, LFO1VAL, LFO2VAL: no delay
    +
    +// IP for a pitch in cents (0: 44,100 frames a second): 4096 an octave.
    +uint16_t awePitch(int32_t cents);
    +
    +// ATN for an attenuation in centibels: 0.375 dB a step, 0xFF at most.
    +uint8_t  aweAtten(int32_t centibels);
    +
    +// IFC for a cutoff in absolute cents (125 Hz at 0, 28.125 cents a step);
    +// filter Q for a resonance in centibels (0..15).
    +uint8_t  aweCutoff(int32_t absCents);
    +uint8_t  aweQ(int32_t centibels);
    +
    +// PAN for -500 (left) .. 500 (right): 0xFF left, 0x00 right.
    +uint8_t  awePan(int32_t pan);
    +
    +// A reverb or chorus send in tenths of a percent as 0..0xFF.
    +uint8_t  aweSend(int32_t tenths);
    +
    +// Envelope and LFO times in timecents: an attack code (1..127), a decay
    +// or release code (1..127), a delay register (0x8000 less 725 us steps),
    +// a hold value (0x7F less 92 ms steps).
    +uint8_t  aweAttack(int32_t timecents);
    +uint8_t  aweDecay(int32_t timecents);
    +uint16_t aweDelay(int32_t timecents);
    +uint8_t  aweHold(int32_t timecents);
    +
    +// Sustain levels: the modulation's in tenths of a percent below the
    +// peak, the volume's in centibels below it (0.75 dB steps from 0x7F).
    +uint8_t  aweModSustain(int32_t tenths);
    +uint8_t  aweVolSustain(int32_t centibels);
    +
    +// An LFO's frequency register for absolute cents (0.042 Hz steps).
    +uint8_t  aweLfoFreq(int32_t absCents);
    +
    +// A signed depth byte (-128..127 as two's complement) for value in steps
    +// of stepNum / stepDen: the guide's depths count 9.375 cents of pitch
    +// (75 / 8), 56.25 cents of cutoff for the envelope (225 / 4) and 28.125
    +// for the LFO (225 / 8), 0.9375 cB of volume (15 / 16).
    +uint8_t  aweDepth(int32_t value, int32_t stepNum, int32_t stepDen);
    +
    +#endif // AWE_CALC_H
    diff --git a/src/libs/kpunch/audio/drivers/awe/aweDrv.c b/src/libs/kpunch/audio/drivers/awe/aweDrv.c
    new file mode 100644
    index 00000000..40ab79c1
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/awe/aweDrv.c
    @@ -0,0 +1,842 @@
    +// 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.
    +
    +// aweDrv.c  -- the Sound Blaster AWE32 / AWE64 wavetable synthesizer
    +// (awe.drv): the EMU8000 beside the card's Sound Blaster
    +//
    +// A synth driver (audioDrv.h "Synthesizer drivers", kind
    +// AUDIO_SYNTH_WAVETABLE).  Where the chip is: BLASTER's E (its base;
    +// without it the Sound Blaster's A + 400h): three windows, E (data 0), E
    +// + 400h (data 1 and 2) and E + 800h (data 3 and the pointer, which picks
    +// a register and a channel for the data ports).  Found by its signature
    +// (data 3 register 7 reads ...Ch, the configuration words their fixed
    +// bits), then set up in the order the EMU8000 Programmer's Guide gives:
    +// every channel's envelope engine off and its registers cleared, the
    +// four sets of initialization arrays (the effects program: Hall 2 reverb,
    +// Chorus 3, flat equalization), audio on.  Channels 30 and 31 carry the
    +// FM chip's sound through the effects and refresh the sample memory, so
    +// 30 voices remain; the memory is sized in 512 KB steps.
    +//
    +// The chip does everything a SoundFont asks: both envelopes, both LFOs,
    +// the resonant filter, reverb and chorus sends (aweCalc.c turns the
    +// SoundFont's units into its registers).  Samples are 16-bit, uploaded
    +// through channels set to memory-write mode (all 30 while nothing plays),
    +// the FM channels set up again afterwards.  The EMU8000 always loops: a
    +// sample that does not loops in the silent frames after its end (the
    +// loader leaves eight); one that loops until the key is up moves its
    +// loop there at release.  Every pointer and data access runs with
    +// interrupts off: the timer starts notes while a program uploads.
    +
    +#include "audioDrv.h"
    +#include "aweCalc.h"
    +#include "../sb/sbCalc.h"
    +#include "../../math/drvMath.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +// Ports, from the EMU base
    +#define PORT_DATA0          0x000   // double words: the low word here, the high at + 2
    +#define PORT_DATA1          0x400
    +#define PORT_DATA2          0x402
    +#define PORT_DATA3          0x800
    +#define PORT_POINTER        0x802
    +#define EMU_FROM_SB         0x400   // the base without BLASTER's E
    +#define REG_SHIFT           5       // pointer: register << 5 | channel
    +#define HIGH_WORD           2
    +#define WORD_BITS           16
    +#define WORD_MASK           0xFFFF
    +
    +// Registers: data port and number (pointer = number << 5 | channel)
    +#define R_CPF               PORT_DATA0, 0
    +#define R_PTRX              PORT_DATA0, 1
    +#define R_CVCF              PORT_DATA0, 2
    +#define R_VTFT              PORT_DATA0, 3
    +#define R_UNKNOWN4          PORT_DATA0, 4
    +#define R_UNKNOWN5          PORT_DATA0, 5
    +#define R_PSST              PORT_DATA0, 6
    +#define R_CSL               PORT_DATA0, 7
    +#define R_CCCA              PORT_DATA1, 0
    +#define R_HW                PORT_DATA1, 1   // channel: which configuration or memory register
    +#define R_HW2               PORT_DATA2, 1
    +#define R_INIT1             PORT_DATA1, 2
    +#define R_INIT2             PORT_DATA2, 2
    +#define R_INIT3             PORT_DATA1, 3
    +#define R_INIT4             PORT_DATA2, 3
    +#define R_ENVVOL            PORT_DATA1, 4
    +#define R_DCYSUSV           PORT_DATA1, 5
    +#define R_ENVVAL            PORT_DATA1, 6
    +#define R_DCYSUS            PORT_DATA1, 7
    +#define R_ATKHLDV           PORT_DATA2, 4
    +#define R_LFO1VAL           PORT_DATA2, 5
    +#define R_ATKHLD            PORT_DATA2, 6
    +#define R_LFO2VAL           PORT_DATA2, 7
    +#define R_IP                PORT_DATA3, 0
    +#define R_IFATN             PORT_DATA3, 1
    +#define R_PEFE              PORT_DATA3, 2
    +#define R_FMMOD             PORT_DATA3, 3
    +#define R_TREMFRQ           PORT_DATA3, 4
    +#define R_FM2FRQ2           PORT_DATA3, 5
    +#define R_ID                PORT_DATA3, 7
    +
    +// R_HW / R_HW2 channels
    +#define HW_CF4              9
    +#define HW_CF5              10
    +#define HW_CF6              13
    +#define HW_SMALR            20
    +#define HW_SMALW            22
    +#define HW_SMLD             26
    +#define HW_WC               27      // on data 2
    +#define HW_FM_TIMING        28
    +#define HW_CF1              29
    +#define HW_CF2              30
    +#define HW_CF3              31
    +
    +// Values the guide gives
    +#define CF1_INIT            0x0059
    +#define CF2_INIT            0x0020
    +#define CF3_AUDIO_ON        0x0004
    +#define CF4_INIT            0x00000000U
    +#define CF5_INIT            0x00000083U
    +#define CF6_INIT            0x00008000U
    +#define ID_MASK             0x000F
    +#define ID_EMU              0x000C
    +#define CF1_MASK            0x007E
    +#define CF1_EXPECT          0x0058
    +#define CF2_MASK            0x0003
    +#define CF2_EXPECT          0x0003
    +#define ENV_OFF             0x0080  // DCYSUSV: the envelope engine off
    +#define ENV_KILL            0x807F  // DCYSUSV: release at the fastest
    +#define RELEASE_BIT         0x8000
    +#define VTFT_SILENT         0x0000FFFFU
    +#define CVCF_SILENT         0x0000FFFFU
    +#define PITCH_UNITY         0x40000000U     // PTRX / CPF: 0x4000 in the top word
    +#define DMA_LEFT_WRITE      0x06000000U
    +#define DMA_LEFT_READ       0x04000000U
    +#define MEM_BUSY            0x80000000U     // SMALW: FULL; SMALR: EMPTY
    +#define DRAM_FIRST          0x200000U       // word address of the sample memory
    +#define DRAM_STEP_BYTES     (512U * 1024U)
    +#define DRAM_MAX_BYTES      (28U * 1024U * 1024U)
    +#define DRAM_PATTERN_A      0xA5B9
    +#define DRAM_PATTERN_B      0x9D53
    +#define INIT_ARRAYS         4
    +#define INIT_WORDS          32
    +#define INIT_SETS           4
    +#define INIT_WAIT_SAMPLES   1024
    +#define INIT_ODD_BIT        0x8000
    +#define INIT1_EVEN_BASE     0x03FF
    +#define INIT1_EVEN_STEP     0x0400
    +#define INIT1_ODD_BASE      0x0030
    +#define INIT1_ODD_SHIFT     8
    +#define INIT1_ODD_MASK      15
    +
    +// FM pass-through on channels 30 and 31 (the reserved memory where the
    +// FM chip's output appears)
    +#define FM_LEFT             30
    +#define FM_RIGHT            31
    +#define FM_LEFT_PSST        0xFFFFFFE0U     // pan full left, loop start
    +#define FM_LEFT_CSL         0x00FFFFE8U
    +#define FM_LEFT_CCCA        0x00FFFFE3U
    +#define FM_RIGHT_PSST       0x00FFFFF0U
    +#define FM_RIGHT_CSL        0x00FFFFF8U
    +#define FM_RIGHT_CCCA       0x00FFFFF3U
    +#define FM_RIGHT_CPF        0x00008000U
    +#define FM_VTFT             0x8000FFFFU
    +#define FM_TIMING           0x4828
    +#define POINTER_PHASE       0x1000          // pointer bit 12: toggles with the chip's cycle
    +#define PHASE_WAIT_US       2000            // each half of its cycle waited for, at most
    +
    +// Voices and notes
    +#define AWE_VOICES          30
    +#define AWE_VOICE_LEVEL     76      // centibels a voice at full volume is louder than the Sound Blaster's full-scale digital sound (86Box)
    +#define AWE_CHANNELS        32
    +#define ONE_SHOT_LOOP_START 4               // a one-shot's loop, frames after its end
    +#define ONE_SHOT_LOOP_END   8
    +#define FILTER_SHIFT        8
    +#define VOL_TARGET_FULL     0xFFFF0000U
    +#define CF_FULL             0x0000FF00U
    +#define PAN_SHIFT           24
    +#define SEND_SHIFT          24
    +#define REVERB_SHIFT        8
    +#define Q_SHIFT             28
    +#define PITCH_TARGET_MASK   0xFFFF0000U
    +#define DEPTH_SHIFT         8
    +#define SUSTAIN_SHIFT       8
    +#define HOLD_SHIFT          8
    +#define STEP_PITCH_NUM      75              // 9.375 cents a depth step
    +#define STEP_PITCH_DEN      8
    +#define STEP_ENV_CUT_NUM    225             // 56.25 cents
    +#define STEP_ENV_CUT_DEN    4
    +#define STEP_LFO_CUT_NUM    225             // 28.125 cents
    +#define STEP_LFO_CUT_DEN    8
    +#define STEP_TREM_NUM       15              // 0.9375 cB
    +#define STEP_TREM_DEN       16
    +#define BYTES_PER_WORD      2
    +#define POLL_MAX            100000
    +#define UPLOAD_RUN          256             // words with interrupts off at a time
    +#define FLAGS_IF            0x200
    +#define KB                  1024
    +
    +// The fourth set of initialization arrays (the guide's section 9, INIT1
    +// to INIT4, 32 words each): the effects program.  The other three sets
    +// follow from it and a pattern (aweInitWord).
    +static const uint16_t sInit4[INIT_ARRAYS * INIT_WORDS] = {
    +    0x0C10, 0x8470, 0x14FE, 0xB488, 0x167F, 0xA470, 0x18E7, 0x84B5,
    +    0x1B6E, 0x842A, 0x1F1D, 0x852A, 0x0DA3, 0x0F7C, 0x167E, 0x7254,
    +    0x0000, 0x842A, 0x0001, 0x852A, 0x18E6, 0x0BAA, 0x1B6D, 0x7234,
    +    0x229F, 0x8429, 0x2746, 0x8529, 0x1F1C, 0x06E7, 0x229E, 0x7224,
    +    0x0DA4, 0x8429, 0x2C29, 0x8529, 0x2745, 0x07F6, 0x2C28, 0x7254,
    +    0x383B, 0x8428, 0x320F, 0x8528, 0x320E, 0x0F02, 0x1341, 0x7264,
    +    0x3EB6, 0x8428, 0x3EB9, 0x8528, 0x383A, 0x0FA9, 0x3EB5, 0x7294,
    +    0x3EB7, 0x8474, 0x3EBA, 0x8575, 0x3EB8, 0x44C3, 0x3EBB, 0x45C3,
    +    0x0000, 0xA404, 0x0001, 0xA504, 0x141F, 0x0671, 0x14FD, 0x0287,
    +    0x3EBC, 0xE610, 0x3EC8, 0x0C7B, 0x031A, 0x07E6, 0x3EC8, 0x86F7,
    +    0x3EC0, 0x821E, 0x3EBE, 0xD208, 0x3EBD, 0x021F, 0x3ECA, 0x0386,
    +    0x3EC1, 0x0C03, 0x3EC9, 0x031E, 0x3ECA, 0x8C4C, 0x3EBF, 0x0C55,
    +    0x3EC9, 0xC208, 0x3EC4, 0xBC84, 0x3EC8, 0x0EAD, 0x3EC8, 0xD308,
    +    0x3EC2, 0x8F7E, 0x3ECB, 0x021E, 0x3ECB, 0xD208, 0x3EC5, 0x031F,
    +    0x3EC6, 0xC308, 0x3EC3, 0x32FF, 0x3EC9, 0x0265, 0x3EC9, 0x831E,
    +    0x1342, 0xD308, 0x3EC7, 0x33FF, 0x0000, 0x8365, 0x1420, 0x9570
    +};
    +
    +// What a note set, for its updates and release
    +typedef struct {
    +    uint32_t loopStart;     // word addresses
    +    uint32_t loopEnd;
    +    uint32_t end;
    +    uint8_t  cutoff;        // IFATN's high byte
    +    uint8_t  pan;
    +    uint8_t  chorus;
    +    uint8_t  volRelease;    // decay codes
    +    uint8_t  modRelease;
    +    uint8_t  loop;          // AUDIO_WT_LOOP_*
    +    bool     released;
    +} AweVoiceT;
    +
    +typedef struct {
    +    AweVoiceT voices[AWE_VOICES];
    +    uint32_t  dramBytes;
    +    uint16_t  base;         // the EMU base (data 0)
    +    bool      locked;
    +} AweStateT;
    +
    +static AweStateT sAwe;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool     aweActive(uint32_t voice);
    +static void     aweClose(void);
    +static bool     aweDetect(void);
    +static void     aweDmaClose(void);
    +static void     aweDmaOpen(uint32_t mode, uint32_t channels);
    +static void     aweFmInit(void);
    +static void     aweInit(void);
    +static uint16_t aweInitWord(int32_t set, int32_t word);
    +static bool     aweLock(bool lock);
    +static bool     aweOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
    +static uint16_t aweRead(uint16_t port, uint32_t reg, uint32_t channel);
    +static uint32_t aweRead32(uint16_t port, uint32_t reg, uint32_t channel);
    +static void     aweRelease(uint32_t voice);
    +static uint32_t aweSizeDram(void);
    +static void     aweStart(uint32_t voice, const AudioWtVoiceT *v);
    +static void     aweStop(uint32_t voice);
    +static void     aweUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +static bool     aweUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
    +static void     aweWaitMem(uint32_t reg);
    +static void     aweWaitSamples(uint32_t samples);
    +static void     aweWrite(uint16_t port, uint32_t reg, uint32_t channel, uint16_t value);
    +static void     aweWrite32(uint16_t port, uint32_t reg, uint32_t channel, uint32_t value);
    +static uint32_t cliSave(void);
    +static void     flagsRestore(uint32_t flags);
    +
    +AUDIO_SYNTH_DRIVER("awe", AUDIO_PRIORITY_CARD,
    +    .kind    = AUDIO_SYNTH_WAVETABLE,
    +    .open    = aweOpen,
    +    .close   = aweClose,
    +    .upload  = aweUpload,
    +    .start   = aweStart,
    +    .update  = aweUpdate,
    +    .release = aweRelease,
    +    .stop    = aweStop,
    +    .active  = aweActive
    +);
    +
    +
    +// Sounding until released and faded out (its current volume 0).
    +// Interrupt time.
    +static bool aweActive(uint32_t voice) {
    +    return !sAwe.voices[voice].released || (aweRead32(R_CVCF, voice) >> WORD_BITS) != 0;
    +}
    +
    +
    +// Every voice stopped, the FM channels as the chip had them.
    +static void aweClose(void) {
    +    for (uint32_t v = 0; v < AWE_VOICES; v++) {
    +        aweStop(v);
    +    }
    +
    +    aweFmInit();
    +
    +    if (sAwe.locked) {
    +        aweLock(false);
    +        sAwe.locked = false;
    +    }
    +}
    +
    +
    +// The signature: data 3 register 7's second read ends in Ch, and the
    +// first two configuration words keep their fixed bits.
    +static bool aweDetect(void) {
    +    aweRead(R_ID, 0);
    +
    +    if ((aweRead(R_ID, 0) & ID_MASK) != ID_EMU) {
    +        return false;
    +    }
    +
    +    aweWrite(R_HW, HW_CF1, CF1_INIT);
    +    aweWrite(R_HW, HW_CF2, CF2_INIT);
    +    aweWrite(R_HW, HW_CF3, 0);
    +    return (aweRead(R_HW, HW_CF1) & CF1_MASK) == CF1_EXPECT && (aweRead(R_HW, HW_CF2) & CF2_MASK) == CF2_EXPECT;
    +}
    +
    +
    +// The memory channels back to voices: no transfer, released at once.
    +static void aweDmaClose(void) {
    +    aweWaitMem(HW_SMALW);
    +
    +    for (uint32_t ch = 0; ch < AWE_VOICES; ch++) {
    +        aweWrite32(R_CCCA, ch, 0);
    +        aweWrite(R_DCYSUSV, ch, ENV_KILL);
    +    }
    +}
    +
    +
    +// Channels 0 .. channels - 1 moving sample memory (mode: left write or
    +// read), the guide's section 5 steps.
    +static void aweDmaOpen(uint32_t mode, uint32_t channels) {
    +    for (uint32_t ch = 0; ch < channels; ch++) {
    +        aweWrite(R_DCYSUSV, ch, ENV_OFF);
    +        aweWrite32(R_VTFT, ch, 0);
    +        aweWrite32(R_CVCF, ch, 0);
    +        aweWrite32(R_PTRX, ch, PITCH_UNITY);
    +        aweWrite32(R_CPF, ch, PITCH_UNITY);
    +        aweWrite32(R_PSST, ch, 0);
    +        aweWrite32(R_CSL, ch, 0);
    +        aweWrite32(R_CCCA, ch, mode);
    +    }
    +}
    +
    +
    +// Channels 30 and 31 play the FM chip's output through the effects (and
    +// so refresh the sample memory): the setup, then the timing write once
    +// the pointer's bit 12 has gone round.
    +static void aweFmInit(void) {
    +    uint16_t ptr = (uint16_t)(sAwe.base + PORT_POINTER);
    +
    +    aweWrite(R_DCYSUSV, FM_LEFT, ENV_OFF);
    +    aweWrite32(R_PSST, FM_LEFT, FM_LEFT_PSST);
    +    aweWrite32(R_CSL, FM_LEFT, FM_LEFT_CSL);
    +    aweWrite32(R_PTRX, FM_LEFT, 0);
    +    aweWrite32(R_CPF, FM_LEFT, 0);
    +    aweWrite32(R_CCCA, FM_LEFT, FM_LEFT_CCCA);
    +    aweWrite(R_DCYSUSV, FM_RIGHT, ENV_OFF);
    +    aweWrite32(R_PSST, FM_RIGHT, FM_RIGHT_PSST);
    +    aweWrite32(R_CSL, FM_RIGHT, FM_RIGHT_CSL);
    +    aweWrite32(R_PTRX, FM_RIGHT, 0);
    +    aweWrite32(R_CPF, FM_RIGHT, FM_RIGHT_CPF);
    +    aweWrite32(R_CCCA, FM_RIGHT, FM_RIGHT_CCCA);
    +
    +    uint32_t flags = cliSave();
    +
    +    outportw(ptr, (uint16_t)(1 << REG_SHIFT | FM_LEFT));
    +    outportw(sAwe.base + PORT_DATA0, 0);
    +
    +    // Bounded by time and by reads (the clock may not move with
    +    // interrupts off)
    +    uint64_t until = platformMicros() + PHASE_WAIT_US;
    +
    +    for (int32_t i = 0; i < POLL_MAX && !(inportw(ptr) & POINTER_PHASE) && platformMicros() < until; i++) {
    +    }
    +
    +    until = platformMicros() + PHASE_WAIT_US;
    +
    +    for (int32_t i = 0; i < POLL_MAX && (inportw(ptr) & POINTER_PHASE) && platformMicros() < until; i++) {
    +    }
    +
    +    outportw(sAwe.base + PORT_DATA0, FM_TIMING);
    +    outportw(ptr, (uint16_t)(1 << REG_SHIFT | HW_FM_TIMING));
    +    outportw(sAwe.base + PORT_DATA1, 0);
    +    flagsRestore(flags);
    +    aweWrite32(R_VTFT, FM_LEFT, FM_VTFT);
    +    aweWrite32(R_VTFT, FM_RIGHT, FM_VTFT);
    +}
    +
    +
    +// The guide's section 4: every channel cleared with its envelope engine
    +// off (sound registers before the current ones), the memory pointers,
    +// the four sets of initialization arrays (a pause after the first), the
    +// effects configuration, the FM channels; audio on after the memory is
    +// sized.
    +static void aweInit(void) {
    +    aweWrite(R_HW, HW_CF1, CF1_INIT);
    +    aweWrite(R_HW, HW_CF2, CF2_INIT);
    +    aweWrite(R_HW, HW_CF3, 0);
    +
    +    for (uint32_t ch = 0; ch < AWE_CHANNELS; ch++) {
    +        aweWrite(R_DCYSUSV, ch, ENV_OFF);
    +    }
    +
    +    for (uint32_t ch = 0; ch < AWE_CHANNELS; ch++) {
    +        aweWrite(R_ENVVOL, ch, 0);
    +        aweWrite(R_ENVVAL, ch, 0);
    +        aweWrite(R_DCYSUS, ch, 0);
    +        aweWrite(R_ATKHLDV, ch, 0);
    +        aweWrite(R_LFO1VAL, ch, 0);
    +        aweWrite(R_ATKHLD, ch, 0);
    +        aweWrite(R_LFO2VAL, ch, 0);
    +        aweWrite(R_IP, ch, 0);
    +        aweWrite(R_IFATN, ch, 0);
    +        aweWrite(R_PEFE, ch, 0);
    +        aweWrite(R_FMMOD, ch, 0);
    +        aweWrite(R_TREMFRQ, ch, 0);
    +        aweWrite(R_FM2FRQ2, ch, 0);
    +        aweWrite32(R_PTRX, ch, 0);
    +        aweWrite32(R_VTFT, ch, 0);
    +        aweWrite32(R_PSST, ch, 0);
    +        aweWrite32(R_CSL, ch, 0);
    +        aweWrite32(R_CCCA, ch, 0);
    +    }
    +
    +    for (uint32_t ch = 0; ch < AWE_CHANNELS; ch++) {
    +        aweWrite32(R_CPF, ch, 0);
    +        aweWrite32(R_CVCF, ch, 0);
    +    }
    +
    +    aweWrite32(R_HW, HW_SMALR, 0);
    +    aweWrite32(R_HW, HW_SMALR + 1, 0);
    +    aweWrite32(R_HW, HW_SMALW, 0);
    +    aweWrite32(R_HW, HW_SMALW + 1, 0);
    +
    +    for (int32_t set = 0; set < INIT_SETS; set++) {
    +        if (set == INIT_SETS - 1) {
    +            aweWrite32(R_HW, HW_CF4, CF4_INIT);
    +            aweWrite32(R_HW, HW_CF5, CF5_INIT);
    +            aweWrite32(R_HW, HW_CF6, CF6_INIT);
    +        }
    +
    +        for (int32_t w = 0; w < INIT_ARRAYS * INIT_WORDS; w++) {
    +            uint32_t ch = (uint32_t)(w % INIT_WORDS);
    +
    +            switch (w / INIT_WORDS) {
    +                case 0:
    +                    aweWrite(R_INIT1, ch, aweInitWord(set, w));
    +                    break;
    +
    +                case 1:
    +                    aweWrite(R_INIT2, ch, aweInitWord(set, w));
    +                    break;
    +
    +                case 2:
    +                    aweWrite(R_INIT3, ch, aweInitWord(set, w));
    +                    break;
    +
    +                default:
    +                    aweWrite(R_INIT4, ch, aweInitWord(set, w));
    +                    break;
    +            }
    +        }
    +
    +        if (set == 0) {
    +            aweWaitSamples(INIT_WAIT_SAMPLES);
    +        }
    +    }
    +
    +    aweFmInit();
    +}
    +
    +
    +// Word w (INIT1's 32, then INIT2's ...) of set 0..3: the first set a
    +// pattern (even words 03FFh up 400h a pair, odd 0030h with the pair's
    +// number in the high nibble), the second the same with bit 15 on the odd
    +// words, the third the fourth with it.
    +static uint16_t aweInitWord(int32_t set, int32_t word) {
    +    uint16_t odd   = (word & 1) ? INIT_ODD_BIT : 0;
    +    int32_t  pair  = word / 2;
    +    uint16_t first = (word & 1) ? (uint16_t)(INIT1_ODD_BASE | (pair & INIT1_ODD_MASK) << INIT1_ODD_SHIFT) : (uint16_t)(INIT1_EVEN_BASE + INIT1_EVEN_STEP * pair);
    +
    +    switch (set) {
    +        case 0:
    +            return first;
    +
    +        case 1:
    +            return (uint16_t)(first | odd);
    +
    +        case 2:
    +            return (uint16_t)(sInit4[word] | odd);
    +
    +        default:
    +            return sInit4[word];
    +    }
    +}
    +
    +
    +// The interrupt-time code and state, aweCalc's and drvMath's.
    +static bool aweLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)aweActive,    0 },
    +        { (const void *)aweRead,      0 },
    +        { (const void *)aweRead32,    0 },
    +        { (const void *)aweRelease,   0 },
    +        { (const void *)aweStart,     0 },
    +        { (const void *)aweStop,      0 },
    +        { (const void *)aweUpdate,    0 },
    +        { (const void *)aweWrite,     0 },
    +        { (const void *)aweWrite32,   0 },
    +        { (const void *)cliSave,      0 },
    +        { (const void *)flagsRestore, 0 },
    +        { &sAwe,                      sizeof(sAwe) }
    +    };
    +    const AudioLockItemT calc[] = {
    +        { (const void *)aweAtten,      0 },
    +        { (const void *)aweAttack,     0 },
    +        { (const void *)aweCutoff,     0 },
    +        { (const void *)aweDecay,      0 },
    +        { (const void *)aweDelay,      0 },
    +        { (const void *)aweDepth,      0 },
    +        { (const void *)aweHold,       0 },
    +        { (const void *)aweLfoFreq,    0 },
    +        { (const void *)aweModSustain, 0 },
    +        { (const void *)awePan,        0 },
    +        { (const void *)awePitch,      0 },
    +        { (const void *)aweQ,          0 },
    +        { (const void *)aweSend,       0 },
    +        { (const void *)aweVolSustain, 0 }
    +    };
    +
    +    bool ok = audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +
    +    ok = audioLockSpan(calc, (int32_t)(sizeof(calc) / sizeof(calc[0])), lock) && ok;
    +    return drvMathLock(lock) && ok;
    +}
    +
    +
    +// BLASTER's E (or A + 400h), the signature, the setup, the memory size.
    +static bool aweOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info) {
    +    SbBlasterT bl;
    +
    +    (void)cfg;
    +
    +    if (!sbParseBlaster(getenv("BLASTER"), &bl)) {
    +        return false;
    +    }
    +
    +    sAwe.base = (uint16_t)(bl.emu != SB_NONE ? bl.emu : bl.base + EMU_FROM_SB);
    +
    +    if (!aweDetect()) {
    +        return false;
    +    }
    +
    +    sAwe.locked = aweLock(true);
    +
    +    if (!sAwe.locked) {
    +        return false;
    +    }
    +
    +    aweInit();
    +    sAwe.dramBytes = aweSizeDram();
    +    aweWrite(R_HW, HW_CF3, CF3_AUDIO_ON);
    +
    +    if (sAwe.dramBytes == 0) {
    +        dvxLog("Audio: awe: no sample memory at the EMU8000 at %Xh", (unsigned)sAwe.base);
    +        aweClose();
    +        return false;
    +    }
    +
    +    info->voices = AWE_VOICES;
    +    info->memory = sAwe.dramBytes;
    +    info->flags  = AUDIO_WT_ENVELOPES | AUDIO_WT_LFOS | AUDIO_WT_FILTER | AUDIO_WT_EFFECTS | AUDIO_WT_ROM;
    +    info->level  = AWE_VOICE_LEVEL;
    +    snprintf(info->description, sizeof(info->description), "Sound Blaster AWE EMU8000 at %Xh (%lu KB)", (unsigned)sAwe.base, (unsigned long)(sAwe.dramBytes / KB));
    +    return true;
    +}
    +
    +
    +static uint16_t aweRead(uint16_t port, uint32_t reg, uint32_t channel) {
    +    uint32_t flags = cliSave();
    +
    +    outportw(sAwe.base + PORT_POINTER, (uint16_t)(reg << REG_SHIFT | channel));
    +
    +    uint16_t value = inportw(sAwe.base + port);
    +
    +    flagsRestore(flags);
    +    return value;
    +}
    +
    +
    +static uint32_t aweRead32(uint16_t port, uint32_t reg, uint32_t channel) {
    +    uint32_t flags = cliSave();
    +
    +    outportw(sAwe.base + PORT_POINTER, (uint16_t)(reg << REG_SHIFT | channel));
    +
    +    uint32_t low  = inportw(sAwe.base + port);
    +    uint32_t high = inportw(sAwe.base + port + HIGH_WORD);
    +
    +    flagsRestore(flags);
    +    return high << WORD_BITS | low;
    +}
    +
    +
    +// Both envelopes to their release; a loop that lasts while the key is
    +// down moved into the silence after the sample (its end first, as the
    +// guide says).  Interrupt time.
    +static void aweRelease(uint32_t voice) {
    +    AweVoiceT *a = &sAwe.voices[voice];
    +
    +    if (a->loop == AUDIO_WT_LOOP_RELEASE) {
    +        aweWrite32(R_CSL, voice, (uint32_t)a->chorus << SEND_SHIFT | (a->end + ONE_SHOT_LOOP_END - 1));
    +        aweWrite32(R_PSST, voice, (uint32_t)a->pan << PAN_SHIFT | (a->end + ONE_SHOT_LOOP_START - 1));
    +    }
    +
    +    aweWrite(R_DCYSUS, voice, (uint16_t)(RELEASE_BIT | a->modRelease));
    +    aweWrite(R_DCYSUSV, voice, (uint16_t)(RELEASE_BIT | a->volRelease));
    +    a->released = true;
    +}
    +
    +
    +// What the setup wrote to the memory's first word must be readable there,
    +// and each further 512 KB step must hold a word without it showing at the
    +// first (the memory wrapping round), with channel 0 writing and channel
    +// 1 reading.  The bytes the steps found.
    +static uint32_t aweSizeDram(void) {
    +    uint32_t bytes = 0;
    +
    +    aweDmaOpen(DMA_LEFT_WRITE, 1);
    +    aweWrite32(R_CCCA, 1, DMA_LEFT_READ);
    +    aweWrite32(R_HW, HW_SMALW, DRAM_FIRST);
    +    aweWrite(R_HW, HW_SMLD, DRAM_PATTERN_A);
    +    aweFmInit();
    +    aweWaitMem(HW_SMALW);
    +    aweWrite32(R_HW, HW_SMALR, DRAM_FIRST);
    +    aweRead(R_HW, HW_SMLD);
    +
    +    bool found = aweRead(R_HW, HW_SMLD) == DRAM_PATTERN_A;
    +
    +    while (found && bytes + DRAM_STEP_BYTES <= DRAM_MAX_BYTES) {
    +        uint32_t at = DRAM_FIRST + (bytes + DRAM_STEP_BYTES) / BYTES_PER_WORD;
    +
    +        bytes += DRAM_STEP_BYTES;
    +        aweWrite32(R_HW, HW_SMALW, at);
    +        aweWrite(R_HW, HW_SMLD, DRAM_PATTERN_B);
    +        aweWaitMem(HW_SMALW);
    +        aweWrite32(R_HW, HW_SMALR, at);
    +        aweRead(R_HW, HW_SMLD);
    +        found = aweRead(R_HW, HW_SMLD) == DRAM_PATTERN_B;
    +        aweWrite32(R_HW, HW_SMALR, DRAM_FIRST);
    +        aweRead(R_HW, HW_SMLD);
    +        found = found && aweRead(R_HW, HW_SMLD) == DRAM_PATTERN_A;
    +    }
    +
    +    aweDmaClose();
    +    aweFmInit();
    +    return bytes;
    +}
    +
    +
    +// The guide's start: the channel silent with its envelope engine off,
    +// every envelope, LFO and filter value, the loop, pan and sends, the
    +// start address, then the targets and the decay last, which starts the
    +// note.  A sample that does not loop loops in the silence after its end
    +// (the GM ROM has it after every sample too).  Interrupt time.
    +static void aweStart(uint32_t voice, const AudioWtVoiceT *v) {
    +    AweVoiceT *a     = &sAwe.voices[voice];
    +    uint32_t   start = v->addr & AUDIO_WT_ADDR_ROM ? (v->addr & ~AUDIO_WT_ADDR_ROM) / BYTES_PER_WORD : DRAM_FIRST + v->addr / BYTES_PER_WORD;
    +    bool       loops = v->loop != AUDIO_WT_LOOP_NONE && v->loopEnd > v->loopStart;
    +
    +    a->end        = start + v->end;
    +    a->loopStart  = loops ? start + v->loopStart : a->end + ONE_SHOT_LOOP_START;
    +    a->loopEnd    = loops ? start + v->loopEnd : a->end + ONE_SHOT_LOOP_END;
    +    a->cutoff     = aweCutoff(v->filterFc);
    +    a->pan        = awePan(v->pan);
    +    a->chorus     = aweSend(v->chorus);
    +    a->volRelease = aweDecay(v->volEnv[AUDIO_WT_ENV_RELEASE]);
    +    a->modRelease = aweDecay(v->modEnv[AUDIO_WT_ENV_RELEASE]);
    +    a->loop       = loops ? v->loop : AUDIO_WT_LOOP_NONE;
    +    a->released   = false;
    +
    +    aweWrite(R_DCYSUSV, voice, ENV_OFF);
    +    aweWrite32(R_VTFT, voice, VTFT_SILENT);
    +    aweWrite32(R_CVCF, voice, CVCF_SILENT);
    +    aweWrite32(R_PTRX, voice, 0);
    +    aweWrite32(R_CPF, voice, 0);
    +    aweWrite(R_IP, voice, awePitch(v->pitch));
    +    aweWrite(R_ENVVAL, voice, aweDelay(v->modEnv[AUDIO_WT_ENV_DELAY]));
    +    aweWrite(R_ATKHLD, voice, (uint16_t)(aweHold(v->modEnv[AUDIO_WT_ENV_HOLD]) << HOLD_SHIFT | aweAttack(v->modEnv[AUDIO_WT_ENV_ATTACK])));
    +    aweWrite(R_DCYSUS, voice, (uint16_t)(aweModSustain(v->modEnv[AUDIO_WT_ENV_SUSTAIN]) << SUSTAIN_SHIFT | aweDecay(v->modEnv[AUDIO_WT_ENV_DECAY])));
    +    aweWrite(R_ENVVOL, voice, aweDelay(v->volEnv[AUDIO_WT_ENV_DELAY]));
    +    aweWrite(R_ATKHLDV, voice, (uint16_t)(aweHold(v->volEnv[AUDIO_WT_ENV_HOLD]) << HOLD_SHIFT | aweAttack(v->volEnv[AUDIO_WT_ENV_ATTACK])));
    +    aweWrite(R_IFATN, voice, (uint16_t)(a->cutoff << FILTER_SHIFT | aweAtten(v->attenuation)));
    +    aweWrite(R_PEFE, voice, (uint16_t)(aweDepth(v->modEnvToPitch, STEP_PITCH_NUM, STEP_PITCH_DEN) << DEPTH_SHIFT | aweDepth(v->modEnvToFilter, STEP_ENV_CUT_NUM, STEP_ENV_CUT_DEN)));
    +    aweWrite(R_LFO1VAL, voice, aweDelay(v->modLfoDelay));
    +    aweWrite(R_LFO2VAL, voice, aweDelay(v->vibLfoDelay));
    +    aweWrite(R_FMMOD, voice, (uint16_t)(aweDepth(v->modLfoToPitch, STEP_PITCH_NUM, STEP_PITCH_DEN) << DEPTH_SHIFT | aweDepth(v->modLfoToFilter, STEP_LFO_CUT_NUM, STEP_LFO_CUT_DEN)));
    +    aweWrite(R_TREMFRQ, voice, (uint16_t)(aweDepth(v->modLfoToVolume, STEP_TREM_NUM, STEP_TREM_DEN) << DEPTH_SHIFT | aweLfoFreq(v->modLfoFreq)));
    +    aweWrite(R_FM2FRQ2, voice, (uint16_t)(aweDepth(v->vibLfoToPitch, STEP_PITCH_NUM, STEP_PITCH_DEN) << DEPTH_SHIFT | aweLfoFreq(v->vibLfoFreq)));
    +    aweWrite32(R_PSST, voice, (uint32_t)a->pan << PAN_SHIFT | (a->loopStart - 1));
    +    aweWrite32(R_CSL, voice, (uint32_t)a->chorus << SEND_SHIFT | (a->loopEnd - 1));
    +    aweWrite32(R_CCCA, voice, (uint32_t)aweQ(v->filterQ) << Q_SHIFT | (start + v->offset - 1));
    +    aweWrite32(R_UNKNOWN4, voice, 0);
    +    aweWrite32(R_UNKNOWN5, voice, 0);
    +    aweWrite32(R_VTFT, voice, VOL_TARGET_FULL | (uint32_t)a->cutoff << FILTER_SHIFT);
    +    aweWrite32(R_CVCF, voice, VOL_TARGET_FULL | CF_FULL);
    +    aweWrite32(R_PTRX, voice, PITCH_UNITY | (uint32_t)aweSend(v->reverb) << REVERB_SHIFT);
    +    aweWrite32(R_CPF, voice, PITCH_UNITY);
    +    aweWrite(R_DCYSUSV, voice, (uint16_t)(aweVolSustain(v->volEnv[AUDIO_WT_ENV_SUSTAIN]) << SUSTAIN_SHIFT | aweDecay(v->volEnv[AUDIO_WT_ENV_DECAY])));
    +}
    +
    +
    +// The guide's hard stop.  Interrupt time.
    +static void aweStop(uint32_t voice) {
    +    aweWrite(R_DCYSUSV, voice, ENV_OFF);
    +    aweWrite32(R_VTFT, voice, VTFT_SILENT);
    +    aweWrite32(R_CVCF, voice, CVCF_SILENT);
    +    sAwe.voices[voice].released = true;
    +}
    +
    +
    +// Changes while the note plays (the guide's section 8).  Interrupt time.
    +static void aweUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    AweVoiceT *a = &sAwe.voices[voice];
    +
    +    if (what & AUDIO_WT_SET_PITCH) {
    +        aweWrite(R_IP, voice, awePitch(v->pitch));
    +    }
    +
    +    if (what & (AUDIO_WT_SET_VOLUME | AUDIO_WT_SET_FILTER)) {
    +        a->cutoff = aweCutoff(v->filterFc);
    +        aweWrite(R_IFATN, voice, (uint16_t)(a->cutoff << FILTER_SHIFT | aweAtten(v->attenuation)));
    +    }
    +
    +    if (what & AUDIO_WT_SET_PAN) {
    +        a->pan = awePan(v->pan);
    +        aweWrite32(R_PSST, voice, (uint32_t)a->pan << PAN_SHIFT | (a->loopStart - 1));
    +    }
    +
    +    if (what & AUDIO_WT_SET_EFFECTS) {
    +        a->chorus = aweSend(v->chorus);
    +        aweWrite32(R_CSL, voice, (uint32_t)a->chorus << SEND_SHIFT | (a->loopEnd - 1));
    +        aweWrite32(R_PTRX, voice, (aweRead32(R_PTRX, voice) & PITCH_TARGET_MASK) | (uint32_t)aweSend(v->reverb) << REVERB_SHIFT);
    +    }
    +
    +    if (what & AUDIO_WT_SET_LFO) {
    +        aweWrite(R_FMMOD, voice, (uint16_t)(aweDepth(v->modLfoToPitch, STEP_PITCH_NUM, STEP_PITCH_DEN) << DEPTH_SHIFT | aweDepth(v->modLfoToFilter, STEP_LFO_CUT_NUM, STEP_LFO_CUT_DEN)));
    +        aweWrite(R_FM2FRQ2, voice, (uint16_t)(aweDepth(v->vibLfoToPitch, STEP_PITCH_NUM, STEP_PITCH_DEN) << DEPTH_SHIFT | aweLfoFreq(v->vibLfoFreq)));
    +    }
    +}
    +
    +
    +// 16-bit frames through all 30 voice channels set to write (nothing plays
    +// while samples load), a run at a time with interrupts off (the pointer
    +// must stay on the data register).  The FM channels (30, 31) are not
    +// touched, so they go on refreshing the memory.  Program time.
    +static bool aweUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits) {
    +    uint16_t ptr  = (uint16_t)(sAwe.base + PORT_POINTER);
    +    uint16_t data = (uint16_t)(sAwe.base + PORT_DATA1);
    +
    +    if (bits != WORD_BITS || addr > sAwe.dramBytes || count > (sAwe.dramBytes - addr) / BYTES_PER_WORD) {
    +        return false;
    +    }
    +
    +    aweDmaOpen(DMA_LEFT_WRITE, AWE_VOICES);
    +    aweWaitMem(HW_SMALW);
    +    aweWrite32(R_HW, HW_SMALW, DRAM_FIRST + addr / BYTES_PER_WORD);
    +
    +    for (uint32_t done = 0; done < count;) {
    +        uint32_t run   = count - done < UPLOAD_RUN ? count - done : UPLOAD_RUN;
    +        uint32_t flags = cliSave();
    +
    +        outportw(ptr, (uint16_t)(1 << REG_SHIFT | HW_SMLD));
    +
    +        for (uint32_t i = 0; i < run; i++) {
    +            outportw(data, (uint16_t)frames[done + i]);
    +        }
    +
    +        flagsRestore(flags);
    +        done += run;
    +    }
    +
    +    aweDmaClose();
    +    return true;
    +}
    +
    +
    +// The memory write (or read) pointer no longer busy (bounded wait).
    +static void aweWaitMem(uint32_t reg) {
    +    for (int32_t i = 0; i < POLL_MAX && (aweRead32(R_HW, reg) & MEM_BUSY); i++) {
    +    }
    +}
    +
    +
    +// samples sample periods by the chip's own counter (it wraps at 65536).
    +static void aweWaitSamples(uint32_t samples) {
    +    uint16_t from = aweRead(R_HW2, HW_WC);
    +
    +    for (int32_t i = 0; i < POLL_MAX && (uint16_t)(aweRead(R_HW2, HW_WC) - from) < samples; i++) {
    +    }
    +}
    +
    +
    +static void aweWrite(uint16_t port, uint32_t reg, uint32_t channel, uint16_t value) {
    +    uint32_t flags = cliSave();
    +
    +    outportw(sAwe.base + PORT_POINTER, (uint16_t)(reg << REG_SHIFT | channel));
    +    outportw(sAwe.base + port, value);
    +    flagsRestore(flags);
    +}
    +
    +
    +// A double word: the low word, then at once the high.
    +static void aweWrite32(uint16_t port, uint32_t reg, uint32_t channel, uint32_t value) {
    +    uint32_t flags = cliSave();
    +
    +    outportw(sAwe.base + PORT_POINTER, (uint16_t)(reg << REG_SHIFT | channel));
    +    outportw(sAwe.base + port, (uint16_t)(value & WORD_MASK));
    +    outportw(sAwe.base + port + HIGH_WORD, (uint16_t)(value >> WORD_BITS));
    +    flagsRestore(flags);
    +}
    +
    +
    +static uint32_t cliSave(void) {
    +    uint32_t flags;
    +
    +    __asm__ __volatile__ ("pushfl\n\tpopl %0\n\tcli" : "=r"(flags) : : "memory");
    +    return flags;
    +}
    +
    +
    +static void flagsRestore(uint32_t flags) {
    +    if (flags & FLAGS_IF) {
    +        __asm__ __volatile__ ("sti" : : : "memory");
    +    }
    +}
    diff --git a/src/libs/kpunch/audio/drivers/gus/gusDrv.c b/src/libs/kpunch/audio/drivers/gus/gusDrv.c
    new file mode 100644
    index 00000000..1aa23aae
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/gus/gusDrv.c
    @@ -0,0 +1,690 @@
    +// 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.
    +
    +// gusDrv.c  -- the Gravis UltraSound sound driver (gus.drv)
    +//
    +// Where the card is: the ULTRASND variable (port, DRAM DMA, record DMA,
    +// GF1 IRQ, MIDI IRQ: SET ULTRASND=220,1,1,11,5).  The GF1 is reset with
    +// DVX.INI [audio] gusVoices active (14..32, default 28: the output runs
    +// at 617,400 / voices Hz, exactly 22,050 at 28), its latches set to that
    +// DMA channel and IRQ, and its memory sized.
    +//
    +// Digital sound plays on voices 0 and 1 (left and right; one centred for
    +// mono) from a ring in the GF1's memory at its top: two halves per
    +// channel, 16-bit (8-bit when the configuration asks), at the mixing
    +// rate, which the GF1 resamples to its output rate.  The SDK's double
    +// buffer (3.11): the left voice plays the first half with its end at the
    +// middle and rollover on (an interrupt there, playing on), then the
    +// second with its end at the end and looping on (an interrupt as it loops
    +// back); the right voice loops the whole ring with no interrupt.  Each
    +// interrupt mixes the half just played (audio.lib's mixer, inside the
    +// interrupt with interrupts enabled again) and moves it into the card by
    +// DMA, left then right, the second transfer started by the first's
    +// terminal count interrupt.  Which half is free comes from where the left
    +// voice is, so a repeated interrupt changes nothing.  A loop starts one
    +// sample before the ring, at a pad holding a copy of the ring's last
    +// sample (the SDK's fix for the click a GF1 makes looping from end to
    +// start).
    +//
    +// The rest of the voices and the memory below the ring are the wavetable
    +// synthesizer's (guswt.drv's code, linked in here too, handed to audio.lib
    +// as this driver's own: AudioDrvInfoT.synth).  The card stays on while
    +// either plays; opening again while the synthesizer plays (after the
    +// output was closed for quiet) leaves the card as it is.
    +//
    +// Recording (recordStart / recordStop) uses ULTRASND's second DMA channel
    +// (the first when there is no usable second), latched with the first at
    +// reset.  An UltraSound MAX (its board version 10 or 11) records
    +// through its CS4231 codec (codec/codecHw.c, as wss.drv drives one):
    +// turned on by the MAX's control register at base + 506h, found at base +
    +// 10Ch, capturing in single-DMA mode over two halves of an auto-init
    +// buffer, its interrupt on the GF1's line (looked at after the GF1's
    +// sources).  Other boards record with the GF1's own sampling: 8-bit
    +// unsigned, mono or stereo, 5,510 to 44,100 Hz, from the line input or
    +// the microphone (the mix register); the GF1 stops at each terminal count
    +// (it has no auto-init), so each half is started from the interrupt that
    +// ends the other, the next half first and then the finished one handed
    +// on.  Neither the CD input nor a MAX's AUX inputs are recorded.  (No
    +// emulator this was written against records on a GUS: Linux's gus_pcm.c
    +// and gusmax.c are the reference.)
    +
    +#include "audioDrv.h"
    +#include "../../codec/codecHw.h"
    +#include "../../gus/gusHw.h"
    +#include "../../gus/gusSynth.h"
    +#include "../../isa/isaHw.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define PCM_LEFT            0
    +#define PCM_RIGHT           1
    +#define PCM_VOICES          2
    +#define PCM_HALF_FRAMES     512             // about 23 ms at 22,050 Hz
    +#define PCM_HALVES          2
    +#define PCM_RING            (PCM_HALF_FRAMES * PCM_HALVES)
    +#define PCM_VOLUME          GUS_VOLUME_MAX
    +#define CHANNELS_MAX        2
    +#define BITS_8              8
    +#define BITS_16             16
    +#define BYTE_BITS           8
    +#define WORD_BYTES          4
    +#define SILENCE_8           0x80
    +#define ISR_PASSES          16              // 2X6 looked at, at most, per interrupt
    +#define IRQ_SOURCE_NONE     0xC0            // 8Fh: neither a wave nor a volume interrupt
    +#define IRQ_SOURCE_WAVE     0x80            // clear: a wave interrupt
    +#define IRQ_SOURCE_VOICE    0x1F
    +#define FIRST_IRQ_WAIT_US   500000
    +#define HALF_NONE           (-1)
    +#define REC_HALF_FRAMES     512             // a recorded half (at most 2 KB: 16-bit stereo)
    +#define REC_HALVES          2
    +
    +// A MAX codec's input for each source recorded (the GF1's: the mix
    +// register)
    +static const uint8_t sMaxInputs[] = {
    +    [AUDIO_REC_MIC]  = CODEC_INPUT_MIC | CODEC_INPUT_MIC_BOOST,
    +    [AUDIO_REC_LINE] = CODEC_INPUT_LINE
    +};
    +
    +typedef struct {
    +    int16_t           mixed[PCM_HALF_FRAMES * CHANNELS_MAX];    // one half, as the mixer gives it (or as it was recorded)
    +    int16_t           chan[PCM_HALF_FRAMES];                    // one channel of it
    +    IsaDmaT           dma;              // the staging buffer: a half of each channel (recording: the two halves)
    +    IsaIrqT           irq;
    +    GusEnvT           env;
    +    CodecT            codec;            // a MAX's (recording)
    +    uint32_t          voices;           // DVX.INI gusVoices (cardUp)
    +    uint32_t          recHalfBytes;     // a recorded half
    +    uint32_t          ring[CHANNELS_MAX];   // each channel's ring in DRAM (its pad just below)
    +    uint32_t          rate;
    +    uint32_t          bits;
    +    uint32_t          channels;
    +    uint32_t          sampleBytes;
    +    uint32_t          halfBytes;        // one channel's half
    +    uint8_t           voiceMode;        // 16-bit or 8-bit
    +    uint8_t           dmaFlags;         // 41h for a transfer
    +    uint8_t           format;           // a MAX codec's I8 (recording)
    +    uint8_t           sampleCtrl;       // 49h while the GF1 samples
    +    uint8_t           sampleRate;       // 48h
    +    volatile int32_t  lastHalf;         // the half refilled last
    +    volatile int32_t  pendingHalf;      // the right channel's transfer waits for the left's
    +    volatile int32_t  sampleHalf;       // the half the GF1 samples into
    +    volatile uint32_t irqCount;         // halves played (or recorded)
    +    volatile uint32_t tcCount;          // DMA transfers done
    +    bool              locked;
    +    bool              playing;
    +    bool              max;              // an UltraSound MAX: its codec records
    +    bool              recording;
    +    bool              recCodec;         // recording through the MAX's codec
    +} GusPcmT;
    +
    +static GusPcmT sPcm = { .dma.selector = -1 };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void     cardUp(void);
    +static uint32_t chanAddr(uint32_t channel, uint32_t frame);
    +static void     dmaStart(uint32_t channel, int32_t half);
    +static void     gusCapture(uint32_t half);
    +static void     gusClose(void);
    +static void     gusIsr(void);
    +static bool     gusLockPcm(bool lock);
    +static bool     gusOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info);
    +static bool     gusPlay(void);
    +static bool     gusRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got);
    +static void     halfEnded(void);
    +static bool     maxCodecOn(void);
    +static void     sampleNext(void);
    +static void     sampleStart(uint32_t half);
    +static void     voiceAddr(uint32_t voice, uint8_t reg, uint32_t addr, bool lowFirst);
    +static void     voicesStart(void);
    +
    +AUDIO_DRIVER("gus", AUDIO_PRIORITY_GUS,
    +    .open        = gusOpen,
    +    .play        = gusPlay,
    +    .close       = gusClose,
    +    .recordStart = gusRecordStart,
    +    .recordStop  = gusClose
    +);
    +
    +// The synthesizer on the other voices
    +static const AudioSynthDriverT sGusSynth = {
    +    .hdr     = DVX_DRIVER_HDR(AudioSynthDriverT, "gus", AUDIO_PRIORITY_GUS),
    +    .kind    = AUDIO_SYNTH_WAVETABLE,
    +    .open    = gusSynthOpen,
    +    .close   = gusSynthClose,
    +    .upload  = gusSynthUpload,
    +    .start   = gusSynthStart,
    +    .update  = gusSynthUpdate,
    +    .release = gusSynthRelease,
    +    .stop    = gusSynthStop,
    +    .active  = gusSynthActive
    +};
    +
    +
    +// The card reset with DVX.INI's voices and routed to ULTRASND's IRQ and
    +// both DMA channels, unless it already runs (the synthesizer, or this
    +// driver since its last release).
    +static void cardUp(void) {
    +    if (gusHw.running) {
    +        return;
    +    }
    +
    +    gusHw.dramBytes = gusSizeDram();
    +    gusHw.park      = gusHw.dramBytes - GUS_ALIGN;
    +    gusOutput(false);
    +    gusReset(sPcm.voices);
    +    gusLatches(gusIrqCode(sPcm.env.irq1), gusDmaLatch(sPcm.env.dma1, sPcm.env.dma2));
    +    gusHw.running = true;
    +}
    +
    +
    +// A channel's ring frame as a byte address in DRAM (frame -1: the pad).
    +static uint32_t chanAddr(uint32_t channel, uint32_t frame) {
    +    return sPcm.ring[channel] + frame * sPcm.sampleBytes;
    +}
    +
    +
    +// One channel's half from the staging buffer into its ring by DMA: the PC
    +// channel, then the GF1's start address and control.  Interrupt time.
    +static void dmaStart(uint32_t channel, int32_t half) {
    +    uint32_t addr = chanAddr(channel, (uint32_t)half * PCM_HALF_FRAMES);
    +
    +    if (sPcm.dma.channel >= ISA_DMA_FIRST_16) {
    +        addr = gusAddr16(addr);
    +    }
    +
    +    isaDmaProgram(&sPcm.dma, channel * sPcm.halfBytes, sPcm.halfBytes, ISA_DMA_ONCE_PLAY);
    +    gusWrite16(GUS_G_DMA_ADDR, (uint16_t)(addr >> GUS_DMA_ADDR_SHIFT));
    +    gusWrite8(GUS_G_DMA_CONTROL, sPcm.dmaFlags);
    +}
    +
    +
    +// Hand the half just recorded to audio.lib.  Interrupt time.
    +static void gusCapture(uint32_t half) {
    +    isaDmaRead(&sPcm.dma, half * sPcm.recHalfBytes, (uint32_t *)sPcm.mixed, sPcm.recHalfBytes / WORD_BYTES);
    +    audioRecPush(sPcm.mixed, sPcm.recHalfBytes);
    +}
    +
    +
    +// Stops playing or recording (recordStop is this too).  Also runs from
    +// audio.lib's shutdown hook, so it logs nothing and copes with any stage
    +// of gusOpen, gusPlay, and gusRecordStart.
    +static void gusClose(void) {
    +    if (sPcm.playing) {
    +        gusVoiceStop(PCM_LEFT);
    +        gusVoiceStop(PCM_RIGHT);
    +        gusWrite8(GUS_G_DMA_CONTROL, 0);
    +        isaDmaStop(&sPcm.dma);
    +        sPcm.playing = false;
    +    }
    +
    +    if (sPcm.recording && sPcm.recCodec) {
    +        codecStop(&sPcm.codec);
    +    } else if (sPcm.recording) {
    +        gusWrite8(GUS_G_SAMPLE_CTRL, 0);
    +        gusRead8(GUS_G_SAMPLE_CTRL);
    +        gusInputs(false, false);
    +    }
    +
    +    if (sPcm.recording) {
    +        isaDmaStop(&sPcm.dma);
    +        sPcm.recording = false;
    +        sPcm.recCodec  = false;
    +    }
    +
    +    gusHw.pcmOpen = false;
    +    gusRelease();
    +
    +    isaIrqUnhook(&sPcm.irq);
    +
    +    if (sPcm.locked) {
    +        gusLockPcm(false);
    +        sPcm.locked = false;
    +    }
    +
    +    isaDmaFree(&sPcm.dma);
    +}
    +
    +
    +// The GF1's interrupt: every source 2X6 shows taken (a terminal count by
    +// reading 41h, or 49h while the GF1 samples, wave and volume interrupts
    +// by draining the 8Fh queue), then a MAX codec's own interrupt (the same
    +// line); then, for the left voice's wave interrupt, the half just played
    +// mixed, for a sampled block the next started and this one handed on, for
    +// the codec the half it finished handed on, with interrupts enabled; the
    +// PIC acknowledged last.
    +static void gusIsr(void) {
    +    bool wave    = false;
    +    bool sampled = false;
    +
    +    if (isaIrqSpurious(&sPcm.irq)) {
    +        return;
    +    }
    +
    +    for (int32_t pass = 0; pass < ISR_PASSES; pass++) {
    +        uint8_t status = inportb(gusHw.base + GUS_IRQ_STATUS);
    +
    +        if (!(status & (GUS_IRQ_WAVE | GUS_IRQ_VOLUME | GUS_IRQ_DMA))) {
    +            break;
    +        }
    +
    +        if ((status & GUS_IRQ_DMA) && sPcm.recording) {
    +            sampled = (gusRead8(GUS_G_SAMPLE_CTRL) & GUS_SC_IRQ_PENDING) || sampled;
    +        } else if (status & GUS_IRQ_DMA) {
    +            gusRead8(GUS_G_DMA_CONTROL);
    +            sPcm.tcCount++;
    +
    +            if (sPcm.pendingHalf != HALF_NONE) {
    +                int32_t half = sPcm.pendingHalf;
    +
    +                sPcm.pendingHalf = HALF_NONE;
    +                dmaStart(PCM_RIGHT, half);
    +            }
    +        }
    +
    +        for (uint8_t src = gusRead8(GUS_G_IRQ_SOURCE); (src & IRQ_SOURCE_NONE) != IRQ_SOURCE_NONE; src = gusRead8(GUS_G_IRQ_SOURCE)) {
    +            wave = wave || (!(src & IRQ_SOURCE_WAVE) && (src & IRQ_SOURCE_VOICE) == PCM_LEFT);
    +        }
    +    }
    +
    +    bool captured = sPcm.recCodec && codecIrq(&sPcm.codec);
    +
    +    if (wave && sPcm.playing) {
    +        sPcm.irqCount++;
    +        halfEnded();
    +    }
    +
    +    if (sampled && sPcm.recording && !sPcm.recCodec) {
    +        sPcm.irqCount++;
    +        sampleNext();
    +    }
    +
    +    if (captured) {
    +        uint32_t half = isaDmaPosition(&sPcm.dma) < sPcm.recHalfBytes ? 1 : 0;
    +
    +        sPcm.irqCount++;
    +        __asm__ __volatile__ ("sti" : : : "memory");
    +        gusCapture(half);
    +        __asm__ __volatile__ ("cli" : : : "memory");
    +    }
    +
    +    isaIrqEnd(&sPcm.irq);
    +}
    +
    +
    +// This file's interrupt-time code and state, and the shared GF1, codec,
    +// and ISA code's.
    +static bool gusLockPcm(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)chanAddr,    0 },
    +        { (const void *)dmaStart,    0 },
    +        { (const void *)gusCapture,  0 },
    +        { (const void *)gusIsr,      0 },
    +        { (const void *)halfEnded,   0 },
    +        { (const void *)sampleNext,  0 },
    +        { (const void *)sampleStart, 0 },
    +        { (const void *)voiceAddr,   0 },
    +        { &sPcm,                     sizeof(sPcm) }
    +    };
    +
    +    bool ok = audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +
    +    ok = isaLock(lock) && ok;
    +    ok = codecLock(lock) && ok;
    +    return gusLock(lock) && ok;
    +}
    +
    +
    +// ULTRASND, the card at its port, reset and routed; the ring at the top
    +// of its memory, under the silent park block.
    +static bool gusOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
    +    memset(&sPcm, 0, sizeof(sPcm));
    +    sPcm.dma.selector = -1;
    +
    +    if (!gusParseEnv(getenv("ULTRASND"), &sPcm.env)) {
    +        dvxLog("Audio: gus: ULTRASND is not set (for example SET ULTRASND=220,1,1,11,5)");
    +        return false;
    +    }
    +
    +    if (!gusHw.running && !gusProbe((uint16_t)sPcm.env.base)) {
    +        dvxLog("Audio: gus: no UltraSound at %lXh", (unsigned long)sPcm.env.base);
    +        return false;
    +    }
    +
    +    if (gusIrqCode(sPcm.env.irq1) == 0 || gusDmaCode(sPcm.env.dma1) == 0) {
    +        dvxLog("Audio: gus: ULTRASND's IRQ %lu or DMA %lu cannot be used", (unsigned long)sPcm.env.irq1, (unsigned long)sPcm.env.dma1);
    +        return false;
    +    }
    +
    +    sPcm.bits        = cfg->bits ? cfg->bits : BITS_16;
    +    sPcm.channels    = cfg->channels ? cfg->channels : CHANNELS_MAX;
    +    sPcm.sampleBytes = sPcm.bits / BYTE_BITS;
    +    sPcm.halfBytes   = PCM_HALF_FRAMES * sPcm.sampleBytes;
    +    sPcm.voiceMode   = sPcm.bits == BITS_16 ? GUS_VC_16BIT : 0;
    +    sPcm.lastHalf    = HALF_NONE;
    +    sPcm.pendingHalf = HALF_NONE;
    +    sPcm.voices      = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, GUS_INI_VOICES, GUS_VOICES_DEFAULT);
    +    sPcm.max         = gusIsMax(gusBoardVersion());
    +
    +    // The top of the memory: the park block, then each channel's ring with
    +    // a block below it (its pad at the top of that) and one above; the
    +    // synthesizer has what is below and the voices after these two
    +    cardUp();
    +
    +    uint32_t span = (PCM_RING * sPcm.sampleBytes + 2 * GUS_ALIGN + GUS_ALIGN - 1) / GUS_ALIGN * GUS_ALIGN;
    +
    +    for (uint32_t c = 0; c < CHANNELS_MAX; c++) {
    +        sPcm.ring[c] = gusHw.park - (c + 1) * span + GUS_ALIGN;
    +    }
    +
    +    gusHw.synthTop   = sPcm.ring[CHANNELS_MAX - 1] - GUS_ALIGN;
    +    gusHw.synthFirst = PCM_VOICES;
    +    gusHw.pcmOpen    = true;
    +
    +    sPcm.rate     = cfg->rate < gusHw.outRate ? cfg->rate : gusHw.outRate;
    +    sPcm.dmaFlags = (uint8_t)(GUS_DMA_ENABLE | GUS_DMA_TC_IRQ | (sPcm.env.dma1 >= ISA_DMA_FIRST_16 ? GUS_DMA_WIDE_CHAN : 0) | (sPcm.bits == BITS_16 ? GUS_DMA_DATA16 : GUS_DMA_INVERT));
    +
    +    if (!isaDmaAlloc(&sPcm.dma, sPcm.env.dma1, sPcm.halfBytes * CHANNELS_MAX)) {
    +        dvxLog("Audio: gus: no conventional memory for the DMA buffer");
    +        return false;
    +    }
    +
    +    snprintf(info->description, sizeof(info->description), "Gravis UltraSound%s at %lXh (%lu KB)", sPcm.max ? " MAX" : "", (unsigned long)sPcm.env.base, (unsigned long)(gusHw.dramBytes / 1024));
    +    info->rate       = sPcm.rate;
    +    info->bits       = sPcm.bits;
    +    info->channels   = sPcm.channels;
    +    info->hasDac     = true;
    +    info->hasSynth   = true;
    +    info->synth      = &sGusSynth;
    +    info->recSources = (1U << AUDIO_REC_MIC) | (1U << AUDIO_REC_LINE);
    +    dvxLog("Audio: gus: %lXh%s, IRQ %lu, DMA %lu, %lu KB, %lu voices (%lu Hz)", (unsigned long)sPcm.env.base, sPcm.max ? " (MAX)" : "", (unsigned long)sPcm.env.irq1, (unsigned long)sPcm.env.dma1, (unsigned long)(gusHw.dramBytes / 1024), (unsigned long)gusHw.voices, (unsigned long)gusHw.outRate);
    +    return true;
    +}
    +
    +
    +static bool gusPlay(void) {
    +    sPcm.locked = gusLockPcm(true);
    +
    +    if (!sPcm.locked) {
    +        return false;
    +    }
    +
    +    isaDmaFill(&sPcm.dma, sPcm.bits == BITS_16 ? 0 : SILENCE_8);
    +
    +    if (!isaIrqHook(&sPcm.irq, sPcm.env.irq1, gusIsr)) {
    +        return false;
    +    }
    +
    +    // The rings and their pads silent (8-bit samples signed in the card)
    +    for (uint32_t c = 0; c < sPcm.channels; c++) {
    +        for (uint32_t b = 0; b < (PCM_RING + 1) * sPcm.sampleBytes; b++) {
    +            gusPoke(chanAddr(c, 0) - sPcm.sampleBytes + b, 0);
    +        }
    +    }
    +
    +    for (uint32_t b = 0; b < GUS_ALIGN; b++) {
    +        gusPoke(gusHw.park + b, 0);
    +    }
    +
    +
    +    sPcm.playing = true;
    +    voicesStart();
    +    gusOutput(true);
    +
    +    // A card on another IRQ than ULTRASND says plays one ring and stops,
    +    // and one on another DMA channel never finishes a transfer; give up
    +    // on it so the next driver is tried.
    +    uint64_t until = platformMicros() + FIRST_IRQ_WAIT_US;
    +
    +    while ((sPcm.irqCount == 0 || sPcm.tcCount == 0) && platformMicros() < until) {
    +        platformYield();
    +    }
    +
    +    if (sPcm.irqCount == 0) {
    +        dvxLog("Audio: gus: no interrupt from the card on IRQ %lu (check ULTRASND)", (unsigned long)sPcm.env.irq1);
    +        return false;
    +    }
    +
    +    if (sPcm.tcCount == 0) {
    +        dvxLog("Audio: gus: no DMA transfer into the card finished on DMA %lu (check ULTRASND)", (unsigned long)sPcm.env.dma1);
    +        return false;
    +    }
    +
    +
    +    return true;
    +}
    +
    +
    +// Take the card for recording (the output is closed): a MAX's codec, else
    +// the GF1's sampling, on the record channel, from source; got is what it
    +// records.  The first half must finish within FIRST_IRQ_WAIT_US (else the
    +// IRQ or the channel is wrong, or nothing here records).
    +static bool gusRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got) {
    +    uint32_t dma      = gusRecordDma(sPcm.env.dma1, sPcm.env.dma2);
    +    uint32_t channels = want->channels == CHANNELS_MAX ? CHANNELS_MAX : 1;
    +    uint32_t bits     = BITS_8;
    +    uint32_t rate;
    +
    +    cardUp();
    +    gusHw.pcmOpen = true;
    +    sPcm.recCodec = sPcm.max && maxCodecOn();
    +
    +    if (sPcm.recCodec) {
    +        bits        = want->bits == BITS_8 ? BITS_8 : BITS_16;
    +        sPcm.format = (uint8_t)(codecFormat(bits, channels) | codecRateCode(want->rate, &rate));
    +    } else {
    +        sPcm.sampleRate = gusSampleRate(want->rate, &rate);
    +        sPcm.sampleCtrl = (uint8_t)(GUS_SC_START | GUS_SC_IRQ | GUS_SC_INVERT | (channels > 1 ? GUS_SC_STEREO : 0) | (dma >= ISA_DMA_FIRST_16 ? GUS_SC_WIDE_CHAN : 0));
    +    }
    +
    +    sPcm.recHalfBytes = REC_HALF_FRAMES * bits / BYTE_BITS * channels;
    +
    +    if (!isaDmaAlloc(&sPcm.dma, dma, sPcm.recHalfBytes * REC_HALVES)) {
    +        dvxLog("Audio: gus: no conventional memory for the recording's DMA buffer");
    +        gusClose();
    +        return false;
    +    }
    +
    +    sPcm.locked = gusLockPcm(true);
    +
    +    if (!sPcm.locked || !isaIrqHook(&sPcm.irq, sPcm.env.irq1, gusIsr)) {
    +        gusClose();
    +        return false;
    +    }
    +
    +    sPcm.irqCount  = 0;
    +    sPcm.recording = true;
    +
    +    if (sPcm.recCodec) {
    +        codecSetFormat(&sPcm.codec, sPcm.format);
    +        codecInputSet(&sPcm.codec, sMaxInputs[source]);
    +        isaDmaProgram(&sPcm.dma, 0, sPcm.dma.bytes, ISA_DMA_AUTO_RECORD);
    +        codecStart(&sPcm.codec, REC_HALF_FRAMES, CODEC_CAPTURE);
    +    } else {
    +        gusInputs(source == AUDIO_REC_LINE, source == AUDIO_REC_MIC);
    +        sampleStart(0);
    +    }
    +
    +    uint64_t until = platformMicros() + FIRST_IRQ_WAIT_US;
    +
    +    while (sPcm.irqCount == 0 && platformMicros() < until) {
    +        platformYield();
    +    }
    +
    +    if (sPcm.irqCount == 0) {
    +        dvxLog("Audio: gus: no interrupt from the card while recording on IRQ %lu, DMA %lu (check ULTRASND)", (unsigned long)sPcm.env.irq1, (unsigned long)dma);
    +        gusClose();
    +        return false;
    +    }
    +
    +    got->rate     = rate;
    +    got->bits     = bits;
    +    got->channels = channels;
    +    dvxLog("Audio: gus: recording through the %s, DMA %lu, %lu Hz", sPcm.recCodec ? "MAX's codec" : "GF1", (unsigned long)dma, (unsigned long)rate);
    +    return true;
    +}
    +
    +
    +// The left voice crossed a half: its end and loop set for the half it
    +// now plays, and the other one mixed (with interrupts on) and moved into
    +// the card; once per half, however many interrupts come.  Interrupt time.
    +static void halfEnded(void) {
    +    uint32_t mid  = chanAddr(PCM_LEFT, PCM_HALF_FRAMES);
    +    bool     wide = sPcm.bits == BITS_16;
    +    bool     late = gusVoicePos(PCM_LEFT) >= (wide ? gusAddr16(mid) : mid);
    +    int32_t  free = late ? 0 : 1;
    +
    +    // In the second half: loop at the ring's end; in the first: roll over
    +    // at the middle (the end moving back, its low word first)
    +    if (late) {
    +        voiceAddr(PCM_LEFT, GUS_V_END_HIGH, chanAddr(PCM_LEFT, PCM_RING - 1), false);
    +        gusVoiceControl(PCM_LEFT, GUS_V_VOL_CONTROL, GUS_RAMP_STOPPED);
    +        gusVoiceControl(PCM_LEFT, GUS_V_CONTROL, (uint8_t)(sPcm.voiceMode | GUS_VC_IRQ | GUS_VC_LOOP));
    +    } else {
    +        gusVoiceControl(PCM_LEFT, GUS_V_CONTROL, (uint8_t)(sPcm.voiceMode | GUS_VC_IRQ));
    +        gusVoiceControl(PCM_LEFT, GUS_V_VOL_CONTROL, GUS_RAMP_STOPPED | GUS_VOL_ROLLOVER);
    +        voiceAddr(PCM_LEFT, GUS_V_END_HIGH, mid, true);
    +    }
    +
    +    if (free == sPcm.lastHalf) {
    +        return;
    +    }
    +
    +    sPcm.lastHalf = free;
    +    __asm__ __volatile__ ("sti" : : : "memory");
    +    audioMixRender(sPcm.mixed, PCM_HALF_FRAMES);
    +    __asm__ __volatile__ ("cli" : : : "memory");
    +
    +    // Each channel apart into the staging buffer; the last half's last
    +    // sample into the pad as well
    +    for (uint32_t c = 0; c < sPcm.channels; c++) {
    +        if (wide) {
    +            for (uint32_t i = 0; i < PCM_HALF_FRAMES; i++) {
    +                sPcm.chan[i] = sPcm.mixed[i * sPcm.channels + c];
    +            }
    +        } else {
    +            const uint8_t *mixed8 = (const uint8_t *)sPcm.mixed;
    +            uint8_t       *chan8  = (uint8_t *)sPcm.chan;
    +
    +            for (uint32_t i = 0; i < PCM_HALF_FRAMES; i++) {
    +                chan8[i] = mixed8[i * sPcm.channels + c];
    +            }
    +        }
    +
    +        isaDmaWrite(&sPcm.dma, c * sPcm.halfBytes, (const uint32_t *)sPcm.chan, sPcm.halfBytes / WORD_BYTES);
    +
    +        if (free == PCM_HALVES - 1) {
    +            if (wide) {
    +                gusPoke16(chanAddr(c, 0) - sPcm.sampleBytes, sPcm.chan[PCM_HALF_FRAMES - 1]);
    +            } else {
    +                gusPoke(chanAddr(c, 0) - sPcm.sampleBytes, (uint8_t)(((const uint8_t *)sPcm.chan)[PCM_HALF_FRAMES - 1] ^ SILENCE_8));
    +            }
    +        }
    +    }
    +
    +    sPcm.pendingHalf = sPcm.channels > 1 ? free : HALF_NONE;
    +    dmaStart(PCM_LEFT, free);
    +}
    +
    +
    +// A MAX's codec turned on (its control register) and found at base +
    +// 10Ch; false (the GF1 records instead) when it does not answer.
    +static bool maxCodecOn(void) {
    +    gusMaxControlSet(gusMaxControl(sPcm.env.base, sPcm.env.dma1, sPcm.env.dma2));
    +
    +    if (codecFound(&sPcm.codec, (uint16_t)(sPcm.env.base + GUS_MAX_CODEC))) {
    +        return true;
    +    }
    +
    +    dvxLog("Audio: gus: the MAX's codec did not answer at %lXh; the GF1 records", (unsigned long)(sPcm.env.base + GUS_MAX_CODEC));
    +    return false;
    +}
    +
    +
    +// The half just sampled handed on, sampling started again into the other
    +// first (the GF1 does not run on by itself, so as little as possible goes
    +// unrecorded in between).  Interrupt time.
    +static void sampleNext(void) {
    +    uint32_t done = (uint32_t)sPcm.sampleHalf;
    +
    +    sampleStart((done + 1) % REC_HALVES);
    +    __asm__ __volatile__ ("sti" : : : "memory");
    +    gusCapture(done);
    +    __asm__ __volatile__ ("cli" : : : "memory");
    +}
    +
    +
    +// The GF1 sampling into half (Linux gus_pcm.c's sequence): sampling off
    +// and its interrupt cleared, the PC channel set for the half, the rate,
    +// then started.  Interrupt time as well.
    +static void sampleStart(uint32_t half) {
    +    gusWrite8(GUS_G_SAMPLE_CTRL, 0);
    +    gusRead8(GUS_G_SAMPLE_CTRL);
    +    isaDmaProgram(&sPcm.dma, half * sPcm.recHalfBytes, sPcm.recHalfBytes, ISA_DMA_ONCE_RECORD);
    +    gusWrite8(GUS_G_SAMPLE_RATE, sPcm.sampleRate);
    +    gusWrite8(GUS_G_SAMPLE_CTRL, sPcm.sampleCtrl);
    +    sPcm.sampleHalf = (int32_t)half;
    +}
    +
    +
    +// A voice address (start, end, position) at a byte address, translated
    +// for 16-bit voices.  Interrupt time as well.
    +static void voiceAddr(uint32_t voice, uint8_t reg, uint32_t addr, bool lowFirst) {
    +    gusVoiceAddr(voice, reg, sPcm.bits == BITS_16 ? gusAddr16(addr) : addr, lowFirst);
    +}
    +
    +
    +// The left voice (and the right) set up and started together: the loop
    +// from the pad, the left voice rolling over at the middle, the right
    +// looping the whole ring.
    +static void voicesStart(void) {
    +    uint16_t fc     = gusFc(sPcm.rate, gusHw.outRate);
    +    bool     stereo = sPcm.channels > 1;
    +
    +    for (uint32_t c = 0; c < sPcm.channels; c++) {
    +        uint32_t v = c == 0 ? PCM_LEFT : PCM_RIGHT;
    +
    +        gusVoiceStop(v);
    +        gusVoiceWrite16(v, GUS_V_FREQ, fc);
    +        voiceAddr(v, GUS_V_START_HIGH, chanAddr(c, 0) - sPcm.sampleBytes, false);
    +        voiceAddr(v, GUS_V_END_HIGH, c == 0 ? chanAddr(c, PCM_HALF_FRAMES) : chanAddr(c, PCM_RING - 1), false);
    +        voiceAddr(v, GUS_V_POS_HIGH, chanAddr(c, 0), false);
    +        gusVoiceWrite8(v, GUS_V_PAN, stereo ? (c == 0 ? GUS_PAN_LEFT : GUS_PAN_RIGHT) : GUS_PAN_CENTRE);
    +        gusVoiceWrite16(v, GUS_V_VOLUME, (uint16_t)(PCM_VOLUME << GUS_VOL_SHIFT));
    +    }
    +
    +    uint32_t flags = gusCli();
    +
    +    gusVoiceControl(PCM_LEFT, GUS_V_VOL_CONTROL, GUS_RAMP_STOPPED | GUS_VOL_ROLLOVER);
    +    gusVoiceControl(PCM_LEFT, GUS_V_CONTROL, (uint8_t)(sPcm.voiceMode | GUS_VC_IRQ));
    +
    +    if (stereo) {
    +        gusVoiceControl(PCM_RIGHT, GUS_V_CONTROL, (uint8_t)(sPcm.voiceMode | GUS_VC_LOOP));
    +    }
    +
    +    gusRestore(flags);
    +}
    diff --git a/src/libs/kpunch/audio/drivers/guswt/gusWtDrv.c b/src/libs/kpunch/audio/drivers/guswt/gusWtDrv.c
    new file mode 100644
    index 00000000..54f75b01
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/guswt/gusWtDrv.c
    @@ -0,0 +1,45 @@
    +// 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.
    +
    +// gusWtDrv.c  -- the Gravis UltraSound as a wavetable synthesizer alone
    +// (guswt.drv)
    +//
    +// For a GUS beside another sound device (the common Sound Blaster 16 and
    +// GUS machine: the Sound Blaster plays digital sound, the GUS music, with
    +// all its voices and memory).  When gus.drv plays digital sound on the
    +// GUS, its own synthesizer comes first and this one is not opened.  The
    +// code is gus.drv's (gus/gusSynth.c).
    +
    +#include "audioDrv.h"
    +#include "../../gus/gusSynth.h"
    +
    +AUDIO_SYNTH_DRIVER("guswt", AUDIO_PRIORITY_GUS,
    +    .kind    = AUDIO_SYNTH_WAVETABLE,
    +    .open    = gusSynthOpen,
    +    .close   = gusSynthClose,
    +    .upload  = gusSynthUpload,
    +    .start   = gusSynthStart,
    +    .update  = gusSynthUpdate,
    +    .release = gusSynthRelease,
    +    .stop    = gusSynthStop,
    +    .active  = gusSynthActive
    +);
    diff --git a/src/libs/kpunch/audio/drivers/lpt/lptDrv.c b/src/libs/kpunch/audio/drivers/lpt/lptDrv.c
    new file mode 100644
    index 00000000..1e39faee
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/lpt/lptDrv.c
    @@ -0,0 +1,344 @@
    +// 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.
    +
    +// lptDrv.c  -- digital sound on a DAC plugged into the parallel port
    +// (lpt.drv)
    +//
    +//   Covox Speech Thing  an 8-bit DAC on the data lines: each sample is
    +//                       written to the data port when it is due, so the
    +//                       timer service interrupts at the rate (like the
    +//                       speaker's digital sound) and writes one byte each
    +//                       time.  It has no way to be found: it is used only
    +//                       when DVX.INI [audio] lptDac = covox.
    +//   Disney Sound Source a DAC behind a 16-sample FIFO that plays at a
    +//                       fixed 7 kHz, powered by SELECT IN; a sample goes
    +//                       in on SELECT IN's rising edge, ACK says the FIFO
    +//                       is full.  The timer service tops the FIFO up
    +//                       LPT_DSS_TICK_HZ times a second until ACK says
    +//                       full (few interrupts).  Found by filling the FIFO
    +//                       and watching it drain.
    +//   Stereo-on-1         two latched 8-bit DACs on the data lines, AUTOFEED
    +//                       opening the left one's latch and STROBE the
    +//                       right's: a frame is six port writes per interrupt
    +//                       at the rate.  Found by D7 wired back to BUSY.
    +//
    +// The port is DVX.INI [audio] lptPort (hex), else LPT1 as the BIOS
    +// lists it.  lptDac = auto (the default) looks for a Disney Sound Source,
    +// then a Stereo-on-1, and gives up (so a printer on the port is left
    +// alone: neither test strobes the port).  The rate is the mixing rate
    +// the configuration asks for, at most 11,048 Hz (LPT_DIVISOR_MIN; the
    +// Disney's own 7 kHz there); samples are 8-bit unsigned.  Feeding a
    +// Covox or a Stereo-on-1 costs the CPU as the speaker's digital sound
    +// does: an interrupt per frame.
    +
    +#include "audioDrv.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define INI_LPT_DAC         "lptDac"
    +#define INI_LPT_PORT        "lptPort"
    +#define LPT_DAC_AUTO        "auto"
    +#define LPT_DAC_COVOX       "covox"
    +#define LPT_DAC_DISNEY      "disney"
    +#define LPT_DAC_STEREO      "stereo"
    +#define PORT_BASE_HEX       16
    +
    +// The parallel port: data, status, control
    +#define LPT_STATUS          1
    +#define LPT_CONTROL         2
    +#define STATUS_ACK          0x40    // Disney: the FIFO is full
    +#define STATUS_BUSY         0x80    // Stereo-on-1: D7 inverted
    +#define CTRL_STROBE         0x01    // inverted: set drives the pin low
    +#define CTRL_AUTOFEED       0x02    // inverted
    +#define CTRL_INIT           0x04    // not inverted: set holds a printer out of reset
    +#define CTRL_SELECT_IN      0x08    // inverted
    +#define BIOS_LPT1           0x0408  // the BIOS data area's LPT1 base
    +#define SAMPLE_MIDDLE       0x80
    +
    +// Disney Sound Source: SELECT IN high (control bit clear) powers it and
    +// clocks a sample in on its way up
    +#define DSS_ON              CTRL_INIT
    +#define DSS_OFF             (CTRL_INIT | CTRL_SELECT_IN)
    +#define DSS_RATE            7000
    +#define DSS_FIFO            16
    +#define DSS_TICK_HZ         1000
    +#define DSS_SETTLE_US       20000   // the FIFO drains in 2.3 ms; detection waits this
    +#define DSS_DETECT_FILL     20      // samples sent to fill it
    +
    +// Stereo-on-1: a latch follows the data while its line is set
    +#define STON_IDLE           CTRL_INIT
    +#define STON_LEFT           (CTRL_INIT | CTRL_AUTOFEED)
    +#define STON_RIGHT          (CTRL_INIT | CTRL_STROBE)
    +
    +// Covox and Stereo-on-1: PIT clocks per frame, at least (an interrupt a
    +// frame costs a 486 too much above about 11 kHz)
    +#define LPT_DIVISOR_MIN     108     // 11,048 Hz
    +#define LPT_BLOCK           8       // frames mixed at a time
    +#define LPT_BITS            8
    +#define LPT_TIMER_NONE      (-1)
    +
    +typedef enum {
    +    LptNoneE,
    +    LptCovoxE,
    +    LptDisneyE,
    +    LptStereoE
    +} LptDacE;
    +
    +typedef struct {
    +    uint8_t  buf[LPT_BLOCK * 2];    // frames mixed, waiting to be written
    +    uint32_t pos;                   // next frame of buf
    +    uint32_t channels;
    +    uint32_t rate;
    +    uint16_t base;
    +    uint8_t  control;               // the control register as found
    +    int32_t  timerId;
    +    LptDacE  dac;
    +    bool     locked;
    +} LptStateT;
    +
    +static LptStateT sLpt = { .timerId = LPT_TIMER_NONE };
    +
    +static const char *const sDacNames[] = { "", "Covox", "Disney Sound Source", "Stereo-on-1" };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void    dssPut(uint16_t base, uint8_t sample);
    +static void    dssTick(void *unused);
    +static void    lptClose(void);
    +static bool    lptDetectDisney(uint16_t base);
    +static bool    lptDetectStereo(uint16_t base);
    +static bool    lptLock(bool lock);
    +static bool    lptOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info);
    +static bool    lptPlay(void);
    +static void    lptTick(void *unused);
    +static void    lptWait(uint32_t us);
    +static uint8_t nextSample(void);
    +
    +AUDIO_DRIVER("lpt", AUDIO_PRIORITY_LPT,
    +    .open     = lptOpen,
    +    .play     = lptPlay,
    +    .close    = lptClose
    +);
    +
    +
    +// One sample into the Disney's FIFO: on the data lines, then SELECT IN
    +// low and high again (the rising edge clocks it in).  Interrupt time.
    +static void dssPut(uint16_t base, uint8_t sample) {
    +    outportb(base, sample);
    +    outportb(base + LPT_CONTROL, DSS_OFF);
    +    outportb(base + LPT_CONTROL, DSS_ON);
    +}
    +
    +
    +// Timer interrupt (Disney): the FIFO topped up until it says full.
    +static void dssTick(void *unused) {
    +    (void)unused;
    +
    +    for (int32_t i = 0; i < DSS_FIFO && !(inportb(sLpt.base + LPT_STATUS) & STATUS_ACK); i++) {
    +        dssPut(sLpt.base, nextSample());
    +    }
    +}
    +
    +
    +// Silent, the port's control lines as they were found (a Disney powered
    +// off, as the BIOS leaves the port).
    +static void lptClose(void) {
    +    platformTimerRemove(sLpt.timerId);
    +    sLpt.timerId = LPT_TIMER_NONE;
    +    outportb(sLpt.base, SAMPLE_MIDDLE);
    +    outportb(sLpt.base + LPT_CONTROL, sLpt.control);
    +
    +    if (sLpt.locked) {
    +        lptLock(false);
    +        sLpt.locked = false;
    +    }
    +}
    +
    +
    +// A Disney powered on takes samples until its FIFO is full and plays them
    +// out: not full at first, full after more samples than it holds, not
    +// full again once they have played (a printer, or nothing, fails one of
    +// the three).  Left powered off.
    +static bool lptDetectDisney(uint16_t base) {
    +    bool found;
    +
    +    outportb(base + LPT_CONTROL, DSS_ON);
    +    lptWait(DSS_SETTLE_US);
    +    found = !(inportb(base + LPT_STATUS) & STATUS_ACK);
    +
    +    for (int32_t i = 0; found && i < DSS_DETECT_FILL; i++) {
    +        dssPut(base, SAMPLE_MIDDLE);
    +    }
    +
    +    found = found && (inportb(base + LPT_STATUS) & STATUS_ACK);
    +    lptWait(DSS_SETTLE_US);
    +    found = found && !(inportb(base + LPT_STATUS) & STATUS_ACK);
    +    outportb(base + LPT_CONTROL, DSS_OFF);
    +    return found;
    +}
    +
    +
    +// A Stereo-on-1 wires D7 back to BUSY (which reads inverted): BUSY follows
    +// every pattern on the data lines.  Nothing is strobed.
    +static bool lptDetectStereo(uint16_t base) {
    +    static const uint8_t patterns[] = { 0x00, 0x80, 0x7F, 0xFF, 0x00 };
    +    bool                 found      = true;
    +
    +    outportb(base + LPT_CONTROL, STON_IDLE);
    +
    +    for (uint32_t i = 0; found && i < sizeof(patterns); i++) {
    +        outportb(base, patterns[i]);
    +        found = ((inportb(base + LPT_STATUS) & STATUS_BUSY) != 0) == !(patterns[i] & STATUS_BUSY);
    +    }
    +
    +    outportb(base, SAMPLE_MIDDLE);
    +    return found;
    +}
    +
    +
    +static bool lptLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)dssPut,     0 },
    +        { (const void *)dssTick,    0 },
    +        { (const void *)lptTick,    0 },
    +        { (const void *)nextSample, 0 },
    +        { &sLpt,                    sizeof(sLpt) }
    +    };
    +
    +    return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +}
    +
    +
    +// The port (DVX.INI's, else LPT1), the DAC on it (DVX.INI's, else the one
    +// found), the rate.
    +static bool lptOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
    +    const char *portText = prefsGetString(cfg->prefs, AUDIO_INI_SECTION, INI_LPT_PORT, "");
    +    const char *want     = prefsGetString(cfg->prefs, AUDIO_INI_SECTION, INI_LPT_DAC, LPT_DAC_AUTO);
    +    uint32_t    divisor;
    +
    +    sLpt.base = portText[0] ? (uint16_t)strtoul(portText, NULL, PORT_BASE_HEX) : _farpeekw(_dos_ds, BIOS_LPT1);
    +    sLpt.dac  = LptNoneE;
    +
    +    if (sLpt.base == 0) {
    +        return false;
    +    }
    +
    +    sLpt.control = inportb(sLpt.base + LPT_CONTROL);
    +
    +    if (strcasecmp(want, LPT_DAC_COVOX) == 0) {
    +        sLpt.dac = LptCovoxE;
    +    } else if (strcasecmp(want, LPT_DAC_DISNEY) == 0) {
    +        sLpt.dac = LptDisneyE;
    +    } else if (strcasecmp(want, LPT_DAC_STEREO) == 0) {
    +        sLpt.dac = LptStereoE;
    +    } else if (strcasecmp(want, LPT_DAC_AUTO) == 0) {
    +        sLpt.dac = lptDetectDisney(sLpt.base) ? LptDisneyE : (lptDetectStereo(sLpt.base) ? LptStereoE : LptNoneE);
    +        outportb(sLpt.base + LPT_CONTROL, sLpt.control);
    +    }
    +
    +    if (sLpt.dac == LptNoneE) {
    +        return false;
    +    }
    +
    +    divisor        = (PLATFORM_PIT_HZ + cfg->rate / 2) / cfg->rate;
    +    divisor        = divisor < LPT_DIVISOR_MIN ? LPT_DIVISOR_MIN : divisor;
    +    sLpt.channels  = sLpt.dac == LptStereoE ? 2 : 1;
    +    sLpt.rate      = sLpt.dac == LptDisneyE ? DSS_RATE : PLATFORM_PIT_HZ / divisor;
    +    info->rate     = sLpt.rate;
    +    info->bits     = LPT_BITS;
    +    info->channels = sLpt.channels;
    +    info->hasDac   = true;
    +    snprintf(info->description, sizeof(info->description), "%s at %Xh", sDacNames[sLpt.dac], (unsigned)sLpt.base);
    +    return true;
    +}
    +
    +
    +// The Disney powered on and topped up from a slow timer; the others fed a
    +// frame each interrupt at the rate.
    +static bool lptPlay(void) {
    +    sLpt.locked = lptLock(true);
    +
    +    if (!sLpt.locked) {
    +        return false;
    +    }
    +
    +    sLpt.pos = 0;
    +
    +    if (sLpt.dac == LptDisneyE) {
    +        outportb(sLpt.base + LPT_CONTROL, DSS_ON);
    +        sLpt.timerId = platformTimerAdd(DSS_TICK_HZ, dssTick, NULL, 0);
    +    } else {
    +        sLpt.timerId = platformTimerAddFast(sLpt.rate, lptTick, NULL, 0);
    +    }
    +
    +    return sLpt.timerId >= 0;
    +}
    +
    +
    +// Timer interrupt (Covox, Stereo-on-1): the next frame on the DAC.
    +static void lptTick(void *unused) {
    +    uint16_t base = sLpt.base;
    +
    +    (void)unused;
    +
    +    if (sLpt.dac == LptCovoxE) {
    +        outportb(base, nextSample());
    +        return;
    +    }
    +
    +    outportb(base + LPT_CONTROL, STON_LEFT);
    +    outportb(base, nextSample());
    +    outportb(base + LPT_CONTROL, STON_IDLE);
    +    outportb(base + LPT_CONTROL, STON_RIGHT);
    +    outportb(base, nextSample());
    +    outportb(base + LPT_CONTROL, STON_IDLE);
    +}
    +
    +
    +// Program time: a wait on the timer service (detection only).
    +static void lptWait(uint32_t us) {
    +    uint64_t until = platformMicros() + us;
    +
    +    while (platformMicros() < until) {
    +    }
    +}
    +
    +
    +// The next sample mixed (a block mixed when the last is used up).
    +// Interrupt time.
    +static uint8_t nextSample(void) {
    +    if (sLpt.pos == 0) {
    +        audioMixRender(sLpt.buf, LPT_BLOCK);
    +    }
    +
    +    uint8_t s = sLpt.buf[sLpt.pos];
    +
    +    sLpt.pos = (sLpt.pos + 1) % (LPT_BLOCK * sLpt.channels);
    +    return s;
    +}
    diff --git a/src/libs/kpunch/audio/drivers/mpu401/mpuDrv.c b/src/libs/kpunch/audio/drivers/mpu401/mpuDrv.c
    index ea416a23..14c65705 100644
    --- a/src/libs/kpunch/audio/drivers/mpu401/mpuDrv.c
    +++ b/src/libs/kpunch/audio/drivers/mpu401/mpuDrv.c
    @@ -25,24 +25,27 @@
     // A synth driver (audioDrv.h "Synthesizer drivers") for an external MIDI
     // synthesizer (a Roland MT-32 or SC-55, a General MIDI module) on an
     // MPU-401 or compatible port, in UART mode: bytes written to the data
    -// port go out on the MIDI cable as they are.  Looked for only when
    -// DVX.INI [audio] midi = mpu401 (at mpuPort, hex, default 330h), because
    -// a port with nothing plugged in would play nothing.
    +// port go out on the MIDI cable as they are, and bytes arriving on its
    +// MIDI IN (a keyboard) are read back.  Looked for only when DVX.INI
    +// [audio] midi or midiIn = mpu401 (at mpuPort, hex, default 330h),
    +// because a port with nothing plugged in would play nothing.
     //
     // Status port (data port + 1): bit 6 clear = ready to take a byte, bit 7
     // clear = a byte to read.  Reset: FFh to the command (status) port, the
     // port answers FEh; UART mode: 3Fh, answered FEh too (UART-only clones
    -// may not answer that one).  Bytes the port sends (active sensing, input)
    -// are read and dropped.  put waits a little for the port to be ready and
    -// otherwise returns false, so the caller sends the byte later; it runs at
    -// interrupt time too and is pinned.  Close sends All Notes Off on every
    -// channel and resets the port (out of UART mode).
    +// may not answer that one).  put first reads what the port has received
    +// (it would hold the port up) into a small ring, then waits a little for
    +// the port to be ready and otherwise returns false, so the caller sends
    +// the byte later; get hands out the ring's bytes, then the port's.  Both
    +// run at interrupt time too and are pinned.  Close sends All Notes Off on
    +// every channel and resets the port (out of UART mode).
     
     #include "audioDrv.h"
     
     #include 
     #include 
     #include 
    +#include 
     #include 
     
     #define PORT_BASE_HEX       16
    @@ -54,7 +57,8 @@
     #define MPU_ACK             0xFE
     #define MPU_ACK_READS       50000   // status reads to wait for an answer
     #define MPU_PUT_READS       64      // status reads put waits for the port
    -#define MPU_DRAIN_MAX       16      // input bytes dropped per put
    +#define MPU_DRAIN_MAX       16      // input bytes put reads past (into the ring) at most
    +#define MPU_IN_RING         64      // input put read, waiting for get (a power of two)
     
     // Sent on close: All Notes Off (controller 123) on each channel
     #define MIDI_CONTROL        0xB0
    @@ -62,8 +66,11 @@
     #define MIDI_ALL_NOTES_OFF  123
     
     typedef struct {
    -    uint16_t data;
    -    uint16_t status;
    +    uint8_t           in[MPU_IN_RING];  // input put read past, for get
    +    volatile uint32_t inHead;           // bytes put stored
    +    volatile uint32_t inTail;           // bytes get took
    +    uint16_t          data;
    +    uint16_t          status;
     } MpuT;
     
     static MpuT sMpu;
    @@ -74,6 +81,7 @@ static MpuT sMpu;
     
     static void mpuClose(void);
     static bool mpuCommand(uint8_t cmd);
    +static bool mpuGet(uint8_t *byte);
     static bool mpuLock(bool lock);
     static bool mpuOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
     static bool mpuPut(uint8_t byte);
    @@ -83,7 +91,8 @@ AUDIO_SYNTH_DRIVER("mpu401", AUDIO_PRIORITY_CARD,
         .kind     = AUDIO_SYNTH_MIDI,
         .open     = mpuOpen,
         .close    = mpuClose,
    -    .put      = mpuPut
    +    .put      = mpuPut,
    +    .get      = mpuGet
     );
     
     
    @@ -120,8 +129,26 @@ static bool mpuCommand(uint8_t cmd) {
     }
     
     
    +// What put read past first, then what the port has.
    +static bool mpuGet(uint8_t *byte) {
    +    if (sMpu.inTail != sMpu.inHead) {
    +        *byte = sMpu.in[sMpu.inTail & (MPU_IN_RING - 1)];
    +        sMpu.inTail++;
    +        return true;
    +    }
    +
    +    if (inportb(sMpu.status) & MPU_NO_DATA) {
    +        return false;
    +    }
    +
    +    *byte = inportb(sMpu.data);
    +    return true;
    +}
    +
    +
     static bool mpuLock(bool lock) {
         const AudioLockItemT items[] = {
    +        { (const void *)mpuGet, 0 },
             { (const void *)mpuPut, 0 },
             { &sMpu,                sizeof(sMpu) }
         };
    @@ -133,10 +160,15 @@ static bool mpuLock(bool lock) {
     // A port left in UART mode ignores the first reset (it takes FFh as
     // data); the second one is answered.
     static bool mpuOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info) {
    -    if (strcasecmp(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, AUDIO_MIDI_AUTO), AUDIO_MIDI_MPU401) != 0) {
    +    bool forMusic = strcasecmp(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, AUDIO_MIDI_AUTO), AUDIO_MIDI_MPU401) == 0;
    +    bool forInput = strcasecmp(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI_IN, AUDIO_MIDI_IN_NONE), AUDIO_MIDI_MPU401) == 0;
    +
    +    if (!forMusic && !forInput) {
             return false;
         }
     
    +    memset(&sMpu, 0, sizeof(sMpu));
    +
         sMpu.data   = (uint16_t)strtoul(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MPU_PORT, AUDIO_MPU_PORT_DEFAULT), NULL, PORT_BASE_HEX);
         sMpu.status = (uint16_t)(sMpu.data + MPU_STATUS);
     
    @@ -156,11 +188,17 @@ static bool mpuOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info) {
     }
     
     
    -// Drop what the port has sent, then send the byte if the port gets ready
    -// soon enough.
    +// Keep what the port has received for get (dropped when the ring is
    +// full: nobody reads it), then send the byte if the port gets ready soon
    +// enough.
     static bool mpuPut(uint8_t byte) {
         for (int32_t i = 0; i < MPU_DRAIN_MAX && !(inportb(sMpu.status) & MPU_NO_DATA); i++) {
    -        (void)inportb(sMpu.data);
    +        uint8_t in = inportb(sMpu.data);
    +
    +        if (sMpu.inHead - sMpu.inTail < MPU_IN_RING) {
    +            sMpu.in[sMpu.inHead & (MPU_IN_RING - 1)] = in;
    +            sMpu.inHead++;
    +        }
         }
     
         for (int32_t i = 0; i < MPU_PUT_READS; i++) {
    diff --git a/src/libs/kpunch/audio/drivers/opl/oplDrv.c b/src/libs/kpunch/audio/drivers/opl/oplDrv.c
    index 945fd4c6..c56777f1 100644
    --- a/src/libs/kpunch/audio/drivers/opl/oplDrv.c
    +++ b/src/libs/kpunch/audio/drivers/opl/oplDrv.c
    @@ -26,9 +26,10 @@
     // of an AdLib or Sound Blaster at 388h (DVX.INI [audio] oplPort, hex)
     // and writes its registers for audio.lib, which shares its voices
     // between programs.  Detection is the AdLib method: timer 1 started, its
    -// flag must come up in the status register; the status bits that an
    -// OPL2 always sets tell it from an OPL3 (18 voices, stereo, a second
    -// register bank at base + 2).
    +// flag must come up in the status register (and not at the slowest
    +// count, which a Gravis UltraSound's AdLib timers ignore); the status
    +// bits that an OPL2 always sets tell it from an OPL3 (18 voices, stereo,
    +// a second register bank at base + 2).
     //
     // A register write is an index write, a short wait, a data write and a
     // longer wait (an OPL2 needs 3.3 us and 23 us; an OPL3 hardly any), timed
    @@ -55,6 +56,7 @@
     #define TIMER_IRQ_RESET     0x80    // clear the flags
     #define TIMER1_START        0x21
     #define TIMER1_FASTEST      0xFF
    +#define TIMER1_SLOWEST      0x00    // 20 ms: never over in the wait
     #define STATUS_FLAGS        0xE0
     #define STATUS_IDLE         0x00
     #define STATUS_TIMER1       0xC0
    @@ -92,6 +94,8 @@ static void    oplClose(void);
     static bool    oplDetect(uint16_t base, bool *opl3);
     static bool    oplLock(bool lock);
     static bool    oplOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
    +static uint8_t oplTimer1(uint16_t base, uint8_t count);
    +static void    oplTimersReset(uint16_t base);
     static void    oplWait(uint16_t port, uint16_t reads);
     static void    oplWrite(uint32_t reg, uint8_t value);
     static void    oplWriteBank(uint16_t port, uint8_t reg, uint8_t value);
    @@ -124,23 +128,18 @@ static void oplClose(void) {
     
     
     // The AdLib test, with a register write that needs no timing yet (the
    -// chip type is not known): generous waits.
    +// chip type is not known): generous waits.  Timer 1 must also keep the
    +// count it is given: at its slowest it is still running when the
    +// fastest has overflowed.  A Gravis UltraSound answers at 388h with
    +// timers that ignore it, and has no FM.
     static bool oplDetect(uint16_t base, bool *opl3) {
    -    oplWriteBank(base, REG_TIMER_CTRL, TIMER_RESET);
    -    oplWriteBank(base, REG_TIMER_CTRL, TIMER_IRQ_RESET);
    +    oplTimersReset(base);
     
         uint8_t before = inportb(base);
    +    uint8_t slow   = oplTimer1(base, TIMER1_SLOWEST);
    +    uint8_t fast   = oplTimer1(base, TIMER1_FASTEST);
     
    -    oplWriteBank(base, REG_TIMER1, TIMER1_FASTEST);
    -    oplWriteBank(base, REG_TIMER_CTRL, TIMER1_START);
    -    oplWait(base, DETECT_WAIT_READS);
    -
    -    uint8_t after = inportb(base);
    -
    -    oplWriteBank(base, REG_TIMER_CTRL, TIMER_RESET);
    -    oplWriteBank(base, REG_TIMER_CTRL, TIMER_IRQ_RESET);
    -
    -    if ((before & STATUS_FLAGS) != STATUS_IDLE || (after & STATUS_FLAGS) != STATUS_TIMER1) {
    +    if ((before & STATUS_FLAGS) != STATUS_IDLE || (slow & STATUS_FLAGS) != STATUS_IDLE || (fast & STATUS_FLAGS) != STATUS_TIMER1) {
             return false;
         }
     
    @@ -186,6 +185,27 @@ static bool oplOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info) {
     }
     
     
    +// Timer 1 started at count and given the detection wait: the status
    +// then, the timers stopped and their flags cleared after.
    +static uint8_t oplTimer1(uint16_t base, uint8_t count) {
    +    oplWriteBank(base, REG_TIMER1, count);
    +    oplWriteBank(base, REG_TIMER_CTRL, TIMER1_START);
    +    oplWait(base, DETECT_WAIT_READS);
    +
    +    uint8_t status = inportb(base);
    +
    +    oplTimersReset(base);
    +    return status;
    +}
    +
    +
    +// Both timers masked and their flags cleared.
    +static void oplTimersReset(uint16_t base) {
    +    oplWriteBank(base, REG_TIMER_CTRL, TIMER_RESET);
    +    oplWriteBank(base, REG_TIMER_CTRL, TIMER_IRQ_RESET);
    +}
    +
    +
     static void oplWait(uint16_t port, uint16_t reads) {
         for (uint16_t i = 0; i < reads; i++) {
             (void)inportb(port);
    diff --git a/src/libs/kpunch/audio/drivers/sb/sbCalc.c b/src/libs/kpunch/audio/drivers/sb/sbCalc.c
    index 7f9a1790..ba119467 100644
    --- a/src/libs/kpunch/audio/drivers/sb/sbCalc.c
    +++ b/src/libs/kpunch/audio/drivers/sb/sbCalc.c
    @@ -43,6 +43,8 @@
     #define RATE_HIGH_MAX       44100
     #define RATE_STEREO_LOW     11025
     #define RATE_STEREO_HIGH    22050
    +#define REC_RATE_OLD_MAX    13000   // input before DSP 2.01, and its normal mode
    +#define REC_RATE_201_MAX    15000   // DSP 2.01 input, high-speed above REC_RATE_OLD_MAX
     #define BITS_8              8
     #define BITS_16             16
     #define CHANNELS_MONO       1
    @@ -56,10 +58,10 @@
     
     static uint32_t clampRate(uint32_t rate, uint32_t lo, uint32_t hi);
     static uint32_t parseNumber(const char **p, uint32_t base);
    -uint32_t        sbDmaPlace(uint32_t phys, uint32_t allocBytes, uint32_t bufBytes, uint32_t boundary);
     const char     *sbModelName(uint32_t dspVersion);
     bool            sbParseBlaster(const char *text, SbBlasterT *out);
     void            sbPlan(uint32_t dspVersion, uint32_t rate, uint32_t bits, uint32_t channels, bool have16Dma, SbPlanT *plan);
    +void            sbRecordPlan(uint32_t dspVersion, uint32_t rate, uint32_t bits, uint32_t channels, bool have16Dma, SbPlanT *plan);
     
     
     static uint32_t clampRate(uint32_t rate, uint32_t lo, uint32_t hi) {
    @@ -94,21 +96,6 @@ static uint32_t parseNumber(const char **p, uint32_t base) {
     }
     
     
    -uint32_t sbDmaPlace(uint32_t phys, uint32_t allocBytes, uint32_t bufBytes, uint32_t boundary) {
    -    uint32_t start = phys;
    -
    -    if (start / boundary != (start + bufBytes - 1) / boundary) {
    -        start = (start / boundary + 1) * boundary;
    -    }
    -
    -    if (start + bufBytes > phys + allocBytes) {
    -        return SB_NONE;
    -    }
    -
    -    return start - phys;
    -}
    -
    -
     const char *sbModelName(uint32_t dspVersion) {
         if (dspVersion >= SB_DSP_SB16) {
             return "Sound Blaster 16";
    @@ -134,6 +121,7 @@ bool sbParseBlaster(const char *text, SbBlasterT *out) {
         out->dma8  = SB_NONE;
         out->dma16 = SB_NONE;
         out->type  = SB_NONE;
    +    out->emu   = SB_NONE;
     
         const char *p = text;
     
    @@ -166,6 +154,11 @@ bool sbParseBlaster(const char *text, SbBlasterT *out) {
                     out->type = parseNumber(&p, DEC_BASE);
                     break;
     
    +            case 'E':
    +            case 'e':
    +                out->emu = parseNumber(&p, HEX_BASE);
    +                break;
    +
                 default:
                     while (*p && *p != ' ' && *p != '\t') {
                         p++;
    @@ -216,3 +209,28 @@ void sbPlan(uint32_t dspVersion, uint32_t rate, uint32_t bits, uint32_t channels
             plan->mode = SB_MODE_AUTO;
         }
     }
    +
    +
    +void sbRecordPlan(uint32_t dspVersion, uint32_t rate, uint32_t bits, uint32_t channels, bool have16Dma, SbPlanT *plan) {
    +    if (dspVersion >= SB_DSP_PRO) {
    +        sbPlan(dspVersion, rate, bits, channels, have16Dma, plan);
    +        return;
    +    }
    +
    +    uint32_t top    = dspVersion >= SB_DSP_HIGH_SPEED ? REC_RATE_201_MAX : REC_RATE_OLD_MAX;
    +    uint32_t want   = clampRate(rate, RATE_MIN, top);
    +    uint32_t period = (TC_CLOCK + want / 2) / want;
    +
    +    plan->bits         = BITS_8;
    +    plan->channels     = CHANNELS_MONO;
    +    plan->timeConstant = TC_BASE - period;
    +    plan->rate         = TC_CLOCK / period;
    +
    +    if (dspVersion < SB_DSP_AUTO_INIT) {
    +        plan->mode = SB_MODE_SINGLE;
    +    } else if (plan->rate > REC_RATE_OLD_MAX) {
    +        plan->mode = SB_MODE_HIGH;
    +    } else {
    +        plan->mode = SB_MODE_AUTO;
    +    }
    +}
    diff --git a/src/libs/kpunch/audio/drivers/sb/sbCalc.h b/src/libs/kpunch/audio/drivers/sb/sbCalc.h
    index e166a65e..3f70430e 100644
    --- a/src/libs/kpunch/audio/drivers/sb/sbCalc.h
    +++ b/src/libs/kpunch/audio/drivers/sb/sbCalc.h
    @@ -23,9 +23,9 @@
     // sbCalc.h  -- Sound Blaster driver arithmetic (no hardware access)
     //
     // The parts of sb.drv that decide rather than touch hardware, kept apart
    -// so the host tests run them: the BLASTER variable, the playback plan
    -// for a DSP version (mode, achieved rate, time constant) and where the
    -// DMA buffer goes in a conventional-memory block.
    +// so the host tests run them: the BLASTER variable and the playback plan
    +// for a DSP version (mode, achieved rate, time constant).  Where the DMA
    +// buffer goes is the shared ISA code's (audio/isa/isaCalc.h).
     #ifndef SB_CALC_H
     #define SB_CALC_H
     
    @@ -42,10 +42,6 @@
     // BLASTER values that were not given
     #define SB_NONE             0xFFFFFFFFU
     
    -// Buffer boundaries a DMA transfer must not cross
    -#define SB_DMA_BOUNDARY_8   0x10000U
    -#define SB_DMA_BOUNDARY_16  0x20000U
    -
     // Playback methods
     typedef enum {
         SB_MODE_SINGLE = 0,    // DSP 1.x: 8-bit single-cycle, restarted every block
    @@ -54,14 +50,15 @@ typedef enum {
         SB_MODE_SB16   = 3     // SB16 8- or 16-bit auto-init at any rate
     } SbModeE;
     
    -// The BLASTER environment variable ("A220 I5 D1 H5 T6"); SB_NONE for a
    -// value not given.
    +// The BLASTER environment variable ("A220 I5 D1 H5 E620 T6"); SB_NONE for
    +// a value not given.
     typedef struct {
         uint32_t base;      // A: I/O base
         uint32_t irq;       // I
         uint32_t dma8;      // D
         uint32_t dma16;     // H
         uint32_t type;      // T: card type
    +    uint32_t emu;       // E: an AWE32's EMU8000 base
     } SbBlasterT;
     
     // How a card plays what the mixer wants.
    @@ -76,10 +73,6 @@ typedef struct {
     // Name of the card family for a DSP version.
     const char *sbModelName(uint32_t dspVersion);
     
    -// Offset in a block at physical address phys, allocBytes long, of a
    -// bufBytes buffer that does not cross a boundary; SB_NONE when none fits.
    -uint32_t sbDmaPlace(uint32_t phys, uint32_t allocBytes, uint32_t bufBytes, uint32_t boundary);
    -
     // Parse a BLASTER value (NULL allowed).  False when it names no I/O base.
     bool sbParseBlaster(const char *text, SbBlasterT *out);
     
    @@ -87,4 +80,11 @@ bool sbParseBlaster(const char *text, SbBlasterT *out);
     // with this DSP version; have16Dma says whether a 16-bit channel is known.
     void sbPlan(uint32_t dspVersion, uint32_t rate, uint32_t bits, uint32_t channels, bool have16Dma, SbPlanT *plan);
     
    +// The recording plan: the same modes, recording (SB_MODE_SINGLE 24h,
    +// SB_MODE_AUTO 2Ch, SB_MODE_HIGH 98h, SB_MODE_SB16 BEh / CEh), with the
    +// cards' input limits: before the SB Pro, 8-bit mono up to 13,000 Hz
    +// (15,000 on DSP 2.01, in high-speed mode above 13,000); the SB Pro and
    +// the SB16 record what they play.
    +void sbRecordPlan(uint32_t dspVersion, uint32_t rate, uint32_t bits, uint32_t channels, bool have16Dma, SbPlanT *plan);
    +
     #endif // SB_CALC_H
    diff --git a/src/libs/kpunch/audio/drivers/sb/sbDrv.c b/src/libs/kpunch/audio/drivers/sb/sbDrv.c
    index fe07c1e5..3d9b3c92 100644
    --- a/src/libs/kpunch/audio/drivers/sb/sbDrv.c
    +++ b/src/libs/kpunch/audio/drivers/sb/sbDrv.c
    @@ -44,6 +44,14 @@
     // programs are; the mixer is integer-only and pinned, like everything
     // here the interrupt touches (sSb, the handler, audioMixRender).
     //
    +// Recording (recordStart / recordStop) takes the card the same way with
    +// the input twins of the playback modes (sbRecordPlan): the DMA
    +// controller writes the buffer, the interrupt hands the half just
    +// filled to audioRecPush.  The mixer picks the source (SB Pro 0Ch;
    +// SB16 input switches 3Dh / 3Eh, the microphone at full); cards before
    +// the Pro have no mixer and record the microphone.  The registers it
    +// changes are put back when it stops.
    +//
     // SB Pro stereo needs its mixer's stereo bit, the output filter off, and
     // one silent byte played first (else left and right come out swapped).
     // Stopping restores the mixer, the PIC mask and the interrupt vector and
    @@ -51,14 +59,12 @@
     
     #include "audioDrv.h"
     #include "sbCalc.h"
    +#include "../../isa/isaHw.h"
     
    -#include 
    -#include 
     #include 
     #include 
     #include 
     #include 
    -#include 
     
     // DSP ports (offsets from the base)
     #define DSP_MIXER_ADDR      0x04
    @@ -73,13 +79,19 @@
     
     // DSP commands
     #define DSP_CMD_SINGLE8     0x14
    +#define DSP_CMD_SINGLE8_IN  0x24
     #define DSP_CMD_AUTO8       0x1C
    +#define DSP_CMD_AUTO8_IN    0x2C
     #define DSP_CMD_TIME_CONST  0x40
     #define DSP_CMD_OUT_RATE    0x41
    +#define DSP_CMD_IN_RATE     0x42
     #define DSP_CMD_BLOCK_SIZE  0x48
     #define DSP_CMD_HIGH_AUTO8  0x90
    +#define DSP_CMD_HIGH_IN8    0x98
     #define DSP_CMD_SB16_16     0xB6    // 16-bit output, auto-init, FIFO on
     #define DSP_CMD_SB16_8      0xC6    // 8-bit output, auto-init, FIFO on
    +#define DSP_CMD_SB16_16_IN  0xBE    // 16-bit input, auto-init, FIFO on
    +#define DSP_CMD_SB16_8_IN   0xCE    // 8-bit input, auto-init, FIFO on
     #define DSP_CMD_PAUSE8      0xD0
     #define DSP_CMD_SPEAKER_ON  0xD1
     #define DSP_CMD_SPEAKER_OFF 0xD3
    @@ -92,11 +104,24 @@
     
     // Mixer registers
     #define MIX_REG_VOICE_PRO   0x04    // SB Pro voice volume (left and right nibbles)
    +#define MIX_REG_INPUT_PRO   0x0C    // SB Pro input: bits 2..1 the source, bit 5 the input filter off
    +#define MIX_IN_PRO_MIC      0x00
    +#define MIX_IN_PRO_CD       0x02
    +#define MIX_IN_PRO_LINE     0x06
    +#define MIX_IN_PRO_NO_FILT  0x20
     #define MIX_REG_OUTPUT      0x0E    // SB Pro: bit 1 stereo, bit 5 output filter off
     #define MIX_OUT_STEREO      0x02
     #define MIX_OUT_NO_FILTER   0x20
     #define MIX_REG_VOICE16_L   0x32    // SB16 voice volume, 5 bits in the top of the byte
     #define MIX_REG_VOICE16_R   0x33
    +#define MIX_REG_MIC16       0x3A    // SB16 microphone volume, 5 bits in the top of the byte
    +#define MIX_REG_IN16_L      0x3D    // SB16 input switches, left and right
    +#define MIX_REG_IN16_R      0x3E
    +#define MIX_IN16_MIC        0x01
    +#define MIX_IN16_CD_L       0x04
    +#define MIX_IN16_CD_R       0x02
    +#define MIX_IN16_LINE_L     0x10
    +#define MIX_IN16_LINE_R     0x08
     #define MIX_REG_IRQ         0x80
     #define MIX_REG_DMA         0x81
     #define MIX_REG_IRQ_STATUS  0x82
    @@ -108,47 +133,14 @@
     // SB16 mixer 80h bits for IRQ 2, 5, 7, 10 and 81h bits for DMA 0..7
     #define SB16_IRQ_COUNT      4
     #define SB16_DMA_16_FIRST   5
    -#define DMA_CHANNELS        8
    -
    -// 8259 interrupt controllers
    -#define PIC1_CMD            0x20
    -#define PIC1_DATA           0x21
    -#define PIC2_CMD            0xA0
    -#define PIC2_DATA           0xA1
    -#define PIC_EOI             0x20
    -#define PIC_READ_ISR        0x0B
    -#define IRQ_SLAVE_FIRST     8
    -#define IRQ_COUNT           16
    -#define IRQ_CASCADE         2
    -#define IRQ_AT_REDIRECT     9       // IRQ 2 arrives as 9 on an AT
    -#define IRQ_SPURIOUS_MASTER 7
    -#define VEC_MASTER_BASE     0x08
    -#define VEC_SLAVE_BASE      0x70
    -
    -// 8237 DMA controllers: channels 0-3 byte-wide, 5-7 word-wide
    -#define DMA8_MASK           0x0A
    -#define DMA8_MODE           0x0B
    -#define DMA8_FLIPFLOP       0x0C
    -#define DMA16_MASK          0xD4
    -#define DMA16_MODE          0xD6
    -#define DMA16_FLIPFLOP      0xD8
    -#define DMA_MASK_SET        0x04
    -#define DMA_CHANNEL_BITS    0x03
    -#define DMA_MODE_AUTO_PLAY  0x58    // single transfer, auto-init, memory to device
    -#define DMA_MODE_ONCE_PLAY  0x48    // single transfer, memory to device
    -#define DMA_FIRST_16        4
    -#define DMA_PAGE_SHIFT      16
    -#define DMA_PAGE_MASK16     0xFE
     
     // Buffer: two halves of SB_BLOCK_FRAMES frames (about 23 ms each at
    -// 22,050 Hz), allocated twice over so a half-size stretch without a
    -// boundary always exists
    +// 22,050 Hz)
     #define SB_BLOCK_FRAMES     512
     #define SB_FRAME_BYTES_MAX  4
     #define SB_HALVES           2
    -#define PARAGRAPH_BYTES     16
    -#define PARAGRAPH_SHIFT     4
     #define WORD_BYTES          4
    +#define IRQ_COUNT           16
     #define SILENCE_8           0x80
     #define SILENCE_16          0x00
     
    @@ -162,11 +154,6 @@
     // Probed without BLASTER
     static const uint32_t sProbeBases[] = { 0x220, 0x240 };
     
    -// Channel registers, by DMA channel number
    -static const uint8_t sDmaAddrPort[DMA_CHANNELS]  = { 0x00, 0x02, 0x04, 0x06, 0xC0, 0xC4, 0xC8, 0xCC };
    -static const uint8_t sDmaCountPort[DMA_CHANNELS] = { 0x01, 0x03, 0x05, 0x07, 0xC2, 0xC6, 0xCA, 0xCE };
    -static const uint8_t sDmaPagePort[DMA_CHANNELS]  = { 0x87, 0x83, 0x81, 0x82, 0x8F, 0x8B, 0x89, 0x8A };
    -
     // SB16 mixer 80h bit n selects this IRQ
     static const uint8_t sSb16Irqs[SB16_IRQ_COUNT] = { 2, 5, 7, 10 };
     
    @@ -178,113 +165,67 @@ static const uint8_t sSb16Irqs[SB16_IRQ_COUNT] = { 2, 5, 7, 10 };
     
     typedef struct {
         uint32_t           scratch[SB_BLOCK_FRAMES * SB_FRAME_BYTES_MAX / WORD_BYTES];   // one half, mixed here first
    -    _go32_dpmi_seginfo oldVector;
    -    _go32_dpmi_seginfo newVector;
    +    IsaDmaT            dma;            // the buffer and the channel in use
    +    IsaIrqT            irq;
         SbPlanT            plan;
         uint32_t           base;
    -    uint32_t           irq;
    -    uint32_t           dma;            // the channel in use
    +    uint32_t           irqNumber;
         uint32_t           dspVersion;
         uint32_t           halfBytes;
         uint32_t           halfFrames;
    -    uint32_t           bufOffset;      // of the buffer in the DOS block
    -    uint32_t           bufPhys;
    -    int32_t            dosSelector;    // the DOS block (-1: none)
         uint8_t            savedOutput;    // SB Pro mixer 0Eh
         uint8_t            savedVoiceL;    // voice volume registers
         uint8_t            savedVoiceR;
    -    uint8_t            savedPicBit;    // the IRQ was masked before
    +    uint8_t            savedInput;     // SB Pro mixer 0Ch, or SB16 3Dh
    +    uint8_t            savedInputR;    // SB16 3Eh
    +    uint8_t            savedMic;       // SB16 3Ah
    +    uint32_t           dma8;           // the card's DMA channels (open)
    +    uint32_t           dma16;
         volatile uint32_t  irqCount;       // block interrupts while playing
         volatile bool      priming;        // the SB Pro silent byte is playing
         volatile bool      primed;         // ... and has ended
         bool               locked;
    -    bool               hooked;
         bool               playing;
    +    bool               recording;
     } SbStateT;
     
    -static SbStateT sSb = { .dosSelector = -1 };
    +static SbStateT sSb = { .dma.selector = -1 };
     
     // ============================================================
     // Prototypes
     // ============================================================
     
    -static uint32_t dmaHalfPlaying(void);
    -static void     dmaProgram(uint32_t offset, uint32_t bytes, uint8_t mode);
    -static void     dmaStop(void);
     static int32_t  dspRead(uint32_t base);
     static bool     dspReset(uint32_t base);
     static bool     dspWrite(uint32_t base, uint8_t value);
    -static void     irqHook(void);
    -static uint32_t irqPicPort(uint32_t irq);
    -static void     irqUnhook(void);
    -static int      irqVector(uint32_t irq);
     static uint8_t  mixerRead(uint8_t reg);
     static void     mixerWrite(uint8_t reg, uint8_t value);
    +static void     sbCapture(uint32_t half);
     static void     sbClose(void);
     static bool     sbFindCard(SbBlasterT *bl);
    +static void     sbHalt(void);
    +static void     sbInputRestore(void);
    +static void     sbInputSet(int32_t source, uint32_t channels);
     static void     sbIsr(void);
     static bool     sbLock(bool lock);
     static bool     sbOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info);
     static bool     sbPlay(void);
     static void     sbPrimeStereo(void);
    +static bool     sbRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got);
     static void     sbRefill(uint32_t half);
    -static void     sbStartDsp(void);
    +static void     sbRelease(void);
    +static void     sbStartDsp(bool input);
    +static bool     sbWaitFirstIrq(void);
     
     AUDIO_DRIVER("sb", AUDIO_PRIORITY_CARD,
    -    .open     = sbOpen,
    -    .play     = sbPlay,
    -    .close    = sbClose
    +    .open        = sbOpen,
    +    .play        = sbPlay,
    +    .close       = sbClose,
    +    .recordStart = sbRecordStart,
    +    .recordStop  = sbClose
     );
     
     
    -// The half the DMA controller is reading now, from its remaining count
    -// (bytes on channels 0-3, words on 5-7).
    -static uint32_t dmaHalfPlaying(void) {
    -    uint32_t ch    = sSb.dma;
    -    bool     wide  = ch >= DMA_FIRST_16;
    -    uint32_t units = wide ? sSb.halfBytes / 2 : sSb.halfBytes;
    -    uint32_t count;
    -
    -    outportb(wide ? DMA16_FLIPFLOP : DMA8_FLIPFLOP, 0);
    -    count  = inportb(sDmaCountPort[ch]);
    -    count |= (uint32_t)inportb(sDmaCountPort[ch]) << BYTE_BITS;
    -
    -    // count is the units left minus one in the whole buffer
    -    uint32_t done = units * SB_HALVES - 1 - count;
    -
    -    return done < units ? 0 : 1;
    -}
    -
    -
    -// Program the channel for bytes of the buffer from offset, in mode.
    -static void dmaProgram(uint32_t offset, uint32_t bytes, uint8_t mode) {
    -    uint32_t ch    = sSb.dma;
    -    bool     wide  = ch >= DMA_FIRST_16;
    -    uint32_t sel   = ch & DMA_CHANNEL_BITS;
    -    uint32_t phys  = sSb.bufPhys + offset;
    -    uint32_t addr  = wide ? (phys >> 1) : phys;
    -    uint32_t count = (wide ? bytes / 2 : bytes) - 1;
    -    uint32_t page  = phys >> DMA_PAGE_SHIFT;
    -
    -    outportb(wide ? DMA16_MASK : DMA8_MASK, (uint8_t)(DMA_MASK_SET | sel));
    -    outportb(wide ? DMA16_FLIPFLOP : DMA8_FLIPFLOP, 0);
    -    outportb(wide ? DMA16_MODE : DMA8_MODE, (uint8_t)(mode | sel));
    -    outportb(sDmaAddrPort[ch], addr & BYTE_MASK);
    -    outportb(sDmaAddrPort[ch], (addr >> BYTE_BITS) & BYTE_MASK);
    -    outportb(sDmaPagePort[ch], (uint8_t)(wide ? (page & DMA_PAGE_MASK16) : page));
    -    outportb(sDmaCountPort[ch], count & BYTE_MASK);
    -    outportb(sDmaCountPort[ch], (count >> BYTE_BITS) & BYTE_MASK);
    -    outportb(wide ? DMA16_MASK : DMA8_MASK, (uint8_t)sel);
    -}
    -
    -
    -static void dmaStop(void) {
    -    bool wide = sSb.dma >= DMA_FIRST_16;
    -
    -    outportb(wide ? DMA16_MASK : DMA8_MASK, (uint8_t)(DMA_MASK_SET | (sSb.dma & DMA_CHANNEL_BITS)));
    -}
    -
    -
     static int32_t dspRead(uint32_t base) {
         for (int32_t i = 0; i < DSP_POLL_MAX; i++) {
             if (inportb(base + DSP_READ_STATUS) & DSP_BUSY) {
    @@ -329,63 +270,6 @@ static bool dspWrite(uint32_t base, uint8_t value) {
     }
     
     
    -static void irqHook(void) {
    -    uint32_t irq    = sSb.irq;
    -    int      vector = irqVector(irq);
    -    uint32_t port   = irqPicPort(irq);
    -    uint8_t  bit    = (uint8_t)(1 << (irq & (IRQ_SLAVE_FIRST - 1)));
    -
    -    _go32_dpmi_get_protected_mode_interrupt_vector(vector, &sSb.oldVector);
    -    sSb.newVector.pm_offset   = (unsigned long)sbIsr;
    -    sSb.newVector.pm_selector = _go32_my_cs();
    -
    -    if (_go32_dpmi_allocate_iret_wrapper(&sSb.newVector) != 0) {
    -        return;
    -    }
    -
    -    uint32_t state = platformIrqSave();
    -
    -    _go32_dpmi_set_protected_mode_interrupt_vector(vector, &sSb.newVector);
    -    sSb.savedPicBit = inportb(port) & bit;
    -    outportb(port, inportb(port) & ~bit);
    -
    -    if (irq >= IRQ_SLAVE_FIRST) {
    -        outportb(PIC1_DATA, inportb(PIC1_DATA) & ~(1 << IRQ_CASCADE));
    -    }
    -
    -    sSb.hooked = true;
    -    platformIrqRestore(state);
    -}
    -
    -
    -// The PIC's mask register for the IRQ.
    -static uint32_t irqPicPort(uint32_t irq) {
    -    return irq < IRQ_SLAVE_FIRST ? PIC1_DATA : PIC2_DATA;
    -}
    -
    -
    -static void irqUnhook(void) {
    -    if (!sSb.hooked) {
    -        return;
    -    }
    -
    -    uint32_t port  = irqPicPort(sSb.irq);
    -    uint32_t state = platformIrqSave();
    -
    -    outportb(port, inportb(port) | sSb.savedPicBit);
    -    _go32_dpmi_set_protected_mode_interrupt_vector(irqVector(sSb.irq), &sSb.oldVector);
    -    platformIrqRestore(state);
    -    _go32_dpmi_free_iret_wrapper(&sSb.newVector);
    -    sSb.hooked = false;
    -}
    -
    -
    -// The interrupt vector an IRQ arrives on.
    -static int irqVector(uint32_t irq) {
    -    return irq < IRQ_SLAVE_FIRST ? VEC_MASTER_BASE + (int)irq : VEC_SLAVE_BASE + (int)(irq - IRQ_SLAVE_FIRST);
    -}
    -
    -
     static uint8_t mixerRead(uint8_t reg) {
         outportb(sSb.base + DSP_MIXER_ADDR, reg);
         return inportb(sSb.base + DSP_MIXER_DATA);
    @@ -398,28 +282,21 @@ static void mixerWrite(uint8_t reg, uint8_t value) {
     }
     
     
    -// Also runs from audio.lib's shutdown hook, so it logs nothing and copes
    -// with any stage of sbOpen / sbPlay.
    +// Hand the half just recorded to audio.lib.  Interrupt time.
    +static void sbCapture(uint32_t half) {
    +    isaDmaRead(&sSb.dma, half * sSb.halfBytes, sSb.scratch, sSb.halfBytes / WORD_BYTES);
    +    audioRecPush(sSb.scratch, sSb.halfBytes);
    +}
    +
    +
    +// Stops playing or recording (recordStop is this too).  Also runs from
    +// audio.lib's shutdown hook, so it logs nothing and copes with any stage
    +// of sbOpen / sbPlay / sbRecordStart.
     static void sbClose(void) {
    -    bool sb16 = sSb.dspVersion >= SB_DSP_SB16;
    -
         if (sSb.playing) {
    -        if (sb16) {
    -            bool wide = sSb.plan.bits == BITS_16;
    +        sbHalt();
     
    -            dspWrite(sSb.base, wide ? DSP_CMD_PAUSE16 : DSP_CMD_PAUSE8);
    -            dspWrite(sSb.base, wide ? DSP_CMD_EXIT16 : DSP_CMD_EXIT8);
    -        } else if (sSb.plan.mode == SB_MODE_AUTO) {
    -            dspWrite(sSb.base, DSP_CMD_PAUSE8);
    -            dspWrite(sSb.base, DSP_CMD_EXIT8);
    -        }
    -
    -        // A reset stops every mode, high-speed and single-cycle included.
    -        dspReset(sSb.base);
    -        dspWrite(sSb.base, DSP_CMD_SPEAKER_OFF);
    -        dmaStop();
    -
    -        if (sb16) {
    +        if (sSb.dspVersion >= SB_DSP_SB16) {
                 mixerWrite(MIX_REG_VOICE16_L, sSb.savedVoiceL);
                 mixerWrite(MIX_REG_VOICE16_R, sSb.savedVoiceR);
             } else if (sSb.dspVersion >= SB_DSP_PRO) {
    @@ -430,17 +307,13 @@ static void sbClose(void) {
             sSb.playing = false;
         }
     
    -    irqUnhook();
    -
    -    if (sSb.locked) {
    -        sbLock(false);
    -        sSb.locked = false;
    +    if (sSb.recording) {
    +        sbHalt();
    +        sbInputRestore();
    +        sSb.recording = false;
         }
     
    -    if (sSb.dosSelector >= 0) {
    -        __dpmi_free_dos_memory(sSb.dosSelector);
    -        sSb.dosSelector = -1;
    -    }
    +    sbRelease();
     }
     
     
    @@ -461,6 +334,65 @@ static bool sbFindCard(SbBlasterT *bl) {
     }
     
     
    +// Stop the transfer running, out or in, at the DSP (a reset stops every
    +// mode, high-speed and single-cycle included) and the DMA controller.
    +static void sbHalt(void) {
    +    if (sSb.dspVersion >= SB_DSP_SB16) {
    +        bool wide = sSb.plan.bits == BITS_16;
    +
    +        dspWrite(sSb.base, wide ? DSP_CMD_PAUSE16 : DSP_CMD_PAUSE8);
    +        dspWrite(sSb.base, wide ? DSP_CMD_EXIT16 : DSP_CMD_EXIT8);
    +    } else if (sSb.plan.mode == SB_MODE_AUTO) {
    +        dspWrite(sSb.base, DSP_CMD_PAUSE8);
    +        dspWrite(sSb.base, DSP_CMD_EXIT8);
    +    }
    +
    +    dspReset(sSb.base);
    +    dspWrite(sSb.base, DSP_CMD_SPEAKER_OFF);
    +    isaDmaStop(&sSb.dma);
    +}
    +
    +
    +// The mixer registers sbInputSet changed, back as they were.
    +static void sbInputRestore(void) {
    +    if (sSb.dspVersion >= SB_DSP_SB16) {
    +        mixerWrite(MIX_REG_IN16_L, sSb.savedInput);
    +        mixerWrite(MIX_REG_IN16_R, sSb.savedInputR);
    +        mixerWrite(MIX_REG_MIC16, sSb.savedMic);
    +    } else if (sSb.dspVersion >= SB_DSP_PRO) {
    +        mixerWrite(MIX_REG_INPUT_PRO, sSb.savedInput);
    +        mixerWrite(MIX_REG_OUTPUT, sSb.savedOutput);
    +    }
    +}
    +
    +
    +// The mixer records from source (AUDIO_REC_*): an SB16's input switches
    +// and its microphone at full; an SB Pro's input select (the input filter
    +// off) and its stereo bit.  Cards before the Pro have no mixer: the
    +// microphone.
    +static void sbInputSet(int32_t source, uint32_t channels) {
    +    static const uint8_t in16L[AUDIO_REC_SOURCES] = { MIX_IN16_MIC, MIX_IN16_LINE_L, MIX_IN16_CD_L };
    +    static const uint8_t in16R[AUDIO_REC_SOURCES] = { MIX_IN16_MIC, MIX_IN16_LINE_R, MIX_IN16_CD_R };
    +    static const uint8_t inPro[AUDIO_REC_SOURCES] = { MIX_IN_PRO_MIC, MIX_IN_PRO_LINE, MIX_IN_PRO_CD };
    +
    +    if (sSb.dspVersion >= SB_DSP_SB16) {
    +        sSb.savedInput  = mixerRead(MIX_REG_IN16_L);
    +        sSb.savedInputR = mixerRead(MIX_REG_IN16_R);
    +        sSb.savedMic    = mixerRead(MIX_REG_MIC16);
    +        mixerWrite(MIX_REG_IN16_L, in16L[source]);
    +        mixerWrite(MIX_REG_IN16_R, in16R[source]);
    +        mixerWrite(MIX_REG_MIC16, MIX_VOICE16_MAX);
    +    } else if (sSb.dspVersion >= SB_DSP_PRO) {
    +        uint8_t output = mixerRead(MIX_REG_OUTPUT);
    +
    +        sSb.savedInput  = mixerRead(MIX_REG_INPUT_PRO);
    +        sSb.savedOutput = output;
    +        mixerWrite(MIX_REG_INPUT_PRO, (uint8_t)(inPro[source] | MIX_IN_PRO_NO_FILT));
    +        mixerWrite(MIX_REG_OUTPUT, (uint8_t)(channels > 1 ? (output | MIX_OUT_STEREO) : (output & ~MIX_OUT_STEREO)));
    +    }
    +}
    +
    +
     // The card's interrupt.  Acknowledge it at the DSP (an SB16 says in mixer
     // 82h which of its sources it was), then mix the half that just finished
     // with interrupts enabled, and only then acknowledge it at the PIC, so it
    @@ -470,12 +402,8 @@ static void sbIsr(void) {
         uint32_t base = sSb.base;
         bool     mine = true;
     
    -    if (sSb.irq == IRQ_SPURIOUS_MASTER) {
    -        outportb(PIC1_CMD, PIC_READ_ISR);
    -
    -        if (!(inportb(PIC1_CMD) & (1 << IRQ_SPURIOUS_MASTER))) {
    -            return;
    -        }
    +    if (isaIrqSpurious(&sSb.irq)) {
    +        return;
         }
     
         if (sSb.dspVersion >= SB_DSP_SB16) {
    @@ -498,41 +426,49 @@ static void sbIsr(void) {
     
         if (mine && sSb.priming) {
             sSb.primed = true;
    -    } else if (mine && sSb.playing) {
    +    } else if (mine && (sSb.playing || sSb.recording)) {
             sSb.irqCount++;
     
             if (sSb.plan.mode == SB_MODE_SINGLE) {
                 uint32_t len = sSb.halfBytes - 1;
     
    -            dspWrite(base, DSP_CMD_SINGLE8);
    +            dspWrite(base, sSb.recording ? DSP_CMD_SINGLE8_IN : DSP_CMD_SINGLE8);
                 dspWrite(base, (uint8_t)(len & BYTE_MASK));
                 dspWrite(base, (uint8_t)((len >> BYTE_BITS) & BYTE_MASK));
             }
     
    +        // The half the DMA controller is not in now is the one done.
    +        uint32_t half = isaDmaPosition(&sSb.dma) < sSb.halfBytes ? 1 : 0;
    +
             __asm__ __volatile__ ("sti" : : : "memory");
    -        sbRefill(1 - dmaHalfPlaying());
    +
    +        if (sSb.recording) {
    +            sbCapture(half);
    +        } else {
    +            sbRefill(half);
    +        }
    +
             __asm__ __volatile__ ("cli" : : : "memory");
         }
     
    -    if (sSb.irq >= IRQ_SLAVE_FIRST) {
    -        outportb(PIC2_CMD, PIC_EOI);
    -    }
    -
    -    outportb(PIC1_CMD, PIC_EOI);
    +    isaIrqEnd(&sSb.irq);
     }
     
     
    +// Pins this file's interrupt-time code and state, and the shared ISA
    +// code's.
     static bool sbLock(bool lock) {
         const AudioLockItemT items[] = {
    -        { (const void *)dmaHalfPlaying, 0 },
    -        { (const void *)dspWrite,       0 },
    -        { (const void *)sbIsr,          0 },
    -        { (const void *)sbRefill,       0 },
    -        { sDmaCountPort,                sizeof(sDmaCountPort) },
    -        { &sSb,                         sizeof(sSb) }
    +        { (const void *)dspWrite,  0 },
    +        { (const void *)sbCapture, 0 },
    +        { (const void *)sbIsr,     0 },
    +        { (const void *)sbRefill,  0 },
    +        { &sSb,                    sizeof(sSb) }
         };
     
    -    return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +    bool ok = audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +
    +    return isaLock(lock) && ok;
     }
     
     
    @@ -540,7 +476,7 @@ static bool sbOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
         SbBlasterT bl;
     
         memset(&sSb, 0, sizeof(sSb));
    -    sSb.dosSelector = -1;
    +    sSb.dma.selector = -1;
     
         if (!sbFindCard(&bl)) {
             dvxLog("Audio: sb: no Sound Blaster found (BLASTER %s)", getenv("BLASTER") ? "set" : "not set");
    @@ -570,7 +506,7 @@ static bool sbOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
                 }
             }
     
    -        for (uint32_t ch = 0; ch < DMA_CHANNELS; ch++) {
    +        for (uint32_t ch = 0; ch < ISA_DMA_CHANNELS; ch++) {
                 if (dmaBits & (1 << ch)) {
                     if (ch >= SB16_DMA_16_FIRST) {
                         bl.dma16 = ch;
    @@ -581,44 +517,29 @@ static bool sbOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
             }
         }
     
    -    sSb.irq = bl.irq == IRQ_CASCADE ? IRQ_AT_REDIRECT : bl.irq;
    +    sSb.irqNumber = bl.irq;
    +    sSb.dma8      = bl.dma8;
    +    sSb.dma16     = bl.dma16;
     
    -    bool have16 = sSb.dspVersion >= SB_DSP_SB16 && bl.dma16 != SB_NONE && bl.dma16 >= SB16_DMA_16_FIRST && bl.dma16 < DMA_CHANNELS;
    +    bool have16 = sSb.dspVersion >= SB_DSP_SB16 && bl.dma16 != SB_NONE && bl.dma16 >= SB16_DMA_16_FIRST && bl.dma16 < ISA_DMA_CHANNELS;
     
         sbPlan(sSb.dspVersion, cfg->rate, cfg->bits, cfg->channels, have16, &sSb.plan);
    -    sSb.dma = sSb.plan.bits == BITS_16 ? bl.dma16 : bl.dma8;
     
    -    bool dmaOk = sSb.plan.bits == BITS_16 ? (sSb.dma >= SB16_DMA_16_FIRST && sSb.dma < DMA_CHANNELS) : sSb.dma < DMA_FIRST_16;
    +    uint32_t dma   = sSb.plan.bits == BITS_16 ? bl.dma16 : bl.dma8;
    +    bool     dmaOk = sSb.plan.bits == BITS_16 ? (dma >= SB16_DMA_16_FIRST && dma < ISA_DMA_CHANNELS) : dma < ISA_DMA_FIRST_16;
     
    -    if (sSb.irq >= IRQ_COUNT || !dmaOk) {
    +    if (sSb.irqNumber >= IRQ_COUNT || !dmaOk) {
             dvxLog("Audio: sb: IRQ or DMA channel unknown or unusable (set BLASTER, e.g. A220 I5 D1 H5)");
             return false;
         }
     
         uint32_t frameBytes = sSb.plan.bits / BYTE_BITS * sSb.plan.channels;
    -    uint32_t bufBytes   = SB_BLOCK_FRAMES * frameBytes * SB_HALVES;
    -    uint32_t allocBytes = bufBytes * 2;
    -    int      selector   = 0;
    -    int      segment    = __dpmi_allocate_dos_memory((int)(allocBytes / PARAGRAPH_BYTES), &selector);
     
    -    if (segment < 0) {
    +    if (!isaDmaAlloc(&sSb.dma, dma, SB_BLOCK_FRAMES * frameBytes * SB_HALVES)) {
             dvxLog("Audio: sb: no conventional memory for the DMA buffer");
             return false;
         }
     
    -    uint32_t blockPhys = (uint32_t)segment << PARAGRAPH_SHIFT;
    -    uint32_t offset    = sbDmaPlace(blockPhys, allocBytes, bufBytes, sSb.plan.bits == BITS_16 ? SB_DMA_BOUNDARY_16 : SB_DMA_BOUNDARY_8);
    -
    -    sSb.dosSelector = selector;
    -
    -    if (offset == SB_NONE) {
    -        dvxLog("Audio: sb: the DMA buffer would cross a DMA boundary");
    -        sbClose();
    -        return false;
    -    }
    -
    -    sSb.bufOffset  = offset;
    -    sSb.bufPhys    = blockPhys + offset;
         sSb.halfFrames = SB_BLOCK_FRAMES;
         sSb.halfBytes  = SB_BLOCK_FRAMES * frameBytes;
     
    @@ -626,8 +547,9 @@ static bool sbOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
         info->rate     = sSb.plan.rate;
         info->bits     = sSb.plan.bits;
         info->channels = sSb.plan.channels;
    -    info->hasDac   = true;
    -    dvxLog("Audio: sb: DSP %d.%02d at %lXh, IRQ %lu, DMA %lu, mode %d", (int)major, (int)minor, (unsigned long)sSb.base, (unsigned long)sSb.irq, (unsigned long)sSb.dma, (int)sSb.plan.mode);
    +    info->hasDac     = true;
    +    info->recSources = sSb.dspVersion >= SB_DSP_PRO ? (1U << AUDIO_REC_MIC) | (1U << AUDIO_REC_LINE) | (1U << AUDIO_REC_CD) : (1U << AUDIO_REC_MIC);
    +    dvxLog("Audio: sb: DSP %d.%02d at %lXh, IRQ %lu, DMA %lu, mode %d", (int)major, (int)minor, (unsigned long)sSb.base, (unsigned long)sSb.irqNumber, (unsigned long)sSb.dma.channel, (int)sSb.plan.mode);
         return true;
     }
     
    @@ -641,15 +563,9 @@ static bool sbPlay(void) {
             return false;
         }
     
    -    _farsetsel((unsigned short)sSb.dosSelector);
    +    isaDmaFill(&sSb.dma, silence);
     
    -    for (uint32_t i = 0; i < sSb.halfBytes * SB_HALVES; i++) {
    -        _farnspokeb(sSb.bufOffset + i, silence);
    -    }
    -
    -    irqHook();
    -
    -    if (!sSb.hooked) {
    +    if (!isaIrqHook(&sSb.irq, sSb.irqNumber, sbIsr)) {
             return false;
         }
     
    @@ -675,32 +591,16 @@ static bool sbPlay(void) {
         sSb.playing = true;
         sbRefill(0);
         sbRefill(1);
    -    dmaProgram(0, sSb.halfBytes * SB_HALVES, DMA_MODE_AUTO_PLAY);
    -    sbStartDsp();
    -
    -    // A card set to another IRQ than BLASTER says plays one buffer and
    -    // stops; give up on it so the next driver is tried.
    -    uint64_t until = platformMicros() + FIRST_IRQ_WAIT_US;
    -
    -    while (sSb.irqCount == 0 && platformMicros() < until) {
    -        platformYield();
    -    }
    -
    -    if (sSb.irqCount == 0) {
    -        dvxLog("Audio: sb: no interrupt from the card on IRQ %lu (check BLASTER)", (unsigned long)sSb.irq);
    -        return false;
    -    }
    -
    -    return true;
    +    isaDmaProgram(&sSb.dma, 0, sSb.dma.bytes, ISA_DMA_AUTO_PLAY);
    +    sbStartDsp(false);
    +    return sbWaitFirstIrq();
     }
     
     
    -// SB Pro stereo: play one silent byte and wait for its interrupt, so the
    -// real transfer starts on the left channel.
    +// SB Pro stereo: play one silent byte (the buffer is silent yet) and wait
    +// for its interrupt, so the real transfer starts on the left channel.
     static void sbPrimeStereo(void) {
    -    _farsetsel((unsigned short)sSb.dosSelector);
    -    _farnspokeb(sSb.bufOffset, SILENCE_8);
    -    dmaProgram(0, 1, DMA_MODE_ONCE_PLAY);
    +    isaDmaProgram(&sSb.dma, 0, 1, ISA_DMA_ONCE_PLAY);
         sSb.primed  = false;
         sSb.priming = true;
         dspWrite(sSb.base, DSP_CMD_TIME_CONST);
    @@ -716,27 +616,84 @@ static void sbPrimeStereo(void) {
         }
     
         sSb.priming = false;
    -    dmaStop();
    +    isaDmaStop(&sSb.dma);
    +}
    +
    +
    +// Take the card for recording (the output is closed): the plan for the
    +// format asked, a DMA buffer on the channel its bits need, the mixer set
    +// to source, and the transfer started; got is what the card records.
    +static bool sbRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got) {
    +    bool have16 = sSb.dspVersion >= SB_DSP_SB16 && sSb.dma16 >= SB16_DMA_16_FIRST && sSb.dma16 < ISA_DMA_CHANNELS;
    +
    +    sbRecordPlan(sSb.dspVersion, want->rate, want->bits, want->channels, have16, &sSb.plan);
    +
    +    uint32_t dma        = sSb.plan.bits == BITS_16 ? sSb.dma16 : sSb.dma8;
    +    uint32_t frameBytes = sSb.plan.bits / BYTE_BITS * sSb.plan.channels;
    +
    +    if (sSb.plan.bits != BITS_16 && dma >= ISA_DMA_FIRST_16) {
    +        dvxLog("Audio: sb: no 8-bit DMA channel to record on (set BLASTER D)");
    +        return false;
    +    }
    +
    +    if (!isaDmaAlloc(&sSb.dma, dma, SB_BLOCK_FRAMES * frameBytes * SB_HALVES)) {
    +        dvxLog("Audio: sb: no conventional memory for the recording's DMA buffer");
    +        return false;
    +    }
    +
    +    sSb.halfFrames = SB_BLOCK_FRAMES;
    +    sSb.halfBytes  = SB_BLOCK_FRAMES * frameBytes;
    +    sSb.locked     = sbLock(true);
    +
    +    if (!sSb.locked || !isaIrqHook(&sSb.irq, sSb.irqNumber, sbIsr)) {
    +        sbRelease();
    +        return false;
    +    }
    +
    +    dspWrite(sSb.base, DSP_CMD_SPEAKER_OFF);
    +    sbInputSet(source, sSb.plan.channels);
    +    sSb.irqCount  = 0;
    +    sSb.recording = true;
    +    isaDmaProgram(&sSb.dma, 0, sSb.dma.bytes, ISA_DMA_AUTO_RECORD);
    +    sbStartDsp(true);
    +
    +    if (!sbWaitFirstIrq()) {
    +        sbClose();
    +        return false;
    +    }
    +
    +    got->rate     = sSb.plan.rate;
    +    got->bits     = sSb.plan.bits;
    +    got->channels = sSb.plan.channels;
    +    dvxLog("Audio: sb: recording, mode %d, DMA %lu", (int)sSb.plan.mode, (unsigned long)sSb.dma.channel);
    +    return true;
     }
     
     
     // Mix one half into the scratch buffer and copy it to the DMA buffer.
     // Interrupt time.
     static void sbRefill(uint32_t half) {
    -    uint32_t offset = sSb.bufOffset + half * sSb.halfBytes;
    -
         audioMixRender(sSb.scratch, sSb.halfFrames);
    -    _farsetsel((unsigned short)sSb.dosSelector);
    -
    -    for (uint32_t i = 0; i < sSb.halfBytes / WORD_BYTES; i++) {
    -        _farnspokel(offset + i * WORD_BYTES, sSb.scratch[i]);
    -    }
    +    isaDmaWrite(&sSb.dma, half * sSb.halfBytes, sSb.scratch, sSb.halfBytes / WORD_BYTES);
     }
     
     
    -// Start the transfer the plan calls for; the DSP interrupts after each
    -// half.
    -static void sbStartDsp(void) {
    +// Unhook the interrupt, unpin, and free the DMA buffer (after sbHalt).
    +static void sbRelease(void) {
    +    isaIrqUnhook(&sSb.irq);
    +
    +    if (sSb.locked) {
    +        sbLock(false);
    +        sSb.locked = false;
    +    }
    +
    +    isaDmaFree(&sSb.dma);
    +}
    +
    +
    +// Start the transfer the plan calls for, playing or recording (input);
    +// the DSP interrupts after each half.
    +static void sbStartDsp(bool input) {
         uint32_t base = sSb.base;
     
         if (sSb.plan.mode == SB_MODE_SB16) {
    @@ -744,10 +701,16 @@ static void sbStartDsp(void) {
             uint32_t len  = sSb.halfFrames * sSb.plan.channels - 1;
             uint8_t  mode = (uint8_t)((sSb.plan.channels > 1 ? SB16_MODE_STEREO : 0) | (wide ? SB16_MODE_SIGNED : 0));
     
    -        dspWrite(base, DSP_CMD_OUT_RATE);
    +        dspWrite(base, input ? DSP_CMD_IN_RATE : DSP_CMD_OUT_RATE);
             dspWrite(base, (uint8_t)((sSb.plan.rate >> BYTE_BITS) & BYTE_MASK));
             dspWrite(base, (uint8_t)(sSb.plan.rate & BYTE_MASK));
    -        dspWrite(base, wide ? DSP_CMD_SB16_16 : DSP_CMD_SB16_8);
    +
    +        if (input) {
    +            dspWrite(base, wide ? DSP_CMD_SB16_16_IN : DSP_CMD_SB16_8_IN);
    +        } else {
    +            dspWrite(base, wide ? DSP_CMD_SB16_16 : DSP_CMD_SB16_8);
    +        }
    +
             dspWrite(base, mode);
             dspWrite(base, (uint8_t)(len & BYTE_MASK));
             dspWrite(base, (uint8_t)((len >> BYTE_BITS) & BYTE_MASK));
    @@ -760,7 +723,7 @@ static void sbStartDsp(void) {
         dspWrite(base, (uint8_t)sSb.plan.timeConstant);
     
         if (sSb.plan.mode == SB_MODE_SINGLE) {
    -        dspWrite(base, DSP_CMD_SINGLE8);
    +        dspWrite(base, input ? DSP_CMD_SINGLE8_IN : DSP_CMD_SINGLE8);
             dspWrite(base, (uint8_t)(len & BYTE_MASK));
             dspWrite(base, (uint8_t)((len >> BYTE_BITS) & BYTE_MASK));
             return;
    @@ -769,5 +732,28 @@ static void sbStartDsp(void) {
         dspWrite(base, DSP_CMD_BLOCK_SIZE);
         dspWrite(base, (uint8_t)(len & BYTE_MASK));
         dspWrite(base, (uint8_t)((len >> BYTE_BITS) & BYTE_MASK));
    -    dspWrite(base, sSb.plan.mode == SB_MODE_HIGH ? DSP_CMD_HIGH_AUTO8 : DSP_CMD_AUTO8);
    +
    +    if (input) {
    +        dspWrite(base, sSb.plan.mode == SB_MODE_HIGH ? DSP_CMD_HIGH_IN8 : DSP_CMD_AUTO8_IN);
    +    } else {
    +        dspWrite(base, sSb.plan.mode == SB_MODE_HIGH ? DSP_CMD_HIGH_AUTO8 : DSP_CMD_AUTO8);
    +    }
    +}
    +
    +
    +// A card set to another IRQ than BLASTER says moves one buffer and stops;
    +// give up on it (the next driver is tried, or the recording refused).
    +static bool sbWaitFirstIrq(void) {
    +    uint64_t until = platformMicros() + FIRST_IRQ_WAIT_US;
    +
    +    while (sSb.irqCount == 0 && platformMicros() < until) {
    +        platformYield();
    +    }
    +
    +    if (sSb.irqCount == 0) {
    +        dvxLog("Audio: sb: no interrupt from the card on IRQ %lu (check BLASTER)", (unsigned long)sSb.irqNumber);
    +        return false;
    +    }
    +
    +    return true;
     }
    diff --git a/src/libs/kpunch/audio/drivers/wss/wssCalc.c b/src/libs/kpunch/audio/drivers/wss/wssCalc.c
    new file mode 100644
    index 00000000..40f9e5e6
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/wss/wssCalc.c
    @@ -0,0 +1,64 @@
    +// 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.
    +
    +// wssCalc.c  -- Windows Sound System driver arithmetic (see wssCalc.h)
    +//
    +// The board's configuration register (Microsoft's layout, as OPTi's data
    +// sheet prints it) holds the IRQ in bits 5:3 and the playback DMA
    +// channel in bits 2:0.
    +
    +#include "wssCalc.h"
    +
    +#define CONFIG_IRQ_SHIFT    3
    +#define NONE                0       // a code that routes nothing
    +
    +// The configuration codes for IRQ 7, 9, 10, 11 and DMA 0, 1, 3
    +static const uint8_t sIrqs[]     = { 7, 9, 10, 11 };
    +static const uint8_t sIrqCodes[] = { 1, 2, 3, 4 };
    +static const uint8_t sDmas[]     = { 0, 1, 3 };
    +static const uint8_t sDmaCodes[] = { 1, 2, 3 };
    +
    +#define IRQ_CHOICES         ((int32_t)(sizeof(sIrqs) / sizeof(sIrqs[0])))
    +#define DMA_CHOICES         ((int32_t)(sizeof(sDmas) / sizeof(sDmas[0])))
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +bool wssConfig(uint32_t irq, uint32_t dma, uint8_t *value);
    +
    +
    +bool wssConfig(uint32_t irq, uint32_t dma, uint8_t *value) {
    +    uint8_t irqCode = NONE;
    +    uint8_t dmaCode = NONE;
    +
    +    for (int32_t i = 0; i < IRQ_CHOICES; i++) {
    +        irqCode = sIrqs[i] == irq ? sIrqCodes[i] : irqCode;
    +    }
    +
    +    for (int32_t i = 0; i < DMA_CHOICES; i++) {
    +        dmaCode = sDmas[i] == dma ? sDmaCodes[i] : dmaCode;
    +    }
    +
    +    *value = (uint8_t)(irqCode << CONFIG_IRQ_SHIFT | dmaCode);
    +    return irqCode != NONE && dmaCode != NONE;
    +}
    diff --git a/src/libs/kpunch/audio/drivers/wss/wssCalc.h b/src/libs/kpunch/audio/drivers/wss/wssCalc.h
    new file mode 100644
    index 00000000..8bad81e9
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/wss/wssCalc.h
    @@ -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.
    +
    +// wssCalc.h  -- Windows Sound System driver arithmetic (no hardware
    +// access)
    +//
    +// The part of wss.drv that decides rather than touches hardware, kept
    +// apart so the host tests run it: the board's configuration register
    +// value for an IRQ and a DMA channel.  The codec's own arithmetic (its
    +// rates and format byte) is codec/codecCalc.c's.
    +#ifndef WSS_CALC_H
    +#define WSS_CALC_H
    +
    +#include 
    +#include 
    +
    +// The configuration register value (written at the board's base) that
    +// routes the codec to irq (7, 9, 10 or 11) and DMA channel dma (0, 1 or
    +// 3); false for one the board cannot use.
    +bool wssConfig(uint32_t irq, uint32_t dma, uint8_t *value);
    +
    +#endif // WSS_CALC_H
    diff --git a/src/libs/kpunch/audio/drivers/wss/wssDrv.c b/src/libs/kpunch/audio/drivers/wss/wssDrv.c
    new file mode 100644
    index 00000000..15196739
    --- /dev/null
    +++ b/src/libs/kpunch/audio/drivers/wss/wssDrv.c
    @@ -0,0 +1,385 @@
    +// 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.
    +
    +// wssDrv.c  -- the Windows Sound System sound driver (wss.drv)
    +//
    +// For boards built on the AD1848 codec and its kin (Microsoft's own,
    +// the Crystal CS4231 / CS4248 ones, Aztech, OPTi and others in their WSS
    +// mode, which play 16-bit stereo at up to 48 kHz).  The board sits at
    +// 530h, 604h, E80h or F40h (DVX.INI [audio] wssPort, hex, to name one):
    +// its configuration register at base..base+3, the codec at base+4..+7.
    +// A codec is there when its index registers keep what is written to
    +// them.  The configuration register routes the codec to an IRQ and an
    +// 8-bit DMA channel (DVX.INI wssIrq: 7, 9, 10 or 11, default 7; wssDma:
    +// 0, 1 or 3, default 1); a codec without one (a Plug and Play board set
    +// up by its own utility) must be on the ones DVX.INI names.  Either way
    +// a board that does not interrupt on that IRQ is given up, so the next
    +// driver is tried.
    +//
    +// The codec itself is codec/codecHw.c's (shared with the UltraSound
    +// MAX): the rate is its nearest to the one asked for, 8- or 16-bit
    +// (16-bit data still moves over an 8-bit DMA channel, a byte at a time),
    +// mono or stereo, the format set in a mode change with its calibration,
    +// the DAC unmuted at full (DVX's own volumes do the rest) and put back
    +// when it stops.  Output is double-buffered as sb.drv's is: one auto-init
    +// DMA transfer over a buffer of two halves, the codec's sample counter
    +// interrupting at the end of each, the interrupt mixing the half just
    +// played (audio.lib's mixer, inside the interrupt with interrupts enabled
    +// again but the card's not yet acknowledged at the PIC).
    +//
    +// Recording (recordStart / recordStop) runs the codec's capture the same
    +// way over the same DMA channel, its interrupt handing each half
    +// recorded to audio.lib.  The input is the ADC's source: the line input,
    +// AUX1 (the CD on WSS boards), or the microphone with its 20 dB boost;
    +// the inputs it found are put back when it stops.
    +
    +#include "audioDrv.h"
    +#include "wssCalc.h"
    +#include "../../codec/codecHw.h"
    +#include "../../isa/isaHw.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define INI_WSS_PORT        "wssPort"
    +#define INI_WSS_IRQ         "wssIrq"
    +#define INI_WSS_DMA         "wssDma"
    +#define WSS_IRQ_DEFAULT     7
    +#define WSS_DMA_DEFAULT     1
    +#define PORT_BASE_HEX       16
    +
    +// The board: its version register (read) and configuration register
    +// (write) at base+0..3, the codec from base+4
    +#define BOARD_VERSION       3
    +#define BOARD_ID_MASK       0x3F
    +#define CODEC_OFFSET        4
    +
    +// Buffer: two halves of WSS_BLOCK_FRAMES frames
    +#define WSS_BLOCK_FRAMES    512
    +#define WSS_FRAME_BYTES_MAX 4
    +#define WSS_HALVES          2
    +#define WORD_BYTES          4
    +#define BYTE_BITS           8
    +#define BITS_8              8
    +#define BITS_16             16
    +#define CHANNELS_MAX        2
    +#define SILENCE_8           0x80
    +#define SILENCE_16          0x00
    +#define FIRST_IRQ_WAIT_US   500000  // many blocks: no interrupt by then means a wrong IRQ
    +
    +// Board bases probed without wssPort
    +static const uint16_t sBases[] = { 0x530, 0x604, 0xE80, 0xF40 };
    +
    +// Version register values of WSS boards (the low six bits)
    +static const uint8_t sBoardIds[] = { 0x04, 0x00, 0x0F };
    +
    +// The ADC's input for each recording source
    +static const uint8_t sInputs[] = {
    +    [AUDIO_REC_MIC]  = CODEC_INPUT_MIC | CODEC_INPUT_MIC_BOOST,
    +    [AUDIO_REC_LINE] = CODEC_INPUT_LINE,
    +    [AUDIO_REC_CD]   = CODEC_INPUT_AUX1
    +};
    +
    +typedef struct {
    +    uint32_t          scratch[WSS_BLOCK_FRAMES * WSS_FRAME_BYTES_MAX / WORD_BYTES];  // one half, mixed here first
    +    CodecT            codec;
    +    IsaDmaT           dma;
    +    IsaIrqT           irq;
    +    uint32_t          irqNumber;
    +    uint32_t          halfBytes;
    +    uint16_t          base;
    +    uint8_t           format;           // I8
    +    volatile uint32_t irqCount;
    +    bool              locked;
    +    bool              playing;
    +    bool              recording;
    +} WssStateT;
    +
    +static WssStateT sWss = { .dma.selector = -1 };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool hasConfig(uint16_t base);
    +static void wssCapture(uint32_t half);
    +static void wssClose(void);
    +static void wssIsr(void);
    +static bool wssLock(bool lock);
    +static bool wssOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info);
    +static bool wssPlay(void);
    +static bool wssRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got);
    +static void wssRefill(uint32_t half);
    +static bool wssStart(uint8_t what);
    +
    +AUDIO_DRIVER("wss", AUDIO_PRIORITY_CODEC,
    +    .open        = wssOpen,
    +    .play        = wssPlay,
    +    .close       = wssClose,
    +    .recordStart = wssRecordStart,
    +    .recordStop  = wssClose
    +);
    +
    +
    +// The board has Microsoft's configuration register (its version register
    +// reads one of the WSS ids).
    +static bool hasConfig(uint16_t base) {
    +    uint8_t id = inportb(base + BOARD_VERSION) & BOARD_ID_MASK;
    +
    +    for (uint32_t i = 0; i < sizeof(sBoardIds); i++) {
    +        if (id == sBoardIds[i]) {
    +            return true;
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +// Hand the half just recorded to audio.lib.  Interrupt time.
    +static void wssCapture(uint32_t half) {
    +    isaDmaRead(&sWss.dma, half * sWss.halfBytes, sWss.scratch, sWss.halfBytes / WORD_BYTES);
    +    audioRecPush(sWss.scratch, sWss.halfBytes);
    +}
    +
    +
    +// Stops playing or recording (recordStop is this too), the codec putting
    +// back the levels or the inputs it found.  Also runs from audio.lib's
    +// shutdown hook, so it logs nothing and copes with any stage of wssOpen,
    +// wssPlay, and wssRecordStart.
    +static void wssClose(void) {
    +    if (sWss.playing || sWss.recording) {
    +        codecStop(&sWss.codec);
    +        isaDmaStop(&sWss.dma);
    +        sWss.playing   = false;
    +        sWss.recording = false;
    +    }
    +
    +    isaIrqUnhook(&sWss.irq);
    +
    +    if (sWss.locked) {
    +        wssLock(false);
    +        sWss.locked = false;
    +    }
    +
    +    isaDmaFree(&sWss.dma);
    +}
    +
    +
    +// The codec's interrupt: its sample counter ran out at the end of a half.
    +// Acknowledged at the codec, the half just played mixed (or the half just
    +// recorded handed on) with interrupts enabled, and only then acknowledged
    +// at the PIC.  A spurious IRQ 7 gets no acknowledgement at all.
    +static void wssIsr(void) {
    +    if (isaIrqSpurious(&sWss.irq)) {
    +        return;
    +    }
    +
    +    if (codecIrq(&sWss.codec) && (sWss.playing || sWss.recording)) {
    +        uint32_t half = isaDmaPosition(&sWss.dma) < sWss.halfBytes ? 1 : 0;
    +
    +        sWss.irqCount++;
    +        __asm__ __volatile__ ("sti" : : : "memory");
    +
    +        if (sWss.recording) {
    +            wssCapture(half);
    +        } else {
    +            wssRefill(half);
    +        }
    +
    +        __asm__ __volatile__ ("cli" : : : "memory");
    +    }
    +
    +    isaIrqEnd(&sWss.irq);
    +}
    +
    +
    +// This file's interrupt-time code and state, and the shared codec and ISA
    +// code's.
    +static bool wssLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)wssCapture, 0 },
    +        { (const void *)wssIsr,     0 },
    +        { (const void *)wssRefill,  0 },
    +        { &sWss,                    sizeof(sWss) }
    +    };
    +
    +    bool ok = audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +
    +    ok = codecLock(lock) && ok;
    +    return isaLock(lock) && ok;
    +}
    +
    +
    +// The board (DVX.INI's, else the usual bases), routed to the IRQ and DMA
    +// channel; the format and the DMA buffer.
    +static bool wssOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
    +    const char *portText = prefsGetString(cfg->prefs, AUDIO_INI_SECTION, INI_WSS_PORT, "");
    +    uint32_t    dma      = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_WSS_DMA, WSS_DMA_DEFAULT);
    +    uint32_t    bits     = cfg->bits ? cfg->bits : BITS_16;
    +    uint32_t    channels = cfg->channels ? cfg->channels : CHANNELS_MAX;
    +    uint32_t    rate;
    +    uint8_t     route;
    +    bool        found    = false;
    +
    +    memset(&sWss, 0, sizeof(sWss));
    +    sWss.dma.selector = -1;
    +    sWss.irqNumber    = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_WSS_IRQ, WSS_IRQ_DEFAULT);
    +
    +    uint16_t named = portText[0] ? (uint16_t)strtoul(portText, NULL, PORT_BASE_HEX) : 0;
    +    uint32_t tries = named ? 1 : (uint32_t)(sizeof(sBases) / sizeof(sBases[0]));
    +
    +    for (uint32_t i = 0; !found && i < tries; i++) {
    +        sWss.base = named ? named : sBases[i];
    +        found     = codecFound(&sWss.codec, (uint16_t)(sWss.base + CODEC_OFFSET));
    +    }
    +
    +    if (!found) {
    +        return false;
    +    }
    +
    +    if (!wssConfig(sWss.irqNumber, dma, &route)) {
    +        dvxLog("Audio: wss: IRQ %lu / DMA %lu cannot be used (wssIrq 7, 9, 10 or 11; wssDma 0, 1 or 3)", (unsigned long)sWss.irqNumber, (unsigned long)dma);
    +        return false;
    +    }
    +
    +    if (hasConfig(sWss.base)) {
    +        outportb(sWss.base, route);
    +    }
    +
    +    sWss.format = (uint8_t)(codecFormat(bits, channels) | codecRateCode(cfg->rate, &rate));
    +
    +    uint32_t frameBytes = bits / BYTE_BITS * channels;
    +
    +    if (!isaDmaAlloc(&sWss.dma, dma, WSS_BLOCK_FRAMES * frameBytes * WSS_HALVES)) {
    +        dvxLog("Audio: wss: no conventional memory for the DMA buffer");
    +        return false;
    +    }
    +
    +    sWss.halfBytes = WSS_BLOCK_FRAMES * frameBytes;
    +    snprintf(info->description, sizeof(info->description), "Windows Sound System (%s codec) at %Xh", codecIsCrystal(&sWss.codec) ? "Crystal" : "AD1848", (unsigned)sWss.base);
    +    info->rate       = rate;
    +    info->bits       = bits;
    +    info->channels   = channels;
    +    info->hasDac     = true;
    +    info->recSources = (1U << AUDIO_REC_MIC) | (1U << AUDIO_REC_LINE) | (1U << AUDIO_REC_CD);
    +    dvxLog("Audio: wss: codec at %Xh, IRQ %lu, DMA %lu, %lu Hz", (unsigned)sWss.codec.port, (unsigned long)sWss.irqNumber, (unsigned long)dma, (unsigned long)rate);
    +    return true;
    +}
    +
    +
    +static bool wssPlay(void) {
    +    sWss.locked = wssLock(true);
    +
    +    if (!sWss.locked) {
    +        return false;
    +    }
    +
    +    isaDmaFill(&sWss.dma, (sWss.format & CODEC_FMT_16BIT) ? SILENCE_16 : SILENCE_8);
    +
    +    if (!isaIrqHook(&sWss.irq, sWss.irqNumber, wssIsr)) {
    +        return false;
    +    }
    +
    +    codecSetFormat(&sWss.codec, sWss.format);
    +    codecDacOn(&sWss.codec);
    +    sWss.playing = true;
    +    wssRefill(0);
    +    wssRefill(1);
    +    isaDmaProgram(&sWss.dma, 0, sWss.dma.bytes, ISA_DMA_AUTO_PLAY);
    +    return wssStart(CODEC_PLAY);
    +}
    +
    +
    +// Take the codec for recording (the output is closed): the format asked
    +// for (the codec's nearest rate; it records anything it plays), a DMA
    +// buffer on the board's channel, the input set to source, and capture
    +// started; got is what it records.
    +static bool wssRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got) {
    +    uint32_t bits       = want->bits == BITS_8 ? BITS_8 : BITS_16;
    +    uint32_t channels   = want->channels == CHANNELS_MAX ? CHANNELS_MAX : 1;
    +    uint32_t frameBytes = bits / BYTE_BITS * channels;
    +    uint32_t rate;
    +
    +    sWss.format = (uint8_t)(codecFormat(bits, channels) | codecRateCode(want->rate, &rate));
    +
    +    if (!isaDmaAlloc(&sWss.dma, sWss.dma.channel, WSS_BLOCK_FRAMES * frameBytes * WSS_HALVES)) {
    +        dvxLog("Audio: wss: no conventional memory for the recording's DMA buffer");
    +        return false;
    +    }
    +
    +    sWss.halfBytes = WSS_BLOCK_FRAMES * frameBytes;
    +    sWss.locked    = wssLock(true);
    +
    +    if (!sWss.locked || !isaIrqHook(&sWss.irq, sWss.irqNumber, wssIsr)) {
    +        wssClose();
    +        return false;
    +    }
    +
    +    codecSetFormat(&sWss.codec, sWss.format);
    +    codecInputSet(&sWss.codec, sInputs[source]);
    +    sWss.recording = true;
    +    isaDmaProgram(&sWss.dma, 0, sWss.dma.bytes, ISA_DMA_AUTO_RECORD);
    +
    +    if (!wssStart(CODEC_CAPTURE)) {
    +        wssClose();
    +        return false;
    +    }
    +
    +    got->rate     = rate;
    +    got->bits     = bits;
    +    got->channels = channels;
    +    dvxLog("Audio: wss: recording, %lu Hz", (unsigned long)rate);
    +    return true;
    +}
    +
    +
    +// Mix one half into the scratch buffer and copy it to the DMA buffer.
    +// Interrupt time.
    +static void wssRefill(uint32_t half) {
    +    audioMixRender(sWss.scratch, WSS_BLOCK_FRAMES);
    +    isaDmaWrite(&sWss.dma, half * sWss.halfBytes, sWss.scratch, sWss.halfBytes / WORD_BYTES);
    +}
    +
    +
    +// The codec started (what: playing or capture) with an interrupt every
    +// half.  A board on another IRQ moves one buffer and stops; it is given
    +// up on (the next driver is tried, or the recording refused).
    +static bool wssStart(uint8_t what) {
    +    sWss.irqCount = 0;
    +    codecStart(&sWss.codec, WSS_BLOCK_FRAMES, what);
    +
    +    uint64_t until = platformMicros() + FIRST_IRQ_WAIT_US;
    +
    +    while (sWss.irqCount == 0 && platformMicros() < until) {
    +        platformYield();
    +    }
    +
    +    if (sWss.irqCount == 0) {
    +        dvxLog("Audio: wss: no interrupt from the codec on IRQ %lu (set wssIrq)", (unsigned long)sWss.irqNumber);
    +        return false;
    +    }
    +
    +    return true;
    +}
    diff --git a/src/libs/kpunch/audio/gus/gusCalc.c b/src/libs/kpunch/audio/gus/gusCalc.c
    new file mode 100644
    index 00000000..75f31813
    --- /dev/null
    +++ b/src/libs/kpunch/audio/gus/gusCalc.c
    @@ -0,0 +1,275 @@
    +// 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.
    +
    +// gusCalc.c  -- Gravis UltraSound arithmetic (see gusCalc.h)
    +
    +#include "gusCalc.h"
    +#include "../math/drvMath.h"
    +
    +#include 
    +
    +#define ENV_FIELDS          5
    +#define PORT_HEX            16
    +#define DECIMAL             10
    +#define IRQ_CASCADE         2
    +#define IRQ_AT_CASCADE      9
    +#define FC_FRACTION_BITS    9       // FC: integer in bits 15-10, fraction in 9-1
    +#define ADDR_HIGH_SHIFT     7
    +#define ADDR_HIGH_MASK      0x1FFF
    +#define ADDR_LOW_MASK       0x7F
    +#define ADDR_LOW_SHIFT      9
    +#define ADDR16_WORD_MASK    0x1FFFF
    +#define ADDR16_BANK_MASK    0xC0000
    +#define VOL_MANT_BITS       8
    +#define VOL_MANT_MASK       0xFF
    +#define LINEAR_TOP_BIT      15
    +
    +#define ATTEN_STEP_NUM      4252    // 12-bit volume steps per 1000 centibels (256 / 6.0206 dB x 100)
    +#define ATTEN_STEP_DEN      1000
    +#define PAN_SPAN            1000    // -500..500
    +#define PAN_HALF            500
    +#define PAN_STEPS           15
    +#define FC_UNITY_SHIFT      26      // FC 1.0 (0400h, 10 bits) in 16.16
    +#define FC_RESULT_SHIFT     32      // 16.16 FC x 16.16 2^x
    +#define FC_MAX              0xFFFE
    +#define DMA_LATCH2_SHIFT    3
    +#define DMA_FIRST_16        4       // channels 5..7 move words
    +#define SAMPLE_DIVISOR_BIAS 2       // 48h holds the divisor less two
    +#define MAX_VERSION_FIRST   10      // a MAX's board versions
    +#define MAX_VERSION_LAST    11
    +#define MAX_PORT_SHIFT      4       // the codec's port: base + 10Ch, from the base's bits 7-4
    +#define MAX_PORT_MASK       0x0F
    +#define MAX_DMA1_WIDE       0x10
    +#define MAX_DMA2_WIDE       0x20
    +#define MAX_CODEC_ON        0x40
    +#define FC_BIT0             0xFFFE  // bit 0 unused
    +
    +// IRQ and DMA latch codes (SDK 2.14, 2.15): position + 1 is the code
    +static const uint8_t sIrqs[] = { IRQ_CASCADE, 5, 3, 7, 11, 12, 15 };
    +static const uint8_t sDmas[] = { 1, 3, 5, 6, 7 };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint32_t gusAddr16(uint32_t addr);
    +uint16_t gusAddrHigh(uint32_t addr);
    +uint16_t gusAddrLow(uint32_t addr);
    +uint16_t gusAttenVolume(int32_t centibels);
    +uint8_t  gusDmaCode(uint32_t dma);
    +uint8_t  gusDmaLatch(uint32_t dma1, uint32_t dma2);
    +uint16_t gusFc(uint32_t hz, uint32_t outRate);
    +uint32_t gusFcBase(uint32_t unityRate, uint32_t outRate);
    +uint16_t gusFcCents(int32_t cents, uint32_t fcBase);
    +uint8_t  gusIrqCode(uint32_t irq);
    +bool     gusIsMax(uint8_t boardVersion);
    +uint8_t  gusMaxControl(uint32_t base, uint32_t dma1, uint32_t dma2);
    +uint32_t gusOutputRate(uint32_t voices);
    +uint8_t  gusPan(int32_t pan);
    +bool     gusParseEnv(const char *text, GusEnvT *out);
    +uint32_t gusRecordDma(uint32_t dma1, uint32_t dma2);
    +uint8_t  gusSampleRate(uint32_t want, uint32_t *rate);
    +uint16_t gusVolume(uint32_t linear);
    +
    +
    +uint32_t gusAddr16(uint32_t addr) {
    +    return ((addr >> 1) & ADDR16_WORD_MASK) | (addr & ADDR16_BANK_MASK);
    +}
    +
    +
    +uint16_t gusAddrHigh(uint32_t addr) {
    +    return (uint16_t)((addr >> ADDR_HIGH_SHIFT) & ADDR_HIGH_MASK);
    +}
    +
    +
    +uint16_t gusAddrLow(uint32_t addr) {
    +    return (uint16_t)((addr & ADDR_LOW_MASK) << ADDR_LOW_SHIFT);
    +}
    +
    +
    +uint16_t gusAttenVolume(int32_t centibels) {
    +    int32_t v = GUS_VOLUME_MAX - (centibels > 0 ? centibels : 0) * ATTEN_STEP_NUM / ATTEN_STEP_DEN;
    +
    +    return (uint16_t)(v > 0 ? v : 0);
    +}
    +
    +
    +uint8_t gusDmaCode(uint32_t dma) {
    +    for (uint32_t i = 0; i < sizeof(sDmas); i++) {
    +        if (sDmas[i] == dma) {
    +            return (uint8_t)(i + 1);
    +        }
    +    }
    +
    +    return 0;
    +}
    +
    +
    +uint8_t gusDmaLatch(uint32_t dma1, uint32_t dma2) {
    +    uint8_t code1 = gusDmaCode(dma1);
    +    uint8_t code2 = gusDmaCode(dma2);
    +
    +    if (code2 == 0 || dma2 == dma1) {
    +        return (uint8_t)(code1 | GUS_DMA_LATCH_COMBINE);
    +    }
    +
    +    return (uint8_t)(code1 | code2 << DMA_LATCH2_SHIFT);
    +}
    +
    +
    +// SDK VOCFREQ.C: the 9-bit fraction rounded, then shifted into place.
    +uint16_t gusFc(uint32_t hz, uint32_t outRate) {
    +    return (uint16_t)((((hz << FC_FRACTION_BITS) + (outRate >> 1)) / outRate) << 1);
    +}
    +
    +
    +// 1.0 at unityRate on this output rate, 16.16 (program time: a 64-bit
    +// division).
    +uint32_t gusFcBase(uint32_t unityRate, uint32_t outRate) {
    +    return (uint32_t)(((uint64_t)unityRate << FC_UNITY_SHIFT) / outRate);
    +}
    +
    +
    +uint16_t gusFcCents(int32_t cents, uint32_t fcBase) {
    +    uint64_t fc = ((uint64_t)fcBase * drvPow2(cents)) >> FC_RESULT_SHIFT;
    +
    +    return (uint16_t)((fc > FC_MAX ? FC_MAX : fc) & FC_BIT0);
    +}
    +
    +
    +uint8_t gusIrqCode(uint32_t irq) {
    +    uint32_t line = irq == IRQ_AT_CASCADE ? IRQ_CASCADE : irq;
    +
    +    for (uint32_t i = 0; i < sizeof(sIrqs); i++) {
    +        if (sIrqs[i] == line) {
    +            return (uint8_t)(i + 1);
    +        }
    +    }
    +
    +    return 0;
    +}
    +
    +
    +bool gusIsMax(uint8_t boardVersion) {
    +    return boardVersion >= MAX_VERSION_FIRST && boardVersion <= MAX_VERSION_LAST;
    +}
    +
    +
    +uint8_t gusMaxControl(uint32_t base, uint32_t dma1, uint32_t dma2) {
    +    uint8_t value = (uint8_t)(((base >> MAX_PORT_SHIFT) & MAX_PORT_MASK) | MAX_CODEC_ON);
    +
    +    if (dma1 != GUS_NONE && dma1 >= DMA_FIRST_16) {
    +        value |= MAX_DMA1_WIDE;
    +    }
    +
    +    if (dma2 != GUS_NONE && dma2 >= DMA_FIRST_16) {
    +        value |= MAX_DMA2_WIDE;
    +    }
    +
    +    return value;
    +}
    +
    +
    +uint32_t gusOutputRate(uint32_t voices) {
    +    uint32_t v = voices < GUS_VOICES_MIN ? GUS_VOICES_MIN : (voices > GUS_VOICES_MAX ? GUS_VOICES_MAX : voices);
    +
    +    return GUS_RATE_BASE / v;
    +}
    +
    +
    +uint8_t gusPan(int32_t pan) {
    +    int32_t p = pan < -PAN_HALF ? -PAN_HALF : (pan > PAN_HALF ? PAN_HALF : pan);
    +
    +    return (uint8_t)(((p + PAN_HALF) * PAN_STEPS + PAN_HALF) / PAN_SPAN);
    +}
    +
    +
    +// "220,1,1,11,5": the port in hex, the rest decimal; spaces after the
    +// commas allowed.
    +bool gusParseEnv(const char *text, GusEnvT *out) {
    +    uint32_t   *fields[ENV_FIELDS] = { &out->base, &out->dma1, &out->dma2, &out->irq1, &out->irq2 };
    +    const char *p                  = text;
    +
    +    for (int32_t i = 0; i < ENV_FIELDS; i++) {
    +        *fields[i] = GUS_NONE;
    +    }
    +
    +    for (int32_t i = 0; p && *p && i < ENV_FIELDS; i++) {
    +        char *end;
    +
    +        while (*p == ' ') {
    +            p++;
    +        }
    +
    +        unsigned long v = strtoul(p, &end, i == 0 ? PORT_HEX : DECIMAL);
    +
    +        if (end == p) {
    +            break;
    +        }
    +
    +        *fields[i] = (uint32_t)v;
    +        p          = end;
    +
    +        while (*p == ' ') {
    +            p++;
    +        }
    +
    +        p = *p == ',' ? p + 1 : NULL;
    +    }
    +
    +    return out->base != GUS_NONE;
    +}
    +
    +
    +// The exponent is the top set bit's place, the mantissa the eight bits
    +// below it (SDK 4.4).
    +uint32_t gusRecordDma(uint32_t dma1, uint32_t dma2) {
    +    return gusDmaCode(dma2) ? dma2 : dma1;
    +}
    +
    +
    +// The nearest divisor of the clamped rate; the rate it gives, rounded.
    +uint8_t gusSampleRate(uint32_t want, uint32_t *rate) {
    +    uint32_t r       = want < GUS_SAMPLE_RATE_MIN ? GUS_SAMPLE_RATE_MIN : (want > GUS_SAMPLE_RATE_MAX ? GUS_SAMPLE_RATE_MAX : want);
    +    uint32_t divisor = (GUS_RATE_BASE + r / 2) / r;
    +
    +    *rate = (GUS_RATE_BASE + divisor / 2) / divisor;
    +    return (uint8_t)(divisor - SAMPLE_DIVISOR_BIAS);
    +}
    +
    +
    +uint16_t gusVolume(uint32_t linear) {
    +    if (linear == 0) {
    +        return 0;
    +    }
    +
    +    uint32_t v = linear > UINT16_MAX ? UINT16_MAX : linear;
    +    uint32_t e = LINEAR_TOP_BIT;
    +
    +    while (!(v & (1u << e))) {
    +        e--;
    +    }
    +
    +    uint32_t m = e >= VOL_MANT_BITS ? v >> (e - VOL_MANT_BITS) : v << (VOL_MANT_BITS - e);
    +
    +    return (uint16_t)(e << VOL_MANT_BITS | (m & VOL_MANT_MASK));
    +}
    diff --git a/src/libs/kpunch/audio/gus/gusCalc.h b/src/libs/kpunch/audio/gus/gusCalc.h
    new file mode 100644
    index 00000000..25c82956
    --- /dev/null
    +++ b/src/libs/kpunch/audio/gus/gusCalc.h
    @@ -0,0 +1,129 @@
    +// 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.
    +
    +// gusCalc.h  -- Gravis UltraSound arithmetic shared by its drivers (no
    +// hardware access)
    +//
    +// The parts of gus.drv and guswt.drv that decide rather than touch
    +// hardware, kept apart so the host tests run them: the ULTRASND variable,
    +// the IRQ and DMA latch codes, the GF1's output rate for a voice count,
    +// frequency control values, address register halves, the 16-bit address
    +// translation, the GF1's logarithmic volume, its sampling rate, and the
    +// UltraSound MAX's board version and control register.  Facts from the
    +// UltraSound SDK 2.22 (sections given) and Linux's GUS drivers
    +// (gus_pcm.c, gusmax.c).
    +#ifndef GUS_CALC_H
    +#define GUS_CALC_H
    +
    +#include 
    +#include 
    +
    +#define GUS_VOICES_MIN      14      // the GF1 plays 14..32 voices (SDK 2.6.2.15)
    +#define GUS_VOICES_MAX      32
    +#define GUS_RATE_BASE       617400  // output rate x active voices (1 / 1.619695497 us, SDK 1.4)
    +#define GUS_BANK_BYTES      0x40000 // a 16-bit sample stays inside one 256 KB bank
    +#define GUS_VOLUME_MAX      0x0FFF  // the 12-bit logarithmic volume at full scale
    +#define GUS_NONE            0xFFFFFFFFU
    +
    +// SET ULTRASND=port,dma1,dma2,irq1,irq2 (SDK UTILITY/CONFIG.C)
    +typedef struct {
    +    uint32_t base;          // hex
    +    uint32_t dma1;          // DRAM DMA (playback)
    +    uint32_t dma2;          // record DMA
    +    uint32_t irq1;          // the GF1's
    +    uint32_t irq2;          // the MIDI port's
    +} GusEnvT;
    +
    +// The ULTRASND value (NULL allowed; spaces allowed after the commas);
    +// false when it names no port.  Fields not given are GUS_NONE.
    +bool gusParseEnv(const char *text, GusEnvT *out);
    +
    +// Latch codes (SDK 2.14, 2.15): 0 for a DMA channel or an IRQ the card
    +// cannot use (IRQ 9 is IRQ 2's line).
    +uint8_t gusDmaCode(uint32_t dma);
    +uint8_t gusIrqCode(uint32_t irq);
    +
    +// The DMA latch for ULTRASND's two channels (SDK 2.15): the first's code
    +// in bits 2:0 and the second's in 5:3, or GUS_DMA_LATCH_COMBINE to record
    +// on the first too when the second is the same, not given, or one the
    +// card cannot use.
    +#define GUS_DMA_LATCH_COMBINE   0x40
    +uint8_t gusDmaLatch(uint32_t dma1, uint32_t dma2);
    +
    +// The channel recording uses, whether the GF1 samples or a MAX's codec
    +// captures: the second when the card can use it, else the first.
    +uint32_t gusRecordDma(uint32_t dma1, uint32_t dma2);
    +
    +// The GF1's sampling (8-bit, mono or stereo): the rate register (48h) for
    +// want, clamped to GUS_SAMPLE_RATE_MIN..GUS_SAMPLE_RATE_MAX, which samples
    +// at GUS_RATE_BASE / (value + 2) Hz (the GF1's 9,878,400 Hz clock / 16,
    +// the clock its voices run on); *rate is that rate.
    +#define GUS_SAMPLE_RATE_MIN     5510
    +#define GUS_SAMPLE_RATE_MAX     44100
    +uint8_t gusSampleRate(uint32_t want, uint32_t *rate);
    +
    +// The UltraSound MAX: its board version (base + 506h) reads 10 or 11
    +// (Linux gus_main.c: 5..9 an ICS mixer board, 30h the ACE, 50h the
    +// Extreme, FFh an older board); and its control register value (written
    +// there) turning its CS4231 codec on at base + 10Ch with ULTRASND's
    +// channels.
    +bool    gusIsMax(uint8_t boardVersion);
    +uint8_t gusMaxControl(uint32_t base, uint32_t dma1, uint32_t dma2);
    +
    +// The output rate with voices active (clamped to 14..32).
    +uint32_t gusOutputRate(uint32_t voices);
    +
    +// Frequency control for a voice playing a sample recorded at hz on a GF1
    +// running at outRate (1.0 = 0400h; SDK VOCFREQ.C rounding).
    +uint16_t gusFc(uint32_t hz, uint32_t outRate);
    +
    +// A DRAM byte address as the address registers take it: the high word
    +// (bits 19-7) and the low word (bits 6-0, fraction 0) (SDK GF1HWARE.H).
    +uint16_t gusAddrHigh(uint32_t addr);
    +uint16_t gusAddrLow(uint32_t addr);
    +
    +// A 16-bit sample's byte address (even) as a 16-bit voice or a 16-bit
    +// DMA channel addresses it: the word offset inside its 256 KB bank (SDK
    +// ULTRADRV.PAS Convert_To_16Bit).
    +uint32_t gusAddr16(uint32_t addr);
    +
    +// The GF1 volume for a linear level (0..65535: 65535 full scale), 12 bits
    +// of exponent and mantissa; 0 for silence (SDK 4.4: out = S x 2^(V/256 -
    +// 16)).
    +uint16_t gusVolume(uint32_t linear);
    +
    +// The GF1 volume for an attenuation in centibels (0 full scale; a step of
    +// the 12-bit volume is 20 log10 2 / 256 dB), 0 at the most.  Interrupt
    +// time.
    +uint16_t gusAttenVolume(int32_t centibels);
    +
    +// A pan of -500 (left) .. 500 (right) as the GF1's 0..15.  Interrupt time.
    +uint8_t  gusPan(int32_t pan);
    +
    +// The frequency control for a pitch in cents (0: unityRate frames a
    +// second) on a GF1 whose frequency control for unityRate is fcBase
    +// (16.16, from gusFcBase at the output rate); interrupt time (drvPow2's
    +// 2^x).
    +uint32_t gusFcBase(uint32_t unityRate, uint32_t outRate);
    +uint16_t gusFcCents(int32_t cents, uint32_t fcBase);
    +
    +#endif // GUS_CALC_H
    diff --git a/src/libs/kpunch/audio/gus/gusHw.c b/src/libs/kpunch/audio/gus/gusHw.c
    new file mode 100644
    index 00000000..057c0fd1
    --- /dev/null
    +++ b/src/libs/kpunch/audio/gus/gusHw.c
    @@ -0,0 +1,558 @@
    +// 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.
    +
    +// gusHw.c  -- the Gravis UltraSound's GF1 chip (see gusHw.h)
    +
    +#include "gusHw.h"
    +#include "../audioDrv.h"
    +#include "../math/drvMath.h"
    +
    +#include 
    +
    +#define DELAY_READS         7       // DRAM port reads: three voice services
    +#define RESET_DELAYS        10
    +#define PROBE_DELAYS        2
    +#define RESET_HOLD          0x00    // 4Ch: the GF1 held in reset
    +#define RESET_RUN           0x01
    +#define RESET_RUNNING       0x07    // running, DAC on, interrupts on
    +#define MIDI_RESET          0x03
    +#define VOICES_FIXED        0xC0    // 0Eh: bits 7-6 set
    +#define VOICE_FC_ONE        0x0400
    +#define VOICE_RAMP_RATE     0x01
    +#define VOICE_RAMP_START    0x10
    +#define VOICE_RAMP_END      0xE0
    +#define VOICE_STOPPED       (GUS_VC_STOPPED | GUS_VC_STOP)
    +#define MIX_IMAGE           0x0B    // line in and out off, latches powered
    +#define MIX_LINE_IN_OFF     0x01
    +#define MIX_LINE_OUT_OFF    0x02
    +#define MIX_MIC_ON          0x04
    +#define MIX_LATCHES_ON      0x09    // latches powered, line in off
    +#define MIX_IRQ_LATCH       0x40    // the next 2XB write: the IRQ latch
    +#define TIMER_COUNT_FASTEST 0xFF    // 46h / 47h: one step to FFh
    +#define LATCH_DMA_FIRST     0x80    // the DMA latch's first write
    +#define LATCH_COMBINE       0x40    // the MIDI interrupts on the GF1's line
    +#define REG_CONTROL_CLEAR   0x05    // 2XF: 2XB's power-up IRQ clear register
    +#define DRAM_HIGH_MASK      0x0F
    +#define DRAM_HIGH_SHIFT     16
    +#define WORD_MASK           0xFFFF
    +#define PAGE_BYTES          0x10000
    +#define POKE_RUN            256     // bytes poked with interrupts off at a time
    +#define POS_HIGH_SHIFT      7
    +#define POS_LOW_SHIFT       9
    +#define POS_MASK            0xFFFFF
    +#define TEST_A              0xAA
    +#define TEST_B              0x55
    +#define BANKS_MAX           4
    +#define BYTE_BITS           8
    +#define BYTE_MASK           0xFF
    +#define FLAGS_IF            0x200
    +
    +GusHwT gusHw;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint8_t  gusBoardVersion(void);
    +uint32_t gusCli(void);
    +void     gusDelay(void);
    +void     gusInputs(bool line, bool mic);
    +void     gusLatches(uint8_t irqCode, uint8_t dmaLatch);
    +bool     gusLock(bool lock);
    +void     gusMaxControlSet(uint8_t value);
    +void     gusOutput(bool on);
    +uint8_t  gusPeek(uint32_t addr);
    +void     gusPoke(uint32_t addr, uint8_t value);
    +void     gusPoke16(uint32_t addr, int16_t value);
    +void     gusPokeBlock(uint32_t addr, const uint8_t *src, uint32_t count);
    +bool     gusProbe(uint16_t base);
    +uint8_t  gusRead8(uint8_t reg);
    +void     gusRelease(void);
    +void     gusReset(uint32_t voices);
    +void     gusRestore(uint32_t flags);
    +uint32_t gusSizeDram(void);
    +void     gusVoiceAddr(uint32_t voice, uint8_t regHigh, uint32_t addr, bool lowFirst);
    +void     gusVoiceControl(uint32_t voice, uint8_t reg, uint8_t value);
    +uint32_t gusVoicePos(uint32_t voice);
    +uint16_t gusVoiceRead16(uint32_t voice, uint8_t reg);
    +uint8_t  gusVoiceRead8(uint32_t voice, uint8_t reg);
    +void     gusVoiceStop(uint32_t voice);
    +void     gusVoiceWrite16(uint32_t voice, uint8_t reg, uint16_t value);
    +void     gusVoiceWrite8(uint32_t voice, uint8_t reg, uint8_t value);
    +void     gusWrite16(uint8_t reg, uint16_t value);
    +void     gusWrite8(uint8_t reg, uint8_t value);
    +
    +
    +uint8_t gusBoardVersion(void) {
    +    return inportb(gusHw.base + GUS_BOARD_VERSION);
    +}
    +
    +
    +uint32_t gusCli(void) {
    +    uint32_t flags;
    +
    +    __asm__ __volatile__ ("pushfl\n\tpopl %0\n\tcli" : "=r"(flags) : : "memory");
    +    return flags;
    +}
    +
    +
    +void gusDelay(void) {
    +    for (int32_t i = 0; i < DELAY_READS; i++) {
    +        inportb(gusHw.base + GUS_DRAM);
    +    }
    +}
    +
    +
    +// SDK INIT.C UltraSetInterface: 2XB takes the latch the mix control write
    +// just before it selected, and only as the very next write.
    +void gusInputs(bool line, bool mic) {
    +    uint32_t flags = gusCli();
    +
    +    gusHw.mix = (uint8_t)((gusHw.mix & ~(MIX_LINE_IN_OFF | MIX_MIC_ON)) | (line ? 0 : MIX_LINE_IN_OFF) | (mic ? MIX_MIC_ON : 0));
    +    outportb(gusHw.base + GUS_MIX, gusHw.mix);
    +    gusRestore(flags);
    +}
    +
    +
    +void gusLatches(uint8_t irqCode, uint8_t dmaLatch) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +    uint8_t  irq   = (uint8_t)(irqCode | LATCH_COMBINE);
    +
    +    gusHw.mix = MIX_IMAGE;
    +    outportb(base + GUS_REG_CONTROL, REG_CONTROL_CLEAR);
    +    outportb(base + GUS_MIX, gusHw.mix);
    +    outportb(base + GUS_LATCH, 0);
    +    outportb(base + GUS_REG_CONTROL, 0);
    +    outportb(base + GUS_MIX, gusHw.mix);
    +    outportb(base + GUS_LATCH, (uint8_t)(dmaLatch | LATCH_DMA_FIRST));
    +    outportb(base + GUS_MIX, (uint8_t)(gusHw.mix | MIX_IRQ_LATCH));
    +    outportb(base + GUS_LATCH, irq);
    +    outportb(base + GUS_MIX, gusHw.mix);
    +    outportb(base + GUS_LATCH, dmaLatch);
    +    outportb(base + GUS_MIX, (uint8_t)(gusHw.mix | MIX_IRQ_LATCH));
    +    outportb(base + GUS_LATCH, irq);
    +    outportb(base + GUS_VOICE, 0);
    +    gusHw.mix |= MIX_LATCHES_ON;
    +    outportb(base + GUS_MIX, gusHw.mix);
    +    outportb(base + GUS_VOICE, 0);
    +    gusRestore(flags);
    +}
    +
    +
    +// This file's code and gusHw, the gusCalc.c code interrupt time uses, and
    +// drvMath's (a span each: the files need not be next to each other).
    +bool gusLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)gusCli,          0 },
    +        { (const void *)gusDelay,        0 },
    +        { (const void *)gusPoke,         0 },
    +        { (const void *)gusPoke16,       0 },
    +        { (const void *)gusRead8,        0 },
    +        { (const void *)gusRestore,      0 },
    +        { (const void *)gusVoiceAddr,    0 },
    +        { (const void *)gusVoiceControl, 0 },
    +        { (const void *)gusVoicePos,     0 },
    +        { (const void *)gusVoiceRead16,  0 },
    +        { (const void *)gusVoiceRead8,   0 },
    +        { (const void *)gusVoiceStop,    0 },
    +        { (const void *)gusVoiceWrite16, 0 },
    +        { (const void *)gusVoiceWrite8,  0 },
    +        { (const void *)gusWrite16,      0 },
    +        { (const void *)gusWrite8,       0 },
    +        { &gusHw,                        sizeof(gusHw) }
    +    };
    +    const AudioLockItemT calc[] = {
    +        { (const void *)gusAddr16,      0 },
    +        { (const void *)gusAddrHigh,    0 },
    +        { (const void *)gusAddrLow,     0 },
    +        { (const void *)gusAttenVolume, 0 },
    +        { (const void *)gusFcCents,     0 },
    +        { (const void *)gusPan,         0 }
    +    };
    +
    +    bool ok = audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +
    +    ok = audioLockSpan(calc, (int32_t)(sizeof(calc) / sizeof(calc[0])), lock) && ok;
    +    return drvMathLock(lock) && ok;
    +}
    +
    +
    +void gusMaxControlSet(uint8_t value) {
    +    outportb(gusHw.base + GUS_BOARD_VERSION, value);
    +}
    +
    +
    +void gusOutput(bool on) {
    +    uint32_t flags = gusCli();
    +
    +    gusHw.mix = (uint8_t)(on ? gusHw.mix & ~MIX_LINE_OUT_OFF : gusHw.mix | MIX_LINE_OUT_OFF);
    +    outportb(gusHw.base + GUS_MIX, gusHw.mix);
    +    gusRestore(flags);
    +}
    +
    +
    +uint8_t gusPeek(uint32_t addr) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +
    +    outportb(base + GUS_SELECT, GUS_G_DRAM_LOW);
    +    outportw(base + GUS_DATA_LOW, (uint16_t)(addr & WORD_MASK));
    +    outportb(base + GUS_SELECT, GUS_G_DRAM_HIGH);
    +    outportb(base + GUS_DATA_HIGH, (uint8_t)((addr >> DRAM_HIGH_SHIFT) & DRAM_HIGH_MASK));
    +
    +    uint8_t value = inportb(base + GUS_DRAM);
    +
    +    gusRestore(flags);
    +    return value;
    +}
    +
    +
    +void gusPoke(uint32_t addr, uint8_t value) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +
    +    outportb(base + GUS_SELECT, GUS_G_DRAM_LOW);
    +    outportw(base + GUS_DATA_LOW, (uint16_t)(addr & WORD_MASK));
    +    outportb(base + GUS_SELECT, GUS_G_DRAM_HIGH);
    +    outportb(base + GUS_DATA_HIGH, (uint8_t)((addr >> DRAM_HIGH_SHIFT) & DRAM_HIGH_MASK));
    +    outportb(base + GUS_DRAM, value);
    +    gusRestore(flags);
    +}
    +
    +
    +void gusPoke16(uint32_t addr, int16_t value) {
    +    gusPoke(addr, (uint8_t)((uint16_t)value & BYTE_MASK));
    +    gusPoke(addr + 1, (uint8_t)((uint16_t)value >> BYTE_BITS));
    +}
    +
    +
    +// Each run inside one 64 KB page and at most POKE_RUN bytes: the high
    +// bits once, then 43h left selected and only its word and the byte
    +// written per byte.
    +void gusPokeBlock(uint32_t addr, const uint8_t *src, uint32_t count) {
    +    uint16_t base = gusHw.base;
    +
    +    while (count > 0) {
    +        uint32_t room  = PAGE_BYTES - (addr & WORD_MASK);
    +        uint32_t run   = count < POKE_RUN ? count : POKE_RUN;
    +        uint32_t flags;
    +
    +        run   = run < room ? run : room;
    +        flags = gusCli();
    +        outportb(base + GUS_SELECT, GUS_G_DRAM_HIGH);
    +        outportb(base + GUS_DATA_HIGH, (uint8_t)((addr >> DRAM_HIGH_SHIFT) & DRAM_HIGH_MASK));
    +        outportb(base + GUS_SELECT, GUS_G_DRAM_LOW);
    +
    +        for (uint32_t i = 0; i < run; i++) {
    +            outportw(base + GUS_DATA_LOW, (uint16_t)((addr + i) & WORD_MASK));
    +            outportb(base + GUS_DRAM, src[i]);
    +        }
    +
    +        gusRestore(flags);
    +        addr  += run;
    +        src   += run;
    +        count -= run;
    +    }
    +}
    +
    +
    +// SDK INIT.C UltraProbe: out of reset, then a ping of DRAM 0 and 1 (put
    +// back as they were).
    +bool gusProbe(uint16_t base) {
    +    gusHw.base = base;
    +    gusWrite8(GUS_G_RESET, RESET_HOLD);
    +
    +    for (int32_t i = 0; i < PROBE_DELAYS; i++) {
    +        gusDelay();
    +    }
    +
    +    gusWrite8(GUS_G_RESET, RESET_RUN);
    +
    +    for (int32_t i = 0; i < PROBE_DELAYS; i++) {
    +        gusDelay();
    +    }
    +
    +    uint8_t a = gusPeek(0);
    +    uint8_t b = gusPeek(1);
    +
    +    gusPoke(0, TEST_A);
    +    gusPoke(1, TEST_B);
    +
    +    bool found = gusPeek(0) == TEST_A && gusPeek(1) == TEST_B;
    +
    +    gusPoke(0, a);
    +    gusPoke(1, b);
    +    return found;
    +}
    +
    +
    +uint8_t gusRead8(uint8_t reg) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_SELECT, reg);
    +
    +    uint8_t value = inportb(gusHw.base + GUS_DATA_HIGH);
    +
    +    gusRestore(flags);
    +    return value;
    +}
    +
    +
    +void gusRelease(void) {
    +    if (gusHw.pcmOpen || gusHw.synthOpen) {
    +        return;
    +    }
    +
    +    gusOutput(false);
    +    gusHw.running = false;
    +}
    +
    +
    +// SDK RESET.C UltraReset.
    +void gusReset(uint32_t voices) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +
    +    gusWrite8(GUS_G_RESET, RESET_HOLD);
    +
    +    for (int32_t i = 0; i < RESET_DELAYS; i++) {
    +        gusDelay();
    +    }
    +
    +    gusWrite8(GUS_G_RESET, RESET_RUN);
    +
    +    for (int32_t i = 0; i < RESET_DELAYS; i++) {
    +        gusDelay();
    +    }
    +
    +    outportb(base + GUS_MIDI_CONTROL, MIDI_RESET);
    +
    +    for (int32_t i = 0; i < RESET_DELAYS; i++) {
    +        gusDelay();
    +    }
    +
    +    outportb(base + GUS_MIDI_CONTROL, 0);
    +    gusWrite8(GUS_G_DMA_CONTROL, 0);
    +    gusWrite8(GUS_G_TIMER_CONTROL, 0);
    +
    +    // The timers count from here to FFh, and they answer an AdLib's
    +    // timer commands at 388h-389h, which the card always decodes.  At
    +    // their fastest whatever count an AdLib program writes, they never
    +    // pass for an OPL's: opl.drv wants timer 1 still running at its
    +    // slowest count.
    +    gusWrite8(GUS_G_TIMER1_COUNT, TIMER_COUNT_FASTEST);
    +    gusWrite8(GUS_G_TIMER2_COUNT, TIMER_COUNT_FASTEST);
    +    gusWrite8(GUS_G_SAMPLE_CTRL, 0);
    +    gusHw.voices  = voices < GUS_VOICES_MIN ? GUS_VOICES_MIN : (voices > GUS_VOICES_MAX ? GUS_VOICES_MAX : voices);
    +    gusHw.outRate = gusOutputRate(gusHw.voices);
    +    gusWrite8(GUS_G_VOICES, (uint8_t)((gusHw.voices - 1) | VOICES_FIXED));
    +
    +    // Stale interrupts cleared, before the voices and after
    +    inportb(base + GUS_IRQ_STATUS);
    +    gusRead8(GUS_G_DMA_CONTROL);
    +    gusRead8(GUS_G_SAMPLE_CTRL);
    +    gusRead8(GUS_G_IRQ_SOURCE);
    +
    +    for (uint32_t v = 0; v < GUS_VOICES_MAX; v++) {
    +        gusVoiceStop(v);
    +        gusVoiceWrite16(v, GUS_V_FREQ, VOICE_FC_ONE);
    +        gusVoiceWrite8(v, GUS_V_RAMP_RATE, VOICE_RAMP_RATE);
    +        gusVoiceWrite8(v, GUS_V_RAMP_START, VOICE_RAMP_START);
    +        gusVoiceWrite8(v, GUS_V_RAMP_END, VOICE_RAMP_END);
    +        gusVoiceWrite8(v, GUS_V_PAN, GUS_PAN_CENTRE);
    +    }
    +
    +    inportb(base + GUS_IRQ_STATUS);
    +    gusRead8(GUS_G_DMA_CONTROL);
    +    gusRead8(GUS_G_SAMPLE_CTRL);
    +    gusRead8(GUS_G_IRQ_SOURCE);
    +    gusWrite8(GUS_G_RESET, RESET_RUNNING);
    +    gusRestore(flags);
    +}
    +
    +
    +void gusRestore(uint32_t flags) {
    +    if (flags & FLAGS_IF) {
    +        __asm__ __volatile__ ("sti" : : : "memory");
    +    }
    +}
    +
    +
    +// ALSA's way: each further 256 KB bank holds two bytes without them
    +// showing at 0 (a missing bank wraps there); every byte touched put back.
    +uint32_t gusSizeDram(void) {
    +    uint8_t  keep0 = gusPeek(0);
    +    uint8_t  keep1 = gusPeek(1);
    +    uint32_t banks = 1;
    +    bool     more  = true;
    +
    +    gusPoke(0, TEST_A);
    +    gusPoke(1, TEST_B);
    +
    +    while (more && banks < BANKS_MAX) {
    +        uint32_t at = banks * GUS_BANK_BYTES;
    +        uint8_t  s0 = gusPeek(at);
    +        uint8_t  s1 = gusPeek(at + 1);
    +
    +        gusPoke(at, TEST_B);
    +        gusPoke(at + 1, TEST_A);
    +        more = gusPeek(at) == TEST_B && gusPeek(at + 1) == TEST_A && gusPeek(0) == TEST_A;
    +        gusPoke(at, s0);
    +        gusPoke(at + 1, s1);
    +        banks += more ? 1 : 0;
    +    }
    +
    +    gusPoke(0, keep0);
    +    gusPoke(1, keep1);
    +    return banks * GUS_BANK_BYTES;
    +}
    +
    +
    +void gusVoiceAddr(uint32_t voice, uint8_t regHigh, uint32_t addr, bool lowFirst) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +
    +    outportb(base + GUS_VOICE, (uint8_t)voice);
    +
    +    if (lowFirst) {
    +        outportb(base + GUS_SELECT, (uint8_t)(regHigh + 1));
    +        outportw(base + GUS_DATA_LOW, gusAddrLow(addr));
    +    }
    +
    +    outportb(base + GUS_SELECT, regHigh);
    +    outportw(base + GUS_DATA_LOW, gusAddrHigh(addr));
    +
    +    if (!lowFirst) {
    +        outportb(base + GUS_SELECT, (uint8_t)(regHigh + 1));
    +        outportw(base + GUS_DATA_LOW, gusAddrLow(addr));
    +    }
    +
    +    gusRestore(flags);
    +}
    +
    +
    +void gusVoiceControl(uint32_t voice, uint8_t reg, uint8_t value) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +
    +    outportb(base + GUS_VOICE, (uint8_t)voice);
    +    outportb(base + GUS_SELECT, reg);
    +    outportb(base + GUS_DATA_HIGH, value);
    +    gusDelay();
    +    outportb(base + GUS_DATA_HIGH, value);
    +    gusRestore(flags);
    +}
    +
    +
    +uint32_t gusVoicePos(uint32_t voice) {
    +    uint16_t base  = gusHw.base;
    +    uint32_t flags = gusCli();
    +
    +    outportb(base + GUS_VOICE, (uint8_t)voice);
    +    outportb(base + GUS_SELECT, GUS_V_POS_HIGH | GUS_READ);
    +
    +    uint32_t high = inportw(base + GUS_DATA_LOW);
    +
    +    outportb(base + GUS_SELECT, (GUS_V_POS_HIGH + 1) | GUS_READ);
    +
    +    uint32_t low = inportw(base + GUS_DATA_LOW);
    +
    +    gusRestore(flags);
    +    return ((high << POS_HIGH_SHIFT) | (low >> POS_LOW_SHIFT)) & POS_MASK;
    +}
    +
    +
    +uint16_t gusVoiceRead16(uint32_t voice, uint8_t reg) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_VOICE, (uint8_t)voice);
    +    outportb(gusHw.base + GUS_SELECT, (uint8_t)(reg | GUS_READ));
    +
    +    uint16_t value = inportw(gusHw.base + GUS_DATA_LOW);
    +
    +    gusRestore(flags);
    +    return value;
    +}
    +
    +
    +uint8_t gusVoiceRead8(uint32_t voice, uint8_t reg) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_VOICE, (uint8_t)voice);
    +    outportb(gusHw.base + GUS_SELECT, (uint8_t)(reg | GUS_READ));
    +
    +    uint8_t value = inportb(gusHw.base + GUS_DATA_HIGH);
    +
    +    gusRestore(flags);
    +    return value;
    +}
    +
    +
    +// Stopped (both self-modifying registers written twice), silent, and
    +// every address at the silent park block.
    +void gusVoiceStop(uint32_t voice) {
    +    gusVoiceControl(voice, GUS_V_CONTROL, VOICE_STOPPED);
    +    gusVoiceControl(voice, GUS_V_VOL_CONTROL, GUS_RAMP_STOPPED);
    +    gusVoiceWrite16(voice, GUS_V_VOLUME, 0);
    +    gusVoiceAddr(voice, GUS_V_START_HIGH, gusHw.park, false);
    +    gusVoiceAddr(voice, GUS_V_END_HIGH, gusHw.park, false);
    +    gusVoiceAddr(voice, GUS_V_POS_HIGH, gusHw.park, false);
    +}
    +
    +
    +void gusVoiceWrite16(uint32_t voice, uint8_t reg, uint16_t value) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_VOICE, (uint8_t)voice);
    +    outportb(gusHw.base + GUS_SELECT, reg);
    +    outportw(gusHw.base + GUS_DATA_LOW, value);
    +    gusRestore(flags);
    +}
    +
    +
    +void gusVoiceWrite8(uint32_t voice, uint8_t reg, uint8_t value) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_VOICE, (uint8_t)voice);
    +    outportb(gusHw.base + GUS_SELECT, reg);
    +    outportb(gusHw.base + GUS_DATA_HIGH, value);
    +    gusRestore(flags);
    +}
    +
    +
    +void gusWrite16(uint8_t reg, uint16_t value) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_SELECT, reg);
    +    outportw(gusHw.base + GUS_DATA_LOW, value);
    +    gusRestore(flags);
    +}
    +
    +
    +void gusWrite8(uint8_t reg, uint8_t value) {
    +    uint32_t flags = gusCli();
    +
    +    outportb(gusHw.base + GUS_SELECT, reg);
    +    outportb(gusHw.base + GUS_DATA_HIGH, value);
    +    gusRestore(flags);
    +}
    diff --git a/src/libs/kpunch/audio/gus/gusHw.h b/src/libs/kpunch/audio/gus/gusHw.h
    new file mode 100644
    index 00000000..b1c4723a
    --- /dev/null
    +++ b/src/libs/kpunch/audio/gus/gusHw.h
    @@ -0,0 +1,215 @@
    +// 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.
    +
    +// gusHw.h  -- the Gravis UltraSound's GF1 chip, shared by its drivers
    +//
    +// gus.drv (digital sound on two voices, and the wavetable synthesizer on
    +// the rest) and guswt.drv (the synthesizer alone, beside another sound
    +// device) each link their own copy.  Every sequence of port accesses (a
    +// voice or register select, then its data) runs with interrupts off, so
    +// the sound interrupt and the music timer can use the chip as well as the
    +// program: no sequence is ever cut in two.  Facts from the UltraSound SDK
    +// 2.22 (sections given) and the reference summary.
    +#ifndef GUS_HW_H
    +#define GUS_HW_H
    +
    +#include "gusCalc.h"
    +
    +#include 
    +#include 
    +
    +// Ports, from the base (2X0)
    +#define GUS_MIX             0x000   // mix control (write only)
    +#define GUS_IRQ_STATUS      0x006
    +#define GUS_LATCH           0x00B   // IRQ or DMA latch (after a mix control write)
    +#define GUS_REG_CONTROL     0x00F   // 2XB register select (rev 3.4+)
    +#define GUS_MIDI_CONTROL    0x100
    +#define GUS_VOICE           0x102   // voice select
    +#define GUS_SELECT          0x103   // register select
    +#define GUS_DATA_LOW        0x104   // 16-bit registers (a word access)
    +#define GUS_DATA_HIGH       0x105   // 8-bit registers
    +#define GUS_DRAM            0x107   // DRAM at the I/O address
    +#define GUS_MAX_CODEC       0x10C   // a MAX's CS4231 (once its control register turns it on)
    +#define GUS_BOARD_VERSION   0x506   // board version (read); a MAX's control register (write)
    +
    +// 2X6 IRQ status bits
    +#define GUS_IRQ_WAVE        0x20
    +#define GUS_IRQ_VOLUME      0x40
    +#define GUS_IRQ_DMA         0x80
    +
    +// Global registers
    +#define GUS_G_DMA_CONTROL   0x41
    +#define GUS_G_DMA_ADDR      0x42
    +#define GUS_G_DRAM_LOW      0x43
    +#define GUS_G_DRAM_HIGH     0x44
    +#define GUS_G_TIMER_CONTROL 0x45
    +#define GUS_G_TIMER1_COUNT  0x46
    +#define GUS_G_TIMER2_COUNT  0x47
    +#define GUS_G_SAMPLE_RATE   0x48
    +#define GUS_G_SAMPLE_CTRL   0x49
    +#define GUS_G_RESET         0x4C
    +#define GUS_G_VOICES        0x0E
    +#define GUS_G_IRQ_SOURCE    0x8F
    +
    +// Voice registers (reads: + GUS_READ)
    +#define GUS_V_CONTROL       0x00
    +#define GUS_V_FREQ          0x01
    +#define GUS_V_START_HIGH    0x02    // loop start; low is + 1
    +#define GUS_V_END_HIGH      0x04    // end; low is + 1
    +#define GUS_V_RAMP_RATE     0x06
    +#define GUS_V_RAMP_START    0x07
    +#define GUS_V_RAMP_END      0x08
    +#define GUS_V_VOLUME        0x09
    +#define GUS_V_POS_HIGH      0x0A    // current address; low is + 1
    +#define GUS_V_PAN           0x0C
    +#define GUS_V_VOL_CONTROL   0x0D
    +#define GUS_READ            0x80
    +
    +// Voice control (00h) and volume control (0Dh) bits
    +#define GUS_VC_STOPPED      0x01
    +#define GUS_VC_STOP         0x02
    +#define GUS_VC_16BIT        0x04
    +#define GUS_VC_LOOP         0x08
    +#define GUS_VC_IRQ          0x20
    +#define GUS_RAMP_STOPPED    0x03    // volume control: the ramp stopped (never left running)
    +#define GUS_VOL_ROLLOVER    0x04
    +
    +// DRAM DMA control (41h)
    +#define GUS_DMA_ENABLE      0x01
    +#define GUS_DMA_WIDE_CHAN   0x04    // a 16-bit PC channel
    +#define GUS_DMA_TC_IRQ      0x20
    +#define GUS_DMA_DATA16      0x40
    +#define GUS_DMA_INVERT      0x80    // flip each sample's top bit (unsigned data)
    +#define GUS_DMA_ADDR_SHIFT  4
    +
    +// Sampling control (49h; Linux gus_pcm.c): no auto-init, so each block is
    +// started again from its terminal count
    +#define GUS_SC_START        0x01
    +#define GUS_SC_STEREO       0x02
    +#define GUS_SC_WIDE_CHAN    0x04    // a 16-bit PC channel
    +#define GUS_SC_IRQ          0x20    // an interrupt at the terminal count
    +#define GUS_SC_IRQ_PENDING  0x40    // (read) that interrupt, cleared by the read
    +#define GUS_SC_INVERT       0x80    // flip each sample's top bit (unsigned data)
    +
    +#define GUS_VOL_SHIFT       4       // 09h holds the 12-bit volume in bits 15-4
    +#define GUS_PAN_LEFT        0
    +#define GUS_PAN_CENTRE      7
    +#define GUS_PAN_RIGHT       15
    +#define GUS_ALIGN           32      // DRAM blocks, DMA start addresses
    +
    +// The card as the module's drivers share it: digital sound on the first
    +// voices and a ring at the top of the memory, the synthesizer on the
    +// voices from synthFirst and the memory below synthTop
    +typedef struct {
    +    uint32_t dramBytes;         // its memory (256 KB steps)
    +    uint32_t voices;            // active voices
    +    uint32_t outRate;           // the output rate they give
    +    uint32_t park;              // a silent DRAM block idle voices point at
    +    uint32_t synthTop;          // the synthesizer's memory: 0 .. synthTop - 1
    +    uint32_t synthFirst;        // the synthesizer's first voice
    +    uint16_t base;
    +    uint8_t  mix;               // the mix control register as last written
    +    bool     running;           // reset and on (by whichever opened first)
    +    bool     pcmOpen;           // digital sound uses it
    +    bool     synthOpen;         // the synthesizer uses it
    +} GusHwT;
    +
    +extern GusHwT gusHw;
    +
    +// Interrupts off (the flags before) and back.
    +uint32_t gusCli(void);
    +void     gusRestore(uint32_t flags);
    +
    +// A GF1 is at base: it holds a byte at DRAM 0 and 1 after a reset.
    +bool     gusProbe(uint16_t base);
    +
    +// The DRAM size in 256 KB steps, looked for without leaving a trace.
    +uint32_t gusSizeDram(void);
    +
    +// The SDK's reset with voices active: every voice stopped, silent and
    +// pointed at gusHw.park, timers and DMA off (the timers' counts at their
    +// fastest: see gusReset), stale interrupts cleared, then the DAC and the
    +// interrupts on.
    +void     gusReset(uint32_t voices);
    +
    +// The IRQ and DMA latches (gusIrqCode, gusDmaLatch) in the SDK's
    +// sequence; the latches powered.
    +void     gusLatches(uint8_t irqCode, uint8_t dmaLatch);
    +
    +// The board version register (a MAX: gusIsMax), and a MAX's control
    +// register (gusMaxControl).
    +uint8_t  gusBoardVersion(void);
    +void     gusMaxControlSet(uint8_t value);
    +
    +// What the GF1 samples: the line input, the microphone, or neither.
    +void     gusInputs(bool line, bool mic);
    +
    +// Line out on or off.
    +void     gusOutput(bool on);
    +
    +// One of the module's users is done with the card (its flag cleared by
    +// the caller): the last turns the output off.
    +void     gusRelease(void);
    +
    +// One DRAM byte at addr, read and written.
    +uint8_t  gusPeek(uint32_t addr);
    +void     gusPoke(uint32_t addr, uint8_t value);
    +
    +// A 16-bit sample poked at addr (low byte first).
    +void     gusPoke16(uint32_t addr, int16_t value);
    +
    +// count bytes poked from addr on, a few hundred at a time with interrupts
    +// off (the address's low word written per byte, its high bits per 64 KB).
    +void     gusPokeBlock(uint32_t addr, const uint8_t *src, uint32_t count);
    +
    +// Global or voice register access (a voice selected first; interrupts off
    +// across the selects and the data).
    +uint8_t  gusRead8(uint8_t reg);
    +uint16_t gusVoiceRead16(uint32_t voice, uint8_t reg);
    +uint8_t  gusVoiceRead8(uint32_t voice, uint8_t reg);
    +void     gusVoiceWrite16(uint32_t voice, uint8_t reg, uint16_t value);
    +void     gusVoiceWrite8(uint32_t voice, uint8_t reg, uint8_t value);
    +void     gusWrite16(uint8_t reg, uint16_t value);
    +void     gusWrite8(uint8_t reg, uint8_t value);
    +
    +// A voice's address pair (start, end or position: the high register) at
    +// a byte address (16-bit voices: translated by the caller).  The high
    +// word first, or the low first when lowFirst (moving the end backwards).
    +void     gusVoiceAddr(uint32_t voice, uint8_t regHigh, uint32_t addr, bool lowFirst);
    +
    +// The voice's current address (byte or 16-bit word address, as written).
    +uint32_t gusVoicePos(uint32_t voice);
    +
    +// A self-modifying register (voice control, volume control) written
    +// twice with the GF1's wait between (SDK 2.6.2).
    +void     gusVoiceControl(uint32_t voice, uint8_t reg, uint8_t value);
    +
    +// The voice stopped and silent.
    +void     gusVoiceStop(uint32_t voice);
    +
    +// The GF1's wait: three voice services (seven DRAM port reads).
    +void     gusDelay(void);
    +
    +// Pin or release this file's code and gusHw (interrupt time uses them).
    +bool     gusLock(bool lock);
    +
    +#endif // GUS_HW_H
    diff --git a/src/libs/kpunch/audio/gus/gusSynth.c b/src/libs/kpunch/audio/gus/gusSynth.c
    new file mode 100644
    index 00000000..2079b3e4
    --- /dev/null
    +++ b/src/libs/kpunch/audio/gus/gusSynth.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.
    +
    +// gusSynth.c  -- the Gravis UltraSound as a wavetable synthesizer (see
    +// gusSynth.h)
    +//
    +// A note: the voice stopped, its frequency control from the pitch, its
    +// loop (start at the loop's start, end at its end: the GF1 jumps back on
    +// reaching the end, so the loop's last frame is the one before) or its
    +// end (the last frame), its position at the sample's start, its pan and
    +// volume, then started.  A sample that loops until the key is up plays
    +// out from there: the loop off and the end moved to the sample's.  A
    +// voice that has played to its end reads stopped.
    +//
    +// Alone (guswt.drv), the card is found here: ULTRASND's port, else the
    +// usual ones probed; reset, its memory sized, a silent block at its top
    +// for idle voices.  No IRQ or DMA is needed: samples go in through the
    +// DRAM port.
    +
    +#include "gusSynth.h"
    +#include "gusHw.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define BITS_16             16
    +#define BYTE_BITS           8
    +#define BYTE_MASK           0xFF
    +#define UPLOAD_CHUNK        512     // bytes made ready at a time
    +#define KB                  1024
    +
    +// Ports probed without ULTRASND
    +static const uint16_t sBases[] = { 0x220, 0x240, 0x260, 0x210, 0x230, 0x250 };
    +
    +// What a voice plays, for its release
    +typedef struct {
    +    uint32_t end;           // the sample's last frame (byte address)
    +    uint8_t  mode;          // voice control: 16-bit, loop
    +    uint8_t  loop;          // AUDIO_WT_LOOP_*
    +} SynthVoiceT;
    +
    +static SynthVoiceT sVoices[GUS_VOICES_MAX];
    +static uint32_t    sFcBase;     // gusFcBase of the output rate
    +static bool        sLocked = false;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +bool        gusSynthActive(uint32_t voice);
    +void        gusSynthClose(void);
    +static bool gusSynthFind(const AudioDrvConfigT *cfg);
    +static bool gusSynthLock(bool lock);
    +bool        gusSynthOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
    +void        gusSynthRelease(uint32_t voice);
    +void        gusSynthStart(uint32_t voice, const AudioWtVoiceT *v);
    +void        gusSynthStop(uint32_t voice);
    +void        gusSynthUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +bool        gusSynthUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
    +static void voiceAddr(uint32_t voice, uint8_t reg, uint32_t addr, bool wide, bool lowFirst);
    +
    +
    +// Played to its end (stopped) or not.  Interrupt time.
    +bool gusSynthActive(uint32_t voice) {
    +    return !(gusVoiceRead8(gusHw.synthFirst + voice, GUS_V_CONTROL) & GUS_VC_STOPPED);
    +}
    +
    +
    +void gusSynthClose(void) {
    +    for (uint32_t v = gusHw.synthFirst; v < gusHw.voices; v++) {
    +        gusVoiceStop(v);
    +    }
    +
    +    if (sLocked) {
    +        gusSynthLock(false);
    +        sLocked = false;
    +    }
    +
    +    gusHw.synthOpen = false;
    +    gusRelease();
    +}
    +
    +
    +// The card alone: ULTRASND's port, else the usual ones; reset with the
    +// voices DVX.INI asks for, the park block at the top of its memory.
    +static bool gusSynthFind(const AudioDrvConfigT *cfg) {
    +    GusEnvT  env;
    +    bool     named = gusParseEnv(getenv("ULTRASND"), &env);
    +    uint32_t tries = named ? 1 : (uint32_t)(sizeof(sBases) / sizeof(sBases[0]));
    +    bool     found = false;
    +
    +    for (uint32_t i = 0; !found && i < tries; i++) {
    +        found = gusProbe(named ? (uint16_t)env.base : sBases[i]);
    +    }
    +
    +    if (!found) {
    +        return false;
    +    }
    +
    +    gusHw.dramBytes = gusSizeDram();
    +    gusHw.park      = gusHw.dramBytes - GUS_ALIGN;
    +
    +    for (uint32_t b = 0; b < GUS_ALIGN; b++) {
    +        gusPoke(gusHw.park + b, 0);
    +    }
    +
    +    gusOutput(false);
    +    gusReset((uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, GUS_INI_VOICES, GUS_VOICES_DEFAULT));
    +    gusOutput(true);
    +    gusHw.synthTop   = gusHw.park;
    +    gusHw.synthFirst = 0;
    +    gusHw.running    = true;
    +    return true;
    +}
    +
    +
    +// The interrupt-time functions here, the voices' table, and the GF1 code.
    +static bool gusSynthLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)gusSynthActive,  0 },
    +        { (const void *)gusSynthRelease, 0 },
    +        { (const void *)gusSynthStart,   0 },
    +        { (const void *)gusSynthStop,    0 },
    +        { (const void *)gusSynthUpdate,  0 },
    +        { (const void *)voiceAddr,       0 },
    +        { sVoices,                       sizeof(sVoices) },
    +        { &sFcBase,                      sizeof(sFcBase) }
    +    };
    +
    +    bool ok = audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +
    +    return gusLock(lock) && ok;
    +}
    +
    +
    +// The voices and memory the card (as the module set it up, or found
    +// here) leaves the synthesizer.
    +bool gusSynthOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info) {
    +    if (!gusHw.running && !gusSynthFind(cfg)) {
    +        return false;
    +    }
    +
    +    sLocked = gusSynthLock(true);
    +
    +    if (!sLocked) {
    +        gusRelease();
    +        return false;
    +    }
    +
    +    // level stays 0: a GUS alone plays digital sound on its own voices
    +    sFcBase         = gusFcBase(AUDIO_WT_PITCH_RATE, gusHw.outRate);
    +    gusHw.synthOpen = true;
    +    info->voices    = gusHw.voices - gusHw.synthFirst;
    +    info->memory    = gusHw.synthTop;
    +    info->flags     = AUDIO_WT_8BIT | AUDIO_WT_BANK_256K;
    +    snprintf(info->description, sizeof(info->description), "Gravis UltraSound wavetable at %Xh (%lu voices, %lu KB)", (unsigned)gusHw.base, (unsigned long)info->voices, (unsigned long)(info->memory / KB));
    +    return true;
    +}
    +
    +
    +// A sample that loops while the key is down plays out: the end moved to
    +// the sample's (forwards: its high word first), then the loop off.
    +// Interrupt time.
    +void gusSynthRelease(uint32_t voice) {
    +    const SynthVoiceT *s  = &sVoices[voice];
    +    uint32_t           gv = gusHw.synthFirst + voice;
    +
    +    if (s->loop != AUDIO_WT_LOOP_RELEASE) {
    +        return;
    +    }
    +
    +    voiceAddr(gv, GUS_V_END_HIGH, s->end, (s->mode & GUS_VC_16BIT) != 0, false);
    +    gusVoiceControl(gv, GUS_V_CONTROL, (uint8_t)(s->mode & ~GUS_VC_LOOP));
    +}
    +
    +
    +// Interrupt time.
    +void gusSynthStart(uint32_t voice, const AudioWtVoiceT *v) {
    +    SynthVoiceT *s     = &sVoices[voice];
    +    uint32_t     gv    = gusHw.synthFirst + voice;
    +    uint32_t     bytes = v->bits / BYTE_BITS;
    +    bool         wide  = v->bits == BITS_16;
    +    bool         loops = v->loop != AUDIO_WT_LOOP_NONE && v->loopEnd > v->loopStart;
    +    uint32_t     last  = v->end > 0 ? v->end - 1 : 0;
    +
    +    s->end  = v->addr + last * bytes;
    +    s->loop = loops ? v->loop : AUDIO_WT_LOOP_NONE;
    +    s->mode = (uint8_t)((wide ? GUS_VC_16BIT : 0) | (loops ? GUS_VC_LOOP : 0));
    +    gusVoiceStop(gv);
    +    gusVoiceWrite16(gv, GUS_V_FREQ, gusFcCents(v->pitch, sFcBase));
    +    voiceAddr(gv, GUS_V_START_HIGH, v->addr + (loops ? v->loopStart : 0) * bytes, wide, false);
    +    voiceAddr(gv, GUS_V_END_HIGH, loops ? v->addr + v->loopEnd * bytes : s->end, wide, false);
    +    voiceAddr(gv, GUS_V_POS_HIGH, v->addr + v->offset * bytes, wide, false);
    +    gusVoiceWrite8(gv, GUS_V_PAN, gusPan(v->pan));
    +    gusVoiceWrite16(gv, GUS_V_VOLUME, (uint16_t)(gusAttenVolume(v->attenuation) << GUS_VOL_SHIFT));
    +    gusVoiceControl(gv, GUS_V_CONTROL, s->mode);
    +}
    +
    +
    +// Interrupt time.
    +void gusSynthStop(uint32_t voice) {
    +    gusVoiceStop(gusHw.synthFirst + voice);
    +}
    +
    +
    +// Pitch, volume and pan (what the GF1 has).  Interrupt time.
    +void gusSynthUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    uint32_t gv = gusHw.synthFirst + voice;
    +
    +    if (what & AUDIO_WT_SET_PITCH) {
    +        gusVoiceWrite16(gv, GUS_V_FREQ, gusFcCents(v->pitch, sFcBase));
    +    }
    +
    +    if (what & AUDIO_WT_SET_VOLUME) {
    +        gusVoiceWrite16(gv, GUS_V_VOLUME, (uint16_t)(gusAttenVolume(v->attenuation) << GUS_VOL_SHIFT));
    +    }
    +
    +    if (what & AUDIO_WT_SET_PAN) {
    +        gusVoiceWrite8(gv, GUS_V_PAN, gusPan(v->pan));
    +    }
    +}
    +
    +
    +// Frames as the GF1 stores them (signed, 16-bit little-endian or the top
    +// byte), through the DRAM port a chunk at a time.  Program time.
    +bool gusSynthUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits) {
    +    uint8_t  chunk[UPLOAD_CHUNK];
    +    bool     wide  = bits == BITS_16;
    +    uint32_t bytes = count * (wide ? 2 : 1);
    +
    +    if (addr > gusHw.synthTop || bytes > gusHw.synthTop - addr) {
    +        return false;
    +    }
    +
    +    for (uint32_t done = 0; done < bytes;) {
    +        uint32_t n = bytes - done < UPLOAD_CHUNK ? bytes - done : UPLOAD_CHUNK;
    +
    +        for (uint32_t i = 0; i < n; i++) {
    +            uint32_t at = done + i;
    +            uint16_t s  = (uint16_t)frames[wide ? at / 2 : at];
    +
    +            chunk[i] = (uint8_t)(wide && !(at & 1) ? s & BYTE_MASK : s >> BYTE_BITS);
    +        }
    +
    +        gusPokeBlock(addr + done, chunk, n);
    +        done += n;
    +    }
    +
    +    return true;
    +}
    +
    +
    +// A voice address at a byte address (translated for 16-bit samples).
    +// Interrupt time.
    +static void voiceAddr(uint32_t voice, uint8_t reg, uint32_t addr, bool wide, bool lowFirst) {
    +    gusVoiceAddr(voice, reg, wide ? gusAddr16(addr) : addr, lowFirst);
    +}
    diff --git a/src/libs/kpunch/audio/gus/gusSynth.h b/src/libs/kpunch/audio/gus/gusSynth.h
    new file mode 100644
    index 00000000..dac10608
    --- /dev/null
    +++ b/src/libs/kpunch/audio/gus/gusSynth.h
    @@ -0,0 +1,51 @@
    +// 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.
    +
    +// gusSynth.h  -- the Gravis UltraSound as a wavetable synthesizer
    +//
    +// The functions of an AUDIO_SYNTH_WAVETABLE driver (audioDrv.h), shared
    +// by gus.drv (the synthesizer beside its own digital sound: the voices
    +// and memory its ring leaves) and guswt.drv (the synthesizer alone, the
    +// card beside another sound device: every voice and all the memory).
    +// The GF1 plays 8- and 16-bit samples with loops and pans 16 ways; the
    +// envelopes, the LFOs, filter and effects are left to music.lib (updates
    +// of pitch and volume), so the flags are AUDIO_WT_8BIT and
    +// AUDIO_WT_BANK_256K only.
    +#ifndef GUS_SYNTH_H
    +#define GUS_SYNTH_H
    +
    +#include "../audioDrv.h"
    +
    +bool gusSynthActive(uint32_t voice);
    +void gusSynthClose(void);
    +bool gusSynthOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
    +void gusSynthRelease(uint32_t voice);
    +void gusSynthStart(uint32_t voice, const AudioWtVoiceT *v);
    +void gusSynthStop(uint32_t voice);
    +void gusSynthUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +bool gusSynthUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
    +
    +// The DVX.INI key for the active voices, and its default (both drivers)
    +#define GUS_INI_VOICES      "gusVoices"
    +#define GUS_VOICES_DEFAULT  28
    +
    +#endif // GUS_SYNTH_H
    diff --git a/src/libs/kpunch/audio/isa/isaCalc.c b/src/libs/kpunch/audio/isa/isaCalc.c
    new file mode 100644
    index 00000000..4c0e805a
    --- /dev/null
    +++ b/src/libs/kpunch/audio/isa/isaCalc.c
    @@ -0,0 +1,54 @@
    +// 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.
    +
    +// isaCalc.c  -- ISA sound card arithmetic shared by the drivers; see
    +// isaCalc.h
    +
    +#include "isaCalc.h"
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint32_t isaDmaBoundary(uint32_t channel);
    +uint32_t isaDmaPlace(uint32_t phys, uint32_t allocBytes, uint32_t bufBytes, uint32_t boundary);
    +
    +
    +uint32_t isaDmaBoundary(uint32_t channel) {
    +    return channel >= ISA_DMA_FIRST_16 ? ISA_DMA_BOUNDARY_16 : ISA_DMA_BOUNDARY_8;
    +}
    +
    +
    +// At phys when the buffer fits there, else at the next boundary.
    +uint32_t isaDmaPlace(uint32_t phys, uint32_t allocBytes, uint32_t bufBytes, uint32_t boundary) {
    +    uint32_t start = phys;
    +
    +    if (start / boundary != (start + bufBytes - 1) / boundary) {
    +        start = (start / boundary + 1) * boundary;
    +    }
    +
    +    if (start + bufBytes > phys + allocBytes) {
    +        return ISA_NONE;
    +    }
    +
    +    return start - phys;
    +}
    diff --git a/src/libs/kpunch/audio/isa/isaCalc.h b/src/libs/kpunch/audio/isa/isaCalc.h
    new file mode 100644
    index 00000000..ac6adf82
    --- /dev/null
    +++ b/src/libs/kpunch/audio/isa/isaCalc.h
    @@ -0,0 +1,52 @@
    +// 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.
    +
    +// isaCalc.h  -- ISA sound card arithmetic shared by the drivers (no
    +// hardware access, so the host tests run it)
    +//
    +// Where a DMA buffer goes in a block of conventional memory: an ISA DMA
    +// transfer cannot cross a 64 KB boundary (byte channels 0-3) or a 128 KB
    +// one (word channels 5-7).
    +#ifndef ISA_CALC_H
    +#define ISA_CALC_H
    +
    +#include 
    +
    +// No such value (an offset that does not fit)
    +#define ISA_NONE                0xFFFFFFFFU
    +
    +// Boundaries a DMA transfer must not cross
    +#define ISA_DMA_BOUNDARY_8      0x10000U
    +#define ISA_DMA_BOUNDARY_16     0x20000U
    +
    +// The first word-wide DMA channel and the channel count
    +#define ISA_DMA_FIRST_16        4
    +#define ISA_DMA_CHANNELS        8
    +
    +// The boundary of a DMA channel.
    +uint32_t isaDmaBoundary(uint32_t channel);
    +
    +// Offset in a block at physical address phys, allocBytes long, of a
    +// bufBytes buffer that does not cross boundary; ISA_NONE when none fits.
    +uint32_t isaDmaPlace(uint32_t phys, uint32_t allocBytes, uint32_t bufBytes, uint32_t boundary);
    +
    +#endif // ISA_CALC_H
    diff --git a/src/libs/kpunch/audio/isa/isaHw.c b/src/libs/kpunch/audio/isa/isaHw.c
    new file mode 100644
    index 00000000..b3456209
    --- /dev/null
    +++ b/src/libs/kpunch/audio/isa/isaHw.c
    @@ -0,0 +1,288 @@
    +// 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.
    +
    +// isaHw.c  -- ISA sound card hardware shared by the drivers; see isaHw.h
    +
    +#include "isaHw.h"
    +#include "audioDrv.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +// 8237 DMA controllers: channels 0-3 byte-wide, 5-7 word-wide
    +#define DMA8_MASK           0x0A
    +#define DMA8_MODE           0x0B
    +#define DMA8_FLIPFLOP       0x0C
    +#define DMA16_MASK          0xD4
    +#define DMA16_MODE          0xD6
    +#define DMA16_FLIPFLOP      0xD8
    +#define DMA_MASK_SET        0x04
    +#define DMA_CHANNEL_BITS    0x03
    +#define DMA_PAGE_SHIFT      16
    +#define DMA_PAGE_MASK16     0xFE
    +
    +// 8259 interrupt controllers
    +#define PIC1_CMD            0x20
    +#define PIC1_DATA           0x21
    +#define PIC2_CMD            0xA0
    +#define PIC2_DATA           0xA1
    +#define PIC_EOI             0x20
    +#define PIC_READ_ISR        0x0B
    +#define IRQ_SLAVE_FIRST     8
    +#define IRQ_SPURIOUS_MASTER 7
    +#define VEC_MASTER_BASE     0x08
    +#define VEC_SLAVE_BASE      0x70
    +
    +#define PARAGRAPH_BYTES     16
    +#define PARAGRAPH_SHIFT     4
    +#define WORD_BYTES          4
    +#define BYTE_MASK           0xFF
    +#define BYTE_BITS           8
    +#define ALLOC_FACTOR        2       // twice over, so a boundary-free stretch always exists
    +
    +// Channel registers, by DMA channel number
    +static const uint8_t sDmaAddrPort[ISA_DMA_CHANNELS]  = { 0x00, 0x02, 0x04, 0x06, 0xC0, 0xC4, 0xC8, 0xCC };
    +static const uint8_t sDmaCountPort[ISA_DMA_CHANNELS] = { 0x01, 0x03, 0x05, 0x07, 0xC2, 0xC6, 0xCA, 0xCE };
    +static const uint8_t sDmaPagePort[ISA_DMA_CHANNELS]  = { 0x87, 0x83, 0x81, 0x82, 0x8F, 0x8B, 0x89, 0x8A };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +bool        isaDmaAlloc(IsaDmaT *dma, uint32_t channel, uint32_t bytes);
    +void        isaDmaFill(const IsaDmaT *dma, uint8_t value);
    +void        isaDmaFree(IsaDmaT *dma);
    +uint32_t    isaDmaPosition(const IsaDmaT *dma);
    +void        isaDmaProgram(const IsaDmaT *dma, uint32_t offset, uint32_t bytes, uint8_t mode);
    +void        isaDmaRead(const IsaDmaT *dma, uint32_t offset, uint32_t *words, uint32_t count);
    +void        isaDmaStop(const IsaDmaT *dma);
    +void        isaDmaWrite(const IsaDmaT *dma, uint32_t offset, const uint32_t *words, uint32_t count);
    +void        isaIrqEnd(const IsaIrqT *irq);
    +bool        isaIrqHook(IsaIrqT *irq, uint32_t number, void (*isr)(void));
    +bool        isaIrqSpurious(const IsaIrqT *irq);
    +void        isaIrqUnhook(IsaIrqT *irq);
    +bool        isaLock(bool lock);
    +static uint32_t picMaskPort(uint32_t irq);
    +static int  picVector(uint32_t irq);
    +
    +
    +bool isaDmaAlloc(IsaDmaT *dma, uint32_t channel, uint32_t bytes) {
    +    uint32_t allocBytes = bytes * ALLOC_FACTOR;
    +    int      selector   = 0;
    +    int      segment    = __dpmi_allocate_dos_memory((int)((allocBytes + PARAGRAPH_BYTES - 1) / PARAGRAPH_BYTES), &selector);
    +
    +    dma->selector = -1;
    +
    +    if (segment < 0) {
    +        return false;
    +    }
    +
    +    uint32_t blockPhys = (uint32_t)segment << PARAGRAPH_SHIFT;
    +    uint32_t offset    = isaDmaPlace(blockPhys, allocBytes, bytes, isaDmaBoundary(channel));
    +
    +    if (offset == ISA_NONE) {
    +        __dpmi_free_dos_memory(selector);
    +        return false;
    +    }
    +
    +    dma->channel  = channel;
    +    dma->bytes    = bytes;
    +    dma->offset   = offset;
    +    dma->phys     = blockPhys + offset;
    +    dma->selector = selector;
    +    return true;
    +}
    +
    +
    +void isaDmaFill(const IsaDmaT *dma, uint8_t value) {
    +    _farsetsel((unsigned short)dma->selector);
    +
    +    for (uint32_t i = 0; i < dma->bytes; i++) {
    +        _farnspokeb(dma->offset + i, value);
    +    }
    +}
    +
    +
    +void isaDmaFree(IsaDmaT *dma) {
    +    if (dma->selector >= 0) {
    +        __dpmi_free_dos_memory(dma->selector);
    +        dma->selector = -1;
    +    }
    +}
    +
    +
    +// The count register holds the units left minus one (bytes on 0-3,
    +// words on 5-7).
    +uint32_t isaDmaPosition(const IsaDmaT *dma) {
    +    uint32_t ch    = dma->channel;
    +    bool     wide  = ch >= ISA_DMA_FIRST_16;
    +    uint32_t units = wide ? dma->bytes / 2 : dma->bytes;
    +    uint32_t count;
    +
    +    outportb(wide ? DMA16_FLIPFLOP : DMA8_FLIPFLOP, 0);
    +    count  = inportb(sDmaCountPort[ch]);
    +    count |= (uint32_t)inportb(sDmaCountPort[ch]) << BYTE_BITS;
    +
    +    uint32_t done = units - 1 - count;
    +
    +    return wide ? done * 2 : done;
    +}
    +
    +
    +void isaDmaProgram(const IsaDmaT *dma, uint32_t offset, uint32_t bytes, uint8_t mode) {
    +    uint32_t ch    = dma->channel;
    +    bool     wide  = ch >= ISA_DMA_FIRST_16;
    +    uint32_t sel   = ch & DMA_CHANNEL_BITS;
    +    uint32_t phys  = dma->phys + offset;
    +    uint32_t addr  = wide ? (phys >> 1) : phys;
    +    uint32_t count = (wide ? bytes / 2 : bytes) - 1;
    +    uint32_t page  = phys >> DMA_PAGE_SHIFT;
    +
    +    outportb(wide ? DMA16_MASK : DMA8_MASK, (uint8_t)(DMA_MASK_SET | sel));
    +    outportb(wide ? DMA16_FLIPFLOP : DMA8_FLIPFLOP, 0);
    +    outportb(wide ? DMA16_MODE : DMA8_MODE, (uint8_t)(mode | sel));
    +    outportb(sDmaAddrPort[ch], addr & BYTE_MASK);
    +    outportb(sDmaAddrPort[ch], (addr >> BYTE_BITS) & BYTE_MASK);
    +    outportb(sDmaPagePort[ch], (uint8_t)(wide ? (page & DMA_PAGE_MASK16) : page));
    +    outportb(sDmaCountPort[ch], count & BYTE_MASK);
    +    outportb(sDmaCountPort[ch], (count >> BYTE_BITS) & BYTE_MASK);
    +    outportb(wide ? DMA16_MASK : DMA8_MASK, (uint8_t)sel);
    +}
    +
    +
    +void isaDmaRead(const IsaDmaT *dma, uint32_t offset, uint32_t *words, uint32_t count) {
    +    _farsetsel((unsigned short)dma->selector);
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        words[i] = _farnspeekl(dma->offset + offset + i * WORD_BYTES);
    +    }
    +}
    +
    +
    +void isaDmaStop(const IsaDmaT *dma) {
    +    bool wide = dma->channel >= ISA_DMA_FIRST_16;
    +
    +    outportb(wide ? DMA16_MASK : DMA8_MASK, (uint8_t)(DMA_MASK_SET | (dma->channel & DMA_CHANNEL_BITS)));
    +}
    +
    +
    +void isaDmaWrite(const IsaDmaT *dma, uint32_t offset, const uint32_t *words, uint32_t count) {
    +    _farsetsel((unsigned short)dma->selector);
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        _farnspokel(dma->offset + offset + i * WORD_BYTES, words[i]);
    +    }
    +}
    +
    +
    +void isaIrqEnd(const IsaIrqT *irq) {
    +    if (irq->irq >= IRQ_SLAVE_FIRST) {
    +        outportb(PIC2_CMD, PIC_EOI);
    +    }
    +
    +    outportb(PIC1_CMD, PIC_EOI);
    +}
    +
    +
    +bool isaIrqHook(IsaIrqT *irq, uint32_t number, void (*isr)(void)) {
    +    irq->irq    = number == ISA_IRQ_CASCADE ? ISA_IRQ_AT_REDIRECT : number;
    +    irq->hooked = false;
    +
    +    int      vector = picVector(irq->irq);
    +    uint32_t port   = picMaskPort(irq->irq);
    +    uint8_t  bit    = (uint8_t)(1 << (irq->irq & (IRQ_SLAVE_FIRST - 1)));
    +
    +    _go32_dpmi_get_protected_mode_interrupt_vector(vector, &irq->oldVector);
    +    irq->newVector.pm_offset   = (unsigned long)isr;
    +    irq->newVector.pm_selector = _go32_my_cs();
    +
    +    if (_go32_dpmi_allocate_iret_wrapper(&irq->newVector) != 0) {
    +        return false;
    +    }
    +
    +    uint32_t state = platformIrqSave();
    +
    +    _go32_dpmi_set_protected_mode_interrupt_vector(vector, &irq->newVector);
    +    irq->savedPicBit = inportb(port) & bit;
    +    outportb(port, inportb(port) & ~bit);
    +
    +    if (irq->irq >= IRQ_SLAVE_FIRST) {
    +        outportb(PIC1_DATA, inportb(PIC1_DATA) & ~(1 << ISA_IRQ_CASCADE));
    +    }
    +
    +    irq->hooked = true;
    +    platformIrqRestore(state);
    +    return true;
    +}
    +
    +
    +bool isaIrqSpurious(const IsaIrqT *irq) {
    +    if (irq->irq != IRQ_SPURIOUS_MASTER) {
    +        return false;
    +    }
    +
    +    outportb(PIC1_CMD, PIC_READ_ISR);
    +    return !(inportb(PIC1_CMD) & (1 << IRQ_SPURIOUS_MASTER));
    +}
    +
    +
    +void isaIrqUnhook(IsaIrqT *irq) {
    +    if (!irq->hooked) {
    +        return;
    +    }
    +
    +    uint32_t port  = picMaskPort(irq->irq);
    +    uint32_t state = platformIrqSave();
    +
    +    outportb(port, inportb(port) | irq->savedPicBit);
    +    _go32_dpmi_set_protected_mode_interrupt_vector(picVector(irq->irq), &irq->oldVector);
    +    platformIrqRestore(state);
    +    _go32_dpmi_free_iret_wrapper(&irq->newVector);
    +    irq->hooked = false;
    +}
    +
    +
    +bool isaLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)isaDmaPosition, 0 },
    +        { (const void *)isaDmaRead,     0 },
    +        { (const void *)isaDmaWrite,    0 },
    +        { (const void *)isaIrqEnd,      0 },
    +        { (const void *)isaIrqSpurious, 0 },
    +        { sDmaCountPort,                sizeof(sDmaCountPort) }
    +    };
    +
    +    return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +}
    +
    +
    +// The PIC's mask register for an IRQ.
    +static uint32_t picMaskPort(uint32_t irq) {
    +    return irq < IRQ_SLAVE_FIRST ? PIC1_DATA : PIC2_DATA;
    +}
    +
    +
    +// The interrupt vector an IRQ arrives on.
    +static int picVector(uint32_t irq) {
    +    return irq < IRQ_SLAVE_FIRST ? VEC_MASTER_BASE + (int)irq : VEC_SLAVE_BASE + (int)(irq - IRQ_SLAVE_FIRST);
    +}
    diff --git a/src/libs/kpunch/audio/isa/isaHw.h b/src/libs/kpunch/audio/isa/isaHw.h
    new file mode 100644
    index 00000000..bd41602d
    --- /dev/null
    +++ b/src/libs/kpunch/audio/isa/isaHw.h
    @@ -0,0 +1,114 @@
    +// 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.
    +
    +// isaHw.h  -- ISA sound card hardware shared by the drivers: a DMA buffer
    +// in conventional memory, the 8237 DMA controllers, and a card's
    +// interrupt at the 8259s
    +//
    +// Each driver links its own copy (audio/isa, beside the drivers), keeps
    +// its own IsaDmaT and IsaIrqT, and pins the copy with isaLock before its
    +// interrupt can run.  The functions marked "interrupt time" keep to the
    +// rules in audioDrv.h.
    +#ifndef ISA_HW_H
    +#define ISA_HW_H
    +
    +#include "isaCalc.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +// 8237 transfer modes (single transfer): playing moves memory to the
    +// device, recording the device to memory
    +#define ISA_DMA_AUTO_PLAY       0x58    // auto-init: the buffer over and over
    +#define ISA_DMA_ONCE_PLAY       0x48    // once
    +#define ISA_DMA_AUTO_RECORD     0x54    // auto-init, into the buffer
    +#define ISA_DMA_ONCE_RECORD     0x44    // once, into the buffer
    +
    +// The cascade IRQ, which an AT delivers as IRQ 9
    +#define ISA_IRQ_CASCADE         2
    +#define ISA_IRQ_AT_REDIRECT     9
    +#define ISA_IRQ_COUNT           16
    +
    +// A DMA buffer in conventional memory and the channel that plays it
    +typedef struct {
    +    uint32_t channel;
    +    uint32_t bytes;         // the buffer's length
    +    uint32_t offset;        // of the buffer in the DOS block
    +    uint32_t phys;          // its physical address
    +    int32_t  selector;      // the DOS block (-1: none)
    +} IsaDmaT;
    +
    +// A card's interrupt, hooked
    +typedef struct {
    +    _go32_dpmi_seginfo oldVector;
    +    _go32_dpmi_seginfo newVector;
    +    uint32_t           irq;
    +    uint8_t            savedPicBit;    // the IRQ was masked before
    +    bool               hooked;
    +} IsaIrqT;
    +
    +// A buffer of bytes for channel, placed so no transfer crosses a DMA
    +// boundary.  False (nothing allocated) when there is no room.
    +bool     isaDmaAlloc(IsaDmaT *dma, uint32_t channel, uint32_t bytes);
    +
    +// Every byte of the buffer set to value.
    +void     isaDmaFill(const IsaDmaT *dma, uint8_t value);
    +
    +void     isaDmaFree(IsaDmaT *dma);
    +
    +// Bytes of the buffer the controller has moved in the current pass of an
    +// auto-init transfer over the whole buffer.  Interrupt time.
    +uint32_t isaDmaPosition(const IsaDmaT *dma);
    +
    +// Copy count 32-bit words out of the buffer from offset (what the card
    +// recorded).  Interrupt time.
    +void     isaDmaRead(const IsaDmaT *dma, uint32_t offset, uint32_t *words, uint32_t count);
    +
    +// Program the channel for bytes of the buffer from offset, in mode, and
    +// unmask it.
    +void     isaDmaProgram(const IsaDmaT *dma, uint32_t offset, uint32_t bytes, uint8_t mode);
    +
    +// Mask the channel.
    +void     isaDmaStop(const IsaDmaT *dma);
    +
    +// Copy count 32-bit words into the buffer from offset.  Interrupt time.
    +void     isaDmaWrite(const IsaDmaT *dma, uint32_t offset, const uint32_t *words, uint32_t count);
    +
    +// End of interrupt at the PICs.  Interrupt time.
    +void     isaIrqEnd(const IsaIrqT *irq);
    +
    +// Point irq (2 meaning 9) at isr and unmask it.  False when the wrapper
    +// could not be made.
    +bool     isaIrqHook(IsaIrqT *irq, uint32_t number, void (*isr)(void));
    +
    +// True for a spurious IRQ 7 (the PIC has it not in service): no
    +// acknowledgement at all.  Interrupt time.
    +bool     isaIrqSpurious(const IsaIrqT *irq);
    +
    +// The vector and the mask back as they were.
    +void     isaIrqUnhook(IsaIrqT *irq);
    +
    +// Pin or release this file's interrupt-time code and tables.
    +bool     isaLock(bool lock);
    +
    +#endif // ISA_HW_H
    diff --git a/src/libs/kpunch/audio/math/drvMath.c b/src/libs/kpunch/audio/math/drvMath.c
    new file mode 100644
    index 00000000..3159dab8
    --- /dev/null
    +++ b/src/libs/kpunch/audio/math/drvMath.c
    @@ -0,0 +1,61 @@
    +// 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.
    +
    +// drvMath.c  -- arithmetic the synthesizer drivers share (see drvMath.h)
    +
    +#include "drvMath.h"
    +#include "../audioDrv.h"
    +
    +#define CENTS_SEMI          100
    +#define SEMITONES           13
    +
    +// 2^(k / 12) in 16.16, k = 0..12
    +static const uint32_t sSemitones[SEMITONES] = {
    +    65536, 69433, 73562, 77936, 82570, 87480, 92682, 98193, 104032, 110218, 116772, 123715, 131072
    +};
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +bool     drvMathLock(bool lock);
    +uint32_t drvPow2(int32_t cents);
    +
    +
    +bool drvMathLock(bool lock) {
    +    const AudioLockItemT items[] = {
    +        { (const void *)drvPow2, 0 },
    +        { sSemitones,            sizeof(sSemitones) }
    +    };
    +
    +    return audioLockSpan(items, (int32_t)(sizeof(items) / sizeof(items[0])), lock);
    +}
    +
    +
    +uint32_t drvPow2(int32_t cents) {
    +    int32_t  octave = cents >= 0 ? cents / DRV_CENTS_OCTAVE : -((DRV_CENTS_OCTAVE - 1 - cents) / DRV_CENTS_OCTAVE);
    +    int32_t  rest   = cents - octave * DRV_CENTS_OCTAVE;
    +    int32_t  semi   = rest / CENTS_SEMI;
    +    uint32_t mul    = sSemitones[semi] + (sSemitones[semi + 1] - sSemitones[semi]) * (uint32_t)(rest % CENTS_SEMI) / CENTS_SEMI;
    +
    +    return octave >= 0 ? mul << octave : mul >> -octave;
    +}
    diff --git a/src/libs/kpunch/audio/math/drvMath.h b/src/libs/kpunch/audio/math/drvMath.h
    new file mode 100644
    index 00000000..1717e1c4
    --- /dev/null
    +++ b/src/libs/kpunch/audio/math/drvMath.h
    @@ -0,0 +1,45 @@
    +// 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.
    +
    +// drvMath.h  -- arithmetic the synthesizer drivers share
    +//
    +// Integer only, so it runs at interrupt time (the drivers pin it with
    +// drvMathLock): no division by 64-bit values, no floating point.
    +#ifndef DRV_MATH_H
    +#define DRV_MATH_H
    +
    +#include 
    +#include 
    +
    +#define DRV_POW2_SHIFT      16
    +#define DRV_POW2_ONE        (1 << DRV_POW2_SHIFT)  // 1.0 in drvPow2's 16.16
    +#define DRV_CENTS_OCTAVE    1200
    +
    +// 2^(cents / 1200) in 16.16: the octave as a shift, the semitone from a
    +// table, the cents between two semitones by a straight line (within 0.1
    +// percent).  cents from -16 to +15 octaves.
    +uint32_t drvPow2(int32_t cents);
    +
    +// Pin or release drvPow2 and its table.
    +bool     drvMathLock(bool lock);
    +
    +#endif // DRV_MATH_H
    diff --git a/src/libs/kpunch/dvxnet/netDos.c b/src/libs/kpunch/dvxnet/netDos.c
    index 4fa0b5f2..4e98cfe6 100644
    --- a/src/libs/kpunch/dvxnet/netDos.c
    +++ b/src/libs/kpunch/dvxnet/netDos.c
    @@ -33,9 +33,11 @@
     // one reach this fcntl).
     //
     // Shell: the constructor registers an idle handler (the stack runs while
    -// DVX is idle) and an app cleanup hook (sockets an ending app left open
    -// are closed); the destructor stops the stack so the packet driver never
    -// calls into an unloaded module.
    +// DVX is idle), an app cleanup hook (sockets an ending app left open are
    +// closed), and a suspend hook (the stack stops while DVX steps aside for
    +// a DOS program, which may use the packet driver itself; it starts again
    +// at its next use); the destructor stops the stack so the packet driver
    +// never calls into an unloaded module.
     
     #include "netInt.h"
     #include "dvxPlat.h"
    @@ -64,6 +66,7 @@ static int  fsextHandler(__FSEXT_Fnumber fn, int *rv, va_list args);
     static void idlePoll(void *ctx);
     static void netDosInit(void) __attribute__((constructor));
     static void netDosShutdown(void) __attribute__((destructor));
    +static void netSuspend(bool suspending);
     
     
     // fcntl for every module loaded after this one (see the file comment).
    @@ -157,11 +160,13 @@ static void netDosInit(void) {
         sLibcFcntl = (libc == fcntl) ? NULL : libc;
         shellRegisterIdle(idlePoll, NULL);
         shellRegisterAppCleanup(netCloseAppSockets);
    +    platformSuspendHookAdd(netSuspend);
     }
     
     
     static void netDosShutdown(void) {
         netStop();
    +    platformSuspendHookRemove(netSuspend);
         shellUnregisterAppCleanup(netCloseAppSockets);
         shellUnregisterIdle(idlePoll, NULL);
     }
    @@ -204,3 +209,13 @@ int32_t netFdSocket(int32_t fd) {
     
         return sFdSock[fd] - 1;
     }
    +
    +
    +// The packet driver is let go while a DOS program runs (it may be a
    +// network program itself); nothing to do after: the stack starts again
    +// when a program next uses it.
    +static void netSuspend(bool suspending) {
    +    if (suspending && netStackRunning()) {
    +        netStop();
    +    }
    +}
    diff --git a/src/libs/kpunch/dvxshell/Makefile b/src/libs/kpunch/dvxshell/Makefile
    index 967aa215..a9856409 100644
    --- a/src/libs/kpunch/dvxshell/Makefile
    +++ b/src/libs/kpunch/dvxshell/Makefile
    @@ -36,7 +36,7 @@ THEMEDIR  = $(SHAREDDIR)/themes
     WPAPERDIR = $(SHAREDDIR)/wpaper
     KEYSDIR   = $(SYSTEMDIR)/KEYS
     
    -SRCS   = shellMain.c shellApp.c shellBas.c shellSysBas.c shellInfo.c shellAssoc.c shellProg.c shellRun.c pmModel.c pmModelBas.c
    +SRCS   = shellMain.c shellApp.c shellBas.c shellSysBas.c shellInfo.c shellAssoc.c shellProg.c shellRun.c shellSaver.c pmModel.c pmModelBas.c
     OBJS   = $(patsubst %.c,$(OBJDIR)/%.o,$(SRCS))
     TARGETDIR = $(LIBSDIR)/kpunch/dvxshell
     TARGET    = $(TARGETDIR)/dvxshell.lib
    @@ -50,7 +50,7 @@ WPAPERS = $(WPAPERDIR)/blueglow.jpg $(WPAPERDIR)/swoop.jpg $(WPAPERDIR)/triangle
     # dvxpkg keygen): what DVX trusts as its own (pkgTrust.h)
     KEYS = $(patsubst ../../../../config/keys/%,$(KEYSDIR)/%,$(wildcard ../../../../config/keys/*.KEY))
     
    -all: $(TARGET) $(TARGETDIR)/dvxshell.dep $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI $(THEMES) $(WPAPERS) $(KEYS)
    +all: $(TARGET) $(TARGETDIR)/dvxshell.dep $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI $(SYSTEMDIR)/DOSPROG.BMP $(THEMES) $(WPAPERS) $(KEYS)
     
     $(TARGETDIR)/dvxshell.dep: dvxshell.dep | $(TARGETDIR)
     	sed 's/$$/\r/' $< > $@
    @@ -67,6 +67,10 @@ $(SYSTEMDIR)/DVX.INI: ../../../../config/dvx.ini | $(SYSTEMDIR)
     $(SYSTEMDIR)/ASSOC.INI: ../../../../config/ASSOC.INI | $(SYSTEMDIR)
     	sed 's/$$/\r/' $< > $@
     
    +# The icon of a DOS program that has none of its own (shellProg.c)
    +$(SYSTEMDIR)/DOSPROG.BMP: ../../../../config/DOSPROG.BMP | $(SYSTEMDIR)
    +	cp $< $@
    +
     $(OBJDIR)/%.o: %.c $(DVX_MAKEFILES) | $(OBJDIR)
     	$(CC) $(CFLAGS) -c -o $@ $<
     
    @@ -101,4 +105,4 @@ $(KEYSDIR)/%.KEY: ../../../../config/keys/%.KEY | $(KEYSDIR)
     
     clean:
     	rm -rf $(OBJS) $(OBJS:.o=.d) $(TARGET) $(TARGETDIR)/dvxshell.dep $(TARGETDIR)
    -	rm -rf $(WPAPERDIR) $(THEMEDIR) $(KEYSDIR) $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI
    +	rm -rf $(WPAPERDIR) $(THEMEDIR) $(KEYSDIR) $(SYSTEMDIR)/DVX.INI $(SYSTEMDIR)/ASSOC.INI $(SYSTEMDIR)/DOSPROG.BMP
    diff --git a/src/libs/kpunch/dvxshell/dvxshell.dhs b/src/libs/kpunch/dvxshell/dvxshell.dhs
    index 48bd06d2..6ba8eddd 100644
    --- a/src/libs/kpunch/dvxshell/dvxshell.dhs
    +++ b/src/libs/kpunch/dvxshell/dvxshell.dhs
    @@ -105,6 +105,7 @@ LoadedE is transient (only during shellLoadApp before the entry point is called)
     .link shell.info System Information
     .link shell.programs Installed Programs
     .link shell.run Running Programs and Documents
    +.link shell.saver The Screen Saver
     .link shell.assoc File Associations
     .link shell.basic BASIC Entry Points
     .link shell.sysbasic BASIC System Settings
    @@ -226,6 +227,7 @@ Per-app slot in the shell's app table. Slot 0 is reserved for the shell itself;
       dxeCtx                         DxeAppContextT *                Heap-allocated context (address stable across realloc).
       taskCodeDepth                  int32_t *                       Per task id: SHELL_APP_CALL calls into this app still open on that task's stack.
       killPending                    bool                            A force kill was deferred; shellReapApps finishes it.
    +  faulted                        bool                            It crashed: its force kill runs no kill hooks (shellRegisterAppKill).
     .endtable
     
     .topic shell.lifecycle
    @@ -304,7 +306,7 @@ Gracefully shut down a single app. Calls the app's shutdownFn (if present), dest
     void shellForceKillApp(AppContextT *ctx, ShellAppT *app);
     .endcode
     
    -Forcibly kill an app. Used by the Task Manager "End Task" or as part of crash recovery. The app's shutdownFn is NOT called: a force kill targets a hung or faulting app whose own code cannot be trusted to run. Instead the shell drops every idle and desktop-update callback the app registered, destroys all of the app's windows, kills its task (for main-loop apps), and finally closes the DXE handle.
    +Forcibly kill an app. Used by the Task Manager "End Task" or as part of crash recovery (which marks the app faulted first). The app's shutdownFn is NOT called: a force kill targets a hung or faulting app whose own code cannot be trusted to run. Instead the libraries' kill hooks (shellRegisterAppKill) end what they run for the app, unless it faulted, then the shell drops every idle and desktop-update callback the app registered, destroys all of the app's windows, kills its task (for main-loop apps), and finally closes the DXE handle.
     
     Cleanup order: callbacks and windows first (removes them from the compositor), then the task (frees its stack; any dispatch frames still open on it are dropped from the context's dispatch depth), then the widget classes and system-wide hotkeys the app registered (its shutdownFn never got the chance to unregister them), then the DXE handle (unmaps the code). Closing the DXE before destroying windows would cause callbacks into unmapped code.
     
    @@ -518,6 +520,8 @@ When the serial library is loaded, the shell feeds keyboard and mouse event timi
     .index shellUnregisterIdle
     .index shellRegisterAppCleanup
     .index shellUnregisterAppCleanup
    +.index shellRegisterAppKill
    +.index shellUnregisterAppKill
     .index ShellAppCleanupFnT
     .index shellDesktopUpdate
     .index shellDesktopChangeCount
    @@ -582,6 +586,15 @@ void shellUnregisterAppCleanup(ShellAppCleanupFnT fn);
     
     Register a function the shell calls with an app's id whenever that app ends -- normally, force-killed, or after a crash -- once its windows are gone, next to the shell's own release of the app's widget classes and hotkeys. A library uses it to release what the app left open with it: the networking library closes the app's sockets this way. For library code that stays loaded; registering the same function again has no effect, and unregistering one that is not registered does nothing.
     
    +.h2 shellRegisterAppKill
    +
    +.code
    +void shellRegisterAppKill(ShellAppCleanupFnT fn);
    +void shellUnregisterAppKill(ShellAppCleanupFnT fn);
    +.endcode
    +
    +Register a function the shell calls with an app's id when the app is ended by force (End Task, a screen saver that would not close, DVX exiting) while none of the app's code is running and its windows are still there -- before the windows go and before the cleanup hooks. A library ends what it runs for the app the way the app's own end would: the BASIC runtime (basrt) frees a standalone program's VM, forms, screen, and module this way, closing its serial ports and its forms' windows, so End Task on a BASIC program leaks nothing. Not called for an app that faulted (its state cannot be trusted), nor for one whose kill had to wait until its code returned (End Task from inside the app's own dialog: its windows went at once). Registration rules as for cleanup hooks.
    +
     .h2 shellDesktopUpdate
     
     .code
    @@ -729,6 +742,13 @@ shellProgramListFree frees a list from shellProgramScan (NULL is fine). shellIsP
     .index shellSplitCommand
     .index shellResolveArgs
     .index shellMinimizeApp
    +.index shellRunDos
    +.index shellRunDosRequest
    +.index shellIsDosProgram
    +.index SHELL_RUN_DOS
    +.index SHELL_DOS_FILTER
    +.index SHELL_DOS_ICON
    +.index DOS programs
     .index Run dialog
     .index Run Minimized
     
    @@ -744,7 +764,19 @@ One way for every program to start another (Program Manager's items and Run...,
     int32_t shellRunProgram(AppContextT *ctx, const char *program, const char *args, const char *folder);
     .endcode
     
    -Start program with args. A .app is loaded with shellLoadAppWithArgs, its arguments first passed through shellResolveArgs(folder, ...). Anything else is a document: it is looked for in folder first (when relative), must exist, and is opened with shellAssocCommand's program and arguments. Problems are shown to the user in an error box (a missing document, a document type with no association; load errors come from shellLoadApp as usual). Returns the new application id, or -1.
    +Start program with args. A .app is loaded with shellLoadAppWithArgs, its arguments first passed through shellResolveArgs(folder, ...). A DOS program (shellIsDosProgram: .EXE, .COM, or .BAT, any case) is set to run with shellRunDos (folder is where it runs; looked for in folder first when relative). Anything else is a document: it is looked for in folder first (when relative), must exist, and is opened with shellAssocCommand's program and arguments; when that program is a DOS program, the document goes to it by its full short name (it runs in its own folder) through shellRunDos. Problems are shown to the user in an error box (a missing document, a document type with no association; load errors come from shellLoadApp as usual). Returns the new application id, SHELL_RUN_DOS (0) for a DOS program set to run, or -1.
    +
    +.h2 shellRunDos / shellRunDosRequest
    +
    +.code
    +void shellRunDos(const char *program, const char *args, const char *folder, bool pause);
    +void shellRunDosRequest(AppContextT *ctx, int32_t keepAppId);
    +bool shellIsDosProgram(const char *path);
    +.endcode
    +
    +Run a DOS program with DVX stepped aside (libdvx's dvxRunDos, which takes the same program, args, folder, and pause). shellRunDos only records the request (a newer one replaces one still waiting): closing the other programs pumps the event loop, which is not safe from an event handler. The main loop calls shellRunDosRequest after shellRunExitRequest; keepAppId is the desktop, which stays. A program named with a folder must exist ("Cannot find X.", titled Run DOS Program); a bare name is looked for on the PATH by DOS. When programs other than the desktop are running, a question names them (by their program names, shellProgramInfo) and says each asks about unsaved work as it closes; No, or a program that keeps a window (shellCloseAllApps), cancels the run. A program that cannot be started is reported after DVX is back ("DVX could not run X." with the reason). The Task Manager is the shell's own window and is not asked. Program Manager's DOS items call shellRunDos with their "Wait for a key" setting (PmItemT.pause, PROGMAN.INI itemN.pause); shellRunProgram uses pause false.
    +
    +SHELL_DOS_FILTER ("DOS programs (*.exe;*.com;*.bat)") is the file dialogs' filter for them (Run..., Program Manager's Properties, Control Panel's File Types). A Program Manager item for a DOS program with no icon file of its own shows SHELL_DOS_ICON (SYSTEM\DOSPROG.BMP, from config/DOSPROG.BMP).
     
     .h2 shellRunCommand
     
    @@ -787,6 +819,59 @@ void shellMinimizeIdle(AppContextT *ctx);
     
     Run Minimized. shellMinimizeApp minimizes every visible window of appId (an id from shellRunProgram) at once and again on each of the next SHELL_MINIMIZE_FRAMES (60) idle frames, because a program with a main loop opens its windows from its own task after it has started. Asking again for the same program starts its frames over; nothing happens for an id that is not running, and a program that ends is forgotten (an app-cleanup hook). shellMinimizeIdle is the per-frame step, called by the shell's idle dispatch. Program Manager's Run Minimized items and BASIC's ShellMinimizeApp use it.
     
    +.topic shell.saver
    +.title The Screen Saver
    +.toc 1 The Screen Saver
    +.index shellSaverSet
    +.index shellSaverStart
    +.index shellSaverStep
    +.index shellSaverRunning
    +.index shellSaverName
    +.index shellSaverWait
    +.index shellSaverFromPrefs
    +.index shellSaverToPrefs
    +.index SHELL_SAVER_ARGS
    +.index Screen saver
    +
    +.h1 The Screen Saver
    +
    +Header: dvxshell/shellSaver.h
    +
    +A screen saver is a program (.app, C or BASIC) in a SAVERS folder (DvxKindSaversE: SHARED\SAVERS, which DVX and add-ons fill, and the user's SAVERS); Program Manager does not list it. After the wait with no key or mouse input (dvxIdleMs) the shell starts the chosen one with SHELL_SAVER_ARGS ("/S"). It takes the whole screen (dvxExclusiveBegin; BASIC SCREEN and FULLSCREEN ON) and draws until its window is closed; it needs no input handling of its own, since the input that wakes the screen goes to it (full-screen mode), never to the program underneath. Input SHELL_SAVER_GRACE_MS (1,000 ms) or more after it started asks each of its windows to close (dvxRequestWindowClose, as Alt+F4 does); one still running SHELL_SAVER_END_MS (3,000 ms) later is ended (shellForceKillApp). The log says "Shell: screen saver X started" and "... ended".
    +
    +The saver does not start while a dialog box is open (ctx->modalWindow) or a program has the full screen (ctx->exclusive.owner: a game played with a joystick gives no key or mouse input). After a saver ends (or fails to start: missing, or not a program, logged and never shown in a message box) the next wait counts from then.
    +
    +DVX.INI [desktop] saver is the saver's name in SAVERS ("" or missing: none) and saverWait the minutes (SHELL_SAVER_WAIT_MIN to SHELL_SAVER_WAIT_MAX, 1 to 60; SHELL_SAVER_WAIT_DEFAULT, 10, when missing). The shell reads them at start; Control Panel's Desktop page sets them live and stores them with OK.
    +
    +.code
    +void        shellSaverSet(const char *name, int32_t minutes);
    +const char *shellSaverName(void);
    +int32_t     shellSaverWait(void);
    +bool        shellSaverStart(void);
    +bool        shellSaverRunning(void);
    +void        shellSaverStep(AppContextT *ctx);
    +void        shellSaverFromPrefs(PrefsHandleT *h);
    +void        shellSaverToPrefs(PrefsHandleT *h);
    +.endcode
    +
    +.table
    +  Function              What it does
    +  --------              ------------
    +  shellSaverSet         Choose the saver (a file in SAVERS is kept by its name there, platformKindNameOf; any other path as it is; "" or NULL: none) and the wait, clamped to the range; at once
    +  shellSaverName / Wait The settings in force
    +  shellSaverStart       Start the saver now (Control Panel's Test); only recorded, the next shellSaverStep starts it.  False with none chosen or one running
    +  shellSaverRunning     True while a saver runs
    +  shellSaverStep        The main loop's step (shell top level): starts the saver when due, ends a running one at input
    +  shellSaverFromPrefs   Read [desktop] saver and saverWait (start, Control Panel's Cancel)
    +  shellSaverToPrefs     Write them (Control Panel's OK and its Cancel snapshot)
    +.endtable
    +
    +BASIC: SysScreenSaver$, SysScreenSaverWait, SysSetScreenSaver, SysScreenSaverStart, SysScreenSaverRunning, SysScreenSaverStore, SysScreenSaverLoad, and SysIdleSeconds (system.bas; see BASIC System Settings).
    +
    +.h2 Writing a screen saver
    +
    +A C saver is a main-loop program that opens a window, takes the full screen with dvxExclusiveBegin, and draws (dvxExclusivePresent8, paced with gameFrameWait) until the window's onClose sets a flag; then it destroys the window and returns (src/savers/kpunch/starfld). A BASIC saver is a program with no form: SCREEN 13, FULLSCREEN ON, and a loop drawing with FRAMEWAIT (src/savers/kpunch/blank, marquee, mystify); closing its screen window ends it. Put the .app in SHARED\SAVERS (an add-on package's kind saver) or the user's SAVERS; the name Control Panel lists is the program's (its "name" resource, a BASIC project's Name).
    +
     .topic shell.assoc
     .title File Associations
     .toc 1 File Associations
    @@ -850,6 +935,8 @@ shellAssocSet adds or replaces an association (programPath may carry ,arguments)
     .toc 1 BASIC Entry Points
     .index ShellRun
     .index ShellRunProgram
    +.index ShellRunDos
    +.index ShellIsDosProgram
     .index ShellRunCommand
     .index ShellRunDialog
     .index ShellMinimizeApp
    @@ -914,12 +1001,13 @@ The shell's services for DVX BASIC programs -- everything Program Manager does,
     int32_t     ShellRun(const char *program, const char *args);
     int32_t     ShellRunProgram(const char *program, const char *args, const char *folder);
     int32_t     ShellRunCommand(const char *command);
    +void        ShellRunDos(const char *program, const char *args, const char *folder, int32_t pause);
     const char *ShellRunDialog(const char *history);
     void        ShellMinimizeApp(int32_t appId);
     int32_t     ShellOpenDocument(const char *path);
     .endcode
     
    -ShellRunProgram is shellRunProgram (a document opens through its association; file names in args, and a relative document, are looked for in folder; "" for none), returning the new app id or 0. ShellRun is ShellRunProgram without a folder, True when it started; the two are one launch path. ShellRunCommand is shellRunCommand (id or 0). ShellRunDialog splits history at line ends (CHR$(10), a CR before it is dropped, empty lines skipped) into the list shellRunDialog shows, and returns the command chosen or "" when cancelled. ShellMinimizeApp is shellMinimizeApp. ShellOpenDocument is shellAssocOpenDocument (it shows nothing when there is no association; ShellRunProgram does).
    +ShellRunProgram is shellRunProgram (a document opens through its association; file names in args, and a relative document, are looked for in folder; "" for none), returning the new app id, -1 (shell.bas SHELL_RUN_DOS) for a DOS program set to run, or 0. ShellRun is ShellRunProgram without a folder, True when it started or a DOS program will run; the two are one launch path. ShellRunCommand is shellRunCommand (id, -1, or 0). ShellRunDos is shellRunDos (pause True or False): the DOS program runs once the calling procedure has returned and the programs besides the desktop, the caller too, have closed. ShellRunDialog splits history at line ends (CHR$(10), a CR before it is dropped, empty lines skipped) into the list shellRunDialog shows, and returns the command chosen or "" when cancelled. ShellMinimizeApp is shellMinimizeApp. ShellOpenDocument is shellAssocOpenDocument (it shows nothing when there is no association; ShellRunProgram does).
     
     .h2 Installed programs
     
    @@ -931,9 +1019,10 @@ void        ShellProgramFree(int32_t list);
     const char *ShellProgramName(const char *path);
     const char *ShellProgramDescription(const char *path);
     int32_t     ShellIsProgram(const char *path);
    +int32_t     ShellIsDosProgram(const char *path);
     .endcode
     
    -ShellProgramScan is shellProgramScan of folder ("" for SHELL_PROG_DIR) without skipPath ("" for none), paths only (readInfo false), so it never opens a program; it returns a list handle, 0 when nothing was found. ShellProgramCount and ShellProgramPath read it (0 / "" for a bad list or index); ShellProgramFree frees it. ShellProgramName and ShellProgramDescription are shellProgramInfo's name and description, read one program at a time so a long list can be filled with DoEvents in between (the C progress callback). ShellIsProgram is shellIsProgram.
    +ShellProgramScan is shellProgramScan of folder ("" for SHELL_PROG_DIR) without skipPath ("" for none), paths only (readInfo false), so it never opens a program; it returns a list handle, 0 when nothing was found. ShellProgramCount and ShellProgramPath read it (0 / "" for a bad list or index); ShellProgramFree frees it. ShellProgramName and ShellProgramDescription are shellProgramInfo's name and description, read one program at a time so a long list can be filled with DoEvents in between (the C progress callback). ShellIsProgram is shellIsProgram, ShellIsDosProgram shellIsDosProgram.
     
     .h2 File types
     
    @@ -1100,13 +1189,17 @@ BASIC (shell.bas, DECLARE LIBRARY "dvxshell"): ShellGroupsLoad / Save / Free / S
     .index SysScreenshot
     .index SysMemoryTotalKb
     .index SysMemoryFreeKb
    +.index SysScreenSaver$
    +.index SysSetScreenSaver
    +.index SysScreenSaverStart
    +.index SysIdleSeconds
     .index SYS_VIDEO_DEFAULT_WIDTH
     
     .h1 BASIC System Settings
     
     Source: dvxshell/shellSysBas.c; BASIC declarations: src/include/basic/system.bas (DECLARE LIBRARY "dvxshell"); prototypes in shellApp.h.
     
    -What Control Panel changes, for DVX BASIC programs: the video mode, the system colors and themes, the wallpaper, text smoothing, and the mouse, each applied at once to shellContext(). Only BASIC-marshalable types; True (-1) / False (0); colors are BASIC RGB() values (&HBBGGRR, converted with wgtBasicColor); lists are 0-based. Arguments named prefs are prefs.bas handles, resolved with prefsBasHandle (dvxPrefs.h); 0 or a closed handle makes a Store or Load do nothing (a Load returns False).
    +What Control Panel changes, for DVX BASIC programs: the video mode, the system colors and themes, the wallpaper, the screen saver, text smoothing, and the mouse, each applied at once to shellContext(). Only BASIC-marshalable types; True (-1) / False (0); colors are BASIC RGB() values (&HBBGGRR, converted with wgtBasicColor); lists are 0-based. Arguments named prefs are prefs.bas handles, resolved with prefsBasHandle (dvxPrefs.h); 0 or a closed handle makes a Store or Load do nothing (a Load returns False).
     
     .code
     int32_t     SysVideoModeCount(void);
    @@ -1131,6 +1224,14 @@ void        SysSetWallpaperMode(int32_t mode);
     int32_t     SysWallpaperMode(void);
     void        SysWallpaperStore(int32_t prefs);
     int32_t     SysWallpaperLoad(int32_t prefs);
    +const char *SysScreenSaver(void);
    +int32_t     SysScreenSaverWait(void);
    +void        SysSetScreenSaver(const char *fileName, int32_t minutes);
    +int32_t     SysScreenSaverStart(void);
    +int32_t     SysScreenSaverRunning(void);
    +void        SysScreenSaverStore(int32_t prefs);
    +void        SysScreenSaverLoad(int32_t prefs);
    +int32_t     SysIdleSeconds(void);
     void        SysSetTextSmoothing(int32_t on);
     int32_t     SysTextSmoothing(void);
     void        SysFontsStore(int32_t prefs);
    @@ -1174,6 +1275,12 @@ int32_t     SysMemoryFreeKb(void);
       SysSetWallpaperMode          dvxSetWallpaperMode with the value of prefsWallpaperChoices[mode]; other modes are ignored
       SysWallpaper / Mode          ctx->wallpaperPath; the prefsWallpaperChoices index of ctx->wallpaperMode
       SysWallpaperStore / Load     dvxWallpaperToPrefs / dvxWallpaperFromPrefs (Load: False when the image could not be loaded)
    +  SysScreenSaver / Wait        shellSaverName / shellSaverWait
    +  SysSetScreenSaver            shellSaverSet
    +  SysScreenSaverStart          shellSaverStart (True when it will start)
    +  SysScreenSaverRunning        shellSaverRunning
    +  SysScreenSaverStore / Load   shellSaverToPrefs / shellSaverFromPrefs
    +  SysIdleSeconds               dvxIdleMs / 1000
       SysSetTextSmoothing          dvxSetFontSmoothing (nonzero is on)
       SysTextSmoothing             ctx->fontSmoothing (the setting; text is smoothed only above 8 bits per pixel)
       SysFontsStore / Load         dvxFontsToPrefs / dvxFontsFromPrefs
    diff --git a/src/libs/kpunch/dvxshell/pmModel.c b/src/libs/kpunch/dvxshell/pmModel.c
    index 201780e5..cf6d870b 100644
    --- a/src/libs/kpunch/dvxshell/pmModel.c
    +++ b/src/libs/kpunch/dvxshell/pmModel.c
    @@ -61,6 +61,7 @@
     #define INI_ITEM_ICON     PM_FIELD_ICON
     #define INI_ITEM_KEY      PM_FIELD_KEY
     #define INI_ITEM_MIN      PM_FIELD_MINIMIZED
    +#define INI_ITEM_PAUSE    PM_FIELD_PAUSE
     #define INI_ITEM_COL      PM_FIELD_COL
     #define INI_ITEM_ROW      PM_FIELD_ROW
     #define INI_KEY_MAX       32        // "item256.minimized" plus room
    @@ -224,6 +225,8 @@ static void loadItem(PmModelT *m, int32_t g, PrefsHandleT *h, const char *sectio
         itemPathsNative(&item);
         itemKey(key, sizeof(key), n, INI_ITEM_MIN);
         item.runMinimized = prefsGetBool(h, section, key, false);
    +    itemKey(key, sizeof(key), n, INI_ITEM_PAUSE);
    +    item.pause = prefsGetBool(h, section, key, false);
         itemKey(key, sizeof(key), n, INI_ITEM_COL);
         item.col = prefsGetInt(h, section, key, PM_CELL_NONE);
         itemKey(key, sizeof(key), n, INI_ITEM_ROW);
    @@ -529,6 +532,8 @@ bool pmItemGetField(const PmItemT *item, const char *field, char *out, int32_t o
             dvxAccelFormat(item->hotkeyKey, item->hotkeyMods, out, outSize);
         } else if (strcasecmp(field, INI_ITEM_MIN) == 0) {
             snprintf(out, (size_t)outSize, "%d", item->runMinimized ? 1 : 0);
    +    } else if (strcasecmp(field, INI_ITEM_PAUSE) == 0) {
    +        snprintf(out, (size_t)outSize, "%d", item->pause ? 1 : 0);
         } else if (strcasecmp(field, INI_ITEM_COL) == 0) {
             snprintf(out, (size_t)outSize, "%" PRId32, item->col);
         } else if (strcasecmp(field, INI_ITEM_ROW) == 0) {
    @@ -642,6 +647,8 @@ bool pmItemSetField(PmItemT *item, const char *field, const char *value) {
     
         if (strcasecmp(field, INI_ITEM_MIN) == 0) {
             item->runMinimized = number != 0;
    +    } else if (strcasecmp(field, INI_ITEM_PAUSE) == 0) {
    +        item->pause = number != 0;
         } else if (strcasecmp(field, INI_ITEM_COL) == 0) {
             item->col = number < 0 ? PM_CELL_NONE : number;
         } else if (strcasecmp(field, INI_ITEM_ROW) == 0) {
    @@ -869,6 +876,11 @@ static void saveGroup(PrefsHandleT *h, const PmGroupT *g, int32_t n) {
                 prefsSetBool(h, section, key, true);
             }
     
    +        if (item->pause) {
    +            itemKey(key, sizeof(key), k, INI_ITEM_PAUSE);
    +            prefsSetBool(h, section, key, true);
    +        }
    +
             if (item->col >= 0) {
                 itemKey(key, sizeof(key), k, INI_ITEM_COL);
                 prefsSetInt(h, section, key, item->col);
    diff --git a/src/libs/kpunch/dvxshell/pmModel.h b/src/libs/kpunch/dvxshell/pmModel.h
    index 89e8f14e..1d09142a 100644
    --- a/src/libs/kpunch/dvxshell/pmModel.h
    +++ b/src/libs/kpunch/dvxshell/pmModel.h
    @@ -35,7 +35,7 @@
     //   [groups]   count
     //   [groupN]   name, count, itemM.name, itemM.program, itemM.args,
     //              itemM.folder, itemM.icon, itemM.key, itemM.minimized,
    -//              itemM.col, itemM.row   (N and M count from 1)
    +//              itemM.pause, itemM.col, itemM.row   (N and M count from 1)
     //   [run]      historyN (newest first)
     //
     // Items sit in grid cells (col, row).  With Auto Arrange the cells follow
    @@ -70,6 +70,7 @@
     #define PM_FIELD_ICON        "icon"
     #define PM_FIELD_KEY         "key"
     #define PM_FIELD_MINIMIZED   "minimized"
    +#define PM_FIELD_PAUSE       "pause"
     #define PM_FIELD_COL         "col"
     #define PM_FIELD_ROW         "row"
     #define PM_FIELD_DESCRIPTION "description"
    @@ -98,12 +99,13 @@ typedef struct {
         int32_t  col;
         int32_t  row;
         char     name[PM_NAME_MAX];
    -    char     program[DVX_MAX_PATH];       // .app, or a document opened by association
    +    char     program[DVX_MAX_PATH];       // .app, a DOS program, or a document opened by association
         char     args[PM_ARGS_MAX];
    -    char     folder[DVX_MAX_PATH];        // working folder for relative file arguments
    +    char     folder[DVX_MAX_PATH];        // working folder for relative file arguments (a DOS program runs in it)
         char     iconPath[DVX_MAX_PATH];      // empty = the program's own icon
         char     description[SHELL_PROG_DESC_MAX];    // tooltip (Installed Programs: from the program)
         bool     runMinimized;
    +    bool     pause;                       // a DOS program: wait for a key when it ends
         bool     iconTried;
     } PmItemT;
     
    diff --git a/src/libs/kpunch/dvxshell/shellApp.c b/src/libs/kpunch/dvxshell/shellApp.c
    index b0557ed8..f3150b0a 100644
    --- a/src/libs/kpunch/dvxshell/shellApp.c
    +++ b/src/libs/kpunch/dvxshell/shellApp.c
    @@ -74,6 +74,7 @@ void (*shellCtrlEscFn)(AppContextT *ctx) = NULL;
     
     // Library cleanup hooks run for every ending app (stb_ds array).
     static ShellAppCleanupFnT *sCleanupFns = NULL;
    +static ShellAppCleanupFnT *sKillFns    = NULL;
     
     // ============================================================
     // Prototypes
    @@ -99,7 +100,9 @@ static void        forceKillNow(AppContextT *ctx, ShellAppT *app);
     static void        killAppTask(ShellAppT *app);
     static bool        makeTempPath(const char *origPath, int32_t id, char *out, int32_t outSize);
     static int32_t     refuseSecondCopy(AppContextT *ctx, const char *path);
    +static void        registerHook(ShellAppCleanupFnT **hooks, ShellAppCleanupFnT fn);
     static void        releaseAppResources(ShellAppT *app);
    +static void        runKillFns(const ShellAppT *app);
     bool               shellActivateApp(AppContextT *ctx, int32_t appId);
     void               shellAppCallEnter(ShellAppT *app);
     void               shellAppCallLeave(ShellAppT *app);
    @@ -123,11 +126,14 @@ void               shellOpenTaskManager(AppContextT *ctx);
     void               shellReapApp(AppContextT *ctx, ShellAppT *app);
     bool               shellReapApps(AppContextT *ctx);
     void               shellRegisterAppCleanup(ShellAppCleanupFnT fn);
    +void               shellRegisterAppKill(ShellAppCleanupFnT fn);
     int32_t            shellRunningAppCount(void);
     int32_t            shellRunningAppId(int32_t index);
     void               shellTerminateAllApps(AppContextT *ctx);
     void               shellUnregisterAppCleanup(ShellAppCleanupFnT fn);
    +void               shellUnregisterAppKill(ShellAppCleanupFnT fn);
     void               shellUserPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize);
    +static void        unregisterHook(ShellAppCleanupFnT **hooks, ShellAppCleanupFnT fn);
     
     
     // Find the first free slot, starting at 1 (slot 0 is the shell).
    @@ -535,6 +541,18 @@ static int32_t refuseSecondCopy(AppContextT *ctx, const char *path) {
     }
     
     
    +// Add fn to a hook list once.
    +static void registerHook(ShellAppCleanupFnT **hooks, ShellAppCleanupFnT fn) {
    +    for (int32_t i = 0; i < arrlen(*hooks); i++) {
    +        if ((*hooks)[i] == fn) {
    +            return;
    +        }
    +    }
    +
    +    arrput(*hooks, fn);
    +}
    +
    +
     // Shared teardown tail for both reap and force-kill: release the widget
     // classes, system-wide hotkeys and timer-service timers the app registered
     // and its hung-callback bookkeeping (a force kill never
    @@ -569,6 +587,20 @@ static void releaseAppResources(ShellAppT *app) {
     }
     
     
    +// The libraries' kill hooks for an app ended by force whose windows are
    +// still there: not one that faulted (its state cannot be trusted), nor
    +// one whose kill waited for its code to return (its windows went then).
    +static void runKillFns(const ShellAppT *app) {
    +    if (app->faulted || app->killPending) {
    +        return;
    +    }
    +
    +    for (int32_t i = 0; i < arrlen(sKillFns); i++) {
    +        sKillFns[i](app->appId);
    +    }
    +}
    +
    +
     // The app's topmost visible window (appTopWindow) is restored if
     // minimized, else raised and focused.  False when it has none.
     bool shellActivateApp(AppContextT *ctx, int32_t appId) {
    @@ -677,6 +709,7 @@ void shellForceKillApp(AppContextT *ctx, ShellAppT *app) {
         }
     
         if (!appCodeMayBeLive(ctx, app)) {
    +        runKillFns(app);
             forceKillNow(ctx, app);
             return;
         }
    @@ -1093,13 +1126,12 @@ bool shellReapApps(AppContextT *ctx) {
     
     
     void shellRegisterAppCleanup(ShellAppCleanupFnT fn) {
    -    for (int32_t i = 0; i < arrlen(sCleanupFns); i++) {
    -        if (sCleanupFns[i] == fn) {
    -            return;
    -        }
    -    }
    +    registerHook(&sCleanupFns, fn);
    +}
     
    -    arrput(sCleanupFns, fn);
    +
    +void shellRegisterAppKill(ShellAppCleanupFnT fn) {
    +    registerHook(&sKillFns, fn);
     }
     
     
    @@ -1137,6 +1169,7 @@ int32_t shellRunningAppId(int32_t index) {
     void shellTerminateAllApps(AppContextT *ctx) {
         for (int32_t i = 1; i < arrlen(sApps); i++) {
             if (sApps[i]->state != AppStateFreeE) {
    +            runKillFns(sApps[i]);
                 forceKillNow(ctx, sApps[i]);
             }
         }
    @@ -1144,14 +1177,25 @@ void shellTerminateAllApps(AppContextT *ctx) {
     
     
     void shellUnregisterAppCleanup(ShellAppCleanupFnT fn) {
    -    for (int32_t i = arrlen(sCleanupFns) - 1; i >= 0; i--) {
    -        if (sCleanupFns[i] == fn) {
    -            arrdel(sCleanupFns, i);
    -        }
    -    }
    +    unregisterHook(&sCleanupFns, fn);
    +}
    +
    +
    +void shellUnregisterAppKill(ShellAppCleanupFnT fn) {
    +    unregisterHook(&sKillFns, fn);
     }
     
     
     void shellUserPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize) {
         platformPathJoin(outPath, outSize, ctx->userDir, filename);
     }
    +
    +
    +// Remove fn from a hook list (nothing when it is not there).
    +static void unregisterHook(ShellAppCleanupFnT **hooks, ShellAppCleanupFnT fn) {
    +    for (int32_t i = arrlen(*hooks) - 1; i >= 0; i--) {
    +        if ((*hooks)[i] == fn) {
    +            arrdel(*hooks, i);
    +        }
    +    }
    +}
    diff --git a/src/libs/kpunch/dvxshell/shellApp.h b/src/libs/kpunch/dvxshell/shellApp.h
    index e7b8d4cb..e1ed6f46 100644
    --- a/src/libs/kpunch/dvxshell/shellApp.h
    +++ b/src/libs/kpunch/dvxshell/shellApp.h
    @@ -155,6 +155,7 @@ typedef struct {
         DxeAppContextT *dxeCtx;     // heap-allocated; address stable across sApps realloc
         int32_t    *taskCodeDepth;  // stb_ds by task id: shell calls into this app in progress on that task's stack (see SHELL_APP_CALL)
         bool        killPending;    // force kill requested while its code was live; released by shellReapApps
    +    bool        faulted;        // it crashed: its kill runs no kill hooks (shellRegisterAppKill)
     } ShellAppT;
     
     // Bracket every direct shell call into an app's code (appMain of a
    @@ -402,6 +403,19 @@ void shellRegisterAppCleanup(ShellAppCleanupFnT fn);
     // Remove a cleanup hook (no-op if absent).
     void shellUnregisterAppCleanup(ShellAppCleanupFnT fn);
     
    +// Called with the app's id when it is ended by force (End Task, a screen
    +// saver that would not close, DVX exiting) while none of its code runs
    +// and its windows are still there, before they go: a library ends what it
    +// runs for the app as the app's own end would (the BASIC runtime frees
    +// the program's VM, forms, and screen this way).  Not for an app that
    +// faulted, nor for one whose kill had to wait for its code to return (its
    +// windows went then).  For library code that stays loaded; idempotent per
    +// function.
    +void shellRegisterAppKill(ShellAppCleanupFnT fn);
    +
    +// Remove a kill hook (no-op if absent).
    +void shellUnregisterAppKill(ShellAppCleanupFnT fn);
    +
     // ============================================================
     // Ctrl+Esc handler (set by taskmgr library)
     // ============================================================
    @@ -606,6 +620,7 @@ void        SysFontsLoad(int32_t prefs);
     void        SysFontsStore(int32_t prefs);
     int32_t     SysFontWidth(void);
     int32_t     SysGetColor(int32_t id);
    +int32_t     SysIdleSeconds(void);
     int32_t     SysLoadTheme(const char *path);
     int32_t     SysMemoryFreeKb(void);
     int32_t     SysMemoryTotalKb(void);
    @@ -621,11 +636,18 @@ int32_t     SysMouseX(void);
     int32_t     SysMouseY(void);
     void        SysResetColors(void);
     int32_t     SysSaveTheme(const char *path);
    +const char *SysScreenSaver(void);
    +void        SysScreenSaverLoad(int32_t prefs);
    +int32_t     SysScreenSaverRunning(void);
    +int32_t     SysScreenSaverStart(void);
    +void        SysScreenSaverStore(int32_t prefs);
    +int32_t     SysScreenSaverWait(void);
     int32_t     SysScreenshot(const char *path);
     int32_t     SysSendKey(int32_t keyCode, int32_t shift);
     void        SysSetColor(int32_t id, int32_t rgb);
     void        SysSetMouse(int32_t wheelDir, int32_t dblClickMs, int32_t accel, int32_t mickeys, int32_t wheelStep);
     void        SysSetMousePos(int32_t x, int32_t y);
    +void        SysSetScreenSaver(const char *fileName, int32_t minutes);
     void        SysSetTextSmoothing(int32_t on);
     int32_t     SysSetVideoMode(int32_t width, int32_t height, int32_t bpp);
     int32_t     SysSetWallpaper(const char *path);
    diff --git a/src/libs/kpunch/dvxshell/shellBas.c b/src/libs/kpunch/dvxshell/shellBas.c
    index d311f7e3..c681d051 100644
    --- a/src/libs/kpunch/dvxshell/shellBas.c
    +++ b/src/libs/kpunch/dvxshell/shellBas.c
    @@ -51,6 +51,7 @@
     #define ACCEL_TEXT_MAX      32
     #define ARG_NONE            (-1)    // argWalk: only count
     #define PRIORITY_NONE       (-1)    // ShellPriority without a task of its own
    +#define BAS_RUN_DOS         (-1)    // shell.bas SHELL_RUN_DOS: a DOS program will run
     
     // A program list from ShellProgramScan and the program it belongs to.
     typedef struct {
    @@ -88,6 +89,7 @@ static int32_t          sLastHotkeyId  = 0;
     
     static void             appEnded(int32_t appId);
     static int32_t          argWalk(const char *command, int32_t index, char *out, int32_t outSize);
    +static int32_t          basRunResult(int32_t id);
     static const ShellAppT *currentApp(void);
     static int32_t          currentAppId(void);
     int32_t                 HotkeyAdd(const char *keyText);
    @@ -122,6 +124,7 @@ const char             *ShellCommandArgs(const char *command);
     const char             *ShellCommandProgram(const char *command);
     void                    ShellExitDvx(void);
     int32_t                 ShellInstanceCount(void);
    +int32_t                 ShellIsDosProgram(const char *path);
     int32_t                 ShellIsProgram(const char *path);
     const char             *ShellItemIcon(const char *iconPath, const char *path);
     void                    ShellLog(const char *text);
    @@ -138,6 +141,7 @@ const char             *ShellProgramsFolder(void);
     int32_t                 ShellRun(const char *program, const char *args);
     int32_t                 ShellRunCommand(const char *command);
     const char             *ShellRunDialog(const char *history);
    +void                    ShellRunDos(const char *program, const char *args, const char *folder, int32_t pause);
     int32_t                 ShellRunningActivate(int32_t appId);
     int32_t                 ShellRunningClose(int32_t appId);
     int32_t                 ShellRunningCount(void);
    @@ -196,6 +200,16 @@ static int32_t argWalk(const char *command, int32_t index, char *out, int32_t ou
     
     
     // The app whose code is running, or NULL.
    +// shellRunProgram's result as BASIC has it: the id, BAS_RUN_DOS, or 0.
    +static int32_t basRunResult(int32_t id) {
    +    if (id == SHELL_RUN_DOS) {
    +        return BAS_RUN_DOS;
    +    }
    +
    +    return id > 0 ? id : 0;
    +}
    +
    +
     static const ShellAppT *currentApp(void) {
         return shellGetApp(currentAppId());
     }
    @@ -579,6 +593,11 @@ int32_t ShellInstanceCount(void) {
     }
     
     
    +int32_t ShellIsDosProgram(const char *path) {
    +    return shellIsDosProgram(path) ? BAS_TRUE : BAS_FALSE;
    +}
    +
    +
     int32_t ShellIsProgram(const char *path) {
         return shellIsProgram(path) ? BAS_TRUE : BAS_FALSE;
     }
    @@ -710,16 +729,14 @@ const char *ShellProgramsFolder(void) {
     
     
     // Start a program with arguments ("" for none), the way ShellRunProgram
    -// does.  True when it started.
    +// does.  True when it started, or a DOS program will run.
     int32_t ShellRun(const char *program, const char *args) {
    -    return ShellRunProgram(program, args, "") > 0 ? BAS_TRUE : BAS_FALSE;
    +    return ShellRunProgram(program, args, "") != 0 ? BAS_TRUE : BAS_FALSE;
     }
     
     
     int32_t ShellRunCommand(const char *command) {
    -    int32_t id = shellRunCommand(shellContext(), command);
    -
    -    return id > 0 ? id : 0;
    +    return basRunResult(shellRunCommand(shellContext(), command));
     }
     
     
    @@ -761,6 +778,13 @@ const char *ShellRunDialog(const char *history) {
     }
     
     
    +// The program runs once the calling procedure has returned and the other
    +// programs have closed (shellRunDos).
    +void ShellRunDos(const char *program, const char *args, const char *folder, int32_t pause) {
    +    shellRunDos(program, args, folder, pause != BAS_FALSE);
    +}
    +
    +
     // Bring a program to the front (Task Manager's Switch To).  True when it
     // has a window to show.
     int32_t ShellRunningActivate(int32_t appId) {
    @@ -839,9 +863,7 @@ const char *ShellRunningPath(int32_t appId) {
     // args found in folder ("" for none).  The new program's id, or 0 (DVX
     // has said why).
     int32_t ShellRunProgram(const char *program, const char *args, const char *folder) {
    -    int32_t id = shellRunProgram(shellContext(), program, args, folder);
    -
    -    return id > 0 ? id : 0;
    +    return basRunResult(shellRunProgram(shellContext(), program, args, folder));
     }
     
     
    diff --git a/src/libs/kpunch/dvxshell/shellMain.c b/src/libs/kpunch/dvxshell/shellMain.c
    index 41e08125..a2d6d3da 100644
    --- a/src/libs/kpunch/dvxshell/shellMain.c
    +++ b/src/libs/kpunch/dvxshell/shellMain.c
    @@ -67,6 +67,7 @@
     #include "shellApp.h"
     #include "shellInf.h"
     #include "shellRun.h"
    +#include "shellSaver.h"
     #include "dvxDlg.h"
     #include "dvxDraw.h"
     #include "dvxVideo.h"
    @@ -831,6 +832,12 @@ int shellMain(int argc, char *argv[]) {
             } else if (sCtx.wallpaperPath[0]) {
                 dvxLog("Preferences: loaded wallpaper %s (%s)", sCtx.wallpaperPath, prefsWallpaperChoices[sCtx.wallpaperMode].iniName);
             }
    +
    +        shellSaverFromPrefs(sPrefs);
    +
    +        if (shellSaverName()[0]) {
    +            dvxLog("Preferences: screen saver %s after %ld minutes", shellSaverName(), (long)shellSaverWait());
    +        }
         }
     
         // Safe mode is explained (and can be kept) before anything else opens.
    @@ -928,6 +935,7 @@ int shellMain(int argc, char *argv[]) {
             if (app) {
                 char msg[SHELL_MSG_MAX];
                 snprintf(msg, sizeof(msg), "'%s' has caused a fault and will be terminated.", app->name);
    +            app->faulted = true;
                 shellForceKillApp(&sCtx, app);
                 sCtx.currentAppId = 0;
     
    @@ -951,6 +959,8 @@ int shellMain(int argc, char *argv[]) {
             shellFrame(&sCtx);
             shellRunCloseRequests(&sCtx);
             shellRunExitRequest(&sCtx, sDesktopId);
    +        shellRunDosRequest(&sCtx, sDesktopId);
    +        shellSaverStep(&sCtx);
     
             // Without a desktop DVX ends with the last program.
             if (sDesktopId == 0 && shellRunningAppCount() == 0) {
    diff --git a/src/libs/kpunch/dvxshell/shellProg.c b/src/libs/kpunch/dvxshell/shellProg.c
    index 052cbcc8..ed88f456 100644
    --- a/src/libs/kpunch/dvxshell/shellProg.c
    +++ b/src/libs/kpunch/dvxshell/shellProg.c
    @@ -44,6 +44,7 @@
     
     static uint8_t *itemIconPick(AppContextT *ctx, const char *iconPath, const char *path, int32_t edge, int32_t *outPitch, char *chosen, int32_t chosenSize);
     static void     scanDir(const char *dir, const char *skipPath, bool readInfo, ShellProgressFnT progress, void *progressCtx, ShellProgramT **list);
    +bool            shellIsDosProgram(const char *path);
     bool            shellIsProgram(const char *path);
     uint8_t        *shellItemIcon(AppContextT *ctx, const char *iconPath, const char *path, int32_t edge, int32_t *outPitch);
     bool            shellItemIconFile(AppContextT *ctx, const char *iconPath, const char *path, char *out, int32_t outSize);
    @@ -54,8 +55,8 @@ int32_t         shellProgramScan(const char *dir, const char *skipPath, bool rea
     
     
     // The item icon rule: the picture file iconPath (NULL or "" for none),
    -// else path's own picture, else for a document the icon of the program
    -// its file type opens in.  chosen (chosenSize bytes, may be NULL) gets
    +// else path's own picture, else for a DOS program SHELL_DOS_ICON, else
    +// for a document the icon of the program its file type opens in.  chosen (chosenSize bytes, may be NULL) gets
     // the file the icon came from ("" for none).
     static uint8_t *itemIconPick(AppContextT *ctx, const char *iconPath, const char *path, int32_t edge, int32_t *outPitch, char *chosen, int32_t chosenSize) {
         char        opener[DVX_MAX_PATH];
    @@ -68,6 +69,11 @@ static uint8_t *itemIconPick(AppContextT *ctx, const char *iconPath, const char
             icon = shellProgramIcon(ctx, from, edge, outPitch);
         }
     
    +    if (!icon && shellIsDosProgram(path)) {
    +        from = SHELL_DOS_ICON;
    +        icon = shellProgramIcon(ctx, from, edge, outPitch);
    +    }
    +
         if (!icon && path && !shellIsProgram(path) && shellAssocCommand(path, opener, sizeof(opener), args, sizeof(args))) {
             from = opener;
             icon = shellProgramIcon(ctx, from, edge, outPitch);
    @@ -127,6 +133,20 @@ static void scanDir(const char *dir, const char *skipPath, bool readInfo, ShellP
     }
     
     
    +// SHELL_DOS_FILTER lists the same extensions.
    +bool shellIsDosProgram(const char *path) {
    +    static const char *const exts[] = { ".exe", ".com", ".bat" };
    +
    +    for (int32_t i = 0; path && i < (int32_t)DVX_ARRAY_LEN(exts); i++) {
    +        if (dvxHasExt(path, exts[i])) {
    +            return true;
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
     bool shellIsProgram(const char *path) {
         return path && dvxHasExt(path, SHELL_PROG_EXT);
     }
    diff --git a/src/libs/kpunch/dvxshell/shellProg.h b/src/libs/kpunch/dvxshell/shellProg.h
    index 1346c023..6ac284d6 100644
    --- a/src/libs/kpunch/dvxshell/shellProg.h
    +++ b/src/libs/kpunch/dvxshell/shellProg.h
    @@ -41,6 +41,14 @@
     // File extension of a program.
     #define SHELL_PROG_EXT        ".app"
     
    +// The file dialogs' filter for DOS programs (the extensions
    +// shellIsDosProgram takes)
    +#define SHELL_DOS_FILTER      "DOS programs (*.exe;*.com;*.bat)"
    +
    +// The icon of a DOS program with no picture of its own (an item's icon
    +// file can still name another)
    +#define SHELL_DOS_ICON        DVX_SYSTEM_DIR DVX_PATH_SEP "DOSPROG.BMP"
    +
     // ShellProgramT.description capacity.
     #define SHELL_PROG_DESC_MAX   128
     
    @@ -61,6 +69,10 @@ typedef void (*ShellProgressFnT)(void *ctx);
     // True when path names a program (.app extension, any case).
     bool     shellIsProgram(const char *path);
     
    +// True when path names a DOS program: .EXE, .COM, or .BAT, any case
    +// (shellRunDos runs one with DVX stepped aside).
    +bool     shellIsDosProgram(const char *path);
    +
     // The icon an item for path shows, edge x edge pixels in display format:
     // the picture file iconPath (NULL or "" for none), else path's own
     // picture (a program's icon32, or path itself when it is a picture
    diff --git a/src/libs/kpunch/dvxshell/shellRun.c b/src/libs/kpunch/dvxshell/shellRun.c
    index 8215d3ee..2afca658 100644
    --- a/src/libs/kpunch/dvxshell/shellRun.c
    +++ b/src/libs/kpunch/dvxshell/shellRun.c
    @@ -34,6 +34,7 @@
     #include "comboBox/comboBox.h"
     #include "label/label.h"
     
    +#include 
     #include 
     #include 
     #include 
    @@ -43,6 +44,11 @@
     #define RUN_TITLE      "Run"
     #define RUN_DLG_W      320
     #define RUN_ROW_GAP    4
    +#define RUN_DOS_TITLE  "Run DOS Program"
    +#define RUN_DOS_ASK    "Running %s closes these programs first:\n%s\n\nEach one asks about unsaved work as it closes.  Continue?"
    +#define RUN_DOS_LINE   "\n    %s"
    +#define RUN_DOS_FAILED "DVX could not run %s.\n\n%s"
    +#define RUN_NOT_FOUND  "Cannot find %s."
     
     typedef struct {
         WidgetT *command;
    @@ -56,11 +62,21 @@ typedef struct {
         int32_t framesLeft;
     } RunMinimizeT;
     
    +// A DOS program waiting for the main loop (shellRunDos)
    +typedef struct {
    +    bool pending;
    +    bool pause;
    +    char program[DVX_MAX_PATH];
    +    char args[SHELL_ARGS_MAX];
    +    char folder[DVX_MAX_PATH];
    +} RunDosT;
    +
     // ============================================================
     // Module state
     // ============================================================
     
     static RunMinimizeT *sMinimize = NULL;   // stb_ds
    +static RunDosT       sDosRun;
     
     // ============================================================
     // Prototypes
    @@ -76,6 +92,8 @@ void               shellMinimizeIdle(AppContextT *ctx);
     void               shellResolveArgs(const char *folder, const char *args, char *out, int32_t outSize);
     int32_t            shellRunCommand(AppContextT *ctx, const char *command);
     bool               shellRunDialog(AppContextT *ctx, const char *const *history, int32_t historyCount, char *outCommand, int32_t outSize);
    +void               shellRunDos(const char *program, const char *args, const char *folder, bool pause);
    +void               shellRunDosRequest(AppContextT *ctx, int32_t keepAppId);
     int32_t            shellRunProgram(AppContextT *ctx, const char *program, const char *args, const char *folder);
     bool               shellSplitCommand(const char *command, char *program, int32_t programSize, char *args, int32_t argsSize);
     
    @@ -121,6 +139,7 @@ static void runDlgBrowse(WidgetT *w) {
         static const char quoteFmt[] = "\"%s\"";
         FileFilterT       filters[] = {
             { "Programs (*.app)" },
    +        { SHELL_DOS_FILTER },
             { "All Files (*.*)" }
         };
         char              path[DVX_MAX_PATH];
    @@ -249,8 +268,83 @@ bool shellRunDialog(AppContextT *ctx, const char *const *history, int32_t histor
     }
     
     
    -// A document (anything but a .app) must exist; its associated program
    -// gets it through the association's argument template.
    +// Only recorded (closing the other programs pumps the event loop, which
    +// is not safe from the caller's event handler); a request made while one
    +// waits replaces it.
    +void shellRunDos(const char *program, const char *args, const char *folder, bool pause) {
    +    if (!program || !program[0]) {
    +        return;
    +    }
    +
    +    snprintf(sDosRun.program, sizeof(sDosRun.program), "%s", program);
    +    snprintf(sDosRun.args, sizeof(sDosRun.args), "%s", args ? args : "");
    +    snprintf(sDosRun.folder, sizeof(sDosRun.folder), "%s", folder ? folder : "");
    +    sDosRun.pause   = pause;
    +    sDosRun.pending = true;
    +}
    +
    +
    +// A program named with a folder must exist (a bare name is looked for on
    +// the PATH by the command interpreter).  The programs running besides the
    +// desktop are named and asked to close first; No, or one that keeps its
    +// window, cancels the run.
    +void shellRunDosRequest(AppContextT *ctx, int32_t keepAppId) {
    +    char        names[SHELL_MSG_MAX];
    +    char        msg[SHELL_MSG_MAX];
    +    int32_t     pos   = 0;
    +    int32_t     count = 0;
    +    struct stat st;
    +
    +    if (!sDosRun.pending) {
    +        return;
    +    }
    +
    +    RunDosT     run  = sDosRun;
    +    const char *base = platformPathBaseName(run.program);
    +
    +    sDosRun.pending = false;
    +    names[0]        = '\0';
    +
    +    if (base != run.program && stat(run.program, &st) != 0) {
    +        snprintf(msg, sizeof(msg), RUN_NOT_FOUND, run.program);
    +        dvxErrorBox(ctx, RUN_DOS_TITLE, msg);
    +        return;
    +    }
    +
    +    for (int32_t i = 0; i < shellRunningAppCount(); i++) {
    +        int32_t          id  = shellRunningAppId(i);
    +        const ShellAppT *app = shellGetApp(id);
    +        ShellProgramT    info;
    +
    +        if (id == keepAppId || !app) {
    +            continue;
    +        }
    +
    +        shellProgramInfo(app->path, &info);
    +        pos = dvxStrAppendf(names, sizeof(names), pos, RUN_DOS_LINE, info.name[0] ? info.name : app->name);
    +        count++;
    +    }
    +
    +    if (count > 0) {
    +        snprintf(msg, sizeof(msg), RUN_DOS_ASK, base, names);
    +
    +        if (dvxMessageBox(ctx, RUN_DOS_TITLE, msg, MB_YESNO | MB_ICONQUESTION) != ID_YES || !shellCloseAllApps(ctx, keepAppId)) {
    +            dvxLog("Shell: %s not run: programs kept running", run.program);
    +            return;
    +        }
    +    }
    +
    +    if (dvxRunDos(ctx, run.program, run.args, run.folder, run.pause) < 0) {
    +        snprintf(msg, sizeof(msg), RUN_DOS_FAILED, base, strerror(errno));
    +        dvxErrorBox(ctx, RUN_DOS_TITLE, msg);
    +    }
    +}
    +
    +
    +// A document (anything but a .app or a DOS program) must exist; its
    +// associated program gets it through the association's argument template.
    +// A DOS program, or a document whose associated program is one, is run
    +// with DVX stepped aside once the main loop gets to it (shellRunDos).
     int32_t shellRunProgram(AppContextT *ctx, const char *program, const char *args, const char *folder) {
         char msg[SHELL_MSG_MAX];
         char target[DVX_MAX_PATH];
    @@ -270,6 +364,11 @@ int32_t shellRunProgram(AppContextT *ctx, const char *program, const char *args,
         struct stat st;
         const char *doc = fileInFolder(folder, program, target, sizeof(target)) ? target : program;
     
    +    if (shellIsDosProgram(program)) {
    +        shellRunDos(doc, args, folder, false);
    +        return SHELL_RUN_DOS;
    +    }
    +
         if (stat(doc, &st) != 0 || S_ISDIR(st.st_mode)) {
             snprintf(msg, sizeof(msg), "Cannot find %s.", doc);
             dvxErrorBox(ctx, RUN_TITLE, msg);
    @@ -282,6 +381,20 @@ int32_t shellRunProgram(AppContextT *ctx, const char *program, const char *args,
             return -1;
         }
     
    +    // A DOS program runs in its own folder and knows only short names: the
    +    // document goes to it by its full short name.
    +    if (shellIsDosProgram(docProgram)) {
    +        char full[DVX_MAX_PATH];
    +        char shortDoc[DVX_MAX_PATH];
    +
    +        if (platformPathAbsolute(doc, full, sizeof(full)) && platformShortPath(full, shortDoc, sizeof(shortDoc))) {
    +            shellAssocCommand(shortDoc, docProgram, sizeof(docProgram), docArgs, sizeof(docArgs));
    +        }
    +
    +        shellRunDos(docProgram, docArgs, NULL, false);
    +        return SHELL_RUN_DOS;
    +    }
    +
         return shellLoadAppWithArgs(ctx, docProgram, docArgs);
     }
     
    diff --git a/src/libs/kpunch/dvxshell/shellRun.h b/src/libs/kpunch/dvxshell/shellRun.h
    index 5fb900b8..3d567a2e 100644
    --- a/src/libs/kpunch/dvxshell/shellRun.h
    +++ b/src/libs/kpunch/dvxshell/shellRun.h
    @@ -67,12 +67,31 @@ int32_t shellRunCommand(AppContextT *ctx, const char *command);
     // history.
     bool    shellRunDialog(AppContextT *ctx, const char *const *history, int32_t historyCount, char *outCommand, int32_t outSize);
     
    +// What shellRunProgram returns when it set a DOS program to run: it has no
    +// application id.
    +#define SHELL_RUN_DOS 0
    +
     // Start program with args (file names in them resolved against folder,
     // see shellResolveArgs; folder may be NULL).  A program that is not a
    -// .app is opened as a document with its associated program.  Problems
    -// are shown to the user.  Returns the new application id, or -1.
    +// .app is opened as a document with its associated program; a DOS
    +// program (shellIsDosProgram), or a document whose associated program is
    +// one, is set to run with shellRunDos (folder is where it runs).
    +// Problems are shown to the user.  Returns the new application id,
    +// SHELL_RUN_DOS, or -1.
     int32_t shellRunProgram(AppContextT *ctx, const char *program, const char *args, const char *folder);
     
    +// Run a DOS program with DVX stepped aside (dvxRunDos: program, args,
    +// folder, and pause as it takes them).  Only recorded: the main loop
    +// carries it out at its top level (shellRunDosRequest), where the
    +// programs running besides the desktop are named and asked to close
    +// first (No, or one that keeps its window, cancels the run).  A request
    +// made while one waits replaces it.
    +void    shellRunDos(const char *program, const char *args, const char *folder, bool pause);
    +
    +// Main-loop step: carry out a waiting shellRunDos; keepAppId is the
    +// desktop, which stays.  Pumps the event loop: shell top level only.
    +void    shellRunDosRequest(AppContextT *ctx, int32_t keepAppId);
    +
     // Split a command line into the program and its arguments.  False when
     // there is no program.
     bool    shellSplitCommand(const char *command, char *program, int32_t programSize, char *args, int32_t argsSize);
    diff --git a/src/libs/kpunch/dvxshell/shellSaver.c b/src/libs/kpunch/dvxshell/shellSaver.c
    new file mode 100644
    index 00000000..7f5405d5
    --- /dev/null
    +++ b/src/libs/kpunch/dvxshell/shellSaver.c
    @@ -0,0 +1,231 @@
    +// 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.
    +
    +// shellSaver.c  -- The screen saver (see shellSaver.h)
    +
    +#include "shellSaver.h"
    +#include "shellApp.h"
    +#include "dvxPlat.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define SAVER_INI_NAME "saver"
    +#define SAVER_INI_WAIT "saverWait"
    +#define MS_PER_MINUTE  60000
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static uint32_t msSince(PlatformTicksT then);
    +static void     saverAsk(AppContextT *ctx);
    +static void     saverBegin(AppContextT *ctx);
    +static void     saverEnded(int32_t appId);
    +static void     saverWatch(AppContextT *ctx);
    +void            shellSaverFromPrefs(PrefsHandleT *h);
    +const char     *shellSaverName(void);
    +bool            shellSaverRunning(void);
    +void            shellSaverSet(const char *name, int32_t minutes);
    +bool            shellSaverStart(void);
    +void            shellSaverStep(AppContextT *ctx);
    +void            shellSaverToPrefs(PrefsHandleT *h);
    +int32_t         shellSaverWait(void);
    +
    +// ============================================================
    +// Module state
    +// ============================================================
    +
    +static char           sName[DVX_MAX_PATH];
    +static int32_t        sWait        = SHELL_SAVER_WAIT_DEFAULT;
    +static int32_t        sAppId       = 0;       // the saver running (0: none)
    +static PlatformTicksT sStarted     = 0;
    +static bool           sAsked       = false;   // its windows were asked to close
    +static bool           sEnding      = false;   // it did not close and is being ended
    +static PlatformTicksT sAskedAt     = 0;
    +static PlatformTicksT sEnded       = 0;       // the last start (or try): the wait counts from it too
    +static bool           sStartWanted = false;   // shellSaverStart
    +
    +
    +static uint32_t msSince(PlatformTicksT then) {
    +    return PLATFORM_TICKS_TO_MS(platformClock() - then);
    +}
    +
    +
    +// Close the saver's windows the way Alt+F4 would; the dispatch depth
    +// keeps the window list as it is while it is walked.
    +static void saverAsk(AppContextT *ctx) {
    +    sAsked   = true;
    +    sAskedAt = platformClock();
    +    ctx->dispatchDepth++;
    +
    +    for (int32_t i = 0; i < ctx->stack.count; i++) {
    +        WindowT *win = ctx->stack.windows[i];
    +
    +        if (win->appId == sAppId && !win->destroyPending) {
    +            dvxRequestWindowClose(ctx, win);
    +        }
    +    }
    +
    +    ctx->dispatchDepth--;
    +}
    +
    +
    +// A saver that is missing or does not start is tried again a wait later,
    +// without a message (nobody is there to read it).
    +static void saverBegin(AppContextT *ctx) {
    +    char        found[DVX_MAX_PATH];
    +    struct stat st;
    +
    +    sEnded = platformClock();
    +
    +    const char *path = platformKindFind(found, sizeof(found), DvxKindSaversE, sName) ? found : sName;
    +
    +    if (stat(path, &st) != 0) {
    +        dvxLog("Shell: screen saver %s not found", sName);
    +        return;
    +    }
    +
    +    shellRegisterAppCleanup(saverEnded);
    +
    +    int32_t appId = shellLoadAppWithArgs(ctx, path, SHELL_SAVER_ARGS);
    +
    +    if (appId < 1) {
    +        dvxLog("Shell: screen saver %s did not start", sName);
    +        return;
    +    }
    +
    +    sAppId   = appId;
    +    sStarted = platformClock();
    +    sAsked   = false;
    +    sEnding  = false;
    +    dvxLog("Shell: screen saver %s started", sName);
    +}
    +
    +
    +static void saverEnded(int32_t appId) {
    +    if (appId == sAppId) {
    +        dvxLog("Shell: screen saver %s ended", sName);
    +        sAppId = 0;
    +        sEnded = platformClock();
    +    }
    +}
    +
    +
    +// Input since the grace ended (dvxIdleMs no longer than the time past
    +// it) asks the saver to close; one still there SHELL_SAVER_END_MS later
    +// is ended (saverEnded, perhaps a frame or two later, says so).
    +static void saverWatch(AppContextT *ctx) {
    +    if (!sAsked) {
    +        uint32_t running = msSince(sStarted);
    +
    +        if (running >= SHELL_SAVER_GRACE_MS && dvxIdleMs(ctx) <= running - SHELL_SAVER_GRACE_MS) {
    +            saverAsk(ctx);
    +        }
    +
    +        return;
    +    }
    +
    +    if (sEnding || msSince(sAskedAt) < SHELL_SAVER_END_MS) {
    +        return;
    +    }
    +
    +    dvxLog("Shell: screen saver %s did not close; ending it", sName);
    +    sEnding = true;
    +    shellForceKillApp(ctx, shellGetApp(sAppId));
    +}
    +
    +
    +void shellSaverFromPrefs(PrefsHandleT *h) {
    +    shellSaverSet(prefsGetString(h, DVX_DESKTOP_SECTION, SAVER_INI_NAME, ""), prefsGetInt(h, DVX_DESKTOP_SECTION, SAVER_INI_WAIT, SHELL_SAVER_WAIT_DEFAULT));
    +}
    +
    +
    +const char *shellSaverName(void) {
    +    return sName;
    +}
    +
    +
    +bool shellSaverRunning(void) {
    +    return sAppId > 0;
    +}
    +
    +
    +// A file in a SAVERS folder is kept by its name there, so the setting
    +// still works when DVX or the user folder moves.
    +void shellSaverSet(const char *name, int32_t minutes) {
    +    char kindName[DVX_MAX_PATH];
    +
    +    if (!name) {
    +        name = "";
    +    }
    +
    +    snprintf(sName, sizeof(sName), "%s", platformKindNameOf(DvxKindSaversE, name, kindName, sizeof(kindName)) ? kindName : name);
    +
    +    if (minutes < SHELL_SAVER_WAIT_MIN) {
    +        minutes = SHELL_SAVER_WAIT_MIN;
    +    }
    +
    +    if (minutes > SHELL_SAVER_WAIT_MAX) {
    +        minutes = SHELL_SAVER_WAIT_MAX;
    +    }
    +
    +    sWait = minutes;
    +}
    +
    +
    +bool shellSaverStart(void) {
    +    sStartWanted = sName[0] && sAppId == 0;
    +    return sStartWanted;
    +}
    +
    +
    +// Not while a dialog box is open or a program has the full screen (a
    +// game played with a joystick gives no key or mouse input).  A start
    +// asked for then is dropped.
    +void shellSaverStep(AppContextT *ctx) {
    +    if (sAppId > 0) {
    +        saverWatch(ctx);
    +        return;
    +    }
    +
    +    uint32_t waitMs = (uint32_t)sWait * MS_PER_MINUTE;
    +    bool     due    = sStartWanted || (dvxIdleMs(ctx) >= waitMs && msSince(sEnded) >= waitMs);
    +
    +    sStartWanted = false;
    +
    +    if (due && sName[0] && !ctx->modalWindow && !ctx->exclusive.owner) {
    +        saverBegin(ctx);
    +    }
    +}
    +
    +
    +void shellSaverToPrefs(PrefsHandleT *h) {
    +    prefsSetString(h, DVX_DESKTOP_SECTION, SAVER_INI_NAME, sName);
    +    prefsSetInt(h, DVX_DESKTOP_SECTION, SAVER_INI_WAIT, sWait);
    +}
    +
    +
    +int32_t shellSaverWait(void) {
    +    return sWait;
    +}
    diff --git a/src/libs/kpunch/dvxshell/shellSaver.h b/src/libs/kpunch/dvxshell/shellSaver.h
    new file mode 100644
    index 00000000..82268ee1
    --- /dev/null
    +++ b/src/libs/kpunch/dvxshell/shellSaver.h
    @@ -0,0 +1,89 @@
    +// 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.
    +
    +// shellSaver.h  -- The screen saver
    +//
    +// A screen saver is a program (.app) in the SAVERS folders (DVX's in
    +// SHARED, the user's; DvxKindSaversE).  After the wait with no key or
    +// mouse input the shell starts the chosen one with SHELL_SAVER_ARGS; it
    +// takes the whole screen (full-screen mode) and draws until the next
    +// input, when the shell asks its windows to close.  Not while a dialog box
    +// is open or a program has the full screen.  The settings are DVX.INI
    +// [desktop] saver (its name in SAVERS, "" for none) and saverWait
    +// (minutes); Control Panel's Desktop page sets them.
    +#ifndef SHELL_SAVER_H
    +#define SHELL_SAVER_H
    +
    +#include "dvxApp.h"
    +#include "dvxPrefs.h"
    +
    +#include 
    +#include 
    +
    +// What a saver's command line is when the shell starts it
    +#define SHELL_SAVER_ARGS         "/S"
    +
    +// The wait, in minutes: its range and what it is with none set
    +#define SHELL_SAVER_WAIT_MIN     1
    +#define SHELL_SAVER_WAIT_MAX     60
    +#define SHELL_SAVER_WAIT_DEFAULT 10
    +
    +// Input this soon after a saver starts does not end it (the click on
    +// Test, the key that started it from a program), in milliseconds.
    +#define SHELL_SAVER_GRACE_MS     1000
    +
    +// A saver still running this long after it was asked to close is ended
    +// (milliseconds).
    +#define SHELL_SAVER_END_MS       3000
    +
    +// Read the settings from prefs (the shell at start, Control Panel's
    +// Cancel).
    +void        shellSaverFromPrefs(PrefsHandleT *h);
    +
    +// The saver's name in SAVERS ("" none).
    +const char *shellSaverName(void);
    +
    +// True while a saver runs.
    +bool        shellSaverRunning(void);
    +
    +// Choose the saver (its name in SAVERS, or any path; "" or NULL: none)
    +// and the wait in minutes (kept within SHELL_SAVER_WAIT_MIN and _MAX).
    +// Takes effect at once; shellSaverToPrefs keeps it.
    +void        shellSaverSet(const char *name, int32_t minutes);
    +
    +// Start the saver now, as if the wait had passed (Control Panel's Test).
    +// Only recorded: the next shellSaverStep starts it.  False when there is
    +// no saver chosen or one runs already.
    +bool        shellSaverStart(void);
    +
    +// Main-loop step: start the saver when it is time; end a running one at
    +// input after SHELL_SAVER_GRACE_MS (and force it after SHELL_SAVER_END_MS
    +// when it does not close).  Shell top level only.
    +void        shellSaverStep(AppContextT *ctx);
    +
    +// Write the settings into prefs (Control Panel's OK).
    +void        shellSaverToPrefs(PrefsHandleT *h);
    +
    +// The wait in minutes.
    +int32_t     shellSaverWait(void);
    +
    +#endif // SHELL_SAVER_H
    diff --git a/src/libs/kpunch/dvxshell/shellSysBas.c b/src/libs/kpunch/dvxshell/shellSysBas.c
    index 37e79993..517a779d 100644
    --- a/src/libs/kpunch/dvxshell/shellSysBas.c
    +++ b/src/libs/kpunch/dvxshell/shellSysBas.c
    @@ -24,8 +24,9 @@
     // (src/include/basic/system.bas)
     //
     // What Control Panel changes, for BASIC programs: the video mode, the
    -// system colors and themes, the wallpaper and the mouse; and the system
    -// font's glyphs (what DVX Demo's own text drawing reads).  Each change
    +// system colors and themes, the wallpaper, the screen saver, and the
    +// mouse; and the system font's glyphs (what DVX Demo's own text drawing
    +// reads).  Each change
     // takes effect at once for the whole desktop.  The *Store / *Load pairs
     // write and read the DVX.INI keys DVX starts with (dvxApp.h), through
     // a prefs.bas handle; a PrefsCreate handle filled with *Store is an
    @@ -38,6 +39,7 @@
     // returning one.
     
     #include "shellApp.h"
    +#include "shellSaver.h"
     #include "dvxApp.h"
     #include "dvxDlg.h"
     #include "dvxPrefs.h"
    @@ -52,6 +54,7 @@
     #include 
     
     #define MEMORY_UNKNOWN    (-1)
    +#define SYS_MS_PER_SEC    1000
     #define KEY_BYTE_MASK     0xFF    // the character / scan code byte of a key code
     #define SYS_SEND_KEY_MAX  64      // keys SysSendKey keeps waiting
     
    @@ -87,6 +90,7 @@ void                         SysFontsLoad(int32_t prefs);
     void                         SysFontsStore(int32_t prefs);
     int32_t                      SysFontWidth(void);
     int32_t                      SysGetColor(int32_t id);
    +int32_t                      SysIdleSeconds(void);
     int32_t                      SysLoadTheme(const char *path);
     int32_t                      SysMemoryFreeKb(void);
     int32_t                      SysMemoryTotalKb(void);
    @@ -102,11 +106,18 @@ int32_t                      SysMouseX(void);
     int32_t                      SysMouseY(void);
     void                         SysResetColors(void);
     int32_t                      SysSaveTheme(const char *path);
    +const char                  *SysScreenSaver(void);
    +void                         SysScreenSaverLoad(int32_t prefs);
    +int32_t                      SysScreenSaverRunning(void);
    +int32_t                      SysScreenSaverStart(void);
    +void                         SysScreenSaverStore(int32_t prefs);
    +int32_t                      SysScreenSaverWait(void);
     int32_t                      SysScreenshot(const char *path);
     int32_t                      SysSendKey(int32_t keyCode, int32_t shift);
     void                         SysSetColor(int32_t id, int32_t rgb);
     void                         SysSetMouse(int32_t wheelDir, int32_t dblClickMs, int32_t accel, int32_t mickeys, int32_t wheelStep);
     void                         SysSetMousePos(int32_t x, int32_t y);
    +void                         SysSetScreenSaver(const char *fileName, int32_t minutes);
     void                         SysSetTextSmoothing(int32_t on);
     int32_t                      SysSetVideoMode(int32_t width, int32_t height, int32_t bpp);
     int32_t                      SysSetWallpaper(const char *path);
    @@ -314,6 +325,12 @@ int32_t SysGetColor(int32_t id) {
     }
     
     
    +// Seconds since the last key or mouse input.
    +int32_t SysIdleSeconds(void) {
    +    return (int32_t)(dvxIdleMs(shellContext()) / SYS_MS_PER_SEC);
    +}
    +
    +
     // Apply a theme file.  True when it was read.
     int32_t SysLoadTheme(const char *path) {
         return dvxLoadTheme(shellContext(), path) ? BAS_TRUE : BAS_FALSE;
    @@ -411,6 +428,50 @@ int32_t SysSaveTheme(const char *path) {
     }
     
     
    +// The screen saver's file as SAVERS knows it ("" none).
    +const char *SysScreenSaver(void) {
    +    return shellSaverName();
    +}
    +
    +
    +// Use the [desktop] screen saver and wait of a prefs handle.
    +void SysScreenSaverLoad(int32_t prefs) {
    +    PrefsHandleT *h = prefsBasHandle(prefs);
    +
    +    if (h) {
    +        shellSaverFromPrefs(h);
    +    }
    +}
    +
    +
    +int32_t SysScreenSaverRunning(void) {
    +    return shellSaverRunning() ? BAS_TRUE : BAS_FALSE;
    +}
    +
    +
    +// Start the screen saver now (Control Panel's Test).  False with none
    +// chosen or one running.
    +int32_t SysScreenSaverStart(void) {
    +    return shellSaverStart() ? BAS_TRUE : BAS_FALSE;
    +}
    +
    +
    +// Store the screen saver and its wait in [desktop] of a prefs handle.
    +void SysScreenSaverStore(int32_t prefs) {
    +    PrefsHandleT *h = prefsBasHandle(prefs);
    +
    +    if (h) {
    +        shellSaverToPrefs(h);
    +    }
    +}
    +
    +
    +// The minutes with no input before the screen saver starts.
    +int32_t SysScreenSaverWait(void) {
    +    return shellSaverWait();
    +}
    +
    +
     // Save the whole screen as a picture file (BMP when path ends in .bmp,
     // else PNG).  True when it was written.
     int32_t SysScreenshot(const char *path) {
    @@ -465,6 +526,13 @@ void SysSetMousePos(int32_t x, int32_t y) {
     }
     
     
    +// Choose the screen saver (a file in SAVERS, any path, "" none) and its
    +// wait in minutes.
    +void SysSetScreenSaver(const char *fileName, int32_t minutes) {
    +    shellSaverSet(fileName, minutes);
    +}
    +
    +
     // Smooth the edges of text (nonzero) or stop; text is smoothed only
     // when the screen has more than 256 colors.
     void SysSetTextSmoothing(int32_t on) {
    diff --git a/src/libs/kpunch/http/http.c b/src/libs/kpunch/http/http.c
    index 7a0caba4..f764048d 100644
    --- a/src/libs/kpunch/http/http.c
    +++ b/src/libs/kpunch/http/http.c
    @@ -713,8 +713,6 @@ const char *httpMessage(const HttpT *h) {
     
     
     HttpStateE httpPoll(HttpT *h, uint32_t nowMs) {
    -    char buf[READ_STEP];
    -
         if (!h || h->step == StepIdleE || h->step == StepFailedE) {
             return HttpFailedE;
         }
    @@ -763,6 +761,7 @@ HttpStateE httpPoll(HttpT *h, uint32_t nowMs) {
         }
     
         if ((h->step == StepHeadE || h->step == StepBodyE) && socketReady(h->fd, POLLIN)) {
    +        char    buf[READ_STEP];
             ssize_t n = recv(h->fd, buf, sizeof(buf), 0);
     
             if (n < 0 && !wouldBlock(errno)) {
    @@ -852,7 +851,7 @@ HttpStateE httpPoll(HttpT *h, uint32_t nowMs) {
             return finish(h);
         }
     
    -    if ((uint32_t)(nowMs - h->lastMs) > h->timeoutMs) {
    +    if (nowMs - h->lastMs > h->timeoutMs) {
             return fail(h, "%s did not answer in time (%lu seconds).", h->target.host, (unsigned long)(h->timeoutMs / MS_PER_SECOND));
         }
     
    @@ -969,15 +968,15 @@ static bool isRedirect(int32_t status) {
     // one, on the same computer when it starts with /, on another with //,
     // else beside the path asked for.
     static bool locationResolve(const HttpT *h, const char *loc, char *out, int32_t outSize) {
    -    char        here[HTTP_HOST_MAX + NUMBER_TEXT_MAX];
    -    const char *slash;
    -    int         n;
    +    int n;
     
         if (strncasecmp(loc, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0 || strncasecmp(loc, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0) {
             n = snprintf(out, (size_t)outSize, "%s", loc);
         } else if (strncmp(loc, SCHEME_SEP, strlen(SCHEME_SEP)) == 0) {
             n = snprintf(out, (size_t)outSize, "http:%s", loc);
         } else {
    +        char here[HTTP_HOST_MAX + NUMBER_TEXT_MAX];
    +
             if (h->target.port == HTTP_PORT_DEFAULT) {
                 snprintf(here, sizeof(here), HTTP_SCHEME "%s", h->target.host);
             } else {
    @@ -987,8 +986,9 @@ static bool locationResolve(const HttpT *h, const char *loc, char *out, int32_t
             if (loc[0] == '/') {
                 n = snprintf(out, (size_t)outSize, "%s%s", here, loc);
             } else {
    -            slash = strrchr(h->target.path, '/');
    -            n     = snprintf(out, (size_t)outSize, "%s%.*s%s", here, (int)(slash - h->target.path + 1), h->target.path, loc);
    +            const char *slash = strrchr(h->target.path, '/');
    +
    +            n = snprintf(out, (size_t)outSize, "%s%.*s%s", here, (int)(slash - h->target.path + 1), h->target.path, loc);
             }
         }
     
    diff --git a/src/libs/kpunch/json/json.c b/src/libs/kpunch/json/json.c
    index fc110fa7..01893a36 100644
    --- a/src/libs/kpunch/json/json.c
    +++ b/src/libs/kpunch/json/json.c
    @@ -111,10 +111,10 @@ static void          skipSpace(ParserT *ps);
     
     
     static bool fail(ParserT *ps, const char *fmt, ...) {
    -    char    what[JSON_ERR_MAX];
    -    va_list ap;
    -
         if (!ps->failed) {
    +        char    what[JSON_ERR_MAX];
    +        va_list ap;
    +
             va_start(ap, fmt);
             vsnprintf(what, sizeof(what), fmt, ap);
             va_end(ap);
    diff --git a/src/libs/kpunch/libdvx/apiref.dhs b/src/libs/kpunch/libdvx/apiref.dhs
    index 982ef2b5..6e15e5bc 100644
    --- a/src/libs/kpunch/libdvx/apiref.dhs
    +++ b/src/libs/kpunch/libdvx/apiref.dhs
    @@ -717,7 +717,7 @@ const BitmapFontT struct describing the 8x16 font, ready to pass to drawText/dra
     
     .h1 dvxText.h -- Fonts
     
    -The font families DVX draws with: "System", the built-in 8x16 font, and the families in the FONTS folder. Which families that folder holds depends on the install -- Setup puts in none, the base set (DejaVu Sans, Sans Mono, and Serif), or all of them -- and dvxFontOpen of a family that is not there gives the system font. A family has up to four faces (regular, bold, italic, bold italic), each a .DFN file holding the face at the pixel sizes it was made at; the files are made on the build host from TrueType, Type 1, and bitmap fonts (src/tools/mkfont.c), so DVX only copies bitmaps.
    +The font families DVX draws with: "System", the built-in 8x16 font, and the families in the FONTS folder. Which families that folder holds depends on the install -- Setup puts in none, the base set (DejaVu Sans, Sans Mono, and Serif), or all of them -- and dvxFontOpen of a family that is not there gives the system font. A family has up to four faces (regular, bold, italic, bold italic), each a .DFN file holding the face at the pixel sizes it was made at; the files are made on the build host from TrueType, Type 1, and bitmap fonts (src/tools/mkfont.c), so DVX only copies bitmaps. More files of the same family and style (up to four in all) add the sizes the face lacks, each size read from the file it came in: the printer-fonts add-on gives the DejaVu faces their large sizes that way, from FONTS\KPUNCH.
     
     dvxFontOpen gives a BitmapFontT every text call draws and measures with. It never scales: it picks the face's size nearest the one asked for. A style the family has no face for is made from the nearest face -- bold by thickening each stroke a column (two from 24 pixels), italic by slanting a column per four rows -- and the font's flags say so. Every character of code page 437 draws: what a face lacks is filled in when the font loads. Box drawing, block, and shade characters are drawn to fill the character cell (the pitch, or the digit 0's width) so lines join, also in place of a face's own when its file asks; other characters come from FONT_FALLBACK_FAMILY (FONT_FALLBACK_FIXED for a fixed-pitch face, centered in its pitch) at the nearest size and the same style, or from the system font when that family is not installed.
     
    @@ -839,7 +839,7 @@ bool dvxFontAddMemory(int32_t owner, const void *data, uint32_t length, const ch
     
     Add the .DFN at path, or held in data (length bytes, copied at once; name names it in the log), as a face of its family for program owner (an app id; 0 for every program, as SETUP.EXE adds the fonts it carries). Families added for owner 0 are seen by every program and kept by dvxFontRescan.
     
    -Returns: True when added; false, logged, when the font cannot be read, calls its family "System", or owner has a face of that family and style already.
    +Returns: True when added; false, logged, when the font cannot be read, calls its family "System", or brings no size that owner's face of that family and style lacks (a second file of a face adds its sizes to it).
     
     .h2 dvxFontAddResources
     
    @@ -2816,6 +2816,12 @@ Install an icon that is already in display pixel format (for example from dvxLoa
     .index dvxTextHash
     .index dvxChangeVideoMode
     .index dvxExclusiveBegin
    +.index dvxRunDos
    +.index dvxIdleMs
    +.index platformDosRun
    +.index platformSuspendHookAdd
    +.index platformPathAbsolute
    +.index DOS programs
     .index dvxExclusiveEnd
     .index dvxExclusiveDisplay
     .index dvxExclusiveBlitOps
    @@ -3748,6 +3754,27 @@ void dvxExclusiveWaitRetrace(void);
     
     Wait for the start of the next vertical retrace (at most two frames' time), before a flush, so the frame is not torn.
     
    +
    +.h2 DOS Programs: dvxRunDos
    +
    +.code
    +int32_t dvxRunDos(AppContextT *ctx, const char *program, const char *args, const char *folder, bool pause);
    +int32_t platformDosRun(const char *command, const char *folder, bool pause, const DisplayT *desktop);
    +bool    platformPathAbsolute(const char *path, char *out, int32_t outSize);
    +.endcode
    +
    +dvxRunDos runs a DOS program (an .EXE, .COM, or .BAT file, or a command DOS knows) with DVX stepped aside, and comes back. Full screen, menus, and drags end; program is made absolute (a bare name stays one, for the PATH) and given to DOS by its short name, with args after it, in folder (NULL or "": the program's own folder). platformDosRun then runs the suspend hooks (newest first), lets the keyboard hook and the timer service go (the timers are kept and stand still), sets text mode, gives the INT 24h handler and Ctrl+Break back to DOS, makes the folder current, and runs the command line with system() (as at the DOS prompt: a batch file through the command interpreter, PATH, redirection); with pause it prints "Press any key to return to DVX." and waits for a key. Everything is taken back in the opposite order, the desktop's mode (and palette) set again, and the resume hooks run (oldest first); dvxRunDos sets the mouse up again where it was, forgets held keys, and repaints the whole screen. DVX logs "DOS: running" and "DOS: back from" with the command line. Nothing of DVX runs meanwhile: close the other programs first (the shell's shellRunDos asks them). Returns the program's exit code, or -1 when it could not be run (errno: ENOENT, ENOMEM, E2BIG for a command line longer than DOS's 126 characters). DVX stays in memory: the program gets the conventional memory DVX's DOS part and the DPMI host leave. On the host, platformDosRun is system() in the folder with the hooks around it.
    +
    +platformPathAbsolute makes a path absolute against the current folder (it need not exist).
    +
    +.h2 Idle Time: dvxIdleMs
    +
    +.code
    +uint32_t dvxIdleMs(const AppContextT *ctx);
    +.endcode
    +
    +The milliseconds since the last key or mouse input: a key (pressed, released, or typed), the mouse moving, a button, or the wheel, injected input (a test script, SysSendKey) included, and input that came while DVX was busy and was thrown away. DVX coming back from a DOS program or from full-screen mode counts as input too. The shell's screen saver starts after DVX.INI [desktop] saverWait minutes of it (shellSaver.h); BASIC: SysIdleSeconds. The time is kept in AppContextT.lastInputTime (platformClock ticks).
    +
     .h2 Accelerators
     
     .h3 dvxCreateAccelTable
    @@ -4458,7 +4485,7 @@ Core widget structure. Generic across all widget types; type-specific data lives
       WindowT *window                             Owning window
       int32_t x, y, w, h                         Computed geometry (relative to content area)
       int32_t calcMinW, calcMinH                  Computed minimum size (from layout pass)
    -  int32_t minW, minH, maxW, maxH, prefW, prefH  Size hints (tagged sizes)
    +  int32_t minW, minH, maxW, maxH              Size hints (tagged sizes): the least and the most the layout gives the widget
       int32_t weight                              Extra-space distribution weight (0=fixed, 100=normal)
       WidgetAlignE align                          Main-axis alignment for children
       int32_t spacing, padding                    Tagged sizes for child spacing and padding
    @@ -7214,12 +7241,20 @@ typedef void (*PlatformShutdownFnT)(void);
     
     bool platformShutdownHookAdd(PlatformShutdownFnT fn);
     void platformShutdownHookRemove(PlatformShutdownFnT fn);
    +
    +#define PLATFORM_SUSPEND_HOOKS 8
    +typedef void (*PlatformSuspendFnT)(bool suspending);
    +
    +bool platformSuspendHookAdd(PlatformSuspendFnT fn);
    +void platformSuspendHookRemove(PlatformSuspendFnT fn);
     .endcode
     
     platformIrqSave disables interrupts and returns the previous state for platformIrqRestore; bracket the few instructions that change data an interrupt handler reads (nothing happens on the host).
     
     A shutdown hook runs when DVX ends without its normal shutdown (exit() or a crash with no recovery point), before the timer service and the keyboard hook are given back, so a module that drives hardware from an interrupt (a sound card playing by DMA) can stop it. Each hook runs once. A module removes its hook before it is unloaded; at most PLATFORM_SHUTDOWN_HOOKS exist at once (Add returns false when full; adding one already present does nothing).
     
    +A suspend hook runs as fn(true) before DVX steps aside for a DOS program (platformDosRun, dvxRunDos) and as fn(false) once DVX is back, so a module that drives hardware itself lets it go (and takes it again then, or at its next use): audio.lib stops every sound, closes the output and the synthesizers, and opens the synthesizers again; music.lib stops every music and lets go of where it plays, closing its context (and the SoundFont kept on the wavetable synthesizer) when none is left; dvxnet stops the packet driver (it starts again at the next use). Suspending runs the newest hook first (a library built on another, loaded after it, lets go before it), resuming the oldest first; a hook may remove itself as they run. At most PLATFORM_SUSPEND_HOOKS; the same rules as the shutdown hooks otherwise.
    +
     .h2 Video
     
     .h3 platformVideoInit / platformVideoShutdown
    @@ -7628,6 +7663,7 @@ platformBootDrive returns the letter of the drive DOS started from (INT 21h AX=3
     #define DVX_THEMES_DIR "THEMES"
     #define DVX_WPAPER_DIR "WPAPER"
     #define DVX_BANKS_DIR  "BANKS"
    +#define DVX_SAVERS_DIR "SAVERS"
     #define DVX_VENDOR_DIR "KPUNCH"
     
     typedef enum {
    @@ -7636,6 +7672,7 @@ typedef enum {
         DvxKindThemesE,
         DvxKindWpaperE,
         DvxKindBanksE,
    +    DvxKindSaversE,
         DvxKindCountE
     } DvxKindE;
     
    @@ -7662,8 +7699,8 @@ The DVX folder (the working directory) holds the program, which Setup installs,
       SYSTEM                    DVX's own tools and files, and the shipped DVX.INI and ASSOC.INI
       LIBS, WIDGETS             The libraries and widgets
       APPS\\   A program, its help, and DATA (the files it ships; read-only)
    -  SHARED                    FONTS, SOUNDS, THEMES, WPAPER, and BANKS: what DVX and add-on packages ship for every program (an add-on's in a folder of its vendor's, such as FONTS\ACME)
    -  USER (the user folder)    DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, WPAPER, and BANKS, KEYS (publishers' keys the user trusts), APPS\ (each program's settings and saved files), and DOWNLOAD
    +  SHARED                    FONTS, SOUNDS, THEMES, WPAPER, BANKS, and SAVERS: what DVX and add-on packages ship for every program (an add-on's in a folder of its vendor's, such as FONTS\ACME)
    +  USER (the user folder)    DVX.INI, ASSOC.INI (the user's changes to SYSTEM's), DVX.LOG, the user's own FONTS, SOUNDS, THEMES, WPAPER, BANKS, and SAVERS, KEYS (publishers' keys the user trusts), APPS\ (each program's settings and saved files), and DOWNLOAD
     .endtable
     
     Only packages put files in SHARED: DVX's own, and add-on packages (pkg.lib), each in its vendor's folders. A program reads it but never writes or installs into it; a program's own files live with it (its DATA folder, or resources inside the .app), under its own vendor and program folder, so two programs' files never collide.
    @@ -7672,7 +7709,7 @@ platformUserInit, called once by the loader, decides the user folder: the folder
     
     platformAppUserDir is a program's folder in the user folder: APPS\KPUNCH\TERMINAL\TERMINAL.APP gives USER\APPS\KPUNCH\TERMINAL; a program outside the DVX folder gives USER\APPS\. It is not made here (the shell's userDir and BASIC's App.User are made on first use).
     
    -SHARED and the user folder have the same five kinds of folder (DvxKindE): fonts, sounds, themes, desktop pictures, and MIDI instrument banks. Each may hold folders of add-on vendors (SHARED\FONTS\ACME), one level deep, whose files are named VENDOR\NAME. platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name (NAME or VENDOR\NAME, either separator), the user's first, so a user's file of the same name wins; any other name with a folder in it, or an absolute one, is taken as the path it is (a VENDOR\NAME not in the kind's folders is tried that way too). platformKindNameOf is the other way: a file in one of the kind's folders gives its name alone, or VENDOR\NAME in a vendor's folder, which is what settings store (DVX.INI [sounds], [desktop] wallpaper, and [audio] midiBank), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them, with each vendor folder's files as VENDOR\NAME (the folders themselves are not listed); free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas, with FILEOPS_KIND_FONTS to FILEOPS_KIND_BANKS), and App.User for a program's own folder.
    +SHARED and the user folder have the same six kinds of folder (DvxKindE): fonts, sounds, themes, desktop pictures, MIDI instrument banks, and screen savers. Each may hold folders of add-on vendors (SHARED\FONTS\ACME), one level deep, whose files are named VENDOR\NAME. platformKindName is a kind's folder name; platformKindDir is SHARED's (user false) or the user's (user true); platformKindBrowseDir is where a file dialog for the kind starts (the user's when it exists, else SHARED's). platformKindFind finds a file of a kind by name (NAME or VENDOR\NAME, either separator), the user's first, so a user's file of the same name wins; any other name with a folder in it, or an absolute one, is taken as the path it is (a VENDOR\NAME not in the kind's folders is tried that way too). platformKindNameOf is the other way: a file in one of the kind's folders gives its name alone, or VENDOR\NAME in a vendor's folder, which is what settings store (DVX.INI [sounds], [desktop] wallpaper and saver, and [audio] midiBank), so moving the user folder or DVX keeps them working. platformKindReadDir lists both folders: the user's names (sorted, case ignored), then SHARED's not among them, with each vendor folder's files as VENDOR\NAME (the folders themselves are not listed); free it with dvxReadDirFree. BASIC: FileOpsUserDir$, FileOpsUserPath$, FileOpsUserInTemp, FileOpsKindDir$, FileOpsKindBrowseDir$, FileOpsKindFind$, FileOpsKindName$, and FileOpsKindList (fileops.bas, with FILEOPS_KIND_FONTS to FILEOPS_KIND_SAVERS), and App.User for a program's own folder.
     
     .h3 Read-only media: platformSetReadOnly / platformIsReadOnly / platformDirIsWritable / platformTempDir / platformSetLogMemory / platformLogMemory
     
    diff --git a/src/libs/kpunch/libdvx/dvxApp.c b/src/libs/kpunch/libdvx/dvxApp.c
    index 9e10bc02..e266a3c8 100644
    --- a/src/libs/kpunch/libdvx/dvxApp.c
    +++ b/src/libs/kpunch/libdvx/dvxApp.c
    @@ -62,6 +62,7 @@
     #include "thirdparty/stb_ds_wrap.h"
     
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -88,6 +89,7 @@ DVX_ABI_STAMP(libdvx);
     #define SUBMENU_ARROW_WIDTH    12
     #define SUBMENU_ARROW_HALF     3     // half-size of submenu arrow glyph
     #define TOOLTIP_DELAY_MS       500
    +#define DVX_DOS_CMD_MAX        256   // a DOS command line as dvxRunDos builds it (DOS takes less)
     #define TOOLTIP_PAD            3
     #define POPUP_BEVEL_WIDTH      2     // popup menu border bevel thickness
     #define POPUP_ITEM_PAD_H       8     // extra horizontal padding in popup items
    @@ -165,7 +167,6 @@ DVX_ABI_STAMP(libdvx);
     #define MOUSE_KEY_ACCEL        "acceleration"
     #define MOUSE_KEY_SPEED        "speed"
     #define MOUSE_KEY_WHEEL_STEP   "wheelspeed"
    -#define DESKTOP_SECTION        "desktop"
     #define DESKTOP_KEY_WALLPAPER  "wallpaper"
     #define DESKTOP_KEY_MODE       "mode"
     #define FONTS_SECTION          "fonts"
    @@ -277,6 +278,7 @@ static int32_t countVisibleWindows(const AppContextT *ctx);
     static int32_t cursorForResizeEdge(int32_t edge);
     static void cycleWindowFocus(AppContextT *ctx, bool reverse);
     static void deferDestroyWindow(AppContextT *ctx, WindowT *win);
    +static void desktopTakeBack(AppContextT *ctx, int32_t mouseX, int32_t mouseY);
     static void destroyWindowPrep(AppContextT *ctx, WindowT *win);
     static void destroyWindowSync(AppContextT *ctx, WindowT *win);
     static void dirtyCursorArea(AppContextT *ctx, int32_t x, int32_t y);
    @@ -1574,6 +1576,25 @@ static void deferDestroyWindow(AppContextT *ctx, WindowT *win) {
     }
     
     
    +// Everything the screen shows is gone after a DOS program or a full-
    +// screen one: the mouse set up again where it was, held keys forgotten,
    +// and the whole screen repainted.  The user was there: the idle time
    +// starts again.
    +static void desktopTakeBack(AppContextT *ctx, int32_t mouseX, int32_t mouseY) {
    +    ctx->lastInputTime = platformClock();
    +    platformMouseInit(ctx->display.width, ctx->display.height);
    +    platformMouseWarp(mouseX, mouseY);
    +    ctx->mouseX     = mouseX;
    +    ctx->mouseY     = mouseY;
    +    ctx->prevMouseX = mouseX;
    +    ctx->prevMouseY = mouseY;
    +
    +    resetClipRect(&ctx->display);
    +    dirtyListInit(&ctx->dirty);
    +    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +}
    +
    +
     // ============================================================
     // destroyWindowPrep
     // ============================================================
    @@ -1591,8 +1612,9 @@ static void destroyWindowPrep(AppContextT *ctx, WindowT *win) {
         // guaranteed same-frame use-after-free without this.
         closeWindowTransients(ctx, win);
     
    -    // A window put off for destroying already had its close sound.
    -    if (!win->modal && !win->destroyPending) {
    +    // A window put off for destroying already had its close sound; one
    +    // with the whole screen (a screen saver, a game) makes none.
    +    if (!win->modal && !win->destroyPending && ctx->exclusive.owner != win) {
             dvxPlaySound(SoundWindowCloseE);
         }
     
    @@ -3552,6 +3574,15 @@ void dvxHideWindow(AppContextT *ctx, WindowT *win) {
     }
     
     
    +// ============================================================
    +// dvxIdleMs
    +// ============================================================
    +
    +uint32_t dvxIdleMs(const AppContextT *ctx) {
    +    return PLATFORM_TICKS_TO_MS(platformClock() - ctx->lastInputTime);
    +}
    +
    +
     bool dvxImageInfo(const char *path, int32_t *outW, int32_t *outH) {
         if (!path) {
             return false;
    @@ -3656,6 +3687,7 @@ int32_t dvxInit(AppContextT *ctx, int32_t requestedW, int32_t requestedH, int32_
         ctx->lastCloseClickTime = 0;
         ctx->lastTitleClickId   = -1;
         ctx->lastTitleClickTime = 0;
    +    ctx->lastInputTime      = platformClock();
         ctx->wheelDirection     = MOUSE_WHEEL_DIR_NORMAL;
         ctx->wheelStep          = MOUSE_WHEEL_STEP_DEFAULT;
         ctx->dblClickMs         = MOUSE_DBLCLICK_DEFAULT_MS;
    @@ -4233,6 +4265,74 @@ void dvxRun(AppContextT *ctx) {
     }
     
     
    +// A program named by a relative path is made absolute first (it runs in
    +// its own folder); a long name reaches DOS by its short one.
    +int32_t dvxRunDos(AppContextT *ctx, const char *program, const char *args, const char *folder, bool pause) {
    +    char full[DVX_MAX_PATH];
    +    char shortName[DVX_MAX_PATH];
    +    char where[DVX_MAX_PATH];
    +    char command[DVX_DOS_CMD_MAX];
    +
    +    if (!ctx || !program || !program[0]) {
    +        errno = ENOENT;
    +        return -1;
    +    }
    +
    +    // A bare name stays one: the command interpreter looks for it on the PATH
    +    if (platformPathBaseName(program) == program || !platformPathAbsolute(program, full, sizeof(full))) {
    +        snprintf(full, sizeof(full), "%s", program);
    +    }
    +
    +    if (folder && folder[0]) {
    +        snprintf(where, sizeof(where), "%s", folder);
    +    } else if (!platformPathDirName(full, where, sizeof(where))) {
    +        where[0] = '\0';
    +    }
    +
    +    if (!platformShortPath(full, shortName, sizeof(shortName))) {
    +        snprintf(shortName, sizeof(shortName), "%s", full);
    +    }
    +
    +    int32_t len = snprintf(command, sizeof(command), args && args[0] ? "%s %s" : "%s", shortName, args);
    +
    +    if (len < 0 || len >= (int32_t)sizeof(command)) {
    +        errno = E2BIG;
    +        return -1;
    +    }
    +
    +    exclusiveLeave(ctx);
    +    closeWindowTransients(ctx, NULL);
    +    dvxInvalidateTooltip(ctx, NULL);
    +
    +    if (ctx->stack.dragWindow >= 0) {
    +        wmDragEnd(&ctx->stack);
    +    }
    +
    +    if (ctx->stack.resizeWindow >= 0) {
    +        wmResizeEnd(&ctx->stack);
    +    }
    +
    +    int32_t mouseX = ctx->mouseX;
    +    int32_t mouseY = ctx->mouseY;
    +
    +    dvxLog("DOS: running %s", command);
    +
    +    int32_t rc  = platformDosRun(command, where, pause, &ctx->display);
    +    int32_t err = errno;
    +
    +    if (rc < 0) {
    +        dvxLog("DOS: %s could not be run: %s", command, strerror(err));
    +    } else {
    +        dvxLog("DOS: back from %s", command);
    +    }
    +
    +    platformKeyStateClear();
    +    desktopTakeBack(ctx, mouseX, mouseY);
    +    errno = err;
    +    return rc;
    +}
    +
    +
     // ============================================================
     // dvxSaveImage
     // ============================================================
    @@ -4892,8 +4992,8 @@ bool dvxUpdate(AppContextT *ctx) {
     // when the path or the mode differs from the current one.
     
     bool dvxWallpaperFromPrefs(AppContextT *ctx, PrefsHandleT *h) {
    -    WallpaperModeE mode  = (WallpaperModeE)prefsWallpaperChoices[prefsGetChoice(h, DESKTOP_SECTION, DESKTOP_KEY_MODE, prefsWallpaperChoices, PREFS_WALLPAPER_CHOICE_COUNT, WALLPAPER_MODE_DEFAULT)].value;
    -    const char    *value = prefsGetString(h, DESKTOP_SECTION, DESKTOP_KEY_WALLPAPER, "");
    +    WallpaperModeE mode  = (WallpaperModeE)prefsWallpaperChoices[prefsGetChoice(h, DVX_DESKTOP_SECTION, DESKTOP_KEY_MODE, prefsWallpaperChoices, PREFS_WALLPAPER_CHOICE_COUNT, WALLPAPER_MODE_DEFAULT)].value;
    +    const char    *value = prefsGetString(h, DVX_DESKTOP_SECTION, DESKTOP_KEY_WALLPAPER, "");
         char           found[DVX_MAX_PATH];
     
         // A name alone is a file in the wallpaper folders (the user's, then
    @@ -4922,12 +5022,12 @@ void dvxWallpaperToPrefs(const AppContextT *ctx, PrefsHandleT *h) {
         if (ctx->wallpaperPath[0]) {
             char name[DVX_MAX_PATH];
     
    -        prefsSetString(h, DESKTOP_SECTION, DESKTOP_KEY_WALLPAPER, platformKindNameOf(DvxKindWpaperE, ctx->wallpaperPath, name, sizeof(name)) ? name : ctx->wallpaperPath);
    +        prefsSetString(h, DVX_DESKTOP_SECTION, DESKTOP_KEY_WALLPAPER, platformKindNameOf(DvxKindWpaperE, ctx->wallpaperPath, name, sizeof(name)) ? name : ctx->wallpaperPath);
         } else {
    -        prefsRemove(h, DESKTOP_SECTION, DESKTOP_KEY_WALLPAPER);
    +        prefsRemove(h, DVX_DESKTOP_SECTION, DESKTOP_KEY_WALLPAPER);
         }
     
    -    prefsSetString(h, DESKTOP_SECTION, DESKTOP_KEY_MODE, prefsWallpaperChoices[ctx->wallpaperMode].iniName);
    +    prefsSetString(h, DVX_DESKTOP_SECTION, DESKTOP_KEY_MODE, prefsWallpaperChoices[ctx->wallpaperMode].iniName);
     }
     
     
    @@ -5083,16 +5183,7 @@ static void exclusiveLeave(AppContextT *ctx) {
             dvxLog("Video: the desktop mode could not be set again");
         }
     
    -    platformMouseInit(ctx->display.width, ctx->display.height);
    -    platformMouseWarp(ex->savedMouseX, ex->savedMouseY);
    -    ctx->mouseX     = ex->savedMouseX;
    -    ctx->mouseY     = ex->savedMouseY;
    -    ctx->prevMouseX = ctx->mouseX;
    -    ctx->prevMouseY = ctx->mouseY;
    -
    -    resetClipRect(&ctx->display);
    -    dirtyListInit(&ctx->dirty);
    -    dirtyListAdd(&ctx->dirty, 0, 0, ctx->display.width, ctx->display.height);
    +    desktopTakeBack(ctx, ex->savedMouseX, ex->savedMouseY);
     }
     
     
    @@ -6563,9 +6654,9 @@ static inline void pixelToRgb(const ChannelLutT *lut, uint32_t pixel, uint8_t *d
     }
     
     
    -// True when (mx, my) lies inside the current (deepest) popup level.
     // The open sound of the window created last, unless it has become a
    -// dialog (modal) or is gone.
    +// dialog (modal), has taken the whole screen (a screen saver, a game
    +// starting full screen) or is gone.
     static void playOpenSound(AppContextT *ctx) {
         if (!sOpenSoundWinId) {
             return;
    @@ -6575,12 +6666,13 @@ static void playOpenSound(AppContextT *ctx) {
     
         sOpenSoundWinId = 0;
     
    -    if (win && !win->modal) {
    +    if (win && !win->modal && ctx->exclusive.owner != win) {
             dvxPlaySound(SoundWindowOpenE);
         }
     }
     
     
    +// True when (mx, my) lies inside the current (deepest) popup level.
     static bool pointInPopup(const AppContextT *ctx, int32_t mx, int32_t my) {
         return mx >= ctx->popup.popupX && mx < ctx->popup.popupX + ctx->popup.popupW && my >= ctx->popup.popupY && my < ctx->popup.popupY + ctx->popup.popupH;
     }
    @@ -6605,7 +6697,7 @@ static void pollKeyboard(AppContextT *ctx) {
             PlatformKeyEventT drain;
     
             while (platformKeyboardRead(&drain)) {
    -            // discard
    +            ctx->lastInputTime = platformClock();
             }
     
             // The keyboard hook keeps queueing raw events regardless of busy,
    @@ -6615,7 +6707,7 @@ static void pollKeyboard(AppContextT *ctx) {
             RawKeyEventT rawDrain;
     
             while (platformKeyEventRead(&rawDrain)) {
    -            // discard
    +            ctx->lastInputTime = platformClock();
             }
     
             return;
    @@ -6634,12 +6726,14 @@ static void pollKeyboard(AppContextT *ctx) {
             uint8_t keyRec[ENTROPY_KEY_BYTES] = { (uint8_t)evt.scancode, (uint8_t)(evt.scancode >> BYTE_SHIFT), (uint8_t)evt.ascii, (uint8_t)evt.modifiers };
     
             entropyFeed(keyRec, ENTROPY_KEY_BYTES);
    +        ctx->lastInputTime = platformClock();
             dispatchKey(ctx, evt.scancode, evt.ascii, evt.modifiers);
         }
     
         // Presses and releases after the keystrokes, so a key tapped within one
         // frame reaches onKey before its onKeyUp.
         while (platformKeyEventRead(&raw)) {
    +        ctx->lastInputTime = platformClock();
             dispatchRawKey(ctx, &raw);
         }
     
    @@ -6661,11 +6755,13 @@ static void pollMouse(AppContextT *ctx) {
     
         int32_t wheel = platformMouseWheelPoll();
     
    -    // Only real input is entropy; an idle mouse polled every frame is not.
    +    // Only real input is entropy (and input for dvxIdleMs); an idle mouse
    +    // polled every frame is not.
         if (mx != ctx->mouseX || my != ctx->mouseY || buttons != ctx->mouseButtons || wheel != 0) {
             uint8_t mouseRec[ENTROPY_MOUSE_BYTES] = { (uint8_t)mx, (uint8_t)(mx >> BYTE_SHIFT), (uint8_t)my, (uint8_t)(my >> BYTE_SHIFT), (uint8_t)buttons, (uint8_t)wheel, (uint8_t)(wheel >> BYTE_SHIFT) };
     
             entropyFeed(mouseRec, ENTROPY_MOUSE_BYTES);
    +        ctx->lastInputTime = platformClock();
         }
     
         ctx->mouseX       = mx;
    diff --git a/src/libs/kpunch/libdvx/dvxApp.h b/src/libs/kpunch/libdvx/dvxApp.h
    index bc793c80..b4e8c4c2 100644
    --- a/src/libs/kpunch/libdvx/dvxApp.h
    +++ b/src/libs/kpunch/libdvx/dvxApp.h
    @@ -127,6 +127,7 @@ typedef struct AppContextT {
         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)
    +    PlatformTicksT lastInputTime;      // the last key or mouse input (dvxIdleMs)
         // 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.
    @@ -342,6 +343,35 @@ void dvxExclusiveSetPalette(AppContextT *ctx, const uint8_t *pal, int32_t first,
     // torn.
     void dvxExclusiveWaitRetrace(void);
     
    +// ============================================================
    +// DOS programs
    +// ============================================================
    +
    +// Run a DOS program (an .EXE, .COM, or .BAT file, or a command the DOS
    +// prompt knows) with DVX stepped aside, and come back: full screen,
    +// menus, and drags end; the screen and DVX's hardware go back to DOS
    +// (platformDosRun); the program runs as at the DOS prompt (a batch file
    +// through the command interpreter) with args after it, in folder (NULL or "": the program's own, or the
    +// current one for a bare name); then DVX takes everything back, sets the
    +// mouse up again, and repaints.  With pause, "Press any key to return to
    +// DVX." waits once it ends, so what it printed can be read.  Nothing of
    +// DVX runs meanwhile, so the other programs should be closed first (the
    +// shell's shellRunDos asks them).  Returns the program's exit code, or -1
    +// when it could not be started (errno: ENOENT, ENOMEM, or
    +// E2BIG for a command line longer than DOS takes).
    +int32_t dvxRunDos(AppContextT *ctx, const char *program, const char *args, const char *folder, bool pause);
    +
    +// ============================================================
    +// Idle time
    +// ============================================================
    +
    +// Milliseconds since the last key or mouse input: a key pressed,
    +// released or typed, the mouse moving, a button, the wheel (injected
    +// input too, and input thrown away while busy).  DVX coming back from a
    +// DOS program or full-screen mode counts as input.  The shell's screen
    +// saver starts after its wait of it (shellSaver.h).
    +uint32_t dvxIdleMs(const AppContextT *ctx);
    +
     // ============================================================
     // Color scheme
     // ============================================================
    @@ -394,6 +424,10 @@ bool dvxSetWallpaper(AppContextT *ctx, const char *path);
     // again.  The mode is kept for the next wallpaper when none is loaded.
     void dvxSetWallpaperMode(AppContextT *ctx, WallpaperModeE mode);
     
    +// DVX.INI's section of the desktop settings: the wallpaper and its mode
    +// here, the screen saver (dvxshell, shellSaver.h)
    +#define DVX_DESKTOP_SECTION "desktop"
    +
     // Apply the DVX.INI [desktop] settings in h: mode (WALLPAPER_MODE_DEFAULT
     // when missing) and wallpaper (none when missing).  The image is only
     // rebuilt when the path or mode differs from the current one.  Returns
    diff --git a/src/libs/kpunch/libdvx/dvxText.c b/src/libs/kpunch/libdvx/dvxText.c
    index dd0a932d..e7f39afd 100644
    --- a/src/libs/kpunch/libdvx/dvxText.c
    +++ b/src/libs/kpunch/libdvx/dvxText.c
    @@ -68,6 +68,7 @@
     #include 
     
     #define FACE_STYLES         4           // regular, bold, italic, bold italic: indexed by style bits
    +#define FACE_FILES_MAX      4           // .DFN files one face's sizes may come from (its own, and add-ons with more sizes)
     #define FONT_IDLE_MAX       8           // fonts no one holds kept loaded
     #define SYSTEM_FAMILY_INDEX 0
     #define NO_FAMILY           (-1)
    @@ -124,11 +125,15 @@ typedef struct {
         uint32_t length;
     } FontSourceT;
     
    -// One .DFN: a face of a family, installed (in the font folders) or added
    +// A face of a family, installed (in the font folders) or added: one
    +// .DFN, or several of the same family and style, each bringing sizes the
    +// others lack (an add-on with large sizes for printing)
     typedef struct {
    -    FontSourceT *src;                   // held; NULL: no face of this style (sizeCount 0)
    +    FontSourceT *src[FACE_FILES_MAX];   // held, srcCount of them; none: no face of this style (sizeCount 0)
    +    int32_t      srcCount;
         bool         installed;             // read from the font folders (a rescan reads it again)
         uint8_t      sizes[DFN_STRIKE_MAX]; // rising, each once
    +    uint8_t      sizeSrc[DFN_STRIKE_MAX];   // the src each size is read from
         int32_t      sizeCount;
         uint32_t     grayMask;              // bit i: sizes[i] has a gray strike too
         uint16_t     style;                 // FONT_STYLE_BOLD / ITALIC
    @@ -257,10 +262,10 @@ static void         entryLinesCopy(FontEntryT *e, uint32_t lines);
     static void         entryPut(FontEntryT *e, const BuildT *b, int32_t ch, const FontGlyphT *g);
     static void         entryRebuild(FontEntryT *e);
     static bool         faceAdd(int32_t owner, const DfnSourceT *src);
    -static void         faceFill(FontFaceT *face, const DfnFileT *file);
     static void         faceFree(FontFaceT *face);
     static bool         faceGlyph(const BuildT *b, int32_t ch, FontGlyphT *g, const uint8_t **bits);
     static bool         faceGray(const FontFaceT *face, int32_t size);
    +static bool         faceMerge(FontFaceT *face, const DfnSourceT *dfn, const DfnFileT *file);
     static void         familyDropEmpty(int32_t fam);
     static int32_t      familyFind(const char *name, int32_t owner);
     static void         familyFree(FontFamilyT *fam);
    @@ -677,7 +682,9 @@ static FontEntryT *entryBuild(int32_t owner, const FontPickT *pick, uint32_t sty
             int32_t    index = 0;
             bool       ok;
     
    -        sourceDfn(f->src, &src);
    +        FontSourceT *fs = f->src[f->sizeSrc[DVX_MAX(sizeIndex(f, size), 0)]];
    +
    +        sourceDfn(fs, &src);
             ok = dfnOpen(&src, &file, err, sizeof(err));
     
             // The size's 1-bit or gray strike
    @@ -687,7 +694,7 @@ static FontEntryT *entryBuild(int32_t owner, const FontPickT *pick, uint32_t sty
     
             if (ok && dfnLoadStrike(&src, &file, index, &strike, err, sizeof(err))) {
                 b.strike  = &strike;
    -            e->src    = f->src;
    +            e->src    = fs;
                 e->src->refs++;
                 b.bpp     = gray ? FONT_GRAY_BPP : 1;
                 b.synth   = style & ~(uint32_t)f->style;
    @@ -972,22 +979,12 @@ static bool faceAdd(int32_t owner, const DfnSourceT *src) {
             return false;
         }
     
    -    int32_t    prev  = memAsSystem();
    -    int32_t    fam   = familyMake(file.header.family, owner);
    -    uint32_t   style = file.header.style & FONT_STYLE_FACE;
    -    FontFaceT *face  = &sFamilies[fam].faces[style];
    -    bool       ok    = face->sizeCount == 0;
    +    int32_t prev = memAsSystem();
    +    int32_t fam  = familyMake(file.header.family, owner);
    +    bool    ok   = faceMerge(&sFamilies[fam].faces[file.header.style & FONT_STYLE_FACE], src, &file);
     
    -    if (!ok) {
    -        dvxLog("Fonts: %s is a second %s face of style %u; skipped", src->name ? src->name : "a font", file.header.family, (unsigned)style);
    -    } else {
    -        face->src = sourceMake(src);
    -        ok        = face->src != NULL;
    -
    -        if (ok) {
    -            faceFill(face, &file);
    -            ownerDetach(owner, file.header.family);
    -        }
    +    if (ok) {
    +        ownerDetach(owner, file.header.family);
         }
     
         familyDropEmpty(fam);
    @@ -997,28 +994,12 @@ static bool faceAdd(int32_t owner, const DfnSourceT *src) {
     }
     
     
    -// face's sizes, smoothed sizes, style and flags from its file's header
    -// and strike table (each size once; a gray strike follows its size's
    -// 1-bit one)
    -static void faceFill(FontFaceT *face, const DfnFileT *file) {
    -    face->style     = file->header.style & FONT_STYLE_FACE;
    -    face->faceFlags = file->header.faceFlags;
    -    face->sizeCount = 0;
    -    face->grayMask  = 0;
    -
    -    for (int32_t i = 0; i < file->header.strikeCount; i++) {
    -        if (file->strikes[i].flags & DFN_STRIKE_GRAY2) {
    -            face->grayMask |= (face->sizeCount > 0) ? 1u << (face->sizeCount - 1) : 0;
    -        } else {
    -            face->sizes[face->sizeCount++] = (uint8_t)file->strikes[i].pixelSize;
    -        }
    -    }
    -}
    -
    -
    -// A face's source given up; the face left empty
    +// A face's sources given up; the face left empty
     static void faceFree(FontFaceT *face) {
    -    sourceRelease(face->src);
    +    for (int32_t i = 0; i < face->srcCount; i++) {
    +        sourceRelease(face->src[i]);
    +    }
    +
         memset(face, 0, sizeof(*face));
     }
     
    @@ -1044,6 +1025,87 @@ static bool faceGray(const FontFaceT *face, int32_t size) {
     }
     
     
    +// Add a .DFN to face: the sizes it has that the face lacks, each in its
    +// place (rising), with their smoothed strikes; the face's first file
    +// gives its style and flags.  False, logged, when it brings no size, the
    +// face has FACE_FILES_MAX files already, or out of memory.
    +static bool faceMerge(FontFaceT *face, const DfnSourceT *dfn, const DfnFileT *file) {
    +    const char  *name  = dfn->name ? dfn->name : "a font";
    +    int32_t      index = face->srcCount;
    +    int32_t      added = 0;
    +    bool         gray[DFN_STRIKE_MAX];
    +    FontSourceT *made;
    +
    +    if (index >= FACE_FILES_MAX) {
    +        dvxLog("Fonts: %s is one %s file of style %u too many; skipped", name, file->header.family, (unsigned)(file->header.style & FONT_STYLE_FACE));
    +        return false;
    +    }
    +
    +    made = sourceMake(dfn);
    +
    +    if (!made) {
    +        dvxLog("Fonts: no memory for %s; skipped", name);
    +        return false;
    +    }
    +
    +    for (int32_t i = 0; i < face->sizeCount; i++) {
    +        gray[i] = (face->grayMask & (1u << i)) != 0;
    +    }
    +
    +    // Its 1-bit sizes the face lacks
    +    for (int32_t s = 0; s < file->header.strikeCount && face->sizeCount < DFN_STRIKE_MAX; s++) {
    +        int32_t size = file->strikes[s].pixelSize;
    +        int32_t at   = 0;
    +
    +        if ((file->strikes[s].flags & DFN_STRIKE_GRAY2) || sizeIndex(face, size) >= 0) {
    +            continue;
    +        }
    +
    +        while (at < face->sizeCount && face->sizes[at] < size) {
    +            at++;
    +        }
    +
    +        memmove(&face->sizes[at + 1], &face->sizes[at], (size_t)(face->sizeCount - at));
    +        memmove(&face->sizeSrc[at + 1], &face->sizeSrc[at], (size_t)(face->sizeCount - at));
    +        memmove(&gray[at + 1], &gray[at], (size_t)(face->sizeCount - at) * sizeof(gray[0]));
    +        face->sizes[at]   = (uint8_t)size;
    +        face->sizeSrc[at] = (uint8_t)index;
    +        gray[at]          = false;
    +        face->sizeCount++;
    +        added++;
    +    }
    +
    +    if (added == 0) {
    +        sourceRelease(made);
    +        dvxLog("Fonts: %s has no size the %s face of style %u lacks; skipped", name, file->header.family, (unsigned)(file->header.style & FONT_STYLE_FACE));
    +        return false;
    +    }
    +
    +    // The smoothed strikes of the sizes it brought
    +    for (int32_t s = 0; s < file->header.strikeCount; s++) {
    +        int32_t i = sizeIndex(face, file->strikes[s].pixelSize);
    +
    +        if ((file->strikes[s].flags & DFN_STRIKE_GRAY2) && i >= 0 && face->sizeSrc[i] == index) {
    +            gray[i] = true;
    +        }
    +    }
    +
    +    face->grayMask = 0;
    +
    +    for (int32_t i = 0; i < face->sizeCount; i++) {
    +        face->grayMask |= gray[i] ? 1u << i : 0;
    +    }
    +
    +    if (index == 0) {
    +        face->style     = file->header.style & FONT_STYLE_FACE;
    +        face->faceFlags = file->header.faceFlags;
    +    }
    +
    +    face->src[face->srcCount++] = made;
    +    return true;
    +}
    +
    +
     // Family fam taken out when it has no face (never the system font's)
     static void familyDropEmpty(int32_t fam) {
         for (int32_t i = 0; i < FACE_STYLES; i++) {
    @@ -1078,7 +1140,7 @@ static int32_t familyFind(const char *name, int32_t owner) {
     // fam's added faces given up and left empty (its installed ones stay)
     static void familyFree(FontFamilyT *fam) {
         for (int32_t i = 0; i < FACE_STYLES; i++) {
    -        if (fam->faces[i].src && !fam->faces[i].installed) {
    +        if (fam->faces[i].srcCount && !fam->faces[i].installed) {
                 faceFree(&fam->faces[i]);
             }
         }
    @@ -1421,20 +1483,11 @@ static void scanFonts(void) {
                 continue;
             }
     
    -        int32_t    fam   = familyMake(file.header.family, OWNER_ALL);
    -        uint32_t   style = file.header.style & FONT_STYLE_FACE;
    -        FontFaceT *face  = &sFamilies[fam].faces[style];
    +        int32_t    fam  = familyMake(file.header.family, OWNER_ALL);
    +        FontFaceT *face = &sFamilies[fam].faces[file.header.style & FONT_STYLE_FACE];
     
    -        if (face->sizeCount) {
    -            dvxLog("Fonts: %s is a second %s face of style %u; skipped", path, file.header.family, (unsigned)style);
    -            continue;
    -        }
    -
    -        face->src = sourceMake(&src);
    -
    -        if (face->src) {
    +        if (faceMerge(face, &src, &file)) {
                 face->installed = true;
    -            faceFill(face, &file);
             }
     
             familyDropEmpty(fam);
    diff --git a/src/libs/kpunch/libdvx/dvxText.h b/src/libs/kpunch/libdvx/dvxText.h
    index 475bfa6d..9c9c88da 100644
    --- a/src/libs/kpunch/libdvx/dvxText.h
    +++ b/src/libs/kpunch/libdvx/dvxText.h
    @@ -26,7 +26,9 @@
     // A family is one name ("DejaVu Sans") with up to four faces (regular,
     // bold, italic, bold italic), each a .DFN file holding the face at the
     // pixel sizes it was made at (dvxFontFmt.h; made on the build host by
    -// mkfont from TrueType, Type 1, and bitmap fonts).  "System" is the
    +// mkfont from TrueType, Type 1, and bitmap fonts).  More .DFN files of
    +// the same family and style add the sizes the face lacks (an add-on with
    +// large sizes for printing).  "System" is the
     // built-in 8x16 font, always there.
     //
     // dvxFontOpen gives a BitmapFontT (dvxTypes.h) every text call draws and
    @@ -117,9 +119,9 @@ bool dvxFontSmooth(void);
     void dvxFontRescan(void);
     
     // Add the .DFN file at path as a face of its family for program owner
    -// (0: for every program).  False, logged, when it cannot be read, calls
    -// its family "System", or owner has a face of that family and style
    -// already.
    +// (0: for every program), or its sizes to owner's face of that family
    +// and style.  False, logged, when it cannot be read, calls its family
    +// "System", or brings no size that face lacks.
     bool dvxFontAddFile(int32_t owner, const char *path);
     
     // The same for a .DFN in memory, length bytes (copied), named name in
    diff --git a/src/libs/kpunch/libdvx/dvxWgt.h b/src/libs/kpunch/libdvx/dvxWgt.h
    index 1e04c595..b70de196 100644
    --- a/src/libs/kpunch/libdvx/dvxWgt.h
    +++ b/src/libs/kpunch/libdvx/dvxWgt.h
    @@ -321,14 +321,14 @@ typedef struct WidgetT {
     
         // Size hints (tagged: wgtPixels/wgtChars/wgtPercent, 0 = auto).
         // These are set by the application and influence the layout engine:
    -    // minW/minH override calcMinW/H if larger, maxW/maxH clamp the final
    -    // size, and prefW/prefH request a specific size (layout may override).
    +    // minW/minH override calcMinW/H if larger (a fixed width in a row, such
    +    // as a button's or a label column's, is minW with weight 0), and
    +    // maxW/maxH clamp the final size (across its box a clamped widget is
    +    // centred).
         int32_t          minW;
         int32_t          minH;
         int32_t          maxW;       // 0 = no limit
         int32_t          maxH;
    -    int32_t          prefW;      // preferred size, 0 = auto
    -    int32_t          prefH;
         // weight controls how extra space beyond minimum is distributed among
         // siblings in a VBox/HBox. weight=0 means fixed size (no stretching),
         // weight=100 is the default for flexible widgets. A widget with
    diff --git a/src/libs/kpunch/libdvx/games.dhs b/src/libs/kpunch/libdvx/games.dhs
    index 4a3841c0..b0ba0bb1 100644
    --- a/src/libs/kpunch/libdvx/games.dhs
    +++ b/src/libs/kpunch/libdvx/games.dhs
    @@ -252,7 +252,7 @@ if (!dvxExclusivePresent8(ctx, fb, 320, 200, 320)) {
     
     .h1 Sound, Music, and MIDI
     
    -Games play sound on whatever the computer has: a Sound Blaster family card, an FM synthesizer chip, a MIDI port, the PC speaker, or nothing. Nothing fails because of missing hardware: audioOpen returns NULL (or the device plays tones only), and the game carries on silently. Several programs can play at once, and everything a program opened is stopped and released when it ends.
    +Games play sound on whatever the computer has: a Sound Blaster family card, a Windows Sound System card, a Gravis UltraSound, a DAC on the printer port, an FM synthesizer chip, a wavetable synthesizer (a Gravis UltraSound's or a Sound Blaster AWE's), a MIDI port, the PC speaker, or nothing. Nothing fails because of missing hardware: audioOpen returns NULL (or the device plays tones only), and the game carries on silently. Several programs can play at once, and everything a program opened is stopped and released when it ends.
     
     .h2 Sound Effects and Tones (audio.h)
     
    @@ -267,7 +267,7 @@ Games play sound on whatever the computer has: a Sound Blaster family card, an F
     
     .h2 Music (music.h)
     
    -musicLoad or musicLoadMem load a tracker module (MOD, S3M, XM, IT) or a Standard MIDI File; musicPlay(music, loop) plays it, with pause, resume, stop, volume, position, and an end notification. MIDI plays on the best synthesizer there is: an external one on the MPU-401 port when the user chose it, else the FM chip with a General MIDI bank, else DVX's own FM software synthesizer; modules play on any card that plays sampled sound. musicMidiOpen gives live MIDI (note on and off, program change, controllers, pitch bend), and musicMmlPlay plays tunes written in QBasic's music macro language.
    +musicLoad or musicLoadMem load a tracker module (MOD, S3M, XM, IT) or a Standard MIDI File; musicPlay(music, loop) plays it, with pause, resume, stop, volume, position, and an end notification. MIDI plays on the best synthesizer there is: an external one on the MPU-401 port when the user chose it, else a wavetable card's recorded instruments (a Gravis UltraSound or a Sound Blaster AWE with a SoundFont), else the FM chip with a General MIDI bank, else DVX's own FM software synthesizer; modules play on a wavetable card's own voices (taking little of the CPU), else on any card that plays sampled sound (mixed in software). musicMidiOpen gives live MIDI (note on and off, program change, controllers, pitch bend), and musicMmlPlay plays tunes written in QBasic's music macro language.
     
     .h2 The FM Chip and the MIDI Port
     
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlat.h b/src/libs/kpunch/libdvx/platform/dvxPlat.h
    index ef94e151..65fd6782 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlat.h
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlat.h
    @@ -153,6 +153,7 @@ const char *platformTempDir(void);
     #define DVX_THEMES_DIR   "THEMES"
     #define DVX_WPAPER_DIR   "WPAPER"
     #define DVX_BANKS_DIR    "BANKS"        // MIDI instrument banks (OP2), in SHARED
    +#define DVX_SAVERS_DIR   "SAVERS"       // screen savers (programs the shell starts after the wait, shellSaver.h)
     #define DVX_VENDOR_DIR   "KPUNCH"       // DVX's own vendor folder (APPS\KPUNCH, LIBS\KPUNCH, ...); add-ons have their own
     
     // DVX /SAFE (or Shift held while it starts): a safe start, in safe video
    @@ -166,6 +167,7 @@ typedef enum {
         DvxKindThemesE,
         DvxKindWpaperE,
         DvxKindBanksE,
    +    DvxKindSaversE,
         DvxKindCountE
     } DvxKindE;
     
    @@ -199,7 +201,8 @@ const char *platformDvxIniPath(void);
     // extension>.  Not made here.
     bool platformAppUserDir(char *out, int32_t outSize, const char *appPath);
     
    -// A kind's folder name ("FONTS", "SOUNDS", "THEMES", "WPAPER", "BANKS");
    +// A kind's folder name ("FONTS", "SOUNDS", "THEMES", "WPAPER", "BANKS",
    +// "SAVERS");
     // NULL for a kind out of range.
     const char *platformKindName(DvxKindE kind);
     
    @@ -369,6 +372,21 @@ typedef void (*PlatformShutdownFnT)(void);
     bool platformShutdownHookAdd(PlatformShutdownFnT fn);
     void platformShutdownHookRemove(PlatformShutdownFnT fn);
     
    +// Suspend hooks: fn(true) runs before DVX steps aside for a DOS program
    +// (platformDosRun) and fn(false) once it is back, so a module that drives
    +// hardware itself (a sound card's DMA and interrupt, a network card's
    +// packet driver) lets it go, and takes it again then or at its next use.
    +// Suspending runs the newest first (a library built on another, loaded
    +// after it, lets go before it), resuming the oldest first.
    +// At most PLATFORM_SUSPEND_HOOKS at once; Add returns false when full
    +// (adding one already present is a no-op).
    +#define PLATFORM_SUSPEND_HOOKS 8
    +
    +typedef void (*PlatformSuspendFnT)(bool suspending);
    +
    +bool platformSuspendHookAdd(PlatformSuspendFnT fn);
    +void platformSuspendHookRemove(PlatformSuspendFnT fn);
    +
     // ============================================================
     // System lifecycle
     // ============================================================
    @@ -382,6 +400,22 @@ void platformShutdownHookRemove(PlatformShutdownFnT fn);
     // on the hidden text screen.  DOS restores its own handler when DVX exits.
     void platformInit(void);
     
    +// Run a DOS command line with DVX stepped aside, and come back: the
    +// suspend hooks run, the timer service and the keyboard hook let go (the
    +// timers kept), the screen in text mode, and Ctrl+Break and the critical-
    +// error handler as DOS had them; folder (NULL or "": the current one) is
    +// made current and command runs as at the DOS prompt (system(): a
    +// program, a batch file through the command interpreter, PATH, and
    +// redirection) and is waited for; with pause,
    +// "Press any key to return to DVX." waits for a key.  Then all of it is
    +// taken back, DVX's folder made current again, and desktop's mode (and
    +// palette) set again: the caller repaints and resets the mouse
    +// (dvxRunDos).  Returns what system() does (the program's exit code), or
    +// -1 when it could not be started (errno: ENOENT, ENOMEM, or E2BIG for a
    +// command longer than DOS takes).  On the host: system() too, the hooks
    +// around it.
    +int32_t platformDosRun(const char *command, const char *folder, bool pause, const DisplayT *desktop);
    +
     // Cooperative yield  -- give up the CPU timeslice when the event loop has
     // nothing to do. On DOS this calls __dpmi_yield() to be friendly to
     // multitaskers (Windows 3.x, OS/2).
    @@ -637,7 +671,9 @@ void platformInjectModifiers(int32_t modifiers);
     // buffer does not.
     void platformInjectScan(uint8_t scan);
     
    -// Override the mouse position and buttons platformMousePoll reports
    +// Override the mouse position and buttons platformMousePoll reports:
    +// each poll reports the next state given (they queue, so a press and its
    +// release given between two polls are both seen), and the last stays
     // until the next call.  The driver is still polled, so its state machine
     // keeps running; only the reported values are replaced.
     void platformInjectMouse(int32_t x, int32_t y, int32_t buttons);
    @@ -742,6 +778,11 @@ int32_t platformChdir(const char *path);
     // when switching between graphics modes live.
     void platformVideoFreeBuffers(DisplayT *d);
     
    +// out = path made absolute against the current folder (as it is when it
    +// already is; it need not exist).  False when it does not fit or the
    +// current folder cannot be read.
    +bool platformPathAbsolute(const char *path, char *out, int32_t outSize);
    +
     // Return a pointer to the last directory separator in path
     // (platformPathIsSep), or NULL if none is found.
     char *platformPathDirEnd(const char *path);
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatIsr.h b/src/libs/kpunch/libdvx/platform/dvxPlatIsr.h
    index bf900201..96de01e9 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatIsr.h
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatIsr.h
    @@ -130,6 +130,10 @@ void     platformTimerProgram(uint32_t divisor);
     // calls it when DVX ends without its normal shutdown.
     void     platformShutdownHooksRun(void);
     
    +// Run every suspend hook (dvxPlatformIrq.c): fn(true) newest first before
    +// a DOS program (platformDosRun), fn(false) oldest first after it.
    +void     platformSuspendHooksRun(bool suspending);
    +
     
     // Decode one byte from the keyboard.
     ISR_INLINE void keyScanFeed(KeyScanStateT *s, uint8_t byte) {
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    index 815c82ba..13d22e14 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformDos.c
    @@ -275,6 +275,9 @@
     
     // INT 21h DOS functions
     #define DOS_FN_GET_VERSION     0x3000
    +#define DOS_FN_GET_BREAK       0x3300  // DL = break checking (0 off, 1 on)
    +#define DOS_CMD_TAIL_MAX       126     // a command line after the program's name
    +#define DOS_PAUSE_TEXT         "\r\nPress any key to return to DVX."
     #define DOS_FN_SET_BREAK       0x3301
     #define DOS_BREAK_OFF          0x0000
     #define DOS_FN_GET_BOOT_DRIVE  0x3305  // DL = boot drive (1 = A:)
    @@ -352,7 +355,6 @@
     #define CRTC_UNPROTECT_MASK   0x7F     // clears the write-protect bit of CRTC 0-7
     #define MISC_MODEX            0xE3     // 25 MHz dot clock, 480-line sync polarities
     #define REG_DATA_SHIFT        8        // outportw: index in the low byte, data in the high
    -#define BYTE_MASK             0xFF
     
     // Vertical retrace: VGA input status 1, bit 3 is set during retrace.  The
     // wait gives up after RETRACE_WAIT_MAX_US (two frames at 50 Hz), so a card
    @@ -377,6 +379,10 @@
     #define MOUSE_TRAP_BOX_H      240
     #define MOUSE_PROMOTE_CONSEC  2
     
    +// Scripted mouse states waiting for platformMousePoll (a test script gives
    +// one a frame; more than a few only pile up when frames are slow)
    +#define INJECT_MOUSE_MAX      16
    +
     
     // ============================================================
     // Types
    @@ -444,6 +450,13 @@ typedef struct {
         uint32_t size;
     } LfbMapT;
     
    +// A scripted mouse state (platformInjectMouse)
    +typedef struct {
    +    int32_t x;
    +    int32_t y;
    +    int32_t buttons;
    +} InjectMouseT;
    +
     // Scores a VBE mode against a request (-1: unusable); findBestMode keeps
     // the highest.
     typedef int32_t (*ModeScoreFnT)(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
    @@ -453,11 +466,16 @@ typedef int32_t (*ModeScoreFnT)(const VbeModeT *m, int32_t requestedW, int32_t r
     // Prototypes
     // ============================================================
     
    +static dxe_symbol_table        sDxeExportTable[];
     static void                    bankCopy(uint32_t offset, const uint8_t *src, uint32_t bytes);
     static void                    bankSet(uint8_t window, uint32_t winStart);
    +static void                    biosKeyFlush(void);
     static uint8_t                 biosVideoMode(void);
     static inline void             copySpan(uint8_t *dst, const uint8_t *src, uint32_t bytes);
     static bool                    cpuHasTsc(void);
    +static void                    critErrorOwn(bool ours);
    +static int32_t                 desktopModeRestore(const DisplayT *desktop);
    +static void                    dosBreakSet(uint16_t state);
     static uint32_t                estimateClockMhz(void);
     static uint32_t                farPtrToLinear(uint32_t addr);
     static int32_t                 findBestMode(int32_t requestedW, int32_t requestedH, int32_t preferredBpp, ModeScoreFnT score, uint16_t *outMode, VbeModeT *outInfo, DisplayT *d);
    @@ -474,15 +492,16 @@ static void                    modeXSet(void);
     static uint32_t                pagesToKb(unsigned long pages);
     static uint32_t                pitLatchElapsed(void);
     static void                    platformCrashHandler(int sig);
    +int32_t                        platformDosRun(const char *command, const char *folder, bool pause, const DisplayT *desktop);
     ISR_INLINE uint64_t            readTsc(void);
     static bool                    regionFromNear(const void *addr, uint32_t size, bool isCode, __dpmi_meminfo *out);
     static int32_t                 scoreExclusive(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
     static int32_t                 scoreMode(const VbeModeT *m, int32_t requestedW, int32_t requestedH, int32_t preferredBpp);
    -static dxe_symbol_table        sDxeExportTable[];
     static void                    setBiosMode(uint16_t mode);
     static int32_t                 setVesaMode(uint16_t mode, bool linear);
     static void                    sysInfoAppend(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
     static void                    sysInfoDrives(void);
    +static void                    timerHold(bool hold);
     static bool                    timerLock(void);
     static void                    timerUnlock(void);
     static void                    unmapLfb(DisplayT *d, LfbMapT *map);
    @@ -503,6 +522,13 @@ static uint8_t sSplashPal[SPLASH_PAL_BYTES];
     // INT 24h stub: MOV AL,3 (fail the call); IRET.  Installed once.
     static const uint8_t sCritStub[]         = { 0xB0, 0x03, 0xCF };
     static bool          sCritErrorInstalled = false;
    +static __dpmi_raddr  sCritVector;            // sCritStub's
    +static __dpmi_raddr  sCritVectorDos;         // DOS's, given back while a DOS program runs
    +
    +// DOS's break checking as DVX found it (platformDosRun gives it back to a
    +// DOS program), and platformInit's one run
    +static uint16_t      sBreakDos           = DOS_BREAK_OFF;
    +static bool          sPlatformInitDone   = false;
     
     // TSC availability for platformEntropyStamp, probed on first use.
     typedef enum {
    @@ -568,11 +594,14 @@ static int32_t    sMickeyH        = MOUSE_SETTING_UNSET;
     static int32_t    sMickeyV        = MOUSE_SETTING_UNSET;
     static int32_t    sAccelThreshold = MOUSE_SETTING_UNSET;
     
    -// Test-script mouse override (platformInjectMouse); never set otherwise.
    -static bool       sInjectMouse    = false;
    -static int32_t    sInjectX        = 0;
    -static int32_t    sInjectY        = 0;
    -static int32_t    sInjectButtons  = 0;
    +// Test-script mouse override (platformInjectMouse): the states not
    +// reported yet, oldest first (a poll reports one), and the last one
    +// reported, which stays; never set otherwise.
    +static bool         sInjectMouse  = false;
    +static InjectMouseT sInjectQueue[INJECT_MOUSE_MAX];
    +static int32_t      sInjectHead   = 0;
    +static int32_t      sInjectCount  = 0;
    +static InjectMouseT sInjectLast;
     
     // System information static buffer
     static char    sSysInfoBuf[PLATFORM_SYSINFO_MAX];
    @@ -693,6 +722,26 @@ static void bankSet(uint8_t window, uint32_t winStart) {
     }
     
     
    +// The BIOS keyboard buffer emptied.
    +static void biosKeyFlush(void) {
    +    __dpmi_regs r;
    +
    +    for (;;) {
    +        memset(&r, 0, sizeof(r));
    +        r.x.ax = KBD_FN_PEEK_EXT;
    +        __dpmi_int(INT_KEYBOARD, &r);
    +
    +        if (r.x.flags & CPU_FLAG_ZF) {
    +            return;
    +        }
    +
    +        memset(&r, 0, sizeof(r));
    +        r.x.ax = KBD_FN_READ_EXT;
    +        __dpmi_int(INT_KEYBOARD, &r);
    +    }
    +}
    +
    +
     // Current BIOS video mode (INT 10h AH=0Fh).
     static uint8_t biosVideoMode(void) {
         __dpmi_regs r;
    @@ -747,6 +796,46 @@ static bool cpuHasTsc(void) {
     }
     
     
    +// INT 24h: DVX's handler (critical errors fail the call), or DOS's own
    +// while a DOS program runs.
    +static void critErrorOwn(bool ours) {
    +    if (sCritErrorInstalled) {
    +        __dpmi_set_real_mode_interrupt_vector(INT_CRIT_ERROR, ours ? &sCritVector : &sCritVectorDos);
    +    }
    +}
    +
    +
    +// The desktop's mode (and, at 8 bpp, its palette) again after something
    +// else had the screen: the desktop's window too, at an unknown position
    +// after the mode set.
    +static int32_t desktopModeRestore(const DisplayT *desktop) {
    +    sBank          = sDesktopBank;
    +    sBank.winStart = PLATFORM_BANK_NONE;
    +
    +    if (setVesaMode(sDesktopMode, !sDesktopBank.active) != 0) {
    +        videoError("VBE: Could not set the desktop's mode 0x%04X again", sDesktopMode);
    +        return -1;
    +    }
    +
    +    if (desktop->format.bitsPerPixel == BPP_8 && desktop->palette) {
    +        platformVideoSetPalette(desktop->palette, 0, PAL_ENTRIES);
    +    }
    +
    +    return 0;
    +}
    +
    +
    +// DOS's break checking during its calls (INT 21h 3301h).
    +static void dosBreakSet(uint16_t state) {
    +    __dpmi_regs r;
    +
    +    memset(&r, 0, sizeof(r));
    +    r.x.ax = DOS_FN_SET_BREAK;
    +    r.x.dx = state;
    +    __dpmi_int(INT_DOS, &r);
    +}
    +
    +
     // RDTSC calibration via BIOS timer.
     // Measures TSC ticks over CLOCK_MEAS_TICKS BIOS timer ticks (~165 ms).
     // Using several ticks instead of 1 reduces jitter from interrupt latency
    @@ -909,9 +998,10 @@ static bool hasCpuid(void) {
     
     
     // Point the real-mode INT 24h vector at sCritStub, copied into a paragraph
    -// of DOS memory, so every critical error fails the DOS call.  Never
    -// removed: DOS restores the vector saved in the PSP when DVX exits, and the
    -// DPMI host frees the DOS memory.
    +// of DOS memory, so every critical error fails the DOS call.  Given back
    +// to DOS's handler only while a DOS program runs (critErrorOwn): DOS
    +// restores the vector saved in the PSP when DVX exits, and the DPMI host
    +// frees the DOS memory.
     static void installCritErrorHandler(void) {
         if (sCritErrorInstalled) {
             return;
    @@ -925,12 +1015,11 @@ static void installCritErrorHandler(void) {
             return;
         }
     
    -    __dpmi_raddr vector;
    -
         dosmemput(sCritStub, sizeof(sCritStub), (uint32_t)segment << PARAGRAPH_SHIFT);
    -    vector.segment      = (uint16_t)segment;
    -    vector.offset16     = 0;
    -    sCritErrorInstalled = __dpmi_set_real_mode_interrupt_vector(INT_CRIT_ERROR, &vector) == 0;
    +    __dpmi_get_real_mode_interrupt_vector(INT_CRIT_ERROR, &sCritVectorDos);
    +    sCritVector.segment  = (uint16_t)segment;
    +    sCritVector.offset16 = 0;
    +    sCritErrorInstalled  = __dpmi_set_real_mode_interrupt_vector(INT_CRIT_ERROR, &sCritVector) == 0;
     
         if (!sCritErrorInstalled) {
             __dpmi_free_dos_memory(selector);
    @@ -1239,6 +1328,67 @@ static void platformCrashHandler(int sig) {
     }
     
     
    +// system() runs the command line as DOS would (a batch file through the
    +// command interpreter, PATH, redirection); spawn would quote the arguments
    +// that hold blanks.  DVX's interrupt hooks are let go first and taken back
    +// after, in the opposite order.
    +int32_t platformDosRun(const char *command, const char *folder, bool pause, const DisplayT *desktop) {
    +    char    cwd[DVX_MAX_PATH];
    +    int32_t rc = -1;
    +
    +    if (strlen(command) > DOS_CMD_TAIL_MAX) {
    +        errno = E2BIG;
    +        return -1;
    +    }
    +
    +    if (!getcwd(cwd, sizeof(cwd))) {
    +        cwd[0] = '\0';
    +    }
    +
    +    platformSuspendHooksRun(true);
    +    platformKeyHookShutdown();
    +    timerHold(true);
    +    setBiosMode(BIOS_MODE_TEXT80);
    +    critErrorOwn(false);
    +    dosBreakSet(sBreakDos);
    +    biosKeyFlush();
    +
    +    if (!folder || !folder[0] || platformChdir(folder) == 0) {
    +        rc = system(command);
    +    } else {
    +        errno = ENOENT;
    +    }
    +
    +    int32_t savedErrno = errno;
    +
    +    if (pause) {
    +        fputs(DOS_PAUSE_TEXT, stdout);
    +        fflush(stdout);
    +        biosKeyFlush();
    +
    +        __dpmi_regs r;
    +
    +        memset(&r, 0, sizeof(r));
    +        r.x.ax = KBD_FN_READ_EXT;
    +        __dpmi_int(INT_KEYBOARD, &r);
    +    }
    +
    +    if (cwd[0]) {
    +        platformChdir(cwd);
    +    }
    +
    +    biosKeyFlush();
    +    dosBreakSet(DOS_BREAK_OFF);
    +    critErrorOwn(true);
    +    desktopModeRestore(desktop);
    +    timerHold(false);
    +    platformKeyHookInit();
    +    platformSuspendHooksRun(false);
    +    errno = savedErrno;
    +    return rc;
    +}
    +
    +
     // IOCTL 4409h (remote?), 440Eh (the letter a shared floppy answers to)
     // and 4408h (removable?) never read the media.
     int32_t platformDriveList(PlatformDriveT *out, int32_t max) {
    @@ -1629,6 +1779,12 @@ const char *platformGetSystemInfo(const DisplayT *display) {
     
     
     void platformInit(void) {
    +    if (sPlatformInitDone) {
    +        return;
    +    }
    +
    +    sPlatformInitDone = true;
    +
         // Disable Ctrl+C/Break at every level so the user can't
         // accidentally kill the GUI while in graphics mode.
     
    @@ -1638,13 +1794,15 @@ void platformInit(void) {
         // 2. C-level: ignore SIGINT if it somehow fires
         signal(SIGINT, SIG_IGN);
     
    -    // 3. DOS-level: disable break checking during INT 21h calls
    -    //    (function 33h, subfunction 01h, DL=0 = break off)
    +    // 3. DOS-level: disable break checking during INT 21h calls (kept
    +    //    first, for a DOS program DVX runs)
         __dpmi_regs r;
    +
         memset(&r, 0, sizeof(r));
    -    r.x.ax = DOS_FN_SET_BREAK;
    -    r.x.dx = DOS_BREAK_OFF;
    +    r.x.ax = DOS_FN_GET_BREAK;
         __dpmi_int(INT_DOS, &r);
    +    sBreakDos = r.h.dl;
    +    dosBreakSet(DOS_BREAK_OFF);
     
         // 4. Critical errors fail the call instead of prompting
         installCritErrorHandler();
    @@ -1681,11 +1839,18 @@ void platformInjectModifiers(int32_t modifiers) {
     }
     
     
    +// Queued, so a press and its release given between two polls are both
    +// seen; when the queue is full the newest state waiting is replaced.
     void platformInjectMouse(int32_t x, int32_t y, int32_t buttons) {
    -    sInjectMouse   = true;
    -    sInjectX       = x;
    -    sInjectY       = y;
    -    sInjectButtons = buttons & MOUSE_BUTTON_MASK;
    +    InjectMouseT state = { x, y, buttons & MOUSE_BUTTON_MASK };
    +
    +    if (sInjectCount == INJECT_MOUSE_MAX) {
    +        sInjectCount--;
    +    }
    +
    +    sInjectQueue[(sInjectHead + sInjectCount) % INJECT_MOUSE_MAX] = state;
    +    sInjectCount++;
    +    sInjectMouse = true;
     }
     
     
    @@ -2066,10 +2231,16 @@ void platformMousePoll(int32_t *mx, int32_t *my, int32_t *buttons) {
             sCurY += (int16_t)r.x.dx;
         }
     
    +    if (sInjectCount > 0) {
    +        sInjectLast  = sInjectQueue[sInjectHead];
    +        sInjectHead  = (sInjectHead + 1) % INJECT_MOUSE_MAX;
    +        sInjectCount--;
    +    }
    +
         if (sInjectMouse) {
    -        sCurX    = sInjectX;
    -        sCurY    = sInjectY;
    -        *buttons = sInjectButtons;
    +        sCurX    = sInjectLast.x;
    +        sCurY    = sInjectLast.y;
    +        *buttons = sInjectLast.buttons;
         }
     
         if (sCurX < 0) {
    @@ -2144,6 +2315,11 @@ void platformMouseWarp(int32_t x, int32_t y) {
         sCurX = x;
         sCurY = y;
     
    +    // A script's mouse moves too, as the host's does, or the next poll puts
    +    // it back (and full screen would see that as the user moving it).
    +    sInjectLast.x = x;
    +    sInjectLast.y = y;
    +
         if (sMouseMode == MouseModeAbsoluteE) {
             // Function 04h: move the driver cursor so the next 03h read agrees.
             memset(&r, 0, sizeof(r));
    @@ -2759,21 +2935,7 @@ int32_t platformVideoExclusiveEnd(DisplayT *d, const DisplayT *desktop) {
         d->backBuf = NULL;
         sModeX     = false;
         unmapLfb(d, &sExclusiveLfb);
    -
    -    // The desktop's window again, at an unknown position after the mode set
    -    sBank          = sDesktopBank;
    -    sBank.winStart = PLATFORM_BANK_NONE;
    -
    -    if (setVesaMode(sDesktopMode, !sDesktopBank.active) != 0) {
    -        videoError("VBE: Could not set the desktop's mode 0x%04X again", sDesktopMode);
    -        return -1;
    -    }
    -
    -    if (desktop->format.bitsPerPixel == BPP_8 && desktop->palette) {
    -        platformVideoSetPalette(desktop->palette, 0, PAL_ENTRIES);
    -    }
    -
    -    return 0;
    +    return desktopModeRestore(desktop);
     }
     
     
    @@ -3176,6 +3338,28 @@ static void sysInfoDrives(void) {
     }
     
     
    +// A DOS program runs (platformDosRun): the PIT at the BIOS rate and INT 8
    +// the BIOS's again, the timers kept (they stand still meanwhile); then the
    +// service's rate and handler again.  The wrapper and the locks stay.
    +static void timerHold(bool hold) {
    +    if (!platformTimer.running) {
    +        return;
    +    }
    +
    +    uint32_t divisor = hold ? PLATFORM_PIT_BIOS_DIVISOR : platformTimer.divisor;
    +    uint32_t state   = platformIrqSave();
    +
    +    outportb(PIT_CMD_PORT, hold ? PIT_CH0_SQUARE_WAVE : PIT_CH0_RATE_GEN);
    +    outportb(PIT_CH0_PORT, divisor & BYTE_MASK);
    +    outportb(PIT_CH0_PORT, (divisor >> BYTE_BITS) & BYTE_MASK);
    +    _go32_dpmi_set_protected_mode_interrupt_vector(TIMER_IRQ_VECTOR, hold ? &sOldInt8 : &sNewInt8);
    +
    +    // The TSC interpolation starts over with the next interrupt.
    +    platformTimer.tscAtIrq = 0;
    +    platformIrqRestore(state);
    +}
    +
    +
     // Pin the timer interrupt's code and data; all or nothing.
     static bool timerLock(void) {
         if (platformLockRegion((const void *)int8Handler, ISR_LOCK_SIZE, true) != 0) {
    @@ -3536,6 +3720,7 @@ DXE_EXPORT_TABLE(sDxeExportTable)
         DXE_EXPORT(platformCopyFile)
         DXE_EXPORT(platformDirIsWritable)
         DXE_EXPORT(platformDiskSpace)
    +    DXE_EXPORT(platformDosRun)
         DXE_EXPORT(platformDriveList)
         DXE_EXPORT(platformDvxIniPath)
         DXE_EXPORT(platformEntropyStamp)
    @@ -3585,6 +3770,7 @@ DXE_EXPORT_TABLE(sDxeExportTable)
         DXE_EXPORT(platformNormalizeFKeyScan)
         DXE_EXPORT(platformPathBaseName)
         DXE_EXPORT(platformPathDirEnd)
    +    DXE_EXPORT(platformPathAbsolute)
         DXE_EXPORT(platformPathDirName)
         DXE_EXPORT(platformPathIsAbsolute)
         DXE_EXPORT(platformPathIsSep)
    @@ -3613,6 +3799,8 @@ DXE_EXPORT_TABLE(sDxeExportTable)
         DXE_EXPORT(platformSpanFill8)
         DXE_EXPORT(platformStatSize)
         DXE_EXPORT(platformStripLineEndings)
    +    DXE_EXPORT(platformSuspendHookAdd)
    +    DXE_EXPORT(platformSuspendHookRemove)
         DXE_EXPORT(platformTempDir)
         DXE_EXPORT(platformTimerAdd)
         DXE_EXPORT(platformTimerAddFast)
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    index 9e757560..9e64e264 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformHost.c
    @@ -60,8 +60,11 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
    +#include 
     #include 
    +#include 
     
     // Default mode when the caller passes zero for a dimension.
     #define HOST_DEFAULT_W       640
    @@ -490,6 +493,39 @@ PlatformTicksT platformClockHz(void) {
     }
     
     
    +// The host has no DOS: the command runs with system() in folder, with the
    +// suspend hooks around it as on DOS (pause and desktop mean nothing here).
    +int32_t platformDosRun(const char *command, const char *folder, bool pause, const DisplayT *desktop) {
    +    char cwd[DVX_MAX_PATH];
    +    int  status = -1;
    +
    +    (void)pause;
    +    (void)desktop;
    +
    +    if (!getcwd(cwd, sizeof(cwd))) {
    +        cwd[0] = '\0';
    +    }
    +
    +    platformSuspendHooksRun(true);
    +
    +    if (!folder || !folder[0] || platformChdir(folder) == 0) {
    +        status = system(command);
    +    } else {
    +        errno = ENOENT;
    +    }
    +
    +    int32_t savedErrno = errno;
    +
    +    if (cwd[0]) {
    +        platformChdir(cwd);
    +    }
    +
    +    platformSuspendHooksRun(false);
    +    errno = savedErrno;
    +    return status < 0 ? -1 : WEXITSTATUS(status);
    +}
    +
    +
     int32_t platformDriveList(PlatformDriveT *out, int32_t max) {
         if (max < 1) {
             return 0;
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformIrq.c b/src/libs/kpunch/libdvx/platform/dvxPlatformIrq.c
    index 6c9cff31..00821a4f 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformIrq.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformIrq.c
    @@ -36,10 +36,16 @@
     // "No timer registered" as a rate (timerApplyRate).
     #define TIMER_RATE_NONE 0
     
    +// A shutdown or suspend hook as its table keeps it (one type, so adding
    +// and removing is shared; converted back to its own type to be called).
    +typedef void (*HookFnT)(void);
    +
     // ============================================================
     // Prototypes
     // ============================================================
     
    +static bool     hookAdd(HookFnT *table, int32_t count, HookFnT fn);
    +static void     hookRemove(HookFnT *table, int32_t count, HookFnT fn);
     void            platformInjectScan(uint8_t scan);
     bool            platformKeyEventRead(RawKeyEventT *evt);
     bool            platformKeyIsDown(int32_t rawCode);
    @@ -48,6 +54,9 @@ void            platformKeyStateGet(uint8_t out[KEY_RAW_COUNT]);
     bool            platformShutdownHookAdd(PlatformShutdownFnT fn);
     void            platformShutdownHookRemove(PlatformShutdownFnT fn);
     void            platformShutdownHooksRun(void);
    +bool            platformSuspendHookAdd(PlatformSuspendFnT fn);
    +void            platformSuspendHookRemove(PlatformSuspendFnT fn);
    +void            platformSuspendHooksRun(bool suspending);
     int32_t         platformTimerAdd(uint32_t hz, PlatformTimerFnT fn, void *userData, int32_t ownerAppId);
     int32_t         platformTimerAddFast(uint32_t hz, PlatformTimerFnT fn, void *userData, int32_t ownerAppId);
     void            platformTimerRemove(int32_t timerId);
    @@ -64,7 +73,42 @@ static uint32_t timerDivisorForHz(uint32_t hz);
     KeyScanStateT platformKeyScan;
     TimerServiceT platformTimer;
     
    -static PlatformShutdownFnT sShutdownHooks[PLATFORM_SHUTDOWN_HOOKS];
    +static HookFnT sShutdownHooks[PLATFORM_SHUTDOWN_HOOKS];
    +static HookFnT sSuspendHooks[PLATFORM_SUSPEND_HOOKS];
    +
    +
    +// fn after the hooks already in table (kept in the order they were
    +// added, with no gaps: hookRemove closes them); true when it is there
    +// (already, or now).
    +static bool hookAdd(HookFnT *table, int32_t count, HookFnT fn) {
    +    for (int32_t i = 0; i < count; i++) {
    +        if (table[i] == fn) {
    +            return true;
    +        }
    +
    +        if (!table[i]) {
    +            table[i] = fn;
    +            return true;
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +static void hookRemove(HookFnT *table, int32_t count, HookFnT fn) {
    +    int32_t kept = 0;
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        if (table[i] != fn) {
    +            table[kept++] = table[i];
    +        }
    +    }
    +
    +    while (kept < count) {
    +        table[kept++] = NULL;
    +    }
    +}
     
     
     void platformInjectScan(uint8_t scan) {
    @@ -128,33 +172,12 @@ void platformKeyStateGet(uint8_t out[KEY_RAW_COUNT]) {
     
     
     bool platformShutdownHookAdd(PlatformShutdownFnT fn) {
    -    int32_t freeSlot = -1;
    -
    -    for (int32_t i = 0; i < PLATFORM_SHUTDOWN_HOOKS; i++) {
    -        if (sShutdownHooks[i] == fn) {
    -            return true;
    -        }
    -
    -        if (!sShutdownHooks[i] && freeSlot < 0) {
    -            freeSlot = i;
    -        }
    -    }
    -
    -    if (freeSlot < 0) {
    -        return false;
    -    }
    -
    -    sShutdownHooks[freeSlot] = fn;
    -    return true;
    +    return hookAdd(sShutdownHooks, PLATFORM_SHUTDOWN_HOOKS, (HookFnT)fn);
     }
     
     
     void platformShutdownHookRemove(PlatformShutdownFnT fn) {
    -    for (int32_t i = 0; i < PLATFORM_SHUTDOWN_HOOKS; i++) {
    -        if (sShutdownHooks[i] == fn) {
    -            sShutdownHooks[i] = NULL;
    -        }
    -    }
    +    hookRemove(sShutdownHooks, PLATFORM_SHUTDOWN_HOOKS, (HookFnT)fn);
     }
     
     
    @@ -173,6 +196,35 @@ void platformShutdownHooksRun(void) {
     }
     
     
    +bool platformSuspendHookAdd(PlatformSuspendFnT fn) {
    +    return hookAdd(sSuspendHooks, PLATFORM_SUSPEND_HOOKS, (HookFnT)fn);
    +}
    +
    +
    +void platformSuspendHookRemove(PlatformSuspendFnT fn) {
    +    hookRemove(sSuspendHooks, PLATFORM_SUSPEND_HOOKS, (HookFnT)fn);
    +}
    +
    +
    +// Newest first when suspending (a library built on another lets go
    +// before it), oldest first when resuming.  The hooks run from a copy, so
    +// one may remove itself (or another) as it runs.
    +void platformSuspendHooksRun(bool suspending) {
    +    HookFnT hooks[PLATFORM_SUSPEND_HOOKS];
    +
    +    memcpy(hooks, sSuspendHooks, sizeof(hooks));
    +
    +    for (int32_t n = 0; n < PLATFORM_SUSPEND_HOOKS; n++) {
    +        int32_t            i  = suspending ? PLATFORM_SUSPEND_HOOKS - 1 - n : n;
    +        PlatformSuspendFnT fn = (PlatformSuspendFnT)hooks[i];
    +
    +        if (fn) {
    +            fn(suspending);
    +        }
    +    }
    +}
    +
    +
     int32_t platformTimerAdd(uint32_t hz, PlatformTimerFnT fn, void *userData, int32_t ownerAppId) {
         return timerAdd(hz, PLATFORM_TIMER_MAX_HZ, fn, userData, ownerAppId);
     }
    diff --git a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
    index 0c077645..d1fc8e1f 100644
    --- a/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
    +++ b/src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c
    @@ -171,7 +171,6 @@ static bool             globMatchLen(const char *pat, int32_t patLen, const char
     static char           **kindList(const char *dir);
     static int              kindNameCompare(const void *a, const void *b);
     static void             logMemAppend(const char *fmt, va_list ap);
    -static bool             pathAbsolute(const char *path, char *out, int32_t outSize);
     static bool             pathCharEq(char a, char b);
     static int32_t          pathSepCount(const char *path);
     static int32_t          pathTrimmedLen(const char *path);
    @@ -203,6 +202,7 @@ int32_t                 platformMkdirRecursive(const char *path);
     int32_t                 platformNormalizeFKeyScan(int32_t scancode);
     void                    platformNormalizeKeyEvent(PlatformKeyEventT *evt, int32_t liveMods);
     void                    platformOutOfMemory(size_t bytes);
    +bool                    platformPathAbsolute(const char *path, char *out, int32_t outSize);
     const char             *platformPathBaseName(const char *path);
     char                   *platformPathDirEnd(const char *path);
     bool                    platformPathDirName(const char *path, char *out, int32_t outSize);
    @@ -254,7 +254,7 @@ static char sBootNotes[PLATFORM_BOOT_NOTES_MAX];
     static bool sBootNotesFull = false;
     
     // The folder of each kind, in DvxKindE order
    -static const char *const sKindDirs[DvxKindCountE] = { DVX_FONTS_DIR, DVX_SOUNDS_DIR, DVX_THEMES_DIR, DVX_WPAPER_DIR, DVX_BANKS_DIR };
    +static const char *const sKindDirs[DvxKindCountE] = { DVX_FONTS_DIR, DVX_SOUNDS_DIR, DVX_THEMES_DIR, DVX_WPAPER_DIR, DVX_BANKS_DIR, DVX_SAVERS_DIR };
     
     // The user folder (platformUserDir; "" until first asked), whether it
     // went in TEMP, and platformDvxIniPath's answer
    @@ -649,19 +649,6 @@ static void logMemAppend(const char *fmt, va_list ap) {
     }
     
     
    -// out = path made absolute against the working directory (as it is when
    -// it already is)
    -static bool pathAbsolute(const char *path, char *out, int32_t outSize) {
    -    char cwd[DVX_MAX_PATH];
    -
    -    if (platformPathIsAbsolute(path)) {
    -        return snprintf(out, (size_t)outSize, "%s", path) < outSize;
    -    }
    -
    -    return getcwd(cwd, sizeof(cwd)) && platformPathJoin(out, outSize, cwd, path);
    -}
    -
    -
     // DVX's path rules are DOS's on every platform: the separators are
     // interchangeable and names compare without case.
     static bool pathCharEq(char a, char b) {
    @@ -1099,12 +1086,12 @@ bool platformKindNameOf(DvxKindE kind, const char *path, char *out, int32_t outS
         char              dir[DVX_MAX_PATH];
         char              absDir[DVX_MAX_PATH];
     
    -    if (!path || !platformPathDirName(path, fileDir, sizeof(fileDir)) || !pathAbsolute(fileDir, absFile, sizeof(absFile)) || !platformPathDirName(absFile, vendorDir, sizeof(vendorDir))) {
    +    if (!path || !platformPathDirName(path, fileDir, sizeof(fileDir)) || !platformPathAbsolute(fileDir, absFile, sizeof(absFile)) || !platformPathDirName(absFile, vendorDir, sizeof(vendorDir))) {
             return false;
         }
     
         for (int32_t i = 0; i < DVX_ARRAY_LEN(userFirst); i++) {
    -        if (!platformKindDir(dir, sizeof(dir), kind, userFirst[i]) || !pathAbsolute(dir, absDir, sizeof(absDir))) {
    +        if (!platformKindDir(dir, sizeof(dir), kind, userFirst[i]) || !platformPathAbsolute(dir, absDir, sizeof(absDir))) {
                 continue;
             }
     
    @@ -1271,6 +1258,17 @@ void platformOutOfMemory(size_t bytes) {
     }
     
     
    +bool platformPathAbsolute(const char *path, char *out, int32_t outSize) {
    +    char cwd[DVX_MAX_PATH];
    +
    +    if (platformPathIsAbsolute(path)) {
    +        return snprintf(out, (size_t)outSize, "%s", path) < outSize;
    +    }
    +
    +    return getcwd(cwd, sizeof(cwd)) && platformPathJoin(out, outSize, cwd, path);
    +}
    +
    +
     // The name after the last separator, or after a drive ("C:A.TXT").
     const char *platformPathBaseName(const char *path) {
         if (!path) {
    diff --git a/src/libs/kpunch/libdvx/widgetLayout.c b/src/libs/kpunch/libdvx/widgetLayout.c
    index 393e6752..e1ba622e 100644
    --- a/src/libs/kpunch/libdvx/widgetLayout.c
    +++ b/src/libs/kpunch/libdvx/widgetLayout.c
    @@ -533,26 +533,9 @@ void widgetLayoutBox(WidgetT *w, const BitmapFontT *font) {
                 }
             }
     
    -        // Assign geometry
    +        // Assign geometry: the box's whole cross size, but for a max size
             int32_t crossSize = availCross;
     
    -        // Apply preferred size as cross-axis cap (e.g. Width in a VBox).
    -        // When set, the widget won't stretch beyond this on the cross axis.
    -        if (horiz && c->prefH) {
    -            int32_t prefPx = wgtResolveSize(c->prefH, innerH, charW);
    -
    -            if (crossSize > prefPx) {
    -                crossSize = prefPx;
    -            }
    -        } else if (!horiz && c->prefW) {
    -            int32_t prefPx = wgtResolveSize(c->prefW, innerW, charW);
    -
    -            if (crossSize > prefPx) {
    -                crossSize = prefPx;
    -            }
    -        }
    -
    -        // Apply max size on cross axis
             if (horiz && c->maxH) {
                 int32_t maxPx = wgtResolveSize(c->maxH, innerH, charW);
     
    diff --git a/src/libs/kpunch/music/Makefile b/src/libs/kpunch/music/Makefile
    index f384de93..a07bac2a 100644
    --- a/src/libs/kpunch/music/Makefile
    +++ b/src/libs/kpunch/music/Makefile
    @@ -22,9 +22,9 @@
     
     # Music Library Makefile for DJGPP cross-compilation
     #
    -# music.lib (see music.h): libxmp-lite (thirdparty/libxmp-lite, built
    -# unmodified with warnings off, as its own options say below) and our own
    -# sources.  The files that run at interrupt time (ISR_SRCS) are linked
    +# music.lib (see music.h): libxmp-lite (thirdparty/libxmp-lite, with
    +# DVX's patches from its dvx-patches folder, built with warnings off, as
    +# its own options say below) and our own sources.  The files that run at interrupt time (ISR_SRCS) are linked
     # last, so musicLock pins one span from their first function to the end
     # of the library's data; gcc must not move parts of them elsewhere or
     # turn loops into library calls.  The OP2 instrument bank is copied
    @@ -34,10 +34,11 @@ include ../../../common.mk
     
     XMP_DIR    = thirdparty/libxmp-lite
     XMP_SRCS   = $(wildcard $(XMP_DIR)/src/*.c) $(wildcard $(XMP_DIR)/src/loaders/*.c)
    +XMP_HDRS   = $(wildcard $(XMP_DIR)/include/*.h $(XMP_DIR)/src/*.h $(XMP_DIR)/src/loaders/*.h)
     XMP_OPTS   = -DLIBXMP_CORE_PLAYER -DLIBXMP_NO_PROWIZARD -DLIBXMP_NO_DEPACKERS -DBUILDING_STATIC -I$(XMP_DIR)/include -I$(XMP_DIR)/src
     XMP_CFLAGS = $(filter-out $(WARNFLAGS) $(DEPFLAGS),$(DOS_CFLAGS)) -w -fno-analyzer $(XMP_OPTS)
     
    -ISR_SRCS   = musicFm.c musicMpu.c musicSeq.c
    +ISR_SRCS   = musicCents.c musicFm.c musicModTick.c musicMpu.c musicWave.c musicSeq.c
     MAIN_SRCS  = $(filter-out $(ISR_SRCS),$(wildcard *.c))
     ISR_CFLAGS = -fno-reorder-blocks-and-partition -fno-reorder-functions -fno-tree-loop-distribute-patterns
     CFLAGS     = $(DOS_CFLAGS) $(ISR_CFLAGS) -I. -I$(XMP_DIR)/include -I../audio -I../libdvx -I../libdvx/platform -I../libdvx/thirdparty -I../libtasks -I../dvxshell
    @@ -65,7 +66,7 @@ $(TARGET): $(OBJS) | $(TARGETDIR)
     	$(DXE3GEN) -o $(TARGETDIR)/music.dxe -U $(OBJS)
     	mv $(TARGETDIR)/music.dxe $@
     
    -$(OBJDIR)/xmp/%.o: $(XMP_DIR)/%.c $(DVX_MAKEFILES)
    +$(OBJDIR)/xmp/%.o: $(XMP_DIR)/%.c $(XMP_HDRS) $(DVX_MAKEFILES)
     	@mkdir -p $(dir $@)
     	$(CC) $(XMP_CFLAGS) -c -o $@ $<
     
    diff --git a/src/libs/kpunch/music/music.c b/src/libs/kpunch/music/music.c
    index 2e4ceae1..a6a921b1 100644
    --- a/src/libs/kpunch/music/music.c
    +++ b/src/libs/kpunch/music/music.c
    @@ -31,12 +31,27 @@
     // Where a MIDI music plays is decided at its first play (or when a live
     // MIDI music opens): DVX.INI [audio] midi = mpu401 and the port is free:
     // the MPU-401 port (one music at a time); = software: the software
    -// synthesizer; otherwise the FM chip when there is one and no program has
    -// it to itself, else the software synthesizer.  On the FM chip and the
    -// port the music timer (musicTick) plays the song; FM notes without a
    -// voice go through the event ring to a software synthesizer on a
    -// stream.  With the software synthesizer the stream's fill function runs
    -// the sequencer, SW_CHUNK_FRAMES frames at a time.
    +// synthesizer; otherwise the wavetable synthesizer when there is one, its
    +// SoundFont is there, and no other music plays on it (one at a time; a
    +// stopped music gives it up and decides again at its next play), unless
    +// midi = fm; else the FM chip when there is one and no program has it to
    +// itself, else the software synthesizer.  On the synthesizers and the
    +// port the music timer (musicTick) plays the song; wavetable notes whose
    +// samples did not fit go to the FM chip (or the software synthesizer),
    +// and FM notes without a voice go through the event ring to a software
    +// synthesizer on a stream.  With the software synthesizer the stream's
    +// fill function runs the sequencer, SW_CHUNK_FRAMES frames at a time.
    +//
    +// A module plays on the wavetable synthesizer's voices when it is free
    +// in the same way (musicModHw.c: libxmp's voices as frames, made ahead
    +// in musicPump and played by the music timer), else on a stream that
    +// libxmp mixes in software.
    +//
    +// The SoundFont stays loaded, its samples in the synthesizer's memory,
    +// from song to song (a song loads only what it lacks; a module's samples
    +// that do not fit beside them put them out); the library's context,
    +// which owns that memory, stays open FONT_KEEP_US after the last music
    +// for the next one.
     //
     // The state a player is in lives in its IsrPlayerT for every kind of
     // music, so there is one place to look: the timer, the fill functions
    @@ -50,6 +65,7 @@
     #include "musicInt.h"
     #include "stb_ds.h"
     
    +#include 
     #include 
     #include 
     #include 
    @@ -59,7 +75,6 @@
     DVX_ABI_STAMP(music);
     
     
    -#define MODULE_STREAM_MS    400
     #define MIDI_STREAM_MS      150
     #define SW_CHUNK_FRAMES     64
     #define SW_VOICES           SYNTH_VOICES_MAX
    @@ -67,9 +82,30 @@ DVX_ABI_STAMP(music);
     #define STREAM_CHANNELS     2
     #define US_PER_SEC          1000000ULL
     #define US_PER_MS           1000
    +#define MS_PER_SEC          1000
     #define GAIN_SHIFT          8
     #define TIMER_NONE          (-1)
     #define ERROR_TEXT_UNKNOWN  "Unknown error"
    +#define FONT_KEEP_US        (MUSIC_FONT_KEEP_SEC * US_PER_SEC)
    +#define GM_BANK             0                   // General MIDI's instruments; live MIDI starts every channel ...
    +#define GM_PROGRAM_FIRST    0                   // ... on the first (and channel 10 on the standard kit)
    +
    +// The SoundFont the wavetable synthesizer plays, with its samples in the
    +// synthesizer's memory (the library's context's).  Pinned with the
    +// bank's arrays (the timer reads them).
    +typedef struct {
    +    WaveBankT  bank;
    +    WaveCacheT cache;
    +    char       name[DVX_MAX_PATH];    // as DVX.INI's soundFont names it
    +} WaveFontT;
    +
    +// The SoundFont software synthesizer's, one per music: its own copy of
    +// the SoundFont's headers, the song's samples in memory, where each is
    +typedef struct {
    +    WaveBankT    bank;
    +    WaveSoftT    soft;
    +    WaveLoadedT *loaded;    // per sample of bank
    +} SoftFontT;
     
     struct MusicT {
         IsrPlayerT    isr;          // first: the timer reads it (the whole struct is pinned)
    @@ -79,9 +115,14 @@ struct MusicT {
         MusicT       *next;
         MidiFileT     file;
         XmpPlayerT   *xmp;
    +    SoundSrcT    *sound;        // MUSIC_KIND_SOUND: the file's frames
         FmSynthT     *swSynth;      // the software synthesizer (MIDI on it, or FM overflow)
    +    WaveSynthT   *wave;         // MUSIC_OUT_WAVE, _SOFTWAVE (pinned)
    +    uint8_t      *waveNeed;     // ... the samples it needs, per sample of the SoundFont
    +    SoftFontT    *softFont;     // MUSIC_OUT_SOFTWAVE
         SynthChipT   *chip;
         AudioStreamT *stream;
    +    MusicBankT   *bank;         // MIDI: its instruments (a reference); before the first play, musicSetBank's choice or NULL
         MusicEndFnT   endFn;
         void         *endData;
         uint64_t      usAcc;        // software sequencer: frames x 10^6 not yet turned into time
    @@ -94,7 +135,10 @@ struct MusicT {
         int32_t       volume;
         int32_t       isrSlot;      // in musicIsrPlayers, -1: none
         bool          loop;
    +    bool          atEnd;        // a module played to its end: its next play starts over
         bool          draining;     // rendered to its end, not yet heard to it
    +    bool          fmChip;       // hwSynth plays (the music, or the wavetable synthesizer's overflow)
    +    char          soundFont[DVX_MAX_PATH];  // musicSetBank's SoundFont; "": DVX.INI's
     };
     
     static const char *sErrorText[MUSIC_ERR_COUNT] = {
    @@ -102,68 +146,150 @@ static const char *sErrorText[MUSIC_ERR_COUNT] = {
         "Invalid argument",
         "Not enough memory",
         "The file could not be read",
    -    "Not a music file (MOD, S3M, XM, IT, or MIDI)",
    +    "Not a music or sound file (MOD, S3M, XM, IT, MIDI, WAV, or VOC)",
         "The music file is damaged or incomplete",
         "No sound device can play this music",
         "The instrument bank for MIDI music is missing or damaged",
    -    "The music string has a mistake in it"
    +    "The music string has a mistake in it",
    +    NULL,                                   // MUSIC_ERR_UNSUPPORTED: audio.lib's text (it decodes sounds)
    +    "The file could not be written"
     };
     
    -static MusicT    *sMusics  = NULL;
    -static AudioCtxT *sCtx     = NULL;
    -static Op2BankT  *sBank    = NULL;
    -static MusicT    *sMpuUser = NULL;
    -static int32_t    sTimerId = TIMER_NONE;
    +static MusicT     *sMusics     = NULL;
    +static AudioCtxT  *sCtx        = NULL;
    +static MusicBankT *sBank       = NULL;  // DVX.INI's choice as last loaded (a reference)
    +static MusicT     *sMpuUser    = NULL;
    +static int32_t     sTimerId    = TIMER_NONE;
    +static WaveFontT  *sWave       = NULL;  // the SoundFont loaded for the wavetable synthesizer
    +static MusicT     *sWaveUser   = NULL;  // the music on it
    +static uint64_t    sQuietSince = 0;     // when the last music went (sCtx kept for sWave)
    +static char        sFontFailed[DVX_MAX_PATH];   // the SoundFont last found missing or damaged (said once)
     
     // ============================================================
     // Prototypes
     // ============================================================
     
    -static bool       bankRead(const char *path, Op2BankT *bank);
    -static int32_t    ctxEnsure(void);
    -static int32_t    decideMidi(MusicT *m);
    -static int32_t    gainOf(const MusicT *m);
    -static void       isrAdd(MusicT *m);
    -static void       isrRemove(MusicT *m);
    -static bool       isrSlotFree(void);
    -static uint32_t   midiFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
    -static void       midiSeek(MusicT *m, uint32_t us);
    -static uint32_t   moduleFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
    -int32_t           musicAppCurrent(void);
    -void              musicAppEnded(int32_t appId);
    -int32_t           musicAppEnter(int32_t appId);
    -const Op2BankT   *musicBank(int32_t *err);
    -const char       *musicErrorText(int32_t err);
    -void              musicFree(MusicT *music);
    -void              musicGetInfo(const MusicT *music, MusicInfoT *info);
    -uint32_t          musicGetPosition(const MusicT *music);
    -int32_t           musicGetVolume(const MusicT *music);
    -void              musicIdle(void *unused);
    -bool              musicIsPlaying(const MusicT *music);
    -int32_t           musicLoad(const char *path, MusicT **out);
    -int32_t           musicLoadMem(const void *data, uint32_t size, MusicT **out);
    -int32_t           musicMidiControl(MusicT *music, int32_t channel, int32_t controller, int32_t value);
    -int32_t           musicMidiNoteOff(MusicT *music, int32_t channel, int32_t note);
    -int32_t           musicMidiNoteOn(MusicT *music, int32_t channel, int32_t note, int32_t velocity);
    -int32_t           musicMidiOpen(MusicT **out);
    -int32_t           musicMidiPitchBend(MusicT *music, int32_t channel, int32_t bend);
    -int32_t           musicMidiProgram(MusicT *music, int32_t channel, int32_t program);
    -static MusicT    *musicNew(int32_t kind);
    -int32_t           musicPause(MusicT *music);
    -int32_t           musicPlay(MusicT *music, bool loop);
    -void              musicPump(void);
    -int32_t           musicResume(MusicT *music);
    -void              musicSetEndFn(MusicT *music, MusicEndFnT fn, void *userData);
    -int32_t           musicSetPosition(MusicT *music, uint32_t ms);
    -int32_t           musicSetVolume(MusicT *music, int32_t volume);
    -void              musicShutdown(void);
    -int32_t           musicStop(MusicT *music);
    -static int32_t    openSoftware(MusicT *m);
    -static int32_t    openStream(MusicT *m, uint32_t bufferMs, AudioFillFnT fill);
    -static int32_t    outputOpen(MusicT *m);
    -static int32_t    sendMidi(MusicT *m, int32_t channel, uint8_t type, int32_t data1, int32_t data2);
    -static void       silence(MusicT *m);
    -static void       timerUpdate(void);
    +static MusicBankT *bankLoad(const char *name, bool fallback, int32_t *err);
    +static bool        bankRead(const char *path, Op2BankT *bank);
    +static void        bankRelease(MusicBankT *bank);
    +static MusicBankT *bankShared(const char *name, bool current, int32_t *err);
    +static void        ctxClose(void);
    +static int32_t     ctxEnsure(void);
    +static int32_t     decideMidi(MusicT *m);
    +static int32_t     fontLoad(const char *want, WaveBankT *bank);
    +static const char *fontName(const char *name);
    +static void        fontSaid(const char *want, int32_t rc);
    +static int32_t     gainOf(const MusicT *m);
    +static void        isrAdd(MusicT *m);
    +static void        isrRemove(MusicT *m);
    +static bool        isrSlotFree(void);
    +static uint32_t    midiFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
    +static void        midiSeek(MusicT *m, uint32_t us);
    +static uint32_t    moduleFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
    +static int32_t     moduleOpen(MusicT *m);
    +static int32_t     moduleWave(MusicT *m);
    +int32_t            musicAppCurrent(void);
    +void               musicAppEnded(int32_t appId);
    +int32_t            musicAppEnter(int32_t appId);
    +const char        *musicErrorText(int32_t err);
    +void               musicFree(MusicT *music);
    +void               musicGetInfo(const MusicT *music, MusicInfoT *info);
    +uint32_t           musicGetPosition(const MusicT *music);
    +int32_t            musicGetVolume(const MusicT *music);
    +void               musicIdle(void *unused);
    +bool               musicIsPlaying(const MusicT *music);
    +int32_t            musicLoad(const char *path, MusicT **out);
    +int32_t            musicLoadMacro(const char *macro, MusicT **out);
    +int32_t            musicLoadMem(const void *data, uint32_t size, MusicT **out);
    +int32_t            musicMidiControl(MusicT *music, int32_t channel, int32_t controller, int32_t value);
    +int32_t            musicMidiNoteOff(MusicT *music, int32_t channel, int32_t note);
    +int32_t            musicMidiNoteOn(MusicT *music, int32_t channel, int32_t note, int32_t velocity);
    +int32_t            musicMidiOpen(MusicT **out);
    +int32_t            musicMidiPitchBend(MusicT *music, int32_t channel, int32_t bend);
    +int32_t            musicMidiProgram(MusicT *music, int32_t channel, int32_t program);
    +int32_t            musicMidiSend(MusicT *music, uint8_t status, uint8_t data1, uint8_t data2);
    +static MusicT     *musicNew(int32_t kind);
    +int32_t            musicPause(MusicT *music);
    +int32_t            musicPlay(MusicT *music, bool loop);
    +void               musicPump(void);
    +int32_t            musicResume(MusicT *music);
    +int32_t            musicSetBank(MusicT *music, const char *bank);
    +void               musicSetEndFn(MusicT *music, MusicEndFnT fn, void *userData);
    +int32_t            musicSetPosition(MusicT *music, uint32_t ms);
    +int32_t            musicSetVolume(MusicT *music, int32_t volume);
    +void               musicShutdown(void);
    +int32_t            musicStop(MusicT *music);
    +void               musicSuspend(bool suspending);
    +static int32_t     openSoftware(MusicT *m);
    +static int32_t     openStream(MusicT *m, uint32_t bufferMs, AudioFillFnT fill);
    +static void        outputClose(MusicT *m);
    +static int32_t     outputOpen(MusicT *m);
    +static void        rewindToStart(MusicT *m);
    +static int32_t     sendMidi(MusicT *m, int32_t channel, uint8_t type, int32_t data1, int32_t data2);
    +static void        silence(MusicT *m);
    +static int32_t     softLoad(MusicT *m, SoftFontT *sf, const char *want);
    +static int32_t     softOpen(MusicT *m, const char *font);
    +static uint32_t    soundFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
    +static bool        soundFontValid(const char *name);
    +static uint32_t    soundHeard(const MusicT *m);
    +static int32_t     soundMusic(SoundSrcT *src, MusicT **out);
    +static void        streamHold(const MusicT *m, bool hold);
    +static void        timerUpdate(void);
    +static bool        waveBankLock(const WaveBankT *bank, bool lock);
    +static bool        waveClaim(AudioWtInfoT *info);
    +static void        waveDrop(MusicT *m);
    +static void        waveFontFree(WaveFontT *font);
    +static int32_t     waveFontUse(const char *name);
    +static void        waveNeedsSong(const MusicT *m, const WaveBankT *bank, uint8_t *need);
    +static int32_t     waveOpen(MusicT *m, const char *font, MidiSinkT *overflow);
    +static void        waveProgram(MusicT *m, int32_t channel, int32_t program);
    +static int32_t     waveSynthNew(MusicT *m, uint32_t samples);
    +
    +
    +// A bank named as DVX.INI's midiBank names one ("" = the standard one),
    +// with one reference and pinned, or NULL with *err.  fallback: the
    +// standard one in place of a named one that is missing or damaged.
    +static MusicBankT *bankLoad(const char *name, bool fallback, int32_t *err) {
    +    MusicBankT *bank     = (MusicBankT *)calloc(1, sizeof(MusicBankT));
    +    bool        standard = !name[0];
    +    bool        ok       = false;
    +
    +    if (!bank) {
    +        *err = MUSIC_ERR_NO_MEMORY;
    +        return NULL;
    +    }
    +
    +    snprintf(bank->name, sizeof(bank->name), "%s", name);
    +
    +    if (!standard) {
    +        char path[DVX_MAX_PATH];
    +
    +        ok = platformKindFind(path, sizeof(path), DvxKindBanksE, name) && bankRead(path, &bank->op2);
    +
    +        if (!ok && fallback) {
    +            dvxLog("Music: the MIDI bank %s is missing or damaged; using the standard one", name);
    +            standard = true;
    +        }
    +    }
    +
    +    if (standard) {
    +        ok = bankRead(MUSIC_BANK_DEFAULT, &bank->op2);
    +
    +        if (!ok) {
    +            dvxLog("Music: no usable MIDI instrument bank");
    +        }
    +    }
    +
    +    if (!ok || platformLockRegion(bank, sizeof(*bank), false) != 0) {
    +        free(bank);
    +        *err = ok ? MUSIC_ERR_NO_MEMORY : MUSIC_ERR_BANK;
    +        return NULL;
    +    }
    +
    +    bank->refs = 1;
    +    *err       = MUSIC_OK;
    +    return bank;
    +}
     
     
     // An OP2 bank file into bank; false when it is missing or damaged.
    @@ -177,6 +303,54 @@ static bool bankRead(const char *path, Op2BankT *bank) {
     }
     
     
    +// Drop a reference to bank; it goes with its last (the timer no longer
    +// reads it: every music holding one has left the timer first).  NULL is
    +// ignored.
    +static void bankRelease(MusicBankT *bank) {
    +    if (bank && --bank->refs == 0) {
    +        platformUnlockRegion(bank, sizeof(*bank), false);
    +        free(bank);
    +    }
    +}
    +
    +
    +// A reference to the bank name chooses.  With current, name is DVX.INI's
    +// choice: the library's own bank while it is still that, else the new
    +// one, loaded in its place (the standard one when it is missing or
    +// damaged), so a choice changed in DVX.INI reaches the next MIDI music to
    +// start and the musics playing keep theirs.  Without current
    +// (musicSetBank) a bank that is missing or damaged is an error.
    +static MusicBankT *bankShared(const char *name, bool current, int32_t *err) {
    +    MusicBankT *bank;
    +
    +    if (sBank && strcasecmp(sBank->name, name) == 0) {
    +        sBank->refs++;
    +        *err = MUSIC_OK;
    +        return sBank;
    +    }
    +
    +    bank = bankLoad(name, current, err);
    +
    +    if (bank && current) {
    +        bankRelease(sBank);
    +        sBank = bank;
    +        bank->refs++;
    +    }
    +
    +    return bank;
    +}
    +
    +
    +// The library's context closed, and the SoundFont with it (its samples
    +// are in the context's memory).
    +static void ctxClose(void) {
    +    waveFontFree(sWave);
    +    sWave = NULL;
    +    audioClose(sCtx);
    +    sCtx = NULL;
    +}
    +
    +
     // The library's own context, opened for no program.
     static int32_t ctxEnsure(void) {
         if (!sCtx) {
    @@ -191,16 +365,24 @@ static int32_t ctxEnsure(void) {
     
     
     // Where a MIDI music plays (see the top); sets it up.  Without an
    -// instrument bank only the MPU-401 port can play it.
    +// instrument bank only the MPU-401 port can play it.  The banks are the
    +// ones musicSetBank chose, else DVX.INI's choices as they are now.
     static int32_t decideMidi(MusicT *m) {
         PrefsHandleT *prefs = prefsLoad(platformDvxIniPath());
         const char   *want  = audioMidiChoices[audioSettingMidi(prefs)].iniName;
         int32_t       mode  = mpuModeChoices[prefsGetChoice(prefs, AUDIO_INI_SECTION, MUSIC_INI_MPU_MODE, mpuModeChoices, MPU_MODE_COUNT, mpuModeChoices[MPU_MODE_GM].iniName)].value;
         bool          mpu   = strcasecmp(want, AUDIO_MIDI_MPU401) == 0;
         bool          soft  = strcasecmp(want, AUDIO_MIDI_SOFTWARE) == 0;
    +    bool          sw    = strcasecmp(want, AUDIO_MIDI_SOFTWAVE) == 0;
    +    bool          wave  = !soft && !sw && strcasecmp(want, AUDIO_MIDI_FM) != 0;
    +    char          bank[DVX_MAX_PATH];
    +    char          font[DVX_MAX_PATH];
         AudioFmInfoT  fm;
    +    MidiSinkT    *fmSink;
         int32_t       rc;
     
    +    snprintf(bank, sizeof(bank), "%s", prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, ""));
    +    snprintf(font, sizeof(font), "%s", m->soundFont[0] ? m->soundFont : prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_SOUND_FONT, ""));
         prefsClose(prefs);
     
         if (mpu && !sMpuUser && isrSlotFree() && audioMidiAcquire(sCtx) == AUDIO_OK) {
    @@ -214,8 +396,12 @@ static int32_t decideMidi(MusicT *m) {
             return MUSIC_OK;
         }
     
    -    if (!musicBank(&rc)) {
    -        return rc;
    +    if (!m->bank) {
    +        m->bank = bankShared(bank, true, &rc);
    +
    +        if (!m->bank) {
    +            return rc;
    +        }
         }
     
         rc = openSoftware(m);
    @@ -224,25 +410,89 @@ static int32_t decideMidi(MusicT *m) {
             return rc;
         }
     
    -    audioFmGetInfo(&fm);
    -
    -    if (!soft && fm.voices > 0 && !fm.rawOwned && isrSlotFree()) {
    -        FmBackendT hw;
    -
    -        m->where = MUSIC_OUT_FM;
    -        fmHwBackend(sCtx, &hw);
    -        fmSynthInit(&m->hwSynth, &hw, sBank, &m->ring.sink, fm.stereo, gainOf(m));
    -        m->isr.sink = &m->hwSynth.sink;
    -        isrAdd(m);
    +    // The SoundFont in software: on the stream, after the FM software
    +    // synthesizer (which plays what the bank lacks)
    +    if (sw && softOpen(m, font) == MUSIC_OK) {
    +        m->where    = MUSIC_OUT_SOFTWAVE;
    +        m->isr.sink = &m->wave->sink;
             return MUSIC_OK;
         }
     
    -    m->where    = MUSIC_OUT_SOFTWARE;
    -    m->isr.sink = &m->swSynth->sink;
    +    // FM notes: on the chip when there is one to share, else on the
    +    // software synthesizer through the ring.
    +    audioFmGetInfo(&fm);
    +    m->fmChip = !soft && !sw && fm.voices > 0 && !fm.rawOwned && isrSlotFree();
    +    fmSink    = &m->ring.sink;
    +
    +    if (m->fmChip) {
    +        FmBackendT hw;
    +
    +        fmHwBackend(sCtx, &hw);
    +        fmSynthInit(&m->hwSynth, &hw, &m->bank->op2, &m->ring.sink, fm.stereo, gainOf(m));
    +        fmSink = &m->hwSynth.sink;
    +    }
    +
    +    if (wave && waveOpen(m, font, fmSink) == MUSIC_OK) {
    +        m->where    = MUSIC_OUT_WAVE;
    +        m->isr.sink = &m->wave->sink;
    +        m->isr.wave = m->wave;
    +    } else if (m->fmChip) {
    +        m->where    = MUSIC_OUT_FM;
    +        m->isr.sink = fmSink;
    +    } else {
    +        m->where    = MUSIC_OUT_SOFTWARE;
    +        m->isr.sink = &m->swSynth->sink;
    +        return MUSIC_OK;
    +    }
    +
    +    isrAdd(m);
         return MUSIC_OK;
     }
     
     
    +// The SoundFont want names (as DVX.INI's soundFont does) into bank: a
    +// SoundFont, or an UltraSound's patches by their ULTRASND.INI; for the
    +// standard one when it is not installed, the UltraSound's own patches,
    +// else the AWE's ROM bank.  MUSIC_ERR_OPEN when none is found.
    +static int32_t fontLoad(const char *want, WaveBankT *bank) {
    +    char path[DVX_MAX_PATH];
    +
    +    if (platformKindFind(path, sizeof(path), DvxKindBanksE, want)) {
    +        return strcasecmp(platformPathBaseName(path), MUSIC_PATCH_SET_INI) == 0 ? patLoadSet(path, bank) : sf2Load(path, bank);
    +    }
    +
    +    if (strcasecmp(want, MUSIC_SOUND_FONT_DEFAULT) != 0) {
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    if (musicPatchSetPath(path, sizeof(path))) {
    +        return patLoadSet(path, bank);
    +    }
    +
    +    return musicRomBankPath(path, sizeof(path)) ? sf2Load(path, bank) : MUSIC_ERR_OPEN;
    +}
    +
    +
    +// A SoundFont's name as given, or the standard one's for "".
    +static const char *fontName(const char *name) {
    +    return name[0] ? name : MUSIC_SOUND_FONT_DEFAULT;
    +}
    +
    +
    +// A SoundFont found missing or damaged (rc) is said once, until one loads.
    +static void fontSaid(const char *want, int32_t rc) {
    +    if (rc == MUSIC_OK) {
    +        sFontFailed[0] = '\0';
    +        return;
    +    }
    +
    +    if (strcasecmp(sFontFailed, want) != 0) {
    +        dvxLog("Music: SoundFont %s: %s; MIDI music plays on the FM synthesizer", want, musicErrorText(rc));
    +        snprintf(sFontFailed, sizeof(sFontFailed), "%s", want);
    +    }
    +}
    +
    +
     // The music's volume times the master volume, for synthesizers the mixer
     // does not scale.
     static int32_t gainOf(const MusicT *m) {
    @@ -293,36 +543,49 @@ static bool isrSlotFree(void) {
     }
     
     
    -// The software synthesizer's stream: overflow notes from the FM chip, or
    -// the song itself, SW_CHUNK_FRAMES at a time.
    +// The software synthesizer's stream: overflow notes from the FM chip or
    +// the wavetable synthesizer (the ring), or the song itself (on the FM
    +// software synthesizer, or the SoundFont one with the FM one for what
    +// its bank lacks), SW_CHUNK_FRAMES at a time.  Nothing while paused:
    +// the stream's voice is held with what it has.
     static uint32_t midiFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData) {
         MusicT  *m   = (MusicT *)userData;
         int16_t *out = (int16_t *)buf;
     
         (void)stream;
     
    -    if (m->where == MUSIC_OUT_FM) {
    -        eventRingDrain(&m->ring, &m->swSynth->sink);
    +    if (m->isr.state == PLAY_PAUSED) {
    +        return 0;
         }
     
    +    eventRingDrain(&m->ring, &m->swSynth->sink);
    +
         for (uint32_t done = 0; done < frames;) {
             uint32_t n = frames - done < SW_CHUNK_FRAMES ? frames - done : SW_CHUNK_FRAMES;
     
    -        if (m->where == MUSIC_OUT_SOFTWARE && m->isr.state == PLAY_PLAYING && !m->draining) {
    +        if ((m->where == MUSIC_OUT_SOFTWARE || m->where == MUSIC_OUT_SOFTWAVE) && m->isr.state == PLAY_PLAYING && !m->draining) {
                 m->usAcc += (uint64_t)n * US_PER_SEC;
     
                 uint32_t us = (uint32_t)(m->usAcc / m->rate);
     
                 m->usAcc -= (uint64_t)us * m->rate;
     
    -            if (!seqAdvance(&m->isr.seq, us, &m->swSynth->sink)) {
    -                m->swSynth->sink.silence(&m->swSynth->sink);
    +            if (!seqAdvance(&m->isr.seq, us, m->isr.sink)) {
    +                m->isr.sink->silence(m->isr.sink);
                     m->draining = true;
                     m->endFrame = m->framesOut + done + n;
                 }
    +
    +            if (m->softFont) {
    +                waveSynthTick(m->wave, us);
    +            }
             }
     
             synthRender(m->chip, out + done * STREAM_CHANNELS, n, STREAM_CHANNELS);
    +
    +        if (m->softFont) {
    +            waveSoftRender(&m->softFont->soft, out + done * STREAM_CHANNELS, n, STREAM_CHANNELS);
    +        }
             done += n;
         }
     
    @@ -341,11 +604,17 @@ static void midiSeek(MusicT *m, uint32_t us) {
     }
     
     
    +// A module's frames; silence when stopped or at its end, nothing while
    +// paused (as midiFill).
     static uint32_t moduleFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData) {
         MusicT *m = (MusicT *)userData;
     
         (void)stream;
     
    +    if (m->isr.state == PLAY_PAUSED) {
    +        return 0;
    +    }
    +
         if (m->isr.state != PLAY_PLAYING || m->draining) {
             memset(buf, 0, (size_t)frames * STREAM_CHANNELS * sizeof(int16_t));
         } else if (!xmpRender(m->xmp, (int16_t *)buf, frames, m->loop)) {
    @@ -358,6 +627,55 @@ static uint32_t moduleFill(AudioStreamT *stream, void *buf, uint32_t frames, voi
     }
     
     
    +// Where a module plays: on the wavetable synthesizer's voices when it can
    +// (moduleWave) and DVX.INI [audio] modules does not say software, else on
    +// a stream mixed in software.
    +static int32_t moduleOpen(MusicT *m) {
    +    PrefsHandleT *prefs = prefsLoad(platformDvxIniPath());
    +    const char   *want  = audioModulesChoices[audioSettingModules(prefs)].iniName;
    +    bool          soft  = strcasecmp(want, AUDIO_MODULES_SOFTWARE) == 0;
    +
    +    prefsClose(prefs);
    +
    +    if (!soft && moduleWave(m) == MUSIC_OK) {
    +        return MUSIC_OK;
    +    }
    +
    +    m->where = MUSIC_OUT_STREAM;
    +    return openStream(m, MUSIC_MODULE_AHEAD_MS, moduleFill);
    +}
    +
    +
    +// The module on the wavetable synthesizer's voices, when it is free (see
    +// waveClaim) and the module plays there (modHwOpen); the SoundFont kept
    +// in the synthesizer's memory gives way when the samples do not fit
    +// beside it.
    +static int32_t moduleWave(MusicT *m) {
    +    AudioWtInfoT info;
    +
    +    if (!waveClaim(&info)) {
    +        return MUSIC_ERR_NO_DEVICE;
    +    }
    +
    +    int32_t rc = modHwOpen(sCtx, m->xmp, gainOf(m), &m->isr.mod);
    +
    +    if (rc == MODHW_ERR_ROOM && sWave) {
    +        waveFontFree(sWave);
    +        sWave = NULL;
    +        rc    = modHwOpen(sCtx, m->xmp, gainOf(m), &m->isr.mod);
    +    }
    +
    +    if (rc != MUSIC_OK) {
    +        return rc;
    +    }
    +
    +    sWaveUser = m;
    +    m->where  = MUSIC_OUT_WAVE;
    +    isrAdd(m);
    +    return MUSIC_OK;
    +}
    +
    +
     // The program running now (0: none, or no shell).
     int32_t musicAppCurrent(void) {
         return dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    @@ -368,6 +686,7 @@ int32_t musicAppCurrent(void) {
     void musicAppEnded(int32_t appId) {
         musicBasForget(appId);
         musicMmlAppEnded(appId);
    +    musicMidiRecordAppEnded(appId);
     
         MusicT *m = sMusics;
     
    @@ -395,56 +714,22 @@ int32_t musicAppEnter(int32_t appId) {
     }
     
     
    -// Loaded once (DVX.INI [audio] midiBank) and pinned (the timer reads it).
    -const Op2BankT *musicBank(int32_t *err) {
    -    if (sBank) {
    -        *err = MUSIC_OK;
    -        return sBank;
    -    }
    -
    -    PrefsHandleT *prefs   = prefsLoad(platformDvxIniPath());
    -    const char   *setting = prefsGetString(prefs, AUDIO_INI_SECTION, MUSIC_INI_BANK, "");
    -    char          path[DVX_MAX_PATH];
    -    Op2BankT     *bank    = (Op2BankT *)malloc(sizeof(Op2BankT));
    -    bool          ok      = false;
    -
    -    *err = bank ? MUSIC_ERR_BANK : MUSIC_ERR_NO_MEMORY;
    -
    -    // The bank DVX.INI names (in the banks folders, or by its path); the
    -    // bundled one when it names none, or one that is gone or damaged
    -    if (bank && setting[0]) {
    -        ok = platformKindFind(path, sizeof(path), DvxKindBanksE, setting) && bankRead(path, bank);
    -
    -        if (!ok) {
    -            dvxLog("Music: the MIDI bank %s is missing or damaged; using the standard one", setting);
    -        }
    -    }
    -
    -    ok = ok || (bank && bankRead(MUSIC_BANK_DEFAULT, bank));
    -    prefsClose(prefs);
    -
    -    if (ok && platformLockRegion(bank, sizeof(Op2BankT), false) == 0) {
    -        sBank = bank;
    -        *err  = MUSIC_OK;
    -    } else {
    -        dvxLog("Music: no usable MIDI instrument bank");
    -        free(bank);
    -    }
    -
    -    return sBank;
    -}
    -
    -
     // Anything from MUSIC_OK up is no error.
     const char *musicErrorText(int32_t err) {
         if (err >= MUSIC_OK) {
             return sErrorText[MUSIC_OK];
         }
     
    +    if (err == MUSIC_ERR_UNSUPPORTED) {
    +        return audioErrorText(AUDIO_ERR_UNSUPPORTED);
    +    }
    +
         return err > -MUSIC_ERR_COUNT ? sErrorText[-err] : ERROR_TEXT_UNKNOWN;
     }
     
     
    +// The context stays open a while for the SoundFont's samples (musicIdle
    +// closes it).
     void musicFree(MusicT *music) {
         if (!music) {
             return;
    @@ -452,19 +737,7 @@ void musicFree(MusicT *music) {
     
         MusicT *m = music;
     
    -    isrRemove(m);
    -    silence(m);
    -    audioStreamClose(m->stream);
    -
    -    // The port gets what is queued (the notes off) before it is let go.
    -    if (sMpuUser == m) {
    -        for (uint32_t i = 0; i < MPU_QUEUE_BYTES && m->mpu.tail != m->mpu.head; i++) {
    -            mpuSinkDrain(&m->mpu, MPU_SYSEX_GAP_US);
    -        }
    -
    -        audioMidiRelease(sCtx);
    -        sMpuUser = NULL;
    -    }
    +    outputClose(m);
     
         for (MusicT **mp = &sMusics; *mp; mp = &(*mp)->next) {
             if (*mp == m) {
    @@ -480,14 +753,17 @@ void musicFree(MusicT *music) {
     
         midiFileFree(&m->file);
         xmpClose(m->xmp);
    -    free(m->swSynth);
    -    free(m->chip);
    +    soundClose(m->sound);
    +    bankRelease(m->bank);
         platformUnlockRegion(m, sizeof(*m), false);
         free(m);
     
         if (!sMusics && sCtx) {
    -        audioClose(sCtx);
    -        sCtx = NULL;
    +        sQuietSince = platformMicros();
    +
    +        if (!sWave) {
    +            ctxClose();
    +        }
         }
     }
     
    @@ -501,6 +777,7 @@ void musicGetInfo(const MusicT *music, MusicInfoT *info) {
     
         AudioFmInfoT   fm;
         AudioMidiInfoT port;
    +    AudioWtInfoT   wt;
     
         info->kind       = music->kind;
         info->where      = music->where;
    @@ -512,7 +789,10 @@ void musicGetInfo(const MusicT *music, MusicInfoT *info) {
         if (music->xmp) {
             snprintf(info->title, sizeof(info->title), "%s", xmpTitle(music->xmp));
             info->lengthMs = xmpLength(music->xmp);
    +    } else if (music->sound) {
    +        info->lengthMs = (uint32_t)((uint64_t)soundFrames(music->sound) * MS_PER_SEC / soundRate(music->sound));
         } else {
    +        snprintf(info->title, sizeof(info->title), "%s", music->file.title);
             info->lengthMs = music->file.length / US_PER_MS;
         }
     
    @@ -531,6 +811,15 @@ void musicGetInfo(const MusicT *music, MusicInfoT *info) {
                 snprintf(info->output, sizeof(info->output), "FM software synthesizer");
                 break;
     
    +        case MUSIC_OUT_WAVE:
    +            audioWtGetInfo(&wt);
    +            snprintf(info->output, sizeof(info->output), "%s", wt.description);
    +            break;
    +
    +        case MUSIC_OUT_SOFTWAVE:
    +            snprintf(info->output, sizeof(info->output), "SoundFont software synthesizer");
    +            break;
    +
             case MUSIC_OUT_STREAM:
                 snprintf(info->output, sizeof(info->output), "Sound device");
                 break;
    @@ -546,6 +835,14 @@ uint32_t musicGetPosition(const MusicT *music) {
             return 0;
         }
     
    +    if (music->isr.mod) {
    +        return modHwPosition(music->isr.mod);
    +    }
    +
    +    if (music->sound) {
    +        return (uint32_t)((uint64_t)soundHeard(music) * MS_PER_SEC / soundRate(music->sound));
    +    }
    +
         return music->xmp ? xmpPosition(music->xmp) : music->isr.seq.now / US_PER_MS;
     }
     
    @@ -555,10 +852,15 @@ int32_t musicGetVolume(const MusicT *music) {
     }
     
     
    +// After a while with no music the SoundFont's samples are let go.
     void musicIdle(void *unused) {
         (void)unused;
         musicMmlIdle();
         musicPump();
    +
    +    if (!sMusics && sCtx && platformMicros() - sQuietSince >= FONT_KEEP_US) {
    +        ctxClose();
    +    }
     }
     
     
    @@ -567,26 +869,84 @@ bool musicIsPlaying(const MusicT *music) {
     }
     
     
    +// A WAV is read as it plays (soundOpenFile); anything else is read whole.
     int32_t musicLoad(const char *path, MusicT **out) {
    -    int32_t len  = 0;
    -    char   *data = path ? platformReadFile(path, &len) : NULL;
    +    SoundSrcT *src = NULL;
    +    int32_t    len = 0;
    +    int32_t    rc;
     
         *out = NULL;
     
    -    if (!data) {
    -        return path ? MUSIC_ERR_OPEN : MUSIC_ERR_ARG;
    +    if (!path) {
    +        return MUSIC_ERR_ARG;
         }
     
    -    int32_t rc = musicLoadMem(data, (uint32_t)len, out);
    +    rc = soundOpenFile(path, &src);
    +
    +    if (rc == MUSIC_OK) {
    +        return soundMusic(src, out);
    +    }
    +
    +    if (rc != MUSIC_ERR_FORMAT) {
    +        return rc;
    +    }
    +
    +    char *data = platformReadFile(path, &len);
    +
    +    if (!data) {
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    rc = musicLoadMem(data, (uint32_t)len, out);
     
         free(data);
         return rc;
     }
     
     
    -// MIDI by its header; anything else is offered to libxmp.
    +// The macro's notes as a MIDI file (mmlSmfBuild), loaded like one.
    +// Notes with no instrument (before any I, or after I0) are the piano's.
    +int32_t musicLoadMacro(const char *macro, MusicT **out) {
    +    MmlStateT st;
    +    MmlNoteT *notes = NULL;
    +    uint8_t  *smf   = NULL;
    +    uint32_t  size  = 0;
    +    int32_t   rc;
    +
    +    *out = NULL;
    +
    +    if (!macro) {
    +        return MUSIC_ERR_ARG;
    +    }
    +
    +    mmlStateInit(&st);
    +    rc = mmlParse(&st, macro, ¬es);
    +
    +    for (int32_t i = 0; i < (int32_t)arrlen(notes); i++) {
    +        if (notes[i].program == MML_PROGRAM_TONE) {
    +            notes[i].program = MML_PROGRAM_PIANO;
    +        }
    +    }
    +
    +    if (rc == MUSIC_OK) {
    +        rc = mmlSmfBuild(notes, (int32_t)arrlen(notes), &smf, &size);
    +    }
    +
    +    if (rc == MUSIC_OK) {
    +        rc = musicLoadMem(smf, size, out);
    +    }
    +
    +    free(smf);
    +    arrfree(notes);
    +    return rc;
    +}
    +
    +
    +// A WAV or VOC file, MIDI by its header; anything else is offered to
    +// libxmp.
     int32_t musicLoadMem(const void *data, uint32_t size, MusicT **out) {
         const uint8_t *bytes = (const uint8_t *)data;
    +    SoundSrcT     *src   = NULL;
     
         *out = NULL;
     
    @@ -594,8 +954,15 @@ int32_t musicLoadMem(const void *data, uint32_t size, MusicT **out) {
             return MUSIC_ERR_ARG;
         }
     
    -    bool    midi = midiIsFile(bytes, size);
    -    int32_t rc   = ctxEnsure();
    +    int32_t rc = soundOpenMem(bytes, size, &src);
    +
    +    if (rc != MUSIC_ERR_FORMAT) {
    +        return rc == MUSIC_OK ? soundMusic(src, out) : rc;
    +    }
    +
    +    bool midi = midiIsFile(bytes, size);
    +
    +    rc = ctxEnsure();
     
         if (rc != MUSIC_OK) {
             return rc;
    @@ -694,6 +1061,17 @@ int32_t musicMidiProgram(MusicT *music, int32_t channel, int32_t program) {
     }
     
     
    +int32_t musicMidiSend(MusicT *music, uint8_t status, uint8_t data1, uint8_t data2) {
    +    if ((status & MIDI_STATUS_BIT) == 0 || status >= MIDI_SYSEX) {
    +        return MUSIC_ERR_ARG;
    +    }
    +
    +    uint8_t type = status & MIDI_TYPE_MASK;
    +
    +    return sendMidi(music, status & MIDI_CHANNEL_MASK, type, data1, MIDI_ONE_DATA_BYTE(type) ? 0 : data2);
    +}
    +
    +
     // A new music of the calling program, pinned (the timer may read it),
     // in the list; NULL when out of memory.
     static MusicT *musicNew(int32_t kind) {
    @@ -726,6 +1104,7 @@ int32_t musicPause(MusicT *music) {
     
         music->isr.state = PLAY_PAUSED;
         silence(music);
    +    streamHold(music, true);
         return MUSIC_OK;
     }
     
    @@ -750,11 +1129,16 @@ int32_t musicPlay(MusicT *music, bool loop) {
         if (m->draining) {
             m->draining  = false;
             m->isr.state = PLAY_STOPPED;
    +        m->atEnd     = m->xmp || m->sound;
         }
     
    -    if (m->xmp) {
    -        if (m->isr.state == PLAY_STOPPED && xmpPosition(m->xmp) > 0) {
    -            xmpRestart(m->xmp);
    +    if (m->xmp || m->sound) {
    +        if (m->isr.state == PLAY_STOPPED && (m->atEnd || m->isr.ended)) {
    +            rewindToStart(m);
    +        }
    +
    +        if (m->isr.mod) {
    +            modHwFill(m->isr.mod, m->xmp, loop);
             }
         } else {
             m->isr.seq.loop = loop || m->kind == MUSIC_KIND_LIVE;
    @@ -764,6 +1148,12 @@ int32_t musicPlay(MusicT *music, bool loop) {
             }
         }
     
    +    // The silence a stopped stream was given is not heard first.
    +    if (m->isr.state == PLAY_STOPPED) {
    +        audioStreamFlush(m->stream);
    +    }
    +
    +    streamHold(m, false);
         m->isr.ended = false;
         m->isr.state = PLAY_PLAYING;
         return MUSIC_OK;
    @@ -784,6 +1174,10 @@ void musicPump(void) {
                 m->isr.state = PLAY_STOPPED;
                 m->isr.ended = true;
             }
    +
    +        if (m->isr.mod && m->isr.state == PLAY_PLAYING) {
    +            modHwFill(m->isr.mod, m->xmp, m->loop);
    +        }
         }
     
         while (again) {
    @@ -795,6 +1189,7 @@ void musicPump(void) {
                 }
     
                 m->isr.ended = false;
    +            m->atEnd     = m->xmp || m->sound;
     
                 if (m->endFn) {
                     int32_t prev = musicAppEnter(m->appId);
    @@ -814,11 +1209,44 @@ int32_t musicResume(MusicT *music) {
             return MUSIC_ERR_ARG;
         }
     
    +    streamHold(music, false);
         music->isr.state = PLAY_PLAYING;
         return MUSIC_OK;
     }
     
     
    +// Before the first play: the bank goes with the music (or back to
    +// DVX.INI's choices for NULL), read now so a missing one is said now.  A
    +// SoundFont is for the wavetable synthesizer (the FM bank stays), an OP2
    +// bank for the FM ones.
    +int32_t musicSetBank(MusicT *music, const char *bank) {
    +    MusicBankT *chosen = NULL;
    +    int32_t     rc     = MUSIC_OK;
    +
    +    if (!music || music->kind != MUSIC_KIND_MIDI || music->where != MUSIC_OUT_NONE) {
    +        return MUSIC_ERR_ARG;
    +    }
    +
    +    if (bank && soundFontValid(bank)) {
    +        snprintf(music->soundFont, sizeof(music->soundFont), "%s", bank);
    +        return MUSIC_OK;
    +    }
    +
    +    if (bank) {
    +        chosen = bankShared(bank, false, &rc);
    +
    +        if (!chosen) {
    +            return rc;
    +        }
    +    }
    +
    +    music->soundFont[0] = '\0';
    +    bankRelease(music->bank);
    +    music->bank = chosen;
    +    return MUSIC_OK;
    +}
    +
    +
     void musicSetEndFn(MusicT *music, MusicEndFnT fn, void *userData) {
         if (music) {
             music->endFn   = fn;
    @@ -832,18 +1260,30 @@ int32_t musicSetPosition(MusicT *music, uint32_t ms) {
             return MUSIC_ERR_ARG;
         }
     
    -    if (music->xmp) {
    +    if (music->sound) {
    +        soundSeek(music->sound, (uint32_t)((uint64_t)ms * soundRate(music->sound) / MS_PER_SEC));
    +    } else if (music->xmp) {
             xmpSeek(music->xmp, ms);
    -        return MUSIC_OK;
    -    }
     
    -    int32_t rc = outputOpen(music);
    +        if (music->isr.mod) {
    +            modHwFlush(music->isr.mod, xmpPosition(music->xmp));
    +            modHwFill(music->isr.mod, music->xmp, music->loop);
    +        }
    +    } else {
    +        int32_t rc = outputOpen(music);
    +
    +        if (rc != MUSIC_OK) {
    +            return rc;
    +        }
     
    -    if (rc == MUSIC_OK) {
             midiSeek(music, ms * US_PER_MS);
         }
     
    -    return rc;
    +    // What the stream held is from the old place: heard no more
    +    audioStreamFlush(music->stream);
    +    music->atEnd    = false;
    +    music->draining = false;
    +    return MUSIC_OK;
     }
     
     
    @@ -860,9 +1300,19 @@ int32_t musicSetVolume(MusicT *music, int32_t volume) {
     
         uint32_t flags = platformIrqSave();
     
    -    if (music->where == MUSIC_OUT_FM) {
    +    if (music->fmChip) {
             fmSynthSetGain(&music->hwSynth, gainOf(music));
    -    } else if (music->where == MUSIC_OUT_MPU401) {
    +    }
    +
    +    if (music->isr.wave) {
    +        waveSynthSetGain(music->wave, gainOf(music));
    +    }
    +
    +    if (music->isr.mod) {
    +        modHwSetGain(music->isr.mod, gainOf(music));
    +    }
    +
    +    if (music->where == MUSIC_OUT_MPU401) {
             mpuSinkSetGain(&music->mpu, gainOf(music));
         }
     
    @@ -871,7 +1321,8 @@ int32_t musicSetVolume(MusicT *music, int32_t volume) {
     }
     
     
    -// The library is being unloaded: free every music, the bank.
    +// The library is being unloaded: free every music, the banks, the
    +// context.
     void musicShutdown(void) {
         musicMmlShutdown();
     
    @@ -879,11 +1330,12 @@ void musicShutdown(void) {
             musicFree(sMusics);
         }
     
    -    if (sBank) {
    -        platformUnlockRegion(sBank, sizeof(Op2BankT), false);
    -        free(sBank);
    -        sBank = NULL;
    +    if (sCtx) {
    +        ctxClose();
         }
    +
    +    bankRelease(sBank);
    +    sBank = NULL;
     }
     
     
    @@ -896,9 +1348,11 @@ int32_t musicStop(MusicT *music) {
         music->isr.ended = false;
         music->draining  = false;
         silence(music);
    +    audioStreamFlush(music->stream);
    +    streamHold(music, false);
     
    -    if (music->xmp) {
    -        xmpRestart(music->xmp);
    +    if (music->xmp || music->sound) {
    +        rewindToStart(music);
         } else if (music->isr.sink) {
             midiSeek(music, 0);
         }
    @@ -907,6 +1361,27 @@ int32_t musicStop(MusicT *music) {
     }
     
     
    +// DVX steps aside for a DOS program (a platform suspend hook, run before
    +// audio.lib's): every music stops and lets go of where it plays (decided
    +// again at its next play), and with none left the context closes, so
    +// nothing of music.lib stays on the synthesizers the program may use.
    +// Nothing to do once DVX is back.
    +void musicSuspend(bool suspending) {
    +    if (!suspending) {
    +        return;
    +    }
    +
    +    for (MusicT *m = sMusics; m; m = m->next) {
    +        musicStop(m);
    +        outputClose(m);
    +    }
    +
    +    if (!sMusics && sCtx) {
    +        ctxClose();
    +    }
    +}
    +
    +
     // The software synthesizer (and its stream): for MIDI on it, or as the
     // FM chip's overflow.  Its gain is full: the stream's volume is the
     // music's.
    @@ -925,11 +1400,13 @@ static int32_t openSoftware(MusicT *m) {
     
         synthInit(m->chip, m->rate, SW_VOICES);
         synthBackend(m->chip, &soft);
    -    fmSynthInit(m->swSynth, &soft, sBank, NULL, true, MUSIC_VOLUME_MAX);
    +    fmSynthInit(m->swSynth, &soft, &m->bank->op2, NULL, true, MUSIC_VOLUME_MAX);
         return openStream(m, MIDI_STREAM_MS, midiFill);
     }
     
     
    +// A sound's stream has the file's rate and channels; the others are
    +// stereo at the device's rate.
     static int32_t openStream(MusicT *m, uint32_t bufferMs, AudioFillFnT fill) {
         AudioInfoT   info;
         AudioFormatT fmt;
    @@ -937,7 +1414,7 @@ static int32_t openStream(MusicT *m, uint32_t bufferMs, AudioFillFnT fill) {
         audioGetInfo(&info);
         fmt.rate     = m->rate ? m->rate : info.rate;
         fmt.bits     = STREAM_BITS;
    -    fmt.channels = STREAM_CHANNELS;
    +    fmt.channels = m->sound ? soundChannels(m->sound) : STREAM_CHANNELS;
     
         int32_t rc = audioStreamOpen(sCtx, &fmt, bufferMs, fill, m, &m->stream);
     
    @@ -951,6 +1428,49 @@ static int32_t openStream(MusicT *m, uint32_t bufferMs, AudioFillFnT fill) {
     }
     
     
    +// Undo outputOpen: the music leaves the timer, the synthesizers, its
    +// stream, and the MPU-401 port or the wavetable synthesizer it held (a
    +// module's samples leave its memory); where it plays is decided again at
    +// the next need.
    +static void outputClose(MusicT *m) {
    +    isrRemove(m);
    +    silence(m);
    +    audioStreamClose(m->stream);
    +
    +    // The port gets what is queued (the notes off) before it is let go.
    +    if (sMpuUser == m) {
    +        for (uint32_t i = 0; i < MPU_QUEUE_BYTES && m->mpu.tail != m->mpu.head; i++) {
    +            mpuSinkDrain(&m->mpu, MPU_SYSEX_GAP_US);
    +        }
    +
    +        audioMidiRelease(sCtx);
    +        sMpuUser = NULL;
    +    }
    +
    +    if (sWaveUser == m) {
    +        sWaveUser = NULL;
    +    }
    +
    +    waveDrop(m);
    +    modHwClose(m->isr.mod, m->xmp);
    +    free(m->swSynth);
    +    free(m->chip);
    +    eventRingInit(&m->ring);
    +    m->stream    = NULL;
    +    m->swSynth   = NULL;
    +    m->chip      = NULL;
    +    m->isr.sink  = NULL;
    +    m->isr.mpu   = NULL;
    +    m->isr.wave  = NULL;
    +    m->isr.mod   = NULL;
    +    m->fmChip    = false;
    +    m->draining  = false;
    +    m->usAcc     = 0;
    +    m->framesOut = 0;
    +    m->where     = MUSIC_OUT_NONE;
    +}
    +
    +
     // Where the music plays, decided and set up at the first need: a
     // module's stream, or where MIDI goes.
     static int32_t outputOpen(MusicT *m) {
    @@ -961,24 +1481,43 @@ static int32_t outputOpen(MusicT *m) {
         int32_t rc;
     
         if (m->kind == MUSIC_KIND_MODULE) {
    +        rc = moduleOpen(m);
    +    } else if (m->kind == MUSIC_KIND_SOUND) {
             m->where = MUSIC_OUT_STREAM;
    -        rc       = openStream(m, MODULE_STREAM_MS, moduleFill);
    +        rc       = openStream(m, MUSIC_MODULE_AHEAD_MS, soundFill);
         } else {
             rc = decideMidi(m);
         }
     
         if (rc != MUSIC_OK) {
    -        m->where = MUSIC_OUT_NONE;
    +        outputClose(m);
         }
     
         return rc;
     }
     
     
    +// A module or a sound back to its start (a module's frames queued for the
    +// synthesizer dropped).
    +static void rewindToStart(MusicT *m) {
    +    if (m->xmp) {
    +        xmpRestart(m->xmp);
    +    } else {
    +        soundSeek(m->sound, 0);
    +    }
    +
    +    if (m->isr.mod) {
    +        modHwFlush(m->isr.mod, 0);
    +    }
    +
    +    m->atEnd = false;
    +}
    +
    +
     // A live channel message to wherever the music plays (with interrupts
     // off while the timer may be sending too).
     static int32_t sendMidi(MusicT *m, int32_t channel, uint8_t type, int32_t data1, int32_t data2) {
    -    if (!m || m->kind == MUSIC_KIND_MODULE || channel < 0 || channel >= MUSIC_MIDI_CHANNELS || data1 < 0 || data1 > MUSIC_MIDI_DATA_MAX || data2 < 0 || data2 > MUSIC_MIDI_DATA_MAX) {
    +    if (!m || m->kind == MUSIC_KIND_MODULE || m->kind == MUSIC_KIND_SOUND || channel < 0 || channel >= MUSIC_MIDI_CHANNELS || data1 < 0 || data1 > MUSIC_MIDI_DATA_MAX || data2 < 0 || data2 > MUSIC_MIDI_DATA_MAX) {
             return MUSIC_ERR_ARG;
         }
     
    @@ -988,6 +1527,10 @@ static int32_t sendMidi(MusicT *m, int32_t channel, uint8_t type, int32_t data1,
             return rc;
         }
     
    +    if ((m->where == MUSIC_OUT_WAVE || m->where == MUSIC_OUT_SOFTWAVE) && type == MIDI_PROGRAM) {
    +        waveProgram(m, channel, data1);
    +    }
    +
         uint32_t flags = platformIrqSave();
     
         m->isr.sink->message(m->isr.sink, (uint8_t)(type | channel), (uint8_t)data1, (uint8_t)data2);
    @@ -996,8 +1539,14 @@ static int32_t sendMidi(MusicT *m, int32_t channel, uint8_t type, int32_t data1,
     }
     
     
    -// Every sounding note off now (MIDI; modules stop by themselves).
    +// Every sounding note off now (MIDI), a module's voices on the
    +// synthesizer stopped (the frame playing starts them again when it plays
    +// on; on a stream a module stops by itself).
     static void silence(MusicT *m) {
    +    if (m->isr.mod) {
    +        modHwSilence(m->isr.mod);
    +    }
    +
         if (m->isr.sink) {
             uint32_t flags = platformIrqSave();
     
    @@ -1007,6 +1556,176 @@ static void silence(MusicT *m) {
     }
     
     
    +// softOpen's SoftFontT sf filled: the SoundFont want names, the music's
    +// renderer, the song's samples, the backend.
    +static int32_t softLoad(MusicT *m, SoftFontT *sf, const char *want) {
    +    int32_t      rc = fontLoad(want, &sf->bank);
    +    WaveBackendT be;
    +
    +    if (rc == MUSIC_OK) {
    +        rc = waveSynthNew(m, sf->bank.sampleCount);
    +    }
    +
    +    if (rc == MUSIC_OK) {
    +        sf->loaded = (WaveLoadedT *)calloc(sf->bank.sampleCount ? sf->bank.sampleCount : 1, sizeof(WaveLoadedT));
    +        rc         = sf->loaded ? MUSIC_OK : MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    if (rc == MUSIC_OK) {
    +        for (uint32_t i = 0; i < sf->bank.sampleCount; i++) {
    +            sf->loaded[i].addr = WAVE_NOT_LOADED;
    +        }
    +
    +        waveNeedsSong(m, &sf->bank, m->waveNeed);
    +        rc = waveSoftLoad(&sf->soft, &sf->bank, sf->loaded, m->waveNeed);
    +    }
    +
    +    if (rc != MUSIC_OK) {
    +        return rc;
    +    }
    +
    +    waveSoftBackend(&sf->soft, m->rate, &be);
    +    waveSynthInit(m->wave, &be, &sf->bank, sf->loaded, &m->swSynth->sink, MUSIC_VOLUME_MAX);
    +    return MUSIC_OK;
    +}
    +
    +
    +// The SoundFont software synthesizer for the music (DVX.INI midi =
    +// softwave): its own copy of the SoundFont's headers, the song's samples
    +// in memory at their own rates, mixed into the stream after the FM
    +// software synthesizer, which plays the notes the bank lacks.
    +static int32_t softOpen(MusicT *m, const char *font) {
    +    const char *want = fontName(font);
    +    SoftFontT  *sf   = (SoftFontT *)calloc(1, sizeof(SoftFontT));
    +    int32_t     rc   = MUSIC_ERR_NO_MEMORY;
    +
    +    m->softFont = sf;
    +
    +    if (sf) {
    +        rc = softLoad(m, sf, want);
    +    }
    +
    +    fontSaid(want, rc);
    +
    +    if (rc != MUSIC_OK) {
    +        waveDrop(m);
    +    }
    +
    +    return rc;
    +}
    +
    +
    +// A sound's frames from where it is; at its end it starts over (loop),
    +// or, as a module does, notes the frame and plays silence (draining).  A
    +// file cut short ends where it ends.  Nothing while paused (as midiFill).
    +static uint32_t soundFill(AudioStreamT *stream, void *buf, uint32_t frames, void *userData) {
    +    MusicT  *m        = (MusicT *)userData;
    +    int16_t *out      = (int16_t *)buf;
    +    uint32_t channels = soundChannels(m->sound);
    +    uint32_t done     = 0;
    +
    +    (void)stream;
    +
    +    if (m->isr.state == PLAY_PAUSED) {
    +        return 0;
    +    }
    +
    +    if (m->isr.state == PLAY_PLAYING && !m->draining) {
    +        done = soundRead(m->sound, out, frames);
    +
    +        while (done < frames && m->loop && soundFrames(m->sound) > 0) {
    +            soundSeek(m->sound, 0);
    +
    +            uint32_t got = soundRead(m->sound, out + (size_t)done * channels, frames - done);
    +
    +            done += got;
    +
    +            if (got == 0) {
    +                break;
    +            }
    +        }
    +
    +        if (done < frames) {
    +            m->draining = true;
    +            m->endFrame = m->framesOut + done;
    +        }
    +    }
    +
    +    memset(out + (size_t)done * channels, 0, (size_t)(frames - done) * channels * sizeof(int16_t));
    +    m->framesOut += frames;
    +    return frames;
    +}
    +
    +
    +// The bank name names (as DVX.INI's soundFont would) is a whole
    +// SoundFont (or patch set).
    +static bool soundFontValid(const char *name) {
    +    WaveBankT bank;
    +    bool      ok = fontLoad(name, &bank) == MUSIC_OK;
    +
    +    if (ok) {
    +        waveBankFree(&bank);
    +    }
    +
    +    return ok;
    +}
    +
    +
    +// The frame being heard: the frame next read less what the stream still
    +// holds (a loop's turn counted back from the sound's end), or, once the
    +// end is rendered, the end less what is still to be heard of it.
    +// Stopped, the stream holds only silence: the frame next read.
    +static uint32_t soundHeard(const MusicT *m) {
    +    uint32_t frames = soundFrames(m->sound);
    +    uint32_t next   = soundTell(m->sound);
    +    uint32_t queued = m->stream ? audioStreamQueued(m->stream) : 0;
    +
    +    if (m->isr.state == PLAY_STOPPED) {
    +        return next;
    +    }
    +
    +    if (m->draining) {
    +        uint64_t heard = m->framesOut - queued;
    +        uint64_t left  = heard >= m->endFrame ? 0 : m->endFrame - heard;
    +
    +        return left >= frames ? 0 : frames - (uint32_t)left;
    +    }
    +
    +    if (queued <= next) {
    +        return next - queued;
    +    }
    +
    +    return m->loop && frames > 0 ? frames - (queued - next) % frames : 0;
    +}
    +
    +
    +// A music for a sound file's frames (it owns them from now on, and frees
    +// them when it cannot be made).
    +static int32_t soundMusic(SoundSrcT *src, MusicT **out) {
    +    int32_t rc = ctxEnsure();
    +    MusicT *m  = rc == MUSIC_OK ? musicNew(MUSIC_KIND_SOUND) : NULL;
    +
    +    if (!m) {
    +        soundClose(src);
    +        return rc == MUSIC_OK ? MUSIC_ERR_NO_MEMORY : rc;
    +    }
    +
    +    m->sound = src;
    +    m->rate  = soundRate(src);
    +    *out     = m;
    +    return MUSIC_OK;
    +}
    +
    +
    +// The music's stream held where it is (a pause) or let go on; no stream:
    +// nothing.
    +static void streamHold(const MusicT *m, bool hold) {
    +    if (m->stream) {
    +        audioVoicePause(sCtx, audioStreamVoice(m->stream), hold);
    +    }
    +}
    +
    +
     // The timer runs while any music is on the synthesizers; the code it
     // runs is pinned meanwhile.
     static void timerUpdate(void) {
    @@ -1028,3 +1747,234 @@ static void timerUpdate(void) {
             musicLock(false);
         }
     }
    +
    +
    +// The bank's arrays the synthesizer reads at interrupt time (presets,
    +// regions, samples), each pinned or let go; all are tried, so a failed
    +// pin is undone with the same call.
    +static bool waveBankLock(const WaveBankT *bank, bool lock) {
    +    const void     *arrays[] = { bank->presets, bank->regions, bank->samples };
    +    const uint32_t  sizes[]  = { bank->presetCount * sizeof(WavePresetT), bank->regionCount * sizeof(WaveRegionT), bank->sampleCount * sizeof(WaveSampleT) };
    +    bool            ok       = true;
    +
    +    for (uint32_t i = 0; i < sizeof(arrays) / sizeof(arrays[0]); i++) {
    +        if (arrays[i] && sizes[i]) {
    +            int32_t rc = lock ? platformLockRegion(arrays[i], sizes[i], false) : platformUnlockRegion(arrays[i], sizes[i], false);
    +
    +            ok = ok && rc == 0;
    +        }
    +    }
    +
    +    return ok;
    +}
    +
    +
    +// The wavetable synthesizer for a music about to play on it (*info
    +// filled): there, no other music playing on it (a stopped one gives it
    +// up), and room for one more player on the timer.
    +static bool waveClaim(AudioWtInfoT *info) {
    +    audioWtGetInfo(info);
    +
    +    if (!info->present || (sWaveUser && sWaveUser->isr.state != PLAY_STOPPED)) {
    +        return false;
    +    }
    +
    +    if (sWaveUser) {
    +        outputClose(sWaveUser);
    +    }
    +
    +    return isrSlotFree();
    +}
    +
    +
    +// The music's wavetable synthesizer and what it holds let go (whatever
    +// part of it there is).
    +static void waveDrop(MusicT *m) {
    +    if (m->wave) {
    +        platformUnlockRegion(m->wave, sizeof(*m->wave), false);
    +    }
    +
    +    if (m->softFont) {
    +        waveSoftFree(&m->softFont->soft);
    +        waveBankFree(&m->softFont->bank);
    +        free(m->softFont->loaded);
    +    }
    +
    +    free(m->softFont);
    +    free(m->wave);
    +    free(m->waveNeed);
    +    m->softFont = NULL;
    +    m->wave     = NULL;
    +    m->waveNeed = NULL;
    +}
    +
    +
    +// A SoundFont and its samples (in the library's context's memory) let go;
    +// NULL is ignored.
    +static void waveFontFree(WaveFontT *font) {
    +    if (!font) {
    +        return;
    +    }
    +
    +    waveCacheFree(sCtx, &font->cache);
    +    waveBankLock(&font->bank, false);
    +    waveBankFree(&font->bank);
    +    platformUnlockRegion(font, sizeof(*font), false);
    +    free(font);
    +}
    +
    +
    +// sWave as name names ("": the standard one), read in place of another
    +// (whose samples leave the synthesizer's memory).  One that is missing or
    +// damaged is said once.
    +static int32_t waveFontUse(const char *name) {
    +    const char *want = fontName(name);
    +    int32_t     rc;
    +
    +    if (sWave && strcasecmp(sWave->name, want) == 0) {
    +        return MUSIC_OK;
    +    }
    +
    +    waveFontFree(sWave);
    +    sWave = NULL;
    +
    +    WaveFontT *font = (WaveFontT *)calloc(1, sizeof(WaveFontT));
    +
    +    if (!font || platformLockRegion(font, sizeof(*font), false) != 0) {
    +        free(font);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    snprintf(font->name, sizeof(font->name), "%s", want);
    +    rc = fontLoad(want, &font->bank);
    +
    +    if (rc == MUSIC_OK) {
    +        rc = waveCacheInit(&font->cache, &font->bank);
    +    }
    +
    +    if (rc == MUSIC_OK && !waveBankLock(&font->bank, true)) {
    +        rc = MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    fontSaid(want, rc);
    +
    +    if (rc != MUSIC_OK) {
    +        waveFontFree(font);
    +        return rc;
    +    }
    +
    +    sWave = font;
    +    return MUSIC_OK;
    +}
    +
    +
    +// The samples the music needs from bank: the song's, or for live MIDI
    +// the first program and the standard kit (the rest as they are chosen).
    +static void waveNeedsSong(const MusicT *m, const WaveBankT *bank, uint8_t *need) {
    +    if (m->kind == MUSIC_KIND_LIVE) {
    +        waveNeedsPreset(bank, GM_BANK, GM_PROGRAM_FIRST, need);
    +        waveNeedsPreset(bank, SF_DRUM_BANK, GM_PROGRAM_FIRST, need);
    +    } else {
    +        waveNeedsFile(bank, &m->file, need);
    +    }
    +}
    +
    +
    +// The music on the wavetable synthesizer, when it is free (waveClaim):
    +// the SoundFont font names in place of another, the samples the song
    +// plays (live MIDI: the first program and the standard kit; the rest as
    +// they are chosen) in the synthesizer's memory.  Notes whose samples do
    +// not fit go to overflow.
    +static int32_t waveOpen(MusicT *m, const char *font, MidiSinkT *overflow) {
    +    AudioWtInfoT info;
    +    WaveBackendT hw;
    +    uint32_t     missing = 0;
    +    int32_t      rc;
    +
    +    if (!waveClaim(&info)) {
    +        return MUSIC_ERR_NO_DEVICE;
    +    }
    +
    +    rc = waveFontUse(font);
    +
    +    if (rc == MUSIC_OK) {
    +        rc = waveSynthNew(m, sWave->bank.sampleCount);
    +    }
    +
    +    if (rc == MUSIC_OK) {
    +        waveNeedsSong(m, &sWave->bank, m->waveNeed);
    +        rc = waveLoadNeeded(sCtx, &sWave->bank, &sWave->cache, m->waveNeed, info.flags, &missing);
    +
    +        if (rc != MUSIC_OK) {
    +            dvxLog("Music: the SoundFont's samples could not be put in the synthesizer: %s", musicErrorText(rc));
    +        }
    +    }
    +
    +    if (rc != MUSIC_OK) {
    +        waveDrop(m);
    +        return rc;
    +    }
    +
    +    if (missing > 0) {
    +        dvxLog("Music: %" PRIu32 " samples do not fit in the synthesizer's memory; their notes play on the FM synthesizer", missing);
    +    }
    +
    +    waveHwBackend(sCtx, &hw);
    +    waveSynthInit(m->wave, &hw, &sWave->bank, sWave->cache.loaded, overflow, gainOf(m));
    +    sWaveUser = m;
    +    return MUSIC_OK;
    +}
    +
    +
    +// A program chosen live on a wavetable synthesizer: its samples loaded
    +// when some are not there yet (on the chip the notes sounding are
    +// silenced first, as samples may move to make room).
    +static void waveProgram(MusicT *m, int32_t channel, int32_t program) {
    +    SoftFontT       *sf      = m->softFont;
    +    const WaveBankT *bank    = sf ? &sf->bank : &sWave->bank;
    +    uint16_t         bankNum = channel == MIDI_PERCUSSION ? SF_DRUM_BANK : m->wave->channels[channel].bankMsb;
    +    AudioWtInfoT     info;
    +    uint32_t         missing = 0;
    +
    +    if (waveNeedsPreset(bank, bankNum, (uint8_t)program, m->waveNeed) == 0) {
    +        return;
    +    }
    +
    +    // In software the samples join the others in memory
    +    if (sf) {
    +        if (waveSoftLoad(&sf->soft, bank, sf->loaded, m->waveNeed) != MUSIC_OK) {
    +            dvxLog("Music: program %" PRId32 "'s samples could not be read; its notes play on the FM synthesizer", program);
    +        }
    +
    +        return;
    +    }
    +
    +    // Its notes off before samples move in the synthesizer's memory (the
    +    // timer may be sending)
    +    uint32_t flags = platformIrqSave();
    +
    +    m->wave->sink.silence(&m->wave->sink);
    +    platformIrqRestore(flags);
    +    audioWtGetInfo(&info);
    +
    +    if (waveLoadNeeded(sCtx, &sWave->bank, &sWave->cache, m->waveNeed, info.flags, &missing) != MUSIC_OK || missing > 0) {
    +        dvxLog("Music: not every sample of program %" PRId32 " fits in the synthesizer's memory; those notes play on the FM synthesizer", program);
    +    }
    +}
    +
    +
    +// The music's renderer (pinned: the timer may run it) and its needs for
    +// a SoundFont of samples samples.
    +static int32_t waveSynthNew(MusicT *m, uint32_t samples) {
    +    WaveSynthT *wave = (WaveSynthT *)calloc(1, sizeof(WaveSynthT));
    +
    +    m->waveNeed = (uint8_t *)calloc(samples ? samples : 1, 1);
    +
    +    if (!wave || platformLockRegion(wave, sizeof(*wave), false) != 0) {
    +        free(wave);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    m->wave = wave;
    +    return m->waveNeed ? MUSIC_OK : MUSIC_ERR_NO_MEMORY;
    +}
    diff --git a/src/libs/kpunch/music/music.dhs b/src/libs/kpunch/music/music.dhs
    index 406915ac..5a4b851e 100644
    --- a/src/libs/kpunch/music/music.dhs
    +++ b/src/libs/kpunch/music/music.dhs
    @@ -31,17 +31,30 @@
     .index musicStop
     .index musicMidiOpen
     .index musicMidiNoteOn
    +.index musicMidiSend
    +.index musicMidiRecordNew
    +.index musicMidiRecordSave
    +.index MIDI recording
    +.index MidiRecordNew
    +.index MusicMidiSend
    +.index MUSIC_ERR_WRITE
     .index MusicInfoT
     .index MIDI
     .index MOD
     .index S3M
     .index XM
     .index IT
    +.index WAV
    +.index VOC
    +.index MUSIC_KIND_SOUND
    +.index MUSIC_ERR_UNSUPPORTED
     .index General MIDI
     .index MT-32
     .index GENMIDI
     .index OP2
     .index libxmp-lite
    +.index Modules on the wavetable synthesizer
    +.index musicModHw
     .index music.bas
     .index MusicLoad
     .index MusicPlay
    @@ -50,12 +63,29 @@
     .index MusicMmlT
     .index Music macro language
     .index musicBasPlay
    +.index musicLoadMacro
    +.index musicSetBank
    +.index MusicLoadMacro
    +.index MusicSetBank
    +.index SoundFont
    +.index SF2
    +.index SBK
    +.index DVXGM.SF2
    +.index Wavetable MIDI
    +.index Gravis UltraSound patches
    +.index ULTRASND.INI
    +.index SYNTHGM.SBK
    +.index musicPatchSetPath
    +.index musicRomBankPath
    +.index MusicPatchSet$
    +.index MusicRomBank$
    +.index softwave
     
     .h1 Music Library
     
    -Music for DVX programs on whatever sound hardware the computer has: tracker modules (MOD, S3M, XM, IT), Standard MIDI Files (type 0 and 1), and live MIDI a program sends itself. Every music belongs to the program that loaded it and is stopped and freed when the program ends, however it ends; any number of programs may play music at the same time.
    +Music for DVX programs on whatever sound hardware the computer has: tracker modules (MOD, S3M, XM, IT), Standard MIDI Files (type 0 and 1), WAV and VOC sound files, and live MIDI a program sends itself. Every music belongs to the program that loaded it and is stopped and freed when the program ends, however it ends; any number of programs may play music at the same time.
     
    -Header: music/music.h. Loaded as: bin/libs/kpunch/music/music.lib (needs audio.lib); the General MIDI instrument bank is bin/libs/kpunch/music/genmidi.op2.
    +Header: music/music.h. Loaded as: bin/libs/kpunch/music/music.lib (needs audio.lib); the General MIDI instrument bank for FM is bin/libs/kpunch/music/genmidi.op2, and the General MIDI SoundFont for wavetable music is an add-on (SHARED\BANKS\KPUNCH\DVXGM.SF2).
     
     .h2 Playing a File
     
    @@ -71,30 +101,35 @@ if (rc != MUSIC_OK) {
     }
     .endcode
     
    -musicLoad and musicLoadMem tell the kind by the contents: a MIDI file (or a RIFF RMID file around one) by its header, anything else is offered to the module player. Errors: MUSIC_ERR_OPEN (unreadable file), MUSIC_ERR_FORMAT (not music DVX plays, or a type 2 MIDI file), MUSIC_ERR_CORRUPT, MUSIC_ERR_NO_MEMORY, MUSIC_ERR_NO_DEVICE (no sound device could start), MUSIC_ERR_BANK (MIDI with no usable instrument bank and no MPU-401 port). Nothing plays until musicPlay(music, loop), which plays from the current position (the start after a load or a stop; a music that played to its end starts over). musicPause / musicResume, musicStop (back to the start), musicIsPlaying, musicSetVolume (0..MUSIC_VOLUME_MAX, per music; the master volume applies too - on the FM chip and the MPU-401 port as it was when the music started or its volume was last set), musicGetPosition / musicSetPosition (milliseconds), musicGetInfo (kind, where it plays, a description of the device, title, length, position, state), and musicFree complete the set. musicSetEndFn(music, fn, userData) asks for a call when a music that does not loop plays to its end; it comes in the shell's idle time as the program that owns the music, never from an interrupt.
    +musicLoad and musicLoadMem tell the kind by the contents: a WAV or VOC sound file (MUSIC_KIND_SOUND) or a MIDI file (or a RIFF RMID file around one) by its header, anything else is offered to the module player. A WAV loaded by its path is read from the disk as it plays (only its header is read at the load), so a long recording needs no memory; a VOC, or a WAV given in memory, is decoded into memory (audioDecode). Errors: MUSIC_ERR_OPEN (unreadable file), MUSIC_ERR_FORMAT (not music DVX plays, or a type 2 MIDI file), MUSIC_ERR_UNSUPPORTED (a sound file that is compressed or not 8- or 16-bit; its text is audio.lib's), MUSIC_ERR_CORRUPT, MUSIC_ERR_NO_MEMORY, MUSIC_ERR_NO_DEVICE (no sound device could start), MUSIC_ERR_BANK (MIDI with no usable instrument bank and no MPU-401 port). Nothing plays until musicPlay(music, loop), which plays from the current position (the start after a load or a stop; a music that played to its end starts over). musicPause / musicResume, musicStop (back to the start), musicIsPlaying, musicSetVolume (0..MUSIC_VOLUME_MAX, per music; the master volume applies too - on the FM chip and the MPU-401 port as it was when the music started or its volume was last set), musicGetPosition / musicSetPosition (milliseconds), musicGetInfo (kind, where it plays, a description of the device, title, length, position, state; the title is a module's own, a MIDI file's sequence name (the first track's name, control characters as spaces), and none for a sound), and musicFree complete the set. musicSetEndFn(music, fn, userData) asks for a call when a music that does not loop plays to its end; it comes in the shell's idle time as the program that owns the music, never from an interrupt.
     
    -Setting the position of a MIDI file sends the instruments and controllers set before that point (no notes). A module goes to the start of the pattern that holds the time.
    +musicLoadMacro(macro, &music) makes a MIDI music from a music macro string (the language PLAY takes, below): its notes play on the General MIDI instruments its I commands name, the piano for notes before any I or after I0, and it is then played, paused, and freed like a MIDI file (MUSIC_ERR_MACRO for a mistake in the string). musicSetBank(music, bank) plays one MIDI music on another instrument bank than DVX.INI's choice: bank names it as midiBank and soundFont do ("" the standard OP2 bank, NULL DVX.INI's choices again). A SoundFont (.SF2 or .SBK) or an UltraSound's ULTRASND.INI is for wavetable music (the FM bank stays DVX.INI's), any other bank for the FM synthesizers. The bank is read at once, so a missing or damaged one is MUSIC_ERR_BANK there and then; it must come before the music's first musicPlay (MUSIC_ERR_ARG for a module, live MIDI, or a music that has played). Control Panel's Instruments Test is musicLoadMacro and musicSetBank.
    +
    +Setting the position of a MIDI file sends the instruments and controllers set before that point (no notes). A module goes to the start of the pattern that holds the time; a sound goes to the frame.
     
     .h2 Where Music Plays
     
    -Modules are rendered by libxmp-lite into a stream of the sound device (16-bit stereo at the device's rate, 400 ms of buffer, linear interpolation), so they play on any device that plays sampled sound.
    +A module plays on the wavetable synthesizer's own voices (a Gravis UltraSound's, an AWE32's or AWE64's) when there is one and no other music plays on it (one music at a time, MIDI or a module; a stopped one gives it up): its samples go into the chip's memory and libxmp's voices are played on the chip's, nothing mixed in software (MUSIC_OUT_WAVE; see Modules). Otherwise libxmp-lite renders it into a stream of the sound device (16-bit stereo at the device's rate, 400 ms of buffer, linear interpolation), so it plays on any device that plays sampled sound (MUSIC_OUT_STREAM): with DVX.INI [audio] modules = software (audioModulesChoices; the Control Panel's Sounds page), when the module has more channels than the chip has voices or a stereo sample, or when its samples do not fit in the chip's memory (the SoundFont kept there for MIDI gives way first).
    +
    +A sound always plays on a stream (MUSIC_OUT_STREAM) at its own rate and channels, 16-bit, with the same 400 ms of buffer; the mixer converts it to the device's rate. A pause, a seek, or a stop of any music on a stream is heard at once: the stream's voice is held where it is (audioVoicePause) and, for a seek or a stop, what it holds is dropped (audioStreamFlush).
     
     MIDI goes to the best synthesizer there is, decided at the first musicPlay (or musicMidiOpen), with DVX.INI [audio] midi:
     
     .table
       midi       MIDI plays on
       ----       -------------
    -  auto       the FM chip (OPL2 / OPL3) when there is one and no program has taken the whole chip; else the software synthesizer
    +  auto       a wavetable synthesizer (a Gravis UltraSound, a Sound Blaster AWE32 or AWE64) when there is one, no other music is using it, and its SoundFont loads; else the FM chip (OPL2 / OPL3) when there is one and no program has taken the whole chip; else the software synthesizer
       mpu401     the MPU-401 port (mpuPort, default 330h) when it was found and no other music is using it; else as auto
    -  fm         as auto
    +  fm         the FM chip when there is one and it is free; else the software synthesizer
       software   the software synthesizer
    +  softwave   the SoundFont software synthesizer (below); else the software synthesizer
     .endtable
     
     On the FM chip and the MPU-401 port the song is played by the timer service (500 times a second), so it keeps time however busy the programs are. On the FM chip every note takes a voice from audio.lib's shared pool; when none is free the note plays on the software synthesizer instead, which then runs beside the chip (its stream is always open for that). The software synthesizer runs in the stream's fill function (the shell's idle time), 64 frames between event checks, 150 ms ahead of what is heard; a program that keeps the desktop busy every frame should call audioPump once a frame, as for any stream.
     
     .h2 General MIDI on FM
     
    -Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank (MUSIC_INI_BANK) names another OP2 file instead: a bank in SHARED\BANKS or the user's BANKS folder by its name (an add-on package's as VENDOR\NAME), or any OP2 file by its path from the DVX folder, for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. Control Panel's Sounds page lists the banks to choose from. A bank that is not there falls back to the bundled one (MUSIC_BANK_DEFAULT). The bank is read once, at the first MIDI music that needs it, so a new choice takes effect when DVX next starts.
    +Instruments come from an OP2 bank (the DMX "GENMIDI" format: 128 melodic instruments and 47 percussion instruments for notes 35 to 81, each one or two two-operator voices, with a note offset, a fixed note for percussion, and a detune for the second voice). DVX ships Freedoom's GENMIDI bank (see Licences below); DVX.INI [audio] midiBank (MUSIC_INI_BANK) names another OP2 file instead: a bank in SHARED\BANKS or the user's BANKS folder by its name (an add-on package's as VENDOR\NAME), or any OP2 file by its path from the DVX folder, for example a bank made with an OPL editor, or another freely licensed bank such as DMXOPL. Control Panel's Sounds page lists the banks to choose from; DVX's own add-ons offer DMXOPL and DMXOPL3 (MIT, by ConSiGno and Sneakernets) through Software. A bank that is not there falls back to the bundled one (MUSIC_BANK_DEFAULT). DVX.INI's choice is read as each MIDI music starts, so a new choice reaches the next one; a music keeps the bank it started with, and a bank is freed with the last music playing on it.
     
     What is played: note on / off, velocity, program change, channel volume (CC7), expression (CC11), pan (CC10: left, both, or right on an OPL3 and the software synthesizer), sustain pedal (CC64), pitch bend with the bend range from RPN 0 (default 2 semitones), all notes off (CC123, CC120), reset controllers (CC121), and General MIDI System On (a reset). Channel 10 plays percussion. Loudness is velocity x volume x expression x the music's volume on a 40 log10 curve, added as attenuation to the voice's level. OP2 banks are tuned for DMX, which sounds note n where MIDI has note n + 12, so notes are played an octave up from the bank's offsets. Not played: modulation (CC1), aftertouch, the FM chip's own rhythm mode, four-operator voices.
     
    @@ -102,6 +137,18 @@ What is played: note on / off, velocity, program change, channel volume (CC7), e
     
     When there is no FM chip (or it is busy), MIDI plays on DVX's own software FM synthesizer: an integer two-operator FM chip modelled on the OPL2 (the same log-sine and exponential tables, waveforms 0 to 3, feedback, the chip's envelope rates and key scaling, key scale level, tremolo, and vibrato) with 24 voices, rendering at the device's rate into a stream. It is driven with the same bank and the same register values as the chip, so music sounds alike on both. The host tests compare it with emu8950, an accurate OPL2 emulator, for several patches: pitch, loudness over time, and harmonics agree closely. Cost on a 486: roughly 1 to 2 percent of a 486DX2-66 per sounding voice at 22,050 Hz.
     
    +.h2 SoundFonts on a Wavetable Synthesizer
    +
    +A wavetable synthesizer (audio.lib's gus and guswt drivers for a Gravis UltraSound, awe for the EMU8000 of a Sound Blaster AWE32 or AWE64) plays recorded instruments from its own memory. Its instruments come from a SoundFont, DVX.INI [audio] soundFont (MUSIC_INI_SOUND_FONT), named as midiBank names an OP2 bank; not set, the standard one (MUSIC_SOUND_FONT_DEFAULT, KPUNCH\DVXGM.SF2: DVX's General MIDI SoundFont add-on, made from FluidR3_GM by mksf, about 3.4 MB). SoundFont 2 files are read in full; SoundFont 1 (.SBK, the AWE32's own) as far as it is known (it has no public specification). soundFont may also name a Gravis UltraSound's ULTRASND.INI by its path, which plays that folder's .PAT patches as instruments; and where the standard SoundFont is not installed, the patches in the folder ULTRADIR names play (musicPatchSetPath), else the AWE's own bank for its sample ROM (musicRomBankPath: SFBANK\SYNTHGM.SF2 or SYNTHGM.SBK in the folder SOUND names, as Creative's drivers installed it). Control Panel's Sounds page lists all of these.
    +
    +A song loads only the samples its notes play (the bank selects, programs, and notes are looked at first), each with the frames a chip's interpolation reads after it, into memory the synthesizer gives every program a share of; what a song loaded stays for the next while it fits, and room is made by giving up the samples used longest ago. When the samples still do not fit they are all loaded again smaller: 8-bit where the chip plays it, then at half the rate, then a quarter; at the least, the ones that do not fit are left out. Notes the bank lacks, or whose samples were left out, play on the FM chip or the software synthesizer, as do the notes beyond the synthesizer's voices. One music plays on the wavetable synthesizer at a time (another meanwhile plays on FM); its SoundFont and samples are kept 60 seconds after the last music (MUSIC_FONT_KEEP_SEC). Live MIDI loads a program's samples as it is chosen. A SoundFont made for an AWE's General MIDI ROM (ROM samples) plays from the card's ROM with nothing loaded; on any other synthesizer its notes go to FM.
    +
    +The AWE runs the SoundFont's envelopes, LFOs, filter, and reverb and chorus sends itself; on the GUS the library runs the envelopes and LFOs (its volume and pitch changed as the note goes on), without a filter or effects.
    +
    +.h2 The SoundFont Software Synthesizer
    +
    +With DVX.INI [audio] midi = softwave, MIDI plays from the SoundFont in software on any device that plays sampled sound: the song's samples are read into memory at their own rates and mixed into the music's stream (linear interpolation, the envelopes and LFOs as on the GUS, no filter or effects), with the software FM synthesizer beside it for the notes the bank lacks. It needs the memory for the song's samples (a General MIDI song of ten instruments and a drum kit about 360 KB of DVXGM's) and more CPU time than FM.
    +
     .h2 The MPU-401 Port
     
     With DVX.INI [audio] midi = mpu401, MIDI goes out of the MPU-401 port (audio.lib's mpu401 driver) to an external synthesizer, one music at a time (a second MIDI music meanwhile plays on the FM chip or the software synthesizer). Bytes are queued and sent by the timer as fast as the port takes them, with a 40 ms pause after every system exclusive message (first-generation MT-32s lose data without it). The music's volume scales every channel volume (CC7) sent. DVX.INI [audio] mpuMode says what is on the port:
    @@ -127,11 +174,34 @@ musicMidiNoteOn(keys, 0, 60, 100);             // middle C
     musicMidiNoteOff(keys, 0, 60);
     .endcode
     
    -musicMidiOpen opens a music with no file that plays at once, on the same synthesizer a MIDI file would use. musicMidiNoteOn, musicMidiNoteOff, musicMidiProgram, musicMidiControl, and musicMidiPitchBend (-8192..8191) send channel messages (channels 0 to 15, data 0 to 127) to it, or to a MIDI file that is playing (for dynamic game scores); MUSIC_ERR_ARG for a module or a value out of range.
    +musicMidiOpen opens a music with no file that plays at once, on the same synthesizer a MIDI file would use. musicMidiNoteOn, musicMidiNoteOff, musicMidiProgram, musicMidiControl, and musicMidiPitchBend (-8192..8191) send channel messages (channels 0 to 15, data 0 to 127) to it, or to a MIDI file that is playing (for dynamic game scores); MUSIC_ERR_ARG for a module or a value out of range. musicMidiSend(music, status, data1, data2) sends any channel message as MIDI input brings it (audioMidiInRead: status 80h..EFh with the channel in it; a data byte the message does not use is ignored), so what a keyboard plays sounds as it comes (MIDI thru).
    +
    +.h2 Recording MIDI
    +
    +.code
    +MusicMidiRecordT *take = NULL;
    +AudioMidiMsgT     msg;
    +
    +musicMidiRecordNew(&take);
    +
    +while (audioMidiInRead(audio, &msg)) {         // each tick, while recording
    +    musicMidiSend(keys, msg.status, msg.data1, msg.data2);
    +    musicMidiRecordAdd(take, msg.ms, msg.status, msg.data1, msg.data2);
    +}
    +
    +musicMidiRecordSave(take, "SONG.MID");
    +musicMidiRecordFree(take);
    +.endcode
    +
    +A MIDI recording keeps channel messages with their times (musicMidiRecordAdd(rec, ms, status, data1, data2), ms from the recording's start; a time before the last message's counts as the last's; MUSIC_ERR_ARG for anything but a channel message) and musicMidiRecordSave(rec, path) writes them as a Standard MIDI File: type 0, one track of 1,000 ticks a quarter note at 60 quarter notes a minute, so a tick is a millisecond. A note still held at the end is let go there, so the file never leaves a note sounding. MUSIC_ERR_WRITE when the file cannot be written (nothing is left of one not all written). musicMidiRecordCount and musicMidiRecordLength (the last message's time) say what it holds; musicMidiRecordFree frees it, and a program's recordings are freed when it ends. musicLoad plays the file. The music macros (below) are written into MIDI files by the same code.
     
     .h2 Modules
     
    -MOD (ProTracker and compatibles), S3M, XM, and IT through libxmp-lite 4.7.3 (thirdparty/libxmp-lite, unmodified; formats only, no depackers or ProWizard). Stereo separation is 50 percent (softer than the Amiga's hard left and right). The whole module is held in memory. libxmp works out pitches with floating point, so modules play in the shell's idle time, never at interrupt time; on a 486SX (no maths coprocessor) that goes through floating point emulation and is slow. Rough cost on a 486DX2-66 at 22,050 Hz: 5 percent for a 4-channel MOD, 20 to 25 percent for a 16-channel XM.
    +MOD (ProTracker and compatibles), S3M, XM, and IT through libxmp-lite 4.7.3 (thirdparty/libxmp-lite with DVX's patches, listed in its DVX-VERSION.md; formats only, no depackers or ProWizard). Stereo separation is 50 percent (softer than the Amiga's hard left and right). The whole module is held in memory. libxmp works out pitches with floating point, so it plays modules in the shell's idle time, never at interrupt time; on a 486SX (no maths coprocessor) that goes through floating point emulation and is slow. On a stream libxmp mixes every channel in software (linear interpolation, three multiplies a channel a frame).
    +
    +On a wavetable synthesizer (musicModHw.c) libxmp mixes nothing: DVX's hardware voice patch has it report each of its voices as every tick starts (the sample, where it is, its rate, volume, pan, and the loop it is in), and only move them on. Those ticks are made up to 400 ms ahead in the idle time, as the synthesizer's notes, and the music timer plays them on time (500 times a second: a note starts within 2 ms of its time). A voice starts on the chip where libxmp starts or moves it (a note, a sample offset, a retrigger, another sample or loop); otherwise its pitch, volume, and pan follow libxmp's each tick, and the chip's own interpolation plays it. The samples are stored as they are (an 8-bit one 8-bit where the chip plays those, as a GUS does), else all 8-bit. The chips loop only forward, so a ping-pong loop is stored unrolled: its way back after its end, each frame where libxmp reads it (an Impulse Tracker module's way back a frame shorter, as Impulse Tracker plays it). What the chips cannot do plays near enough: a ping-pong sustain loop goes forward, a sample played backwards with no loop goes forward, and Impulse Tracker's filters are not heard; a voice the chip has none free for (a program holds the rest) is not heard. A voice at full volume plays 8 dB down, with the chip's level (AudioWtInfoT) taken off too, so a module is about as loud as libxmp mixes it. A pause stops the voices, and the play goes on from the tick that was playing; musicGetPosition is the tick being heard.
    +
    +Measured on an emulated 486DX2-66 at 22,050 Hz, a 4-channel MOD with all four channels sounding takes about 49 percent of the CPU mixed in software and about 14 percent on a Gravis UltraSound's voices, most of that libxmp's own work each tick (about 1.3 ms of the 20).
     
     .h2 Music Macros
     
    @@ -148,11 +218,11 @@ Timing is QBasic's: a whole note is four beats of 60 / T seconds, each dot adds
     
     .h2 Several Programs at Once
     
    -Every music plays through one audio context of the library's own; audio.lib mixes all streams, so modules and software MIDI of any number of programs play together. FM voices are shared with every program's tones and FM music; the MPU-401 port serves one music. When a program ends, its musics stop and are freed and their FM voices and the port are given back; the others go on.
    +Every music plays through one audio context of the library's own; audio.lib mixes all streams, so modules and software MIDI of any number of programs play together. FM voices are shared with every program's tones and FM music; the MPU-401 port serves one music, and so does the wavetable synthesizer (MIDI or a module). When a program ends, its musics stop and are freed and their FM voices and the port are given back; the others go on.
     
     .h2 BASIC
     
    -src/include/basic/music.bas (DECLARE LIBRARY "music"): MusicLoad(fileName) and MusicMidiOpen() return a music number (0 on failure: MusicError$ says why); MusicPlay(music, loopIt), MusicStop, MusicPause, MusicResume, MusicPlaying, MusicVolume(music, percent), MusicPosition, MusicLength, MusicSeek(music, ms), MusicFree; live MIDI MusicNoteOn(music, channel, note, velocity), MusicNoteOff, MusicProgram, MusicControl, MusicPitchBend with channels 1 to 16 (10 for drums). A number only works for the program that got it. MusicGetVolume(music) (musicGetVolume in percent) and MusicInfo$(music, field) (musicGetInfo: "title", "output", "kind" = "module" / "midi" / "live" for MusicKindE, "where" = "" / "stream" / "mpu401" / "fm" / "software" for MusicOutputE, "paused" and "looping" as "-1" / "0"; any case, "" for another field). music.bas also declares the raw MIDI port calls of audio.lib (MidiPortInfo$, MidiAcquire, MidiPut, MidiRelease; see the sound library).
    +src/include/basic/music.bas (DECLARE LIBRARY "music"): MusicLoad(fileName) and MusicMidiOpen() return a music number (0 on failure: MusicError$ says why); MusicPlay(music, loopIt), MusicStop, MusicPause, MusicResume, MusicPlaying, MusicVolume(music, percent), MusicPosition, MusicLength, MusicSeek(music, ms), MusicFree; live MIDI MusicNoteOn(music, channel, note, velocity), MusicNoteOff, MusicProgram, MusicControl, MusicPitchBend with channels 1 to 16 (10 for drums), and MusicMidiSend(music, midiMsg) (musicMidiSend of a message packed as MidiInMessage gives it, AUDIO_MIDI_PACKED_*). MIDI recordings: MidiRecordNew (a recording number, 0 when all 8 are in use), MidiRecordAdd(recording, milliseconds, midiMsg), MidiRecordCount, MidiRecordLength, MidiRecordSave(recording, fileName) (True / False with MusicError$), MidiRecordFree. A number only works for the program that got it. MusicLoadMacro(macro) (musicLoadMacro, a music number) and MusicSetBank(music, bank) (musicSetBank, "" the standard bank, a SoundFont or an ULTRASND.INI for wavetable music; MusicError$ says when it is missing or damaged); MusicPatchSet$ and MusicRomBank$ (musicPatchSetPath, musicRomBankPath: "" when there is none). MusicGetVolume(music) (musicGetVolume in percent) and MusicInfo$(music, field) (musicGetInfo: "title", "output", "kind" = "module" / "midi" / "live" / "sound" for MusicKindE, "where" = "" / "stream" / "mpu401" / "fm" / "software" / "wave" / "softwave" for MusicOutputE, "paused" and "looping" as "-1" / "0"; any case, "" for another field). music.bas also declares the raw MIDI port calls of audio.lib (MidiPortInfo$, MidiAcquire, MidiPut, MidiRelease) and its MIDI input (MidiInOpen, MidiInClose, MidiInMessage, MidiInTime, MidiInClock, MidiInLost, and the MIDI_* CONSTs); see the sound library.
     
     The BASIC runtime itself calls musicBasBeep, musicBasSound(hz, ticks), musicBasPlay(macro), musicBasPlayCount, and musicBasBusy for the statements BEEP, SOUND, and PLAY and the PLAY(n) function; each BASIC program has its own macro queue. QBasic's X command and "=" numbers (given with VARPTR$) are filled in by the BASIC runtime before musicBasPlay is called, so the macro language itself has neither. SOUND's duration is in clock ticks (18.2 a second) and 0 stops the queue; BEEP is 800 Hz for a quarter second.
     
    @@ -162,6 +232,8 @@ libxmp-lite (Extended Module Player Lite), (C) 1996-2026 Claudio Matsuoka and Hi
     
     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.
     
    +The DVX General MIDI SoundFont add-on (DVXGM.SF2) is made from FluidR3_GM by Frank Wen (MIT licence); its notice is installed beside it (SHARED\BANKS\KPUNCH\DVXGM.TXT).
    +
     The GENMIDI instrument bank is from Freedoom 0.13.0, Copyright (C) 2001-2024 Contributors to the Freedoom project. All rights reserved. BSD 3-clause licence:
     
     Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. Neither the name of the Freedoom project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
    diff --git a/src/libs/kpunch/music/music.h b/src/libs/kpunch/music/music.h
    index 5e0d576c..a8e71bb0 100644
    --- a/src/libs/kpunch/music/music.h
    +++ b/src/libs/kpunch/music/music.h
    @@ -25,19 +25,29 @@
     // Music for DVX programs on whatever sound hardware the computer has:
     //
     //   Modules    tracker music (MOD, S3M, XM, IT), played by libxmp-lite
    -//              into a sound stream, so it plays on any card that plays
    -//              sampled sound.
    +//              on a wavetable synthesizer's own voices when it is free
    +//              (DVX.INI [audio] modules), else into a sound stream, so it
    +//              plays on any card that plays sampled sound.
    +//   Sounds     WAV and VOC files, played into a sound stream at their own
    +//              rate (a WAV loaded by its path is read from the disk as it
    +//              plays, so a long recording needs no memory), with
    +//              everything a module has: pause, seek, position, loop.
     //   MIDI       Standard MIDI Files (type 0 and 1), played by DVX's own
     //              sequencer on the best synthesizer there is: an external
     //              MIDI synthesizer on the MPU-401 port when DVX.INI [audio]
     //              midi = mpu401 (General MIDI, or a Roland MT-32 with
    -//              mpuMode), else the FM chip (OPL2 / OPL3) with the General
    -//              MIDI instrument bank (DVX.INI [audio] midiBank, an OP2
    -//              bank, default the bundled Freedoom GENMIDI bank), else the
    -//              library's own FM software synthesizer, which sounds like
    -//              the FM chip and plays on any card that plays sampled
    -//              sound.  When the FM chip runs out of voices, extra notes
    -//              play on the software synthesizer.
    +//              mpuMode), else a wavetable synthesizer (Gravis
    +//              UltraSound, Sound Blaster AWE32 / AWE64) with a General
    +//              MIDI SoundFont (DVX.INI [audio] soundFont), else the FM
    +//              chip (OPL2 / OPL3) with the General MIDI instrument bank
    +//              (DVX.INI [audio] midiBank, an OP2 bank, default the
    +//              bundled Freedoom GENMIDI bank), else the library's own FM
    +//              software synthesizer, which sounds like the FM chip and
    +//              plays on any card that plays sampled sound.  A wavetable
    +//              synthesizer's memory holds only the samples each song
    +//              plays; notes whose samples do not fit play on the FM chip.
    +//              When the FM chip runs out of voices, extra notes play on
    +//              the software synthesizer.
     //   Live MIDI  note on / off, program change, controllers and pitch bend
     //              sent by the program, on the same synthesizers (a music
     //              opened with musicMidiOpen, or one playing a file).
    @@ -48,13 +58,14 @@
     // Every music belongs to the program that loaded it; it is stopped and
     // freed when the program ends, however it ends.  Any number of programs
     // may play music at the same time (they share the FM chip's voices; the
    -// MPU-401 port plays one program's MIDI at a time and the others use the
    -// FM chip or the software synthesizer).
    +// MPU-401 port and the wavetable synthesizer each play one MIDI music at
    +// a time, and the others use the FM chip or the software synthesizer).
     //
     // MIDI played on the synthesizers runs from the timer service, so it
    -// keeps time however busy the programs are; modules and the software
    -// synthesizer are made in the shell's idle time (audioPump), a quarter of
    -// a second or so ahead.  Module playback uses floating point (libxmp):
    +// keeps time however busy the programs are; modules, sounds, and the
    +// software synthesizer are made in the shell's idle time (audioPump), a
    +// quarter of a second or so ahead (a pause, a seek, or a stop is heard at
    +// once: what the stream holds is held or dropped).  Module playback uses floating point (libxmp):
     // on a computer without a maths coprocessor it is slow.
     //
     // Functions returning int32_t return MUSIC_OK (0) on success and a
    @@ -78,14 +89,40 @@
     #define MUSIC_MIDI_BEND_MIN     (-8192)
     #define MUSIC_MIDI_BEND_MAX     8191
     
    -// Text sizes in MusicInfoT (with the terminating zero)
     // DVX.INI [audio] midiBank: the FM instrument bank, an OP2 file named as
     // the banks kind names it (NAME or VENDOR\NAME in SHARED\BANKS or the
     // user's BANKS folder; platformKindFind) or by its path.  Not set, or not
    -// found: the bundled one.  Read once, when MIDI first plays.
    +// found: the bundled one.  Read as each MIDI music starts, so a new
    +// choice reaches the next one (the musics playing keep theirs).
     #define MUSIC_INI_BANK          "midiBank"
     #define MUSIC_BANK_DEFAULT      "libs" DVX_PATH_SEP "kpunch" DVX_PATH_SEP "music" DVX_PATH_SEP "genmidi.op2"
     
    +// DVX.INI [audio] soundFont: the wavetable synthesizer's General MIDI
    +// instruments, a SoundFont 2 file named as midiBank names an OP2 bank.
    +// Not set: the standard one, DVX's General MIDI add-on.  Missing or
    +// damaged: MIDI music plays on the FM chip.
    +#define MUSIC_INI_SOUND_FONT     "soundFont"
    +#define MUSIC_SOUND_FONT_DEFAULT "KPUNCH" DVX_PATH_SEP "DVXGM.SF2"
    +
    +// soundFont (and musicSetBank) may also name a Gravis UltraSound's
    +// ULTRASND.INI by its path: that UltraSound folder's General MIDI patches
    +// (MIDI\*.PAT, as the INI maps them) play as a SoundFont's instruments
    +// would.  Where the standard SoundFont is not installed, the patches in
    +// the folder ULTRADIR names play, when it has them.
    +#define MUSIC_PATCH_SET_INI      "ULTRASND.INI"
    +#define MUSIC_PATCH_SET_ENV      "ULTRADIR"
    +
    +// soundFont may also name an AWE32's or AWE64's own General MIDI bank for
    +// its sample ROM (SYNTHGM.SF2, or the older SoundFont 1 SYNTHGM.SBK, from
    +// Creative's drivers): its instruments play from the card's ROM with
    +// nothing loaded into its memory (on other synthesizers their notes go to
    +// the FM synthesizer).  Creative's drivers keep it in SFBANK in the folder
    +// SOUND names; where neither the standard SoundFont nor UltraSound
    +// patches are found, that one plays.
    +#define MUSIC_ROM_BANK_ENV       "SOUND"
    +#define MUSIC_ROM_BANK_DIR       "SFBANK"
    +
    +// Text sizes in MusicInfoT (with the terminating zero)
     #define MUSIC_TITLE_MAX         64
     #define MUSIC_DESC_MAX          64
     
    @@ -99,7 +136,9 @@
     #define MUSIC_ERR_NO_DEVICE     (-6)    // no sound device, or nothing that can play this music
     #define MUSIC_ERR_BANK          (-7)    // the FM instrument bank is missing or damaged
     #define MUSIC_ERR_MACRO         (-8)    // a mistake in a music macro string
    -#define MUSIC_ERR_COUNT         9       // MUSIC_OK and the eight errors
    +#define MUSIC_ERR_UNSUPPORTED   (-9)    // a WAV or VOC file in a form not played (compressed, or not 8- or 16-bit)
    +#define MUSIC_ERR_WRITE         (-10)   // the file could not be written
    +#define MUSIC_ERR_COUNT         11      // MUSIC_OK and the ten errors
     
     // Opaque handle
     typedef struct MusicT    MusicT;
    @@ -109,21 +148,24 @@ typedef struct MusicMmlT MusicMmlT;
     typedef enum {
         MUSIC_KIND_MODULE = 0,      // MOD, S3M, XM, IT
         MUSIC_KIND_MIDI   = 1,      // a MIDI file
    -    MUSIC_KIND_LIVE   = 2       // live MIDI only (musicMidiOpen)
    +    MUSIC_KIND_LIVE   = 2,      // live MIDI only (musicMidiOpen)
    +    MUSIC_KIND_SOUND  = 3       // a WAV or VOC sound file
     } MusicKindE;
     
     // Where it plays
     typedef enum {
         MUSIC_OUT_NONE     = 0,     // not decided yet (before the first musicPlay)
    -    MUSIC_OUT_STREAM   = 1,     // modules: a sound stream
    +    MUSIC_OUT_STREAM   = 1,     // modules and sounds: a sound stream (mixed in software)
         MUSIC_OUT_MPU401   = 2,     // MIDI: the MPU-401 port
         MUSIC_OUT_FM       = 3,     // MIDI: the FM chip (extra notes on the software synthesizer)
    -    MUSIC_OUT_SOFTWARE = 4      // MIDI: the software FM synthesizer
    +    MUSIC_OUT_SOFTWARE = 4,     // MIDI: the software FM synthesizer
    +    MUSIC_OUT_WAVE     = 5,     // MIDI or a module: the wavetable synthesizer (MIDI notes whose samples do not fit there on the FM chip)
    +    MUSIC_OUT_SOFTWAVE = 6      // MIDI: the SoundFont software synthesizer (DVX.INI midi = softwave)
     } MusicOutputE;
     
     // A music (musicGetInfo).
     typedef struct {
    -    char     title[MUSIC_TITLE_MAX];        // the module's title ("" for MIDI files)
    +    char     title[MUSIC_TITLE_MAX];        // a module's own title or a MIDI file's first track's name ("" for none, and for sounds)
         char     output[MUSIC_DESC_MAX];        // for people, e.g. "OPL3 FM synthesizer at 388h"
         int32_t  kind;                          // MusicKindE
         int32_t  where;                         // MusicOutputE
    @@ -142,11 +184,35 @@ typedef void (*MusicEndFnT)(MusicT *music, void *userData);
     // Loading and playing
     // ============================================================
     
    -// Load a module or MIDI file (by its contents, not its name) from a file
    -// or from memory (copied).  Nothing plays until musicPlay.
    +// Load a module, a MIDI file, or a WAV or VOC sound file (by its
    +// contents, not its name) from a file or from memory (copied).  Nothing
    +// plays until musicPlay.  MUSIC_ERR_UNSUPPORTED: a sound file in a form
    +// that is not played (compressed, or not 8- or 16-bit).
     int32_t musicLoad(const char *path, MusicT **out);
     int32_t musicLoadMem(const void *data, uint32_t size, MusicT **out);
     
    +// A MIDI music from a music macro string (the language below): its notes
    +// on the General MIDI instruments its I commands name, the piano for
    +// notes before any I or after I0.  MUSIC_ERR_MACRO when it has a mistake.
    +int32_t musicLoadMacro(const char *macro, MusicT **out);
    +
    +// Play a MIDI music on another instrument bank than DVX.INI's choice:
    +// bank names one as DVX.INI's midiBank and soundFont do ("" = the
    +// standard OP2 bank, NULL = DVX.INI's choices again).  A SoundFont is for
    +// the wavetable synthesizer, an OP2 bank for the FM ones.  It is read now
    +// (MUSIC_ERR_BANK when it is missing or damaged).  Before the music's
    +// first musicPlay; MUSIC_ERR_ARG for a module, live MIDI, or a music that
    +// has played.
    +int32_t musicSetBank(MusicT *music, const char *bank);
    +
    +// The UltraSound's own patches: out = ULTRASND.INI in the folder ULTRADIR
    +// names, when it is there; false otherwise.
    +bool musicPatchSetPath(char *out, int32_t outSize);
    +
    +// The AWE's own ROM bank: out = SFBANK\SYNTHGM.SF2 (else SYNTHGM.SBK) in
    +// the folder SOUND names, when it is there; false otherwise.
    +bool musicRomBankPath(char *out, int32_t outSize);
    +
     // Stop music and free it.  NULL is ignored.
     void musicFree(MusicT *music);
     
    @@ -171,7 +237,8 @@ int32_t musicSetVolume(MusicT *music, int32_t volume);
     
     // Position and length in milliseconds.  Setting the position of a MIDI
     // file replays its settings (instruments, controllers) up to that point;
    -// a module goes to the start of the pattern that holds that time.
    +// a module goes to the start of the pattern that holds that time; a
    +// sound goes to that very frame.
     uint32_t musicGetPosition(const MusicT *music);
     int32_t  musicSetPosition(MusicT *music, uint32_t ms);
     
    @@ -206,6 +273,41 @@ int32_t musicMidiNoteOn(MusicT *music, int32_t channel, int32_t note, int32_t ve
     int32_t musicMidiPitchBend(MusicT *music, int32_t channel, int32_t bend);
     int32_t musicMidiProgram(MusicT *music, int32_t channel, int32_t program);
     
    +// Any channel message, as MIDI input brings it (audioMidiInRead): status
    +// 80h..EFh (the kind and the channel 0..15) and its data bytes (one a
    +// message does not use is ignored); MUSIC_ERR_ARG for anything else.
    +// Plays what a keyboard plays (MIDI thru).
    +int32_t musicMidiSend(MusicT *music, uint8_t status, uint8_t data1, uint8_t data2);
    +
    +// ============================================================
    +// Recording MIDI
    +// ============================================================
    +//
    +// A MIDI recording: channel messages with their times, written as a
    +// Standard MIDI File (type 0, a tick a millisecond) that musicLoad plays.
    +// It belongs to the program that made it and goes when that program
    +// ends.
    +
    +typedef struct MusicMidiRecordS MusicMidiRecordT;
    +
    +int32_t musicMidiRecordNew(MusicMidiRecordT **out);
    +
    +// A channel message (as musicMidiSend takes) at ms milliseconds from the
    +// recording's start; a time before the last message's counts as the
    +// last's.  MUSIC_ERR_ARG for anything else.
    +int32_t musicMidiRecordAdd(MusicMidiRecordT *rec, uint32_t ms, uint8_t status, uint8_t data1, uint8_t data2);
    +
    +// The messages kept, and the time of the last (0 when none).
    +uint32_t musicMidiRecordCount(const MusicMidiRecordT *rec);
    +uint32_t musicMidiRecordLength(const MusicMidiRecordT *rec);
    +
    +// Write it to path (MUSIC_ERR_WRITE when it cannot be written; nothing is
    +// left of a file not all written).  Notes still held at the end are let
    +// go there.
    +int32_t musicMidiRecordSave(const MusicMidiRecordT *rec, const char *path);
    +
    +void musicMidiRecordFree(MusicMidiRecordT *rec);
    +
     // ============================================================
     // Music macros
     // ============================================================
    @@ -286,7 +388,15 @@ int32_t musicBasSound(double hz, double ticks);
     // ============================================================
     
     // Each BASIC program's musics are numbered from 1 (0 = MUSIC_FAILED;
    -// MusicError$ says why).  Volumes are percent (0..100).
    +// MusicError$ says why), and its MIDI recordings the same way.  Volumes
    +// are percent (0..100).  A MIDI message is packed as MidiInMessage gives
    +// it (AUDIO_MIDI_PACKED_*).
    +void        MidiRecordAdd(int32_t handle, int32_t milliseconds, int32_t midiMsg);
    +int32_t     MidiRecordCount(int32_t handle);
    +void        MidiRecordFree(int32_t handle);
    +int32_t     MidiRecordLength(int32_t handle);
    +int32_t     MidiRecordNew(void);
    +int32_t     MidiRecordSave(int32_t handle, const char *fileName);
     void        MusicControl(int32_t handle, int32_t channel, int32_t controller, int32_t value);
     const char *MusicError(void);
     void        MusicFree(int32_t handle);
    @@ -294,9 +404,12 @@ int32_t     MusicGetVolume(int32_t handle);
     const char *MusicInfo(int32_t handle, const char *field);
     int32_t     MusicLength(int32_t handle);
     int32_t     MusicLoad(const char *fileName);
    +int32_t     MusicLoadMacro(const char *macro);
     int32_t     MusicMidiOpen(void);
    +void        MusicMidiSend(int32_t handle, int32_t midiMsg);
     void        MusicNoteOff(int32_t handle, int32_t channel, int32_t note);
     void        MusicNoteOn(int32_t handle, int32_t channel, int32_t note, int32_t velocity);
    +const char *MusicPatchSet(void);
     void        MusicPause(int32_t handle);
     void        MusicPitchBend(int32_t handle, int32_t channel, int32_t bend);
     void        MusicPlay(int32_t handle, int32_t loopIt);
    @@ -304,7 +417,9 @@ int32_t     MusicPlaying(int32_t handle);
     int32_t     MusicPosition(int32_t handle);
     void        MusicProgram(int32_t handle, int32_t channel, int32_t program);
     void        MusicResume(int32_t handle);
    +const char *MusicRomBank(void);
     void        MusicSeek(int32_t handle, int32_t milliseconds);
    +void        MusicSetBank(int32_t handle, const char *bank);
     void        MusicStop(int32_t handle);
     void        MusicVolume(int32_t handle, int32_t volume);
     
    diff --git a/src/libs/kpunch/music/musicBas.c b/src/libs/kpunch/music/musicBas.c
    index 16d95467..91d02f89 100644
    --- a/src/libs/kpunch/music/musicBas.c
    +++ b/src/libs/kpunch/music/musicBas.c
    @@ -26,9 +26,10 @@
     // signatures use only BASIC-marshalable types.  A BASIC program refers
     // to its musics by number (1..BAS_SLOTS, 0 = failed; MusicError$ says
     // why); a number only works for the program that got it.  MIDI channels
    -// are 1..16 in BASIC (10 is percussion), volumes percent.  The library
    -// frees a program's musics when it ends (musicAppEnded, which also
    -// forgets their numbers here).  The musicBas... functions are BASIC's own
    +// are 1..16 in BASIC (10 is percussion), volumes percent.  MIDI
    +// recordings are numbered the same way, in a table of their own.  The
    +// library frees a program's musics and recordings when it ends
    +// (musicAppEnded, which also forgets their numbers here).  The musicBas... functions are BASIC's own
     // BEEP, SOUND and PLAY statements, called by the BASIC runtime, on the
     // program's macro queue.
     
    @@ -40,6 +41,8 @@
     #include 
     
     #define BAS_SLOTS           64
    +#define BAS_RECORD_SLOTS    8
    +#define BYTE_MASK           0xFF
     #define BAS_FIRST_CHANNEL   1
     #define PERCENT_MAX         100
     #define BAS_INFO_MAX        (MUSIC_TITLE_MAX + MUSIC_DESC_MAX)
    @@ -55,6 +58,11 @@ typedef struct {
         int32_t appId;
     } BasSlotT;
     
    +typedef struct {
    +    MusicMidiRecordT *rec;
    +    int32_t           appId;
    +} BasRecordSlotT;
    +
     // MusicInfo$ fields
     typedef enum {
         InfoTitleE,
    @@ -69,47 +77,62 @@ typedef enum {
     // MusicInfo$ field names, and the words for MusicKindE and MusicOutputE
     // in their enum order
     static const char *const sInfoNames[InfoCountE] = { "title", "output", "kind", "where", "paused", "looping" };
    -static const char *const sKindWords[]           = { "module", "midi", "live" };
    -static const char *const sWhereWords[]          = { "", "stream", "mpu401", "fm", "software" };
    +static const char *const sKindWords[]           = { "module", "midi", "live", "sound" };
    +static const char *const sWhereWords[]          = { "", "stream", "mpu401", "fm", "software", "wave", "softwave" };
     
    -static BasSlotT sSlots[BAS_SLOTS];
    -static int32_t  sLastError = MUSIC_OK;
    -static char     sInfo[BAS_INFO_MAX];
    +static BasSlotT       sSlots[BAS_SLOTS];
    +static BasRecordSlotT sRecords[BAS_RECORD_SLOTS];
    +static int32_t        sLastError = MUSIC_OK;
    +static char           sInfo[BAS_INFO_MAX];
     
     // ============================================================
     // Prototypes
     // ============================================================
     
    -static int32_t     basAdd(MusicT *music, int32_t rc);
    -static MusicT     *basGet(int32_t handle);
    -static int32_t     basResult(int32_t rc);
    -static const char *basWord(const char *const *words, int32_t count, int32_t value);
    -int32_t            musicBasBeep(void);
    -bool               musicBasBusy(void);
    -void               musicBasForget(int32_t appId);
    -int32_t            musicBasPlay(const char *macro);
    -int32_t            musicBasPlayCount(void);
    -int32_t            musicBasSound(double hz, double ticks);
    -void               MusicControl(int32_t handle, int32_t channel, int32_t controller, int32_t value);
    -const char        *MusicError(void);
    -void               MusicFree(int32_t handle);
    -int32_t            MusicGetVolume(int32_t handle);
    -const char        *MusicInfo(int32_t handle, const char *field);
    -int32_t            MusicLength(int32_t handle);
    -int32_t            MusicLoad(const char *fileName);
    -int32_t            MusicMidiOpen(void);
    -void               MusicNoteOff(int32_t handle, int32_t channel, int32_t note);
    -void               MusicNoteOn(int32_t handle, int32_t channel, int32_t note, int32_t velocity);
    -void               MusicPause(int32_t handle);
    -void               MusicPitchBend(int32_t handle, int32_t channel, int32_t bend);
    -void               MusicPlay(int32_t handle, int32_t loopIt);
    -int32_t            MusicPlaying(int32_t handle);
    -int32_t            MusicPosition(int32_t handle);
    -void               MusicProgram(int32_t handle, int32_t channel, int32_t program);
    -void               MusicResume(int32_t handle);
    -void               MusicSeek(int32_t handle, int32_t milliseconds);
    -void               MusicStop(int32_t handle);
    -void               MusicVolume(int32_t handle, int32_t volume);
    +static int32_t            basAdd(MusicT *music, int32_t rc);
    +static int32_t            basAppId(void);
    +static MusicT            *basGet(int32_t handle);
    +static uint8_t            basPacked(int32_t midiMsg, int32_t shift);
    +static MusicMidiRecordT  *basRecord(int32_t handle);
    +static int32_t            basResult(int32_t rc);
    +static const char        *basWord(const char *const *words, int32_t count, int32_t value);
    +void                      MidiRecordAdd(int32_t handle, int32_t milliseconds, int32_t midiMsg);
    +int32_t                   MidiRecordCount(int32_t handle);
    +void                      MidiRecordFree(int32_t handle);
    +int32_t                   MidiRecordLength(int32_t handle);
    +int32_t                   MidiRecordNew(void);
    +int32_t                   MidiRecordSave(int32_t handle, const char *fileName);
    +int32_t                   musicBasBeep(void);
    +bool                      musicBasBusy(void);
    +void                      musicBasForget(int32_t appId);
    +int32_t                   musicBasPlay(const char *macro);
    +int32_t                   musicBasPlayCount(void);
    +int32_t                   musicBasSound(double hz, double ticks);
    +void                      MusicControl(int32_t handle, int32_t channel, int32_t controller, int32_t value);
    +const char               *MusicError(void);
    +void                      MusicFree(int32_t handle);
    +int32_t                   MusicGetVolume(int32_t handle);
    +const char               *MusicInfo(int32_t handle, const char *field);
    +int32_t                   MusicLength(int32_t handle);
    +int32_t                   MusicLoad(const char *fileName);
    +int32_t                   MusicLoadMacro(const char *macro);
    +int32_t                   MusicMidiOpen(void);
    +void                      MusicMidiSend(int32_t handle, int32_t midiMsg);
    +void                      MusicNoteOff(int32_t handle, int32_t channel, int32_t note);
    +void                      MusicNoteOn(int32_t handle, int32_t channel, int32_t note, int32_t velocity);
    +const char               *MusicPatchSet(void);
    +void                      MusicPause(int32_t handle);
    +void                      MusicPitchBend(int32_t handle, int32_t channel, int32_t bend);
    +void                      MusicPlay(int32_t handle, int32_t loopIt);
    +int32_t                   MusicPlaying(int32_t handle);
    +int32_t                   MusicPosition(int32_t handle);
    +void                      MusicProgram(int32_t handle, int32_t channel, int32_t program);
    +void                      MusicResume(int32_t handle);
    +const char               *MusicRomBank(void);
    +void                      MusicSeek(int32_t handle, int32_t milliseconds);
    +void                      MusicSetBank(int32_t handle, const char *bank);
    +void                      MusicStop(int32_t handle);
    +void                      MusicVolume(int32_t handle, int32_t volume);
     
     
     // A number for a newly loaded music (rc its load result), or 0.
    @@ -118,7 +141,7 @@ static int32_t basAdd(MusicT *music, int32_t rc) {
             return basResult(rc);
         }
     
    -    int32_t appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    +    int32_t appId = basAppId();
     
         for (int32_t i = 0; i < BAS_SLOTS; i++) {
             if (!sSlots[i].music) {
    @@ -134,12 +157,16 @@ static int32_t basAdd(MusicT *music, int32_t rc) {
     }
     
     
    +// The program calling.
    +static int32_t basAppId(void) {
    +    return dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    +}
    +
    +
     // The calling program's music with this number, else NULL (and the error
     // is noted).
     static MusicT *basGet(int32_t handle) {
    -    int32_t appId = dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    -
    -    if (handle < 1 || handle > BAS_SLOTS || !sSlots[handle - 1].music || sSlots[handle - 1].appId != appId) {
    +    if (handle < 1 || handle > BAS_SLOTS || !sSlots[handle - 1].music || sSlots[handle - 1].appId != basAppId()) {
             sLastError = MUSIC_ERR_ARG;
             return NULL;
         }
    @@ -148,6 +175,24 @@ static MusicT *basGet(int32_t handle) {
     }
     
     
    +// One byte of a message packed as MidiInMessage gives it.
    +static uint8_t basPacked(int32_t midiMsg, int32_t shift) {
    +    return (uint8_t)((midiMsg >> shift) & BYTE_MASK);
    +}
    +
    +
    +// The calling program's MIDI recording with this number, else NULL (and
    +// the error is noted).
    +static MusicMidiRecordT *basRecord(int32_t handle) {
    +    if (handle < 1 || handle > BAS_RECORD_SLOTS || !sRecords[handle - 1].rec || sRecords[handle - 1].appId != basAppId()) {
    +        sLastError = MUSIC_ERR_ARG;
    +        return NULL;
    +    }
    +
    +    return sRecords[handle - 1].rec;
    +}
    +
    +
     // 0 with the error kept for MusicError$.
     static int32_t basResult(int32_t rc) {
         sLastError = rc;
    @@ -161,6 +206,61 @@ static const char *basWord(const char *const *words, int32_t count, int32_t valu
     }
     
     
    +// A message packed as MidiInMessage gives it, at milliseconds from the
    +// recording's start (MidiInTime).
    +void MidiRecordAdd(int32_t handle, int32_t milliseconds, int32_t midiMsg) {
    +    uint32_t ms = milliseconds < 0 ? 0 : (uint32_t)milliseconds;
    +
    +    sLastError = musicMidiRecordAdd(basRecord(handle), ms, basPacked(midiMsg, 0), basPacked(midiMsg, AUDIO_MIDI_PACKED_DATA1_SHIFT), basPacked(midiMsg, AUDIO_MIDI_PACKED_DATA2_SHIFT));
    +}
    +
    +
    +int32_t MidiRecordCount(int32_t handle) {
    +    return (int32_t)musicMidiRecordCount(basRecord(handle));
    +}
    +
    +
    +void MidiRecordFree(int32_t handle) {
    +    MusicMidiRecordT *rec = basRecord(handle);
    +
    +    if (rec) {
    +        musicMidiRecordFree(rec);
    +        sRecords[handle - 1].rec = NULL;
    +    }
    +}
    +
    +
    +int32_t MidiRecordLength(int32_t handle) {
    +    return (int32_t)musicMidiRecordLength(basRecord(handle));
    +}
    +
    +
    +// A number for a new recording, or 0.
    +int32_t MidiRecordNew(void) {
    +    for (int32_t i = 0; i < BAS_RECORD_SLOTS; i++) {
    +        if (!sRecords[i].rec) {
    +            int32_t rc = musicMidiRecordNew(&sRecords[i].rec);
    +
    +            if (rc != MUSIC_OK) {
    +                return basResult(rc);
    +            }
    +
    +            sRecords[i].appId = basAppId();
    +            sLastError        = MUSIC_OK;
    +            return i + 1;
    +        }
    +    }
    +
    +    return basResult(MUSIC_ERR_NO_MEMORY);
    +}
    +
    +
    +int32_t MidiRecordSave(int32_t handle, const char *fileName) {
    +    sLastError = musicMidiRecordSave(basRecord(handle), fileName);
    +    return sLastError == MUSIC_OK ? BAS_TRUE : BAS_FALSE;
    +}
    +
    +
     int32_t musicBasBeep(void) {
         return musicMmlTone(musicMmlBasic(true), QB_BEEP_MILLIHZ, QB_BEEP_US);
     }
    @@ -171,13 +271,20 @@ bool musicBasBusy(void) {
     }
     
     
    -// The program ended (its musics are being freed): forget their numbers.
    +// The program ended (its musics and recordings are being freed): forget
    +// their numbers.
     void musicBasForget(int32_t appId) {
         for (int32_t i = 0; i < BAS_SLOTS; i++) {
             if (sSlots[i].appId == appId) {
                 sSlots[i].music = NULL;
             }
         }
    +
    +    for (int32_t i = 0; i < BAS_RECORD_SLOTS; i++) {
    +        if (sRecords[i].appId == appId) {
    +            sRecords[i].rec = NULL;
    +        }
    +    }
     }
     
     
    @@ -236,9 +343,9 @@ int32_t MusicGetVolume(int32_t handle) {
     
     // A fact about the music by name (any case): "title" (a module's own
     // title), "output" (where it plays, for people), "kind" ("module",
    -// "midi", "live"), "where" ("stream", "mpu401", "fm", "software"; ""
    -// before it first plays), "paused" and "looping" (True / False as text);
    -// "" for another field or no such music.
    +// "midi", "live", "sound"), "where" ("stream", "mpu401", "fm", "software",
    +// "wave", "softwave"; "" before it first plays), "paused" and "looping"
    +// (True / False as text); "" for another field or no such music.
     const char *MusicInfo(int32_t handle, const char *field) {
         MusicT    *m   = basGet(handle);
         int32_t    idx = -1;
    @@ -301,6 +408,14 @@ int32_t MusicLoad(const char *fileName) {
     }
     
     
    +int32_t MusicLoadMacro(const char *macro) {
    +    MusicT *m  = NULL;
    +    int32_t rc = musicLoadMacro(macro, &m);
    +
    +    return basAdd(m, rc);
    +}
    +
    +
     int32_t MusicMidiOpen(void) {
         MusicT *m  = NULL;
         int32_t rc = musicMidiOpen(&m);
    @@ -309,6 +424,12 @@ int32_t MusicMidiOpen(void) {
     }
     
     
    +// A message packed as MidiInMessage gives it (MIDI thru).
    +void MusicMidiSend(int32_t handle, int32_t midiMsg) {
    +    sLastError = musicMidiSend(basGet(handle), basPacked(midiMsg, 0), basPacked(midiMsg, AUDIO_MIDI_PACKED_DATA1_SHIFT), basPacked(midiMsg, AUDIO_MIDI_PACKED_DATA2_SHIFT));
    +}
    +
    +
     void MusicNoteOff(int32_t handle, int32_t channel, int32_t note) {
         sLastError = musicMidiNoteOff(basGet(handle), channel - BAS_FIRST_CHANNEL, note);
     }
    @@ -319,6 +440,18 @@ void MusicNoteOn(int32_t handle, int32_t channel, int32_t note, int32_t velocity
     }
     
     
    +// The UltraSound's own patches' ULTRASND.INI (musicPatchSetPath), or "".
    +const char *MusicPatchSet(void) {
    +    static char path[DVX_MAX_PATH];
    +
    +    if (!musicPatchSetPath(path, sizeof(path))) {
    +        path[0] = '\0';
    +    }
    +
    +    return path;
    +}
    +
    +
     void MusicPause(int32_t handle) {
         sLastError = musicPause(basGet(handle));
     }
    @@ -354,11 +487,29 @@ void MusicResume(int32_t handle) {
     }
     
     
    +// The AWE's own ROM bank (musicRomBankPath), or "".
    +const char *MusicRomBank(void) {
    +    static char path[DVX_MAX_PATH];
    +
    +    if (!musicRomBankPath(path, sizeof(path))) {
    +        path[0] = '\0';
    +    }
    +
    +    return path;
    +}
    +
    +
     void MusicSeek(int32_t handle, int32_t milliseconds) {
         sLastError = milliseconds < 0 ? MUSIC_ERR_ARG : musicSetPosition(basGet(handle), (uint32_t)milliseconds);
     }
     
     
    +// "" is the standard bank.
    +void MusicSetBank(int32_t handle, const char *bank) {
    +    sLastError = musicSetBank(basGet(handle), bank);
    +}
    +
    +
     void MusicStop(int32_t handle) {
         sLastError = musicStop(basGet(handle));
     }
    diff --git a/src/libs/kpunch/music/musicCents.c b/src/libs/kpunch/music/musicCents.c
    new file mode 100644
    index 00000000..d6518616
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicCents.c
    @@ -0,0 +1,143 @@
    +// 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.
    +
    +// musicCents.c  -- cents and powers of two in fixed point, and loudness
    +// as attenuation (see musicInt.h, "Cents in fixed point")
    +//
    +// 2^(cents / 1200) from a table of 2^(i / 120) in 16.16, ten cents a
    +// step, with straight lines between its entries, and the other way: a
    +// ratio's cents from the same table.  An amplitude's attenuation from a
    +// table of 128 steps.  No floating point and no library
    +// calls in waveOctaves and wavePow2, which the synthesizers use at
    +// interrupt time (an interrupt-time file, pinned by musicLock);
    +// waveRatioCents divides in 64 bits and is for program time only.
    +
    +#include "musicInt.h"
    +
    +#define CENTS_STEP          10
    +#define CENTS_STEPS         (WAVE_CENTS_OCTAVE / CENTS_STEP)
    +#define LOCK_ITEMS          4
    +
    +// 65536 x 2^(i / 120)
    +static const uint32_t sPow2[CENTS_STEPS + 1] = {
    +     65536,  65916,  66297,  66682,  67068,  67456,  67847,  68240,
    +     68635,  69033,  69433,  69835,  70240,  70647,  71056,  71468,
    +     71882,  72298,  72717,  73138,  73562,  73988,  74416,  74848,
    +     75281,  75717,  76156,  76597,  77041,  77487,  77936,  78387,
    +     78841,  79298,  79758,  80220,  80684,  81152,  81622,  82095,
    +     82570,  83049,  83530,  84013,  84500,  84990,  85482,  85977,
    +     86475,  86976,  87480,  87987,  88497,  89009,  89525,  90043,
    +     90565,  91090,  91617,  92148,  92682,  93219,  93759,  94302,
    +     94848,  95398,  95950,  96506,  97065,  97628,  98193,  98762,
    +     99334,  99909, 100488, 101070, 101656, 102245, 102837, 103433,
    +    104032, 104635, 105241, 105850, 106464, 107080, 107701, 108324,
    +    108952, 109583, 110218, 110856, 111499, 112145, 112794, 113448,
    +    114105, 114766, 115431, 116099, 116772, 117448, 118129, 118813,
    +    119501, 120194, 120890, 121590, 122295, 123003, 123715, 124432,
    +    125153, 125878, 126607, 127341, 128078, 128820, 129567, 130317,
    +    131072
    +};
    +
    +// 200 log10(128 / i): the attenuation of an amplitude of i / 128
    +const int16_t waveCbForStep[WAVE_STEP_MAX + 1] = {
    +     1440,   421,   361,   326,   301,   282,   266,   252,   241,   231,   221,   213,   206,   199,   192,   186,
    +      181,   175,   170,   166,   161,   157,   153,   149,   145,   142,   138,   135,   132,   129,   126,   123,
    +      120,   118,   115,   113,   110,   108,   105,   103,   101,    99,    97,    95,    93,    91,    89,    87,
    +       85,    83,    82,    80,    78,    77,    75,    73,    72,    70,    69,    67,    66,    64,    63,    62,
    +       60,    59,    58,    56,    55,    54,    52,    51,    50,    49,    48,    46,    45,    44,    43,    42,
    +       41,    40,    39,    38,    37,    36,    35,    34,    33,    32,    31,    30,    29,    28,    27,    26,
    +       25,    24,    23,    22,    21,    21,    20,    19,    18,    17,    16,    16,    15,    14,    13,    12,
    +       12,    11,    10,     9,     9,     8,     7,     6,     6,     5,     4,     3,     3,     2,     1,     1,
    +        0
    +};
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +int32_t  waveCentsLockItems(AudioLockItemT *items);
    +int32_t  waveOctaves(int32_t cents, uint32_t *frac);
    +uint32_t wavePow2(int32_t cents);
    +int32_t  waveRatioCents(uint32_t num, uint32_t den);
    +
    +
    +int32_t waveCentsLockItems(AudioLockItemT *items) {
    +    items[0].addr = (const void *)waveOctaves;
    +    items[0].size = 0;
    +    items[1].addr = (const void *)wavePow2;
    +    items[1].size = 0;
    +    items[2].addr = sPow2;
    +    items[2].size = sizeof(sPow2);
    +    items[3].addr = waveCbForStep;
    +    items[3].size = sizeof(waveCbForStep);
    +    return LOCK_ITEMS;
    +}
    +
    +
    +// The whole octaves below cents (rounded down), and the table's entry
    +// below the rest with a straight line to the next.
    +int32_t waveOctaves(int32_t cents, uint32_t *frac) {
    +    int32_t oct  = cents >= 0 ? cents / WAVE_CENTS_OCTAVE : -((WAVE_CENTS_OCTAVE - 1 - cents) / WAVE_CENTS_OCTAVE);
    +    int32_t rest = cents - oct * WAVE_CENTS_OCTAVE;
    +    int32_t i    = rest / CENTS_STEP;
    +
    +    *frac = sPow2[i] + (sPow2[i + 1] - sPow2[i]) * (uint32_t)(rest % CENTS_STEP) / CENTS_STEP;
    +    return oct;
    +}
    +
    +
    +uint32_t wavePow2(int32_t cents) {
    +    uint32_t frac;
    +    int32_t  oct = waveOctaves(cents, &frac);
    +
    +    return oct >= 0 ? frac << oct : frac >> -oct;
    +}
    +
    +
    +// The ratio as 16.16 between 1 and 2 and the octaves taken out, then the
    +// table's entry below it and a straight line to the next.
    +int32_t waveRatioCents(uint32_t num, uint32_t den) {
    +    if (!num || !den) {
    +        return 0;
    +    }
    +
    +    uint64_t r   = ((uint64_t)num << WAVE_FIX_SHIFT) / den;
    +    int32_t  oct = 0;
    +
    +    while (r >= ((uint64_t)WAVE_FIX_ONE << 1)) {
    +        r >>= 1;
    +        oct++;
    +    }
    +
    +    while (r < WAVE_FIX_ONE) {
    +        r <<= 1;
    +        oct--;
    +    }
    +
    +    int32_t i = 0;
    +
    +    while (i < CENTS_STEPS - 1 && sPow2[i + 1] <= r) {
    +        i++;
    +    }
    +
    +    return oct * WAVE_CENTS_OCTAVE + i * CENTS_STEP + (int32_t)((r - sPow2[i]) * CENTS_STEP / (sPow2[i + 1] - sPow2[i]));
    +}
    diff --git a/src/libs/kpunch/music/musicFound.c b/src/libs/kpunch/music/musicFound.c
    new file mode 100644
    index 00000000..d8b25fd7
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicFound.c
    @@ -0,0 +1,74 @@
    +// 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.
    +
    +// musicFound.c  -- the instrument banks a sound card's own software
    +// installed (see music.h): a Gravis UltraSound's patches (ULTRASND.INI in
    +// the folder ULTRADIR names) and an AWE's bank for its sample ROM
    +// (SFBANK\SYNTHGM.SF2 or SYNTHGM.SBK in the folder SOUND names, where
    +// Creative's drivers put it).
    +
    +#include "musicInt.h"
    +
    +#include 
    +#include 
    +
    +static const char *sRomBanks[] = { "SYNTHGM.SF2", "SYNTHGM.SBK" };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +bool musicFileThere(const char *path);
    +bool musicPatchSetPath(char *out, int32_t outSize);
    +bool musicRomBankPath(char *out, int32_t outSize);
    +
    +
    +bool musicFileThere(const char *path) {
    +    struct stat st;
    +
    +    return stat(path, &st) == 0;
    +}
    +
    +
    +bool musicPatchSetPath(char *out, int32_t outSize) {
    +    const char *dir = getenv(MUSIC_PATCH_SET_ENV);
    +
    +    return dir && dir[0] && platformPathJoin(out, outSize, dir, MUSIC_PATCH_SET_INI) && musicFileThere(out);
    +}
    +
    +
    +bool musicRomBankPath(char *out, int32_t outSize) {
    +    const char *sound = getenv(MUSIC_ROM_BANK_ENV);
    +    char        dir[DVX_MAX_PATH];
    +
    +    if (!sound || !sound[0] || !platformPathJoin(dir, sizeof(dir), sound, MUSIC_ROM_BANK_DIR)) {
    +        return false;
    +    }
    +
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(sRomBanks); i++) {
    +        if (platformPathJoin(out, outSize, dir, sRomBanks[i]) && musicFileThere(out)) {
    +            return true;
    +        }
    +    }
    +
    +    return false;
    +}
    diff --git a/src/libs/kpunch/music/musicInt.h b/src/libs/kpunch/music/musicInt.h
    index d8c0d5a3..90c3c056 100644
    --- a/src/libs/kpunch/music/musicInt.h
    +++ b/src/libs/kpunch/music/musicInt.h
    @@ -24,30 +24,51 @@
     //
     // The parts of the library and how they connect:
     //
    -//   musicSmf.c    Standard MIDI File -> one list of events, times in
    -//                 microseconds (MidiFileT)
    -//   musicSeq.c    the sequencer (SeqT: sends the events that are due to a
    -//                 MidiSinkT), the event ring that carries MIDI from the
    -//                 timer interrupt to the software synthesizer, and the
    -//                 timer callback that plays MIDI on the synthesizers
    -//   musicFm.c     General MIDI on two-operator FM voices (FmSynthT): the
    -//                 channels' state, notes, the OP2 bank's patches, pitch
    -//                 and loudness, on the FM chip or the software synthesizer
    -//   musicMpu.c    MIDI out of the MPU-401 port (MpuSinkT): General MIDI
    -//                 as it is, or remapped for a Roland MT-32, paced
    -//   musicSynth.c  the software FM synthesizer (SynthChipT): an integer
    -//                 two-operator FM chip rendering into a sound stream
    -//   musicOp2.c    the OP2 instrument bank
    -//   musicXmp.c    tracker modules through libxmp-lite
    -//   music.c       musics, programs, streams, the public functions
    +//   musicSmf.c      Standard MIDI File -> one list of events, times in
    +//                   microseconds (MidiFileT)
    +//   musicSeq.c      the sequencer (SeqT: sends the events that are due
    +//                   to a MidiSinkT), the event ring that carries MIDI
    +//                   from the timer interrupt to the software
    +//                   synthesizer, and the music timer
    +//   musicFm.c       General MIDI on two-operator FM voices (FmSynthT):
    +//                   the channels' state, notes, the OP2 bank's patches,
    +//                   pitch and loudness, on the FM chip or the software
    +//                   synthesizer
    +//   musicSynth.c    the software FM synthesizer (SynthChipT): an
    +//                   integer two-operator FM chip rendering into a stream
    +//   musicOp2.c      the OP2 instrument bank
    +//   musicMpu.c      MIDI out of the MPU-401 port (MpuSinkT): General MIDI
    +//                   as it is, or remapped for a Roland MT-32, paced
    +//   musicSf2.c      SoundFont banks (WaveBankT)
    +//   musicPat.c      a Gravis UltraSound patch set as a SoundFont bank
    +//   musicFound.c    the banks a sound card's own software installed
    +//   musicWave.c     General MIDI on wavetable voices (WaveSynthT), on
    +//                   the wavetable synthesizer or in software
    +//   musicWaveLoad.c a SoundFont's samples in the synthesizer's memory
    +//   musicWaveSoft.c the SoundFont software synthesizer (WaveSoftT)
    +//   musicCents.c    cents, powers of two, and attenuation in fixed point
    +//   musicXmp.c      tracker modules through libxmp-lite
    +//   musicSound.c    WAV and VOC files (SoundSrcT): a WAV read from the
    +//                   disk as it plays, a VOC decoded into memory
    +//   musicModHw.c    a module on a wavetable synthesizer's voices: its
    +//                   samples there, libxmp's voices made into frames
    +//                   ahead (ModHwT)
    +//   musicModTick.c  ... the frames played by the music timer
    +//   musicMml.c      QBasic's music macro language
    +//   musicMmlPlay.c  music macro queues
    +//   musicBas.c      DVX BASIC's entry points
    +//   musicShell.c    the library's hooks into the shell
    +//   music.c         musics, programs, streams, the public functions
     //
    -// musicSeq.c, musicFm.c and musicMpu.c also run at interrupt time (the
    -// timer service plays MIDI on the FM chip and the MPU-401 port), so they
    -// keep to the interrupt rules (audioDrv.h): integer arithmetic, no calls
    -// out of those files but audio.lib's interrupt-safe FM and MIDI
    -// functions, everything pinned (the Makefile links them last, so one
    -// span from their first function to the end of the library's data covers
    -// them; musicLock).
    +// musicSeq.c, musicFm.c, musicMpu.c, musicWave.c, musicCents.c and
    +// musicModTick.c also run at interrupt time (the music timer plays MIDI
    +// on the FM chip, the wavetable synthesizer and the MPU-401 port, and
    +// modules on the wavetable synthesizer), so they keep to the interrupt
    +// rules (audioDrv.h): integer arithmetic, no calls out of those files
    +// but audio.lib's interrupt-safe FM, MIDI and wavetable functions,
    +// everything pinned (the Makefile links them last, so one span from
    +// their first function to the end of the library's data covers them;
    +// musicLock).
     #ifndef MUSIC_INT_H
     #define MUSIC_INT_H
     
    @@ -77,6 +98,10 @@
     #define MIDI_DATA_BITS          7
     #define MIDI_CENTER             64
     
    +// A channel message of this type (status AND MIDI_TYPE_MASK) carries one
    +// data byte (program change, channel pressure); the others carry two.
    +#define MIDI_ONE_DATA_BYTE(type) ((type) == MIDI_PROGRAM || (type) == MIDI_CHAN_PRESSURE)
    +
     // Controllers
     #define MIDI_CC_BANK            0
     #define MIDI_CC_MODULATION      1
    @@ -126,6 +151,7 @@ typedef struct {
         uint32_t    count;
         uint32_t    sysexBytes;
         uint32_t    length;     // microseconds: the last event or end of track
    +    char        title[MUSIC_TITLE_MAX];   // the first track's name (meta event 03), "" when it has none
     } MidiFileT;
     
     // Most events and system exclusive bytes a file may have
    @@ -140,6 +166,18 @@ void    midiFileFree(MidiFileT *file);
     bool    midiIsFile(const uint8_t *data, uint32_t size);
     int32_t midiParse(const uint8_t *data, uint32_t size, MidiFileT *out);
     
    +// Writing one (musicSmf.c): a track's events go into an stb_ds byte
    +// array -- a number in bytes bytes (most significant first), a delta time
    +// or other variable-length number, a tempo event at the start, the end of
    +// the track delta ticks after the last event.  midiFileBuild makes a type
    +// 0 file of the track (malloc'd into *out): MUSIC_ERR_NO_MEMORY or
    +// MUSIC_OK.
    +int32_t midiFileBuild(const uint8_t *trk, uint32_t trkLen, uint32_t division, uint8_t **out, uint32_t *size);
    +void    midiPutBig(uint8_t **buf, uint32_t value, int32_t bytes);
    +void    midiPutEnd(uint8_t **trk, uint32_t delta);
    +void    midiPutTempo(uint8_t **trk, uint32_t usPerQuarter);
    +void    midiPutVlq(uint8_t **buf, uint32_t value);
    +
     // ============================================================
     // Where MIDI goes (musicSeq.c, musicFm.c, musicMpu.c)
     // ============================================================
    @@ -239,6 +277,187 @@ typedef struct {
     // MUSIC_OK or MUSIC_ERR_BANK (not an OP2 bank, too short).
     int32_t op2Parse(const uint8_t *data, uint32_t size, Op2BankT *bank);
     
    +// A loaded bank, shared: every MIDI music that plays on it holds a
    +// reference, and so does the library while it is DVX.INI's choice.
    +// Pinned (the timer reads it); freed with its last reference.
    +typedef struct {
    +    Op2BankT op2;
    +    int32_t  refs;
    +    char     name[DVX_MAX_PATH];    // as DVX.INI's midiBank names it ("" = the standard one)
    +} MusicBankT;
    +
    +// ============================================================
    +// SoundFont banks (musicSf2.c)
    +// ============================================================
    +
    +// The SoundFont 2 generators (the specification's numbers and order)
    +#define SF_GEN_START_OFFSET         0
    +#define SF_GEN_END_OFFSET           1
    +#define SF_GEN_LOOP_START_OFFSET    2
    +#define SF_GEN_LOOP_END_OFFSET      3
    +#define SF_GEN_START_COARSE         4
    +#define SF_GEN_MOD_LFO_TO_PITCH     5
    +#define SF_GEN_VIB_LFO_TO_PITCH     6
    +#define SF_GEN_MOD_ENV_TO_PITCH     7
    +#define SF_GEN_FILTER_FC            8
    +#define SF_GEN_FILTER_Q             9
    +#define SF_GEN_MOD_LFO_TO_FILTER    10
    +#define SF_GEN_MOD_ENV_TO_FILTER    11
    +#define SF_GEN_END_COARSE           12
    +#define SF_GEN_MOD_LFO_TO_VOLUME    13
    +#define SF_GEN_CHORUS_SEND          15
    +#define SF_GEN_REVERB_SEND          16
    +#define SF_GEN_PAN                  17
    +#define SF_GEN_MOD_LFO_DELAY        21
    +#define SF_GEN_MOD_LFO_FREQ         22
    +#define SF_GEN_VIB_LFO_DELAY        23
    +#define SF_GEN_VIB_LFO_FREQ         24
    +#define SF_GEN_MOD_ENV_DELAY        25
    +#define SF_GEN_MOD_ENV_ATTACK       26
    +#define SF_GEN_MOD_ENV_HOLD         27
    +#define SF_GEN_MOD_ENV_DECAY        28
    +#define SF_GEN_MOD_ENV_SUSTAIN      29
    +#define SF_GEN_MOD_ENV_RELEASE      30
    +#define SF_GEN_KEY_TO_MOD_ENV_HOLD  31
    +#define SF_GEN_KEY_TO_MOD_ENV_DECAY 32
    +#define SF_GEN_VOL_ENV_DELAY        33
    +#define SF_GEN_VOL_ENV_ATTACK       34
    +#define SF_GEN_VOL_ENV_HOLD         35
    +#define SF_GEN_VOL_ENV_DECAY        36
    +#define SF_GEN_VOL_ENV_SUSTAIN      37
    +#define SF_GEN_VOL_ENV_RELEASE      38
    +#define SF_GEN_KEY_TO_VOL_ENV_HOLD  39
    +#define SF_GEN_KEY_TO_VOL_ENV_DECAY 40
    +#define SF_GEN_INSTRUMENT           41
    +#define SF_GEN_KEY_RANGE            43
    +#define SF_GEN_VEL_RANGE            44
    +#define SF_GEN_LOOP_START_COARSE    45
    +#define SF_GEN_KEYNUM               46
    +#define SF_GEN_VELOCITY             47
    +#define SF_GEN_ATTENUATION          48
    +#define SF_GEN_LOOP_END_COARSE      50
    +#define SF_GEN_COARSE_TUNE          51
    +#define SF_GEN_FINE_TUNE            52
    +#define SF_GEN_SAMPLE_ID            53
    +#define SF_GEN_SAMPLE_MODES         54
    +#define SF_GEN_SCALE_TUNING         56
    +#define SF_GEN_EXCLUSIVE_CLASS      57
    +#define SF_GEN_ROOT_KEY             58
    +#define SF_GEN_COUNT                61
    +
    +// sampleModes: how a sample loops
    +#define SF_LOOP_NONE                0
    +#define SF_LOOP_ON                  1   // loops for as long as it sounds
    +#define SF_LOOP_RELEASE             3   // loops while the key is held, then plays out
    +
    +// Sample types (a ROM sample's bit set: in the synthesizer's own memory)
    +#define SF_SAMPLE_MONO              1
    +#define SF_SAMPLE_RIGHT             2
    +#define SF_SAMPLE_LEFT              4
    +#define SF_SAMPLE_ROM               0x8000
    +
    +#define SF_NAME_LEN                 20
    +#define SF_DRUM_BANK                128     // General MIDI percussion, on channel 10
    +#define SF_ROOT_NONE                (-1)    // overridingRootKey / keynum / velocity not set
    +
    +// A sample, in frames of the bank's 16-bit sample data: start .. end - 1;
    +// the loop from loopStart to loopEnd - 1.
    +typedef struct {
    +    uint32_t start;
    +    uint32_t end;
    +    uint32_t loopStart;
    +    uint32_t loopEnd;
    +    uint32_t rate;          // Hz
    +    uint16_t type;          // SF_SAMPLE_*
    +    uint8_t  rootKey;       // the MIDI note it sounds as recorded
    +    int8_t   correction;    // cents to add
    +} WaveSampleT;
    +
    +// What one note plays: a sample with every generator resolved (the
    +// instrument's, its global zone's, and the preset's added), for the keys
    +// and velocities in its ranges.
    +typedef struct {
    +    int16_t  gen[SF_GEN_COUNT];
    +    uint16_t sample;
    +    uint8_t  keyLo;
    +    uint8_t  keyHi;
    +    uint8_t  velLo;
    +    uint8_t  velHi;
    +} WaveRegionT;
    +
    +// A preset: its regions in WaveBankT.regions
    +typedef struct {
    +    uint32_t first;
    +    uint32_t count;
    +    uint16_t bank;
    +    uint16_t program;
    +    char     name[SF_NAME_LEN + 1];
    +} WavePresetT;
    +
    +// Where a bank's frames are when they are not one SoundFont's data (a
    +// Gravis patch set: each wave in its own .PAT file): a run of the bank's
    +// frames read from a file's bytes, 8- or 16-bit, signed or not.  Frames
    +// no source covers read as silence.
    +#define WAVE_SRC_16BIT          0x01
    +#define WAVE_SRC_UNSIGNED       0x02
    +
    +typedef struct {
    +    uint32_t first;         // bank frame
    +    uint32_t frames;
    +    uint32_t offset;        // bytes into the file
    +    uint16_t file;          // in WaveBankT.files
    +    uint8_t  format;        // WAVE_SRC_*
    +} WaveSourceT;
    +
    +// A SoundFont: the presets, regions, and samples, in malloc'd arrays; the
    +// sample data stays where it is (in the file, or the memory it was read
    +// from; or, with sources, in the files they name) until a song needs it
    +// (waveBankRead).
    +typedef struct {
    +    WavePresetT   *presets;
    +    WaveRegionT   *regions;
    +    WaveSampleT   *samples;
    +    WaveSourceT   *sources;                 // NULL: the data is path's (or memory's)
    +    char         (*files)[DVX_MAX_PATH];    // the sources' files
    +    uint32_t       presetCount;
    +    uint32_t       regionCount;
    +    uint32_t       sampleCount;
    +    uint32_t       sourceCount;
    +    uint32_t       fileCount;
    +    uint32_t       dataOffset;              // of the sample data, bytes from the start
    +    uint32_t       dataFrames;
    +    const uint8_t *memory;                  // the whole file in memory, or NULL: path
    +    char           path[DVX_MAX_PATH];
    +} WaveBankT;
    +
    +// sf2Load reads a SoundFont 2 file's presets, instruments, and sample
    +// headers; sf2Parse the same from a whole file in memory (which must
    +// last as long as the bank).  MUSIC_OK, MUSIC_ERR_OPEN, _BANK (not a
    +// SoundFont, or damaged), _NO_MEMORY.
    +int32_t sf2Load(const char *path, WaveBankT *bank);
    +int32_t sf2Parse(const uint8_t *data, uint32_t size, WaveBankT *bank);
    +
    +// A generator's value where no zone sets it (the specification's
    +// default; 0 for one out of range).
    +int16_t sf2GenDefault(int32_t gen);
    +
    +// A Gravis UltraSound patch set as a bank (musicPat.c): an UltraSound
    +// folder's ULTRASND.INI (iniPath) and the .PAT files it names; bank 0's
    +// 128 programs and the standard kit, the sample data left in the files.
    +// MUSIC_OK, MUSIC_ERR_OPEN (no such INI), _BANK (no patch), _NO_MEMORY.
    +int32_t patLoadSet(const char *iniPath, WaveBankT *bank);
    +
    +// A file or folder is at path (musicFound.c).
    +bool    musicFileThere(const char *path);
    +
    +// The bank's memory given back (the bank cleared).
    +void    waveBankFree(WaveBankT *bank);
    +
    +
    +// frames frames of sample data from frame first into out (16-bit);
    +// false when they cannot be read.
    +bool    waveBankRead(const WaveBankT *bank, uint32_t first, uint32_t frames, int16_t *out);
    +
     // ============================================================
     // General MIDI on FM voices (musicFm.c)
     // ============================================================
    @@ -310,6 +529,237 @@ void fmSynthSetGain(FmSynthT *synth, int32_t gain);
     #define FM_PITCH_STEPS          32
     void fmPitchToFreq(int32_t pitch, uint32_t *fnum, uint32_t *block);
     
    +// ============================================================
    +// Cents in fixed point (musicCents.c)
    +// ============================================================
    +
    +#define WAVE_CENTS_OCTAVE       1200
    +#define WAVE_FIX_SHIFT          16      // 16.16
    +#define WAVE_FIX_ONE            65536U
    +
    +// cents as whole octaves (returned, rounded down) and 2^(the rest / 1200)
    +// in 16.16 (*frac).  Interrupt-safe.
    +int32_t  waveOctaves(int32_t cents, uint32_t *frac);
    +
    +// 2^(cents / 1200) in 16.16 (cents from -16 to +15 octaves).
    +// Interrupt-safe.
    +uint32_t wavePow2(int32_t cents);
    +
    +// 1200 log2(num / den): the cents between two rates (program time).
    +int32_t  waveRatioCents(uint32_t num, uint32_t den);
    +
    +// 200 log10(WAVE_STEP_MAX / i): the attenuation in centibels of an
    +// amplitude of i / WAVE_STEP_MAX (0: AUDIO_WT_ATTEN_MAX); a volume or
    +// gain of 0..AUDIO_VOLUME_MAX indexes it by halves.
    +#define WAVE_STEP_MAX           128
    +#define WAVE_GAIN_SHIFT         1
    +extern const int16_t waveCbForStep[WAVE_STEP_MAX + 1];
    +
    +// This file's interrupt-time code and tables for musicLock (how many).
    +int32_t  waveCentsLockItems(AudioLockItemT *items);
    +
    +// ============================================================
    +// General MIDI on wavetable voices (musicWave.c)
    +// ============================================================
    +
    +#define WAVE_VOICES_MAX         AUDIO_WT_VOICES_MAX
    +#define WAVE_NOT_LOADED         0xFFFFFFFFU
    +#define WAVE_GUARD_FRAMES       8       // frames stored after a sample's end (a chip's interpolation reads them)
    +
    +// Voices come from a backend: the wavetable synthesizer (audio.lib's
    +// shared voices; waveHwBackend) or the software one (musicWaveSoft.c).
    +// The calls are audio.lib's audioWt* ones; flags says what the backend
    +// does itself (AUDIO_WT_*), and the synthesizer does the rest by updates.
    +typedef struct {
    +    int32_t (*alloc)(void *owner);
    +    void    (*free)(void *owner, int32_t voice);
    +    void    (*start)(void *owner, int32_t voice, const AudioWtVoiceT *v);
    +    void    (*update)(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +    void    (*release)(void *owner, int32_t voice);
    +    bool    (*active)(void *owner, int32_t voice);
    +    void     *owner;
    +    uint32_t  flags;
    +} WaveBackendT;
    +
    +// A sample of the bank in the synthesizer's memory (musicWaveLoad.c)
    +typedef struct {
    +    uint32_t addr;          // WAVE_NOT_LOADED: not there
    +    uint32_t frames;        // stored (from the sample's start, guard frames included)
    +    int32_t  rateCents;     // 1200 log2(stored rate / 44,100)
    +    uint8_t  bits;          // 8 or 16
    +    uint8_t  shift;         // the frames were halved this many times to fit
    +} WaveLoadedT;
    +
    +typedef struct {
    +    int16_t bend;           // -8192..8191
    +    uint8_t program;
    +    uint8_t bankMsb;
    +    uint8_t volume;
    +    uint8_t expression;
    +    uint8_t pan;
    +    uint8_t bendRange;      // semitones
    +    uint8_t modWheel;
    +    uint8_t pressure;
    +    uint8_t reverb;
    +    uint8_t chorus;
    +    uint8_t rpnMsb;
    +    uint8_t rpnLsb;
    +    bool    sustain;
    +} WaveChannelT;
    +
    +// Voice states
    +#define WAVE_V_FREE             0
    +#define WAVE_V_ON               1
    +#define WAVE_V_HELD             2       // the key is up, the sustain pedal down
    +#define WAVE_V_RELEASED         3
    +
    +// A sounding voice: the note as last sent to the backend, and (for a
    +// backend without them) the envelopes and LFOs run here
    +typedef struct {
    +    AudioWtVoiceT      note;
    +    const WaveRegionT *region;
    +    int32_t            id;          // backend voice (0: free slot)
    +    uint32_t           age;
    +    int32_t            basePitch;   // cents: key, tuning, the sample's rate
    +    int32_t            baseAtten;   // centibels: the region and the velocity
    +    int32_t            envAtten;    // the volume envelope now (centibels)
    +    int32_t            relFrom;     // ... where its release began
    +    int32_t            modEnv;      // the modulation envelope now (0..WAVE_PERMILLE)
    +    int32_t            modFrom;     // ... where its release began
    +    uint32_t           stageUs;     // time in the volume envelope's stage
    +    uint32_t           modStageUs;  // ... in the modulation envelope's
    +    uint32_t           sinceOn;     // microseconds since the note began
    +    uint32_t           modPhase;    // LFO phases (65536 a cycle)
    +    uint32_t           vibPhase;
    +    int32_t            sentAtten;   // last attenuation and pitch sent
    +    int32_t            sentPitch;
    +    uint8_t            stage;       // AUDIO_WT_ENV_*, or WAVE_ENV_DONE
    +    uint8_t            modStage;
    +    uint8_t            state;       // WAVE_V_*
    +    uint8_t            channel;
    +    uint8_t            key;
    +    uint8_t            exclusive;
    +} WaveVoiceT;
    +
    +#define WAVE_ENV_DONE           AUDIO_WT_ENV_STAGES
    +#define WAVE_PERMILLE           1000
    +
    +// overflow: where notes go that the bank cannot play here (no region, or
    +// a sample not in the synthesizer's memory; NULL: they are not played);
    +// it gets every other message too, so its channels are in step.  gain:
    +// the music's volume x the master volume (0..AUDIO_VOLUME_MAX).
    +typedef struct {
    +    MidiSinkT          sink;
    +    WaveBackendT       backend;
    +    const WaveBankT   *bank;
    +    const WaveLoadedT *loaded;          // per sample of bank
    +    MidiSinkT         *overflow;
    +    WaveChannelT       channels[MUSIC_MIDI_CHANNELS];
    +    WaveVoiceT         voices[WAVE_VOICES_MAX];
    +    uint32_t           clock;
    +    int32_t            gain;
    +} WaveSynthT;
    +
    +// The preset for a bank and program: that one; for drums (bank 128) the
    +// standard kit when the kit asked for is missing; else the same program
    +// in bank 0 (a variation the bank lacks); NULL when there is none.
    +// Interrupt time.
    +const WavePresetT *waveBankPreset(const WaveBankT *bank, uint16_t bankNum, uint8_t program);
    +
    +// The wavetable synthesizer as a backend (audio.lib's shared voices, for
    +// ctx).
    +void    waveHwBackend(AudioCtxT *ctx, WaveBackendT *out);
    +int32_t waveLockItems(AudioLockItemT *items);
    +void    waveSynthInit(WaveSynthT *synth, const WaveBackendT *backend, const WaveBankT *bank, const WaveLoadedT *loaded, MidiSinkT *overflow, int32_t gain);
    +void    waveSynthSetGain(WaveSynthT *synth, int32_t gain);
    +
    +// Move the notes on by us microseconds: the envelopes and LFOs the
    +// backend does not run, voices that have fallen silent given back.
    +void    waveSynthTick(WaveSynthT *synth, uint32_t us);
    +
    +
    +// ============================================================
    +// SoundFont MIDI in software (musicWaveSoft.c)
    +// ============================================================
    +
    +// The software wavetable synthesizer: a WaveBackendT whose voices are
    +// mixed here (linear interpolation, the volume and pan as gains), from a
    +// song's samples held in memory, 16-bit at their own rates.  The
    +// envelopes and LFOs are musicWave.c's (flags 0).  It runs at program
    +// time, in the music's stream fill.
    +typedef struct {
    +    uint32_t pos;           // the frame in the arena
    +    uint32_t frac;          // ... and the 16-bit fraction past it
    +    uint32_t step;          // frames per output frame, 16.16
    +    uint32_t loopStart;     // frames in the arena
    +    uint32_t loopEnd;
    +    uint32_t end;
    +    int32_t  gainL;         // 0..WAVE_SOFT_GAIN_ONE
    +    int32_t  gainR;
    +    uint8_t  loop;          // AUDIO_WT_LOOP_*
    +    bool     used;          // allocated
    +    bool     playing;
    +} WaveSoftVoiceT;
    +
    +#define WAVE_SOFT_GAIN_SHIFT    15
    +#define WAVE_SOFT_GAIN_ONE      (1 << WAVE_SOFT_GAIN_SHIFT)
    +
    +typedef struct {
    +    WaveSoftVoiceT voices[WAVE_VOICES_MAX];
    +    int16_t       *arena;   // the samples loaded (malloc'd)
    +    uint32_t       frames;  // in the arena
    +    int32_t        rateCents;   // 1200 log2(44,100 / the output rate)
    +} WaveSoftT;
    +
    +// The synthesizer's backend; its samples (need[i] set) read into its
    +// arena (those already there kept), loaded[] saying where; and freed.
    +// MUSIC_OK, MUSIC_ERR_NO_MEMORY, MUSIC_ERR_OPEN.
    +void    waveSoftBackend(WaveSoftT *soft, uint32_t rate, WaveBackendT *out);
    +void    waveSoftFree(WaveSoftT *soft);
    +int32_t waveSoftLoad(WaveSoftT *soft, const WaveBankT *bank, WaveLoadedT *loaded, const uint8_t *need);
    +
    +// frames of every playing voice added into out (16-bit, channels 1 or 2,
    +// clipped).
    +void    waveSoftRender(WaveSoftT *soft, int16_t *out, uint32_t frames, uint32_t channels);
    +
    +// ============================================================
    +// SoundFont samples in the synthesizer's memory (musicWaveLoad.c)
    +// ============================================================
    +
    +// Seconds the library keeps the SoundFont's samples in the synthesizer
    +// after the last music goes (music.c), for the next one.
    +#define MUSIC_FONT_KEEP_SEC     60
    +
    +// The bank's samples in the synthesizer's memory, kept from song to song
    +// while they fit; loaded (read at interrupt time) is pinned.
    +typedef struct {
    +    WaveLoadedT *loaded;        // per bank sample
    +    uint32_t    *lastUse;       // the song count when it was last needed
    +    uint32_t     count;
    +    uint32_t     songs;
    +} WaveCacheT;
    +
    +// waveCacheInit: a cache for bank with nothing loaded; MUSIC_ERR_NO_MEMORY.
    +// waveCacheFree gives its synthesizer memory back (ctx's).
    +void    waveCacheFree(AudioCtxT *ctx, WaveCacheT *cache);
    +int32_t waveCacheInit(WaveCacheT *cache, const WaveBankT *bank);
    +
    +// The samples (need[sample] set) a file plays, from its bank selects,
    +// programs and notes; those of one preset, at every key and velocity
    +// (live MIDI, whose notes are not known), added to need: how many were
    +// not needed before.
    +void     waveNeedsFile(const WaveBankT *bank, const MidiFileT *file, uint8_t *need);
    +uint32_t waveNeedsPreset(const WaveBankT *bank, uint16_t bankNum, uint8_t program, uint8_t *need);
    +
    +// Every needed sample in ctx's synthesizer memory, at the best quality
    +// that fits: 16-bit, 8-bit (where the chip plays it), then at half and a
    +// quarter of the rate; samples not needed are given up first, those used
    +// longest ago first.  When they do not all fit even at the least, as many
    +// as fit, and *missing says how many did not (their notes go to the
    +// overflow).  MUSIC_OK; MUSIC_ERR_NO_DEVICE when none fits (or the bank
    +// cannot be read); MUSIC_ERR_NO_MEMORY.
    +int32_t waveLoadNeeded(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t flags, uint32_t *missing);
    +
     // ============================================================
     // The MPU-401 port (musicMpu.c)
     // ============================================================
    @@ -419,6 +869,44 @@ void    synthWrite(SynthChipT *chip, int32_t voice, uint8_t reg, uint8_t value);
     
     typedef struct XmpPlayerT XmpPlayerT;
     
    +// A sample of the module (xmpSample): frames of 8- or 16-bit signed data
    +// (libxmp's own, which lasts as long as the module), its loop frames
    +typedef struct {
    +    const void *data;
    +    uint32_t    frames;
    +    uint32_t    loopStart;
    +    uint32_t    loopEnd;
    +    bool        bits16;
    +    bool        loops;
    +    bool        bidi;       // a ping-pong loop
    +    bool        stereo;     // two channels a frame (no synthesizer voice plays it)
    +} XmpSampleT;
    +
    +// A voice libxmp reports as a frame starts when it plays on a
    +// synthesizer's voices (xmpHwOn; libxmp's struct xmp_hw_voice)
    +typedef struct {
    +    double   pos;           // sample frames
    +    double   rate;          // sample frames a second
    +    uint32_t voice;         // its voice, 0 .. the voices xmpHwOn gave
    +    uint32_t sample;        // xmpSample's number
    +    uint32_t trigger;       // changes when libxmp starts or moves the voice
    +    uint32_t loopStart;     // the loop it is in: its first frame
    +    uint32_t loopEnd;       // ... one past its last (no loop: the end)
    +    int32_t  vol;           // 0 .. XMP_HW_VOL_MAX
    +    int32_t  pan;           // -XMP_HW_PAN_SPAN (left) .. XMP_HW_PAN_SPAN - 1
    +    uint8_t  loop;          // XMP_VOICE_LOOP_*
    +    bool     surround;
    +    bool     reverse;       // playing backwards (a ping-pong loop's way back)
    +} XmpHwVoiceT;
    +
    +#define XMP_HW_VOL_MAX          1024
    +#define XMP_HW_PAN_SPAN         128
    +#define XMP_VOICE_LOOP_NONE     0       // plays to loopEnd and stops
    +#define XMP_VOICE_LOOP_FORWARD  1
    +#define XMP_VOICE_LOOP_PINGPONG 2       // there and back (xmpBidiAdjust)
    +
    +typedef void (*XmpHwFnT)(void *user, const XmpHwVoiceT *voice);
    +
     // xmpOpen: MUSIC_OK, _FORMAT (not a module), _CORRUPT, _NO_MEMORY; the
     // module is ready to render at rate (Hz) in channels (1 or 2) 16-bit
     // frames.  xmpRender renders frames frames; false once a song that does
    @@ -432,6 +920,165 @@ void        xmpRestart(XmpPlayerT *player);
     void        xmpSeek(XmpPlayerT *player, uint32_t ms);
     const char *xmpTitle(XmpPlayerT *player);
     
    +// The module's tracks and samples (false: no sample there), and the
    +// frames its ping-pong loops are shortened by on the way back (Impulse
    +// Tracker's 1: a loop of n frames takes 2n - 1 to play there and back).
    +uint32_t    xmpBidiAdjust(XmpPlayerT *player);
    +uint32_t    xmpChannels(XmpPlayerT *player);
    +bool        xmpSample(XmpPlayerT *player, uint32_t i, XmpSampleT *out);
    +uint32_t    xmpSampleCount(XmpPlayerT *player);
    +
    +// The module on a synthesizer's voices: libxmp started again with voices
    +// voices (each of its voices one of the synthesizer's), mixing nothing,
    +// fn told every sounding voice as each frame starts (false: it could not
    +// start); xmpHwOff back to rendering.  xmpHwFrame plays one frame (a
    +// tracker tick), its length in *us; false once a song that does not loop
    +// has ended (fn not called for that frame).
    +bool        xmpHwFrame(XmpPlayerT *player, bool loop, uint32_t *us);
    +void        xmpHwOff(XmpPlayerT *player);
    +bool        xmpHwOn(XmpPlayerT *player, uint32_t voices, XmpHwFnT fn, void *user);
    +
    +// ============================================================
    +// Sound files (musicSound.c)
    +// ============================================================
    +
    +typedef struct SoundSrcT SoundSrcT;
    +
    +// A WAV or VOC file as the frames of a sound music.  soundOpenFile reads
    +// a WAV named by its path as it plays (only its start now); soundOpenMem
    +// decodes a WAV or VOC file's bytes into memory.  MUSIC_OK,
    +// MUSIC_ERR_FORMAT (not a file the call takes: soundOpenFile takes only
    +// WAV), _OPEN, _CORRUPT, _NO_MEMORY.  soundRead gives up to frames 16-bit
    +// frames from the place it is at (fewer at the end), soundSeek moves it.
    +// Program time only.
    +uint32_t  soundChannels(const SoundSrcT *s);
    +void      soundClose(SoundSrcT *s);
    +uint32_t  soundFrames(const SoundSrcT *s);
    +int32_t   soundOpenFile(const char *path, SoundSrcT **out);
    +int32_t   soundOpenMem(const uint8_t *data, uint32_t size, SoundSrcT **out);
    +uint32_t  soundRate(const SoundSrcT *s);
    +uint32_t  soundRead(SoundSrcT *s, int16_t *out, uint32_t frames);
    +void      soundSeek(SoundSrcT *s, uint32_t frame);
    +uint32_t  soundTell(const SoundSrcT *s);
    +
    +// ============================================================
    +// Tracker modules on a wavetable synthesizer (musicModHw.c: the samples
    +// and the frames; musicModTick.c: the frames played at interrupt time)
    +// ============================================================
    +
    +// A module's sound made ahead of what is heard: its stream's buffer, or
    +// its frames queued for the timer.
    +#define MUSIC_MODULE_AHEAD_MS   400
    +
    +#define MODHW_FRAMES            64      // frames queued (a power of two)
    +#define MODHW_ENTRIES           1024    // voices queued, a frame's each (a power of two)
    +
    +// A module sample in the synthesizer's memory: from addr, frames frames
    +// (a ping-pong loop unrolled forward: its way back stored after its end,
    +// the frames past that dropped)
    +typedef struct {
    +    uint32_t addr;          // WAVE_NOT_LOADED: not there
    +    uint32_t frames;
    +    uint32_t loopStart;     // the module's loop (for a ping-pong one)
    +    uint32_t loopEnd;
    +    uint32_t backEnd;       // a ping-pong loop unrolled: where its way back ends (0: not)
    +    uint8_t  bits;
    +} ModHwSampleT;
    +
    +// A voice as a frame plays it, in the synthesizer's terms
    +typedef struct {
    +    uint32_t trigger;       // libxmp's count of its starts and moves
    +    uint32_t offset;        // the frame it starts from (when it starts)
    +    uint32_t loopStart;
    +    uint32_t loopEnd;
    +    uint32_t end;
    +    int32_t  pitch;         // cents
    +    int16_t  atten;         // centibels (the music's gain not in it)
    +    int16_t  pan;
    +    uint16_t sample;
    +    uint8_t  voice;         // libxmp's voice
    +    uint8_t  loop;          // AUDIO_WT_LOOP_*
    +} ModHwEntryT;
    +
    +// A frame: its voices (entries first .. first + count - 1), or the
    +// song's end
    +typedef struct {
    +    uint32_t us;            // its length
    +    uint32_t ms;            // the song's time at its start
    +    uint32_t first;
    +    uint32_t count;
    +    bool     end;
    +} ModHwFrameT;
    +
    +// One of libxmp's voices on the synthesizer
    +typedef struct {
    +    AudioWtVoiceT note;     // as last sent
    +    int32_t       id;       // the synthesizer's voice (0: none)
    +    uint32_t      trigger;
    +    uint16_t      sample;
    +} ModHwSlotT;
    +
    +// The frames are a queue: the program side adds at the heads, the timer
    +// takes from the tails; the frame at frameTail plays once applied, its
    +// entries kept until the next one plays (a pause starts its voices
    +// again).  Each side writes only its own counts.  Pinned: the timer
    +// reads it all.
    +typedef struct {
    +    ModHwEntryT        entries[MODHW_ENTRIES];
    +    ModHwFrameT        frames[MODHW_FRAMES];
    +    ModHwSlotT         slots[AUDIO_WT_VOICES_MAX];
    +    AudioCtxT         *ctx;
    +    ModHwSampleT      *samples;         // per module sample (pinned)
    +    uint32_t           sampleCount;
    +    uint32_t           voices;          // libxmp's
    +    uint32_t           bidiAdjust;
    +    int32_t            level;           // centibels every voice is lowered by: libxmp's level and the chip's
    +    uint32_t           staged;          // program side: entries the frame being made has (0 between frames)
    +    volatile uint32_t  entryHead;       // program side
    +    volatile uint32_t  frameHead;
    +    volatile uint32_t  madeUs;          // the frames' lengths added
    +    volatile uint32_t  entryTail;       // timer
    +    volatile uint32_t  frameTail;
    +    volatile uint32_t  playedUs;        // ... the lengths of those played
    +    volatile uint32_t  usInto;          // ... time into the frame playing
    +    volatile uint32_t  ms;              // ... the song's time at its start
    +    volatile int32_t   gainAtten;       // centibels for the music's gain (the next frame sends it)
    +    volatile bool      applied;         // the frame at frameTail plays
    +    bool               endMade;         // program side: the end is queued
    +} ModHwT;
    +
    +// modHwOpen: the module (libxmp's player) on ctx's wavetable
    +// synthesizer at gain (the music's volume x the master volume), its
    +// samples in the synthesizer's memory, libxmp switched to reporting its
    +// voices; MUSIC_OK, MUSIC_ERR_NO_MEMORY, MUSIC_ERR_NO_DEVICE (no
    +// synthesizer, too few voices for the module's channels, a stereo
    +// sample), MODHW_ERR_ROOM (the samples do not fit in its memory).
    +// modHwClose undoes it (libxmp back to rendering, where the song was).
    +#define MODHW_ERR_ROOM          (-MUSIC_ERR_COUNT)     // modHwOpen only; never leaves the library
    +void     modHwClose(ModHwT *mod, XmpPlayerT *xmp);
    +int32_t  modHwOpen(AudioCtxT *ctx, XmpPlayerT *xmp, int32_t gain, ModHwT **out);
    +
    +// Frames made until MUSIC_MODULE_AHEAD_MS are queued (or the song's
    +// end: a song that does not loop ends after one play).
    +void     modHwFill(ModHwT *mod, XmpPlayerT *xmp, bool loop);
    +
    +// The queue emptied and the voices stopped (before libxmp is moved: a
    +// seek, a restart); the song's time is ms until the next frame plays.
    +void     modHwFlush(ModHwT *mod, uint32_t ms);
    +
    +// The song's time heard; the gain changed; the voices stopped (a pause:
    +// the frame playing starts them again at the next tick that plays).
    +uint32_t modHwPosition(const ModHwT *mod);
    +void     modHwSetGain(ModHwT *mod, int32_t gain);
    +void     modHwSilence(ModHwT *mod);
    +
    +// Interrupt time: the frames on by us microseconds; false once the
    +// song's end plays (the voices stopped).  modHwStop stops every voice
    +// (with interrupts off at program time).
    +int32_t  modHwLockItems(AudioLockItemT *items);
    +void     modHwStop(ModHwT *mod);
    +bool     modHwTick(ModHwT *mod, uint32_t us);
    +
     // ============================================================
     // Players at interrupt time (musicSeq.c) and the library (music.c)
     // ============================================================
    @@ -441,12 +1088,15 @@ const char *xmpTitle(XmpPlayerT *player);
     #define PLAY_PLAYING            1
     #define PLAY_PAUSED             2
     
    -// The part of a MIDI music the timer interrupt plays (on the FM chip or
    -// the MPU-401 port).
    +// The part of a music the timer interrupt plays: MIDI (on the FM chip,
    +// the wavetable chip, or the MPU-401 port) or a module (on the wavetable
    +// chip).
     typedef struct {
         SeqT             seq;
         MidiSinkT       *sink;
         MpuSinkT        *mpu;       // drained every tick (NULL: not the MPU-401)
    +    WaveSynthT      *wave;      // ticked every tick (NULL: not the wavetable chip)
    +    ModHwT          *mod;       // a module on the wavetable chip (NULL: MIDI)
         volatile int32_t state;
         volatile bool    ended;     // played to its end: the end call is due
     } IsrPlayerT;
    @@ -481,6 +1131,7 @@ bool    musicLock(bool lock);
     // Instruments (I): 0 plays square-wave tones, 1..MML_PROGRAMS General MIDI
     // instrument n (program n - 1)
     #define MML_PROGRAM_TONE        0
    +#define MML_PROGRAM_PIANO       1       // the acoustic grand piano, General MIDI's first
     #define MML_PROGRAMS            128
     
     // The settings a macro changes, which last from one string to the next.
    @@ -521,15 +1172,19 @@ void       musicMmlIdle(void);
     void       musicMmlShutdown(void);
     
     // music.c: the program running now, the library's end of a program's
    -// life, the shell's idle time and its own unloading (musicShell.c hooks
    -// them up), making appId the current program (returns the one before),
    -// the MIDI bank in use (loaded on first need; NULL when it cannot be).
    +// life, the shell's idle time, a DOS program run with DVX stepped aside
    +// (musicSuspend, a platform suspend hook) and its own unloading
    +// (musicShell.c hooks them up), making appId the current program
    +// (returns the one before).
     int32_t         musicAppCurrent(void);
     void            musicAppEnded(int32_t appId);
     int32_t         musicAppEnter(int32_t appId);
    -const Op2BankT *musicBank(int32_t *err);
     void            musicBasForget(int32_t appId);
     void            musicIdle(void *unused);
     void            musicShutdown(void);
    +void            musicSuspend(bool suspending);
    +
    +// musicMidiRec.c: a program's MIDI recordings go when it ends.
    +void            musicMidiRecordAppEnded(int32_t appId);
     
     #endif // MUSIC_INT_H
    diff --git a/src/libs/kpunch/music/musicMidiRec.c b/src/libs/kpunch/music/musicMidiRec.c
    new file mode 100644
    index 00000000..951f5ad8
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicMidiRec.c
    @@ -0,0 +1,237 @@
    +// 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.
    +
    +// musicMidiRec.c  -- recording MIDI into a Standard MIDI File
    +//
    +// A recording keeps the channel messages it is given with their times
    +// and writes them as a type 0 file: one track of 1,000 ticks a quarter
    +// note at 60 quarter notes a minute, so a tick is a millisecond.  Every
    +// message is written with its status byte (no running status).  A note
    +// still held at the end is let go at the last message's time, so the
    +// file never leaves a note sounding.  A recording is the calling
    +// program's own and goes when it ends (musicMidiRecordAppEnded).
    +
    +#include "musicInt.h"
    +#include "stb_ds.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define SMF_TICKS_PER_QUARTER   1000        // with SMF_US_PER_QUARTER, a tick is a millisecond
    +#define SMF_US_PER_QUARTER      1000000
    +#define HELD_WORD_BITS          32
    +#define HELD_WORDS              ((MUSIC_MIDI_DATA_MAX + 1) / HELD_WORD_BITS)
    +
    +typedef struct {
    +    uint32_t ms;
    +    uint8_t  status;
    +    uint8_t  data1;
    +    uint8_t  data2;
    +} RecMsgT;
    +
    +struct MusicMidiRecordS {
    +    RecMsgT          *msgs;     // stb_ds, in time order
    +    MusicMidiRecordT *next;     // every recording (sRecords)
    +    int32_t           appId;
    +};
    +
    +static MusicMidiRecordT *sRecords = NULL;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +int32_t         musicMidiRecordAdd(MusicMidiRecordT *rec, uint32_t ms, uint8_t status, uint8_t data1, uint8_t data2);
    +void            musicMidiRecordAppEnded(int32_t appId);
    +uint32_t        musicMidiRecordCount(const MusicMidiRecordT *rec);
    +void            musicMidiRecordFree(MusicMidiRecordT *rec);
    +uint32_t        musicMidiRecordLength(const MusicMidiRecordT *rec);
    +int32_t         musicMidiRecordNew(MusicMidiRecordT **out);
    +int32_t         musicMidiRecordSave(const MusicMidiRecordT *rec, const char *path);
    +static void     putMsg(uint8_t **trk, uint32_t delta, uint8_t status, uint8_t data1, uint8_t data2);
    +static uint8_t *trackBytes(const MusicMidiRecordT *rec);
    +
    +
    +int32_t musicMidiRecordAdd(MusicMidiRecordT *rec, uint32_t ms, uint8_t status, uint8_t data1, uint8_t data2) {
    +    if (!rec || (status & MIDI_STATUS_BIT) == 0 || status >= MIDI_SYSEX || data1 > MUSIC_MIDI_DATA_MAX || data2 > MUSIC_MIDI_DATA_MAX) {
    +        return MUSIC_ERR_ARG;
    +    }
    +
    +    ptrdiff_t count = arrlen(rec->msgs);
    +    RecMsgT   msg   = { ms, status, data1, MIDI_ONE_DATA_BYTE(status & MIDI_TYPE_MASK) ? 0 : data2 };
    +
    +    if (count > 0 && msg.ms < rec->msgs[count - 1].ms) {
    +        msg.ms = rec->msgs[count - 1].ms;
    +    }
    +
    +    arrput(rec->msgs, msg);
    +    return MUSIC_OK;
    +}
    +
    +
    +void musicMidiRecordAppEnded(int32_t appId) {
    +    MusicMidiRecordT *rec = sRecords;
    +
    +    while (rec) {
    +        MusicMidiRecordT *next = rec->next;
    +
    +        if (rec->appId == appId) {
    +            musicMidiRecordFree(rec);
    +        }
    +
    +        rec = next;
    +    }
    +}
    +
    +
    +uint32_t musicMidiRecordCount(const MusicMidiRecordT *rec) {
    +    return rec ? (uint32_t)arrlen(rec->msgs) : 0;
    +}
    +
    +
    +void musicMidiRecordFree(MusicMidiRecordT *rec) {
    +    if (!rec) {
    +        return;
    +    }
    +
    +    MusicMidiRecordT **link = &sRecords;
    +
    +    while (*link && *link != rec) {
    +        link = &(*link)->next;
    +    }
    +
    +    if (*link) {
    +        *link = rec->next;
    +    }
    +
    +    arrfree(rec->msgs);
    +    free(rec);
    +}
    +
    +
    +uint32_t musicMidiRecordLength(const MusicMidiRecordT *rec) {
    +    ptrdiff_t count = rec ? arrlen(rec->msgs) : 0;
    +
    +    return count > 0 ? rec->msgs[count - 1].ms : 0;
    +}
    +
    +
    +int32_t musicMidiRecordNew(MusicMidiRecordT **out) {
    +    *out = NULL;
    +
    +    MusicMidiRecordT *rec = (MusicMidiRecordT *)calloc(1, sizeof(MusicMidiRecordT));
    +
    +    if (!rec) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    rec->appId = musicAppCurrent();
    +    rec->next  = sRecords;
    +    sRecords   = rec;
    +    *out       = rec;
    +    return MUSIC_OK;
    +}
    +
    +
    +// A file that is not all written is removed.
    +int32_t musicMidiRecordSave(const MusicMidiRecordT *rec, const char *path) {
    +    if (!rec || !path || !path[0]) {
    +        return MUSIC_ERR_ARG;
    +    }
    +
    +    uint8_t *trk  = trackBytes(rec);
    +    uint8_t *file = NULL;
    +    uint32_t size = 0;
    +    int32_t  rc   = midiFileBuild(trk, (uint32_t)arrlen(trk), SMF_TICKS_PER_QUARTER, &file, &size);
    +
    +    arrfree(trk);
    +
    +    if (rc != MUSIC_OK) {
    +        return rc;
    +    }
    +
    +    FILE *f  = fopen(path, "wb");
    +    bool  ok = f != NULL;
    +
    +    if (f) {
    +        ok = fwrite(file, 1, size, f) == size;
    +        ok = (fclose(f) == 0) && ok;
    +
    +        if (!ok) {
    +            remove(path);
    +        }
    +    }
    +
    +    free(file);
    +    return ok ? MUSIC_OK : MUSIC_ERR_WRITE;
    +}
    +
    +
    +static void putMsg(uint8_t **trk, uint32_t delta, uint8_t status, uint8_t data1, uint8_t data2) {
    +    midiPutVlq(trk, delta);
    +    arrput(*trk, status);
    +    arrput(*trk, data1);
    +
    +    if (!MIDI_ONE_DATA_BYTE(status & MIDI_TYPE_MASK)) {
    +        arrput(*trk, data2);
    +    }
    +}
    +
    +
    +// The track's events: the tempo, the messages, a note off for every note
    +// still held, and the end of the track.
    +static uint8_t *trackBytes(const MusicMidiRecordT *rec) {
    +    uint32_t held[MUSIC_MIDI_CHANNELS][HELD_WORDS];
    +    uint8_t *trk  = NULL;
    +    uint32_t last = 0;
    +
    +    memset(held, 0, sizeof(held));
    +    midiPutTempo(&trk, SMF_US_PER_QUARTER);
    +
    +    for (ptrdiff_t i = 0; i < arrlen(rec->msgs); i++) {
    +        const RecMsgT *m       = &rec->msgs[i];
    +        uint8_t        kind    = m->status & MIDI_TYPE_MASK;
    +        uint32_t      *channel = held[m->status & MIDI_CHANNEL_MASK];
    +        uint32_t       bit     = 1U << (m->data1 % HELD_WORD_BITS);
    +
    +        if (kind == MIDI_NOTE_ON && m->data2 > 0) {
    +            channel[m->data1 / HELD_WORD_BITS] |= bit;
    +        } else if (kind == MIDI_NOTE_ON || kind == MIDI_NOTE_OFF) {
    +            channel[m->data1 / HELD_WORD_BITS] &= ~bit;
    +        }
    +
    +        putMsg(&trk, m->ms - last, m->status, m->data1, m->data2);
    +        last = m->ms;
    +    }
    +
    +    for (int32_t ch = 0; ch < MUSIC_MIDI_CHANNELS; ch++) {
    +        for (int32_t note = 0; note <= MUSIC_MIDI_DATA_MAX; note++) {
    +            if (held[ch][note / HELD_WORD_BITS] & (1U << (note % HELD_WORD_BITS))) {
    +                putMsg(&trk, 0, (uint8_t)(MIDI_NOTE_OFF | ch), (uint8_t)note, 0);
    +            }
    +        }
    +    }
    +
    +    midiPutEnd(&trk, 0);
    +    return trk;
    +}
    diff --git a/src/libs/kpunch/music/musicMml.c b/src/libs/kpunch/music/musicMml.c
    index faaac9fe..4eea5993 100644
    --- a/src/libs/kpunch/music/musicMml.c
    +++ b/src/libs/kpunch/music/musicMml.c
    @@ -33,8 +33,6 @@
     #include "stb_ds.h"
     
     #include 
    -#include 
    -#include 
     
     // Ranges and defaults (QBasic's)
     #define MML_OCTAVE_MAX          6
    @@ -71,19 +69,7 @@ static const int32_t sLetterSemitone[] = { 9, 11, 0, 2, 4, 5, 7 };
     #define SMF_DIVISION            10000
     #define SMF_TEMPO_US            1000000
     #define SMF_US_PER_TICK         (SMF_TEMPO_US / SMF_DIVISION)
    -#define SMF_HEADER_LEN          6
    -#define SMF_FORMAT_0            0
    -#define SMF_TRACKS              1
    -#define SMF_TAG_BYTES           4
    -#define SMF_LEN_BYTES           4
    -#define SMF_META_TEMPO          0x51
    -#define SMF_META_END            0x2F
    -#define SMF_TEMPO_BYTES         3
     #define SMF_VELOCITY            100
    -#define SMF_VLQ_MAX_BYTES       5
    -#define SMF_WORD_BYTES          2
    -#define BITS_PER_BYTE           8
    -#define BYTE_MASK               0xFF
     
     // ============================================================
     // Prototypes
    @@ -97,8 +83,6 @@ void            mmlStateInit(MmlStateT *st);
     static uint32_t pitchMilliHz(int32_t pitch);
     static bool     readNumber(const char **p, int32_t *out);
     static bool     readRanged(const char **p, int32_t lo, int32_t hi, int32_t *out);
    -static void     smfBig(uint8_t **buf, uint32_t value, int32_t bytes);
    -static void     smfVlq(uint8_t **buf, uint32_t value);
     
     
     // A note of pitch (semitones from octave 0's C; -1 a rest) and length
    @@ -279,11 +263,7 @@ int32_t mmlSmfBuild(const MmlNoteT *notes, int32_t count, uint8_t **out, uint32_
         uint32_t lastTick = 0;
         int32_t  program  = MML_PROGRAM_TONE;
     
    -    smfVlq(&trk, 0);
    -    arrput(trk, MIDI_META);
    -    arrput(trk, SMF_META_TEMPO);
    -    arrput(trk, SMF_TEMPO_BYTES);
    -    smfBig(&trk, SMF_TEMPO_US, SMF_TEMPO_BYTES);
    +    midiPutTempo(&trk, SMF_TEMPO_US);
     
         for (int32_t i = 0; i < count; i++) {
             const MmlNoteT *n     = ¬es[i];
    @@ -292,18 +272,18 @@ int32_t mmlSmfBuild(const MmlNoteT *notes, int32_t count, uint8_t **out, uint32_
     
             if (n->program != program && n->program != MML_PROGRAM_TONE) {
                 program = n->program;
    -            smfVlq(&trk, onAt - lastTick);
    +            midiPutVlq(&trk, onAt - lastTick);
                 arrput(trk, MIDI_PROGRAM);
                 arrput(trk, (uint8_t)(program - 1));
                 lastTick = onAt;
             }
     
             if (n->milliHz) {
    -            smfVlq(&trk, onAt - lastTick);
    +            midiPutVlq(&trk, onAt - lastTick);
                 arrput(trk, MIDI_NOTE_ON);
                 arrput(trk, (uint8_t)n->midiNote);
                 arrput(trk, SMF_VELOCITY);
    -            smfVlq(&trk, offAt - onAt);
    +            midiPutVlq(&trk, offAt - onAt);
                 arrput(trk, MIDI_NOTE_OFF);
                 arrput(trk, (uint8_t)n->midiNote);
                 arrput(trk, 0);
    @@ -313,41 +293,12 @@ int32_t mmlSmfBuild(const MmlNoteT *notes, int32_t count, uint8_t **out, uint32_
             startUs += n->us;
         }
     
    -    smfVlq(&trk, (uint32_t)(startUs / SMF_US_PER_TICK) - lastTick);
    -    arrput(trk, MIDI_META);
    -    arrput(trk, SMF_META_END);
    -    arrput(trk, 0);
    +    midiPutEnd(&trk, (uint32_t)(startUs / SMF_US_PER_TICK) - lastTick);
     
    -    uint32_t trkLen = (uint32_t)arrlen(trk);
    -    uint32_t total  = SMF_TAG_BYTES + SMF_LEN_BYTES + SMF_HEADER_LEN + SMF_TAG_BYTES + SMF_LEN_BYTES + trkLen;
    -    uint8_t *file   = (uint8_t *)malloc(total);
    -    uint8_t *hdr    = NULL;
    +    int32_t rc = midiFileBuild(trk, (uint32_t)arrlen(trk), SMF_DIVISION, out, size);
     
    -    if (!file) {
    -        arrfree(trk);
    -        return MUSIC_ERR_NO_MEMORY;
    -    }
    -
    -    arrput(hdr, 'M');
    -    arrput(hdr, 'T');
    -    arrput(hdr, 'h');
    -    arrput(hdr, 'd');
    -    smfBig(&hdr, SMF_HEADER_LEN, SMF_LEN_BYTES);
    -    smfBig(&hdr, SMF_FORMAT_0, SMF_WORD_BYTES);
    -    smfBig(&hdr, SMF_TRACKS, SMF_WORD_BYTES);
    -    smfBig(&hdr, SMF_DIVISION, SMF_WORD_BYTES);
    -    arrput(hdr, 'M');
    -    arrput(hdr, 'T');
    -    arrput(hdr, 'r');
    -    arrput(hdr, 'k');
    -    smfBig(&hdr, trkLen, SMF_LEN_BYTES);
    -    memcpy(file, hdr, arrlen(hdr));
    -    memcpy(file + arrlen(hdr), trk, trkLen);
    -    arrfree(hdr);
         arrfree(trk);
    -    *out  = file;
    -    *size = total;
    -    return MUSIC_OK;
    +    return rc;
     }
     
     
    @@ -396,29 +347,3 @@ static bool readRanged(const char **p, int32_t lo, int32_t hi, int32_t *out) {
         *out = v;
         return true;
     }
    -
    -
    -// value in bytes bytes, most significant first.
    -static void smfBig(uint8_t **buf, uint32_t value, int32_t bytes) {
    -    for (int32_t i = bytes - 1; i >= 0; i--) {
    -        arrput(*buf, (uint8_t)((value >> (i * BITS_PER_BYTE)) & BYTE_MASK));
    -    }
    -}
    -
    -
    -// A variable-length quantity: 7 bits a byte, most significant first, the
    -// top bit set on all but the last.
    -static void smfVlq(uint8_t **buf, uint32_t value) {
    -    uint8_t tmp[SMF_VLQ_MAX_BYTES];
    -    int32_t n = 0;
    -
    -    do {
    -        tmp[n++] = (uint8_t)(value & MIDI_DATA_MASK);
    -        value  >>= MIDI_DATA_BITS;
    -    } while (value);
    -
    -    while (n > 0) {
    -        n--;
    -        arrput(*buf, (uint8_t)(tmp[n] | (n > 0 ? MIDI_STATUS_BIT : 0)));
    -    }
    -}
    diff --git a/src/libs/kpunch/music/musicModHw.c b/src/libs/kpunch/music/musicModHw.c
    new file mode 100644
    index 00000000..a2691957
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicModHw.c
    @@ -0,0 +1,384 @@
    +// 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.
    +
    +// musicModHw.c  -- tracker modules on a wavetable synthesizer's voices
    +// (see musicInt.h, "Tracker modules on a wavetable synthesizer")
    +//
    +// libxmp plays the module with DVX's hardware voice patch (xmpHwOn): it
    +// mixes nothing and reports its voices as each frame (a tracker tick)
    +// starts.  Here, at program time (libxmp uses floating point), frames
    +// are made ahead, up to MUSIC_MODULE_AHEAD_MS, into a queue of voices in
    +// the synthesizer's terms (the sample, the frame to start from, the
    +// loop, pitch in cents, attenuation, pan); the music timer plays them at
    +// their times (musicModTick.c).  The music's gain is added there, so a
    +// volume change is heard at the next frame.
    +//
    +// The samples go into the synthesizer's memory when the module opens: as
    +// they are (an 8-bit one stays 8-bit where the chip plays those), else
    +// all 8-bit.  The chips loop only forward, so a ping-pong loop is
    +// unrolled: its way back stored after its end (the frames past the loop
    +// dropped, as they are never heard), each frame where libxmp reads it
    +// (Impulse Tracker's way back a frame shorter: xmpBidiAdjust).  What the
    +// chips cannot do plays near enough: a ping-pong sustain loop goes
    +// forward, a sample played backwards (no loop) goes forward, Impulse
    +// Tracker's filters are not heard, and a voice that libxmp has no
    +// synthesizer voice for (a program holds the rest) is not heard.
    +
    +#include "musicInt.h"
    +
    +#include 
    +#include 
    +
    +#define BITS_8              8
    +#define BITS_16             16
    +#define BYTE_BITS           8
    +#define SAMPLE_8_SHIFT      8       // an 8-bit frame as 16-bit
    +#define RATE_FIX            16      // a rate's sixteenths count in its pitch
    +#define VOL_SHIFT           3       // XMP_HW_VOL_MAX >> VOL_SHIFT = WAVE_STEP_MAX
    +#define LEVEL_CB            80      // a voice at full volume 8 dB down: as loud as libxmp mixes it (measured on a GUS alone)
    +#define US_PER_MS           1000
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static int16_t frameAt(const XmpSampleT *xs, uint32_t i);
    +void           modHwClose(ModHwT *mod, XmpPlayerT *xmp);
    +void           modHwFill(ModHwT *mod, XmpPlayerT *xmp, bool loop);
    +void           modHwFlush(ModHwT *mod, uint32_t ms);
    +static void    modHwFree(ModHwT *mod);
    +int32_t        modHwOpen(AudioCtxT *ctx, XmpPlayerT *xmp, int32_t gain, ModHwT **out);
    +uint32_t       modHwPosition(const ModHwT *mod);
    +void           modHwSetGain(ModHwT *mod, int32_t gain);
    +void           modHwSilence(ModHwT *mod);
    +static void    modHwVoice(void *user, const XmpHwVoiceT *v);
    +static int32_t sampleLoad(ModHwT *mod, const XmpSampleT *xs, uint32_t i, uint32_t bits);
    +static void    samplesFree(ModHwT *mod);
    +static int32_t samplesLoad(ModHwT *mod, XmpPlayerT *xmp, uint32_t flags);
    +
    +
    +// Frame i of the sample as 16-bit.
    +static int16_t frameAt(const XmpSampleT *xs, uint32_t i) {
    +    if (xs->bits16) {
    +        return ((const int16_t *)xs->data)[i];
    +    }
    +
    +    return (int16_t)(((const int8_t *)xs->data)[i] * (1 << SAMPLE_8_SHIFT));
    +}
    +
    +
    +void modHwClose(ModHwT *mod, XmpPlayerT *xmp) {
    +    if (!mod) {
    +        return;
    +    }
    +
    +    uint32_t flags = platformIrqSave();
    +
    +    modHwStop(mod);
    +    platformIrqRestore(flags);
    +    xmpHwOff(xmp);
    +    modHwFree(mod);
    +}
    +
    +
    +// A frame is made while there is room for every voice it may have.  The
    +// song's end is a frame of its own.
    +void modHwFill(ModHwT *mod, XmpPlayerT *xmp, bool loop) {
    +    while (!mod->endMade && mod->madeUs - mod->playedUs < MUSIC_MODULE_AHEAD_MS * US_PER_MS && mod->frameHead - mod->frameTail < MODHW_FRAMES && MODHW_ENTRIES - (mod->entryHead - mod->entryTail) >= mod->voices) {
    +        ModHwFrameT *f  = &mod->frames[mod->frameHead & (MODHW_FRAMES - 1)];
    +        uint32_t     us = 0;
    +
    +        f->ms           = xmpPosition(xmp);
    +        f->end          = !xmpHwFrame(xmp, loop, &us);
    +        f->us           = f->end ? 0 : us;
    +        f->first        = mod->entryHead;
    +        f->count        = f->end ? 0 : mod->staged;
    +        mod->staged     = 0;
    +        mod->endMade    = f->end;
    +        mod->madeUs    += f->us;
    +        mod->entryHead += f->count;
    +        __atomic_signal_fence(__ATOMIC_RELEASE);
    +        mod->frameHead++;
    +    }
    +}
    +
    +
    +void modHwFlush(ModHwT *mod, uint32_t ms) {
    +    uint32_t flags = platformIrqSave();
    +
    +    modHwStop(mod);
    +    mod->frameTail = mod->frameHead;
    +    mod->entryTail = mod->entryHead;
    +    mod->playedUs  = mod->madeUs;
    +    mod->usInto    = 0;
    +    mod->applied   = false;
    +    mod->ms        = ms;
    +    platformIrqRestore(flags);
    +    mod->endMade = false;
    +}
    +
    +
    +// Its samples out of the synthesizer's memory, unpinned and freed.
    +static void modHwFree(ModHwT *mod) {
    +    if (mod->samples) {
    +        samplesFree(mod);
    +        platformUnlockRegion(mod->samples, mod->sampleCount * sizeof(ModHwSampleT), false);
    +    }
    +
    +    free(mod->samples);
    +    platformUnlockRegion(mod, sizeof(*mod), false);
    +    free(mod);
    +}
    +
    +
    +int32_t modHwOpen(AudioCtxT *ctx, XmpPlayerT *xmp, int32_t gain, ModHwT **out) {
    +    AudioWtInfoT info;
    +    XmpSampleT   xs;
    +    uint32_t     count  = xmpSampleCount(xmp);
    +    bool         stereo = false;
    +
    +    *out = NULL;
    +    audioWtGetInfo(&info);
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        stereo = stereo || (xmpSample(xmp, i, &xs) && xs.stereo);
    +    }
    +
    +    uint32_t voices = info.voices < AUDIO_WT_VOICES_MAX ? info.voices : AUDIO_WT_VOICES_MAX;
    +
    +    if (!info.present || voices < xmpChannels(xmp) || stereo) {
    +        return MUSIC_ERR_NO_DEVICE;
    +    }
    +
    +    ModHwT *mod = (ModHwT *)calloc(1, sizeof(ModHwT));
    +
    +    if (!mod) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    if (platformLockRegion(mod, sizeof(*mod), false) != 0) {
    +        free(mod);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    mod->ctx         = ctx;
    +    mod->voices      = voices;
    +    mod->bidiAdjust  = xmpBidiAdjust(xmp);
    +    mod->level       = LEVEL_CB + info.level;
    +    mod->sampleCount = count;
    +    mod->samples     = (ModHwSampleT *)calloc(count ? count : 1, sizeof(ModHwSampleT));
    +
    +    if (!mod->samples || platformLockRegion(mod->samples, count * sizeof(ModHwSampleT), false) != 0) {
    +        free(mod->samples);
    +        mod->samples = NULL;
    +        modHwFree(mod);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        mod->samples[i].addr = WAVE_NOT_LOADED;
    +    }
    +
    +    for (uint32_t v = 0; v < AUDIO_WT_VOICES_MAX; v++) {
    +        audioWtNoteInit(&mod->slots[v].note);
    +    }
    +
    +    int32_t rc = samplesLoad(mod, xmp, info.flags);
    +
    +    if (rc == MUSIC_OK && !xmpHwOn(xmp, voices, modHwVoice, mod)) {
    +        rc = MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    if (rc != MUSIC_OK) {
    +        modHwFree(mod);
    +        return rc;
    +    }
    +
    +    modHwSetGain(mod, gain);
    +    mod->ms = xmpPosition(xmp);
    +    *out    = mod;
    +    return MUSIC_OK;
    +}
    +
    +
    +uint32_t modHwPosition(const ModHwT *mod) {
    +    return mod->ms + mod->usInto / US_PER_MS;
    +}
    +
    +
    +void modHwSetGain(ModHwT *mod, int32_t gain) {
    +    int32_t g = gain < 0 ? 0 : (gain > AUDIO_VOLUME_MAX ? AUDIO_VOLUME_MAX : gain);
    +
    +    mod->gainAtten = waveCbForStep[g >> WAVE_GAIN_SHIFT];
    +}
    +
    +
    +void modHwSilence(ModHwT *mod) {
    +    uint32_t flags = platformIrqSave();
    +
    +    modHwStop(mod);
    +    mod->applied = false;
    +    platformIrqRestore(flags);
    +}
    +
    +
    +// libxmp's voice as the frame being made has it (xmpHwOn's function): a
    +// voice on a sample not in the synthesizer's memory, or at its end, is
    +// left out (and so is any beyond the room modHwFill made: libxmp has no
    +// more voices than that).
    +static void modHwVoice(void *user, const XmpHwVoiceT *v) {
    +    ModHwT *mod = (ModHwT *)user;
    +
    +    if (mod->staged >= mod->voices || v->sample >= mod->sampleCount || v->voice >= mod->voices || mod->samples[v->sample].addr == WAVE_NOT_LOADED) {
    +        return;
    +    }
    +
    +    const ModHwSampleT *s    = &mod->samples[v->sample];
    +    ModHwEntryT        *e    = &mod->entries[(mod->entryHead + mod->staged) & (MODHW_ENTRIES - 1)];
    +    uint32_t            pos  = (uint32_t)v->pos;
    +    double              rate = v->rate * RATE_FIX;
    +    int32_t             vol  = v->vol < 0 ? 0 : (v->vol > XMP_HW_VOL_MAX ? XMP_HW_VOL_MAX : v->vol);
    +
    +    if (v->loop == XMP_VOICE_LOOP_PINGPONG && s->backEnd && v->loopStart == s->loopStart && v->loopEnd == s->loopEnd) {
    +        // On the way back: where libxmp reads, in the stored way back
    +        e->loop      = AUDIO_WT_LOOP_ON;
    +        e->loopStart = s->loopStart;
    +        e->loopEnd   = s->backEnd;
    +        e->end       = s->backEnd;
    +        e->offset    = v->reverse ? 2 * s->loopEnd - mod->bidiAdjust - pos : pos;
    +        e->offset    = e->offset < s->backEnd ? e->offset : s->loopStart;
    +    } else {
    +        e->loop      = v->loop == XMP_VOICE_LOOP_NONE ? AUDIO_WT_LOOP_NONE : AUDIO_WT_LOOP_ON;
    +        e->loopStart = v->loopStart < s->frames ? v->loopStart : s->frames;
    +        e->loopEnd   = v->loopEnd < s->frames ? v->loopEnd : s->frames;
    +        e->end       = e->loopEnd;
    +        e->offset    = pos;
    +    }
    +
    +    if (e->offset >= e->end || (e->loop != AUDIO_WT_LOOP_NONE && e->loopEnd <= e->loopStart)) {
    +        return;
    +    }
    +
    +    e->trigger = v->trigger;
    +    e->pitch   = waveRatioCents(rate < (double)UINT32_MAX ? (uint32_t)rate : UINT32_MAX, AUDIO_WT_PITCH_RATE * RATE_FIX);
    +    e->atten   = (int16_t)(waveCbForStep[vol >> VOL_SHIFT] + mod->level);
    +    e->pan     = (int16_t)(v->surround ? 0 : v->pan * AUDIO_WT_PAN_MAX / XMP_HW_PAN_SPAN);
    +    e->sample  = (uint16_t)v->sample;
    +    e->voice   = (uint8_t)v->voice;
    +    mod->staged++;
    +}
    +
    +
    +// Sample i into the synthesizer's memory at bits: its frames (a ping-pong
    +// loop unrolled), then WAVE_GUARD_FRAMES the chip's interpolation reads
    +// past the end (the loop again, where the stored frames end with it;
    +// else silence).  MODHW_ERR_ROOM when it does not fit.
    +static int32_t sampleLoad(ModHwT *mod, const XmpSampleT *xs, uint32_t i, uint32_t bits) {
    +    ModHwSampleT *s      = &mod->samples[i];
    +    uint32_t      span   = xs->loopEnd - xs->loopStart;
    +    uint32_t      back   = xs->bidi && span > mod->bidiAdjust ? span - mod->bidiAdjust : 0;
    +    uint32_t      body   = back ? xs->loopEnd : xs->frames;
    +    uint32_t      frames = body + back;
    +    uint32_t      repeat = xs->loops && (back || xs->loopEnd == xs->frames) ? frames - xs->loopStart : 0;
    +    int16_t      *pcm    = (int16_t *)malloc((frames + WAVE_GUARD_FRAMES) * sizeof(int16_t));
    +    uint32_t      addr   = 0;
    +
    +    if (!pcm) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    for (uint32_t k = 0; k < body; k++) {
    +        pcm[k] = frameAt(xs, k);
    +    }
    +
    +    // The way back: frame k of it is libxmp's loopEnd - adjust - k (its
    +    // first, at the end, reads the last)
    +    for (uint32_t k = 0; k < back; k++) {
    +        uint32_t from = xs->loopEnd - mod->bidiAdjust - k;
    +
    +        pcm[body + k] = frameAt(xs, from < xs->loopEnd ? from : xs->loopEnd - 1);
    +    }
    +
    +    for (uint32_t g = 0; g < WAVE_GUARD_FRAMES; g++) {
    +        pcm[frames + g] = repeat ? pcm[xs->loopStart + g % repeat] : 0;
    +    }
    +
    +    int32_t rc = audioWtMemAlloc(mod->ctx, (frames + WAVE_GUARD_FRAMES) * (bits / BYTE_BITS), bits, &addr);
    +
    +    if (rc == AUDIO_OK && audioWtUpload(mod->ctx, addr, pcm, frames + WAVE_GUARD_FRAMES, bits) != AUDIO_OK) {
    +        audioWtMemFree(mod->ctx, addr);
    +        rc = AUDIO_ERR_ARG;
    +    }
    +
    +    free(pcm);
    +
    +    if (rc != AUDIO_OK) {
    +        return rc == AUDIO_ERR_NO_ROOM ? MODHW_ERR_ROOM : MUSIC_ERR_NO_DEVICE;
    +    }
    +
    +    s->addr      = addr;
    +    s->frames    = frames;
    +    s->loopStart = xs->loopStart;
    +    s->loopEnd   = xs->loopEnd;
    +    s->backEnd   = back ? frames : 0;
    +    s->bits      = (uint8_t)bits;
    +    return MUSIC_OK;
    +}
    +
    +
    +static void samplesFree(ModHwT *mod) {
    +    for (uint32_t i = 0; i < mod->sampleCount; i++) {
    +        if (mod->samples[i].addr != WAVE_NOT_LOADED) {
    +            audioWtMemFree(mod->ctx, mod->samples[i].addr);
    +        }
    +
    +        mod->samples[i].addr = WAVE_NOT_LOADED;
    +    }
    +}
    +
    +
    +// Every sample as it is (8-bit ones 8-bit where the chip plays those),
    +// else (some do not fit) all 8-bit where it does; a sample libxmp has no
    +// data for is left out.
    +static int32_t samplesLoad(ModHwT *mod, XmpPlayerT *xmp, uint32_t flags) {
    +    bool    chip8 = (flags & AUDIO_WT_8BIT) != 0;
    +    int32_t rc    = MODHW_ERR_ROOM;
    +
    +    for (uint32_t pass = 0; pass < (chip8 ? 2U : 1U) && rc == MODHW_ERR_ROOM; pass++) {
    +        XmpSampleT xs;
    +
    +        rc = MUSIC_OK;
    +        samplesFree(mod);
    +
    +        for (uint32_t i = 0; i < mod->sampleCount && rc == MUSIC_OK; i++) {
    +            if (xmpSample(xmp, i, &xs)) {
    +                rc = sampleLoad(mod, &xs, i, chip8 && (pass > 0 || !xs.bits16) ? BITS_8 : BITS_16);
    +            }
    +        }
    +    }
    +
    +    if (rc != MUSIC_OK) {
    +        samplesFree(mod);
    +    }
    +
    +    return rc;
    +}
    diff --git a/src/libs/kpunch/music/musicModTick.c b/src/libs/kpunch/music/musicModTick.c
    new file mode 100644
    index 00000000..c64ea150
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicModTick.c
    @@ -0,0 +1,215 @@
    +// 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.
    +
    +// musicModTick.c  -- a module's frames played on the wavetable
    +// synthesizer by the music timer (see musicInt.h, "Tracker modules on a
    +// wavetable synthesizer")
    +//
    +// Runs at interrupt time (see musicInt.h).  A frame is applied as it
    +// starts: each of its voices started on the synthesizer when it is new
    +// there, when libxmp started or moved it, or when its sample or loop
    +// changed; else its pitch, attenuation (the music's gain added), and pan
    +// sent where they changed.  A voice of libxmp's that the frame does not
    +// have is stopped and its synthesizer voice given back.  When the next
    +// frame is not made yet the one playing goes on, its voices sounding.
    +
    +#include "musicInt.h"
    +
    +#define LOCK_ITEMS          5
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool frameApply(ModHwT *mod);
    +int32_t     modHwLockItems(AudioLockItemT *items);
    +void        modHwStop(ModHwT *mod);
    +bool        modHwTick(ModHwT *mod, uint32_t us);
    +static void slotApply(ModHwT *mod, const ModHwEntryT *e);
    +static void slotStop(const ModHwT *mod, ModHwSlotT *slot);
    +
    +
    +// The frame at frameTail onto the voices; false: it is the song's end
    +// (every voice stopped).
    +static bool frameApply(ModHwT *mod) {
    +    const ModHwFrameT *f    = &mod->frames[mod->frameTail & (MODHW_FRAMES - 1)];
    +    uint32_t           seen = 0;
    +
    +    if (f->end) {
    +        modHwStop(mod);
    +        return false;
    +    }
    +
    +    for (uint32_t i = 0; i < f->count; i++) {
    +        const ModHwEntryT *e = &mod->entries[(f->first + i) & (MODHW_ENTRIES - 1)];
    +
    +        seen |= 1U << e->voice;
    +        slotApply(mod, e);
    +    }
    +
    +    for (uint32_t v = 0; v < mod->voices; v++) {
    +        if (!(seen & (1U << v))) {
    +            slotStop(mod, &mod->slots[v]);
    +        }
    +    }
    +
    +    mod->ms = f->ms;
    +    return true;
    +}
    +
    +
    +int32_t modHwLockItems(AudioLockItemT *items) {
    +    const void *fns[LOCK_ITEMS] = {
    +        (const void *)frameApply,
    +        (const void *)modHwStop,
    +        (const void *)modHwTick,
    +        (const void *)slotApply,
    +        (const void *)slotStop
    +    };
    +
    +    for (int32_t i = 0; i < LOCK_ITEMS; i++) {
    +        items[i].addr = fns[i];
    +        items[i].size = 0;
    +    }
    +
    +    return LOCK_ITEMS;
    +}
    +
    +
    +void modHwStop(ModHwT *mod) {
    +    for (uint32_t v = 0; v < mod->voices; v++) {
    +        slotStop(mod, &mod->slots[v]);
    +    }
    +}
    +
    +
    +// The frames on by us: each that has played its length gives way to the
    +// next one made (none made: it goes on).  A frame not applied yet (the
    +// first, or the one playing after a pause) is applied first.
    +bool modHwTick(ModHwT *mod, uint32_t us) {
    +    if (!mod->applied) {
    +        if (mod->frameTail == mod->frameHead) {
    +            return true;
    +        }
    +
    +        __atomic_signal_fence(__ATOMIC_ACQUIRE);
    +        mod->applied = true;
    +
    +        if (!frameApply(mod)) {
    +            return false;
    +        }
    +    }
    +
    +    mod->usInto += us;
    +
    +    for (;;) {
    +        const ModHwFrameT *f = &mod->frames[mod->frameTail & (MODHW_FRAMES - 1)];
    +
    +        if (mod->usInto < f->us) {
    +            break;
    +        }
    +
    +        if (mod->frameTail + 1 == mod->frameHead) {
    +            mod->usInto = f->us;
    +            break;
    +        }
    +
    +        __atomic_signal_fence(__ATOMIC_ACQUIRE);
    +        mod->usInto   -= f->us;
    +        mod->playedUs += f->us;
    +        mod->frameTail++;
    +        mod->entryTail = mod->frames[mod->frameTail & (MODHW_FRAMES - 1)].first;
    +
    +        if (!frameApply(mod)) {
    +            return false;
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +// One voice of the frame: started (a voice taken when it has none; none
    +// free: it is not heard) or changed.
    +static void slotApply(ModHwT *mod, const ModHwEntryT *e) {
    +    ModHwSlotT         *slot  = &mod->slots[e->voice];
    +    AudioWtVoiceT      *n     = &slot->note;
    +    const ModHwSampleT *smp   = &mod->samples[e->sample];
    +    int32_t             atten = e->atten + mod->gainAtten;
    +    uint32_t            what  = 0;
    +
    +    atten = atten < AUDIO_WT_ATTEN_MAX ? atten : AUDIO_WT_ATTEN_MAX;
    +
    +    if (!slot->id || slot->trigger != e->trigger || slot->sample != e->sample || n->loop != e->loop || n->loopStart != e->loopStart || n->loopEnd != e->loopEnd || n->end != e->end) {
    +        if (!slot->id) {
    +            int32_t id = audioWtAlloc(mod->ctx);
    +
    +            if (id <= 0) {
    +                return;
    +            }
    +
    +            slot->id = id;
    +        }
    +
    +        slot->trigger  = e->trigger;
    +        slot->sample   = e->sample;
    +        n->addr        = smp->addr;
    +        n->bits        = smp->bits;
    +        n->offset      = e->offset;
    +        n->loopStart   = e->loopStart;
    +        n->loopEnd     = e->loopEnd;
    +        n->end         = e->end;
    +        n->loop        = e->loop;
    +        n->pitch       = e->pitch;
    +        n->attenuation = atten;
    +        n->pan         = e->pan;
    +        audioWtStart(mod->ctx, slot->id, n);
    +        return;
    +    }
    +
    +    if (n->pitch != e->pitch) {
    +        n->pitch  = e->pitch;
    +        what     |= AUDIO_WT_SET_PITCH;
    +    }
    +
    +    if (n->attenuation != atten) {
    +        n->attenuation  = atten;
    +        what           |= AUDIO_WT_SET_VOLUME;
    +    }
    +
    +    if (n->pan != e->pan) {
    +        n->pan  = e->pan;
    +        what   |= AUDIO_WT_SET_PAN;
    +    }
    +
    +    if (what) {
    +        audioWtUpdate(mod->ctx, slot->id, n, what);
    +    }
    +}
    +
    +
    +static void slotStop(const ModHwT *mod, ModHwSlotT *slot) {
    +    if (slot->id) {
    +        audioWtFree(mod->ctx, slot->id);
    +        slot->id = 0;
    +    }
    +}
    diff --git a/src/libs/kpunch/music/musicMpu.c b/src/libs/kpunch/music/musicMpu.c
    index 54925321..d3eecccb 100644
    --- a/src/libs/kpunch/music/musicMpu.c
    +++ b/src/libs/kpunch/music/musicMpu.c
    @@ -253,7 +253,7 @@ static void mpuQueueMsg(MpuSinkT *m, uint8_t status, uint8_t data1, uint8_t data
         uint8_t       type = status & MIDI_TYPE_MASK;
         const uint8_t msg[MPU_MSG_BYTES_MAX] = { status, data1, data2 };
     
    -    mpuQueue(m, msg, (type == MIDI_PROGRAM || type == MIDI_CHAN_PRESSURE) ? MPU_MSG_BYTES_MAX - 1 : MPU_MSG_BYTES_MAX);
    +    mpuQueue(m, msg, MIDI_ONE_DATA_BYTE(type) ? MPU_MSG_BYTES_MAX - 1 : MPU_MSG_BYTES_MAX);
     }
     
     
    diff --git a/src/libs/kpunch/music/musicPat.c b/src/libs/kpunch/music/musicPat.c
    new file mode 100644
    index 00000000..49a5c959
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicPat.c
    @@ -0,0 +1,547 @@
    +// 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.
    +
    +// musicPat.c  -- a Gravis UltraSound patch set as a SoundFont bank (see
    +// musicInt.h, patLoadSet)
    +//
    +// ULTRASND.INI in an UltraSound folder (ULTRADIR) maps General MIDI
    +// programs ([Melodic Bank 0]: 0..127) and drum keys ([Drum Bank 0]: the
    +// MIDI note) to patch names (BLANK or nothing: none; the older
    +// [Melodic Patches] / [Drum Patches] where the banks are missing); each
    +// is NAME.PAT in its PatchDir, else ULTRADIR\MIDI.  Only the headers are
    +// read here: a .PAT is a 129-byte header, a 63-byte instrument and a
    +// 47-byte layer header (the file's sizes there are not to be trusted),
    +// then its waves, each a 96-byte header and its data.  Each wave becomes
    +// a sample (its frames read from the file when a song needs them:
    +// WaveBankT sources) and a region:
    +//
    +//   keys      from the wave's lowest and highest frequency (one wave:
    +//             every key); kept apart where they overlap (the GUS plays
    +//             the later wave) and stretched over the gaps
    +//   pitch     the patch's pivot key (scale_frequency) is the root, the
    +//             keys scale by scale_factor / 1024 semitones (0: one pitch
    +//             for every key, as drums do), the root frequency's distance
    +//             from the pivot's goes in the tuning (coarse and fine)
    +//   loop      looping or not (backwards and bidirectional loops play
    +//             forwards)
    +//   envelope  the six GF1 volume points: the first is the attack (its
    +//             level the region's attenuation), the third (with sustain)
    +//             or the last the sustain level, the second's rate the
    +//             decay, the fourth's the release; GF1 rates as times at 24
    +//             voices (a frame of 38.873 us)
    +//   LFOs      tremolo (to volume, up to 12 dB) and vibrato (to pitch, up
    +//             to an octave) at 0.05 + rate x 0.0233 Hz; the vibrato's
    +//             sweep as its delay
    +//   pan       balance 0..15
    +
    +#include "musicInt.h"
    +#include "stb_ds.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define PAT_MIDI_DIR        "MIDI"
    +#define PAT_EXT             ".PAT"
    +#define PAT_BLANK           "BLANK"
    +#define PAT_SECTION_DIR     "Ultrasound"
    +#define PAT_SECTION_MELODIC "Melodic Bank 0"
    +#define PAT_SECTION_DRUMS   "Drum Bank 0"
    +#define PAT_LEGACY_MELODIC  "Melodic Patches"
    +#define PAT_LEGACY_DRUMS    "Drum Patches"
    +#define PAT_KEY_DIR         "PatchDir"
    +#define PAT_KIT_NAME        "Standard"
    +#define PAT_KEY_LEN         8
    +
    +// File layout
    +#define PAT_MAGIC           "GF1PATCH1"     // GF1PATCH100 or GF1PATCH110
    +#define PAT_MAGIC_LEN       9
    +#define PAT_HEADER_BYTES    129
    +#define PAT_INSTR_BYTES     63
    +#define PAT_LAYER_BYTES     47
    +#define PAT_WAVE_BYTES      96
    +#define PAT_WAVES_AT        85              // the header's wave count
    +#define W_NAME_BYTES        7
    +#define W_SIZE              8
    +#define W_LOOP_START        12
    +#define W_LOOP_END          16
    +#define W_RATE              20
    +#define W_LOW               22
    +#define W_HIGH              26
    +#define W_ROOT              30
    +#define W_BALANCE           36
    +#define W_ENV_RATE          37
    +#define W_ENV_OFFSET        43
    +#define W_TREM_RATE         50
    +#define W_TREM_DEPTH        51
    +#define W_VIB_SWEEP         52
    +#define W_VIB_RATE          53
    +#define W_VIB_DEPTH         54
    +#define W_MODES             55
    +#define W_SCALE_FREQ        56
    +#define W_SCALE_FACTOR      58
    +#define MODE_16BIT          0x01
    +#define MODE_UNSIGNED       0x02
    +#define MODE_LOOP           0x04
    +#define MODE_SUSTAIN        0x20
    +#define MODE_ENVELOPE       0x40
    +#define ENV_POINTS          6
    +#define ENV_SUSTAIN_POINT   2
    +#define ENV_RELEASE_POINT   3
    +
    +// Units
    +#define A4_MILLIHZ          440000
    +#define A4_KEY_CENTS        6900
    +#define CENTS_SEMI          100
    +#define KEY_TOP             127
    +#define SCALE_UNITY         1024            // scale_factor: a semitone a key
    +#define BALANCE_MAX         15
    +#define PAN_SPAN            1000
    +#define PAN_HALF            500
    +#define VOL_SHIFT           4               // envelope offsets: 12-bit volume >> 4
    +#define VOL_FULL            4095
    +#define VOL_STEP_CB_NUM     2352            // a 12-bit volume step: 0.2352 cB
    +#define VOL_STEP_CB_DEN     10000
    +#define ATTEN_GEN_NUM       10              // the region's attenuation counts 0.4 dB a unit
    +#define ATTEN_GEN_DEN       4
    +#define RATE_INC_MASK       0x3F
    +#define RATE_RANGE_SHIFT    6
    +#define RATE_RANGE_STEP     8               // each range step: 8 times slower
    +#define FRAME_NS            38873           // a GF1 frame at 24 voices
    +#define NS_PER_US           1000
    +#define US_PER_SEC          1000000
    +#define LFO_BASE_MILLIHZ    8176            // 0 absolute cents
    +#define LFO_MILLIHZ_BASE    50              // 0.05 Hz
    +#define LFO_MILLIHZ_NUM     2333            // + 0.02333 Hz a step
    +#define LFO_MILLIHZ_DEN     100
    +#define TREM_CB_FULL        120             // 12 dB at depth 255
    +#define VIB_CENTS_FULL      1200
    +#define DEPTH_STEPS         256
    +#define SWEEP_SECS          45              // the vibrato's depth fades in over sweep / 45 s
    +#define BITS_8_BYTES        1
    +#define BITS_16_BYTES       2
    +
    +// A wave of a patch, as its header gives it
    +typedef struct {
    +    uint32_t dataAt;        // bytes into the file
    +    uint32_t size;          // bytes
    +    uint32_t loopStart;     // bytes from the wave's start
    +    uint32_t loopEnd;
    +    uint32_t low;           // milli-Hz
    +    uint32_t high;
    +    uint32_t root;
    +    uint16_t rate;          // Hz
    +    uint16_t scaleKey;      // the pivot key
    +    uint16_t scaleFactor;
    +    uint8_t  balance;
    +    uint8_t  envRate[ENV_POINTS];
    +    uint8_t  envLevel[ENV_POINTS];
    +    uint8_t  tremRate;
    +    uint8_t  tremDepth;
    +    uint8_t  vibSweep;
    +    uint8_t  vibRate;
    +    uint8_t  vibDepth;
    +    uint8_t  modes;
    +} PatWaveT;
    +
    +// The bank as it is built (stb_ds arrays)
    +typedef struct {
    +    WavePresetT  *presets;
    +    WaveRegionT  *regions;
    +    WaveSampleT  *samples;
    +    WaveSourceT  *sources;
    +    char        (*files)[DVX_MAX_PATH];
    +    uint32_t      frames;   // the bank's frames so far
    +} PatBuildT;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static int32_t  keyCents(uint32_t milliHz);
    +static uint32_t le16(const uint8_t *p);
    +static uint32_t le32(const uint8_t *p);
    +static int32_t  lfoCents(uint8_t rate);
    +static void     patAddWave(PatBuildT *b, const PatWaveT *w, uint16_t file, uint8_t keyLo, uint8_t keyHi);
    +static bool     patFind(PrefsHandleT *ini, const char *section, const char *dir, int32_t key, char *path);
    +int32_t         patLoadSet(const char *iniPath, WaveBankT *bank);
    +static int32_t  patRead(const char *path, PatWaveT **waves);
    +static int16_t  rampTc(uint32_t units, uint8_t rate);
    +
    +
    +// 100 x the MIDI key (fractional) of a frequency.
    +static int32_t keyCents(uint32_t milliHz) {
    +    return A4_KEY_CENTS + waveRatioCents(milliHz ? milliHz : 1, A4_MILLIHZ);
    +}
    +
    +
    +static uint32_t le16(const uint8_t *p) {
    +    return (uint32_t)p[0] | (uint32_t)p[1] << 8;
    +}
    +
    +
    +static uint32_t le32(const uint8_t *p) {
    +    return le16(p) | le16(p + 2) << 16;
    +}
    +
    +
    +// An LFO rate byte (0.05 Hz + 0.0233 Hz a step) in absolute cents.
    +static int32_t lfoCents(uint8_t rate) {
    +    return waveRatioCents(LFO_MILLIHZ_BASE + rate * LFO_MILLIHZ_NUM / LFO_MILLIHZ_DEN, LFO_BASE_MILLIHZ);
    +}
    +
    +
    +// A wave as a sample (its frames from the file, silence after them) and
    +// a region over keyLo..keyHi (see the top).
    +static void patAddWave(PatBuildT *b, const PatWaveT *w, uint16_t file, uint8_t keyLo, uint8_t keyHi) {
    +    uint32_t    bytes = w->modes & MODE_16BIT ? BITS_16_BYTES : BITS_8_BYTES;
    +    int32_t     tune;
    +    WaveSourceT src;
    +    WaveSampleT s;
    +    WaveRegionT r;
    +
    +    memset(&src, 0, sizeof(src));
    +    src.first  = b->frames;
    +    src.frames = w->size / bytes;
    +    src.offset = w->dataAt;
    +    src.file   = file;
    +    src.format = (uint8_t)((w->modes & MODE_16BIT ? WAVE_SRC_16BIT : 0) | (w->modes & MODE_UNSIGNED ? WAVE_SRC_UNSIGNED : 0));
    +    arrput(b->sources, src);
    +
    +    memset(&s, 0, sizeof(s));
    +    s.start     = src.first;
    +    s.end       = src.first + src.frames;
    +    s.loopStart = src.first + w->loopStart / bytes;
    +    s.loopEnd   = src.first + w->loopEnd / bytes;
    +    s.rate      = w->rate;
    +    s.type      = SF_SAMPLE_MONO;
    +    s.rootKey   = (uint8_t)(w->scaleKey > KEY_TOP ? KEY_TOP : w->scaleKey);
    +    s.loopEnd   = s.loopEnd > s.end ? s.end : s.loopEnd;
    +    s.loopStart = s.loopStart > s.loopEnd ? s.loopEnd : s.loopStart;
    +    tune        = s.rootKey * CENTS_SEMI - keyCents(w->root);
    +    b->frames  += src.frames + WAVE_GUARD_FRAMES;
    +    arrput(b->samples, s);
    +
    +    memset(&r, 0, sizeof(r));
    +
    +    for (int32_t g = 0; g < SF_GEN_COUNT; g++) {
    +        r.gen[g] = sf2GenDefault(g);
    +    }
    +
    +    r.sample                        = (uint16_t)(arrlen(b->samples) - 1);
    +    r.keyLo                         = keyLo;
    +    r.keyHi                         = keyHi;
    +    r.velHi                         = KEY_TOP;
    +    r.gen[SF_GEN_SAMPLE_MODES]      = w->modes & MODE_LOOP ? SF_LOOP_ON : SF_LOOP_NONE;
    +    r.gen[SF_GEN_SCALE_TUNING]      = (int16_t)(w->scaleFactor * CENTS_SEMI / SCALE_UNITY);
    +    r.gen[SF_GEN_COARSE_TUNE]       = (int16_t)(tune / CENTS_SEMI);
    +    r.gen[SF_GEN_FINE_TUNE]         = (int16_t)(tune % CENTS_SEMI);
    +    r.gen[SF_GEN_PAN]               = (int16_t)(w->balance * PAN_SPAN / BALANCE_MAX - PAN_HALF);
    +
    +    if (w->modes & MODE_ENVELOPE) {
    +        uint32_t peak = (uint32_t)w->envLevel[0] << VOL_SHIFT;
    +        uint32_t held = (uint32_t)w->envLevel[w->modes & MODE_SUSTAIN ? ENV_SUSTAIN_POINT : ENV_POINTS - 1] << VOL_SHIFT;
    +
    +        r.gen[SF_GEN_ATTENUATION]   = (int16_t)((VOL_FULL - peak) * VOL_STEP_CB_NUM / VOL_STEP_CB_DEN * ATTEN_GEN_NUM / ATTEN_GEN_DEN);
    +        r.gen[SF_GEN_VOL_ENV_ATTACK] = rampTc(peak, w->envRate[0]);
    +        r.gen[SF_GEN_VOL_ENV_DECAY]  = rampTc(VOL_FULL, w->envRate[1]);
    +        r.gen[SF_GEN_VOL_ENV_SUSTAIN] = (int16_t)(held < peak ? (peak - held) * VOL_STEP_CB_NUM / VOL_STEP_CB_DEN : 0);
    +        r.gen[SF_GEN_VOL_ENV_RELEASE] = rampTc(VOL_FULL, w->envRate[ENV_RELEASE_POINT]);
    +    }
    +
    +    if (w->tremDepth) {
    +        r.gen[SF_GEN_MOD_LFO_FREQ]      = (int16_t)lfoCents(w->tremRate);
    +        r.gen[SF_GEN_MOD_LFO_TO_VOLUME] = (int16_t)((w->tremDepth + 1) * TREM_CB_FULL / DEPTH_STEPS);
    +    }
    +
    +    if (w->vibDepth) {
    +        r.gen[SF_GEN_VIB_LFO_FREQ]     = (int16_t)lfoCents(w->vibRate);
    +        r.gen[SF_GEN_VIB_LFO_TO_PITCH] = (int16_t)((w->vibDepth + 1) * VIB_CENTS_FULL / DEPTH_STEPS);
    +
    +        if (w->vibSweep) {
    +            r.gen[SF_GEN_VIB_LFO_DELAY] = (int16_t)waveRatioCents(w->vibSweep, SWEEP_SECS);
    +        }
    +    }
    +
    +    arrput(b->regions, r);
    +}
    +
    +
    +// The patch for key (a program, or a drum's note) in section: in its
    +// PatchDir, else the folder's MIDI folder.  False for none or BLANK.
    +static bool patFind(PrefsHandleT *ini, const char *section, const char *dir, int32_t key, char *path) {
    +    char        keyName[PAT_KEY_LEN];
    +    char        file[DVX_MAX_PATH];
    +    char        midi[DVX_MAX_PATH];
    +    const char *name;
    +    const char *patchDir;
    +
    +    snprintf(keyName, sizeof(keyName), "%d", (int)key);
    +    name = prefsGetString(ini, section, keyName, "");
    +
    +    if (!name[0] || strcasecmp(name, PAT_BLANK) == 0) {
    +        return false;
    +    }
    +
    +    snprintf(file, sizeof(file), "%s" PAT_EXT, name);
    +    patchDir = prefsGetString(ini, section, PAT_KEY_DIR, prefsGetString(ini, PAT_SECTION_DIR, PAT_KEY_DIR, ""));
    +
    +    if (patchDir[0] && platformPathJoin(path, DVX_MAX_PATH, patchDir, file) && musicFileThere(path)) {
    +        return true;
    +    }
    +
    +    return platformPathJoin(midi, sizeof(midi), dir, PAT_MIDI_DIR) && platformPathJoin(path, DVX_MAX_PATH, midi, file) && musicFileThere(path);
    +}
    +
    +
    +// Each program's patch, then the drum kit (a region a key), from the
    +// banks' sections (the legacy ones where the banks are missing).
    +int32_t patLoadSet(const char *iniPath, WaveBankT *bank) {
    +    char          dir[DVX_MAX_PATH];
    +    char          path[DVX_MAX_PATH];
    +    PatBuildT     b;
    +    PrefsHandleT *ini;
    +    const char   *melodic;
    +    const char   *drums;
    +    int32_t       rc = MUSIC_OK;
    +
    +    memset(bank, 0, sizeof(*bank));
    +    memset(&b, 0, sizeof(b));
    +
    +    if (!platformPathDirName(iniPath, dir, sizeof(dir)) || !musicFileThere(iniPath)) {
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    ini = prefsLoad(iniPath);
    +
    +    if (!ini) {
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    melodic = prefsKeyName(ini, PAT_SECTION_MELODIC, 0) ? PAT_SECTION_MELODIC : PAT_LEGACY_MELODIC;
    +    drums   = prefsKeyName(ini, PAT_SECTION_DRUMS, 0) ? PAT_SECTION_DRUMS : PAT_LEGACY_DRUMS;
    +
    +    for (int32_t pass = 0; pass < 2 && rc == MUSIC_OK; pass++) {
    +        bool        kit   = pass == 1;
    +        WavePresetT p;
    +
    +        memset(&p, 0, sizeof(p));
    +        p.first = (uint32_t)arrlen(b.regions);
    +
    +        for (int32_t key = 0; key <= KEY_TOP && rc == MUSIC_OK; key++) {
    +            PatWaveT *waves = NULL;
    +
    +            if (!patFind(ini, kit ? drums : melodic, dir, key, path)) {
    +                continue;
    +            }
    +
    +            rc = patRead(path, &waves);
    +
    +            if (rc != MUSIC_OK) {
    +                dvxLog("Music: the patch %s is damaged; left out", path);
    +                rc = MUSIC_OK;
    +                arrfree(waves);
    +                continue;
    +            }
    +
    +            uint16_t file = (uint16_t)arrlen(b.files);
    +
    +            arrsetlen(b.files, file + 1);
    +            snprintf(b.files[file], DVX_MAX_PATH, "%s", path);
    +
    +            if (!kit) {
    +                p.first = (uint32_t)arrlen(b.regions);
    +            }
    +
    +            // Drums: the wave for the key's own pitch, at that key only;
    +            // instruments: each wave over its keys, kept apart, stretched
    +            // over the gaps
    +            int32_t next = 0;
    +
    +            for (int32_t i = 0; i < (int32_t)arrlen(waves); i++) {
    +                const PatWaveT *w    = &waves[i];
    +                bool            last = i == (int32_t)arrlen(waves) - 1;
    +                int32_t         lo   = (keyCents(w->low) + CENTS_SEMI - 1) / CENTS_SEMI;
    +                int32_t         hi   = keyCents(w->high) / CENTS_SEMI;
    +
    +                if (kit) {
    +                    if (last || (key >= lo && key <= hi)) {
    +                        patAddWave(&b, w, file, (uint8_t)key, (uint8_t)key);
    +                        break;
    +                    }
    +
    +                    continue;
    +                }
    +
    +                lo = next;
    +                hi = last || hi > KEY_TOP ? KEY_TOP : hi;
    +
    +                if (hi >= lo) {
    +                    patAddWave(&b, w, file, (uint8_t)lo, (uint8_t)hi);
    +                    next = hi + 1;
    +                }
    +            }
    +
    +            arrfree(waves);
    +
    +            if (!kit) {
    +                p.count   = (uint32_t)arrlen(b.regions) - p.first;
    +                p.bank    = 0;
    +                p.program = (uint16_t)key;
    +                snprintf(p.name, sizeof(p.name), "%s", platformPathBaseName(path));
    +                arrput(b.presets, p);
    +            }
    +        }
    +
    +        if (kit && arrlen(b.regions) > p.first) {
    +            p.count   = (uint32_t)arrlen(b.regions) - p.first;
    +            p.bank    = SF_DRUM_BANK;
    +            p.program = 0;
    +            snprintf(p.name, sizeof(p.name), "%s", PAT_KIT_NAME);
    +            arrput(b.presets, p);
    +        }
    +    }
    +
    +    prefsClose(ini);
    +
    +    if (arrlen(b.presets) == 0) {
    +        rc = rc == MUSIC_OK ? MUSIC_ERR_BANK : rc;
    +    }
    +
    +    // The arrays into the bank (malloc'd copies, as sf2Load leaves them)
    +    if (rc == MUSIC_OK) {
    +        bank->presetCount = (uint32_t)arrlen(b.presets);
    +        bank->regionCount = (uint32_t)arrlen(b.regions);
    +        bank->sampleCount = (uint32_t)arrlen(b.samples);
    +        bank->sourceCount = (uint32_t)arrlen(b.sources);
    +        bank->fileCount   = (uint32_t)arrlen(b.files);
    +        bank->dataFrames  = b.frames;
    +        bank->presets     = (WavePresetT *)malloc(bank->presetCount * sizeof(WavePresetT));
    +        bank->regions     = (WaveRegionT *)malloc(bank->regionCount * sizeof(WaveRegionT) + 1);
    +        bank->samples     = (WaveSampleT *)malloc(bank->sampleCount * sizeof(WaveSampleT) + 1);
    +        bank->sources     = (WaveSourceT *)malloc(bank->sourceCount * sizeof(WaveSourceT) + 1);
    +        bank->files       = (char (*)[DVX_MAX_PATH])malloc(bank->fileCount * DVX_MAX_PATH + 1);
    +
    +        if (!bank->presets || !bank->regions || !bank->samples || !bank->sources || !bank->files) {
    +            waveBankFree(bank);
    +            rc = MUSIC_ERR_NO_MEMORY;
    +        } else {
    +            memcpy(bank->presets, b.presets, bank->presetCount * sizeof(WavePresetT));
    +            memcpy(bank->regions, b.regions, bank->regionCount * sizeof(WaveRegionT));
    +            memcpy(bank->samples, b.samples, bank->sampleCount * sizeof(WaveSampleT));
    +            memcpy(bank->sources, b.sources, bank->sourceCount * sizeof(WaveSourceT));
    +            memcpy(bank->files, b.files, bank->fileCount * DVX_MAX_PATH);
    +            snprintf(bank->path, sizeof(bank->path), "%s", iniPath);
    +        }
    +    }
    +
    +    arrfree(b.presets);
    +    arrfree(b.regions);
    +    arrfree(b.samples);
    +    arrfree(b.sources);
    +    arrfree(b.files);
    +    return rc;
    +}
    +
    +
    +// A patch's waves (headers only): the patch header's mark, then each
    +// wave's header, its data skipped by its own size.
    +static int32_t patRead(const char *path, PatWaveT **waves) {
    +    uint8_t  head[PAT_HEADER_BYTES];
    +    uint8_t  wh[PAT_WAVE_BYTES];
    +    FILE    *f  = fopen(path, "rb");
    +    int32_t  rc = MUSIC_OK;
    +
    +    if (!f) {
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    if (fread(head, 1, sizeof(head), f) != sizeof(head) || memcmp(head, PAT_MAGIC, PAT_MAGIC_LEN) != 0 || fseek(f, PAT_INSTR_BYTES + PAT_LAYER_BYTES, SEEK_CUR) != 0) {
    +        fclose(f);
    +        return MUSIC_ERR_BANK;
    +    }
    +
    +    uint32_t count = le16(head + PAT_WAVES_AT);
    +    long     at    = PAT_HEADER_BYTES + PAT_INSTR_BYTES + PAT_LAYER_BYTES;
    +
    +    for (uint32_t i = 0; i < count && rc == MUSIC_OK; i++) {
    +        PatWaveT w;
    +
    +        if (fread(wh, 1, sizeof(wh), f) != sizeof(wh)) {
    +            rc = MUSIC_ERR_BANK;
    +            break;
    +        }
    +
    +        memset(&w, 0, sizeof(w));
    +        w.dataAt      = (uint32_t)(at + PAT_WAVE_BYTES);
    +        w.size        = le32(wh + W_SIZE);
    +        w.loopStart   = le32(wh + W_LOOP_START);
    +        w.loopEnd     = le32(wh + W_LOOP_END);
    +        w.rate        = (uint16_t)le16(wh + W_RATE);
    +        w.low         = le32(wh + W_LOW);
    +        w.high        = le32(wh + W_HIGH);
    +        w.root        = le32(wh + W_ROOT);
    +        w.balance     = wh[W_BALANCE] > BALANCE_MAX ? BALANCE_MAX : wh[W_BALANCE];
    +        w.tremRate    = wh[W_TREM_RATE];
    +        w.tremDepth   = wh[W_TREM_DEPTH];
    +        w.vibSweep    = wh[W_VIB_SWEEP];
    +        w.vibRate     = wh[W_VIB_RATE];
    +        w.vibDepth    = wh[W_VIB_DEPTH];
    +        w.modes       = wh[W_MODES];
    +        w.scaleKey    = (uint16_t)le16(wh + W_SCALE_FREQ);
    +        w.scaleFactor = (uint16_t)le16(wh + W_SCALE_FACTOR);
    +        memcpy(w.envRate, wh + W_ENV_RATE, ENV_POINTS);
    +        memcpy(w.envLevel, wh + W_ENV_OFFSET, ENV_POINTS);
    +        at += PAT_WAVE_BYTES + (long)w.size;
    +
    +        if (w.rate == 0 || w.size == 0 || fseek(f, at, SEEK_SET) != 0) {
    +            rc = MUSIC_ERR_BANK;
    +            break;
    +        }
    +
    +        arrput(*waves, w);
    +    }
    +
    +    fclose(f);
    +    return rc;
    +}
    +
    +
    +// A GF1 volume ramp of units 12-bit steps at a rate byte (RRMMMMMM: MMMMMM
    +// steps every 8^RR frames) as timecents.
    +static int16_t rampTc(uint32_t units, uint8_t rate) {
    +    uint32_t inc    = rate & RATE_INC_MASK;
    +    uint32_t frames = units;
    +
    +    if (inc == 0 || units == 0) {
    +        return sf2GenDefault(SF_GEN_VOL_ENV_ATTACK);
    +    }
    +
    +    for (uint32_t r = rate >> RATE_RANGE_SHIFT; r > 0; r--) {
    +        frames *= RATE_RANGE_STEP;
    +    }
    +
    +    uint64_t us = (uint64_t)frames / inc * FRAME_NS / NS_PER_US;
    +
    +    return (int16_t)waveRatioCents((uint32_t)(us ? us : 1), US_PER_SEC);
    +}
    diff --git a/src/libs/kpunch/music/musicSeq.c b/src/libs/kpunch/music/musicSeq.c
    index 6f5883d7..1f835e2f 100644
    --- a/src/libs/kpunch/music/musicSeq.c
    +++ b/src/libs/kpunch/music/musicSeq.c
    @@ -23,9 +23,10 @@
     // musicSeq.c  -- the sequencer, the event ring and the music timer
     //
     // Runs at interrupt time as well as program time (see musicInt.h): the
    -// timer callback musicTick plays every MIDI music on the FM chip or the
    -// MPU-401 port, MUSIC_TICK_US at a time.  The software synthesizer runs
    -// the same sequencer from its stream's fill function instead.
    +// timer callback musicTick plays every MIDI music on the FM chip, the
    +// wavetable chip, or the MPU-401 port, and every module on the wavetable
    +// chip, MUSIC_TICK_US at a time.  The software synthesizer runs the same
    +// sequencer from its stream's fill function instead.
     
     #include "musicInt.h"
     
    @@ -35,7 +36,7 @@
     #define RING_DATA1_SHIFT    8
     #define RING_DATA2_SHIFT    16
     #define RING_BYTE_MASK      0xFF
    -#define LOCK_ITEMS_MAX      64
    +#define LOCK_ITEMS_MAX      128
     
     IsrPlayerT *musicIsrPlayers[MUSIC_ISR_PLAYERS_MAX];
     
    @@ -82,10 +83,11 @@ void eventRingInit(EventRingT *ring) {
     }
     
     
    -// Every function of the three files that run at interrupt time
    -// (musicFm.c, musicMpu.c, this one) and the player table: one span from
    -// the lowest of them to the end of the table, which is the library's
    -// last data (the Makefile links these files last).
    +// Every function of the files that run at interrupt time (musicCents.c,
    +// musicFm.c, musicModTick.c, musicMpu.c, musicWave.c, this one) and the
    +// player table: one
    +// span from the lowest of them to the end of the table, which is the
    +// library's last data (the Makefile links these files last).
     bool musicLock(bool lock) {
         const void *fns[] = {
             (const void *)eventRingDrain,
    @@ -109,7 +111,9 @@ bool musicLock(bool lock) {
         }
     
         count               += fmLockItems(&items[count]);
    +    count               += modHwLockItems(&items[count]);
         count               += mpuLockItems(&items[count]);
    +    count               += waveLockItems(&items[count]);
         items[count].addr    = musicIsrPlayers;
         items[count].size    = sizeof(musicIsrPlayers);
         count++;
    @@ -118,8 +122,10 @@ bool musicLock(bool lock) {
     
     
     // A player that plays to its end is stopped and silenced here; the end
    -// call follows in the idle time (musicIdle).  The MPU-401 queue drains
    -// even while stopped (its closing messages).
    +// call follows in the idle time (musicIdle).  A module's frames play on
    +// the wavetable synthesizer (musicModTick.c).  The MPU-401 queue drains
    +// even while stopped (its closing messages), and wavetable notes play
    +// out their releases.
     void musicTick(void *unused) {
         (void)unused;
     
    @@ -130,7 +136,12 @@ void musicTick(void *unused) {
                 continue;
             }
     
    -        if (p->state == PLAY_PLAYING && !seqAdvance(&p->seq, MUSIC_TICK_US, p->sink)) {
    +        if (p->mod) {
    +            if (p->state == PLAY_PLAYING && !modHwTick(p->mod, MUSIC_TICK_US)) {
    +                p->state = PLAY_STOPPED;
    +                p->ended = true;
    +            }
    +        } else if (p->state == PLAY_PLAYING && !seqAdvance(&p->seq, MUSIC_TICK_US, p->sink)) {
                 p->state = PLAY_STOPPED;
                 p->sink->silence(p->sink);
                 p->ended = true;
    @@ -139,6 +150,10 @@ void musicTick(void *unused) {
             if (p->mpu) {
                 mpuSinkDrain(p->mpu, MUSIC_TICK_US);
             }
    +
    +        if (p->wave) {
    +            waveSynthTick(p->wave, MUSIC_TICK_US);
    +        }
         }
     }
     
    diff --git a/src/libs/kpunch/music/musicSf2.c b/src/libs/kpunch/music/musicSf2.c
    new file mode 100644
    index 00000000..90e9def7
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicSf2.c
    @@ -0,0 +1,994 @@
    +// 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.
    +
    +// musicSf2.c  -- SoundFont 2 banks (see musicInt.h, "SoundFont banks")
    +//
    +// A SoundFont is a RIFF "sfbk" file: LIST INFO (names, the version),
    +// LIST sdta (smpl: every sample's 16-bit data in one run), and LIST pdta,
    +// the "hydra" of nine record arrays: presets (phdr) with their zones
    +// (pbag) and each zone's generators (pgen) and modulators (pmod),
    +// instruments (inst, ibag, igen, imod) the same, and sample headers
    +// (shdr).  Each array ends with a terminal record.
    +//
    +// A zone is a list of generators.  An instrument zone ends with the
    +// sample it plays (sampleID); one without, if it is the first, is the
    +// instrument's global zone, whose values every other zone starts from.
    +// A preset zone ends with an instrument; its global zone works the same.
    +// Instrument values are absolute (over the specification's defaults);
    +// preset values are added to them, except the key and velocity ranges,
    +// which intersect, and the generators a preset may not set.  Each
    +// preset zone x instrument zone pair whose ranges meet is one region,
    +// every generator resolved and clamped to its legal range, so a note
    +// needs only its regions.  Modulators are not read: the renderer applies
    +// the specification's default ones (velocity, volume, expression, pan,
    +// pitch bend, modulation wheel).
    +//
    +// The sample data is not read here: a song reads what it uses
    +// (waveBankRead), from the file or the memory the bank came from.
    +//
    +// SoundFont 1 (.SBK, the AWE32's own banks, such as SYNTHGM.SBK for its
    +// General MIDI ROM) is the same file but for version 1 in ifil, 16-byte
    +// sample headers (start, end, loop start, loop end: no name, rate, or
    +// key; 44,100 Hz and middle C), the ROM samples first (their addresses
    +// the ROM's) until the first that starts at 0 (the file's own data on),
    +// loops kept one and two frames lower, and generators in the chip's own
    +// units, which sf1Value makes SoundFont 2's.  A preset's value there is
    +// not added: it counts only where the instrument zone sets nothing.  The
    +// specification is not public; this follows what is known of the files.
    +
    +#include "musicInt.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define RIFF_HEADER_BYTES   8       // id, length
    +#define RIFF_TYPE_BYTES     4
    +#define RIFF_ID_BYTES       4
    +#define RIFF_ALIGN_MASK     1u      // chunks are padded to even lengths
    +
    +// Record sizes of the hydra's arrays
    +#define PHDR_BYTES          38
    +#define BAG_BYTES           4
    +#define MOD_BYTES           10
    +#define GEN_BYTES           4
    +#define INST_BYTES          22
    +#define SHDR_BYTES          46
    +#define HYDRA_ARRAYS        9
    +
    +// Field offsets
    +#define PHDR_PRESET         20
    +#define PHDR_BANK           22
    +#define PHDR_BAG            24
    +#define INST_BAG            20
    +#define BAG_GEN             0
    +#define GEN_OPER            0
    +#define GEN_AMOUNT          2
    +#define SHDR_START          20
    +#define SHDR_END            24
    +#define SHDR_LOOP_START     28
    +#define SHDR_LOOP_END       32
    +#define SHDR_RATE           36
    +#define SHDR_PITCH          40
    +#define SHDR_CORRECTION     41
    +#define SHDR_TYPE           44
    +#define SHDR1_START         0       // SoundFont 1's sample header
    +#define SHDR1_END           4
    +#define SHDR1_LOOP_START    8
    +#define SHDR1_LOOP_END      12
    +
    +#define SF_VERSION_1        1
    +#define SF_VERSION_2        2
    +#define IFIL_BYTES          4
    +#define RANGE_FULL          0x7F00  // keyRange / velRange 0..127 (low byte low, high byte high)
    +#define RANGE_HIGH_SHIFT    8
    +#define BYTE_MASK           0xFF
    +#define MIDI_TOP            127
    +#define SAMPLE_BYTES        2
    +#define BYTE_BITS           8
    +#define SIGN_FLIP           0x8000  // unsigned to signed 16-bit
    +#define UNPITCHED_ROOT      60      // an unpitched sample (original pitch 255) plays as middle C
    +
    +// SoundFont 1
    +#define SHDR1_BYTES         16
    +#define SF1_RATE            44100
    +#define SF1_ROOT            60
    +#define SF1_LOOP_START_ADD  1       // its loop start is kept one frame lower
    +#define SF1_LOOP_END_ADD    2       // and its end two
    +#define SF1_GEN_SAMPLE_PITCH 55     // key x 100 + cents the sample sounds at
    +#define SF1_CENTS_KEY       100
    +#define SF1_FINE_ROUND      (-50)   // a fine tune this low rounds the root up
    +#define SF1_SCALE_HALF      50      // scaleTuning 1: 50 cents a key
    +#define SF1_SCALE_FULL      100
    +#define SF1_TC_NONE         (-12000) // a time of 0 ms
    +#define SF1_MS_PER_SEC      1000
    +#define SF1_SUSTAIN_FULL    96      // sustain values from here on: no fall
    +#define SF1_VOL_SUSTAIN_CB  1000    // sustainVolEnv 0: 100 dB down ...
    +#define SF1_VOL_SUSTAIN_NUM 21      // ... and 10.5 cB less a step
    +#define SF1_VOL_SUSTAIN_DEN 2
    +#define SF1_MOD_SUSTAIN_MAX 1000    // sustainModEnv: tenths of a percent down
    +#define SF1_KEY_TIME_NUM    555     // keynumTo*: 5.55 timecents a key a step
    +#define SF1_KEY_TIME_DEN    100
    +#define SF1_LEVEL_MAX       127     // pan, attenuation, filter cutoff: 0..127
    +#define SF1_PAN_SPAN        1000
    +#define SF1_PAN_HALF        500
    +#define SF1_ATTEN_SILENT    1440
    +#define SF1_ATTEN_NUM       10      // 200 log10(x) cB is 1200 log2(x) cents x 10 / 199
    +#define SF1_ATTEN_DEN       199
    +#define SF1_SEND_MAX        255     // chorus, reverb: 0..255
    +#define SF1_SEND_SPAN       1000
    +#define SF1_FC_OPEN         14400
    +#define SF1_FC_STEP         59      // absolute cents a step ...
    +#define SF1_FC_BASE         4366    // ... from here
    +#define SF1_Q_NUM           3       // filter Q: 1.5 cB a step
    +#define SF1_Q_DEN           2
    +#define SF1_LFO_OFFSET      7925    // LFO rates: 1200 log2(v) less this
    +#define SF1_MOD_LFO_NONE    (-725)  // the LFOs' rates at 0 (and when not set)
    +#define SF1_VIB_LFO_NONE    (-15600)
    +#define SF1_DEPTH_STEPS     64      // LFO and envelope depths: the chip's steps
    +#define SF1_PITCH_SPAN      1200    // to pitch: (1200 v / 64 + 1) / 2 cents
    +#define SF1_PITCH_ROUND     1
    +#define SF1_PITCH_DEN       2
    +#define SF1_LFO_FILTER_SPAN 3600
    +#define SF1_ENV_FILTER_SPAN 7200
    +#define SF1_VOLUME_SPAN     120
    +
    +// A generator's legal range, and whether a preset's value is added
    +typedef struct {
    +    int16_t lo;
    +    int16_t hi;
    +    int16_t def;
    +    bool    preset;     // a preset may set it (and its value is added)
    +} GenRuleT;
    +
    +// Where the file is read from
    +typedef struct {
    +    const uint8_t *data;    // the whole file in memory, or NULL
    +    uint32_t       size;
    +    FILE          *file;
    +} SourceT;
    +
    +// The hydra's arrays, located in the pdta data
    +typedef struct {
    +    const uint8_t *at[HYDRA_ARRAYS];
    +    uint32_t       count[HYDRA_ARRAYS];     // records, the terminal one included
    +    bool           sf1;                     // SoundFont 1
    +} HydraT;
    +
    +// A zone's generators as applyGens leaves them: values and which it set
    +typedef struct {
    +    int16_t gen[SF_GEN_COUNT];
    +    uint8_t set[SF_GEN_COUNT];
    +} ZoneT;
    +
    +// The hydra's arrays in their file order
    +enum { H_PHDR, H_PBAG, H_PMOD, H_PGEN, H_INST, H_IBAG, H_IMOD, H_IGEN, H_SHDR };
    +
    +static const char     sHydraIds[HYDRA_ARRAYS][RIFF_ID_BYTES + 1] = { "phdr", "pbag", "pmod", "pgen", "inst", "ibag", "imod", "igen", "shdr" };
    +static const uint32_t sHydraBytes[HYDRA_ARRAYS] = { PHDR_BYTES, BAG_BYTES, MOD_BYTES, GEN_BYTES, INST_BYTES, BAG_BYTES, MOD_BYTES, GEN_BYTES, SHDR_BYTES };
    +
    +// Ranges and defaults from the SoundFont 2.01 specification (section 8.1)
    +static const GenRuleT sGenRules[SF_GEN_COUNT] = {
    +    [SF_GEN_START_OFFSET]         = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_END_OFFSET]           = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_LOOP_START_OFFSET]    = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_LOOP_END_OFFSET]      = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_START_COARSE]         = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_MOD_LFO_TO_PITCH]     = { -12000, 12000, 0, true },
    +    [SF_GEN_VIB_LFO_TO_PITCH]     = { -12000, 12000, 0, true },
    +    [SF_GEN_MOD_ENV_TO_PITCH]     = { -12000, 12000, 0, true },
    +    [SF_GEN_FILTER_FC]            = { 1500, 13500, 13500, true },
    +    [SF_GEN_FILTER_Q]             = { 0, 960, 0, true },
    +    [SF_GEN_MOD_LFO_TO_FILTER]    = { -12000, 12000, 0, true },
    +    [SF_GEN_MOD_ENV_TO_FILTER]    = { -12000, 12000, 0, true },
    +    [SF_GEN_END_COARSE]           = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_MOD_LFO_TO_VOLUME]    = { -960, 960, 0, true },
    +    [14]                          = { 0, 0, 0, false },
    +    [SF_GEN_CHORUS_SEND]          = { 0, 1000, 0, true },
    +    [SF_GEN_REVERB_SEND]          = { 0, 1000, 0, true },
    +    [SF_GEN_PAN]                  = { -500, 500, 0, true },
    +    [18]                          = { 0, 0, 0, false },
    +    [19]                          = { 0, 0, 0, false },
    +    [20]                          = { 0, 0, 0, false },
    +    [SF_GEN_MOD_LFO_DELAY]        = { -12000, 5000, -12000, true },
    +    [SF_GEN_MOD_LFO_FREQ]         = { -16000, 4500, 0, true },
    +    [SF_GEN_VIB_LFO_DELAY]        = { -12000, 5000, -12000, true },
    +    [SF_GEN_VIB_LFO_FREQ]         = { -16000, 4500, 0, true },
    +    [SF_GEN_MOD_ENV_DELAY]        = { -12000, 5000, -12000, true },
    +    [SF_GEN_MOD_ENV_ATTACK]       = { -12000, 8000, -12000, true },
    +    [SF_GEN_MOD_ENV_HOLD]         = { -12000, 5000, -12000, true },
    +    [SF_GEN_MOD_ENV_DECAY]        = { -12000, 8000, -12000, true },
    +    [SF_GEN_MOD_ENV_SUSTAIN]      = { 0, 1000, 0, true },
    +    [SF_GEN_MOD_ENV_RELEASE]      = { -12000, 8000, -12000, true },
    +    [SF_GEN_KEY_TO_MOD_ENV_HOLD]  = { -1200, 1200, 0, true },
    +    [SF_GEN_KEY_TO_MOD_ENV_DECAY] = { -1200, 1200, 0, true },
    +    [SF_GEN_VOL_ENV_DELAY]        = { -12000, 5000, -12000, true },
    +    [SF_GEN_VOL_ENV_ATTACK]       = { -12000, 8000, -12000, true },
    +    [SF_GEN_VOL_ENV_HOLD]         = { -12000, 5000, -12000, true },
    +    [SF_GEN_VOL_ENV_DECAY]        = { -12000, 8000, -12000, true },
    +    [SF_GEN_VOL_ENV_SUSTAIN]      = { 0, 1440, 0, true },
    +    [SF_GEN_VOL_ENV_RELEASE]      = { -12000, 8000, -12000, true },
    +    [SF_GEN_KEY_TO_VOL_ENV_HOLD]  = { -1200, 1200, 0, true },
    +    [SF_GEN_KEY_TO_VOL_ENV_DECAY] = { -1200, 1200, 0, true },
    +    [SF_GEN_INSTRUMENT]           = { INT16_MIN, INT16_MAX, 0, false },
    +    [42]                          = { 0, 0, 0, false },
    +    [SF_GEN_KEY_RANGE]            = { INT16_MIN, INT16_MAX, RANGE_FULL, false },
    +    [SF_GEN_VEL_RANGE]            = { INT16_MIN, INT16_MAX, RANGE_FULL, false },
    +    [SF_GEN_LOOP_START_COARSE]    = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_KEYNUM]               = { SF_ROOT_NONE, MIDI_TOP, SF_ROOT_NONE, false },
    +    [SF_GEN_VELOCITY]             = { SF_ROOT_NONE, MIDI_TOP, SF_ROOT_NONE, false },
    +    [SF_GEN_ATTENUATION]          = { 0, 1440, 0, true },
    +    [49]                          = { 0, 0, 0, false },
    +    [SF_GEN_LOOP_END_COARSE]      = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_COARSE_TUNE]          = { -120, 120, 0, true },
    +    [SF_GEN_FINE_TUNE]            = { -99, 99, 0, true },
    +    [SF_GEN_SAMPLE_ID]            = { INT16_MIN, INT16_MAX, 0, false },
    +    [SF_GEN_SAMPLE_MODES]         = { INT16_MIN, INT16_MAX, SF_LOOP_NONE, false },
    +    [55]                          = { 0, 0, 0, false },
    +    [SF_GEN_SCALE_TUNING]         = { 0, 1200, 100, true },
    +    [SF_GEN_EXCLUSIVE_CLASS]      = { 0, MIDI_TOP, 0, false },
    +    [SF_GEN_ROOT_KEY]             = { SF_ROOT_NONE, MIDI_TOP, SF_ROOT_NONE, false },
    +    [59]                          = { 0, 0, 0, false },
    +    [60]                          = { 0, 0, 0, false }
    +};
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void         applyGens(const HydraT *h, int32_t genArray, uint32_t first, uint32_t last, ZoneT *zone);
    +static bool         bagRange(const HydraT *h, int32_t bagArray, uint32_t bag, uint32_t *first, uint32_t *last);
    +static int32_t      buildRegions(const HydraT *h, WaveBankT *bank);
    +static bool         findHydra(const uint8_t *pdta, uint32_t size, bool sf1, HydraT *h);
    +static int32_t      instrumentRegions(const HydraT *h, const WaveBankT *bank, uint32_t inst, const ZoneT *preset, uint32_t *count, WaveRegionT **regions);
    +static uint32_t     le16(const uint8_t *p);
    +static uint32_t     le32(const uint8_t *p);
    +static int32_t      readHydra(SourceT *src, uint32_t offset, uint32_t size, bool sf1, WaveBankT *bank);
    +static int32_t      readSamples(const HydraT *h, WaveBankT *bank);
    +static void         regionGens(bool sf1, const ZoneT *inst, const ZoneT *preset, int16_t *out);
    +static int32_t      sf1Default(int32_t gen);
    +static const ZoneT *sf1From(const ZoneT *inst, const ZoneT *preset, int32_t gen);
    +static void         sf1Tuning(const ZoneT *inst, const ZoneT *preset, int16_t *out);
    +static int32_t      sf1Value(int32_t gen, int32_t v);
    +static int32_t      sf2From(SourceT *src, WaveBankT *bank);
    +int16_t             sf2GenDefault(int32_t gen);
    +int32_t             sf2Load(const char *path, WaveBankT *bank);
    +int32_t             sf2Parse(const uint8_t *data, uint32_t size, WaveBankT *bank);
    +static bool         sourcesRead(const WaveBankT *bank, uint32_t first, uint32_t frames, int16_t *out);
    +static bool         srcRead(SourceT *src, uint32_t offset, void *out, uint32_t len);
    +void                waveBankFree(WaveBankT *bank);
    +bool                waveBankRead(const WaveBankT *bank, uint32_t first, uint32_t frames, int16_t *out);
    +static bool         zoneHas(const HydraT *h, int32_t genArray, uint32_t first, uint32_t last, uint32_t oper);
    +
    +
    +// The generators first .. last - 1 of an array set into zone (ranges and
    +// all), as a zone's own values replace what it started from.
    +static void applyGens(const HydraT *h, int32_t genArray, uint32_t first, uint32_t last, ZoneT *zone) {
    +    for (uint32_t g = first; g < last; g++) {
    +        const uint8_t *rec  = h->at[genArray] + g * GEN_BYTES;
    +        uint32_t       oper = le16(rec + GEN_OPER);
    +
    +        if (oper < SF_GEN_COUNT) {
    +            zone->gen[oper] = (int16_t)le16(rec + GEN_AMOUNT);
    +            zone->set[oper] = 1;
    +        }
    +    }
    +}
    +
    +
    +// The generators of zone bag (bagArray: H_PBAG or H_IBAG) are first ..
    +// last - 1; false when they run past the generator array.
    +static bool bagRange(const HydraT *h, int32_t bagArray, uint32_t bag, uint32_t *first, uint32_t *last) {
    +    int32_t genArray = bagArray == H_PBAG ? H_PGEN : H_IGEN;
    +
    +    *first = le16(h->at[bagArray] + bag * BAG_BYTES + BAG_GEN);
    +    *last  = le16(h->at[bagArray] + (bag + 1) * BAG_BYTES + BAG_GEN);
    +    return *first <= *last && *last <= h->count[genArray];
    +}
    +
    +
    +// Every preset's regions, in preset order.
    +static int32_t buildRegions(const HydraT *h, WaveBankT *bank) {
    +    uint32_t presets = h->count[H_PHDR] - 1;
    +
    +    bank->presets = (WavePresetT *)calloc(presets ? presets : 1, sizeof(WavePresetT));
    +
    +    if (!bank->presets) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    for (uint32_t p = 0; p < presets; p++) {
    +        const uint8_t *rec      = h->at[H_PHDR] + p * PHDR_BYTES;
    +        uint32_t       bagFirst = le16(rec + PHDR_BAG);
    +        uint32_t       bagLast  = le16(rec + PHDR_BYTES + PHDR_BAG);
    +        WavePresetT   *out      = &bank->presets[p];
    +        ZoneT          global;
    +
    +        memcpy(out->name, rec, SF_NAME_LEN);
    +        out->name[SF_NAME_LEN] = '\0';
    +        out->program           = (uint16_t)le16(rec + PHDR_PRESET);
    +        out->bank              = (uint16_t)le16(rec + PHDR_BANK);
    +        out->first             = bank->regionCount;
    +
    +        if (bagFirst > bagLast || bagLast >= h->count[H_PBAG]) {
    +            return MUSIC_ERR_BANK;
    +        }
    +
    +        // A preset's own values start at nothing to add, its ranges full
    +        memset(&global, 0, sizeof(global));
    +        global.gen[SF_GEN_KEY_RANGE] = RANGE_FULL;
    +        global.gen[SF_GEN_VEL_RANGE] = RANGE_FULL;
    +
    +        for (uint32_t b = bagFirst; b < bagLast; b++) {
    +            uint32_t first;
    +            uint32_t last;
    +            ZoneT    zone;
    +
    +            if (!bagRange(h, H_PBAG, b, &first, &last)) {
    +                return MUSIC_ERR_BANK;
    +            }
    +
    +            if (!zoneHas(h, H_PGEN, first, last, SF_GEN_INSTRUMENT)) {
    +                if (b == bagFirst) {
    +                    applyGens(h, H_PGEN, first, last, &global);
    +                }
    +
    +                continue;
    +            }
    +
    +            zone = global;
    +            applyGens(h, H_PGEN, first, last, &zone);
    +
    +            uint32_t inst = (uint16_t)zone.gen[SF_GEN_INSTRUMENT];
    +
    +            if (inst + 1 >= h->count[H_INST]) {
    +                return MUSIC_ERR_BANK;
    +            }
    +
    +            int32_t rc = instrumentRegions(h, bank, inst, &zone, &bank->regionCount, &bank->regions);
    +
    +            if (rc != MUSIC_OK) {
    +                return rc;
    +            }
    +        }
    +
    +        out->count = bank->regionCount - out->first;
    +    }
    +
    +    bank->presetCount = presets;
    +    return MUSIC_OK;
    +}
    +
    +
    +// The pdta list's nine arrays, each a whole number of records with its
    +// terminal one (the modulators may be missing: they are not read).
    +static bool findHydra(const uint8_t *pdta, uint32_t size, bool sf1, HydraT *h) {
    +    memset(h, 0, sizeof(*h));
    +    h->sf1 = sf1;
    +
    +    for (uint32_t at = 0; at + RIFF_HEADER_BYTES <= size;) {
    +        uint32_t len  = le32(pdta + at + RIFF_ID_BYTES);
    +        uint32_t body = at + RIFF_HEADER_BYTES;
    +
    +        if (len > size - body) {
    +            return false;
    +        }
    +
    +        for (int32_t i = 0; i < HYDRA_ARRAYS; i++) {
    +            uint32_t rec = i == H_SHDR && sf1 ? SHDR1_BYTES : sHydraBytes[i];
    +
    +            if (memcmp(pdta + at, sHydraIds[i], RIFF_ID_BYTES) == 0) {
    +                if (len % rec != 0 || len < rec) {
    +                    return false;
    +                }
    +
    +                h->at[i]    = pdta + body;
    +                h->count[i] = len / rec;
    +            }
    +        }
    +
    +        at = body + len + (len & RIFF_ALIGN_MASK);
    +    }
    +
    +    for (int32_t i = 0; i < HYDRA_ARRAYS; i++) {
    +        if (!h->at[i] && i != H_PMOD && i != H_IMOD) {
    +            return false;
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +// The regions of instrument inst under preset zone preset (its values,
    +// its ranges), appended to *regions.
    +static int32_t instrumentRegions(const HydraT *h, const WaveBankT *bank, uint32_t inst, const ZoneT *preset, uint32_t *count, WaveRegionT **regions) {
    +    const uint8_t *rec      = h->at[H_INST] + inst * INST_BYTES;
    +    uint32_t       bagFirst = le16(rec + INST_BAG);
    +    uint32_t       bagLast  = le16(rec + INST_BYTES + INST_BAG);
    +    ZoneT          global;
    +
    +    if (bagFirst > bagLast || bagLast >= h->count[H_IBAG]) {
    +        return MUSIC_ERR_BANK;
    +    }
    +
    +    memset(&global, 0, sizeof(global));
    +
    +    for (int32_t g = 0; g < SF_GEN_COUNT; g++) {
    +        global.gen[g] = sGenRules[g].def;
    +    }
    +
    +    for (uint32_t b = bagFirst; b < bagLast; b++) {
    +        uint32_t first;
    +        uint32_t last;
    +        ZoneT    zone;
    +
    +        if (!bagRange(h, H_IBAG, b, &first, &last)) {
    +            return MUSIC_ERR_BANK;
    +        }
    +
    +        if (!zoneHas(h, H_IGEN, first, last, SF_GEN_SAMPLE_ID)) {
    +            if (b == bagFirst) {
    +                applyGens(h, H_IGEN, first, last, &global);
    +            }
    +
    +            continue;
    +        }
    +
    +        zone = global;
    +        applyGens(h, H_IGEN, first, last, &zone);
    +
    +        uint32_t sample = (uint16_t)zone.gen[SF_GEN_SAMPLE_ID];
    +        uint32_t keyLo  = (uint32_t)zone.gen[SF_GEN_KEY_RANGE] & BYTE_MASK;
    +        uint32_t keyHi  = ((uint32_t)zone.gen[SF_GEN_KEY_RANGE] >> RANGE_HIGH_SHIFT) & BYTE_MASK;
    +        uint32_t velLo  = (uint32_t)zone.gen[SF_GEN_VEL_RANGE] & BYTE_MASK;
    +        uint32_t velHi  = ((uint32_t)zone.gen[SF_GEN_VEL_RANGE] >> RANGE_HIGH_SHIFT) & BYTE_MASK;
    +        uint32_t pKeyLo = (uint32_t)preset->gen[SF_GEN_KEY_RANGE] & BYTE_MASK;
    +        uint32_t pKeyHi = ((uint32_t)preset->gen[SF_GEN_KEY_RANGE] >> RANGE_HIGH_SHIFT) & BYTE_MASK;
    +        uint32_t pVelLo = (uint32_t)preset->gen[SF_GEN_VEL_RANGE] & BYTE_MASK;
    +        uint32_t pVelHi = ((uint32_t)preset->gen[SF_GEN_VEL_RANGE] >> RANGE_HIGH_SHIFT) & BYTE_MASK;
    +
    +        if (sample >= bank->sampleCount) {
    +            return MUSIC_ERR_BANK;
    +        }
    +
    +        keyLo = keyLo > pKeyLo ? keyLo : pKeyLo;
    +        keyHi = keyHi < pKeyHi ? keyHi : pKeyHi;
    +        velLo = velLo > pVelLo ? velLo : pVelLo;
    +        velHi = velHi < pVelHi ? velHi : pVelHi;
    +
    +        if (keyLo > keyHi || velLo > velHi || keyHi > MIDI_TOP || velHi > MIDI_TOP) {
    +            continue;
    +        }
    +
    +        WaveRegionT *grown = (WaveRegionT *)realloc(*regions, (*count + 1) * sizeof(WaveRegionT));
    +
    +        if (!grown) {
    +            return MUSIC_ERR_NO_MEMORY;
    +        }
    +
    +        *regions = grown;
    +
    +        WaveRegionT *r = &grown[(*count)++];
    +
    +        regionGens(h->sf1, &zone, preset, r->gen);
    +        r->sample = (uint16_t)sample;
    +        r->keyLo  = (uint8_t)keyLo;
    +        r->keyHi  = (uint8_t)keyHi;
    +        r->velLo  = (uint8_t)velLo;
    +        r->velHi  = (uint8_t)velHi;
    +    }
    +
    +    return MUSIC_OK;
    +}
    +
    +
    +static uint32_t le16(const uint8_t *p) {
    +    return (uint32_t)p[0] | ((uint32_t)p[1] << 8);
    +}
    +
    +
    +static uint32_t le32(const uint8_t *p) {
    +    return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
    +}
    +
    +
    +// The pdta list read and made into the bank.
    +static int32_t readHydra(SourceT *src, uint32_t offset, uint32_t size, bool sf1, WaveBankT *bank) {
    +    uint8_t *pdta = (uint8_t *)malloc(size ? size : 1);
    +    HydraT   h;
    +    int32_t  rc;
    +
    +    if (!pdta) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    if (!srcRead(src, offset, pdta, size)) {
    +        free(pdta);
    +        return MUSIC_ERR_BANK;
    +    }
    +
    +    rc = findHydra(pdta, size, sf1, &h) ? readSamples(&h, bank) : MUSIC_ERR_BANK;
    +    rc = rc == MUSIC_OK ? buildRegions(&h, bank) : rc;
    +    free(pdta);
    +    return rc;
    +}
    +
    +
    +// The sample headers, checked against the sample data: a ROM sample is
    +// kept as it is (its addresses are the synthesizer's); a loop outside
    +// its sample is moved inside it.  SoundFont 1's: see the top.
    +static int32_t readSamples(const HydraT *h, WaveBankT *bank) {
    +    uint32_t count = h->count[H_SHDR] - 1;
    +    bool     rom   = h->sf1;
    +
    +    bank->samples = (WaveSampleT *)calloc(count ? count : 1, sizeof(WaveSampleT));
    +
    +    if (!bank->samples) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        WaveSampleT *s = &bank->samples[i];
    +
    +        if (h->sf1) {
    +            const uint8_t *rec = h->at[H_SHDR] + i * SHDR1_BYTES;
    +
    +            rom           = rom && le32(rec + SHDR1_START) != 0;
    +            s->start      = le32(rec + SHDR1_START);
    +            s->end        = le32(rec + SHDR1_END);
    +            s->loopStart  = le32(rec + SHDR1_LOOP_START) + SF1_LOOP_START_ADD;
    +            s->loopEnd    = le32(rec + SHDR1_LOOP_END) + SF1_LOOP_END_ADD;
    +            s->rate       = SF1_RATE;
    +            s->rootKey    = SF1_ROOT;
    +            s->correction = 0;
    +            s->type       = (uint16_t)(rom ? SF_SAMPLE_ROM | SF_SAMPLE_MONO : SF_SAMPLE_MONO);
    +        } else {
    +            const uint8_t *rec = h->at[H_SHDR] + i * SHDR_BYTES;
    +
    +            s->start      = le32(rec + SHDR_START);
    +            s->end        = le32(rec + SHDR_END);
    +            s->loopStart  = le32(rec + SHDR_LOOP_START);
    +            s->loopEnd    = le32(rec + SHDR_LOOP_END);
    +            s->rate       = le32(rec + SHDR_RATE);
    +            s->rootKey    = rec[SHDR_PITCH] > MIDI_TOP ? UNPITCHED_ROOT : rec[SHDR_PITCH];
    +            s->correction = (int8_t)rec[SHDR_CORRECTION];
    +            s->type       = (uint16_t)le16(rec + SHDR_TYPE);
    +        }
    +
    +        if (!(s->type & SF_SAMPLE_ROM) && (s->start > s->end || s->end > bank->dataFrames)) {
    +            return MUSIC_ERR_BANK;
    +        }
    +
    +        if (s->loopStart < s->start || s->loopEnd > s->end || s->loopStart >= s->loopEnd) {
    +            s->loopStart = s->start;
    +            s->loopEnd   = s->end;
    +        }
    +    }
    +
    +    bank->sampleCount = count;
    +    return MUSIC_OK;
    +}
    +
    +
    +// A region's generators from its instrument zone and preset zone (see
    +// the top), each clamped to its legal range.
    +static void regionGens(bool sf1, const ZoneT *inst, const ZoneT *preset, int16_t *out) {
    +    for (int32_t g = 0; g < SF_GEN_COUNT; g++) {
    +        int32_t v;
    +
    +        if (sf1) {
    +            const ZoneT *from = sf1From(inst, preset, g);
    +
    +            v = from ? sf1Value(g, from->gen[g]) : sf1Default(g);
    +        } else {
    +            v = inst->gen[g] + (sGenRules[g].preset ? preset->gen[g] : 0);
    +        }
    +
    +        v      = v < sGenRules[g].lo ? sGenRules[g].lo : v;
    +        v      = v > sGenRules[g].hi ? sGenRules[g].hi : v;
    +        out[g] = (int16_t)v;
    +    }
    +
    +    if (sf1) {
    +        sf1Tuning(inst, preset, out);
    +    }
    +}
    +
    +
    +// A SoundFont 1 generator no zone sets: the specification's default, but
    +// for the sustains and the LFO rates, which are what 0 is.
    +static int32_t sf1Default(int32_t gen) {
    +    switch (gen) {
    +        case SF_GEN_VOL_ENV_SUSTAIN:
    +        case SF_GEN_MOD_ENV_SUSTAIN:
    +        case SF_GEN_MOD_LFO_FREQ:
    +        case SF_GEN_VIB_LFO_FREQ:
    +            return sf1Value(gen, 0);
    +
    +        default:
    +            return sGenRules[gen].def;
    +    }
    +}
    +
    +
    +// The zone a SoundFont 1 generator's value comes from: the instrument's
    +// when it sets it, else the preset's (where a preset may set it); NULL
    +// for neither.
    +static const ZoneT *sf1From(const ZoneT *inst, const ZoneT *preset, int32_t gen) {
    +    if (inst->set[gen]) {
    +        return inst;
    +    }
    +
    +    return sGenRules[gen].preset && preset->set[gen] ? preset : NULL;
    +}
    +
    +
    +// SoundFont 1's tuning: the coarse tune (keys) and fine tune scaled by the
    +// scale tuning (0: 100 cents a key, 1: 50), and the sample's own pitch
    +// (its root key, the cents off it added to the fine tune), made SoundFont
    +// 2's semitones and cents.
    +static void sf1Tuning(const ZoneT *inst, const ZoneT *preset, int16_t *out) {
    +    const ZoneT *sc    = sf1From(inst, preset, SF_GEN_SCALE_TUNING);
    +    const ZoneT *co    = sf1From(inst, preset, SF_GEN_COARSE_TUNE);
    +    const ZoneT *fi    = sf1From(inst, preset, SF_GEN_FINE_TUNE);
    +    const ZoneT *sp    = sf1From(inst, preset, SF1_GEN_SAMPLE_PITCH);
    +    int32_t      scale = sc && sc->gen[SF_GEN_SCALE_TUNING] ? SF1_SCALE_HALF : SF1_SCALE_FULL;
    +    int32_t      tune  = (co ? co->gen[SF_GEN_COARSE_TUNE] * scale : 0) + (fi ? fi->gen[SF_GEN_FINE_TUNE] * scale / SF1_CENTS_KEY : 0);
    +
    +    if (sp) {
    +        int32_t root = sp->gen[SF1_GEN_SAMPLE_PITCH] / SF1_CENTS_KEY;
    +        int32_t fine = -(sp->gen[SF1_GEN_SAMPLE_PITCH] % SF1_CENTS_KEY);
    +
    +        if (fine <= SF1_FINE_ROUND) {
    +            root++;
    +            fine += SF1_CENTS_KEY;
    +        }
    +
    +        tune += fine;
    +
    +        if (out[SF_GEN_ROOT_KEY] == SF_ROOT_NONE && root >= 0 && root <= MIDI_TOP) {
    +            out[SF_GEN_ROOT_KEY] = (int16_t)root;
    +        }
    +    }
    +
    +    out[SF_GEN_SCALE_TUNING] = (int16_t)scale;
    +    out[SF_GEN_COARSE_TUNE]  = (int16_t)(tune / SF1_CENTS_KEY);
    +    out[SF_GEN_FINE_TUNE]    = (int16_t)(tune % SF1_CENTS_KEY);
    +}
    +
    +
    +// A SoundFont 1 generator's value v (the chip's units) in SoundFont 2's.
    +static int32_t sf1Value(int32_t gen, int32_t v) {
    +    switch (gen) {
    +        case SF_GEN_MOD_LFO_DELAY:
    +        case SF_GEN_VIB_LFO_DELAY:
    +        case SF_GEN_MOD_ENV_DELAY:
    +        case SF_GEN_MOD_ENV_ATTACK:
    +        case SF_GEN_MOD_ENV_HOLD:
    +        case SF_GEN_MOD_ENV_DECAY:
    +        case SF_GEN_MOD_ENV_RELEASE:
    +        case SF_GEN_VOL_ENV_DELAY:
    +        case SF_GEN_VOL_ENV_ATTACK:
    +        case SF_GEN_VOL_ENV_HOLD:
    +        case SF_GEN_VOL_ENV_DECAY:
    +        case SF_GEN_VOL_ENV_RELEASE:
    +            // Milliseconds
    +            return (uint16_t)v ? waveRatioCents((uint16_t)v, SF1_MS_PER_SEC) : SF1_TC_NONE;
    +
    +        case SF_GEN_VOL_ENV_SUSTAIN:
    +            return v >= SF1_SUSTAIN_FULL ? 0 : SF1_VOL_SUSTAIN_CB - v * SF1_VOL_SUSTAIN_NUM / SF1_VOL_SUSTAIN_DEN;
    +
    +        case SF_GEN_MOD_ENV_SUSTAIN:
    +            return v >= SF1_SUSTAIN_FULL ? 0 : SF1_MOD_SUSTAIN_MAX * (SF1_SUSTAIN_FULL - v) / SF1_SUSTAIN_FULL;
    +
    +        case SF_GEN_KEY_TO_MOD_ENV_HOLD:
    +        case SF_GEN_KEY_TO_MOD_ENV_DECAY:
    +        case SF_GEN_KEY_TO_VOL_ENV_HOLD:
    +        case SF_GEN_KEY_TO_VOL_ENV_DECAY:
    +            return v * SF1_KEY_TIME_NUM / SF1_KEY_TIME_DEN;
    +
    +        case SF_GEN_PAN:
    +            return v * SF1_PAN_SPAN / SF1_LEVEL_MAX - SF1_PAN_HALF;
    +
    +        case SF_GEN_ATTENUATION:
    +            // 0..127 of the full level
    +            if (v >= SF1_LEVEL_MAX) {
    +                return 0;
    +            }
    +
    +            return v > 0 ? -waveRatioCents((uint32_t)v, SF1_LEVEL_MAX) * SF1_ATTEN_NUM / SF1_ATTEN_DEN : SF1_ATTEN_SILENT;
    +
    +        case SF_GEN_CHORUS_SEND:
    +        case SF_GEN_REVERB_SEND:
    +            return v * SF1_SEND_SPAN / SF1_SEND_MAX;
    +
    +        case SF_GEN_FILTER_FC:
    +            return v >= SF1_LEVEL_MAX ? SF1_FC_OPEN : SF1_FC_STEP * v + SF1_FC_BASE;
    +
    +        case SF_GEN_FILTER_Q:
    +            return v * SF1_Q_NUM / SF1_Q_DEN;
    +
    +        case SF_GEN_MOD_LFO_FREQ:
    +            return v > 0 ? waveRatioCents((uint32_t)v, 1) - SF1_LFO_OFFSET : SF1_MOD_LFO_NONE;
    +
    +        case SF_GEN_VIB_LFO_FREQ:
    +            return v > 0 ? waveRatioCents((uint32_t)v, 1) - SF1_LFO_OFFSET : SF1_VIB_LFO_NONE;
    +
    +        case SF_GEN_MOD_LFO_TO_PITCH:
    +        case SF_GEN_VIB_LFO_TO_PITCH:
    +        case SF_GEN_MOD_ENV_TO_PITCH:
    +            return (SF1_PITCH_SPAN * v / SF1_DEPTH_STEPS + SF1_PITCH_ROUND) / SF1_PITCH_DEN;
    +
    +        case SF_GEN_MOD_LFO_TO_FILTER:
    +            return SF1_LFO_FILTER_SPAN * v / SF1_DEPTH_STEPS;
    +
    +        case SF_GEN_MOD_ENV_TO_FILTER:
    +            return SF1_ENV_FILTER_SPAN * v / SF1_DEPTH_STEPS;
    +
    +        case SF_GEN_MOD_LFO_TO_VOLUME:
    +            return SF1_VOLUME_SPAN * v / SF1_DEPTH_STEPS;
    +
    +        default:
    +            return v;
    +    }
    +}
    +
    +
    +// The file's top level: "RIFF" length "sfbk", the INFO list (the version
    +// must be 1 or 2), sdta (where smpl is), pdta (read).
    +static int32_t sf2From(SourceT *src, WaveBankT *bank) {
    +    uint8_t  head[RIFF_HEADER_BYTES + RIFF_TYPE_BYTES];
    +    uint32_t pdtaAt  = 0;
    +    uint32_t pdtaLen = 0;
    +    uint32_t version = 0;
    +
    +    if (!srcRead(src, 0, head, sizeof(head)) || memcmp(head, "RIFF", RIFF_ID_BYTES) != 0 || memcmp(head + RIFF_HEADER_BYTES, "sfbk", RIFF_TYPE_BYTES) != 0) {
    +        return MUSIC_ERR_BANK;
    +    }
    +
    +    uint32_t end = RIFF_HEADER_BYTES + le32(head + RIFF_ID_BYTES);
    +
    +    end = end < src->size ? end : src->size;
    +
    +    for (uint32_t at = sizeof(head); at + RIFF_HEADER_BYTES + RIFF_TYPE_BYTES <= end;) {
    +        uint8_t  list[RIFF_HEADER_BYTES + RIFF_TYPE_BYTES];
    +        uint32_t len;
    +
    +        if (!srcRead(src, at, list, sizeof(list))) {
    +            return MUSIC_ERR_BANK;
    +        }
    +
    +        len = le32(list + RIFF_ID_BYTES);
    +
    +        if (len > end - at - RIFF_HEADER_BYTES) {
    +            return MUSIC_ERR_BANK;
    +        }
    +
    +        uint32_t body    = at + RIFF_HEADER_BYTES + RIFF_TYPE_BYTES;
    +        uint32_t bodyEnd = at + RIFF_HEADER_BYTES + len;
    +        bool     isList  = memcmp(list, "LIST", RIFF_ID_BYTES) == 0;
    +
    +        // The sub-chunks of INFO and sdta that matter: ifil, smpl
    +        for (uint32_t sub = body; isList && sub + RIFF_HEADER_BYTES <= bodyEnd;) {
    +            uint8_t  ck[RIFF_HEADER_BYTES];
    +            uint32_t ckLen;
    +
    +            if (!srcRead(src, sub, ck, sizeof(ck))) {
    +                return MUSIC_ERR_BANK;
    +            }
    +
    +            ckLen = le32(ck + RIFF_ID_BYTES);
    +
    +            if (ckLen > bodyEnd - sub - RIFF_HEADER_BYTES) {
    +                return MUSIC_ERR_BANK;
    +            }
    +
    +            if (memcmp(list + RIFF_HEADER_BYTES, "INFO", RIFF_TYPE_BYTES) == 0 && memcmp(ck, "ifil", RIFF_ID_BYTES) == 0 && ckLen >= IFIL_BYTES) {
    +                uint8_t ver[IFIL_BYTES];
    +
    +                version = srcRead(src, sub + RIFF_HEADER_BYTES, ver, sizeof(ver)) ? le16(ver) : 0;
    +            } else if (memcmp(list + RIFF_HEADER_BYTES, "sdta", RIFF_TYPE_BYTES) == 0 && memcmp(ck, "smpl", RIFF_ID_BYTES) == 0) {
    +                bank->dataOffset = sub + RIFF_HEADER_BYTES;
    +                bank->dataFrames = ckLen / SAMPLE_BYTES;
    +            }
    +
    +            sub += RIFF_HEADER_BYTES + ckLen + (ckLen & RIFF_ALIGN_MASK);
    +        }
    +
    +        if (isList && memcmp(list + RIFF_HEADER_BYTES, "pdta", RIFF_TYPE_BYTES) == 0) {
    +            pdtaAt  = body;
    +            pdtaLen = len - RIFF_TYPE_BYTES;
    +        }
    +
    +        at = bodyEnd + (len & RIFF_ALIGN_MASK);
    +    }
    +
    +    if ((version != SF_VERSION_1 && version != SF_VERSION_2) || !pdtaAt) {
    +        return MUSIC_ERR_BANK;
    +    }
    +
    +    return readHydra(src, pdtaAt, pdtaLen, version == SF_VERSION_1, bank);
    +}
    +
    +
    +int16_t sf2GenDefault(int32_t gen) {
    +    return gen >= 0 && gen < SF_GEN_COUNT ? sGenRules[gen].def : 0;
    +}
    +
    +
    +int32_t sf2Load(const char *path, WaveBankT *bank) {
    +    SourceT src;
    +    int32_t rc;
    +
    +    memset(bank, 0, sizeof(*bank));
    +    memset(&src, 0, sizeof(src));
    +    src.file = fopen(path, "rb");
    +
    +    if (!src.file) {
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    fseek(src.file, 0, SEEK_END);
    +    src.size = (uint32_t)ftell(src.file);
    +    snprintf(bank->path, sizeof(bank->path), "%s", path);
    +    rc = sf2From(&src, bank);
    +    fclose(src.file);
    +
    +    if (rc != MUSIC_OK) {
    +        waveBankFree(bank);
    +    }
    +
    +    return rc;
    +}
    +
    +
    +int32_t sf2Parse(const uint8_t *data, uint32_t size, WaveBankT *bank) {
    +    SourceT src;
    +    int32_t rc;
    +
    +    memset(bank, 0, sizeof(*bank));
    +
    +    if (!data) {
    +        return MUSIC_ERR_BANK;
    +    }
    +
    +    memset(&src, 0, sizeof(src));
    +    src.data     = data;
    +    src.size     = size;
    +    bank->memory = data;
    +    rc           = sf2From(&src, bank);
    +
    +    if (rc != MUSIC_OK) {
    +        waveBankFree(bank);
    +    }
    +
    +    return rc;
    +}
    +
    +
    +// Frames from the sources that hold them (silence between), each read
    +// from its file and made signed 16-bit.
    +static bool sourcesRead(const WaveBankT *bank, uint32_t first, uint32_t frames, int16_t *out) {
    +    memset(out, 0, (size_t)frames * sizeof(int16_t));
    +
    +    for (uint32_t i = 0; i < bank->sourceCount; i++) {
    +        const WaveSourceT *src  = &bank->sources[i];
    +        uint32_t           from = src->first > first ? src->first : first;
    +        uint32_t           to   = src->first + src->frames < first + frames ? src->first + src->frames : first + frames;
    +
    +        if (from >= to) {
    +            continue;
    +        }
    +
    +        uint32_t bytes = src->format & WAVE_SRC_16BIT ? SAMPLE_BYTES : 1;
    +        uint32_t count = to - from;
    +        uint8_t *raw   = (uint8_t *)malloc((size_t)count * bytes);
    +
    +        if (!raw) {
    +            return false;
    +        }
    +
    +        FILE *f  = fopen(bank->files[src->file], "rb");
    +        long  at = (long)src->offset + (long)(from - src->first) * (long)bytes;
    +        bool  ok = f && fseek(f, at, SEEK_SET) == 0 && fread(raw, bytes, count, f) == count;
    +
    +        if (f) {
    +            fclose(f);
    +        }
    +
    +        for (uint32_t k = 0; ok && k < count; k++) {
    +            uint32_t v = bytes == SAMPLE_BYTES ? le16(raw + k * SAMPLE_BYTES) : (uint32_t)raw[k] << BYTE_BITS;
    +
    +            out[from - first + k] = (int16_t)(src->format & WAVE_SRC_UNSIGNED ? v ^ SIGN_FLIP : v);
    +        }
    +
    +        free(raw);
    +
    +        if (!ok) {
    +            return false;
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +static bool srcRead(SourceT *src, uint32_t offset, void *out, uint32_t len) {
    +    if (offset > src->size || len > src->size - offset) {
    +        return false;
    +    }
    +
    +    if (src->data) {
    +        memcpy(out, src->data + offset, len);
    +        return true;
    +    }
    +
    +    return fseek(src->file, (long)offset, SEEK_SET) == 0 && fread(out, 1, len, src->file) == len;
    +}
    +
    +
    +void waveBankFree(WaveBankT *bank) {
    +    free(bank->presets);
    +    free(bank->regions);
    +    free(bank->samples);
    +    free(bank->sources);
    +    free(bank->files);
    +    memset(bank, 0, sizeof(*bank));
    +}
    +
    +
    +bool waveBankRead(const WaveBankT *bank, uint32_t first, uint32_t frames, int16_t *out) {
    +    if (first > bank->dataFrames || frames > bank->dataFrames - first) {
    +        return false;
    +    }
    +
    +    if (bank->sources) {
    +        return sourcesRead(bank, first, frames, out);
    +    }
    +
    +    uint32_t offset = bank->dataOffset + first * SAMPLE_BYTES;
    +
    +    if (bank->memory) {
    +        for (uint32_t i = 0; i < frames; i++) {
    +            out[i] = (int16_t)le16(bank->memory + offset + i * SAMPLE_BYTES);
    +        }
    +
    +        return true;
    +    }
    +
    +    FILE *f  = fopen(bank->path, "rb");
    +    bool  ok = f && fseek(f, (long)offset, SEEK_SET) == 0 && fread(out, SAMPLE_BYTES, frames, f) == frames;
    +
    +    if (f) {
    +        fclose(f);
    +    }
    +
    +    return ok;
    +}
    +
    +
    +// Whether generators first .. last - 1 hold oper (a zone's instrument or
    +// sample: its last generator, by the specification).
    +static bool zoneHas(const HydraT *h, int32_t genArray, uint32_t first, uint32_t last, uint32_t oper) {
    +    return last > first && le16(h->at[genArray] + (last - 1) * GEN_BYTES + GEN_OPER) == oper;
    +}
    diff --git a/src/libs/kpunch/music/musicShell.c b/src/libs/kpunch/music/musicShell.c
    index d4f6c443..f68bbd1c 100644
    --- a/src/libs/kpunch/music/musicShell.c
    +++ b/src/libs/kpunch/music/musicShell.c
    @@ -23,9 +23,10 @@
     // musicShell.c  -- music.lib's hooks into the shell
     //
     // Loaded as a module of the shell: the constructor asks for the shell's
    -// idle time (end calls) and to be told when a program ends (its musics
    -// are freed then); the destructor frees everything and takes the hooks
    -// back.  Not in the host tests, which call musicIdle and musicAppEnded
    +// idle time (end calls), to be told when a program ends (its musics are
    +// freed then), and when DVX steps aside for a DOS program (every music
    +// lets go of the synthesizers); the destructor frees everything and takes
    +// the hooks back.  Not in the host tests, which call musicIdle and musicAppEnded
     // directly.
     
     #include "musicInt.h"
    @@ -38,11 +39,13 @@ static void musicShellShutdown(void) __attribute__((destructor));
     static void musicShellInit(void) {
         shellRegisterIdle(musicIdle, NULL);
         shellRegisterAppCleanup(musicAppEnded);
    +    platformSuspendHookAdd(musicSuspend);
     }
     
     
     static void musicShellShutdown(void) {
         musicShutdown();
    +    platformSuspendHookRemove(musicSuspend);
         shellUnregisterAppCleanup(musicAppEnded);
         shellUnregisterIdle(musicIdle, NULL);
     }
    diff --git a/src/libs/kpunch/music/musicSmf.c b/src/libs/kpunch/music/musicSmf.c
    index fd3410c9..8b4ade1d 100644
    --- a/src/libs/kpunch/music/musicSmf.c
    +++ b/src/libs/kpunch/music/musicSmf.c
    @@ -28,8 +28,9 @@
     // entries, the entries are sorted into one time line, and tempo changes
     // (from any track) turn ticks into microseconds on the way.  Channel
     // messages (with running status), system exclusive messages (F0, and F7
    -// escapes) and the tempo and end-of-track meta events are kept; other
    -// meta events are skipped.  A note on with velocity 0 becomes a note
    +// escapes) and the tempo and end-of-track meta events are kept, and the
    +// first track's name becomes the file's title; other meta events are
    +// skipped.  A note on with velocity 0 becomes a note
     // off.
     //
     // Damage: an unknown chunk is skipped; a track that breaks off (short
    @@ -38,6 +39,9 @@
     // where it breaks; a chunk longer than the file is read to the end of the
     // file.  Only a missing header or no track at all makes the file
     // unusable.  Type 2 files (independent patterns) are not played.
    +//
    +// Writing: type 0 files (one track) for the music macros and MIDI
    +// recordings, the events put into a byte array by midiPut....
     
     #include "musicInt.h"
     #include "stb_ds.h"
    @@ -59,13 +63,21 @@
     #define RIFF_HEADER_BYTES   12
     #define RIFF_FORM_AT        8
     #define VLQ_BYTES_MAX       4
    +#define VLQ_VALUE_MAX       0x0FFFFFFF  // the largest four bytes hold
     #define VLQ_MORE            0x80
     #define META_TEMPO          0x51
    +#define META_TRACK_NAME     0x03    // a track's name; the first track's is the file's title
    +#define TITLE_SPACE         ' '     // what a control character of a title becomes
     #define META_END_OF_TRACK   0x2F
     #define META_TEMPO_BYTES    3
     #define TEMPO_DEFAULT_US    500000  // 120 beats a minute
     #define US_PER_SEC          1000000ULL
     #define BYTE_BITS           8
    +#define BYTE_MASK           0xFF
    +#define CHUNK_LEN_BYTES     4
    +#define WORD_BYTES          2
    +#define SMF_TYPE_ONE_TRACK  0
    +#define SMF_ONE_TRACK       1
     #define NOTE_OFF_VELOCITY   64
     #define SYSTEM_FIRST        0xF0
     
    @@ -91,11 +103,17 @@ static uint32_t be16(const uint8_t *p);
     static uint32_t be32(const uint8_t *p);
     static bool     findSmf(const uint8_t *data, uint32_t size, const uint8_t **smf, uint32_t *smfSize);
     static bool     idIs(const uint8_t *p, const char *id);
    +int32_t         midiFileBuild(const uint8_t *trk, uint32_t trkLen, uint32_t division, uint8_t **out, uint32_t *size);
     void            midiFileFree(MidiFileT *file);
     bool            midiIsFile(const uint8_t *data, uint32_t size);
     int32_t         midiParse(const uint8_t *data, uint32_t size, MidiFileT *out);
    +void            midiPutBig(uint8_t **buf, uint32_t value, int32_t bytes);
    +void            midiPutEnd(uint8_t **trk, uint32_t delta);
    +static void     midiPutId(uint8_t **buf, const char *id);
    +void            midiPutTempo(uint8_t **trk, uint32_t usPerQuarter);
    +void            midiPutVlq(uint8_t **buf, uint32_t value);
     static int      rawCompare(const void *a, const void *b);
    -static uint32_t readTrack(SmfCursorT c, uint32_t track, RawEventT **raw, uint8_t **sysex);
    +static uint32_t readTrack(SmfCursorT c, uint32_t track, RawEventT **raw, uint8_t **sysex, char *title);
     static bool     readVlq(SmfCursorT *c, uint32_t *value);
     
     
    @@ -151,6 +169,35 @@ static bool idIs(const uint8_t *p, const char *id) {
     }
     
     
    +// The header chunk, then the track chunk around the events.
    +int32_t midiFileBuild(const uint8_t *trk, uint32_t trkLen, uint32_t division, uint8_t **out, uint32_t *size) {
    +    uint8_t *hdr = NULL;
    +
    +    midiPutId(&hdr, "MThd");
    +    midiPutBig(&hdr, SMF_HEADER_BYTES, CHUNK_LEN_BYTES);
    +    midiPutBig(&hdr, SMF_TYPE_ONE_TRACK, WORD_BYTES);
    +    midiPutBig(&hdr, SMF_ONE_TRACK, WORD_BYTES);
    +    midiPutBig(&hdr, division, WORD_BYTES);
    +    midiPutId(&hdr, "MTrk");
    +    midiPutBig(&hdr, trkLen, CHUNK_LEN_BYTES);
    +
    +    uint32_t hdrLen = (uint32_t)arrlen(hdr);
    +    uint8_t *file   = (uint8_t *)malloc(hdrLen + trkLen);
    +
    +    if (!file) {
    +        arrfree(hdr);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    memcpy(file, hdr, hdrLen);
    +    memcpy(file + hdrLen, trk, trkLen);
    +    arrfree(hdr);
    +    *out  = file;
    +    *size = hdrLen + trkLen;
    +    return MUSIC_OK;
    +}
    +
    +
     void midiFileFree(MidiFileT *file) {
         free(file->events);
         free(file->sysex);
    @@ -220,7 +267,7 @@ int32_t midiParse(const uint8_t *data, uint32_t size, MidiFileT *out) {
     
             if (idIs(smf + at, "MTrk")) {
                 SmfCursorT  c    = { smf + at + CHUNK_HEADER_BYTES, smf + at + CHUNK_HEADER_BYTES + len };
    -            uint32_t last = readTrack(c, tracks, &raw, &sysex);
    +            uint32_t last = readTrack(c, tracks, &raw, &sysex, out->title);
     
                 endTick = last > endTick ? last : endTick;
                 tracks++;
    @@ -309,6 +356,59 @@ int32_t midiParse(const uint8_t *data, uint32_t size, MidiFileT *out) {
     
     
     // Time, then track, then order within the track.
    +void midiPutBig(uint8_t **buf, uint32_t value, int32_t bytes) {
    +    for (int32_t i = bytes - 1; i >= 0; i--) {
    +        arrput(*buf, (uint8_t)((value >> (i * BYTE_BITS)) & BYTE_MASK));
    +    }
    +}
    +
    +
    +void midiPutEnd(uint8_t **trk, uint32_t delta) {
    +    midiPutVlq(trk, delta);
    +    arrput(*trk, MIDI_META);
    +    arrput(*trk, META_END_OF_TRACK);
    +    arrput(*trk, 0);
    +}
    +
    +
    +static void midiPutId(uint8_t **buf, const char *id) {
    +    for (int32_t i = 0; i < CHUNK_ID_BYTES; i++) {
    +        arrput(*buf, (uint8_t)id[i]);
    +    }
    +}
    +
    +
    +void midiPutTempo(uint8_t **trk, uint32_t usPerQuarter) {
    +    midiPutVlq(trk, 0);
    +    arrput(*trk, MIDI_META);
    +    arrput(*trk, META_TEMPO);
    +    arrput(*trk, META_TEMPO_BYTES);
    +    midiPutBig(trk, usPerQuarter, META_TEMPO_BYTES);
    +}
    +
    +
    +// Seven bits a byte, most significant first, VLQ_MORE set on all but the
    +// last; a value over four bytes' worth is held to the most they carry.
    +void midiPutVlq(uint8_t **buf, uint32_t value) {
    +    uint8_t tmp[VLQ_BYTES_MAX];
    +    int32_t n = 0;
    +
    +    if (value > VLQ_VALUE_MAX) {
    +        value = VLQ_VALUE_MAX;
    +    }
    +
    +    do {
    +        tmp[n++] = (uint8_t)(value & MIDI_DATA_MASK);
    +        value  >>= MIDI_DATA_BITS;
    +    } while (value);
    +
    +    while (n > 0) {
    +        n--;
    +        arrput(*buf, (uint8_t)(tmp[n] | (n > 0 ? VLQ_MORE : 0)));
    +    }
    +}
    +
    +
     static int rawCompare(const void *a, const void *b) {
         const RawEventT *x = (const RawEventT *)a;
         const RawEventT *y = (const RawEventT *)b;
    @@ -326,8 +426,10 @@ static int rawCompare(const void *a, const void *b) {
     
     
     // Append a track's events to raw (system exclusive bytes to sysex);
    -// returns the tick where it ends.
    -static uint32_t readTrack(SmfCursorT c, uint32_t track, RawEventT **raw, uint8_t **sysex) {
    +// returns the tick where it ends.  The first track's first name is the
    +// file's title (control characters as spaces, MUSIC_TITLE_MAX - 1 bytes
    +// at most).
    +static uint32_t readTrack(SmfCursorT c, uint32_t track, RawEventT **raw, uint8_t **sysex, char *title) {
         uint32_t tick    = 0;
         uint32_t order   = 0;
         uint8_t  running = 0;
    @@ -360,7 +462,7 @@ static uint32_t readTrack(SmfCursorT c, uint32_t track, RawEventT **raw, uint8_t
     
             if (status < SYSTEM_FIRST) {
                 uint8_t  type  = status & MIDI_TYPE_MASK;
    -            uint32_t bytes = (type == MIDI_PROGRAM || type == MIDI_CHAN_PRESSURE) ? 1 : 2;
    +            uint32_t bytes = MIDI_ONE_DATA_BYTE(type) ? 1 : 2;
     
                 if (c.end - c.p < (ptrdiff_t)bytes || (c.p[0] & MIDI_STATUS_BIT) || (bytes == 2 && (c.p[1] & MIDI_STATUS_BIT))) {
                     break;
    @@ -396,6 +498,16 @@ static uint32_t readTrack(SmfCursorT c, uint32_t track, RawEventT **raw, uint8_t
                     break;
                 }
     
    +            if (meta == META_TRACK_NAME && track == 0 && !title[0]) {
    +                uint32_t n = len < MUSIC_TITLE_MAX - 1 ? len : MUSIC_TITLE_MAX - 1;
    +
    +                for (uint32_t i = 0; i < n; i++) {
    +                    title[i] = c.p[i] < TITLE_SPACE ? TITLE_SPACE : (char)c.p[i];
    +                }
    +
    +                title[n] = '\0';
    +            }
    +
                 if (meta == META_TEMPO && len == META_TEMPO_BYTES) {
                     e.tempo = ((uint32_t)c.p[0] << (BYTE_BITS * 2)) | ((uint32_t)c.p[1] << BYTE_BITS) | c.p[2];
     
    diff --git a/src/libs/kpunch/music/musicSound.c b/src/libs/kpunch/music/musicSound.c
    new file mode 100644
    index 00000000..43d15666
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicSound.c
    @@ -0,0 +1,249 @@
    +// 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.
    +
    +// musicSound.c  -- WAV and VOC files as music (see musicInt.h, "Sound
    +// files")
    +//
    +// A WAV file named by its path is read as it plays: loading reads only
    +// its first SOUND_HEAD_MAX bytes (audioWavParse finds its format and
    +// where its sound is), and the stream's fill reads the frames it needs, a
    +// buffer at a time, at program time; a long recording needs no memory.
    +// A VOC file (its blocks may hold silences and continue one another), or
    +// a sound file given as bytes, is decoded into 16-bit frames in memory
    +// (audioDecode; not pinned, as nothing reads it at interrupt time).
    +
    +#include "musicInt.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define SOUND_HEAD_MAX      65536   // bytes read to find a WAV's format and data
    +#define SOUND_READ_FRAMES   1024    // frames read from the file at a time
    +#define BYTE_BITS           8
    +
    +struct SoundSrcT {
    +    FILE        *fp;        // a WAV read as it plays (NULL: pcm)
    +    int16_t     *pcm;       // decoded frames (NULL: fp)
    +    uint8_t     *raw;       // fp's frames as the file has them, SOUND_READ_FRAMES at a time
    +    AudioFormatT fmt;       // as the file has it (bits 8 or 16)
    +    uint32_t     dataAt;    // fp: where the frames start
    +    uint32_t     frames;
    +    uint32_t     pos;       // the next frame read
    +};
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +uint32_t        soundChannels(const SoundSrcT *s);
    +void            soundClose(SoundSrcT *s);
    +static int32_t  soundError(int32_t audioRc);
    +static uint32_t soundFrameBytes(const SoundSrcT *s);
    +uint32_t        soundFrames(const SoundSrcT *s);
    +int32_t         soundOpenFile(const char *path, SoundSrcT **out);
    +int32_t         soundOpenMem(const uint8_t *data, uint32_t size, SoundSrcT **out);
    +uint32_t        soundRate(const SoundSrcT *s);
    +uint32_t        soundRead(SoundSrcT *s, int16_t *out, uint32_t frames);
    +static uint32_t soundReadFile(SoundSrcT *s, int16_t *out, uint32_t frames);
    +void            soundSeek(SoundSrcT *s, uint32_t frame);
    +uint32_t        soundTell(const SoundSrcT *s);
    +
    +
    +uint32_t soundChannels(const SoundSrcT *s) {
    +    return s->fmt.channels;
    +}
    +
    +
    +void soundClose(SoundSrcT *s) {
    +    if (!s) {
    +        return;
    +    }
    +
    +    if (s->fp) {
    +        fclose(s->fp);
    +    }
    +
    +    free(s->raw);
    +    free(s->pcm);
    +    free(s);
    +}
    +
    +
    +// audio.lib's reason as music.lib's.
    +static int32_t soundError(int32_t audioRc) {
    +    switch (audioRc) {
    +        case AUDIO_ERR_NO_MEMORY:
    +            return MUSIC_ERR_NO_MEMORY;
    +
    +        case AUDIO_ERR_CORRUPT:
    +            return MUSIC_ERR_CORRUPT;
    +
    +        case AUDIO_ERR_UNSUPPORTED:
    +            return MUSIC_ERR_UNSUPPORTED;
    +
    +        default:
    +            return MUSIC_ERR_FORMAT;
    +    }
    +}
    +
    +
    +// A frame of the file as the file has it, in bytes.
    +static uint32_t soundFrameBytes(const SoundSrcT *s) {
    +    return s->fmt.channels * s->fmt.bits / BYTE_BITS;
    +}
    +
    +
    +uint32_t soundFrames(const SoundSrcT *s) {
    +    return s->frames;
    +}
    +
    +
    +// The file's start read and parsed; it stays open for the frames.
    +int32_t soundOpenFile(const char *path, SoundSrcT **out) {
    +    SoundSrcT *s = (SoundSrcT *)calloc(1, sizeof(SoundSrcT));
    +    uint8_t   *head;
    +    uint32_t   dataBytes;
    +    int32_t    rc;
    +
    +    *out = NULL;
    +
    +    if (!s) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    s->fp = fopen(path, "rb");
    +
    +    long size = s->fp && fseek(s->fp, 0, SEEK_END) == 0 ? ftell(s->fp) : -1;
    +
    +    if (size <= 0 || fseek(s->fp, 0, SEEK_SET) != 0) {
    +        soundClose(s);
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    uint32_t headBytes = (uint32_t)size < SOUND_HEAD_MAX ? (uint32_t)size : SOUND_HEAD_MAX;
    +
    +    head = (uint8_t *)malloc(headBytes);
    +
    +    if (!head) {
    +        soundClose(s);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    rc = fread(head, 1, headBytes, s->fp) == headBytes ? audioWavParse(head, headBytes, (uint32_t)size, &s->fmt, &s->dataAt, &dataBytes) : AUDIO_ERR_CORRUPT;
    +    free(head);
    +
    +    if (rc != AUDIO_OK) {
    +        soundClose(s);
    +        return soundError(rc);
    +    }
    +
    +    s->frames = dataBytes / soundFrameBytes(s);
    +    s->raw    = (uint8_t *)malloc(SOUND_READ_FRAMES * soundFrameBytes(s));
    +
    +    if (!s->raw) {
    +        soundClose(s);
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    soundSeek(s, 0);
    +    *out = s;
    +    return MUSIC_OK;
    +}
    +
    +
    +int32_t soundOpenMem(const uint8_t *data, uint32_t size, SoundSrcT **out) {
    +    SoundSrcT *s = (SoundSrcT *)calloc(1, sizeof(SoundSrcT));
    +    int32_t    rc;
    +
    +    *out = NULL;
    +
    +    if (!s) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    rc = audioDecode(data, size, &s->pcm, &s->frames, &s->fmt);
    +
    +    if (rc != AUDIO_OK) {
    +        soundClose(s);
    +        return soundError(rc);
    +    }
    +
    +    *out = s;
    +    return MUSIC_OK;
    +}
    +
    +
    +uint32_t soundRate(const SoundSrcT *s) {
    +    return s->fmt.rate;
    +}
    +
    +
    +uint32_t soundRead(SoundSrcT *s, int16_t *out, uint32_t frames) {
    +    uint32_t n = frames < s->frames - s->pos ? frames : s->frames - s->pos;
    +
    +    if (s->fp) {
    +        return soundReadFile(s, out, n);
    +    }
    +
    +    memcpy(out, s->pcm + (size_t)s->pos * s->fmt.channels, (size_t)n * s->fmt.channels * sizeof(int16_t));
    +    s->pos += n;
    +    return n;
    +}
    +
    +
    +// A buffer at a time; a file that ends before its header said ends there.
    +static uint32_t soundReadFile(SoundSrcT *s, int16_t *out, uint32_t frames) {
    +    uint32_t done = 0;
    +
    +    while (done < frames) {
    +        uint32_t want = frames - done < SOUND_READ_FRAMES ? frames - done : SOUND_READ_FRAMES;
    +        uint32_t got  = (uint32_t)fread(s->raw, soundFrameBytes(s), want, s->fp);
    +
    +        audioConvert(s->raw, out + (size_t)done * s->fmt.channels, got * s->fmt.channels, s->fmt.bits);
    +        done   += got;
    +        s->pos += got;
    +
    +        if (got < want) {
    +            s->frames = s->pos;
    +            break;
    +        }
    +    }
    +
    +    return done;
    +}
    +
    +
    +void soundSeek(SoundSrcT *s, uint32_t frame) {
    +    s->pos = frame < s->frames ? frame : s->frames;
    +
    +    if (s->fp) {
    +        uint32_t at = s->dataAt + s->pos * soundFrameBytes(s);
    +
    +        fseek(s->fp, (long)at, SEEK_SET);
    +    }
    +}
    +
    +
    +uint32_t soundTell(const SoundSrcT *s) {
    +    return s->pos;
    +}
    diff --git a/src/libs/kpunch/music/musicWave.c b/src/libs/kpunch/music/musicWave.c
    new file mode 100644
    index 00000000..5276f3a0
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicWave.c
    @@ -0,0 +1,1064 @@
    +// 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.
    +
    +// musicWave.c  -- General MIDI on wavetable voices (SoundFont regions)
    +//
    +// A WaveSynthT plays MIDI with a SoundFont bank (musicSf2.c) whose
    +// samples musicWaveLoad.c put in the synthesizer's memory, on voices from
    +// a backend: the wavetable chip (audio.lib's shared voices) or the
    +// software one.  Runs at interrupt time as well (the timer plays MIDI on
    +// the chip), see musicInt.h: integer arithmetic only, no calls out but
    +// the backend's, everything pinned.
    +//
    +// A note plays every region of its preset whose key and velocity ranges
    +// hold it (several: layers, stereo pairs), a voice each.  The preset is
    +// the channel's bank and program; channel 10 plays bank 128 (the drum
    +// kits), its program choosing the kit.  The note's pitch is the key's
    +// distance from the sample's root (scale tuning cents a key), the tuning,
    +// the sample's correction, and its rate (cents from 44,100 Hz), with the
    +// pitch bend (RPN 0 range) on top.  Loudness follows the SoundFont's
    +// default modulators as attenuation in centibels: the region's own (0.4
    +// dB a unit, as the EMU8000 SoundFonts were made for), the velocity, the
    +// channel volume (CC7) and expression (CC11) each 40 log10 (the same
    +// curve as General MIDI's), and the music's gain.  CC10 pans, CC1 and
    +// channel pressure deepen the vibrato (50 cents at full), CC91 and CC93
    +// add reverb and chorus (20 percent at full), the velocity closes the
    +// filter (2400 cents at the softest).  An exclusive class (an open and a
    +// closed hi-hat) stops the class's other voices on the channel.  The
    +// sustain pedal holds released notes.  With no voice free, the oldest
    +// released voice is taken, else the oldest.  A note the bank cannot play
    +// here (no preset or region for it, or a sample that did not fit in the
    +// synthesizer's memory) goes whole to the overflow sink, which gets every
    +// other message as well, so its channels are in step.
    +//
    +// What the backend does not do itself is done here, a tick at a time
    +// (waveSynthTick): the volume envelope (delay, attack linear in
    +// amplitude, hold, decay and release at 96 dB per stage time, sustain),
    +// the modulation envelope and the two LFOs (triangles) to pitch and
    +// volume, sent as pitch and volume updates when they move by a step.
    +
    +#include "musicInt.h"
    +
    +#include 
    +
    +#define WAVE_ATTEN_NUM          4       // the region's attenuation counts 0.4 dB a unit
    +#define WAVE_ATTEN_DEN          10
    +#define WAVE_FULL_SCALE         960     // centibels a decay or release moves in its stage time
    +#define WAVE_ATTEN_STEP         4       // centibels the volume moves before it is sent again
    +#define WAVE_CENTS_SEMI         100
    +#define WAVE_COARSE_FRAMES      32768   // a coarse address offset's unit
    +#define WAVE_KEY_CENTER         60      // the key the keynum-to-envelope generators count from
    +#define WAVE_VEL_FILTER         2400    // cents the softest note closes the filter by
    +#define WAVE_FILTER_MIN         1500
    +#define WAVE_FILTER_MAX         13500
    +#define WAVE_MOD_DEPTH          50      // cents of vibrato the mod wheel and pressure add at full
    +#define WAVE_SEND_DEPTH         200     // tenths of a percent CC91 / CC93 add at full
    +#define WAVE_SEND_MAX           1000
    +#define WAVE_EXPRESSION_MAX     127
    +#define WAVE_BEND_STEPS         8192
    +#define WAVE_LOOP_MASK          3
    +#define WAVE_LOOP_UNUSED        2       // sampleModes 2 plays like no loop
    +#define BYTE_BITS               8
    +
    +// Time: timecents (2^(tc / 1200) seconds) through waveOctaves
    +#define US_MANT_MUL             15625   // 1,000,000 / 65536 = 15625 / 1024
    +#define US_MANT_SHIFT           10
    +#define LFO_BASE_MILLIHZ        8176    // 0 absolute cents: 8.176 Hz
    +#define LFO_CYCLE               65536   // a phase's units in one cycle
    +#define LFO_HALF                (LFO_CYCLE / 2)
    +#define LFO_PHASE_DIV           15259   // 1e9 / 65536: millihertz x microseconds -> phase units
    +
    +// 400 log10(127 / i): the attenuation of a velocity or controller level
    +static const int16_t sCbForLevel[128] = {
    +     1440,   842,   721,   651,   601,   562,   530,   503,   480,   460,   442,   425,   410,   396,   383,   371,
    +      360,   349,   339,   330,   321,   313,   305,   297,   289,   282,   276,   269,   263,   257,   251,   245,
    +      239,   234,   229,   224,   219,   214,   210,   205,   201,   196,   192,   188,   184,   180,   176,   173,
    +      169,   165,   162,   158,   155,   152,   149,   145,   142,   139,   136,   133,   130,   127,   125,   122,
    +      119,   116,   114,   111,   109,   106,   103,   101,    99,    96,    94,    91,    89,    87,    85,    82,
    +       80,    78,    76,    74,    72,    70,    68,    66,    64,    62,    60,    58,    56,    54,    52,    50,
    +       49,    47,    45,    43,    42,    40,    38,    36,    35,    33,    31,    30,    28,    27,    25,    23,
    +       22,    20,    19,    17,    16,    14,    13,    11,    10,     8,     7,     6,     4,     3,     1,     0
    +};
    +
    +static const uint8_t sGmOn[] = { 0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7 };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static int32_t  bendCents(const WaveChannelT *c);
    +static const WavePresetT *channelPreset(const WaveSynthT *s, uint8_t channel);
    +static void     channelReset(WaveChannelT *c);
    +static int32_t  clampInt(int32_t v, int32_t lo, int32_t hi);
    +static void     controller(WaveSynthT *s, uint8_t channel, uint8_t cc, uint8_t value);
    +static void     envTick(const WaveSynthT *s, WaveVoiceT *v, uint32_t us);
    +static int32_t  lfoAt(uint32_t *phase, int16_t freq, uint32_t us);
    +static uint32_t lfoMilliHz(int32_t cents);
    +static void     modEnvTick(WaveVoiceT *v, uint32_t us);
    +static void     noteOff(WaveSynthT *s, uint8_t channel, uint8_t key);
    +static void     noteOn(WaveSynthT *s, uint8_t channel, uint8_t key, uint8_t velocity);
    +static bool     notePlayable(const WaveSynthT *s, uint8_t channel, uint8_t key, uint8_t velocity);
    +static void     noteSetup(const WaveSynthT *s, WaveVoiceT *v, const WaveRegionT *r, uint8_t key, uint8_t velocity);
    +static void     releaseHeld(WaveSynthT *s, uint8_t channel);
    +static int32_t  stageDiv(uint32_t stageUs, int32_t span);
    +static uint32_t tcToUs(int32_t tc);
    +static void     voiceDeliver(WaveSynthT *s, WaveVoiceT *v, uint32_t what);
    +static void     voiceFree(WaveSynthT *s, WaveVoiceT *v);
    +static void     voiceLevels(const WaveSynthT *s, WaveVoiceT *v);
    +static void     voiceRelease(WaveSynthT *s, WaveVoiceT *v);
    +static void     voicesFreeAll(WaveSynthT *s);
    +static void     voicesUpdate(WaveSynthT *s, uint8_t channel, uint32_t what);
    +static WaveVoiceT *voiceTake(WaveSynthT *s);
    +const WavePresetT *waveBankPreset(const WaveBankT *bank, uint16_t bankNum, uint8_t program);
    +static bool     waveHwActive(void *owner, int32_t voice);
    +static int32_t  waveHwAlloc(void *owner);
    +void            waveHwBackend(AudioCtxT *ctx, WaveBackendT *out);
    +static void     waveHwFree(void *owner, int32_t voice);
    +static void     waveHwRelease(void *owner, int32_t voice);
    +static void     waveHwStart(void *owner, int32_t voice, const AudioWtVoiceT *v);
    +static void     waveHwUpdate(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +int32_t         waveLockItems(AudioLockItemT *items);
    +static void     waveMessage(MidiSinkT *sink, uint8_t status, uint8_t data1, uint8_t data2);
    +static void     waveSilence(MidiSinkT *sink);
    +void            waveSynthInit(WaveSynthT *synth, const WaveBackendT *backend, const WaveBankT *bank, const WaveLoadedT *loaded, MidiSinkT *overflow, int32_t gain);
    +void            waveSynthSetGain(WaveSynthT *synth, int32_t gain);
    +void            waveSynthTick(WaveSynthT *synth, uint32_t us);
    +static void     waveSysex(MidiSinkT *sink, const uint8_t *data, uint32_t len);
    +
    +
    +static int32_t bendCents(const WaveChannelT *c) {
    +    return (int32_t)c->bend * c->bendRange * WAVE_CENTS_SEMI / WAVE_BEND_STEPS;
    +}
    +
    +
    +// The preset a channel plays: its bank and program; channel 10's kit.
    +static const WavePresetT *channelPreset(const WaveSynthT *s, uint8_t channel) {
    +    const WaveChannelT *c = &s->channels[channel];
    +
    +    return waveBankPreset(s->bank, channel == MIDI_PERCUSSION ? SF_DRUM_BANK : c->bankMsb, c->program);
    +}
    +
    +
    +static void channelReset(WaveChannelT *c) {
    +    c->bend       = 0;
    +    c->program    = 0;
    +    c->bankMsb    = 0;
    +    c->volume     = MIDI_VOLUME_DEFAULT;
    +    c->expression = WAVE_EXPRESSION_MAX;
    +    c->pan        = MIDI_CENTER;
    +    c->bendRange  = MIDI_BEND_RANGE_DEFAULT;
    +    c->modWheel   = 0;
    +    c->pressure   = 0;
    +    c->reverb     = 0;
    +    c->chorus     = 0;
    +    c->rpnMsb     = MIDI_RPN_NULL;
    +    c->rpnLsb     = MIDI_RPN_NULL;
    +    c->sustain    = false;
    +}
    +
    +
    +static int32_t clampInt(int32_t v, int32_t lo, int32_t hi) {
    +    return v < lo ? lo : (v > hi ? hi : v);
    +}
    +
    +
    +static void controller(WaveSynthT *s, uint8_t channel, uint8_t cc, uint8_t value) {
    +    WaveChannelT *c = &s->channels[channel];
    +
    +    switch (cc) {
    +        case MIDI_CC_BANK:
    +            c->bankMsb = value;
    +            break;
    +
    +        case MIDI_CC_MODULATION:
    +            c->modWheel = value;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_LFO);
    +            break;
    +
    +        case MIDI_CC_VOLUME:
    +            c->volume = value;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_VOLUME);
    +            break;
    +
    +        case MIDI_CC_PAN:
    +            c->pan = value;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_PAN);
    +            break;
    +
    +        case MIDI_CC_EXPRESSION:
    +            c->expression = value;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_VOLUME);
    +            break;
    +
    +        case MIDI_CC_SUSTAIN:
    +            c->sustain = value >= MIDI_CENTER;
    +
    +            if (!c->sustain) {
    +                releaseHeld(s, channel);
    +            }
    +
    +            break;
    +
    +        case MIDI_CC_REVERB:
    +            c->reverb = value;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_EFFECTS);
    +            break;
    +
    +        case MIDI_CC_CHORUS:
    +            c->chorus = value;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_EFFECTS);
    +            break;
    +
    +        case MIDI_CC_RPN_MSB:
    +            c->rpnMsb = value;
    +            break;
    +
    +        case MIDI_CC_RPN_LSB:
    +            c->rpnLsb = value;
    +            break;
    +
    +        case MIDI_CC_NRPN_MSB:
    +        case MIDI_CC_NRPN_LSB:
    +            c->rpnMsb = MIDI_RPN_NULL;
    +            c->rpnLsb = MIDI_RPN_NULL;
    +            break;
    +
    +        case MIDI_CC_DATA:
    +            if (c->rpnMsb == 0 && c->rpnLsb == MIDI_RPN_BEND_RANGE) {
    +                c->bendRange = value;
    +                voicesUpdate(s, channel, AUDIO_WT_SET_PITCH);
    +            }
    +
    +            break;
    +
    +        case MIDI_CC_SOUND_OFF:
    +            for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +                if (s->voices[i].id && s->voices[i].channel == channel) {
    +                    voiceFree(s, &s->voices[i]);
    +                }
    +            }
    +
    +            break;
    +
    +        case MIDI_CC_RESET:
    +            c->bend       = 0;
    +            c->expression = WAVE_EXPRESSION_MAX;
    +            c->modWheel   = 0;
    +            c->pressure   = 0;
    +            c->sustain    = false;
    +            c->rpnMsb     = MIDI_RPN_NULL;
    +            c->rpnLsb     = MIDI_RPN_NULL;
    +            releaseHeld(s, channel);
    +            voicesUpdate(s, channel, AUDIO_WT_SET_PITCH | AUDIO_WT_SET_VOLUME | AUDIO_WT_SET_LFO);
    +            break;
    +
    +        case MIDI_CC_NOTES_OFF:
    +            for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +                if (s->voices[i].id && s->voices[i].channel == channel && s->voices[i].state != WAVE_V_RELEASED) {
    +                    voiceRelease(s, &s->voices[i]);
    +                }
    +            }
    +
    +            break;
    +
    +        default:
    +            break;
    +    }
    +}
    +
    +
    +// The volume envelope (and the modulation envelope) a tick on.  Decay and
    +// release move WAVE_FULL_SCALE in their stage times; the voice is done
    +// once its release has passed WAVE_FULL_SCALE (96 dB down).
    +static void envTick(const WaveSynthT *s, WaveVoiceT *v, uint32_t us) {
    +    const int16_t *env = v->note.volEnv;
    +
    +    (void)s;
    +    v->stageUs += us;
    +
    +    switch (v->stage) {
    +        case AUDIO_WT_ENV_DELAY:
    +            v->envAtten = AUDIO_WT_ATTEN_MAX;
    +
    +            if (v->stageUs >= tcToUs(env[AUDIO_WT_ENV_DELAY])) {
    +                v->stage   = AUDIO_WT_ENV_ATTACK;
    +                v->stageUs = 0;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_ATTACK: {
    +            int32_t step = v->stageUs / (uint32_t)stageDiv(tcToUs(env[AUDIO_WT_ENV_ATTACK]), WAVE_STEP_MAX);
    +
    +            v->envAtten = waveCbForStep[step < WAVE_STEP_MAX ? step : WAVE_STEP_MAX];
    +
    +            if (step >= WAVE_STEP_MAX) {
    +                v->stage   = AUDIO_WT_ENV_HOLD;
    +                v->stageUs = 0;
    +            }
    +
    +            break;
    +        }
    +
    +        case AUDIO_WT_ENV_HOLD:
    +            v->envAtten = 0;
    +
    +            if (v->stageUs >= tcToUs(env[AUDIO_WT_ENV_HOLD])) {
    +                v->stage   = AUDIO_WT_ENV_DECAY;
    +                v->stageUs = 0;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_DECAY:
    +            v->envAtten = (int32_t)(v->stageUs / (uint32_t)stageDiv(tcToUs(env[AUDIO_WT_ENV_DECAY]), WAVE_FULL_SCALE));
    +
    +            if (v->envAtten >= env[AUDIO_WT_ENV_SUSTAIN]) {
    +                v->envAtten = env[AUDIO_WT_ENV_SUSTAIN];
    +                v->stage    = AUDIO_WT_ENV_SUSTAIN;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_SUSTAIN:
    +            v->envAtten = env[AUDIO_WT_ENV_SUSTAIN];
    +            break;
    +
    +        case AUDIO_WT_ENV_RELEASE:
    +            v->envAtten = v->relFrom + (int32_t)(v->stageUs / (uint32_t)stageDiv(tcToUs(env[AUDIO_WT_ENV_RELEASE]), WAVE_FULL_SCALE));
    +
    +            if (v->envAtten >= WAVE_FULL_SCALE) {
    +                v->envAtten = AUDIO_WT_ATTEN_MAX;
    +                v->stage    = WAVE_ENV_DONE;
    +            }
    +
    +            break;
    +
    +        default:
    +            break;
    +    }
    +
    +    if (v->note.modEnvToPitch || v->note.modEnvToFilter) {
    +        modEnvTick(v, us);
    +    }
    +}
    +
    +
    +// A triangle LFO's value (-32768..32767) after moving us microseconds at
    +// freq (absolute cents).
    +static int32_t lfoAt(uint32_t *phase, int16_t freq, uint32_t us) {
    +    uint32_t p;
    +
    +    *phase = (*phase + lfoMilliHz(freq) * us / LFO_PHASE_DIV) % LFO_CYCLE;
    +    p      = *phase;
    +    return (int32_t)(p < LFO_HALF ? p : LFO_CYCLE - 1 - p) * 2 - LFO_HALF;
    +}
    +
    +
    +// Absolute cents to millihertz (8.176 Hz x 2^(cents / 1200)).
    +static uint32_t lfoMilliHz(int32_t cents) {
    +    uint32_t frac;
    +    int32_t  oct  = waveOctaves(cents, &frac);
    +    uint32_t base = LFO_BASE_MILLIHZ * frac >> WAVE_FIX_SHIFT;
    +
    +    return oct >= 0 ? base << oct : base >> -oct;
    +}
    +
    +
    +// The modulation envelope (0..WAVE_PERMILLE): attack linear, hold,
    +// decay toward the sustain level and release to 0 at full scale per
    +// stage time.
    +static void modEnvTick(WaveVoiceT *v, uint32_t us) {
    +    const int16_t *env     = v->note.modEnv;
    +    int32_t        sustain = WAVE_PERMILLE - env[AUDIO_WT_ENV_SUSTAIN];
    +
    +    v->modStageUs += us;
    +
    +    switch (v->modStage) {
    +        case AUDIO_WT_ENV_DELAY:
    +            v->modEnv = 0;
    +
    +            if (v->modStageUs >= tcToUs(env[AUDIO_WT_ENV_DELAY])) {
    +                v->modStage   = AUDIO_WT_ENV_ATTACK;
    +                v->modStageUs = 0;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_ATTACK:
    +            v->modEnv = (int32_t)(v->modStageUs / (uint32_t)stageDiv(tcToUs(env[AUDIO_WT_ENV_ATTACK]), WAVE_PERMILLE));
    +
    +            if (v->modEnv >= WAVE_PERMILLE) {
    +                v->modEnv     = WAVE_PERMILLE;
    +                v->modStage   = AUDIO_WT_ENV_HOLD;
    +                v->modStageUs = 0;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_HOLD:
    +            if (v->modStageUs >= tcToUs(env[AUDIO_WT_ENV_HOLD])) {
    +                v->modStage   = AUDIO_WT_ENV_DECAY;
    +                v->modStageUs = 0;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_DECAY:
    +            v->modEnv = WAVE_PERMILLE - (int32_t)(v->modStageUs / (uint32_t)stageDiv(tcToUs(env[AUDIO_WT_ENV_DECAY]), WAVE_PERMILLE));
    +
    +            if (v->modEnv <= sustain) {
    +                v->modEnv   = sustain;
    +                v->modStage = AUDIO_WT_ENV_SUSTAIN;
    +            }
    +
    +            break;
    +
    +        case AUDIO_WT_ENV_RELEASE:
    +            v->modEnv = v->modFrom - (int32_t)(v->modStageUs / (uint32_t)stageDiv(tcToUs(env[AUDIO_WT_ENV_RELEASE]), WAVE_PERMILLE));
    +            v->modEnv = v->modEnv > 0 ? v->modEnv : 0;
    +            break;
    +
    +        default:
    +            break;
    +    }
    +}
    +
    +
    +// Every voice of the key on the channel released (held while the sustain
    +// pedal is down).
    +static void noteOff(WaveSynthT *s, uint8_t channel, uint8_t key) {
    +    bool sustain = s->channels[channel].sustain;
    +
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        WaveVoiceT *v = &s->voices[i];
    +
    +        if (v->id && v->state == WAVE_V_ON && v->channel == channel && v->key == key) {
    +            if (sustain) {
    +                v->state = WAVE_V_HELD;
    +            } else {
    +                voiceRelease(s, v);
    +            }
    +        }
    +    }
    +}
    +
    +
    +// A voice for every region of the preset that holds the key and the
    +// velocity and whose sample is in the synthesizer's memory.
    +// Every region of the note's preset that holds it, a voice each
    +// (notePlayable said they can play).
    +static void noteOn(WaveSynthT *s, uint8_t channel, uint8_t key, uint8_t velocity) {
    +    const WavePresetT *preset = channelPreset(s, channel);
    +
    +    for (uint32_t i = 0; i < preset->count; i++) {
    +        const WaveRegionT *r = &s->bank->regions[preset->first + i];
    +
    +        if (key < r->keyLo || key > r->keyHi || velocity < r->velLo || velocity > r->velHi) {
    +            continue;
    +        }
    +
    +        uint8_t exclusive = (uint8_t)r->gen[SF_GEN_EXCLUSIVE_CLASS];
    +
    +        for (int32_t j = 0; exclusive && j < WAVE_VOICES_MAX; j++) {
    +            if (s->voices[j].id && s->voices[j].channel == channel && s->voices[j].exclusive == exclusive) {
    +                voiceFree(s, &s->voices[j]);
    +            }
    +        }
    +
    +        WaveVoiceT *v = voiceTake(s);
    +
    +        if (!v) {
    +            return;
    +        }
    +
    +        v->channel   = channel;
    +        v->key       = key;
    +        v->exclusive = exclusive;
    +        v->state     = WAVE_V_ON;
    +        v->age       = ++s->clock;
    +        noteSetup(s, v, r, key, velocity);
    +        voiceLevels(s, v);
    +        s->backend.start(s->backend.owner, v->id, &v->note);
    +        v->sentAtten = v->note.attenuation;
    +        v->sentPitch = v->note.pitch;
    +    }
    +}
    +
    +
    +// Some region of the channel's preset holds the note and every one that
    +// does has its sample in the synthesizer's memory.
    +static bool notePlayable(const WaveSynthT *s, uint8_t channel, uint8_t key, uint8_t velocity) {
    +    const WavePresetT *preset = channelPreset(s, channel);
    +    bool               any    = false;
    +
    +    for (uint32_t i = 0; preset && i < preset->count; i++) {
    +        const WaveRegionT *r = &s->bank->regions[preset->first + i];
    +
    +        if (key < r->keyLo || key > r->keyHi || velocity < r->velLo || velocity > r->velHi) {
    +            continue;
    +        }
    +
    +        if (s->loaded[r->sample].addr == WAVE_NOT_LOADED) {
    +            return false;
    +        }
    +
    +        any = true;
    +    }
    +
    +    return any;
    +}
    +
    +
    +// What a region makes of a key and velocity: the sample's place, loop
    +// and pitch, the attenuation before the channel's, the filter, the
    +// envelopes and LFOs (keyed hold and decay), the effect sends.
    +static void noteSetup(const WaveSynthT *s, WaveVoiceT *v, const WaveRegionT *r, uint8_t key, uint8_t velocity) {
    +    const int16_t     *g      = r->gen;
    +    const WaveSampleT *sample = &s->bank->samples[r->sample];
    +    const WaveLoadedT *ld     = &s->loaded[r->sample];
    +    AudioWtVoiceT     *n      = &v->note;
    +    int32_t            root   = g[SF_GEN_ROOT_KEY] >= 0 ? g[SF_GEN_ROOT_KEY] : sample->rootKey;
    +    int32_t            k      = g[SF_GEN_KEYNUM] >= 0 ? g[SF_GEN_KEYNUM] : key;
    +    int32_t            vel    = g[SF_GEN_VELOCITY] >= 0 ? g[SF_GEN_VELOCITY] : velocity;
    +    int32_t            len    = (int32_t)(sample->end - sample->start);
    +    int32_t            start  = clampInt(g[SF_GEN_START_OFFSET] + (g[SF_GEN_START_COARSE] * WAVE_COARSE_FRAMES), 0, len);
    +    int32_t            end    = clampInt(len + g[SF_GEN_END_OFFSET] + (g[SF_GEN_END_COARSE] * WAVE_COARSE_FRAMES), start, len);
    +    int32_t            lStart = clampInt((int32_t)(sample->loopStart - sample->start) + g[SF_GEN_LOOP_START_OFFSET] + (g[SF_GEN_LOOP_START_COARSE] * WAVE_COARSE_FRAMES), start, end);
    +    int32_t            lEnd   = clampInt((int32_t)(sample->loopEnd - sample->start) + g[SF_GEN_LOOP_END_OFFSET] + (g[SF_GEN_LOOP_END_COARSE] * WAVE_COARSE_FRAMES), lStart, end);
    +    int32_t            keyed  = WAVE_KEY_CENTER - k;
    +    uint32_t           frame  = ld->bits / BYTE_BITS;
    +    uint8_t            modes  = (uint8_t)(g[SF_GEN_SAMPLE_MODES] & WAVE_LOOP_MASK);
    +
    +    // Every field is set here or by voiceLevels (no library calls at
    +    // interrupt time)
    +    start  >>= ld->shift;
    +    end    >>= ld->shift;
    +    lStart >>= ld->shift;
    +    lEnd   >>= ld->shift;
    +
    +    n->addr      = ld->addr + (uint32_t)start * frame;
    +    n->offset    = 0;
    +    n->end       = (uint32_t)(end - start);
    +    n->loopStart = (uint32_t)(lStart - start);
    +    n->loopEnd   = (uint32_t)(lEnd - start);
    +    n->bits      = ld->bits;
    +    n->loop      = modes == WAVE_LOOP_UNUSED ? AUDIO_WT_LOOP_NONE : modes;
    +
    +    if (n->loopEnd <= n->loopStart) {
    +        n->loop = AUDIO_WT_LOOP_NONE;
    +    }
    +
    +    v->region    = r;
    +    v->basePitch = ld->rateCents + (k - root) * g[SF_GEN_SCALE_TUNING] + g[SF_GEN_COARSE_TUNE] * WAVE_CENTS_SEMI + g[SF_GEN_FINE_TUNE] + sample->correction;
    +    v->baseAtten = g[SF_GEN_ATTENUATION] * WAVE_ATTEN_NUM / WAVE_ATTEN_DEN + sCbForLevel[vel];
    +
    +    n->filterFc       = clampInt(g[SF_GEN_FILTER_FC] - WAVE_VEL_FILTER * (MIDI_DATA_MASK - vel) / MIDI_DATA_MASK, WAVE_FILTER_MIN, WAVE_FILTER_MAX);
    +    n->filterQ        = g[SF_GEN_FILTER_Q];
    +    n->modEnvToPitch  = g[SF_GEN_MOD_ENV_TO_PITCH];
    +    n->modEnvToFilter = g[SF_GEN_MOD_ENV_TO_FILTER];
    +    n->modLfoDelay    = g[SF_GEN_MOD_LFO_DELAY];
    +    n->modLfoFreq     = g[SF_GEN_MOD_LFO_FREQ];
    +    n->modLfoToPitch  = g[SF_GEN_MOD_LFO_TO_PITCH];
    +    n->modLfoToFilter = g[SF_GEN_MOD_LFO_TO_FILTER];
    +    n->modLfoToVolume = g[SF_GEN_MOD_LFO_TO_VOLUME];
    +    n->vibLfoDelay    = g[SF_GEN_VIB_LFO_DELAY];
    +    n->vibLfoFreq     = g[SF_GEN_VIB_LFO_FREQ];
    +
    +    for (int32_t i = 0; i < AUDIO_WT_ENV_STAGES; i++) {
    +        n->volEnv[i] = g[SF_GEN_VOL_ENV_DELAY + i];
    +        n->modEnv[i] = g[SF_GEN_MOD_ENV_DELAY + i];
    +    }
    +
    +    n->volEnv[AUDIO_WT_ENV_HOLD]  = (int16_t)(n->volEnv[AUDIO_WT_ENV_HOLD] + g[SF_GEN_KEY_TO_VOL_ENV_HOLD] * keyed);
    +    n->volEnv[AUDIO_WT_ENV_DECAY] = (int16_t)(n->volEnv[AUDIO_WT_ENV_DECAY] + g[SF_GEN_KEY_TO_VOL_ENV_DECAY] * keyed);
    +    n->modEnv[AUDIO_WT_ENV_HOLD]  = (int16_t)(n->modEnv[AUDIO_WT_ENV_HOLD] + g[SF_GEN_KEY_TO_MOD_ENV_HOLD] * keyed);
    +    n->modEnv[AUDIO_WT_ENV_DECAY] = (int16_t)(n->modEnv[AUDIO_WT_ENV_DECAY] + g[SF_GEN_KEY_TO_MOD_ENV_DECAY] * keyed);
    +
    +    // Envelopes run here start silent; the chip's start at once
    +    bool soft = !(s->backend.flags & AUDIO_WT_ENVELOPES);
    +
    +    v->stage      = soft ? AUDIO_WT_ENV_DELAY : AUDIO_WT_ENV_SUSTAIN;
    +    v->modStage   = AUDIO_WT_ENV_DELAY;
    +    v->envAtten   = soft ? AUDIO_WT_ATTEN_MAX : 0;
    +    v->modEnv     = 0;
    +    v->stageUs    = 0;
    +    v->modStageUs = 0;
    +    v->sinceOn    = 0;
    +    v->modPhase   = 0;
    +    v->vibPhase   = 0;
    +}
    +
    +
    +// The sustain pedal is up: the notes it held are released.
    +static void releaseHeld(WaveSynthT *s, uint8_t channel) {
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        if (s->voices[i].id && s->voices[i].state == WAVE_V_HELD && s->voices[i].channel == channel) {
    +            voiceRelease(s, &s->voices[i]);
    +        }
    +    }
    +}
    +
    +
    +// The microseconds one unit of a stage takes when span units take
    +// stageUs (at least 1, so the stage ends).
    +static int32_t stageDiv(uint32_t stageUs, int32_t span) {
    +    uint32_t d = stageUs / (uint32_t)span;
    +
    +    return d > 0 ? (int32_t)d : 1;
    +}
    +
    +
    +// Timecents to microseconds: 1,000,000 x 2^(tc / 1200), all in 32 bits
    +// (tc -12000 .. 8000: about 1 ms .. 101 s).
    +static uint32_t tcToUs(int32_t tc) {
    +    uint32_t frac;
    +    int32_t  oct  = waveOctaves(tc, &frac);
    +    uint32_t base = frac * US_MANT_MUL >> US_MANT_SHIFT;
    +
    +    return oct >= 0 ? base << oct : base >> -oct;
    +}
    +
    +
    +// Send the voice's note again (what changed), and remember what was sent.
    +static void voiceDeliver(WaveSynthT *s, WaveVoiceT *v, uint32_t what) {
    +    voiceLevels(s, v);
    +    s->backend.update(s->backend.owner, v->id, &v->note, what);
    +    v->sentAtten = v->note.attenuation;
    +    v->sentPitch = v->note.pitch;
    +}
    +
    +
    +static void voiceFree(WaveSynthT *s, WaveVoiceT *v) {
    +    s->backend.free(s->backend.owner, v->id);
    +    v->id    = 0;
    +    v->state = WAVE_V_FREE;
    +}
    +
    +
    +// The note's pitch, attenuation, pan, effect sends and vibrato depth from
    +// its region's values, its channel, the music's gain, and (run here) the
    +// envelopes and LFOs as they stand.
    +static void voiceLevels(const WaveSynthT *s, WaveVoiceT *v) {
    +    const WaveChannelT *c     = &s->channels[v->channel];
    +    const int16_t      *g     = v->region->gen;
    +    AudioWtVoiceT      *n     = &v->note;
    +    int32_t             gain  = clampInt(s->gain, 0, AUDIO_VOLUME_MAX) >> WAVE_GAIN_SHIFT;
    +    int32_t             depth = g[SF_GEN_VIB_LFO_TO_PITCH] + (c->modWheel + c->pressure) * WAVE_MOD_DEPTH / MIDI_DATA_MASK;
    +    int32_t             pitch = v->basePitch + bendCents(c);
    +    int32_t             atten = v->baseAtten + sCbForLevel[c->volume] + sCbForLevel[c->expression] + waveCbForStep[gain] + v->envAtten;
    +
    +    if (!(s->backend.flags & AUDIO_WT_ENVELOPES)) {
    +        pitch += n->modEnvToPitch * v->modEnv / WAVE_PERMILLE;
    +    }
    +
    +    if (!(s->backend.flags & AUDIO_WT_LFOS) && v->sinceOn >= tcToUs(n->vibLfoDelay)) {
    +        uint32_t phase = v->vibPhase;
    +
    +        pitch += depth * lfoAt(&phase, n->vibLfoFreq, 0) / LFO_HALF;
    +    }
    +
    +    if (!(s->backend.flags & AUDIO_WT_LFOS) && v->sinceOn >= tcToUs(n->modLfoDelay)) {
    +        uint32_t phase = v->modPhase;
    +        int32_t  lfo   = lfoAt(&phase, n->modLfoFreq, 0);
    +
    +        pitch += n->modLfoToPitch * lfo / LFO_HALF;
    +        atten += n->modLfoToVolume * lfo / LFO_HALF;
    +    }
    +
    +    n->pitch         = pitch;
    +    n->attenuation   = clampInt(atten, 0, AUDIO_WT_ATTEN_MAX);
    +    n->pan           = clampInt(g[SF_GEN_PAN] + (c->pan - MIDI_CENTER) * AUDIO_WT_PAN_MAX / MIDI_CENTER, -AUDIO_WT_PAN_MAX, AUDIO_WT_PAN_MAX);
    +    n->reverb        = clampInt(g[SF_GEN_REVERB_SEND] + c->reverb * WAVE_SEND_DEPTH / MIDI_DATA_MASK, 0, WAVE_SEND_MAX);
    +    n->chorus        = clampInt(g[SF_GEN_CHORUS_SEND] + c->chorus * WAVE_SEND_DEPTH / MIDI_DATA_MASK, 0, WAVE_SEND_MAX);
    +    n->vibLfoToPitch = (int16_t)depth;
    +}
    +
    +
    +// The key is up: the backend's envelopes release (a sample looping while
    +// the key is down plays out); ours start their release from where they
    +// are.
    +static void voiceRelease(WaveSynthT *s, WaveVoiceT *v) {
    +    v->state = WAVE_V_RELEASED;
    +    s->backend.release(s->backend.owner, v->id);
    +
    +    if (!(s->backend.flags & AUDIO_WT_ENVELOPES)) {
    +        v->relFrom    = v->stage == AUDIO_WT_ENV_DELAY ? WAVE_FULL_SCALE : v->envAtten;
    +        v->modFrom    = v->modEnv;
    +        v->stage      = AUDIO_WT_ENV_RELEASE;
    +        v->modStage   = AUDIO_WT_ENV_RELEASE;
    +        v->stageUs    = 0;
    +        v->modStageUs = 0;
    +    }
    +}
    +
    +
    +static void voicesFreeAll(WaveSynthT *s) {
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        if (s->voices[i].id) {
    +            voiceFree(s, &s->voices[i]);
    +        }
    +    }
    +}
    +
    +
    +// Every sounding voice of the channel sent again (what changed).
    +static void voicesUpdate(WaveSynthT *s, uint8_t channel, uint32_t what) {
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        if (s->voices[i].id && s->voices[i].channel == channel) {
    +            voiceDeliver(s, &s->voices[i], what);
    +        }
    +    }
    +}
    +
    +
    +// A free slot and a backend voice for it; with none free, the oldest
    +// released voice is taken, else the oldest.
    +static WaveVoiceT *voiceTake(WaveSynthT *s) {
    +    WaveVoiceT *slot = NULL;
    +
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX && !slot; i++) {
    +        slot = s->voices[i].id ? NULL : &s->voices[i];
    +    }
    +
    +    int32_t id = slot ? s->backend.alloc(s->backend.owner) : 0;
    +
    +    if (id <= 0) {
    +        WaveVoiceT *victim = NULL;
    +
    +        for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +            WaveVoiceT *v = &s->voices[i];
    +
    +            if (!v->id) {
    +                continue;
    +            }
    +
    +            bool better = !victim || (v->state == WAVE_V_RELEASED && victim->state != WAVE_V_RELEASED) || ((v->state == WAVE_V_RELEASED) == (victim->state == WAVE_V_RELEASED) && v->age < victim->age);
    +
    +            victim = better ? v : victim;
    +        }
    +
    +        if (!victim) {
    +            return NULL;
    +        }
    +
    +        voiceFree(s, victim);
    +        slot = victim;
    +        id   = s->backend.alloc(s->backend.owner);
    +
    +        if (id <= 0) {
    +            return NULL;
    +        }
    +    }
    +
    +    slot->id = id;
    +    return slot;
    +}
    +
    +
    +// That one; else, for drums, the standard kit (program 0, else the
    +// bank's first); else the same program in bank 0 (a variation the bank
    +// lacks).
    +const WavePresetT *waveBankPreset(const WaveBankT *bank, uint16_t bankNum, uint8_t program) {
    +    bool               drums = bankNum == SF_DRUM_BANK;
    +    const WavePresetT *spare = NULL;
    +
    +    for (uint32_t i = 0; i < bank->presetCount; i++) {
    +        const WavePresetT *p = &bank->presets[i];
    +
    +        if (p->bank == bankNum && p->program == program) {
    +            return p;
    +        }
    +
    +        if (drums ? (p->bank == SF_DRUM_BANK && (!spare || p->program == 0)) : (p->bank == 0 && p->program == program)) {
    +            spare = p;
    +        }
    +    }
    +
    +    return spare;
    +}
    +
    +
    +static bool waveHwActive(void *owner, int32_t voice) {
    +    return audioWtActive((AudioCtxT *)owner, voice);
    +}
    +
    +
    +static int32_t waveHwAlloc(void *owner) {
    +    return audioWtAlloc((AudioCtxT *)owner);
    +}
    +
    +
    +void waveHwBackend(AudioCtxT *ctx, WaveBackendT *out) {
    +    AudioWtInfoT info;
    +
    +    audioWtGetInfo(&info);
    +    out->alloc   = waveHwAlloc;
    +    out->free    = waveHwFree;
    +    out->start   = waveHwStart;
    +    out->update  = waveHwUpdate;
    +    out->release = waveHwRelease;
    +    out->active  = waveHwActive;
    +    out->owner   = ctx;
    +    out->flags   = info.flags;
    +}
    +
    +
    +static void waveHwFree(void *owner, int32_t voice) {
    +    audioWtFree((AudioCtxT *)owner, voice);
    +}
    +
    +
    +static void waveHwRelease(void *owner, int32_t voice) {
    +    audioWtRelease((AudioCtxT *)owner, voice);
    +}
    +
    +
    +static void waveHwStart(void *owner, int32_t voice, const AudioWtVoiceT *v) {
    +    audioWtStart((AudioCtxT *)owner, voice, v);
    +}
    +
    +
    +static void waveHwUpdate(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    audioWtUpdate((AudioCtxT *)owner, voice, v, what);
    +}
    +
    +
    +int32_t waveLockItems(AudioLockItemT *items) {
    +    const void *fns[] = {
    +        (const void *)bendCents,
    +        (const void *)channelPreset,
    +        (const void *)channelReset,
    +        (const void *)clampInt,
    +        (const void *)controller,
    +        (const void *)envTick,
    +        (const void *)lfoAt,
    +        (const void *)lfoMilliHz,
    +        (const void *)modEnvTick,
    +        (const void *)noteOff,
    +        (const void *)noteOn,
    +        (const void *)notePlayable,
    +        (const void *)noteSetup,
    +        (const void *)releaseHeld,
    +        (const void *)stageDiv,
    +        (const void *)tcToUs,
    +        (const void *)voiceDeliver,
    +        (const void *)voiceFree,
    +        (const void *)voiceLevels,
    +        (const void *)voiceRelease,
    +        (const void *)voiceTake,
    +        (const void *)voicesFreeAll,
    +        (const void *)voicesUpdate,
    +        (const void *)waveBankPreset,
    +        (const void *)waveHwActive,
    +        (const void *)waveHwAlloc,
    +        (const void *)waveHwFree,
    +        (const void *)waveHwRelease,
    +        (const void *)waveHwStart,
    +        (const void *)waveHwUpdate,
    +        (const void *)waveMessage,
    +        (const void *)waveSilence,
    +        (const void *)waveSynthTick,
    +        (const void *)waveSysex
    +    };
    +    int32_t count = (int32_t)(sizeof(fns) / sizeof(fns[0]));
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        items[i].addr = fns[i];
    +        items[i].size = 0;
    +    }
    +
    +    items[count].addr = sCbForLevel;
    +    items[count].size = sizeof(sCbForLevel);
    +    count++;
    +    return count + waveCentsLockItems(&items[count]);
    +}
    +
    +
    +// A note on that plays here is not passed on; every other message is
    +// (to keep the overflow's channels in step, and its notes ending).
    +static void waveMessage(MidiSinkT *sink, uint8_t status, uint8_t data1, uint8_t data2) {
    +    WaveSynthT *s       = (WaveSynthT *)sink;
    +    uint8_t     channel = status & MIDI_CHANNEL_MASK;
    +
    +    switch (status & MIDI_TYPE_MASK) {
    +        case MIDI_NOTE_ON:
    +            if (!data2) {
    +                noteOff(s, channel, data1);
    +            } else if (notePlayable(s, channel, data1, data2)) {
    +                noteOn(s, channel, data1, data2);
    +                return;
    +            }
    +
    +            break;
    +
    +        case MIDI_NOTE_OFF:
    +            noteOff(s, channel, data1);
    +            break;
    +
    +        case MIDI_CONTROL:
    +            controller(s, channel, data1, data2);
    +            break;
    +
    +        case MIDI_PROGRAM:
    +            s->channels[channel].program = data1;
    +            break;
    +
    +        case MIDI_CHAN_PRESSURE:
    +            s->channels[channel].pressure = data1;
    +            voicesUpdate(s, channel, AUDIO_WT_SET_LFO);
    +            break;
    +
    +        case MIDI_PITCH_BEND:
    +            s->channels[channel].bend = (int16_t)((data1 | (data2 << MIDI_DATA_BITS)) - MIDI_BEND_CENTER);
    +            voicesUpdate(s, channel, AUDIO_WT_SET_PITCH);
    +            break;
    +
    +        default:
    +            break;
    +    }
    +
    +    if (s->overflow) {
    +        s->overflow->message(s->overflow, status, data1, data2);
    +    }
    +}
    +
    +
    +static void waveSilence(MidiSinkT *sink) {
    +    WaveSynthT *s = (WaveSynthT *)sink;
    +
    +    voicesFreeAll(s);
    +
    +    if (s->overflow) {
    +        s->overflow->silence(s->overflow);
    +    }
    +}
    +
    +
    +void waveSynthInit(WaveSynthT *synth, const WaveBackendT *backend, const WaveBankT *bank, const WaveLoadedT *loaded, MidiSinkT *overflow, int32_t gain) {
    +    memset(synth, 0, sizeof(*synth));
    +    synth->sink.message = waveMessage;
    +    synth->sink.sysex   = waveSysex;
    +    synth->sink.silence = waveSilence;
    +    synth->backend      = *backend;
    +    synth->bank         = bank;
    +    synth->loaded       = loaded;
    +    synth->overflow     = overflow;
    +    synth->gain         = gain;
    +
    +    for (int32_t i = 0; i < MUSIC_MIDI_CHANNELS; i++) {
    +        channelReset(&synth->channels[i]);
    +    }
    +}
    +
    +
    +void waveSynthSetGain(WaveSynthT *synth, int32_t gain) {
    +    synth->gain = gain;
    +
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        if (synth->voices[i].id) {
    +            voiceDeliver(synth, &synth->voices[i], AUDIO_WT_SET_VOLUME);
    +        }
    +    }
    +}
    +
    +
    +// Each voice: what runs here moves on (sent when the volume or the pitch
    +// moved by a step, or the envelope finished); a voice whose envelope has
    +// ended, or that the backend says has fallen silent, is given back.
    +void waveSynthTick(WaveSynthT *synth, uint32_t us) {
    +    bool softEnv = !(synth->backend.flags & AUDIO_WT_ENVELOPES);
    +    bool softLfo = !(synth->backend.flags & AUDIO_WT_LFOS);
    +
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        WaveVoiceT *v = &synth->voices[i];
    +
    +        if (!v->id) {
    +            continue;
    +        }
    +
    +        if (!synth->backend.active(synth->backend.owner, v->id)) {
    +            voiceFree(synth, v);
    +            continue;
    +        }
    +
    +        v->sinceOn += us;
    +
    +        if (softEnv) {
    +            envTick(synth, v, us);
    +        }
    +
    +        if (softLfo) {
    +            lfoAt(&v->vibPhase, v->note.vibLfoFreq, us);
    +            lfoAt(&v->modPhase, v->note.modLfoFreq, us);
    +        }
    +
    +        if (softEnv && v->stage == WAVE_ENV_DONE) {
    +            voiceFree(synth, v);
    +            continue;
    +        }
    +
    +        if (!softEnv && !softLfo) {
    +            continue;
    +        }
    +
    +        voiceLevels(synth, v);
    +
    +        uint32_t what  = 0;
    +        int32_t  moved = v->note.attenuation - v->sentAtten;
    +
    +        what |= (moved >= WAVE_ATTEN_STEP || moved <= -WAVE_ATTEN_STEP) ? AUDIO_WT_SET_VOLUME : 0;
    +        what |= v->note.pitch != v->sentPitch ? AUDIO_WT_SET_PITCH : 0;
    +
    +        if (what) {
    +            synth->backend.update(synth->backend.owner, v->id, &v->note, what);
    +            v->sentAtten = v->note.attenuation;
    +            v->sentPitch = v->note.pitch;
    +        }
    +    }
    +}
    +
    +
    +// General MIDI System On: every voice freed, every channel reset (the
    +// overflow gets the message itself).
    +static void waveSysex(MidiSinkT *sink, const uint8_t *data, uint32_t len) {
    +    WaveSynthT *s    = (WaveSynthT *)sink;
    +    bool        gmOn = len == sizeof(sGmOn);
    +
    +    if (s->overflow) {
    +        s->overflow->sysex(s->overflow, data, len);
    +    }
    +
    +    for (uint32_t i = 0; gmOn && i < len; i++) {
    +        gmOn = data[i] == sGmOn[i];
    +    }
    +
    +    if (!gmOn) {
    +        return;
    +    }
    +
    +    voicesFreeAll(s);
    +
    +    for (int32_t i = 0; i < MUSIC_MIDI_CHANNELS; i++) {
    +        channelReset(&s->channels[i]);
    +    }
    +}
    diff --git a/src/libs/kpunch/music/musicWaveLoad.c b/src/libs/kpunch/music/musicWaveLoad.c
    new file mode 100644
    index 00000000..917ef442
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicWaveLoad.c
    @@ -0,0 +1,386 @@
    +// 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.
    +
    +// musicWaveLoad.c  -- SoundFont samples in the synthesizer's memory (see
    +// musicInt.h)
    +//
    +// A wavetable synthesizer has little memory (256 KB to 1 MB on a Gravis
    +// UltraSound, 512 KB on most AWE32s), so a song loads only the samples
    +// its notes play: its bank selects, programs and notes are walked first
    +// (waveNeedsFile).  What a song loads stays for the next song while it
    +// fits; when room runs out the samples the song does not need go first,
    +// those used longest ago first.  When the needed samples do not fit even
    +// then, they are all loaded again smaller: 8-bit where the chip plays
    +// that, then at half the rate (each pair of frames averaged), then a
    +// quarter, each try at one quality for every sample of the song; at the
    +// least, the samples that still do not fit are left out (their notes play
    +// on the FM synthesizer, musicWave.c).  A ROM sample (a SoundFont made for
    +// an AWE's General MIDI ROM) needs no memory: on a chip with that ROM it
    +// plays from there, on any other it is left out.
    +//
    +// A sample is stored from its first frame through its end and the few
    +// frames after it (a SoundFont puts 46 zero frames after every sample, and
    +// matching frames after a loop's end) that a chip's interpolation reads.
    +
    +#include "musicInt.h"
    +
    +#include 
    +#include 
    +
    +#define BITS_8              8
    +#define BITS_16             16
    +#define BYTE_BITS           8
    +#define SHIFT_MAX           2       // a quarter of the rate at the least
    +
    +// The qualities tried, best first
    +typedef struct {
    +    uint8_t bits;
    +    uint8_t shift;
    +} QualityT;
    +
    +static const QualityT sQualities[] = {
    +    { BITS_16, 0 },
    +    { BITS_8,  0 },
    +    { BITS_16, 1 },
    +    { BITS_8,  1 },
    +    { BITS_16, SHIFT_MAX },
    +    { BITS_8,  SHIFT_MAX }
    +};
    +
    +#define QUALITY_COUNT       ((int32_t)(sizeof(sQualities) / sizeof(sQualities[0])))
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool     evictOldest(AudioCtxT *ctx, WaveCacheT *cache, const uint8_t *need);
    +static int32_t  loadAt(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, const QualityT *q, bool leaveOut, uint32_t *missing);
    +static int32_t  loadOne(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t sample, const QualityT *q);
    +static uint32_t loadRom(const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t flags);
    +static uint32_t markPreset(const WaveBankT *bank, const WavePresetT *preset, int32_t key, int32_t velocity, uint8_t *need);
    +static bool     qualityPlays(const QualityT *q, uint32_t flags);
    +static uint32_t storedFrames(const WaveBankT *bank, uint32_t sample);
    +static void     unload(AudioCtxT *ctx, WaveCacheT *cache, uint32_t sample);
    +void            waveCacheFree(AudioCtxT *ctx, WaveCacheT *cache);
    +int32_t         waveCacheInit(WaveCacheT *cache, const WaveBankT *bank);
    +int32_t         waveLoadNeeded(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t flags, uint32_t *missing);
    +void            waveNeedsFile(const WaveBankT *bank, const MidiFileT *file, uint8_t *need);
    +uint32_t        waveNeedsPreset(const WaveBankT *bank, uint16_t bankNum, uint8_t program, uint8_t *need);
    +
    +
    +// Give up the loaded sample the song does not need that was used longest
    +// ago; false when there is none.
    +static bool evictOldest(AudioCtxT *ctx, WaveCacheT *cache, const uint8_t *need) {
    +    int32_t oldest = -1;
    +
    +    for (uint32_t i = 0; i < cache->count; i++) {
    +        uint32_t addr = cache->loaded[i].addr;
    +
    +        if (addr != WAVE_NOT_LOADED && !(addr & AUDIO_WT_ADDR_ROM) && !need[i] && (oldest < 0 || cache->lastUse[i] < cache->lastUse[oldest])) {
    +            oldest = (int32_t)i;
    +        }
    +    }
    +
    +    if (oldest < 0) {
    +        return false;
    +    }
    +
    +    unload(ctx, cache, (uint32_t)oldest);
    +    return true;
    +}
    +
    +
    +// The needed samples at quality q (those stored at another quality given
    +// up and loaded again); MUSIC_ERR_NO_DEVICE when one does not fit, unless
    +// leaveOut: it is counted in *missing and the rest go on.  ROM samples
    +// are loadRom's.
    +static int32_t loadAt(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, const QualityT *q, bool leaveOut, uint32_t *missing) {
    +    int32_t rc = MUSIC_OK;
    +
    +    *missing = 0;
    +
    +    for (uint32_t i = 0; i < cache->count; i++) {
    +        const WaveLoadedT *ld = &cache->loaded[i];
    +
    +        if (need[i] && !(bank->samples[i].type & SF_SAMPLE_ROM) && ld->addr != WAVE_NOT_LOADED && (ld->bits != q->bits || ld->shift != q->shift)) {
    +            unload(ctx, cache, i);
    +        }
    +    }
    +
    +    for (uint32_t i = 0; i < cache->count && rc == MUSIC_OK; i++) {
    +        if (need[i] && !(bank->samples[i].type & SF_SAMPLE_ROM) && cache->loaded[i].addr == WAVE_NOT_LOADED) {
    +            rc = loadOne(ctx, bank, cache, need, i, q);
    +
    +            if (rc == MUSIC_ERR_NO_DEVICE && leaveOut) {
    +                (*missing)++;
    +                rc = MUSIC_OK;
    +            }
    +        }
    +    }
    +
    +    return rc;
    +}
    +
    +
    +// One sample into the synthesizer's memory at quality q: read, halved
    +// shift times, room made (samples not needed given up) and uploaded.
    +static int32_t loadOne(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t sample, const QualityT *q) {
    +    const WaveSampleT *s      = &bank->samples[sample];
    +    uint32_t           frames = storedFrames(bank, sample);
    +    int16_t           *pcm    = (int16_t *)malloc((frames ? frames : 1) * sizeof(int16_t));
    +    uint32_t           addr   = 0;
    +    int32_t            rc;
    +
    +    if (!pcm) {
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    if (!waveBankRead(bank, s->start, frames, pcm)) {
    +        free(pcm);
    +        return MUSIC_ERR_OPEN;
    +    }
    +
    +    for (uint32_t h = 0; h < q->shift; h++) {
    +        for (uint32_t i = 0; i < frames / 2; i++) {
    +            pcm[i] = (int16_t)(((int32_t)pcm[i * 2] + pcm[i * 2 + 1]) / 2);
    +        }
    +
    +        frames /= 2;
    +    }
    +
    +    uint32_t bytes = frames * (q->bits / BYTE_BITS);
    +
    +    while ((rc = audioWtMemAlloc(ctx, bytes, q->bits, &addr)) == AUDIO_ERR_NO_ROOM && evictOldest(ctx, cache, need)) {
    +    }
    +
    +    if (rc == AUDIO_OK && audioWtUpload(ctx, addr, pcm, frames, q->bits) != AUDIO_OK) {
    +        audioWtMemFree(ctx, addr);
    +        rc = AUDIO_ERR_ARG;
    +    }
    +
    +    free(pcm);
    +
    +    if (rc != AUDIO_OK) {
    +        return MUSIC_ERR_NO_DEVICE;
    +    }
    +
    +    WaveLoadedT *ld = &cache->loaded[sample];
    +
    +    ld->addr      = addr;
    +    ld->frames    = frames;
    +    ld->bits      = q->bits;
    +    ld->shift     = q->shift;
    +    ld->rateCents = waveRatioCents(s->rate, AUDIO_WT_PITCH_RATE) - (int32_t)q->shift * WAVE_CENTS_OCTAVE;
    +    return MUSIC_OK;
    +}
    +
    +
    +// The needed ROM samples where the chip has the ROM (flags): there, as
    +// they are; returns how many it cannot play (no ROM).
    +static uint32_t loadRom(const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t flags) {
    +    uint32_t left = 0;
    +
    +    for (uint32_t i = 0; i < cache->count; i++) {
    +        const WaveSampleT *s  = &bank->samples[i];
    +        WaveLoadedT       *ld = &cache->loaded[i];
    +
    +        if (!need[i] || !(s->type & SF_SAMPLE_ROM) || ld->addr != WAVE_NOT_LOADED) {
    +            continue;
    +        }
    +
    +        if (!(flags & AUDIO_WT_ROM)) {
    +            left++;
    +            continue;
    +        }
    +
    +        ld->addr      = AUDIO_WT_ADDR_ROM | s->start * (BITS_16 / BYTE_BITS);
    +        ld->frames    = s->end - s->start;
    +        ld->bits      = BITS_16;
    +        ld->shift     = 0;
    +        ld->rateCents = waveRatioCents(s->rate, AUDIO_WT_PITCH_RATE);
    +    }
    +
    +    return left;
    +}
    +
    +
    +// The samples of a preset's regions that hold the key and velocity (any
    +// key and velocity when they are negative); how many were not needed
    +// before.
    +static uint32_t markPreset(const WaveBankT *bank, const WavePresetT *preset, int32_t key, int32_t velocity, uint8_t *need) {
    +    uint32_t added = 0;
    +
    +    for (uint32_t i = 0; preset && i < preset->count; i++) {
    +        const WaveRegionT *r = &bank->regions[preset->first + i];
    +
    +        if ((key < 0 || (key >= r->keyLo && key <= r->keyHi)) && (velocity < 0 || (velocity >= r->velLo && velocity <= r->velHi))) {
    +            added           += !need[r->sample];
    +            need[r->sample]  = 1;
    +        }
    +    }
    +
    +    return added;
    +}
    +
    +
    +// The chip plays samples at q's bits.
    +static bool qualityPlays(const QualityT *q, uint32_t flags) {
    +    return q->bits == BITS_16 || (flags & AUDIO_WT_8BIT) != 0;
    +}
    +
    +
    +// A sample's frames as stored: through its end and the guard frames after
    +// it (as many as the bank has).
    +static uint32_t storedFrames(const WaveBankT *bank, uint32_t sample) {
    +    const WaveSampleT *s     = &bank->samples[sample];
    +    uint32_t           guard = bank->dataFrames - s->end < WAVE_GUARD_FRAMES ? bank->dataFrames - s->end : WAVE_GUARD_FRAMES;
    +
    +    return s->end - s->start + guard;
    +}
    +
    +
    +// A sample out of the synthesizer's memory (a ROM sample only forgotten).
    +static void unload(AudioCtxT *ctx, WaveCacheT *cache, uint32_t sample) {
    +    WaveLoadedT *ld = &cache->loaded[sample];
    +
    +    if (!(ld->addr & AUDIO_WT_ADDR_ROM)) {
    +        audioWtMemFree(ctx, ld->addr);
    +    }
    +
    +    ld->addr = WAVE_NOT_LOADED;
    +}
    +
    +
    +void waveCacheFree(AudioCtxT *ctx, WaveCacheT *cache) {
    +    for (uint32_t i = 0; i < cache->count; i++) {
    +        if (cache->loaded[i].addr != WAVE_NOT_LOADED) {
    +            unload(ctx, cache, i);
    +        }
    +    }
    +
    +    if (cache->loaded) {
    +        platformUnlockRegion(cache->loaded, cache->count * sizeof(WaveLoadedT), false);
    +    }
    +
    +    free(cache->loaded);
    +    free(cache->lastUse);
    +    memset(cache, 0, sizeof(*cache));
    +}
    +
    +
    +int32_t waveCacheInit(WaveCacheT *cache, const WaveBankT *bank) {
    +    uint32_t count = bank->sampleCount ? bank->sampleCount : 1;
    +
    +    memset(cache, 0, sizeof(*cache));
    +    cache->loaded  = (WaveLoadedT *)calloc(count, sizeof(WaveLoadedT));
    +    cache->lastUse = (uint32_t *)calloc(count, sizeof(uint32_t));
    +    cache->count   = bank->sampleCount;
    +
    +    if (!cache->loaded || !cache->lastUse || platformLockRegion(cache->loaded, count * sizeof(WaveLoadedT), false) != 0) {
    +        free(cache->loaded);
    +        free(cache->lastUse);
    +        memset(cache, 0, sizeof(*cache));
    +        return MUSIC_ERR_NO_MEMORY;
    +    }
    +
    +    for (uint32_t i = 0; i < cache->count; i++) {
    +        cache->loaded[i].addr = WAVE_NOT_LOADED;
    +    }
    +
    +    return MUSIC_OK;
    +}
    +
    +
    +// The ROM samples, then each quality in turn until the needed samples
    +// fit; at the least one those that do not are left out.
    +int32_t waveLoadNeeded(AudioCtxT *ctx, const WaveBankT *bank, WaveCacheT *cache, const uint8_t *need, uint32_t flags, uint32_t *missing) {
    +    int32_t  last    = QUALITY_COUNT - 1;
    +    uint32_t needed  = 0;
    +    uint32_t romLeft = loadRom(bank, cache, need, flags);
    +    int32_t  rc      = MUSIC_ERR_NO_DEVICE;
    +
    +    cache->songs++;
    +    *missing = 0;
    +
    +    for (uint32_t i = 0; i < cache->count; i++) {
    +        cache->lastUse[i] = need[i] ? cache->songs : cache->lastUse[i];
    +        needed           += need[i];
    +    }
    +
    +    while (!qualityPlays(&sQualities[last], flags)) {
    +        last--;
    +    }
    +
    +    for (int32_t qi = 0; qi <= last && rc == MUSIC_ERR_NO_DEVICE; qi++) {
    +        if (qualityPlays(&sQualities[qi], flags)) {
    +            rc = loadAt(ctx, bank, cache, need, &sQualities[qi], qi == last, missing);
    +        }
    +    }
    +
    +    *missing += romLeft;
    +
    +    // None fitted even at the least
    +    return rc == MUSIC_OK && needed > 0 && *missing == needed ? MUSIC_ERR_NO_DEVICE : rc;
    +}
    +
    +
    +void waveNeedsFile(const WaveBankT *bank, const MidiFileT *file, uint8_t *need) {
    +    uint8_t program[MUSIC_MIDI_CHANNELS];
    +    uint8_t bankMsb[MUSIC_MIDI_CHANNELS];
    +
    +    memset(need, 0, bank->sampleCount);
    +    memset(program, 0, sizeof(program));
    +    memset(bankMsb, 0, sizeof(bankMsb));
    +
    +    for (uint32_t i = 0; i < file->count; i++) {
    +        const MidiEventT *e  = &file->events[i];
    +        uint8_t           ch = e->status & MIDI_CHANNEL_MASK;
    +
    +        switch (e->status & MIDI_TYPE_MASK) {
    +            case MIDI_CONTROL:
    +                if (e->data1 == MIDI_CC_BANK) {
    +                    bankMsb[ch] = e->data2;
    +                }
    +
    +                break;
    +
    +            case MIDI_PROGRAM:
    +                program[ch] = e->data1;
    +                break;
    +
    +            case MIDI_NOTE_ON:
    +                if (e->data2 > 0) {
    +                    markPreset(bank, waveBankPreset(bank, ch == MIDI_PERCUSSION ? SF_DRUM_BANK : bankMsb[ch], program[ch]), e->data1, e->data2, need);
    +                }
    +
    +                break;
    +
    +            default:
    +                break;
    +        }
    +    }
    +}
    +
    +
    +uint32_t waveNeedsPreset(const WaveBankT *bank, uint16_t bankNum, uint8_t program, uint8_t *need) {
    +    return markPreset(bank, waveBankPreset(bank, bankNum, program), -1, -1, need);
    +}
    diff --git a/src/libs/kpunch/music/musicWaveSoft.c b/src/libs/kpunch/music/musicWaveSoft.c
    new file mode 100644
    index 00000000..f77df3bd
    --- /dev/null
    +++ b/src/libs/kpunch/music/musicWaveSoft.c
    @@ -0,0 +1,260 @@
    +// 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.
    +
    +// musicWaveSoft.c  -- SoundFont MIDI in software (see musicInt.h)
    +//
    +// DVX.INI [audio] midi = softwave: musicWave.c's General MIDI on voices
    +// mixed here instead of a chip's, for a sound card that only plays
    +// sampled sound.  A song's samples are read into memory (16-bit, at
    +// their own rates: no card memory to fit) and stay with the music.  A
    +// voice steps through its sample by 2^(pitch / 1200) x 44,100 / the
    +// output rate, interpolating between two frames; its attenuation and pan
    +// become two gains (the pan as a balance: both full at the centre).  The
    +// envelopes and LFOs are musicWave.c's, sent as pitch and volume updates.
    +// Program time only (the music's stream fill), so nothing here is pinned.
    +
    +#include "musicInt.h"
    +
    +#include 
    +#include 
    +
    +#define ATTEN_CENTS_NUM     19932   // 2^(-cB x 19.932 / 1200) = 10^(-cB / 200)
    +#define ATTEN_CENTS_DEN     1000
    +#define FIX_SHIFT           16
    +#define FIX_MASK            0xFFFF
    +#define FRAC_HALF_SHIFT     1       // the fraction as 15 bits for the multiply
    +#define PAN_HALF            500
    +#define RENDER_CHUNK        64
    +#define SAMPLE_MAX          32767
    +#define SAMPLE_MIN          (-32768)
    +#define STEREO              2
    +#define BITS_16             16
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool     softActive(void *owner, int32_t voice);
    +static int32_t  softAlloc(void *owner);
    +static void     softFree(void *owner, int32_t voice);
    +static void     softGains(WaveSoftVoiceT *v, const AudioWtVoiceT *note);
    +static void     softRelease(void *owner, int32_t voice);
    +static void     softStart(void *owner, int32_t voice, const AudioWtVoiceT *v);
    +static uint32_t softStep(const WaveSoftT *soft, int32_t pitch);
    +static void     softUpdate(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +void            waveSoftBackend(WaveSoftT *soft, uint32_t rate, WaveBackendT *out);
    +void            waveSoftFree(WaveSoftT *soft);
    +int32_t         waveSoftLoad(WaveSoftT *soft, const WaveBankT *bank, WaveLoadedT *loaded, const uint8_t *need);
    +void            waveSoftRender(WaveSoftT *soft, int16_t *out, uint32_t frames, uint32_t channels);
    +
    +
    +static bool softActive(void *owner, int32_t voice) {
    +    return ((WaveSoftT *)owner)->voices[voice - 1].playing;
    +}
    +
    +
    +// Voices are numbered from 1 (0 is no voice).
    +static int32_t softAlloc(void *owner) {
    +    WaveSoftT *s = (WaveSoftT *)owner;
    +
    +    for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +        if (!s->voices[i].used) {
    +            s->voices[i].used    = true;
    +            s->voices[i].playing = false;
    +            return i + 1;
    +        }
    +    }
    +
    +    return AUDIO_ERR_NO_VOICE;
    +}
    +
    +
    +static void softFree(void *owner, int32_t voice) {
    +    WaveSoftVoiceT *v = &((WaveSoftT *)owner)->voices[voice - 1];
    +
    +    v->used    = false;
    +    v->playing = false;
    +}
    +
    +
    +// The attenuation as a gain, the pan as a balance.
    +static void softGains(WaveSoftVoiceT *v, const AudioWtVoiceT *note) {
    +    int32_t gain = (int32_t)(wavePow2(-note->attenuation * ATTEN_CENTS_NUM / ATTEN_CENTS_DEN) >> (FIX_SHIFT - WAVE_SOFT_GAIN_SHIFT));
    +    int32_t pan  = note->pan < -PAN_HALF ? -PAN_HALF : (note->pan > PAN_HALF ? PAN_HALF : note->pan);
    +
    +    v->gainL = pan > 0 ? gain * (PAN_HALF - pan) / PAN_HALF : gain;
    +    v->gainR = pan < 0 ? gain * (PAN_HALF + pan) / PAN_HALF : gain;
    +}
    +
    +
    +// A loop that lasts while the key is down ends: the sample plays out.
    +static void softRelease(void *owner, int32_t voice) {
    +    WaveSoftVoiceT *v = &((WaveSoftT *)owner)->voices[voice - 1];
    +
    +    if (v->loop == AUDIO_WT_LOOP_RELEASE) {
    +        v->loop = AUDIO_WT_LOOP_NONE;
    +    }
    +}
    +
    +
    +// The note's sample is in the arena at addr (16-bit frames).
    +static void softStart(void *owner, int32_t voice, const AudioWtVoiceT *v) {
    +    WaveSoftT      *s     = (WaveSoftT *)owner;
    +    WaveSoftVoiceT *sv    = &s->voices[voice - 1];
    +    uint32_t        first = v->addr / sizeof(int16_t);
    +
    +    sv->pos       = first + v->offset;
    +    sv->frac      = 0;
    +    sv->step      = softStep(s, v->pitch);
    +    sv->loopStart = first + v->loopStart;
    +    sv->loopEnd   = first + v->loopEnd;
    +    sv->end       = first + v->end;
    +    sv->loop      = v->loopEnd > v->loopStart ? v->loop : AUDIO_WT_LOOP_NONE;
    +    sv->playing   = v->end > 0;
    +    softGains(sv, v);
    +}
    +
    +
    +// 2^(pitch / 1200) x 44,100 / the output rate, 16.16.
    +static uint32_t softStep(const WaveSoftT *soft, int32_t pitch) {
    +    return wavePow2(pitch + soft->rateCents);
    +}
    +
    +
    +static void softUpdate(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    WaveSoftT      *s  = (WaveSoftT *)owner;
    +    WaveSoftVoiceT *sv = &s->voices[voice - 1];
    +
    +    if (what & AUDIO_WT_SET_PITCH) {
    +        sv->step = softStep(s, v->pitch);
    +    }
    +
    +    if (what & (AUDIO_WT_SET_VOLUME | AUDIO_WT_SET_PAN)) {
    +        softGains(sv, v);
    +    }
    +}
    +
    +
    +// Program time: the output rate's cents worked out here, not as notes
    +// start in the mixer.
    +void waveSoftBackend(WaveSoftT *soft, uint32_t rate, WaveBackendT *out) {
    +    soft->rateCents = waveRatioCents(AUDIO_WT_PITCH_RATE, rate);
    +
    +    out->alloc   = softAlloc;
    +    out->free    = softFree;
    +    out->start   = softStart;
    +    out->update  = softUpdate;
    +    out->release = softRelease;
    +    out->active  = softActive;
    +    out->owner   = soft;
    +    out->flags   = 0;
    +}
    +
    +
    +void waveSoftFree(WaveSoftT *soft) {
    +    free(soft->arena);
    +    soft->arena  = NULL;
    +    soft->frames = 0;
    +}
    +
    +
    +// Each needed sample not there yet: its frames and the guard frames
    +// after it (as many as the bank has), at the end of the arena.  A ROM
    +// sample has no data here: its notes go to the FM synthesizer.
    +int32_t waveSoftLoad(WaveSoftT *soft, const WaveBankT *bank, WaveLoadedT *loaded, const uint8_t *need) {
    +    for (uint32_t i = 0; i < bank->sampleCount; i++) {
    +        const WaveSampleT *s = &bank->samples[i];
    +
    +        if (!need[i] || (s->type & SF_SAMPLE_ROM) || loaded[i].addr != WAVE_NOT_LOADED) {
    +            continue;
    +        }
    +
    +        uint32_t guard  = bank->dataFrames - s->end < WAVE_GUARD_FRAMES ? bank->dataFrames - s->end : WAVE_GUARD_FRAMES;
    +        uint32_t frames = s->end - s->start + guard;
    +        int16_t *grown  = (int16_t *)realloc(soft->arena, (size_t)(soft->frames + frames) * sizeof(int16_t));
    +
    +        if (!grown) {
    +            return MUSIC_ERR_NO_MEMORY;
    +        }
    +
    +        soft->arena = grown;
    +
    +        if (!waveBankRead(bank, s->start, frames, soft->arena + soft->frames)) {
    +            return MUSIC_ERR_OPEN;
    +        }
    +
    +        loaded[i].addr      = soft->frames * sizeof(int16_t);
    +        loaded[i].frames    = frames;
    +        loaded[i].rateCents = waveRatioCents(s->rate, AUDIO_WT_PITCH_RATE);
    +        loaded[i].bits      = BITS_16;
    +        loaded[i].shift     = 0;
    +        soft->frames       += frames;
    +    }
    +
    +    return MUSIC_OK;
    +}
    +
    +
    +// A chunk at a time: every playing voice into 32-bit sums, then added to
    +// out and clipped.
    +void waveSoftRender(WaveSoftT *soft, int16_t *out, uint32_t frames, uint32_t channels) {
    +    int32_t acc[RENDER_CHUNK * STEREO];
    +
    +    for (uint32_t done = 0; done < frames;) {
    +        uint32_t n = frames - done < RENDER_CHUNK ? frames - done : RENDER_CHUNK;
    +
    +        memset(acc, 0, sizeof(acc));
    +
    +        for (int32_t i = 0; i < WAVE_VOICES_MAX; i++) {
    +            WaveSoftVoiceT *v = &soft->voices[i];
    +
    +            for (uint32_t f = 0; f < n && v->playing; f++) {
    +                int32_t s0 = soft->arena[v->pos];
    +                int32_t s1 = soft->arena[v->pos + 1];
    +                int32_t s  = s0 + (((s1 - s0) * (int32_t)(v->frac >> FRAC_HALF_SHIFT)) >> (FIX_SHIFT - FRAC_HALF_SHIFT));
    +
    +                acc[f * STEREO]     += (s * v->gainL) >> WAVE_SOFT_GAIN_SHIFT;
    +                acc[f * STEREO + 1] += (s * v->gainR) >> WAVE_SOFT_GAIN_SHIFT;
    +                v->frac += v->step & FIX_MASK;
    +                v->pos  += (v->step >> FIX_SHIFT) + (v->frac >> FIX_SHIFT);
    +                v->frac &= FIX_MASK;
    +
    +                if (v->loop != AUDIO_WT_LOOP_NONE && v->pos >= v->loopEnd) {
    +                    v->pos -= v->loopEnd - v->loopStart;
    +                } else if (v->pos >= v->end) {
    +                    v->playing = false;
    +                }
    +            }
    +        }
    +
    +        for (uint32_t f = 0; f < n; f++) {
    +            for (uint32_t c = 0; c < channels; c++) {
    +                int16_t *o   = &out[(done + f) * channels + c];
    +                int32_t  sum = *o + (channels > 1 ? acc[f * STEREO + c] : (acc[f * STEREO] + acc[f * STEREO + 1]) / STEREO);
    +
    +                *o = (int16_t)(sum > SAMPLE_MAX ? SAMPLE_MAX : (sum < SAMPLE_MIN ? SAMPLE_MIN : sum));
    +            }
    +        }
    +
    +        done += n;
    +    }
    +}
    diff --git a/src/libs/kpunch/music/musicXmp.c b/src/libs/kpunch/music/musicXmp.c
    index 95104843..b092866f 100644
    --- a/src/libs/kpunch/music/musicXmp.c
    +++ b/src/libs/kpunch/music/musicXmp.c
    @@ -22,11 +22,15 @@
     
     // musicXmp.c  -- tracker modules (MOD, S3M, XM, IT) through libxmp-lite
     //
    -// libxmp-lite (thirdparty/libxmp-lite, unmodified) loads the module from
    -// memory and renders 16-bit frames at the stream's rate with linear
    -// interpolation; the stream's fill function calls xmpRender at program
    -// time (libxmp uses floating point).  xmp_play_buffer's loop count ends
    -// a song that does not loop after one play.
    +// libxmp-lite (thirdparty/libxmp-lite, with DVX's patches) loads the
    +// module from memory and renders 16-bit frames at the stream's rate with
    +// linear interpolation; the stream's fill function calls xmpRender at
    +// program time (libxmp uses floating point).  xmp_play_buffer's loop
    +// count ends a song that does not loop after one play.  On a wavetable
    +// synthesizer (musicModHw.c) libxmp mixes nothing instead and reports its
    +// voices as every frame starts (xmpHwOn, the hardware voice patch); the
    +// loop count ends a song there the same way.  This is the only file that
    +// includes libxmp's header.
     
     #include "musicInt.h"
     #include "xmp.h"
    @@ -43,6 +47,10 @@
     struct XmpPlayerT {
         xmp_context ctx;
         uint32_t    channels;
    +    uint32_t    rate;
    +    int32_t     voices;         // libxmp's voices before xmpHwOn
    +    XmpHwFnT    hwFn;           // NULL: rendering
    +    void       *hwUser;
         char        title[MUSIC_TITLE_MAX];
     };
     
    @@ -50,16 +58,38 @@ struct XmpPlayerT {
     // Prototypes
     // ============================================================
     
    +uint32_t    xmpBidiAdjust(XmpPlayerT *player);
    +uint32_t    xmpChannels(XmpPlayerT *player);
     void        xmpClose(XmpPlayerT *player);
    +bool        xmpHwFrame(XmpPlayerT *player, bool loop, uint32_t *us);
    +static void xmpHwHook(void *user, const struct xmp_hw_voice *v);
    +void        xmpHwOff(XmpPlayerT *player);
    +bool        xmpHwOn(XmpPlayerT *player, uint32_t voices, XmpHwFnT fn, void *user);
     uint32_t    xmpLength(XmpPlayerT *player);
     int32_t     xmpOpen(const uint8_t *data, uint32_t size, uint32_t rate, uint32_t channels, XmpPlayerT **out);
     uint32_t    xmpPosition(XmpPlayerT *player);
     bool        xmpRender(XmpPlayerT *player, int16_t *out, uint32_t frames, bool loop);
     void        xmpRestart(XmpPlayerT *player);
    +bool        xmpSample(XmpPlayerT *player, uint32_t i, XmpSampleT *out);
    +uint32_t    xmpSampleCount(XmpPlayerT *player);
     void        xmpSeek(XmpPlayerT *player, uint32_t ms);
    +static bool xmpStart(XmpPlayerT *player);
     const char *xmpTitle(XmpPlayerT *player);
     
     
    +uint32_t xmpBidiAdjust(XmpPlayerT *player) {
    +    return (uint32_t)xmp_hw_bidir_adjust(player->ctx);
    +}
    +
    +
    +uint32_t xmpChannels(XmpPlayerT *player) {
    +    struct xmp_module_info mi;
    +
    +    xmp_get_module_info(player->ctx, &mi);
    +    return mi.mod ? (uint32_t)mi.mod->chn : 0;
    +}
    +
    +
     void xmpClose(XmpPlayerT *player) {
         if (!player) {
             return;
    @@ -72,6 +102,75 @@ void xmpClose(XmpPlayerT *player) {
     }
     
     
    +bool xmpHwFrame(XmpPlayerT *player, bool loop, uint32_t *us) {
    +    struct xmp_frame_info fi;
    +
    +    int rc = xmp_play_frame(player->ctx);
    +
    +    xmp_get_frame_info(player->ctx, &fi);
    +    *us = fi.frame_time > 0 ? (uint32_t)fi.frame_time : 0;
    +    return rc == 0 && (loop || fi.loop_count == 0);
    +}
    +
    +
    +// libxmp's voice as XmpHwVoiceT, to the music library's function.
    +static void xmpHwHook(void *user, const struct xmp_hw_voice *v) {
    +    XmpPlayerT  *p = (XmpPlayerT *)user;
    +    XmpHwVoiceT  hv;
    +
    +    hv.pos       = v->pos;
    +    hv.rate      = v->rate;
    +    hv.voice     = (uint32_t)v->voice;
    +    hv.sample    = (uint32_t)v->sample;
    +    hv.trigger   = v->trigger;
    +    hv.loopStart = v->loop_start > 0 ? (uint32_t)v->loop_start : 0;
    +    hv.loopEnd   = v->loop_end > 0 ? (uint32_t)v->loop_end : 0;
    +    hv.vol       = v->vol;
    +    hv.pan       = v->pan;
    +    hv.loop      = v->loop == XMP_HW_LOOP_BIDIR ? XMP_VOICE_LOOP_PINGPONG : (v->loop == XMP_HW_LOOP_FORWARD ? XMP_VOICE_LOOP_FORWARD : XMP_VOICE_LOOP_NONE);
    +    hv.surround  = v->surround != 0;
    +    hv.reverse   = v->reverse != 0;
    +    p->hwFn(p->hwUser, &hv);
    +}
    +
    +
    +// Back to rendering, where the song was.
    +void xmpHwOff(XmpPlayerT *player) {
    +    uint32_t ms = xmpPosition(player);
    +
    +    player->hwFn = NULL;
    +    xmp_set_hw_voices(player->ctx, NULL, NULL);
    +    xmp_end_player(player->ctx);
    +    xmp_set_player(player->ctx, XMP_PLAYER_VOICES, player->voices);
    +    xmpStart(player);
    +    xmpSeek(player, ms);
    +}
    +
    +
    +// Started again (where the song was) with the voices and the hook: the
    +// voice count is set before libxmp starts playing.
    +bool xmpHwOn(XmpPlayerT *player, uint32_t voices, XmpHwFnT fn, void *user) {
    +    uint32_t ms = xmpPosition(player);
    +
    +    player->hwFn   = fn;
    +    player->hwUser = user;
    +    xmp_end_player(player->ctx);
    +    xmp_set_player(player->ctx, XMP_PLAYER_VOICES, (int)voices);
    +    xmp_set_hw_voices(player->ctx, xmpHwHook, player);
    +
    +    if (!xmpStart(player)) {
    +        xmpHwOff(player);
    +        return false;
    +    }
    +
    +    // Nothing is mixed: no interpolation spares libxmp copying each
    +    // loop's ends at every turn of it
    +    xmp_set_player(player->ctx, XMP_PLAYER_INTERP, XMP_INTERP_NEAREST);
    +    xmpSeek(player, ms);
    +    return true;
    +}
    +
    +
     uint32_t xmpLength(XmpPlayerT *player) {
         struct xmp_frame_info fi;
     
    @@ -91,6 +190,7 @@ int32_t xmpOpen(const uint8_t *data, uint32_t size, uint32_t rate, uint32_t chan
     
         p->ctx      = xmp_create_context();
         p->channels = channels;
    +    p->rate     = rate;
     
         if (!p->ctx) {
             free(p);
    @@ -112,14 +212,14 @@ int32_t xmpOpen(const uint8_t *data, uint32_t size, uint32_t rate, uint32_t chan
             return rc == -XMP_ERROR_SYSTEM ? MUSIC_ERR_NO_MEMORY : MUSIC_ERR_CORRUPT;
         }
     
    -    if (xmp_start_player(p->ctx, (int)rate, channels == 1 ? XMP_FORMAT_MONO : 0) != 0) {
    +    if (!xmpStart(p)) {
             xmp_release_module(p->ctx);
             xmp_free_context(p->ctx);
             free(p);
             return MUSIC_ERR_NO_MEMORY;
         }
     
    -    xmp_set_player(p->ctx, XMP_PLAYER_INTERP, XMP_INTERP_LINEAR);
    +    p->voices = xmp_get_player(p->ctx, XMP_PLAYER_VOICES);
     
         struct xmp_module_info mi;
     
    @@ -153,12 +253,59 @@ void xmpRestart(XmpPlayerT *player) {
     }
     
     
    +bool xmpSample(XmpPlayerT *player, uint32_t i, XmpSampleT *out) {
    +    struct xmp_module_info mi;
    +
    +    xmp_get_module_info(player->ctx, &mi);
    +
    +    if (!mi.mod || i >= (uint32_t)mi.mod->smp) {
    +        return false;
    +    }
    +
    +    const struct xmp_sample *x = &mi.mod->xxs[i];
    +
    +    if (!x->data || x->len <= 0 || (x->flg & XMP_SAMPLE_SYNTH)) {
    +        return false;
    +    }
    +
    +    out->data      = x->data;
    +    out->frames    = (uint32_t)x->len;
    +    out->loopStart = x->lps > 0 ? (uint32_t)x->lps : 0;
    +    out->loopEnd   = x->lpe > x->lps && x->lpe <= x->len ? (uint32_t)x->lpe : out->loopStart;
    +    out->bits16    = (x->flg & XMP_SAMPLE_16BIT) != 0;
    +    out->loops     = (x->flg & XMP_SAMPLE_LOOP) && out->loopEnd > out->loopStart;
    +    out->bidi      = out->loops && (x->flg & XMP_SAMPLE_LOOP_BIDIR);
    +    out->stereo    = (x->flg & XMP_SAMPLE_STEREO) != 0;
    +    return true;
    +}
    +
    +
    +uint32_t xmpSampleCount(XmpPlayerT *player) {
    +    struct xmp_module_info mi;
    +
    +    xmp_get_module_info(player->ctx, &mi);
    +    return mi.mod ? (uint32_t)mi.mod->smp : 0;
    +}
    +
    +
     void xmpSeek(XmpPlayerT *player, uint32_t ms) {
         xmp_seek_time(player->ctx, (int)ms);
         xmp_play_buffer(player->ctx, NULL, 0, 0);
     }
     
     
    +// The player started at the stream's rate and format, linear
    +// interpolation (what xmp_start_player resets is set again).
    +static bool xmpStart(XmpPlayerT *player) {
    +    if (xmp_start_player(player->ctx, (int)player->rate, player->channels == 1 ? XMP_FORMAT_MONO : 0) != 0) {
    +        return false;
    +    }
    +
    +    xmp_set_player(player->ctx, XMP_PLAYER_INTERP, XMP_INTERP_LINEAR);
    +    return true;
    +}
    +
    +
     const char *xmpTitle(XmpPlayerT *player) {
         return player->title;
     }
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/DVX-VERSION.md b/src/libs/kpunch/music/thirdparty/libxmp-lite/DVX-VERSION.md
    index 0f51b70f..f33df121 100644
    --- a/src/libs/kpunch/music/thirdparty/libxmp-lite/DVX-VERSION.md
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/DVX-VERSION.md
    @@ -6,9 +6,9 @@
     | Version | 4.7.3 (released 2026-09-11) |
     | Download | https://github.com/libxmp/libxmp/releases/download/libxmp-4.7.3/libxmp-lite-4.7.3.tar.gz |
     | Tarball SHA-256 | e199da3f7552f5ac688a091b8d28ea3c9ffd2beb845d15aac17d4d264f851d71 (262,671 bytes) |
    -| Files kept | src/*.c, src/*.h, src/loaders/*.c, src/loaders/*.h, include/libxmp-lite/xmp.h (as include/xmp.h), README (it holds the licence), test/test.it and test/test.xm (used by the host tests) -- byte for byte as in the tarball. Not kept: the build files (configure, CMake, the Makefiles, including src/Makefile and src/loaders/Makefile), jni, the rest of test/. |
    +| Files kept | src/*.c, src/*.h, src/loaders/*.c, src/loaders/*.h, include/libxmp-lite/xmp.h (as include/xmp.h), README (it holds the licence), test/test.it and test/test.xm (used by the host tests) -- byte for byte as in the tarball but for the patches below. Not kept: the build files (configure, CMake, the Makefiles, including src/Makefile and src/loaders/Makefile), jni, the rest of test/. |
     | Licence | MIT ("Extended Module Player Lite, Copyright (C) 1996-2026 Claudio Matsuoka and Hipolito Carraro Jr", in README). Keep the notice with the source; reproduced in the music library's developer help (topic lib.music) for binaries. |
    -| Patches | None |
    +| Patches | dvx-patches/0001-hardware-voices.patch (see Patches) |
     
     ## Build options
     
    @@ -27,11 +27,38 @@ loaded from memory (`xmp_load_module_from_memory`); `src/filetype.c`
     (DJGPP branch) calls `_chmod`, which the DVX loader exports for it
     (dvxPlatformDos.c), so the file is built unchanged.
     
    +## Patches
    +
    +Each is a unified diff in `dvx-patches/`, applied from this folder with
    +`patch -p1 < dvx-patches/`; every change is marked "DVX
    +patch" in the sources.
    +
    +- `0001-hardware-voices.patch` -- `xmp_set_hw_voices(ctx, fn, user)`
    +  (include/xmp.h, src/control.c, src/common.h): with a hook set, the
    +  software mixer (src/mixer.c) mixes nothing and renders nothing, but
    +  keeps every voice's position, loops, and sample ends as it would; as
    +  each frame starts it calls the hook once for every voice sounding
    +  (`struct xmp_hw_voice`: the sample, its position, its rate in frames a
    +  second, its volume 0..1024, pan, whether it plays backwards, the loop
    +  it is in (none, forward, or ping-pong, `XMP_HW_LOOP_*`) and that
    +  loop's bounds, and `trigger`, a count src/mixer.h's `hw_trigger` keeps
    +  of the times libxmp started or moved it, from `libxmp_mixer_voicepos`).
    +  With the hook set the voices only move on (`hw_advance`, src/mixer.c:
    +  to each loop or sample end at once and whole turns of a forward loop
    +  at once, with the mixer's handling of sample ends, ping-pong turns and
    +  Protracker sample swaps), which keeps short loops from costing a turn
    +  of the mixer's loop each.  `xmp_hw_bidir_adjust(ctx)` says how many
    +  frames the loaded module's ping-pong loops are shortened by on the way
    +  back (Impulse Tracker's 1).  music.lib (musicModHw.c) plays modules on
    +  a wavetable synthesizer's voices that way.
    +
     ## Updating
     
     1. Download the new libxmp-lite tarball, check it against the published
        release, record the version and SHA-256 here.
    -2. Replace src/, include/xmp.h, README and the two test modules.
    +2. Replace src/, include/xmp.h, README and the two test modules, then
    +   apply each patch in dvx-patches/ (in order) and fix any that no longer
    +   applies, keeping the "DVX patch" marks.
     3. Rebuild music.lib; run the music host suite (`make -C src/test/music`,
        `UPDATE_GOLDENS=1` if the module render hashes change after a check by
        ear) and the music DOS scripts (src/test/dos/scripts/mus*.scr).
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/dvx-patches/0001-hardware-voices.patch b/src/libs/kpunch/music/thirdparty/libxmp-lite/dvx-patches/0001-hardware-voices.patch
    new file mode 100644
    index 00000000..9e7fa1ff
    --- /dev/null
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/dvx-patches/0001-hardware-voices.patch
    @@ -0,0 +1,238 @@
    +--- a/include/xmp.h
    ++++ b/include/xmp.h
    +@@ -343,6 +343,31 @@
    + 	struct xmp_channel_info channel_info[XMP_MAX_CHANNELS];		/* Current channel information */
    + };
    + 
    ++/* DVX patch (hardware voices): with a hook set, the software mixer mixes
    ++ * nothing; it keeps every voice's position, loops and sample ends as it
    ++ * would, and as each frame starts reports each voice sounding, for a
    ++ * caller that plays them on a synthesizer's own voices. */
    ++struct xmp_hw_voice {
    ++	int voice;			/* Mixer voice */
    ++	int sample;			/* Module sample (xxs index) */
    ++	unsigned int trigger;		/* Changes when the voice starts or moves */
    ++	double pos;			/* Sample frames at the frame's start */
    ++	double rate;			/* Sample frames per second */
    ++	int vol;			/* Volume, 0 to 1024 */
    ++	int pan;			/* Pan, -128 (left) to 127 (right) */
    ++	int surround;			/* Surround (pan meaningless) */
    ++	int reverse;			/* Playing backwards (a ping-pong loop) */
    ++	int loop;			/* The loop it is in: XMP_HW_LOOP_* */
    ++	int loop_start;			/* ... its first frame */
    ++	int loop_end;			/* ... one past its last (no loop: the end) */
    ++};
    ++
    ++#define XMP_HW_LOOP_NONE	0	/* DVX patch: plays to loop_end and stops */
    ++#define XMP_HW_LOOP_FORWARD	1
    ++#define XMP_HW_LOOP_BIDIR	2	/* ping-pong (see xmp_hw_bidir_adjust) */
    ++
    ++typedef void (*xmp_hw_voice_fn)(void *, const struct xmp_hw_voice *);
    ++
    + struct xmp_callbacks {
    + 	unsigned long	(*read_func)(void *dest, unsigned long len,
    + 				     unsigned long nmemb, void *priv);
    +@@ -397,6 +422,8 @@
    + LIBXMP_EXPORT int         xmp_channel_mute    (xmp_context, int, int);
    + LIBXMP_EXPORT int         xmp_channel_vol     (xmp_context, int, int);
    + LIBXMP_EXPORT int         xmp_set_player      (xmp_context, int, int);
    ++LIBXMP_EXPORT int         xmp_set_hw_voices   (xmp_context, xmp_hw_voice_fn, void *);	/* DVX patch */
    ++LIBXMP_EXPORT int         xmp_hw_bidir_adjust (xmp_context);	/* DVX patch */
    + LIBXMP_EXPORT int         xmp_get_player      (xmp_context, int);
    + LIBXMP_EXPORT int         xmp_set_instrument_path (xmp_context, const char *);
    + 
    +--- a/src/common.h
    ++++ b/src/common.h
    +@@ -670,6 +670,8 @@
    + 	int dtleft;		/* anticlick control, left channel */
    + 	int bidir_adjust;	/* adjustment for IT bidirectional loops */
    + 	double pbase;		/* period base */
    ++	xmp_hw_voice_fn hw_voice;	/* DVX patch: hardware voices (NULL: mix) */
    ++	void *hw_user;		/* DVX patch: its argument */
    + };
    + 
    + struct rng_state {
    +--- a/src/control.c
    ++++ b/src/control.c
    +@@ -412,6 +412,33 @@
    + #define xmp_set_player__ xmp_set_player
    + #endif
    + 
    ++/* DVX patch: hardware voices (see xmp.h); a NULL fn mixes again. */
    ++int xmp_set_hw_voices(xmp_context opaque, xmp_hw_voice_fn fn, void *user)
    ++{
    ++	struct context_data *ctx = (struct context_data *)opaque;
    ++	struct mixer_data *s = &ctx->s;
    ++
    ++	s->hw_voice = fn;
    ++	s->hw_user = user;
    ++	return 0;
    ++}
    ++
    ++/* DVX patch: the frames a ping-pong loop's way back is shortened by in
    ++ * the loaded module (Impulse Tracker's 1, else 0): a loop of n frames
    ++ * takes 2n minus this to play there and back. */
    ++int xmp_hw_bidir_adjust(xmp_context opaque)
    ++{
    ++#ifndef LIBXMP_CORE_DISABLE_IT
    ++	struct context_data *ctx = (struct context_data *)opaque;
    ++	struct module_data *m = &ctx->m;
    ++
    ++	return IS_PLAYER_MODE_IT() ? 1 : 0;
    ++#else
    ++	(void)opaque;
    ++	return 0;
    ++#endif
    ++}
    ++
    + int xmp_set_player__(xmp_context opaque, int parm, int val)
    + {
    + 	struct context_data *ctx = (struct context_data *)opaque;
    +--- a/src/mixer.c
    ++++ b/src/mixer.c
    +@@ -421,6 +421,67 @@
    + 	adjust_voice_end(ctx, vi, *xxs, *xtra);
    + }
    + 
    ++/* DVX patch: hardware voices: the voice moved on by frames sample frames
    ++ * as the software mixer's loop moves it, with nothing mixed: to each loop
    ++ * or sample end at once, whole turns of a forward loop at once. */
    ++static void hw_advance(struct context_data *ctx, int voc, struct mixer_voice *vi,
    ++		       struct xmp_sample **xxs, struct extra_sample_data **xtra,
    ++		       int *c5spd, double frames)
    ++{
    ++	struct player_data *p = &ctx->p;
    ++	struct module_data *m = &ctx->m;
    ++	int split_noloop = p->xc_data[vi->chn].split != 0;
    ++	int stuck = 0;
    ++
    ++	while (frames > 0) {
    ++		int reverse = (vi->flags & VOICE_REVERSE) != 0;
    ++		double room = reverse ? vi->pos - vi->start : vi->end - vi->pos;
    ++
    ++		if (room > frames) {
    ++			vi->pos += reverse ? -frames : frames;
    ++			return;
    ++		}
    ++
    ++		if (room > 0) {
    ++			vi->pos += reverse ? -room : room;
    ++			frames -= room;
    ++		} else if (++stuck > 1) {
    ++			/* a loop of no length */
    ++			return;
    ++		}
    ++
    ++		/* One-shot samples do not loop. */
    ++		if ((!has_active_loop(ctx, vi, *xxs) || split_noloop) &&
    ++		    !(vi->flags & SAMPLE_QUEUED)) {
    ++			set_sample_end(ctx, voc, 1);
    ++			return;
    ++		}
    ++
    ++		/* Protracker sample swap */
    ++		if (vi->flags & SAMPLE_QUEUED) {
    ++			if (vi->queued.smp < 0 ||
    ++			    (!has_active_loop(ctx, vi, *xxs) &&
    ++			     !(m->mod.xxs[vi->queued.smp].flg & XMP_SAMPLE_LOOP))) {
    ++				vi->flags &= ~SAMPLE_QUEUED;
    ++				vi->flags |= SAMPLE_PAUSED;
    ++				set_sample_end(ctx, voc, 1);
    ++				return;
    ++			}
    ++			hotswap_sample(ctx, vi, voc, vi->queued.smp);
    ++			get_current_sample(ctx, vi, xxs, xtra, c5spd);
    ++			vi->pos = vi->start;
    ++			continue;
    ++		}
    ++
    ++		if ((vi->flags & SAMPLE_LOOP) && (~vi->flags & VOICE_BIDIR) &&
    ++		    vi->end > vi->start) {
    ++			frames = fmod(frames, vi->end - vi->start);
    ++		}
    ++
    ++		loop_reposition(ctx, vi, *xxs, *xtra);
    ++	}
    ++}
    ++
    + /* Calculate the required number of sample frames to render a tick.
    +  * Returns -1 if any of the parameters are invalid. */
    + int libxmp_mixer_get_ticksize(int freq, double time_factor, double rrate, int bpm)
    +@@ -461,6 +522,11 @@
    + 		s->ticksize = s->total_size / 2;
    + 	}
    + 
    ++	/* DVX patch: hardware voices mix nothing */
    ++	if (s->hw_voice != NULL) {
    ++		return;
    ++	}
    ++
    + 	bytelen = s->ticksize * sizeof(int32);
    + 	if (~s->format & XMP_FORMAT_MONO) {
    + 		bytelen *= 2;
    +@@ -592,6 +658,35 @@
    + 
    + 		init_sample_wraparound(s, &loop_data, vi, xxs);
    + 
    ++		/* DVX patch: the voice as the frame starts, for the caller's
    ++		 * synthesizer (one that ends in the frame plays out there) */
    ++		if (s->hw_voice != NULL && (vi->fidx & FLAG_ACTIVE)) {
    ++			struct xmp_hw_voice hv;
    ++
    ++			hv.voice = voc;
    ++			hv.sample = vi->smp;
    ++			hv.trigger = vi->hw_trigger;
    ++			hv.pos = vi->pos;
    ++			hv.rate = step * s->freq;
    ++			hv.vol = vol;
    ++			hv.pan = vi->pan == PAN_SURROUND ? 0 : vi->pan;
    ++			hv.surround = vi->pan == PAN_SURROUND;
    ++			hv.reverse = (vi->flags & VOICE_REVERSE) != 0;
    ++			hv.loop = !has_active_loop(ctx, vi, xxs) ? XMP_HW_LOOP_NONE : (vi->flags & VOICE_BIDIR) ? XMP_HW_LOOP_BIDIR : XMP_HW_LOOP_FORWARD;
    ++			hv.loop_start = vi->start;
    ++			hv.loop_end = vi->end;
    ++			s->hw_voice(s->hw_user, &hv);
    ++		}
    ++
    ++		/* DVX patch: hardware voices only move on */
    ++		if (s->hw_voice != NULL) {
    ++			reset_sample_wraparound(&loop_data);
    ++			hw_advance(ctx, voc, vi, &xxs, &xtra, &c5spd, step * s->ticksize);
    ++			vi->old_vl = vol_l;
    ++			vi->old_vr = vol_r;
    ++			continue;
    ++		}
    ++
    + 		rampsize = s->ticksize >> ANTICLICK_SHIFT;
    + 		delta_l = (vol_l - vi->old_vl) / rampsize;
    + 		delta_r = (vol_r - vi->old_vr) / rampsize;
    +@@ -761,6 +856,11 @@
    + 		vi->old_vr = vol_r;
    + 	}
    + 
    ++	/* DVX patch: hardware voices render nothing */
    ++	if (s->hw_voice != NULL) {
    ++		return;
    ++	}
    ++
    + 	/* Render final frame */
    + 
    + 	size = s->ticksize;
    +@@ -819,6 +919,7 @@
    + 	}
    + 
    + 	vi->pos = pos;
    ++	vi->hw_trigger++;	/* DVX patch */
    + 
    + 	adjust_voice_end(ctx, vi, xxs, xtra);
    + 
    +--- a/src/mixer.h
    ++++ b/src/mixer.h
    +@@ -46,6 +46,7 @@
    + #define SAMPLE_PAUSED	(1 << 6)
    + 	int flags;		/* flags */
    + 	void *sptr;		/* sample pointer */
    ++	unsigned int hw_trigger;	/* DVX patch: counts starts and moves */
    + #ifdef LIBXMP_PAULA_SIMULATOR
    + 	struct paula_state *paula; /* paula simulation state */
    + #endif
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/include/xmp.h b/src/libs/kpunch/music/thirdparty/libxmp-lite/include/xmp.h
    index 9f7aa781..65d7fef4 100644
    --- a/src/libs/kpunch/music/thirdparty/libxmp-lite/include/xmp.h
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/include/xmp.h
    @@ -343,6 +343,31 @@ struct xmp_frame_info {			/* Current frame information */
     	struct xmp_channel_info channel_info[XMP_MAX_CHANNELS];		/* Current channel information */
     };
     
    +/* DVX patch (hardware voices): with a hook set, the software mixer mixes
    + * nothing; it keeps every voice's position, loops and sample ends as it
    + * would, and as each frame starts reports each voice sounding, for a
    + * caller that plays them on a synthesizer's own voices. */
    +struct xmp_hw_voice {
    +	int voice;			/* Mixer voice */
    +	int sample;			/* Module sample (xxs index) */
    +	unsigned int trigger;		/* Changes when the voice starts or moves */
    +	double pos;			/* Sample frames at the frame's start */
    +	double rate;			/* Sample frames per second */
    +	int vol;			/* Volume, 0 to 1024 */
    +	int pan;			/* Pan, -128 (left) to 127 (right) */
    +	int surround;			/* Surround (pan meaningless) */
    +	int reverse;			/* Playing backwards (a ping-pong loop) */
    +	int loop;			/* The loop it is in: XMP_HW_LOOP_* */
    +	int loop_start;			/* ... its first frame */
    +	int loop_end;			/* ... one past its last (no loop: the end) */
    +};
    +
    +#define XMP_HW_LOOP_NONE	0	/* DVX patch: plays to loop_end and stops */
    +#define XMP_HW_LOOP_FORWARD	1
    +#define XMP_HW_LOOP_BIDIR	2	/* ping-pong (see xmp_hw_bidir_adjust) */
    +
    +typedef void (*xmp_hw_voice_fn)(void *, const struct xmp_hw_voice *);
    +
     struct xmp_callbacks {
     	unsigned long	(*read_func)(void *dest, unsigned long len,
     				     unsigned long nmemb, void *priv);
    @@ -397,6 +422,8 @@ LIBXMP_EXPORT int         xmp_seek_time_frame (xmp_context, int);
     LIBXMP_EXPORT int         xmp_channel_mute    (xmp_context, int, int);
     LIBXMP_EXPORT int         xmp_channel_vol     (xmp_context, int, int);
     LIBXMP_EXPORT int         xmp_set_player      (xmp_context, int, int);
    +LIBXMP_EXPORT int         xmp_set_hw_voices   (xmp_context, xmp_hw_voice_fn, void *);	/* DVX patch */
    +LIBXMP_EXPORT int         xmp_hw_bidir_adjust (xmp_context);	/* DVX patch */
     LIBXMP_EXPORT int         xmp_get_player      (xmp_context, int);
     LIBXMP_EXPORT int         xmp_set_instrument_path (xmp_context, const char *);
     
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/common.h b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/common.h
    index 324ed65e..042bafaf 100644
    --- a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/common.h
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/common.h
    @@ -670,6 +670,8 @@ struct mixer_data {
     	int dtleft;		/* anticlick control, left channel */
     	int bidir_adjust;	/* adjustment for IT bidirectional loops */
     	double pbase;		/* period base */
    +	xmp_hw_voice_fn hw_voice;	/* DVX patch: hardware voices (NULL: mix) */
    +	void *hw_user;		/* DVX patch: its argument */
     };
     
     struct rng_state {
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/control.c b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/control.c
    index bcbaf370..9412de9b 100644
    --- a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/control.c
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/control.c
    @@ -412,6 +412,33 @@ LIBXMP_END_DECLS
     #define xmp_set_player__ xmp_set_player
     #endif
     
    +/* DVX patch: hardware voices (see xmp.h); a NULL fn mixes again. */
    +int xmp_set_hw_voices(xmp_context opaque, xmp_hw_voice_fn fn, void *user)
    +{
    +	struct context_data *ctx = (struct context_data *)opaque;
    +	struct mixer_data *s = &ctx->s;
    +
    +	s->hw_voice = fn;
    +	s->hw_user = user;
    +	return 0;
    +}
    +
    +/* DVX patch: the frames a ping-pong loop's way back is shortened by in
    + * the loaded module (Impulse Tracker's 1, else 0): a loop of n frames
    + * takes 2n minus this to play there and back. */
    +int xmp_hw_bidir_adjust(xmp_context opaque)
    +{
    +#ifndef LIBXMP_CORE_DISABLE_IT
    +	struct context_data *ctx = (struct context_data *)opaque;
    +	struct module_data *m = &ctx->m;
    +
    +	return IS_PLAYER_MODE_IT() ? 1 : 0;
    +#else
    +	(void)opaque;
    +	return 0;
    +#endif
    +}
    +
     int xmp_set_player__(xmp_context opaque, int parm, int val)
     {
     	struct context_data *ctx = (struct context_data *)opaque;
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.c b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.c
    index 804f5021..6f5ee819 100644
    --- a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.c
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.c
    @@ -421,6 +421,67 @@ static void get_current_sample(struct context_data *ctx, struct mixer_voice *vi,
     	adjust_voice_end(ctx, vi, *xxs, *xtra);
     }
     
    +/* DVX patch: hardware voices: the voice moved on by frames sample frames
    + * as the software mixer's loop moves it, with nothing mixed: to each loop
    + * or sample end at once, whole turns of a forward loop at once. */
    +static void hw_advance(struct context_data *ctx, int voc, struct mixer_voice *vi,
    +		       struct xmp_sample **xxs, struct extra_sample_data **xtra,
    +		       int *c5spd, double frames)
    +{
    +	struct player_data *p = &ctx->p;
    +	struct module_data *m = &ctx->m;
    +	int split_noloop = p->xc_data[vi->chn].split != 0;
    +	int stuck = 0;
    +
    +	while (frames > 0) {
    +		int reverse = (vi->flags & VOICE_REVERSE) != 0;
    +		double room = reverse ? vi->pos - vi->start : vi->end - vi->pos;
    +
    +		if (room > frames) {
    +			vi->pos += reverse ? -frames : frames;
    +			return;
    +		}
    +
    +		if (room > 0) {
    +			vi->pos += reverse ? -room : room;
    +			frames -= room;
    +		} else if (++stuck > 1) {
    +			/* a loop of no length */
    +			return;
    +		}
    +
    +		/* One-shot samples do not loop. */
    +		if ((!has_active_loop(ctx, vi, *xxs) || split_noloop) &&
    +		    !(vi->flags & SAMPLE_QUEUED)) {
    +			set_sample_end(ctx, voc, 1);
    +			return;
    +		}
    +
    +		/* Protracker sample swap */
    +		if (vi->flags & SAMPLE_QUEUED) {
    +			if (vi->queued.smp < 0 ||
    +			    (!has_active_loop(ctx, vi, *xxs) &&
    +			     !(m->mod.xxs[vi->queued.smp].flg & XMP_SAMPLE_LOOP))) {
    +				vi->flags &= ~SAMPLE_QUEUED;
    +				vi->flags |= SAMPLE_PAUSED;
    +				set_sample_end(ctx, voc, 1);
    +				return;
    +			}
    +			hotswap_sample(ctx, vi, voc, vi->queued.smp);
    +			get_current_sample(ctx, vi, xxs, xtra, c5spd);
    +			vi->pos = vi->start;
    +			continue;
    +		}
    +
    +		if ((vi->flags & SAMPLE_LOOP) && (~vi->flags & VOICE_BIDIR) &&
    +		    vi->end > vi->start) {
    +			frames = fmod(frames, vi->end - vi->start);
    +		}
    +
    +		loop_reposition(ctx, vi, *xxs, *xtra);
    +	}
    +}
    +
     /* Calculate the required number of sample frames to render a tick.
      * Returns -1 if any of the parameters are invalid. */
     int libxmp_mixer_get_ticksize(int freq, double time_factor, double rrate, int bpm)
    @@ -461,6 +522,11 @@ static void libxmp_mixer_prepare(struct context_data *ctx)
     		s->ticksize = s->total_size / 2;
     	}
     
    +	/* DVX patch: hardware voices mix nothing */
    +	if (s->hw_voice != NULL) {
    +		return;
    +	}
    +
     	bytelen = s->ticksize * sizeof(int32);
     	if (~s->format & XMP_FORMAT_MONO) {
     		bytelen *= 2;
    @@ -592,6 +658,35 @@ void libxmp_mixer_softmixer(struct context_data *ctx)
     
     		init_sample_wraparound(s, &loop_data, vi, xxs);
     
    +		/* DVX patch: the voice as the frame starts, for the caller's
    +		 * synthesizer (one that ends in the frame plays out there) */
    +		if (s->hw_voice != NULL && (vi->fidx & FLAG_ACTIVE)) {
    +			struct xmp_hw_voice hv;
    +
    +			hv.voice = voc;
    +			hv.sample = vi->smp;
    +			hv.trigger = vi->hw_trigger;
    +			hv.pos = vi->pos;
    +			hv.rate = step * s->freq;
    +			hv.vol = vol;
    +			hv.pan = vi->pan == PAN_SURROUND ? 0 : vi->pan;
    +			hv.surround = vi->pan == PAN_SURROUND;
    +			hv.reverse = (vi->flags & VOICE_REVERSE) != 0;
    +			hv.loop = !has_active_loop(ctx, vi, xxs) ? XMP_HW_LOOP_NONE : (vi->flags & VOICE_BIDIR) ? XMP_HW_LOOP_BIDIR : XMP_HW_LOOP_FORWARD;
    +			hv.loop_start = vi->start;
    +			hv.loop_end = vi->end;
    +			s->hw_voice(s->hw_user, &hv);
    +		}
    +
    +		/* DVX patch: hardware voices only move on */
    +		if (s->hw_voice != NULL) {
    +			reset_sample_wraparound(&loop_data);
    +			hw_advance(ctx, voc, vi, &xxs, &xtra, &c5spd, step * s->ticksize);
    +			vi->old_vl = vol_l;
    +			vi->old_vr = vol_r;
    +			continue;
    +		}
    +
     		rampsize = s->ticksize >> ANTICLICK_SHIFT;
     		delta_l = (vol_l - vi->old_vl) / rampsize;
     		delta_r = (vol_r - vi->old_vr) / rampsize;
    @@ -761,6 +856,11 @@ void libxmp_mixer_softmixer(struct context_data *ctx)
     		vi->old_vr = vol_r;
     	}
     
    +	/* DVX patch: hardware voices render nothing */
    +	if (s->hw_voice != NULL) {
    +		return;
    +	}
    +
     	/* Render final frame */
     
     	size = s->ticksize;
    @@ -819,6 +919,7 @@ void libxmp_mixer_voicepos(struct context_data *ctx, int voc, double pos, int ac
     	}
     
     	vi->pos = pos;
    +	vi->hw_trigger++;	/* DVX patch */
     
     	adjust_voice_end(ctx, vi, xxs, xtra);
     
    diff --git a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.h b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.h
    index a582b1f0..6f09dd17 100644
    --- a/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.h
    +++ b/src/libs/kpunch/music/thirdparty/libxmp-lite/src/mixer.h
    @@ -46,6 +46,7 @@ struct mixer_voice {
     #define SAMPLE_PAUSED	(1 << 6)
     	int flags;		/* flags */
     	void *sptr;		/* sample pointer */
    +	unsigned int hw_trigger;	/* DVX patch: counts starts and moves */
     #ifdef LIBXMP_PAULA_SIMULATOR
     	struct paula_state *paula; /* paula simulation state */
     #endif
    diff --git a/src/libs/kpunch/pkg/pkg.dhs b/src/libs/kpunch/pkg/pkg.dhs
    index 3919fda2..b66fa399 100644
    --- a/src/libs/kpunch/pkg/pkg.dhs
    +++ b/src/libs/kpunch/pkg/pkg.dhs
    @@ -45,7 +45,7 @@ Header: pkg/pkg.h (it includes the others below)
     
     Loaded as: bin/libs/kpunch/pkg/pkg.lib
     
    -BASIC: pkg.bas (sessions, offers, plans, install and remove jobs, building, signing, and keys).
    +BASIC: pkg.bas (sessions, offers, plans, install and remove jobs, building, signing, and keys; PkgFileOwner$ is pkgDbOwner).
     
     .h2 A Package
     
    @@ -57,7 +57,7 @@ format    = 1
     id        = acme.chess          ; vendor.name, each 1..8 of a-z 0-9 _
     name      = Chess
     version   = 1.2.0
    -kind      = app                 ; app library widget font bank theme wallpaper sound help bundle system
    +kind      = app                 ; app library widget font bank theme wallpaper sound saver help bundle system
     summary   = Play chess against the computer.
     publisher = Acme Games          ; also author, licence, homepage
     dvx       = 1.0.0               ; the oldest DVX it runs on
    @@ -78,6 +78,7 @@ Every file is listed with its size and SHA-256 and every listed file is in the p
       font, bank            SHARED/FONTS/VENDOR, SHARED/BANKS/VENDOR
       theme, wallpaper      SHARED/THEMES/VENDOR, SHARED/WPAPER/VENDOR
       sound                 SHARED/SOUNDS/VENDOR
    +  saver                 SHARED/SAVERS/VENDOR (a screen saver: a program, so it needs abi)
       bundle                no files: a set of packages, its dependencies
       system                DVX itself (kpunch.dvx): anywhere but the user folder
     .endtable
    diff --git a/src/libs/kpunch/pkg/pkgBas.c b/src/libs/kpunch/pkg/pkgBas.c
    index 9c1d35a2..96ef0eb0 100644
    --- a/src/libs/kpunch/pkg/pkgBas.c
    +++ b/src/libs/kpunch/pkg/pkgBas.c
    @@ -99,6 +99,7 @@ int32_t                 PkgDvxAbi(void);
     const char             *PkgDvxVersion(void);
     const char             *PkgError(int32_t s);
     const char             *PkgField(int32_t s, const char *id, int32_t field);
    +const char             *PkgFileOwner(int32_t s, const char *fileName);
     const char             *PkgFilesRows(int32_t s, const char *id);
     const char             *PkgFolderRows(int32_t s, const char *folder, const char *prefix);
     const char             *PkgIdPart(const char *text);
    @@ -512,6 +513,16 @@ const char *PkgField(int32_t s, const char *id, int32_t field) {
     }
     
     
    +// The id of the installed package that put fileName there (relative to
    +// the DVX folder, either separator, any case), or "".
    +const char *PkgFileOwner(int32_t s, const char *fileName) {
    +    SessionT            *ss    = sessionOf(s);
    +    const PkgInstalledT *owner = ss && fileName ? pkgDbOwner(&ss->db, fileName, NULL) : NULL;
    +
    +    return basTextSet(&sText, owner ? owner->manifest.id : "");
    +}
    +
    +
     // The files of the installed package id.  Rows: name, size.
     const char *PkgFilesRows(int32_t s, const char *id) {
         SessionT            *ss   = sessionOf(s);
    diff --git a/src/libs/kpunch/pkg/pkgBas.h b/src/libs/kpunch/pkg/pkgBas.h
    index 1b2eb354..2c73bd94 100644
    --- a/src/libs/kpunch/pkg/pkgBas.h
    +++ b/src/libs/kpunch/pkg/pkgBas.h
    @@ -67,6 +67,7 @@ int32_t     PkgDvxAbi(void);
     const char *PkgDvxVersion(void);
     const char *PkgError(int32_t s);
     const char *PkgField(int32_t s, const char *id, int32_t field);
    +const char *PkgFileOwner(int32_t s, const char *fileName);
     const char *PkgFilesRows(int32_t s, const char *id);
     const char *PkgFolderRows(int32_t s, const char *folder, const char *prefix);
     const char *PkgIdPart(const char *text);
    diff --git a/src/libs/kpunch/pkg/pkgCli.c b/src/libs/kpunch/pkg/pkgCli.c
    index fc05ca50..9fef22f3 100644
    --- a/src/libs/kpunch/pkg/pkgCli.c
    +++ b/src/libs/kpunch/pkg/pkgCli.c
    @@ -83,7 +83,7 @@ static int   cmdVerify(int argc, char *argv[]);
     static bool  loadKey(const char *path, PkgKeyT *key);
     static bool  loadSecret(const char *path, PkgSecretT *secret);
     static bool  loadSig(const char *path, PkgSigT *sig);
    -static bool  parseOpts(int argc, char *argv[], CliOptsT *o);
    +static bool  parseOpts(int argc, char *const argv[], CliOptsT *o);
     int          pkgCliRun(int argc, char *argv[], PkgRandomFnT random, bool *handled);
     void         pkgCliUsage(FILE *f);
     static int   runOp(PkgOpT *op, const char *name);
    @@ -516,7 +516,7 @@ static bool loadSig(const char *path, PkgSigT *sig) {
     
     // install / remove / list's options; false for anything else (or a second
     // argument).
    -static bool parseOpts(int argc, char *argv[], CliOptsT *o) {
    +static bool parseOpts(int argc, char *const argv[], CliOptsT *o) {
         memset(o, 0, sizeof(*o));
         o->root = "";
     
    diff --git a/src/libs/kpunch/pkg/pkgDpk.c b/src/libs/kpunch/pkg/pkgDpk.c
    index 0459b591..1cfc3c68 100644
    --- a/src/libs/kpunch/pkg/pkgDpk.c
    +++ b/src/libs/kpunch/pkg/pkgDpk.c
    @@ -271,7 +271,7 @@ bool pkgDpkCheckFiles(const char *path, const PkgDpkInfoT *info, char *err, int3
         PkgZipStepE         step  = PkgZipFailE;
     
         if (!ok) {
    -        ok = fail(err, errSize, "Cannot read %s.", path);
    +        fail(err, errSize, "Cannot read %s.", path);
         }
     
         while (ok && (step = pkgZipNext(zip, &e)) == PkgZipEntryE) {
    @@ -442,7 +442,11 @@ bool pkgDpkSign(const char *path, const PkgSecretT *secret, bool detached, char
     
         pkgSign(secret, (const uint8_t *)info.text, info.textLen, &sig);
         sigText = pkgSigFormat(&sig, &sigLen);
    -    ok      = sigText != NULL || fail(err, errSize, "There is not enough memory.");
    +    ok      = sigText != NULL;
    +
    +    if (!ok) {
    +        fail(err, errSize, "There is not enough memory.");
    +    }
     
         if (ok && !siblingPath(path, detached ? PKG_SIG_EXT : TEMP_EXT, other, sizeof(other))) {
             ok = fail(err, errSize, "The path of %s is too long.", path);
    diff --git a/src/libs/kpunch/pkg/pkgManifest.c b/src/libs/kpunch/pkg/pkgManifest.c
    index 91e28cd0..6bc36a75 100644
    --- a/src/libs/kpunch/pkg/pkgManifest.c
    +++ b/src/libs/kpunch/pkg/pkgManifest.c
    @@ -92,7 +92,7 @@ static const char *const sPackageKeys[KeyCountE] = { "format", "id", "name", "ve
     #define REQUIRED_KEYS ((1u << KeyFormatE) | (1u << KeyIdE) | (1u << KeyNameE) | (1u << KeyVersionE) | (1u << KeyKindE))
     
     // Kind names (PkgKindE order)
    -static const char *const sKindNames[PkgKindCountE] = { "app", "library", "widget", "font", "bank", "theme", "wallpaper", "sound", "help", "bundle", "system" };
    +static const char *const sKindNames[PkgKindCountE] = { "app", "library", "widget", "font", "bank", "theme", "wallpaper", "sound", "saver", "help", "bundle", "system" };
     
     // Each kind's folders; help goes in its program's folder, or beside a
     // library or widget of its name (pkgFileAllowed); bundle has none; system
    @@ -106,6 +106,7 @@ static const KindFolderT sKindFolders[PkgKindCountE] = {
         [PkgKindThemeE]     = { DVX_SHARED_DIR "/" DVX_THEMES_DIR, false },
         [PkgKindWallpaperE] = { DVX_SHARED_DIR "/" DVX_WPAPER_DIR, false },
         [PkgKindSoundE]     = { DVX_SHARED_DIR "/" DVX_SOUNDS_DIR, false },
    +    [PkgKindSaverE]     = { DVX_SHARED_DIR "/" DVX_SAVERS_DIR, false },
         [PkgKindHelpE]      = { DVX_APPS_DIR,                     true  },
         [PkgKindBundleE]    = { NULL,                             false },
         [PkgKindSystemE]    = { NULL,                             false }
    @@ -485,7 +486,7 @@ bool pkgKindFolder(PkgKindE kind, const char *id, char *out, int32_t outSize) {
     
     
     bool pkgKindIsCode(PkgKindE kind) {
    -    return kind == PkgKindAppE || kind == PkgKindLibraryE || kind == PkgKindWidgetE;
    +    return kind == PkgKindAppE || kind == PkgKindLibraryE || kind == PkgKindWidgetE || kind == PkgKindSaverE;
     }
     
     
    diff --git a/src/libs/kpunch/pkg/pkgManifest.h b/src/libs/kpunch/pkg/pkgManifest.h
    index 26603482..c575f0d8 100644
    --- a/src/libs/kpunch/pkg/pkgManifest.h
    +++ b/src/libs/kpunch/pkg/pkgManifest.h
    @@ -90,6 +90,7 @@ typedef enum {
         PkgKindThemeE,
         PkgKindWallpaperE,
         PkgKindSoundE,
    +    PkgKindSaverE,          // screen savers (programs in SAVERS)
         PkgKindHelpE,
         PkgKindBundleE,         // no files: a set of packages (its dependencies)
         PkgKindSystemE,         // DVX itself
    diff --git a/src/libs/kpunch/pkg/pkgOp.c b/src/libs/kpunch/pkg/pkgOp.c
    index 11e51d3c..ca7029b9 100644
    --- a/src/libs/kpunch/pkg/pkgOp.c
    +++ b/src/libs/kpunch/pkg/pkgOp.c
    @@ -366,7 +366,7 @@ PkgOpT *pkgOpInstall(PkgDbT *db, const char *path, const PkgKeyringT *ring, bool
     
         // Every new file is unpacked before any old one goes
         if (platformDiskSpace(db->root[0] ? db->root : ".", &diskTotal, &diskFree) && diskFree < op->total) {
    -        opFail(op, "There is not enough room on the disk: %s needs %lu KB and %lu KB are free.", op->manifest.name, (unsigned long)(op->total / OP_KB + 1), (unsigned long)(diskFree / OP_KB));
    +        opFail(op, "There is not enough room on the disk: %s needs %lu KB and %lu KB are free.", op->manifest.name, (unsigned long)op->total / OP_KB + 1, (unsigned long)diskFree / OP_KB);
             return op;
         }
     
    diff --git a/src/libs/kpunch/pkg/pkgPlan.c b/src/libs/kpunch/pkg/pkgPlan.c
    index 8c6c995e..2d8aabdc 100644
    --- a/src/libs/kpunch/pkg/pkgPlan.c
    +++ b/src/libs/kpunch/pkg/pkgPlan.c
    @@ -133,7 +133,6 @@ static bool checkOffer(PlanT *p, int32_t i, PkgActE *act) {
         const PkgOfferT     *o    = &p->offers[i];
         const PkgManifestT  *m    = &o->manifest;
         const PkgInstalledT *inst = pkgDbFind(p->db, m->id);
    -    char                 need[PKG_VERSION_MAX];
         char                 have[PKG_VERSION_MAX];
     
         if (pkgTrustIsDvxId(m->id) && o->trust != PkgTrustSystemE) {
    @@ -141,6 +140,8 @@ static bool checkOffer(PlanT *p, int32_t i, PkgActE *act) {
         }
     
         if (pkgVersionCompare(m->dvx, p->dvx) > 0) {
    +        char need[PKG_VERSION_MAX];
    +
             pkgVersionFormat(m->dvx, need, sizeof(need));
             pkgVersionFormat(p->dvx, have, sizeof(have));
             return fail(p, "%s needs DVX %s or later (this is DVX %s).", m->name, need, have);
    @@ -287,13 +288,10 @@ bool pkgOfferRead(const char *path, const PkgKeyringT *ring, PkgOfferT *offer, c
         offer->hasSigner = signer != NULL;
         offer->hasNamed  = signer != NULL || info.hasSig || hasDetached;
     
    +    // The key the signature names: the trusted one (the signer too), else
    +    // the package's own signature's, else the detached one's
         if (signer) {
             memcpy(offer->signer, signer->id, PKG_KEY_ID_SIZE);
    -    }
    -
    -    // The key the signature names: the trusted one, else the package's own
    -    // signature's, else the detached one's
    -    if (signer) {
             memcpy(offer->named, signer->id, PKG_KEY_ID_SIZE);
         } else if (info.hasSig) {
             memcpy(offer->named, info.sig.keyId, PKG_KEY_ID_SIZE);
    diff --git a/src/libs/kpunch/pkgnet/pkgNet.c b/src/libs/kpunch/pkgnet/pkgNet.c
    index 55c3a7a5..fe5b22ab 100644
    --- a/src/libs/kpunch/pkgnet/pkgNet.c
    +++ b/src/libs/kpunch/pkgnet/pkgNet.c
    @@ -238,9 +238,7 @@ static void accountAnswer(PkgNetT *n, uint32_t nowMs) {
     // An account's job: sign in when there is no session, else send what the
     // job sends (an upload, the list of the account's versions).
     static void accountNext(PkgNetT *n, uint32_t nowMs) {
    -    char    auth[AUTH_MAX];
    -    char   *data = NULL;
    -    int32_t len  = 0;
    +    char auth[AUTH_MAX];
     
         if (!n->token[0]) {
             loginStart(n, nowMs);
    @@ -250,7 +248,8 @@ static void accountNext(PkgNetT *n, uint32_t nowMs) {
         snprintf(auth, sizeof(auth), "%s%s", BEARER, n->token);
     
         if (n->job == JobUploadE) {
    -        data = platformReadFile(n->upload, &len);
    +        int32_t len  = 0;
    +        char   *data = platformReadFile(n->upload, &len);
     
             if (!data) {
                 failed(n, "%s cannot be read.", n->upload);
    diff --git a/src/libs/kpunch/print/print.dep b/src/libs/kpunch/print/print.dep
    index e064b831..5517cb67 100644
    --- a/src/libs/kpunch/print/print.dep
    +++ b/src/libs/kpunch/print/print.dep
    @@ -1,2 +1,3 @@
     libdvx
     libtasks
    +serial
    diff --git a/src/libs/kpunch/print/print.dhs b/src/libs/kpunch/print/print.dhs
    index 8578c940..dd7ceaf9 100644
    --- a/src/libs/kpunch/print/print.dhs
    +++ b/src/libs/kpunch/print/print.dhs
    @@ -28,12 +28,21 @@
     .index printJobOpen
     .index printJobWrite
     .index printTextJob
    -.index printRasterJob
    +.index printDocOpen
    +.index printDocPage
    +.index printDocPicture
    +.index printUiDocOpen
    +.index printModelPage
    +.index printFontOpen
    +.index PrintFontT
    +.index Printer Fonts
    +.index PrintDocT
    +.index printVecBegin
    +.index Vector Pages
     .index printDialog
     .index printPageSetupDialog
     .index printUiText
     .index PrintJobT
    -.index PrintDriverT
     .index INT 17h
     
     .h1 Printing Library
    @@ -46,7 +55,7 @@ Loaded as: bin/libs/kpunch/print/print.lib
     
     .h2 Settings
     
    -The printer is chosen once for everything, in DVX.INI [printer] (the Control Panel's Printer page and the Page Setup dialog edit it): where output goes (PrintDestE: LPT1, LPT2, LPT3, or a file), the printer model (PrintModelE: text only, Epson 9-pin, Epson 24-pin, HP LaserJet), and the page setup (PrintPageSetupT).
    +The printer is chosen once for everything, in DVX.INI [printer] (the Control Panel's Printer page and the Page Setup dialog edit it): where output goes (PrintDestE: LPT1 to LPT3, COM1 to COM4, or a file), a serial port's speed and handshake (baud, PrintHandshakeE), the printer model (PrintModelE: text only; Epson 9-pin or 24-pin, each with a black or a color ribbon; HP LaserJet at 150 dpi; HP LaserJet III or DeskJet at 300 dpi; HP DeskJet 500C; HP DeskJet 550C and later; PostScript, black or color; Apple ImageWriter II, black or color), and the page setup (PrintPageSetupT).
     
     .table
       Function                                   Description
    @@ -65,7 +74,7 @@ The printer is chosen once for everything, in DVX.INI [printer] (the Control Pan
     
     .h2 Jobs and Printer Problems
     
    -A job sends bytes to a printer port or into a file. On DOS a port is driven through the BIOS printer service (INT 17h); a byte is sent only when a status check says the printer is ready, so the BIOS never waits by itself. While the printer is busy the job calls the idle hook; a printer busy for PRINT_BUSY_TIMEOUT_MS (10 seconds), out of paper, off line, or reporting an error is a problem, and so is a file that cannot be written (disk full). A problem goes to the ask hook: Retry tries again, Cancel ends the job and every later write fails at once.
    +A job sends bytes to a printer port or into a file. On DOS a parallel port is driven through the BIOS printer service (INT 17h); a byte is sent only when a status check says the printer is ready, so the BIOS never waits by itself. A serial port (COM1 to COM4, for serial printers such as the ImageWriter II) is opened through the serial library when the job starts, at the speed and handshake in the settings (8 data bits, no parity, 1 stop bit); its interrupt handler sends from a buffer as the printer's handshake allows (XON / XOFF, or the printer's DTR seen on the computer's CTS, or on its DSR as Apple's cable wires it), the port is busy while the buffer is well filled, and when the job closes it waits for the buffer to empty before closing the port. A PrintPortOpsT's open, pending, and close (NULL for none) are these steps. While the printer is busy the job calls the idle hook; a printer busy for PRINT_BUSY_TIMEOUT_MS (10 seconds), out of paper, off line, or reporting an error is a problem, and so is a file that cannot be written (disk full). A problem goes to the ask hook: Retry tries again, Cancel ends the job and every later write fails at once.
     
     .code
     PrintHooksT hooks = { myAsk, myIdle, NULL, myState };
    @@ -92,25 +101,122 @@ printJobWrite returns once every byte was taken, or false when the job is cancel
     
     .h2 Text
     
    -printTextJob(job, model, text, len, &pageSetup, &info) prints plain text on pages. Lines end with LF or CR LF; a form feed in the text starts a new page. Each printed line starts with marginLeft blanks and ends with CR LF, tabs are expanded to tabWidth, other control characters print as blanks, and a line longer than the text width is wrapped at the last blank before it (a word longer than a whole line is split) or, with wrap off, cut off. Every page ends with a form feed. For the Epson and LaserJet models the printer's reset command comes first (and, for the LaserJet, last).
    +printTextJob(job, model, text, len, &pageSetup, &info) prints plain text on pages. Lines end with LF or CR LF; a form feed in the text starts a new page. Each printed line starts with marginLeft blanks and ends with CR LF, tabs are expanded to tabWidth, other control characters print as blanks, and a line longer than the text width is wrapped at the last blank before it (a word longer than a whole line is split) or, with wrap off, cut off. Every page ends with a form feed. For the Epson and LaserJet models the printer's reset command comes first (and, for the LaserJet, last). A PostScript printer prints no plain text, so on the PostScript models the pages are PostScript: Courier as large as lets the page setup's columns fill 8 inches and its lines 10, a show per line (CP437 letters and signs in ISO Latin-1; line and block characters as -, |, +, and #).
     
     A page is marginTop lines with the header in their middle line, the text, then marginBottom lines with the footer in their middle line. Header and footer codes: &f file name (PrintTextInfoT.title), &p page number, &n number of pages, &d date, &t time (PrintTextInfoT.date / time, or today and now), && an ampersand; &l, &c, and &r put what follows at the left, centre, or right (text before any of them is centred). printFormatHeader expands a header for one page.
     
     PrintTextInfoT also chooses a page range (firstPage, lastPage; 0 = all) and the number of copies. printTextPageCount returns the number of pages text takes.
     
    -.h2 Pictures and Printer Drivers
    +.h2 Documents: Pictures and Drawn Pages
     
    -printRasterJob(job, driver, rgb, w, h, pitch) prints a picture of R, G, B bytes on one page: scaled to the largest size that fits the printable area (8 by 10 inches) keeping its shape, and dithered to black and white with a 4x4 ordered pattern. The driver gets one band of dot rows at a time, so only one band is ever in memory. printDriver(model) returns the driver of a model (NULL for text only).
    +A document is pages of dot graphics on a picture-printing model. printDocOpen(job, model) starts one on a job (the document owns the job from then on, and printDocClose closes it). Each page is the whole printable area (8 by 10 inches): printDocPage(doc, fn, ctx) asks the program's PrintBandFnT for it a band at a time, top down, as R, G, B bytes at the printer's resolution (the band comes white), and printDocPicture(doc, rgb, w, h, pitch) prints a picture scaled to the largest size that fits, keeping its shape. Only one band of the page is ever in memory, so a page costs the same however large it is.
     
     .table
    -  Model                  Driver
    -  -----                  ------
    -  Epson 9-pin (ESC/P)    120 x 72 dots per inch (ESC * 1), 8-row bands, paper fed with ESC J; blank bands only feed paper, and each pass stops after its last black dot.
    -  Epson 24-pin (ESC/P)   180 x 180 dots per inch (ESC * 39, 3 bytes per column), 24-row bands.
    -  HP LaserJet (PCL)      150 dots per inch raster graphics, one uncompressed row transfer per dot row (every LaserJet understands it), trailing white bytes left out.
    +  Function                                   Description
    +  --------                                   -----------
    +  printModelPage(model, &info)               The page a model prints on (PrintPageInfoT: dots per inch across and down, the printable area in dots, color or not, and vector: a PostScript printer, which can draw the page itself); false for text only. info may be NULL to ask only whether the model prints pictures.
    +  printDocOpen(job, model)                   Start a document; NULL (the job closed) for a text-only model.
    +  printDocInfo(doc, &info)                   The document's page.
    +  printDocPage(doc, fn, ctx)                 Print a page the program draws. fn(ctx, rgb, width, y0, rows) fills rows y0 to y0 + rows - 1; false from it stops the page.
    +  printDocPicture(doc, rgb, w, h, pitch)     Print a picture as a page.
    +  printDocCancelled(doc)                     True when the job was cancelled.
    +  printDocClose(doc)                         End the document and close its job; true when every page printed. After a failure every later page fails at once.
    +  printUiDocOpen(ctx, title, &choice)        A document on the printer in the settings, at a Print dialog answer's destination (NULL: the settings'), with the Printing window up until it closes and the Retry box; tells the user why when it cannot open. Copies are the program's: print each page as often as wanted.
    +  printUiDocClose(ctx, doc)                  Close it, telling the user of a failure (not of a cancel).
     .endtable
     
    -A PrintDriverT holds the resolution, the printable size in dots, the band height, the text reset strings and usual page length, and begin / band / end functions that write the printer's commands; a new printer family is one more driver.
    +Each band is dithered to the printer's inks with an ordered pattern (4x4, 17 levels, on the dot-matrix printers and the 150 dpi LaserJet; 8x8, 65 levels, at 300 dpi). A black-only printer prints the brightness. A color printer prints cyan, magenta, and yellow from the red, green, and blue; with black ink as well, black takes what the three have in common (full undercolor removal). Each ink is dithered with its own offset of the pattern, so dots of different inks do not land on each other in a regular way, and the pattern follows the row's place on the page, so it runs on from band to band.
    +
    +.code
    +static bool drawBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    // a red bar one inch down, a quarter inch high
    +    const PrintPageInfoT *pi = (const PrintPageInfoT *)ctx;
    +
    +    for (int32_t r = 0; r < rows; r++) {
    +        int32_t y = y0 + r;
    +
    +        if (y >= pi->dpiY && y < pi->dpiY + pi->dpiY / 4) {
    +            for (int32_t x = 0; x < width; x++) {
    +                uint8_t *p = rgb + ((r * width) + x) * 3;
    +
    +                p[0] = 0xFF;
    +                p[1] = 0;
    +                p[2] = 0;
    +            }
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +PrintDocT     *doc = printUiDocOpen(ctx, "Report", NULL);
    +PrintPageInfoT pi;
    +
    +if (doc) {
    +    printDocInfo(doc, &pi);
    +    printDocPage(doc, drawBand, &pi);
    +    printUiDocClose(ctx, doc);
    +}
    +.endcode
    +
    +.h2 Vector Pages
    +
    +On a PostScript printer (PrintPageInfoT.vector) a page can be drawn by the printer itself, as lines, curves, fills, and its own letters, instead of being sent as dots: sharper at any size, and much smaller. printVecBegin(doc) starts such a page and printVecEnd(doc) prints it; in between, the drawing calls below go to the printer as they are made. Coordinates are the page's dots (y down, a dot's centre at whole numbers, so a shape covers the same dots it would in a band), colors are 0xRRGGBB (grey on the black-and-white model), and widths are in dots. printVecBegin is false, and the drawing calls do nothing, on a printer that cannot or a document that failed; a raster page (printDocPage, printDocPicture) is refused while a vector page is open, and printDocClose ends one left open.
    +
    +.table
    +  Function                                                     Description
    +  --------                                                     -----------
    +  printVecBegin(doc), printVecEnd(doc)                         Start a vector page; print it (false when it did not print).
    +  printVecLine(doc, x0, y0, x1, y1, color, width, dash)        A line, round at the ends; dash is a 16-step pattern from its top bit (0xFFFF solid), a step a dot along the line's longer side, as BASIC's LINE style.
    +  printVecRect(doc, x0, y0, x1, y1, color, fill)               A filled box, corners inclusive.
    +  printVecEllipseFill(doc, cx, cy, rx, ry, color, fill)        A filled ellipse, out to the dots rx and ry from its centre.
    +  printVecEllipse(doc, cx, cy, rx, ry, color, width, dash, arc, start, end)  An ellipse's outline, or (arc) the part from start to end radians counterclockwise; dash as printVecLine's, along the outline from angle 0.
    +  printVecDot(doc, x, y, color, width)                         A round dot width dots across.
    +  printVecText(doc, font, x, y, text, len, color)              Text with the top of its line at (x, y), with the font's underline and strike-through.
    +  printVecImage(doc, rgb, w, h, sx, sy, sw, sh, dx, dy, dw, dh)  The part sx, sy, sw, sh of a picture (R, G, B bytes) stretched over the dots dx, dy, dw, dh by the printer.
    +.endtable
    +
    +A fill (PrintFillE) is solid or a hatch of lines one dot wide every PRINT_HATCH_DOTS (8) dots, horizontal, vertical, either diagonal, or crossed, lined up across the page as BASIC's FillStyle hatches are. The pen (its color, width, and dashes) and the font are sent only when they change, and each of the printer's faces is made Latin-1 the first time the document uses it.
    +
    +A font opened for a vector page (printFontOpen with page.vector set) is the printer's own Helvetica, Times, or Courier in the style asked for (FONT_STYLE_BOLD, FONT_STYLE_ITALIC): Courier for a fixed-pitch family or one with Mono or Courier in its name, Times for one with Serif, Times, Roman, Tinos, or Ledger in it (but not Sans), else Helvetica. It is exactly the size asked for, and printFontWidth, printFontHeight (1.2 times the size), and printFontAscent measure the printer's letters by Adobe's published widths, so text placed with them lines up as it prints. It draws only with printVecText (printFontSpans and printFontDraw draw nothing with it); for DVX's own letters drawn in bands on a PostScript printer, open the font with vector cleared in a copy of the page.
    +
    +.h2 Text on a Page
    +
    +A page a program draws can carry text in any font family (dvxText.h) at a size in points. printFontOpen(&page, family, decipoints, style) gives a PrintFontT for a page's resolution (its PrintPageInfoT): the size is in tenths of a point (120 for 12 points), the style any FONT_STYLE_ bits, underline and strike-through included. The font is the family's smallest strike at least as large as the size in printer dots (12 points is 50 dots at 300 dpi, 30 at 180), else its largest, scaled to the size across and down apart, since a 9-pin printer's dots are not square. Scaling is nearest pixel; scaling down, every pixel row and column of the strike still marks a dot, so thin strokes stay.
    +
    +The base set's sizes go up to 24 pixels, so most printed sizes are scaled up from it and look blocky at 300 dpi. The Printer Fonts add-on (kpunch.prfonts) gives DejaVu Sans, Serif, and Sans Mono in every style at 25 to 100 pixels, about 6 to 24 points at 300 dpi and 10 to 40 at 180: installed, those families print crisp at those sizes with no change to the program.
    +
    +.table
    +  Function                                                    Description
    +  --------                                                    -----------
    +  printFontOpen(&page, family, decipoints, style)             The font; NULL when out of memory or for a size of 0 or less. printFontClose gives it up.
    +  printFontAscent(font), printFontHeight(font)                Dots from the top of a line to its baseline, and to the next line.
    +  printFontWidth(font, text, len)                             Dots across len characters of text.
    +  printFontSpans(font, x, y, text, len, top, bottom, fn, ctx) The text with the top of its line at (x, y) as runs of dots (PrintSpanFnT fn(ctx, x0, x1, y)), only rows top to bottom: a line that misses them is skipped at once, so a page drawn band by band costs little for the lines elsewhere.
    +  printFontDraw(font, x, y, text, len, color, rgb, width, y0, rows)  The same painted in color (0xRRGGBB) into the band a PrintBandFnT is filling.
    +.endtable
    +
    +Positions are worked out in the strike's pixels from the start of the text and only then turned into dots, so printFontWidth and what is drawn always agree.
    +
    +.h2 Printer Drivers
    +
    +.table
    +  Model                                  Driver
    +  -----                                  ------
    +  Epson 9-pin (ESC/P)                    120 x 72 dots per inch (ESC * 1), 8-row bands, paper fed with ESC J; blank bands only feed paper, and each pass stops after its last black dot.
    +  Epson 24-pin (ESC/P)                   180 x 180 dots per inch (ESC * 39, 3 bytes per column), 24-row bands.
    +  HP LaserJet (PCL)                      150 dots per inch raster graphics, one uncompressed row transfer per dot row (every LaserJet understands it), trailing white bytes left out.
    +  HP LaserJet III or DeskJet (300 dpi)   300 dots per inch (PCL 5, and PCL 3 on DeskJets): each row packed (compression mode 2, TIFF PackBits) and blank rows skipped (ESC * b n Y) instead of sent, so a page with white space on it costs next to nothing.
    +  Epson 9-pin / 24-pin color             As the black ones, with a four-color ribbon: a pass of the head for each ink with ESC r n choosing its band of the ribbon, yellow first and black last (dark ink does not smear into the light ones).
    +  HP DeskJet 500C                        As the 300 dpi LaserJet, in three planes (ESC * r -3 U: cyan, magenta, yellow; black is the three together): ESC * b n V for each plane of a row but the last, ESC * b n W for the last.
    +  HP DeskJet 550C and later              As the 500C, in four planes (ESC * r -4 U: black, cyan, magenta, yellow).
    +  PostScript                             Level 2 at 300 dpi: each band an imagemask of its black dots, packed (RunLengthDecode, the same PackBits) and in ASCII85, which any port carries; a band with no ink is not sent. The 8 by 10 inch area is centred across the paper the printer has loaded, half an inch from its top.
    +  PostScript (color)                     As PostScript, each band a colorimage of 4 bits a dot (cyan, magenta, yellow, black) from the dithered planes. Both PostScript models also draw vector pages (see Vector Pages).
    +  Apple ImageWriter II                   144 x 144 dots per inch: 144 across (ESC p), and down by two passes of the 8 graphics wires 1/144 inch apart (the even rows of a 16-row band, a 1/144-inch line feed, the odd rows, then ESC T 15 and a line feed). A line is ESC G nnnn and its columns (bit 0 the top dot), placed at its first dot (ESC F nnnn). The document resets the printer (ESC c) and sets its switches whatever its DIP switches say: no line feed after CR, 8-bit data, no perforation skip; then unidirectional printing (ESC >), which keeps the passes in line.
    +  Apple ImageWriter II (color)           As the ImageWriter II with the four-color ribbon: a line for each ink with ESC K n choosing its band, yellow first and black last, CR between them.
    +.endtable
    +
    +The drivers are inside the library: each holds its resolution, the printable size in dots, the band height, the dither size, its inks, the text reset strings and usual page length, and begin / band / end functions that write the printer's commands. A new printer family is one more driver.
     
     .h2 Dialogs and Ready-Made Printing
     
    @@ -130,7 +236,9 @@ Jobs opened by these ask about printer problems with a Retry / Cancel message bo
     
     .h2 BASIC
     
    -src/include/basic/print.bas declares PrintTextDialog, PrintText, PrintPictureDialog, PrintPicture, PrintPageSetup, and PrintCanPrintPictures. For the printer settings it also declares PrintModelCount / PrintModelLabel$, PrintDestCount / PrintDestLabel$ / PrintDestIsFile, PrintSettingModel / PrintSettingDest / PrintSettingFile$ (printSettingsRead on a prefs.bas handle, through prefsBasHandle), PrintSettingsProblem$ (printDeviceProblem), and PrintSettingsStore (printDeviceStore).
    +BASIC's Printer object (the form runtime, formPrinter.c) is built on the calls above: it records a page's drawing, prints it with printUiDocOpen / printDocPage (each band drawn again from the record), and prints text with printFontOpen and printFontDraw; on a PostScript printer it draws the record once more as a vector page (printVec*), in the printer's own faces; its document goes where the program's last PrintChoose chose (printBasChoice, the running program's own answer only).
    +
    +src/include/basic/print.bas declares PrintTextDialog, PrintText, PrintPictureDialog, PrintPicture, PrintPageSetup, PrintCanPrintPictures, and PrintCanPrintColor (printModelPage). For serial printers it declares PrintDestIsSerial (printDestIsSerial), PrintBaudRate (printBaudRate), PrintHandshakeCount / PrintHandshakeLabel$ (printHandshakeLabel), PrintSettingBaud / PrintSettingHandshake (printSettingsRead), and PrintSerialStore (printSerialStore). For the printer settings it also declares PrintModelCount / PrintModelLabel$, PrintDestCount / PrintDestLabel$ / PrintDestIsFile, PrintSettingModel / PrintSettingDest / PrintSettingFile$ (printSettingsRead on a prefs.bas handle, through prefsBasHandle), PrintSettingsProblem$ (printDeviceProblem), and PrintSettingsStore (printDeviceStore).
     
     .table
       BASIC                                             C it calls
    diff --git a/src/libs/kpunch/print/print.h b/src/libs/kpunch/print/print.h
    index 55812d80..7b32bdc6 100644
    --- a/src/libs/kpunch/print/print.h
    +++ b/src/libs/kpunch/print/print.h
    @@ -38,11 +38,12 @@
     //                    date, time, page number), tab expansion, word wrap
     //                    or cut-off at the right margin, a page eject after
     //                    every page, a page range and several copies.
    -//   Pictures         A small printer-driver interface turns a picture
    -//                    into dot graphics for Epson ESC/P 9-pin and 24-pin
    -//                    printers and HP LaserJet (PCL) printers: the picture
    -//                    is scaled to fit the page and dithered to black and
    -//                    white (printRasterJob).
    +//   Documents        Pages of pictures and drawings: a page is drawn a
    +//                    band at a time in RGB (or is a picture scaled to
    +//                    fit), dithered to the printer's inks, and sent as
    +//                    dot graphics to Epson ESC/P 9-pin and 24-pin
    +//                    printers (black or a color ribbon) or HP LaserJet
    +//                    and DeskJet (PCL) printers (printDocOpen).
     //   Settings         The printer model, port and page setup live in
     //                    DVX.INI [printer] (printSettingsLoad / Save).
     //   Dialogs          The common Print dialog (printer, copies, pages), the
    @@ -62,8 +63,17 @@
     #include 
     #include 
     
    -// Printer ports LPT1..LPT3 (port numbers 0..2)
    -#define PRINT_PORT_COUNT        3
    +// Printer ports: LPT1..LPT3 (port numbers 0..2), then the serial ports
    +// COM1..COM4 (3..6) for serial printers such as the ImageWriter II
    +#define PRINT_LPT_COUNT         3
    +#define PRINT_COM_COUNT         4
    +#define PRINT_PORT_COM1         PRINT_LPT_COUNT
    +#define PRINT_PORT_COUNT        (PRINT_LPT_COUNT + PRINT_COM_COUNT)
    +
    +// A serial port's speed and data (DVX.INI [printer] baud, handshake):
    +// 8 data bits, no parity, 1 stop bit
    +#define PRINT_BAUD_DEFAULT      9600
    +#define PRINT_BAUD_COUNT        7       // the speeds offered (printBaudRate)
     
     // printUiOpenPort: the printer chosen in the settings
     #define PRINT_PORT_DEFAULT      (-1)
    @@ -111,19 +121,44 @@
     #define PRINT_FILE_FILTER       "Printer Files (*.prn)"   // Browse... for a print file: its filter
     #define PRINT_FILE_TITLE        "Print to File"           // and its title
     
    +// Where output goes: a port (the value is its port number) or a file
     typedef enum {
         PrintDestLpt1E,
         PrintDestLpt2E,
         PrintDestLpt3E,
    +    PrintDestCom1E,
    +    PrintDestCom2E,
    +    PrintDestCom3E,
    +    PrintDestCom4E,
         PrintDestFileE,
         PrintDestCountE
     } PrintDestE;
     
    +// How a serial printer says it is busy: not at all, XON / XOFF
    +// characters, or a line (the printer's DTR on the computer's CTS, or on
    +// its DSR as Apple's ImageWriter cable wires it)
     typedef enum {
    -    PrintModelTextE,        // any printer, text only
    -    PrintModelEpson9E,      // Epson ESC/P 9-pin (FX, MX, and compatibles)
    -    PrintModelEpson24E,     // Epson ESC/P 24-pin (LQ and compatibles)
    -    PrintModelLaserJetE,    // HP LaserJet (PCL) and compatibles
    +    PrintHandshakeNoneE,
    +    PrintHandshakeXonXoffE,
    +    PrintHandshakeRtsCtsE,
    +    PrintHandshakeDtrDsrE,
    +    PrintHandshakeCountE
    +} PrintHandshakeE;
    +
    +typedef enum {
    +    PrintModelTextE,             // any printer, text only
    +    PrintModelEpson9E,           // Epson ESC/P 9-pin (FX, MX, and compatibles)
    +    PrintModelEpson24E,          // Epson ESC/P 24-pin (LQ and compatibles)
    +    PrintModelLaserJetE,         // HP LaserJet (PCL) and compatibles
    +    PrintModelLaserJet300E,      // HP LaserJet III, DeskJet and later: 300 dpi, compressed
    +    PrintModelEpson9ColorE,      // Epson 9-pin with a color ribbon (JX-80, EX-800, and compatibles)
    +    PrintModelEpson24ColorE,     // Epson 24-pin with a color ribbon (LQ-2550, LQ-860, and color kits)
    +    PrintModelDeskJet500CE,      // HP DeskJet 500C: cyan, magenta, and yellow (black made of the three)
    +    PrintModelDeskJetColorE,     // HP DeskJet 550C and later: black and the three colors
    +    PrintModelPostScriptE,       // PostScript (Level 2) printers
    +    PrintModelPostScriptColorE,  // PostScript color printers
    +    PrintModelImageWriterE,      // Apple ImageWriter II
    +    PrintModelImageWriterColorE, // Apple ImageWriter II with the color ribbon
         PrintModelCountE
     } PrintModelE;
     
    @@ -159,6 +194,8 @@ typedef struct {
         PrintModelE     model;
         char            filePath[PRINT_PATH_MAX];   // PrintDestFileE
         PrintPageSetupT page;
    +    int32_t         baud;                       // a serial port's speed (printBaudRate)
    +    PrintHandshakeE handshake;                  // ... and how it is held back
     } PrintSettingsT;
     
     // What one text print is about (printTextJob)
    @@ -190,48 +227,81 @@ typedef struct {
     } PrintHooksT;
     
     // The printer port services (port 0..PRINT_PORT_COUNT-1).  status and
    -// write return PRINT_STATUS_* bits; write sends one byte.
    +// write return PRINT_STATUS_* bits; write sends one byte.  open (a job
    +// starts: a serial port's speed and handshake), pending (bytes taken but
    +// not sent yet) and close (the job is done) may be NULL.
     typedef struct {
         bool    (*present)(void *ctx, int32_t port);
         uint8_t (*status)(void *ctx, int32_t port);
         uint8_t (*write)(void *ctx, int32_t port, uint8_t byte);
         void     *ctx;
    +    bool    (*open)(void *ctx, int32_t port, int32_t baud, PrintHandshakeE handshake);
    +    int32_t (*pending)(void *ctx, int32_t port);
    +    void    (*close)(void *ctx, int32_t port);
     } PrintPortOpsT;
     
    -// A printer driver for pictures: dots per inch, the printable area in
    -// dots, and how many dot rows it takes at a time.  begin / band / end
    -// write the printer's commands; band gets rows of 1-bit dots (most
    -// significant bit leftmost, 1 = black), rowBytes apart.
    -typedef struct PrintDriverS PrintDriverT;
    -
     struct PrintJobS;
     typedef struct PrintJobS PrintJobT;
     
    -struct PrintDriverS {
    -    const char *name;
    -    int32_t     dpiX;
    -    int32_t     dpiY;
    -    int32_t     pageWidthDots;
    -    int32_t     pageHeightDots;
    -    int32_t     bandRows;
    -    const char *textBegin;          // sent before printed text ("" = nothing)
    -    const char *textEnd;            // sent after printed text
    -    int32_t     linesPerPage;       // the usual page length for text
    -    bool      (*begin)(PrintJobT *job, int32_t widthDots, int32_t heightDots);
    -    bool      (*band)(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots);
    -    bool      (*end)(PrintJobT *job);
    -};
    +// A document of picture pages (printDocOpen)
    +struct PrintDocS;
    +typedef struct PrintDocS PrintDocT;
     
    -// Shared preference vocabularies (see PrefsChoiceT): [printer] port and
    -// model, indexed by PrintDestE / PrintModelE.
    +// The page a printer model prints pictures on: its dots per inch across
    +// and down (they differ on the 9-pin printers), the printable area in
    +// dots (8 by 10 inches), and whether it prints in color (else in shades
    +// of grey).
    +typedef struct {
    +    int32_t dpiX;
    +    int32_t dpiY;
    +    int32_t widthDots;
    +    int32_t heightDots;
    +    bool    color;
    +    bool    vector;                 // a PostScript printer: pages may be drawn with printVec*, and fonts measure as its own
    +} PrintPageInfoT;
    +
    +// How printVecRect / printVecEllipseFill fill: solid, or lines one dot
    +// wide every PRINT_HATCH_DOTS dots, lined up across the page (as BASIC's
    +// FillStyle hatches)
    +#define PRINT_HATCH_DOTS    8
    +
    +typedef enum {
    +    PrintFillSolidE,
    +    PrintFillHorizontalE,
    +    PrintFillVerticalE,
    +    PrintFillUpDiagonalE,
    +    PrintFillDownDiagonalE,
    +    PrintFillCrossE,
    +    PrintFillDiagonalCrossE,
    +    PrintFillCountE
    +} PrintFillE;
    +
    +// Where a page's pixels come from: fill rows y0 .. y0 + rows - 1 of the
    +// page, width dots across, into rgb (three bytes a dot, red first, rows
    +// width * 3 bytes apart), which comes white.  The page is asked a band at
    +// a time, top down, at the printer's resolution.  False stops the page.
    +typedef bool (*PrintBandFnT)(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +
    +// Shared preference vocabularies (see PrefsChoiceT): [printer] port,
    +// model, and handshake, indexed by PrintDestE / PrintModelE /
    +// PrintHandshakeE.
     extern const PrefsChoiceT printDestChoices[PrintDestCountE];
    +extern const PrefsChoiceT printHandshakeChoices[PrintHandshakeCountE];
     extern const PrefsChoiceT printModelChoices[PrintModelCountE];
     
     // The user-facing labels of those choices (for programs that look the
     // library up by name, which only finds functions).
     const char *printDestLabel(PrintDestE dest);
    +const char *printHandshakeLabel(PrintHandshakeE handshake);
     const char *printModelLabel(PrintModelE model);
     
    +// The serial speeds offered, index 0 .. PRINT_BAUD_COUNT - 1 (0 past the
    +// end).
    +int32_t     printBaudRate(int32_t index);
    +
    +// True when dest is a serial port.
    +bool        printDestIsSerial(PrintDestE dest);
    +
     // ------------------------------------------------------------
     // Settings
     // ------------------------------------------------------------
    @@ -259,6 +329,11 @@ const char *printDeviceProblem(PrintDestE dest, const char *filePath);
     // saves h).  The Control Panel's OK and print.bas PrintSettingsStore.
     void        printDeviceStore(PrefsHandleT *h, PrintModelE model, PrintDestE dest, const char *filePath);
     
    +// Store a serial port's speed and handshake the same way; false (nothing
    +// stored) for a speed printBaudRate does not offer or a handshake out of
    +// range.
    +bool        printSerialStore(PrefsHandleT *h, int32_t baud, PrintHandshakeE handshake);
    +
     // NULL when the page setup can be used, else why not.
     const char *printPageSetupProblem(const PrintPageSetupT *ps);
     
    @@ -272,10 +347,10 @@ int32_t     printPageLines(const PrintPageSetupT *ps, PrintModelE model);
     // Replace the port services (NULL = the real ones again).
     void        printSetPortOps(const PrintPortOpsT *ops);
     
    -// True when LPTn (0-based) exists.
    +// True when port (0-based: LPT1 .. LPT3, then COM1 .. COM4) exists.
     bool        printPortPresent(int32_t port);
     
    -// BIOS status bits of LPTn.
    +// The status bits of port (PRINT_STATUS_*: a parallel port's BIOS bits).
     uint8_t     printPortStatus(int32_t port);
     
     // Describe the problem in a status (from a status check, or from a
    @@ -283,9 +358,10 @@ uint8_t     printPortStatus(int32_t port);
     // False (out untouched) when the status shows no problem.
     bool        printStatusProblem(uint8_t status, bool afterWrite, const char *where, char *out, int32_t outSize);
     
    -// Start a job on a port or into filePath (created, or emptied).  hooks
    -// may be NULL.  NULL with a reason in err when the port does not exist
    -// or the file cannot be created.
    +// Start a job on a port or into filePath (created, or emptied); a serial
    +// port is opened at the speed and handshake in the settings.  hooks may
    +// be NULL.  NULL with a reason in err when the port does not exist or
    +// cannot be opened, or the file cannot be created.
     PrintJobT  *printJobOpen(PrintDestE dest, const char *filePath, const PrintHooksT *hooks, char *err, int32_t errSize);
     
     // Send bytes.  Returns once all of them have been taken, or false when
    @@ -322,17 +398,127 @@ int32_t     printTextPageCount(const char *text, int32_t len, const PrintPageSet
     bool        printTextJob(PrintJobT *job, PrintModelE model, const char *text, int32_t len, const PrintPageSetupT *ps, const PrintTextInfoT *info);
     
     // ------------------------------------------------------------
    -// Pictures
    +// Documents: pages of pictures and drawings
     // ------------------------------------------------------------
     
    -// The picture driver of a model, NULL for PrintModelTextE.
    -const PrintDriverT *printDriver(PrintModelE model);
    +// The page model prints on (info may be NULL); false for text only.
    +bool        printModelPage(PrintModelE model, PrintPageInfoT *info);
    +
    +// Start a document on job for model.  The document owns the job from
    +// here on, and printDocClose closes it; NULL (the job closed) for a
    +// text-only model or out of memory.
    +PrintDocT  *printDocOpen(PrintJobT *job, PrintModelE model);
    +
    +// The document's page (printModelPage of its model).
    +void        printDocInfo(const PrintDocT *doc, PrintPageInfoT *info);
    +
    +// Print a page the program draws: fn is asked for the whole printable
    +// area a band at a time, top down, in RGB at the printer's resolution,
    +// and each band goes to the printer as the printer's inks.  False when
    +// fn returned false, the job failed or was cancelled, or out of memory;
    +// after a failure every later page fails at once.
    +bool        printDocPage(PrintDocT *doc, PrintBandFnT fn, void *ctx);
     
     // Print a picture of w x h pixels (3 bytes R, G, B per pixel, rows pitch
    -// bytes apart) on one page: scaled to the largest size that fits the
    -// printable area, dithered to black and white.  False when the job
    -// failed or was cancelled, or out of memory.
    -bool        printRasterJob(PrintJobT *job, const PrintDriverT *drv, const uint8_t *rgb, int32_t w, int32_t h, int32_t pitch);
    +// bytes apart) as a page: scaled to the largest size that fits the
    +// printable area, keeping its shape.  False as printDocPage.
    +bool        printDocPicture(PrintDocT *doc, const uint8_t *rgb, int32_t w, int32_t h, int32_t pitch);
    +
    +// True when the job was cancelled (the Printing window's Cancel, or the
    +// Retry box answered Cancel).
    +bool        printDocCancelled(const PrintDocT *doc);
    +
    +// End the document and close its job.  True when every page printed.
    +bool        printDocClose(PrintDocT *doc);
    +
    +// ------------------------------------------------------------
    +// Text on a page: fonts at printer sizes
    +// ------------------------------------------------------------
    +
    +// A font of a family (dvxText.h) at a size in points, for one page's
    +// resolution (printFontOpen)
    +struct PrintFontS;
    +typedef struct PrintFontS PrintFontT;
    +
    +// Where text's dots go: a run of them, x0 to x1 (inclusive), on row y of
    +// the page.
    +typedef void (*PrintSpanFnT)(void *ctx, int32_t x0, int32_t x1, int32_t y);
    +
    +// family (as dvxFontOpen: NULL or "" the system font) at decipoints
    +// tenths of a point in style (FONT_STYLE_*, lines too) on page (its dots
    +// per inch: printModelPage or printDocInfo), and given up.  The family's
    +// smallest strike at least that many dots is scaled to it, else its
    +// largest: text is crisp where the family has large sizes (the
    +// printer-fonts add-on) and blockier where it is scaled up.  On a vector
    +// page (page->vector) the font is instead the printer's own face nearest
    +// the family (Courier for a fixed-pitch one, Times for a serif one, else
    +// Helvetica), which measures as it prints and is drawn with printVecText
    +// only (printFontSpans and printFontDraw draw nothing with it); clear
    +// vector in a copy of the page for DVX's own letters in bands.  NULL
    +// when out of memory or for a size of 0 or less.
    +void        printFontClose(PrintFontT *font);
    +PrintFontT *printFontOpen(const PrintPageInfoT *page, const char *family, int32_t decipoints, uint32_t style);
    +
    +// Dots from the top of a line of text to its baseline, and to the top of
    +// the next line.
    +int32_t     printFontAscent(const PrintFontT *font);
    +int32_t     printFontHeight(const PrintFontT *font);
    +
    +// Dots across len characters of text take.
    +int32_t     printFontWidth(const PrintFontT *font, const char *text, int32_t len);
    +
    +// len characters of text with the top of their line at (x, y) dots, as
    +// spans to fn: only rows top .. bottom (a band; whole lines are skipped
    +// at once when they miss it).
    +void        printFontSpans(const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx);
    +
    +// The same painted in color (0xRRGGBB) into a band a PrintBandFnT is
    +// filling: rows y0 .. y0 + rows - 1 of the page, width dots across.
    +void        printFontDraw(const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, uint32_t color, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +
    +// ------------------------------------------------------------
    +// Vector pages (PostScript printers: PrintPageInfoT.vector)
    +// ------------------------------------------------------------
    +
    +// A page drawn with the printer's own lines, shapes, and fonts instead of
    +// dots: printVecBegin, the drawing, printVecEnd.  Coordinates are the
    +// page's dots (y down; a dot's centre at whole numbers), colors 0xRRGGBB,
    +// widths in dots.  printVecBegin is false (and the rest do nothing) for a
    +// document whose printer cannot, or that failed; printVecEnd is false
    +// when the page did not print.  A raster page (printDocPage,
    +// printDocPicture) is refused while a vector page is open, and
    +// printDocClose ends one left open.  A font on such a page is the
    +// printer's own (Helvetica, Times, or Courier, as near the family as can
    +// be) and measures as it will print.
    +bool        printVecBegin(PrintDocT *doc);
    +
    +// A pen dot width dots across.
    +void        printVecDot(PrintDocT *doc, int32_t x, int32_t y, uint32_t color, int32_t width);
    +
    +// An ellipse's outline, or (arc) the part from start to end radians,
    +// counterclockwise as the page shows it; dash as printVecLine's, a step a
    +// dot along the outline from angle 0.
    +void        printVecEllipse(PrintDocT *doc, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t color, int32_t width, uint16_t dash, bool arc, double start, double end);
    +
    +// A filled ellipse, out to the dots rx and ry from its centre.
    +void        printVecEllipseFill(PrintDocT *doc, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t color, PrintFillE fill);
    +
    +bool        printVecEnd(PrintDocT *doc);
    +
    +// The part sx, sy, sw, sh of a picture (R, G, B bytes, w x h) stretched
    +// over the dots dx, dy, dw, dh.
    +void        printVecImage(PrintDocT *doc, const uint8_t *rgb, int32_t w, int32_t h, int32_t sx, int32_t sy, int32_t sw, int32_t sh, int32_t dx, int32_t dy, int32_t dw, int32_t dh);
    +
    +// A line; dash is a 16-step pattern from its top bit (0xFFFF solid), a
    +// step a dot along the line's longer side.
    +void        printVecLine(PrintDocT *doc, double x0, double y0, double x1, double y1, uint32_t color, int32_t width, uint16_t dash);
    +
    +// A filled box, corners (in any order) inclusive.
    +void        printVecRect(PrintDocT *doc, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t color, PrintFillE fill);
    +
    +// Text with the top of its line at (x, y) in font (printFontOpen on this
    +// page), with its underline and strike-through.
    +void        printVecText(PrintDocT *doc, const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, uint32_t color);
     
     // ------------------------------------------------------------
     // Dialogs and ready-made printing (need the GUI)
    @@ -365,8 +551,25 @@ bool        printUiWindow(AppContextT *ctx, WindowT *win, bool askFirst);
     // Print dialog answer (or one filled by the program), without asking.
     bool        printUiTextChoice(AppContextT *ctx, const char *text, const char *title, const PrintChoiceT *choice);
     
    +// Open a document on the printer in the settings, at the destination of
    +// choice (a Print dialog answer; NULL: the settings'), with the Printing
    +// window shown until printUiDocClose, Cancel in it, and a Retry / Cancel
    +// box for problems.  title names it in the window.  NULL, after telling
    +// the user why, when the model prints text only or the printer cannot be
    +// opened.  Copies are the caller's: print each page as often as wanted.
    +PrintDocT  *printUiDocOpen(AppContextT *ctx, const char *title, const PrintChoiceT *choice);
    +
    +// End it; a failure (not a cancel) is told to the user.  True when every
    +// page printed.
    +bool        printUiDocClose(AppContextT *ctx, PrintDocT *doc);
    +
     // The context the BASIC functions (print.bas) use; the BASIC runtime
     // sets it.
     void        printBasSetContext(AppContextT *ctx);
     
    +// The last PrintChoose answer the user gave with Print (its destination,
    +// file, and copies: where the BASIC Printer object prints); false when
    +// there was none.
    +bool        printBasChoice(PrintChoiceT *out);
    +
     #endif
    diff --git a/src/libs/kpunch/print/printBas.c b/src/libs/kpunch/print/printBas.c
    index 9aa1dfbe..b647d422 100644
    --- a/src/libs/kpunch/print/printBas.c
    +++ b/src/libs/kpunch/print/printBas.c
    @@ -37,6 +37,7 @@
     #include "printInt.h"
     #include "dvxPrefs.h"
     #include "dvxBas.h"
    +#include "dvxMem.h"
     
     #include 
     #include 
    @@ -45,6 +46,7 @@
     #include 
     
     #define BAS_NONE       (-1)
    +#define NO_APP         (-1)    // no program made the choice
     #define BAS_LPT_FIRST  1       // BASIC numbers the printer ports LPT1 = 1
     
     // A number field of PrintPageSetupT by name (PrintPageSetting)
    @@ -70,17 +72,22 @@ static const BasPageFieldT sPageFields[] = {
     
     static AppContextT *sBasCtx = NULL;
     static PrintChoiceT sChoice;            // the last PrintChoose answer
    +static int32_t      sChoiceApp = NO_APP;    // the program (app id) that chose Print in it
     static char         sText[PRINT_MSG_MAX];
     
     // ============================================================
     // Prototypes
     // ============================================================
     
    +static int32_t appNow(void);
     static int32_t basBool(bool value);
     static bool    destValid(int32_t dest);
     static bool    modelValid(int32_t model);
     static void    pageSetupFrom(PrintPageSetupT *ps, int32_t lines, int32_t columns, int32_t marginTop, int32_t marginBottom, int32_t marginLeft, int32_t marginRight, int32_t tabWidth, int32_t wrap, const char *header, const char *footer);
    +bool           printBasChoice(PrintChoiceT *out);
     void           printBasSetContext(AppContextT *ctx);
    +int32_t        PrintBaudRate(int32_t index);
    +int32_t        PrintCanPrintColor(void);
     int32_t        PrintCanPrintPictures(void);
     int32_t        PrintChoiceCopies(void);
     int32_t        PrintChoiceDest(void);
    @@ -90,7 +97,10 @@ int32_t        PrintChoiceLast(void);
     int32_t        PrintChoose(const char *title, int32_t pageCount);
     int32_t        PrintDestCount(void);
     int32_t        PrintDestIsFile(int32_t dest);
    +int32_t        PrintDestIsSerial(int32_t dest);
     const char    *PrintDestLabel(int32_t dest);
    +int32_t        PrintHandshakeCount(void);
    +const char    *PrintHandshakeLabel(int32_t handshake);
     int32_t        PrintModelCount(void);
     const char    *PrintModelLabel(int32_t model);
     int32_t        PrintPageCount(const char *text);
    @@ -104,8 +114,11 @@ int32_t        PrintPicture(const char *path, const char *title);
     int32_t        PrintPictureDialog(const char *path, const char *title);
     int32_t        PrintPortPresent(int32_t port);
     int32_t        PrintPortStatus(int32_t port);
    +int32_t        PrintSerialStore(int32_t prefs, int32_t baud, int32_t handshake);
    +int32_t        PrintSettingBaud(int32_t prefs);
     int32_t        PrintSettingDest(int32_t prefs);
     const char    *PrintSettingFile(int32_t prefs);
    +int32_t        PrintSettingHandshake(int32_t prefs);
     int32_t        PrintSettingModel(int32_t prefs);
     const char    *PrintSettingsProblem(int32_t dest, const char *filePath);
     int32_t        PrintSettingsStore(int32_t prefs, int32_t model, int32_t dest, const char *filePath);
    @@ -115,6 +128,12 @@ int32_t        PrintTextDialog(const char *text, const char *title);
     int32_t        PrintTextPages(const char *text, const char *title, int32_t firstPage, int32_t lastPage, int32_t copies);
     
     
    +// The running program's app id (0 where there are none).
    +static int32_t appNow(void) {
    +    return dvxMemAppIdPtr ? *dvxMemAppIdPtr : 0;
    +}
    +
    +
     static int32_t basBool(bool value) {
         return value ? BAS_TRUE : BAS_FALSE;
     }
    @@ -147,8 +166,37 @@ static void pageSetupFrom(PrintPageSetupT *ps, int32_t lines, int32_t columns, i
     }
     
     
    +// Only the running program's own answer counts: print.lib is shared by
    +// every program.
    +bool printBasChoice(PrintChoiceT *out) {
    +    *out = sChoice;
    +    return sChoiceApp == appNow();
    +}
    +
    +
    +// A program starting (its runtime binds) has made no choice yet.
     void printBasSetContext(AppContextT *ctx) {
         sBasCtx = ctx;
    +
    +    if (sChoiceApp == appNow()) {
    +        sChoiceApp = NO_APP;
    +    }
    +}
    +
    +
    +// The serial speeds offered; 0 past the last.
    +int32_t PrintBaudRate(int32_t index) {
    +    return printBaudRate(index);
    +}
    +
    +
    +// True when the printer model in the settings prints in color.
    +int32_t PrintCanPrintColor(void) {
    +    PrintSettingsT s;
    +    PrintPageInfoT pi;
    +
    +    printSettingsLoad(&s);
    +    return basBool(printModelPage(s.model, &pi) && pi.color);
     }
     
     
    @@ -157,7 +205,7 @@ int32_t PrintCanPrintPictures(void) {
         PrintSettingsT s;
     
         printSettingsLoad(&s);
    -    return basBool(printDriver(s.model) != NULL);
    +    return basBool(printModelPage(s.model, NULL));
     }
     
     
    @@ -199,7 +247,13 @@ int32_t PrintChoose(const char *title, int32_t pageCount) {
             return BAS_FALSE;
         }
     
    -    return basBool(printDialog(sBasCtx, title, pageCount, &sChoice));
    +    bool chose = printDialog(sBasCtx, title, pageCount, &sChoice);
    +
    +    if (chose) {
    +        sChoiceApp = appNow();
    +    }
    +
    +    return basBool(chose);
     }
     
     
    @@ -214,12 +268,29 @@ int32_t PrintDestIsFile(int32_t dest) {
     }
     
     
    +// True for a serial port.
    +int32_t PrintDestIsSerial(int32_t dest) {
    +    return basBool(destValid(dest) && printDestIsSerial((PrintDestE)dest));
    +}
    +
    +
     // "" for a number out of range.
     const char *PrintDestLabel(int32_t dest) {
         return destValid(dest) ? printDestLabel((PrintDestE)dest) : "";
     }
     
     
    +int32_t PrintHandshakeCount(void) {
    +    return PrintHandshakeCountE;
    +}
    +
    +
    +// "" for a number out of range.
    +const char *PrintHandshakeLabel(int32_t handshake) {
    +    return handshake >= 0 && handshake < PrintHandshakeCountE ? printHandshakeLabel((PrintHandshakeE)handshake) : "";
    +}
    +
    +
     int32_t PrintModelCount(void) {
         return PrintModelCountE;
     }
    @@ -370,8 +441,26 @@ int32_t PrintPortStatus(int32_t port) {
     }
     
     
    +// Store a serial printer's speed and handshake; False for a bad handle,
    +// a speed not offered or a handshake out of range.
    +int32_t PrintSerialStore(int32_t prefs, int32_t baud, int32_t handshake) {
    +    PrefsHandleT *h = prefsBasHandle(prefs);
    +
    +    return basBool(h && printSerialStore(h, baud, (PrintHandshakeE)handshake));
    +}
    +
    +
     // The destination in the [printer] settings of a prefs.bas handle
     // (the defaults for 0).
    +// A serial printer's speed in the settings of prefs.
    +int32_t PrintSettingBaud(int32_t prefs) {
    +    PrintSettingsT s;
    +
    +    printSettingsRead(prefsBasHandle(prefs), &s);
    +    return s.baud;
    +}
    +
    +
     int32_t PrintSettingDest(int32_t prefs) {
         PrintSettingsT s;
     
    @@ -391,6 +480,15 @@ const char *PrintSettingFile(int32_t prefs) {
     }
     
     
    +// A serial printer's handshake (a PrintHandshakeE number).
    +int32_t PrintSettingHandshake(int32_t prefs) {
    +    PrintSettingsT s;
    +
    +    printSettingsRead(prefsBasHandle(prefs), &s);
    +    return s.handshake;
    +}
    +
    +
     int32_t PrintSettingModel(int32_t prefs) {
         PrintSettingsT s;
     
    diff --git a/src/libs/kpunch/print/printEscp.c b/src/libs/kpunch/print/printEscp.c
    new file mode 100644
    index 00000000..e8a9defb
    --- /dev/null
    +++ b/src/libs/kpunch/print/printEscp.c
    @@ -0,0 +1,264 @@
    +// 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.
    +
    +// printEscp.c  -- Printing library: the Epson ESC/P picture drivers (see
    +// printInt.h)
    +//
    +//   9-pin    ESC/P, 120 x 72 dpi (ESC * 1: double density), bands of 8
    +//            rows (one pass of the print head), paper advanced with ESC J
    +//            n (n/216 inch).
    +//   24-pin   ESC/P, 180 x 180 dpi (ESC * 39), bands of 24 rows (3 bytes
    +//            per column), paper advanced with ESC J n (n/180 inch).
    +//   Color    Either with a four-color ribbon (JX-80, EX-800, LQ-2550, and
    +//            color kits): a pass for each ink, its ribbon band chosen
    +//            with ESC r n, yellow first and black last (a dark ink does
    +//            not smear into the light ones).
    +//
    +// A band is one pass of the head for each ink (ESC * mode nL nH and the
    +// columns, then CR) followed by the paper feed of its height.  A band
    +// with no ink only feeds; columns after the last dot are not sent.
    +
    +#include "printInt.h"
    +
    +#include 
    +
    +#define ESC                   "\x1B"
    +#define ESC_CHAR              0x1B
    +#define CR_CHAR               '\r'
    +#define FORM_FEED             "\f"
    +#define BITS_PER_BYTE         8
    +#define TOP_BIT               0x80
    +
    +#define EPSON_RESET           ESC "@"
    +#define EPSON_GRAPHICS        '*'
    +#define EPSON_FEED            'J'
    +#define EPSON_RIBBON          'r'         // ESC r n: the ribbon band
    +#define EPSON_RIBBON_BLACK    0
    +#define EPSON_RIBBON_MAGENTA  1
    +#define EPSON_RIBBON_CYAN     2
    +#define EPSON_RIBBON_YELLOW   4
    +#define EPSON9_DPI_X          120
    +#define EPSON9_DPI_Y          72
    +#define EPSON9_MODE           1           // ESC * 1: double density
    +#define EPSON9_PINS           8
    +#define EPSON9_FEED_UNITS     216         // ESC J n feeds n/216 inch
    +#define EPSON24_DPI           180
    +#define EPSON24_MODE          39          // ESC * 39: 180 dpi, 24 pins
    +#define EPSON24_PINS          24
    +#define EPSON24_FEED_UNITS    180         // ESC J n feeds n/180 inch
    +#define EPSON_FEED_MAX        255         // largest n of ESC J n
    +#define EPSON_COUNT_SHIFT     8           // nL nH
    +#define EPSON_COUNT_MASK      0xFF
    +#define EPSON_HEADER_LEN      5           // ESC * m nL nH
    +#define EPSON_RIBBON_LEN      3           // ESC r n
    +
    +// An Epson driver's own settings (PrintDriverT.params)
    +typedef struct {
    +    int32_t mode;           // ESC * m: the graphics density
    +    int32_t pins;           // dot rows a pass prints
    +    int32_t feedPerRow;     // ESC J units a dot row is high
    +} EpsonParamsT;
    +
    +static const EpsonParamsT sEpson9Params  = { EPSON9_MODE, EPSON9_PINS, EPSON9_FEED_UNITS / EPSON9_DPI_Y };
    +static const EpsonParamsT sEpson24Params = { EPSON24_MODE, EPSON24_PINS, EPSON24_FEED_UNITS / EPSON24_DPI };
    +
    +// A color ribbon's passes, lightest first
    +static const PrintInkE sInksRibbon[PRINT_PLANES_MAX] = { PrintInkYellowE, PrintInkMagentaE, PrintInkCyanE, PrintInkBlackE };
    +
    +// ESC r n for each ink, indexed by PrintInkE
    +static const uint8_t sRibbonOf[PrintInkCountE] = { EPSON_RIBBON_BLACK, EPSON_RIBBON_CYAN, EPSON_RIBBON_MAGENTA, EPSON_RIBBON_YELLOW };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool    epsonBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes);
    +static bool    epsonDocBegin(const PrintDriverT *drv, PrintJobT *job);
    +static bool    epsonDocEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    epsonFeed(PrintJobT *job, int32_t units);
    +static bool    epsonPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots);
    +static bool    epsonPageEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    epsonPass(PrintJobT *job, const PrintPlanesT *planes, int32_t plane, const EpsonParamsT *ep);
    +static int32_t lastInkColumn(const PrintPlanesT *planes, int32_t plane);
    +
    +const PrintDriverT printDrvEpson9 = {
    +    EPSON9_DPI_X, EPSON9_DPI_Y, EPSON9_DPI_X * PRINT_PAGE_WIDTH_IN, EPSON9_DPI_Y * PRINT_PAGE_HEIGHT_IN, EPSON9_PINS,
    +    PRINT_DITHER_SMALL, 1, printInksBlack, EPSON_RESET, "", PRINT_LINES_STANDARD, NULL, &sEpson9Params,
    +    epsonDocBegin, epsonPageBegin, epsonBand, epsonPageEnd, epsonDocEnd, false
    +};
    +
    +const PrintDriverT printDrvEpson24 = {
    +    EPSON24_DPI, EPSON24_DPI, EPSON24_DPI * PRINT_PAGE_WIDTH_IN, EPSON24_DPI * PRINT_PAGE_HEIGHT_IN, EPSON24_PINS,
    +    PRINT_DITHER_SMALL, 1, printInksBlack, EPSON_RESET, "", PRINT_LINES_STANDARD, NULL, &sEpson24Params,
    +    epsonDocBegin, epsonPageBegin, epsonBand, epsonPageEnd, epsonDocEnd, false
    +};
    +
    +const PrintDriverT printDrvEpson9Color = {
    +    EPSON9_DPI_X, EPSON9_DPI_Y, EPSON9_DPI_X * PRINT_PAGE_WIDTH_IN, EPSON9_DPI_Y * PRINT_PAGE_HEIGHT_IN, EPSON9_PINS,
    +    PRINT_DITHER_SMALL, PRINT_PLANES_MAX, sInksRibbon, EPSON_RESET, "", PRINT_LINES_STANDARD, NULL, &sEpson9Params,
    +    epsonDocBegin, epsonPageBegin, epsonBand, epsonPageEnd, epsonDocEnd, false
    +};
    +
    +const PrintDriverT printDrvEpson24Color = {
    +    EPSON24_DPI, EPSON24_DPI, EPSON24_DPI * PRINT_PAGE_WIDTH_IN, EPSON24_DPI * PRINT_PAGE_HEIGHT_IN, EPSON24_PINS,
    +    PRINT_DITHER_SMALL, PRINT_PLANES_MAX, sInksRibbon, EPSON_RESET, "", PRINT_LINES_STANDARD, NULL, &sEpson24Params,
    +    epsonDocBegin, epsonPageBegin, epsonBand, epsonPageEnd, epsonDocEnd, false
    +};
    +
    +
    +// A pass of the head for each plane with ink, then the paper feed of the
    +// band's height.
    +static bool epsonBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes) {
    +    const EpsonParamsT *ep = (const EpsonParamsT *)drv->params;
    +
    +    for (int32_t p = 0; p < planes->count; p++) {
    +        if (!epsonPass(job, planes, p, ep)) {
    +            return false;
    +        }
    +    }
    +
    +    return epsonFeed(job, planes->rows * ep->feedPerRow);
    +}
    +
    +
    +static bool epsonDocBegin(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, EPSON_RESET);
    +}
    +
    +
    +static bool epsonDocEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, EPSON_RESET);
    +}
    +
    +
    +// ESC J n, split when more than one command's worth.
    +static bool epsonFeed(PrintJobT *job, int32_t units) {
    +    while (units > 0) {
    +        int32_t n      = units > EPSON_FEED_MAX ? EPSON_FEED_MAX : units;
    +        uint8_t cmd[3] = { ESC_CHAR, EPSON_FEED, (uint8_t)n };
    +
    +        if (!printJobWrite(job, cmd, (int32_t)sizeof(cmd))) {
    +            return false;
    +        }
    +
    +        units -= n;
    +    }
    +
    +    return true;
    +}
    +
    +
    +static bool epsonPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots) {
    +    (void)drv;
    +    (void)job;
    +    (void)widthDots;
    +    (void)heightDots;
    +    return true;
    +}
    +
    +
    +static bool epsonPageEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, FORM_FEED);
    +}
    +
    +
    +// One pass of the head over plane: on a color printer ESC r n for its
    +// ink, then ESC * mode nL nH, for every column pins / 8 bytes (top dot in
    +// the high bit), and CR.  Nothing when the plane has no ink in the band.
    +static bool epsonPass(PrintJobT *job, const PrintPlanesT *planes, int32_t plane, const EpsonParamsT *ep) {
    +    int32_t cols        = lastInkColumn(planes, plane) + 1;
    +    int32_t bytesPerCol = ep->pins / BITS_PER_BYTE;
    +    uint8_t *line;
    +    int32_t  n          = 0;
    +    bool     ok;
    +
    +    if (cols == 0) {
    +        return true;
    +    }
    +
    +    line = (uint8_t *)malloc((size_t)(EPSON_RIBBON_LEN + EPSON_HEADER_LEN + cols * bytesPerCol + 1));
    +
    +    if (!line) {
    +        return false;
    +    }
    +
    +    if (planes->count > 1) {
    +        line[n++] = ESC_CHAR;
    +        line[n++] = EPSON_RIBBON;
    +        line[n++] = sRibbonOf[planes->ink[plane]];
    +    }
    +
    +    line[n++] = ESC_CHAR;
    +    line[n++] = EPSON_GRAPHICS;
    +    line[n++] = (uint8_t)ep->mode;
    +    line[n++] = (uint8_t)(cols & EPSON_COUNT_MASK);
    +    line[n++] = (uint8_t)(cols >> EPSON_COUNT_SHIFT);
    +
    +    for (int32_t x = 0; x < cols; x++) {
    +        for (int32_t b = 0; b < bytesPerCol; b++) {
    +            uint8_t v = 0;
    +
    +            for (int32_t bit = 0; bit < BITS_PER_BYTE; bit++) {
    +                int32_t row = b * BITS_PER_BYTE + bit;
    +
    +                if (row < planes->rows && printPlaneDot(planes, plane, row, x)) {
    +                    v |= (uint8_t)(TOP_BIT >> bit);
    +                }
    +            }
    +
    +            line[n++] = v;
    +        }
    +    }
    +
    +    line[n++] = CR_CHAR;
    +    ok        = printJobWrite(job, line, n);
    +    free(line);
    +    return ok;
    +}
    +
    +
    +// Index of the last column with a dot in plane, -1 when none: the last
    +// byte column with any ink in the band's rows, then its last dot.
    +static int32_t lastInkColumn(const PrintPlanesT *planes, int32_t plane) {
    +    for (int32_t b = planes->rowBytes - 1; b >= 0; b--) {
    +        uint8_t any = 0;
    +
    +        for (int32_t r = 0; r < planes->rows; r++) {
    +            any |= planes->bits[plane][r * planes->rowBytes + b];
    +        }
    +
    +        if (any) {
    +            int32_t x = b * BITS_PER_BYTE + BITS_PER_BYTE - 1;
    +
    +            while (!(any & (TOP_BIT >> (x % BITS_PER_BYTE)))) {
    +                x--;
    +            }
    +
    +            return x;
    +        }
    +    }
    +
    +    return -1;
    +}
    diff --git a/src/libs/kpunch/print/printFont.c b/src/libs/kpunch/print/printFont.c
    new file mode 100644
    index 00000000..3fdf1e66
    --- /dev/null
    +++ b/src/libs/kpunch/print/printFont.c
    @@ -0,0 +1,386 @@
    +// 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.
    +
    +// printFont.c  -- Printing library: text on a page at printer sizes (see
    +// print.h)
    +//
    +// A print font is a DVX font (dvxFontOpen) scaled to a size in points at
    +// a page's resolution, across and down apart (a 9-pin printer's dots are
    +// not square).  The strike scaled is the family's smallest at least as
    +// large as the size in dots, else its largest, so text is scaled down or
    +// as little up as the family allows (the printer-fonts add-on gives the
    +// DejaVu families sizes up to 24 points at 300 dpi).  Scaling is nearest
    +// pixel: a strike pixel becomes the dots it covers, and scaling down
    +// every strike row and column still marks a dot, so thin strokes stay.
    +//
    +// Text goes out as spans (runs of dots on a row) to a PrintSpanFnT;
    +// printFontDraw paints them into an RGB band.  Positions are worked out
    +// in strike pixels from the start of the text and only then turned into
    +// dots, so a line of text and its width (printFontWidth) always agree.
    +//
    +// On a vector page the font is a PostScript printer's own face instead
    +// (printPs.c draws it): its size is kept unrounded and it measures by
    +// the face's character widths.
    +
    +#include "printInt.h"
    +#include "dvxText.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define POINTS_PER_INCH     72
    +#define DECIPOINTS_PER_INCH (POINTS_PER_INCH * 10)
    +#define FONT_SIZES_MAX      64          // sizes a family may list (dvxFontSizes)
    +#define RGB_BYTES           3
    +#define RGB_RED_SHIFT       16
    +#define RGB_GREEN_SHIFT     8
    +#define RGB_MASK            0xFF
    +#define ROUND_HALF          0.5
    +
    +// One glyph as a bitmap: the system font's cells and a loaded font's
    +// glyphs alike
    +typedef struct {
    +    int32_t        width;
    +    int32_t        height;
    +    int32_t        left;                // pen to the bitmap's left column
    +    int32_t        top;                 // baseline to its top row (up is positive)
    +    int32_t        advance;
    +    int32_t        rowBytes;
    +    bool           gray;                // FONT_GRAY_BPP bits a pixel
    +    const uint8_t *bits;
    +} GlyphT;
    +
    +// printFontDraw's band
    +typedef struct {
    +    uint8_t *rgb;
    +    int32_t  width;
    +    int32_t  y0;
    +    uint8_t  color[RGB_BYTES];
    +} BandT;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void    bandSpan(void *ctx, int32_t x0, int32_t x1, int32_t y);
    +static bool    glyphInk(const GlyphT *g, const uint8_t *row, int32_t col);
    +static bool    glyphOf(const BitmapFontT *font, uint8_t ch, GlyphT *g);
    +static void    glyphSpans(const PrintFontT *pf, const GlyphT *g, int32_t x, int32_t y, int32_t pen, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx);
    +static void    lineSpans(const PrintFontT *pf, int32_t x, int32_t y, int32_t row, int32_t thick, int32_t width, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx);
    +int32_t        printFontAscent(const PrintFontT *font);
    +void           printFontClose(PrintFontT *font);
    +void           printFontDraw(const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, uint32_t color, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +int32_t        printFontHeight(const PrintFontT *font);
    +PrintFontT    *printFontOpen(const PrintPageInfoT *page, const char *family, int32_t decipoints, uint32_t style);
    +void           printFontSpans(const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx);
    +int32_t        printFontWidth(const PrintFontT *font, const char *text, int32_t len);
    +static int32_t scaleX(const PrintFontT *pf, int32_t px);
    +static int32_t scaleY(const PrintFontT *pf, int32_t px);
    +static int32_t textPixels(const BitmapFontT *font, const char *text, int32_t len);
    +
    +
    +// A span in the band's color, the part of it inside the band.
    +static void bandSpan(void *ctx, int32_t x0, int32_t x1, int32_t y) {
    +    const BandT *b = (const BandT *)ctx;
    +
    +    x0 = x0 < 0 ? 0 : x0;
    +    x1 = x1 >= b->width ? b->width - 1 : x1;
    +
    +    for (uint8_t *p = b->rgb + ((int64_t)(y - b->y0) * b->width + x0) * RGB_BYTES; x0 <= x1; x0++, p += RGB_BYTES) {
    +        memcpy(p, b->color, RGB_BYTES);
    +    }
    +}
    +
    +
    +// Whether the glyph pixel at col of a row is ink (a smoothed one from
    +// half covered).
    +static bool glyphInk(const GlyphT *g, const uint8_t *row, int32_t col) {
    +    if (g->gray) {
    +        return FONT_GRAY_LEVEL(row, col) >= FONT_GRAY_HALF;
    +    }
    +
    +    return (row[FONT_PIXEL_BYTE(col)] & FONT_PIXEL_BIT(col)) != 0;
    +}
    +
    +
    +// Character ch of font as a bitmap; false when the font has no glyph for
    +// it (outside its range).
    +static bool glyphOf(const BitmapFontT *font, uint8_t ch, GlyphT *g) {
    +    int32_t idx = ch - font->firstChar;
    +
    +    if (idx < 0 || idx >= font->numChars) {
    +        return false;
    +    }
    +
    +    if (!font->glyphs) {
    +        g->width    = font->charWidth;
    +        g->height   = font->charHeight;
    +        g->left     = 0;
    +        g->top      = font->ascent;
    +        g->advance  = font->charWidth;
    +        g->rowBytes = FONT_ROW_BYTES(font->charWidth);
    +        g->gray     = false;
    +        g->bits     = font->glyphData + idx * font->charHeight * g->rowBytes;
    +        return true;
    +    }
    +
    +    const FontGlyphT *fg = &font->glyphs[idx];
    +
    +    g->width    = fg->width;
    +    g->height   = fg->height;
    +    g->left     = fg->left;
    +    g->top      = fg->top;
    +    g->advance  = fg->advance;
    +    g->gray     = (font->flags & FONT_FLAG_GRAY) != 0;
    +    g->rowBytes = FONT_ROW_BYTES_BPP(fg->width, FONT_BPP(font));
    +    g->bits     = font->glyphData + font->glyphAt[idx];
    +    return true;
    +}
    +
    +
    +// A glyph's dots, its pen pen strike pixels from the text's start at x,
    +// the top of its line at y: each run of ink on a strike row, scaled.
    +static void glyphSpans(const PrintFontT *pf, const GlyphT *g, int32_t x, int32_t y, int32_t pen, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx) {
    +    int32_t rowTop = pf->font->ascent - g->top;
    +
    +    for (int32_t r = 0; r < g->height; r++) {
    +        int32_t y0 = y + scaleY(pf, rowTop + r);
    +        int32_t y1 = y + scaleY(pf, rowTop + r + 1);
    +
    +        y1 = y1 > y0 ? y1 : y0 + 1;
    +
    +        if (y1 <= top || y0 > bottom) {
    +            continue;
    +        }
    +
    +        const uint8_t *row = g->bits + r * g->rowBytes;
    +        int32_t        rt0 = y0 < top ? top : y0;
    +        int32_t        rt1 = y1 - 1 > bottom ? bottom : y1 - 1;
    +
    +        for (int32_t c = 0; c < g->width;) {
    +            if (!glyphInk(g, row, c)) {
    +                c++;
    +                continue;
    +            }
    +
    +            int32_t end = c;
    +
    +            while (end < g->width && glyphInk(g, row, end)) {
    +                end++;
    +            }
    +
    +            int32_t xa = x + scaleX(pf, pen + g->left + c);
    +            int32_t xb = x + scaleX(pf, pen + g->left + end) - 1;
    +
    +            xb = xb >= xa ? xb : xa;
    +
    +            for (int32_t t = rt0; t <= rt1; t++) {
    +                fn(ctx, xa, xb, t);
    +            }
    +
    +            c = end;
    +        }
    +    }
    +}
    +
    +
    +// An underline or strike-through: strike rows row .. row + thick - 1 of
    +// the line at y, width strike pixels from x.
    +static void lineSpans(const PrintFontT *pf, int32_t x, int32_t y, int32_t row, int32_t thick, int32_t width, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx) {
    +    int32_t y0 = y + scaleY(pf, row);
    +    int32_t y1 = y + scaleY(pf, row + thick);
    +    int32_t xb = x + scaleX(pf, width) - 1;
    +
    +    y1 = y1 > y0 ? y1 : y0 + 1;
    +
    +    if (xb < x) {
    +        return;
    +    }
    +
    +    for (int32_t t = y0 < top ? top : y0; t < y1 && t <= bottom; t++) {
    +        fn(ctx, x, xb, t);
    +    }
    +}
    +
    +
    +int32_t printFontAscent(const PrintFontT *font) {
    +    if (font->psFace != PRINT_PS_NONE) {
    +        return (int32_t)floor(printPsAscent(font) + ROUND_HALF);
    +    }
    +
    +    return scaleY(font, font->font->ascent);
    +}
    +
    +
    +void printFontClose(PrintFontT *font) {
    +    if (!font) {
    +        return;
    +    }
    +
    +    dvxFontClose(font->font);
    +    free(font);
    +}
    +
    +
    +void printFontDraw(const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, uint32_t color, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    BandT b;
    +
    +    b.rgb      = rgb;
    +    b.width    = width;
    +    b.y0       = y0;
    +    b.color[0] = (uint8_t)((color >> RGB_RED_SHIFT) & RGB_MASK);
    +    b.color[1] = (uint8_t)((color >> RGB_GREEN_SHIFT) & RGB_MASK);
    +    b.color[2] = (uint8_t)(color & RGB_MASK);
    +    printFontSpans(font, x, y, text, len, y0, y0 + rows - 1, bandSpan, &b);
    +}
    +
    +
    +int32_t printFontHeight(const PrintFontT *font) {
    +    if (font->psFace != PRINT_PS_NONE) {
    +        return (int32_t)floor(PRINT_PS_LEADING * font->psSizeY / PRINT_PS_UNITS + ROUND_HALF);
    +    }
    +
    +    return scaleY(font, font->font->charHeight);
    +}
    +
    +
    +// The strike: the family's smallest size at least the larger of the
    +// sizes across and down, else its largest.  A vector page's font is the
    +// printer's face.
    +PrintFontT *printFontOpen(const PrintPageInfoT *page, const char *family, int32_t decipoints, uint32_t style) {
    +    int32_t     sizes[FONT_SIZES_MAX];
    +    PrintFontT *pf = (PrintFontT *)calloc(1, sizeof(PrintFontT));
    +
    +    if (!pf || !page || decipoints <= 0) {
    +        free(pf);
    +        return NULL;
    +    }
    +
    +    pf->dotsX  = (int32_t)((int64_t)decipoints * page->dpiX / DECIPOINTS_PER_INCH);
    +    pf->dotsY  = (int32_t)((int64_t)decipoints * page->dpiY / DECIPOINTS_PER_INCH);
    +    pf->dotsX  = pf->dotsX < 1 ? 1 : pf->dotsX;
    +    pf->dotsY  = pf->dotsY < 1 ? 1 : pf->dotsY;
    +    pf->style  = style;
    +    pf->psFace = PRINT_PS_NONE;
    +
    +    if (page->vector) {
    +        pf->psFace  = printPsFaceOf(family, style);
    +        pf->psSizeX = (double)decipoints * page->dpiX / DECIPOINTS_PER_INCH;
    +        pf->psSizeY = (double)decipoints * page->dpiY / DECIPOINTS_PER_INCH;
    +        return pf;
    +    }
    +
    +    int32_t want  = pf->dotsX > pf->dotsY ? pf->dotsX : pf->dotsY;
    +    int32_t count = dvxFontSizes(family, style, sizes, FONT_SIZES_MAX);
    +    int32_t pick  = FONT_CHAR_HEIGHT;
    +
    +    count = count > FONT_SIZES_MAX ? FONT_SIZES_MAX : count;
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        pick = sizes[i];
    +
    +        if (pick >= want) {
    +            break;
    +        }
    +    }
    +
    +    pf->font = dvxFontOpen(family, pick, style);
    +
    +    if (!pf->font) {
    +        free(pf);
    +        return NULL;
    +    }
    +
    +    pf->size = pf->font->pixelSize > 0 ? pf->font->pixelSize : pick;
    +    return pf;
    +}
    +
    +
    +// Each glyph's spans, then the lines the font's style asks for; nothing
    +// when the line misses top .. bottom (or for a printer's face).
    +void printFontSpans(const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, int32_t top, int32_t bottom, PrintSpanFnT fn, void *ctx) {
    +    const BitmapFontT *f   = font->font;
    +    int32_t            pen = 0;
    +
    +    if (!f || !text || len <= 0 || y > bottom || y + printFontHeight(font) <= top) {
    +        return;
    +    }
    +
    +    for (int32_t i = 0; i < len; i++) {
    +        GlyphT g;
    +
    +        if (!glyphOf(f, (uint8_t)text[i], &g)) {
    +            pen += f->charWidth;
    +            continue;
    +        }
    +
    +        glyphSpans(font, &g, x, y, pen, top, bottom, fn, ctx);
    +        pen += g.advance;
    +    }
    +
    +    if (f->style & FONT_STYLE_UNDERLINE) {
    +        lineSpans(font, x, y, f->underlineY, f->lineThick, pen, top, bottom, fn, ctx);
    +    }
    +
    +    if (f->style & FONT_STYLE_STRIKE) {
    +        lineSpans(font, x, y, f->strikeY, f->lineThick, pen, top, bottom, fn, ctx);
    +    }
    +}
    +
    +
    +int32_t printFontWidth(const PrintFontT *font, const char *text, int32_t len) {
    +    if (!text || len <= 0) {
    +        return 0;
    +    }
    +
    +    if (font->psFace != PRINT_PS_NONE) {
    +        return (int32_t)floor(printPsWidth(font, text, len) + ROUND_HALF);
    +    }
    +
    +    return scaleX(font, textPixels(font->font, text, len));
    +}
    +
    +
    +// Strike pixels across / down as dots.
    +static int32_t scaleX(const PrintFontT *pf, int32_t px) {
    +    return (int32_t)((int64_t)px * pf->dotsX / pf->size);
    +}
    +
    +
    +static int32_t scaleY(const PrintFontT *pf, int32_t px) {
    +    return (int32_t)((int64_t)px * pf->dotsY / pf->size);
    +}
    +
    +
    +// The pen's movement over len characters of text, in strike pixels.
    +static int32_t textPixels(const BitmapFontT *font, const char *text, int32_t len) {
    +    int32_t pen = 0;
    +
    +    for (int32_t i = 0; i < len; i++) {
    +        GlyphT g;
    +
    +        pen += glyphOf(font, (uint8_t)text[i], &g) ? g.advance : font->charWidth;
    +    }
    +
    +    return pen;
    +}
    diff --git a/src/libs/kpunch/print/printInt.h b/src/libs/kpunch/print/printInt.h
    index 857361e7..3e3f8931 100644
    --- a/src/libs/kpunch/print/printInt.h
    +++ b/src/libs/kpunch/print/printInt.h
    @@ -38,4 +38,201 @@ extern const PrintPortOpsT printSysPortOps;
     // Name of a destination for messages: "LPT1" or the file's base name.
     void printDestName(PrintDestE dest, const char *filePath, char *out, int32_t outSize);
     
    +// ============================================================
    +// Pictures: inks, bands, and the drivers (printRaster.c: the pipeline;
    +// printEscp.c and printPcl.c: the drivers)
    +// ============================================================
    +
    +// A printer's inks, a plane of dots each
    +typedef enum {
    +    PrintInkBlackE,
    +    PrintInkCyanE,
    +    PrintInkMagentaE,
    +    PrintInkYellowE,
    +    PrintInkCountE
    +} PrintInkE;
    +
    +#define PRINT_PLANES_MAX    PrintInkCountE
    +
    +// Ordered dither matrix sizes: 4 x 4 (17 levels, for the coarse dot-
    +// matrix printers) or 8 x 8 (65 levels, for 300 dpi)
    +#define PRINT_DITHER_SMALL  4
    +#define PRINT_DITHER_LARGE  8
    +
    +// The printable area every driver uses, in inches
    +#define PRINT_PAGE_WIDTH_IN  8
    +#define PRINT_PAGE_HEIGHT_IN 10
    +
    +// One band of a page as dots of ink: plane i holds ink[i]'s, rows top
    +// first, rowBytes apart, the leftmost dot in the high bit of a row's
    +// first byte.
    +typedef struct {
    +    int32_t   count;
    +    PrintInkE ink[PRINT_PLANES_MAX];
    +    uint8_t  *bits[PRINT_PLANES_MAX];
    +    int32_t   rowBytes;
    +    int32_t   rows;
    +    int32_t   widthDots;
    +    int32_t   y0;                       // the band's first row on the page
    +} PrintPlanesT;
    +
    +// A printer model's picture driver.  A document is docBegin, then each
    +// page: pageBegin, its bands top down, pageEnd; then docEnd.  The band
    +// gets the planes of inks in that order, dithered from the page's colours.
    +// Each call gets the driver itself, whose params are its family's own
    +// settings (EpsonParamsT, PclParamsT).
    +typedef struct PrintDriverS PrintDriverT;
    +
    +// A driver that prints text its own way (PostScript): the text layout's
    +// pages and lines (each with its left margin, no line end) go to it in
    +// place of plain text, a job at a time.
    +typedef struct {
    +    bool (*begin)(PrintJobT *job, int32_t columns, int32_t lines);
    +    bool (*pageBegin)(PrintJobT *job);
    +    bool (*line)(PrintJobT *job, const char *text, int32_t len);
    +    bool (*pageEnd)(PrintJobT *job);
    +    bool (*end)(PrintJobT *job);
    +} PrintTextOpsT;
    +
    +struct PrintDriverS {
    +    int32_t              dpiX;
    +    int32_t              dpiY;
    +    int32_t              pageWidthDots;      // the printable area
    +    int32_t              pageHeightDots;
    +    int32_t              bandRows;           // rows in a band (the last may have fewer)
    +    int32_t              ditherSize;         // PRINT_DITHER_SMALL or PRINT_DITHER_LARGE
    +    int32_t              inkCount;
    +    const PrintInkE     *inks;               // the planes band gets, in its order
    +    const char          *textBegin;          // sent before printed text ("" = nothing)
    +    const char          *textEnd;            // sent after printed text
    +    int32_t              linesPerPage;       // the usual page length for text
    +    const PrintTextOpsT *textOps;            // NULL: text is plain text, between textBegin and textEnd
    +    const void          *params;
    +    bool               (*docBegin)(const PrintDriverT *drv, PrintJobT *job);
    +    bool               (*pageBegin)(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots);
    +    bool               (*band)(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes);
    +    bool               (*pageEnd)(const PrintDriverT *drv, PrintJobT *job);
    +    bool               (*docEnd)(const PrintDriverT *drv, PrintJobT *job);
    +    bool                 vector;             // pages may be drawn as PostScript (printVec*)
    +};
    +
    +// A document of picture pages (print.h)
    +struct PrintDocS {
    +    PrintJobT          *job;
    +    const PrintDriverT *drv;
    +    PrintModelE         model;
    +    bool                ok;                  // false once a page failed
    +    bool                vecPage;             // between printVecBegin and printVecEnd
    +    bool                vecDashed;           // the vector page's pen: dashed (square ends) or solid (round)
    +    uint16_t            psFaces;             // printPsFaces made Latin-1 in the job so far, a bit each
    +    uint32_t            vecColor;            // ... the pen's colour
    +    int32_t             vecWidth;            // ... and width in dots
    +    int32_t             vecFace;             // the vector page's font: its face (PRINT_PS_NONE: none yet)
    +    double              vecSizeX;            // ... and size in dots
    +    double              vecSizeY;
    +};
    +
    +// A print font (print.h): a DVX font scaled to the page's dots, or on a
    +// vector page a PostScript printer's own face
    +struct PrintFontS {
    +    BitmapFontT *font;                  // NULL for a printer's face
    +    int32_t      size;                  // its strike's pixel size (a strike pixel is dotsX / size dots across)
    +    int32_t      dotsX;                 // the size in dots across
    +    int32_t      dotsY;                 // ... and down
    +    int32_t      psFace;                // printPsFaces index of a printer's face, else -1
    +    uint32_t     style;                 // FONT_STYLE_* (a printer's face draws the lines itself)
    +    double       psSizeX;               // a printer's face: the size in dots across (not rounded)
    +    double       psSizeY;               // ... and down
    +};
    +
    +// The PostScript printers' own text faces (printPsAfm.c, made from
    +// Adobe's AFM files by src/tools/afmWidths.py): Helvetica, Times, and
    +// Courier, each upright, bold, slanted, and both (PRINT_PS_BOLD and
    +// PRINT_PS_SLANT added to a family's first); the ascender, descender,
    +// x-height, underline position (its centre: negative below the baseline)
    +// and thickness, and each character's width from PRINT_PS_FIRST_CHAR in
    +// ISOLatin1Encoding order, all in thousandths of the size.  A line of
    +// text is PRINT_PS_LEADING thousandths tall, the space beyond the
    +// ascender and descender shared above and below them.
    +#define PRINT_PS_FACES       12
    +#define PRINT_PS_FIRST_CHAR  32
    +#define PRINT_PS_CHARS       (256 - PRINT_PS_FIRST_CHAR)
    +#define PRINT_PS_SANS        0
    +#define PRINT_PS_SERIF       4
    +#define PRINT_PS_MONO        8
    +#define PRINT_PS_BOLD        1
    +#define PRINT_PS_SLANT       2
    +#define PRINT_PS_UNITS       1000
    +#define PRINT_PS_LEADING     1200
    +#define PRINT_PS_NONE        (-1)               // a font that is not a printer's face
    +
    +typedef struct {
    +    const char *name;
    +    int16_t     ascender;
    +    int16_t     descender;
    +    int16_t     xHeight;
    +    int16_t     underlinePos;
    +    int16_t     underlineThick;
    +    uint16_t    widths[PRINT_PS_CHARS];
    +} PrintPsFaceT;
    +
    +extern const PrintPsFaceT printPsFaces[PRINT_PS_FACES];
    +
    +// The face of printPsFaces a family and style (FONT_STYLE_BOLD, _ITALIC)
    +// print in: Courier for a fixed-pitch family (or one named Mono or
    +// Courier), Times for a serif one (its name says so), else Helvetica
    +// (printPs.c).
    +int32_t printPsFaceOf(const char *family, uint32_t style);
    +
    +// A CP437 character as ISOLatin1Encoding has it: Latin-1 letters and
    +// signs as themselves, lines as - | +, blocks and shades as #, the rest
    +// as ? (printPs.c); always PRINT_PS_FIRST_CHAR or more.
    +int32_t printPsLatin1(uint8_t ch);
    +
    +// A printer's face (PrintFontT.psFace): dots from the top of a line to
    +// its baseline, and across len characters of text, not rounded
    +// (printPs.c).
    +double  printPsAscent(const PrintFontT *font);
    +double  printPsWidth(const PrintFontT *font, const char *text, int32_t len);
    +
    +// The one ink of a black-and-white printer, and the four of a printer
    +// with black (printRaster.c)
    +extern const PrintInkE printInksBlack[1];
    +extern const PrintInkE printInksKcmy[PRINT_PLANES_MAX];
    +
    +// The drivers (printEscp.c, printPcl.c)
    +extern const PrintDriverT printDrvDeskJet500C;
    +extern const PrintDriverT printDrvDeskJetColor;
    +extern const PrintDriverT printDrvEpson9;
    +extern const PrintDriverT printDrvEpson9Color;
    +extern const PrintDriverT printDrvEpson24;
    +extern const PrintDriverT printDrvEpson24Color;
    +extern const PrintDriverT printDrvImageWriter;
    +extern const PrintDriverT printDrvImageWriterColor;
    +extern const PrintDriverT printDrvLaserJet;
    +extern const PrintDriverT printDrvLaserJet300;
    +extern const PrintDriverT printDrvPostScript;
    +extern const PrintDriverT printDrvPostScriptColor;
    +
    +// The picture driver of a model, NULL for PrintModelTextE.
    +const PrintDriverT *printDriver(PrintModelE model);
    +
    +// One page through drv, widthDots x heightDots: fn fills each band in RGB
    +// at the driver's resolution, which is turned into the driver's inks.
    +// False when the job failed or was cancelled, or out of memory.
    +bool printPageRun(PrintJobT *job, const PrintDriverT *drv, int32_t widthDots, int32_t heightDots, PrintBandFnT fn, void *ctx);
    +
    +// n bytes as TIFF PackBits (PCL compression mode 2, PostScript's
    +// RunLengthDecode) into out, which holds PRINT_PACK_MAX(n); returns the
    +// bytes written.
    +#define PACK_RUN_MAX        128
    +#define PRINT_PACK_MAX(n)   ((n) + (n) / PACK_RUN_MAX + 1)
    +int32_t printPackBits(const uint8_t *in, int32_t n, uint8_t *out);
    +
    +// The brightness (0 .. 255) of an R, G, B pixel.
    +int32_t printLuma(const uint8_t *px);
    +
    +// True when plane has a dot at (x, row) of the band.
    +bool printPlaneDot(const PrintPlanesT *pl, int32_t plane, int32_t row, int32_t x);
    +
     #endif
    diff --git a/src/libs/kpunch/print/printIw.c b/src/libs/kpunch/print/printIw.c
    new file mode 100644
    index 00000000..9b08cf1a
    --- /dev/null
    +++ b/src/libs/kpunch/print/printIw.c
    @@ -0,0 +1,242 @@
    +// 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.
    +
    +// printIw.c  -- Printing library: the Apple ImageWriter II drivers (see
    +// printInt.h; the codes are the ImageWriter II Technical Reference
    +// Manual's, 1986)
    +//
    +//   ImageWriter II        144 x 144 dpi: 144 dots across (ESC p), and 144
    +//                         down by two passes of the 8 graphics wires (72
    +//                         dpi) 1/144 inch apart -- the even rows of a
    +//                         16-row band, a 1/144-inch line feed (ESC T 01),
    +//                         the odd rows, then the rest of the band (ESC T
    +//                         15).
    +//   ImageWriter II color  the same with the four-color ribbon: a line for
    +//                         each ink with its ribbon band chosen by ESC K n,
    +//                         yellow first and black last, CR without a line
    +//                         feed between them.
    +//
    +// A line of graphics is ESC G nnnn (four ASCII digits) and the columns,
    +// one byte each, bit 0 the top dot; it starts at the first column with
    +// ink (ESC F nnnn places the head there), stops after the last, and is
    +// not sent when it has none.  The document starts by resetting the
    +// printer (ESC c), then sets its software switches whatever its DIP
    +// switches say: no line feed after CR, 8-bit data (ESC Z 80 20), no
    +// perforation skip (ESC D 40 04); then 144 dpi and unidirectional
    +// printing (ESC >), which keeps the two passes and the inks in line.  It
    +// ends with ESC c again.
    +
    +#include "printInt.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define ESC                 "\x1B"
    +#define CR_CHAR             '\r'
    +#define FORM_FEED           "\f"
    +
    +#define IW_DPI              144
    +#define IW_PINS             8           // graphics wires, 1/72 inch apart
    +#define IW_PASSES           2           // passes a band: its even rows, then its odd rows
    +#define IW_BAND_ROWS        (IW_PINS * IW_PASSES)
    +#define IW_RESET            ESC "c"
    +#define IW_SWITCHES         ESC "Z\x80\x20" ESC "D\x40\x04"     // no LF after CR, 8-bit data; no perforation skip
    +#define IW_SETUP            IW_SWITCHES ESC "p" ESC ">"             // 144 dpi, unidirectional
    +#define IW_GRAPHICS_FMT     ESC "G%04" PRId32
    +#define IW_PLACE_FMT        ESC "F%04" PRId32
    +#define IW_FEED_FMT         ESC "T%02" PRId32
    +#define IW_FEED_MAX         99          // largest ESC T nn
    +#define IW_RIBBON           'K'         // ESC K n: the ribbon band
    +#define IW_RIBBON_BLACK     '0'
    +#define IW_RIBBON_YELLOW    '1'
    +#define IW_RIBBON_MAGENTA   '2'
    +#define IW_RIBBON_CYAN      '3'
    +#define IW_CMD_MAX          16
    +
    +// A color ribbon's lines, lightest first
    +static const PrintInkE sInksRibbon[PRINT_PLANES_MAX] = { PrintInkYellowE, PrintInkMagentaE, PrintInkCyanE, PrintInkBlackE };
    +
    +// ESC K n for each ink, indexed by PrintInkE
    +static const char sRibbonOf[PrintInkCountE] = { IW_RIBBON_BLACK, IW_RIBBON_CYAN, IW_RIBBON_MAGENTA, IW_RIBBON_YELLOW };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static bool    iwBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes);
    +static uint8_t iwColumn(const PrintPlanesT *planes, int32_t plane, int32_t pass, int32_t x);
    +static bool    iwDocBegin(const PrintDriverT *drv, PrintJobT *job);
    +static bool    iwDocEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    iwFeed(PrintJobT *job, int32_t rows);
    +static bool    iwLine(PrintJobT *job, const PrintPlanesT *planes, int32_t plane, int32_t pass);
    +static bool    iwPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots);
    +static bool    iwPageEnd(const PrintDriverT *drv, PrintJobT *job);
    +
    +const PrintDriverT printDrvImageWriter = {
    +    IW_DPI, IW_DPI, IW_DPI * PRINT_PAGE_WIDTH_IN, IW_DPI * PRINT_PAGE_HEIGHT_IN, IW_BAND_ROWS,
    +    PRINT_DITHER_SMALL, 1, printInksBlack, IW_RESET IW_SWITCHES, IW_RESET, PRINT_LINES_STANDARD, NULL, NULL,
    +    iwDocBegin, iwPageBegin, iwBand, iwPageEnd, iwDocEnd, false
    +};
    +
    +const PrintDriverT printDrvImageWriterColor = {
    +    IW_DPI, IW_DPI, IW_DPI * PRINT_PAGE_WIDTH_IN, IW_DPI * PRINT_PAGE_HEIGHT_IN, IW_BAND_ROWS,
    +    PRINT_DITHER_SMALL, PRINT_PLANES_MAX, sInksRibbon, IW_RESET IW_SWITCHES, IW_RESET, PRINT_LINES_STANDARD, NULL, NULL,
    +    iwDocBegin, iwPageBegin, iwBand, iwPageEnd, iwDocEnd, false
    +};
    +
    +
    +// Each pass: a line of each ink, CR between them; then the feed to the
    +// next pass (1/144 inch) or past the band.
    +static bool iwBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes) {
    +    (void)drv;
    +
    +    for (int32_t pass = 0; pass < IW_PASSES; pass++) {
    +        for (int32_t p = 0; p < planes->count; p++) {
    +            if (!iwLine(job, planes, p, pass)) {
    +                return false;
    +            }
    +        }
    +
    +        if (!iwFeed(job, pass + 1 < IW_PASSES ? 1 : planes->rows - pass)) {
    +            return false;
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +// Column x of a pass as a graphics byte: the band's rows pass, pass + 2,
    +// ... from bit 0 (the top wire) down.
    +static uint8_t iwColumn(const PrintPlanesT *planes, int32_t plane, int32_t pass, int32_t x) {
    +    uint8_t v = 0;
    +
    +    for (int32_t bit = 0; bit < IW_PINS; bit++) {
    +        int32_t row = pass + bit * IW_PASSES;
    +
    +        if (row < planes->rows && printPlaneDot(planes, plane, row, x)) {
    +            v |= (uint8_t)(1u << bit);
    +        }
    +    }
    +
    +    return v;
    +}
    +
    +
    +static bool iwDocBegin(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, IW_RESET IW_SETUP);
    +}
    +
    +
    +static bool iwDocEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, IW_RESET);
    +}
    +
    +
    +// Paper fed rows / 144 inch: ESC T nn and a line feed, as many as it takes.
    +static bool iwFeed(PrintJobT *job, int32_t rows) {
    +    char cmd[IW_CMD_MAX];
    +
    +    while (rows > 0) {
    +        int32_t n = rows > IW_FEED_MAX ? IW_FEED_MAX : rows;
    +
    +        snprintf(cmd, sizeof(cmd), IW_FEED_FMT "\n", n);
    +
    +        if (!printJobPuts(job, cmd)) {
    +            return false;
    +        }
    +
    +        rows -= n;
    +    }
    +
    +    return true;
    +}
    +
    +
    +// One ink's line of a pass: on a color printer ESC K n, the head placed
    +// at the first column with ink (ESC F nnnn), ESC G nnnn and the columns
    +// to the last one with ink, then CR.  Nothing when the pass has no ink.
    +static bool iwLine(PrintJobT *job, const PrintPlanesT *planes, int32_t plane, int32_t pass) {
    +    int32_t first = 0;
    +    int32_t last  = planes->widthDots - 1;
    +
    +    while (first < planes->widthDots && iwColumn(planes, plane, pass, first) == 0) {
    +        first++;
    +    }
    +
    +    if (first == planes->widthDots) {
    +        return true;
    +    }
    +
    +    while (iwColumn(planes, plane, pass, last) == 0) {
    +        last--;
    +    }
    +
    +    int32_t  count = last - first + 1;
    +    uint8_t *data  = (uint8_t *)malloc((size_t)count);
    +    char     cmd[IW_CMD_MAX * 2];
    +    int32_t  n     = 0;
    +    bool     ok;
    +
    +    if (!data) {
    +        return false;
    +    }
    +
    +    if (planes->count > 1) {
    +        n += snprintf(cmd + n, sizeof(cmd) - (size_t)n, ESC "%c%c", IW_RIBBON, sRibbonOf[planes->ink[plane]]);
    +    }
    +
    +    if (first > 0) {
    +        n += snprintf(cmd + n, sizeof(cmd) - (size_t)n, IW_PLACE_FMT, first);
    +    }
    +
    +    snprintf(cmd + n, sizeof(cmd) - (size_t)n, IW_GRAPHICS_FMT, count);
    +
    +    for (int32_t x = 0; x < count; x++) {
    +        data[x] = iwColumn(planes, plane, pass, first + x);
    +    }
    +
    +    uint8_t cr = CR_CHAR;
    +
    +    ok = printJobPuts(job, cmd) && printJobWrite(job, data, count) && printJobWrite(job, &cr, 1);
    +    free(data);
    +    return ok;
    +}
    +
    +
    +static bool iwPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots) {
    +    (void)drv;
    +    (void)job;
    +    (void)widthDots;
    +    (void)heightDots;
    +    return true;
    +}
    +
    +
    +static bool iwPageEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, FORM_FEED);
    +}
    diff --git a/src/libs/kpunch/print/printJob.c b/src/libs/kpunch/print/printJob.c
    index 8ebd6bbe..d80d36fa 100644
    --- a/src/libs/kpunch/print/printJob.c
    +++ b/src/libs/kpunch/print/printJob.c
    @@ -28,7 +28,9 @@
     // is busy the job calls the idle hook and checks again, and a printer
     // that stays busy for PRINT_BUSY_TIMEOUT_MS is reported like any other
     // problem.  A problem is put to the ask hook; Retry tries the same byte
    -// again, Cancel ends the job.
    +// again, Cancel ends the job.  A serial port is opened when its job
    +// starts and, at the end, closed once the bytes it holds have gone (the
    +// printer holding them back counts as busy).
     
     #include "printInt.h"
     #include "dvxPlat.h"
    @@ -54,6 +56,8 @@ DVX_ABI_STAMP(print);
     #define INI_WRAP                "wrap"
     #define INI_HEADER              "header"
     #define INI_FOOTER              "footer"
    +#define INI_BAUD                "baud"
    +#define INI_HANDSHAKE           "handshake"
     
     // Bytes written to a file between two calls of the idle hook
     #define PRINT_FILE_YIELD_BYTES  4096
    @@ -74,14 +78,36 @@ const PrefsChoiceT printDestChoices[PrintDestCountE] = {
         { "LPT1", "LPT1", PrintDestLpt1E },
         { "LPT2", "LPT2", PrintDestLpt2E },
         { "LPT3", "LPT3", PrintDestLpt3E },
    +    { "COM1", "COM1", PrintDestCom1E },
    +    { "COM2", "COM2", PrintDestCom2E },
    +    { "COM3", "COM3", PrintDestCom3E },
    +    { "COM4", "COM4", PrintDestCom4E },
         { "File", "FILE", PrintDestFileE }
     };
     
    +const PrefsChoiceT printHandshakeChoices[PrintHandshakeCountE] = {
    +    { "None",                    "none",    PrintHandshakeNoneE    },
    +    { "XON / XOFF",              "xonxoff", PrintHandshakeXonXoffE },
    +    { "Hardware (CTS)",          "rtscts",  PrintHandshakeRtsCtsE  },
    +    { "Hardware (DSR, Apple)",   "dtrdsr",  PrintHandshakeDtrDsrE  }
    +};
    +
    +static const int32_t sBaudRates[PRINT_BAUD_COUNT] = { 300, 1200, 2400, 4800, 9600, 19200, 38400 };
    +
     const PrefsChoiceT printModelChoices[PrintModelCountE] = {
    -    { "Text only (any printer)",  "text",     PrintModelTextE     },
    -    { "Epson 9-pin (ESC/P)",      "epson9",   PrintModelEpson9E   },
    -    { "Epson 24-pin (ESC/P)",     "epson24",  PrintModelEpson24E  },
    -    { "HP LaserJet (PCL)",        "laserjet", PrintModelLaserJetE }
    +    { "Text only (any printer)",              "text",             PrintModelTextE             },
    +    { "Epson 9-pin (ESC/P)",                  "epson9",           PrintModelEpson9E           },
    +    { "Epson 24-pin (ESC/P)",                 "epson24",          PrintModelEpson24E          },
    +    { "HP LaserJet (PCL)",                    "laserjet",         PrintModelLaserJetE         },
    +    { "HP LaserJet III or DeskJet (300 dpi)", "laserjet300",      PrintModelLaserJet300E      },
    +    { "Epson 9-pin color (ESC/P)",            "epson9color",      PrintModelEpson9ColorE      },
    +    { "Epson 24-pin color (ESC/P)",           "epson24color",     PrintModelEpson24ColorE     },
    +    { "HP DeskJet 500C (color)",              "deskjet500c",      PrintModelDeskJet500CE      },
    +    { "HP DeskJet 550C and later (color)",    "deskjetcolor",     PrintModelDeskJetColorE     },
    +    { "PostScript",                           "postscript",       PrintModelPostScriptE       },
    +    { "PostScript (color)",                   "postscriptcolor",  PrintModelPostScriptColorE  },
    +    { "Apple ImageWriter II",                 "imagewriter",      PrintModelImageWriterE      },
    +    { "Apple ImageWriter II (color)",         "imagewritercolor", PrintModelImageWriterColorE }
     };
     
     static const char sNotResponding[] = "is not responding. Check that it is switched on and connected, then choose Retry.";
    @@ -93,11 +119,15 @@ static const PrintPortOpsT *sOps = &printSysPortOps;
     // ============================================================
     
     static bool        askRetry(PrintJobT *job, const char *message);
    +static int32_t     baudOffered(int32_t baud);
     static bool        idle(PrintJobT *job);
    +int32_t            printBaudRate(int32_t index);
    +bool               printDestIsSerial(PrintDestE dest);
     const char        *printDestLabel(PrintDestE dest);
     void               printDestName(PrintDestE dest, const char *filePath, char *out, int32_t outSize);
     const char        *printDeviceProblem(PrintDestE dest, const char *filePath);
     void               printDeviceStore(PrefsHandleT *h, PrintModelE model, PrintDestE dest, const char *filePath);
    +const char        *printHandshakeLabel(PrintHandshakeE handshake);
     uint32_t           printJobBytes(const PrintJobT *job);
     bool               printJobCancelled(const PrintJobT *job);
     bool               printJobClose(PrintJobT *job);
    @@ -109,6 +139,7 @@ int32_t            printPageLines(const PrintPageSetupT *ps, PrintModelE model);
     const char        *printPageSetupProblem(const PrintPageSetupT *ps);
     bool               printPortPresent(int32_t port);
     uint8_t            printPortStatus(int32_t port);
    +bool               printSerialStore(PrefsHandleT *h, int32_t baud, PrintHandshakeE handshake);
     void               printSetPortOps(const PrintPortOpsT *ops);
     void               printSettingsDefaults(PrintSettingsT *s);
     void               printSettingsLoad(PrintSettingsT *s);
    @@ -134,6 +165,18 @@ static bool askRetry(PrintJobT *job, const char *message) {
     }
     
     
    +// A speed printBaudRate offers, else the default.
    +static int32_t baudOffered(int32_t baud) {
    +    for (int32_t i = 0; i < PRINT_BAUD_COUNT; i++) {
    +        if (sBaudRates[i] == baud) {
    +            return baud;
    +        }
    +    }
    +
    +    return PRINT_BAUD_DEFAULT;
    +}
    +
    +
     // Let other programs run.  False (job cancelled) when the hook says so.
     static bool idle(PrintJobT *job) {
         job->idled     = true;
    @@ -153,6 +196,16 @@ static bool idle(PrintJobT *job) {
     }
     
     
    +int32_t printBaudRate(int32_t index) {
    +    return index >= 0 && index < PRINT_BAUD_COUNT ? sBaudRates[index] : 0;
    +}
    +
    +
    +bool printDestIsSerial(PrintDestE dest) {
    +    return dest >= PrintDestCom1E && dest <= PrintDestCom4E;
    +}
    +
    +
     const char *printDestLabel(PrintDestE dest) {
         return printDestChoices[dest].label;
     }
    @@ -162,7 +215,7 @@ void printDestName(PrintDestE dest, const char *filePath, char *out, int32_t out
         if (dest == PrintDestFileE) {
             snprintf(out, outSize, "%s", platformPathBaseName(filePath ? filePath : ""));
         } else {
    -        snprintf(out, outSize, "LPT%d", (int)(dest + PRINT_PORT_NUMBER_BASE));
    +        snprintf(out, outSize, "%s", printDestChoices[dest].label);
         }
     }
     
    @@ -191,6 +244,11 @@ void printDeviceStore(PrefsHandleT *h, PrintModelE model, PrintDestE dest, const
     }
     
     
    +const char *printHandshakeLabel(PrintHandshakeE handshake) {
    +    return printHandshakeChoices[handshake].label;
    +}
    +
    +
     uint32_t printJobBytes(const PrintJobT *job) {
         return job ? job->bytes : 0;
     }
    @@ -206,6 +264,35 @@ bool printJobClose(PrintJobT *job) {
             return false;
         }
     
    +    // A port's bytes held back (a serial printer's buffer) go out first
    +    if (!job->fp && sOps->pending) {
    +        PlatformTicksT since = platformClock();
    +        int32_t        left  = sOps->pending(sOps->ctx, job->port);
    +
    +        while (!job->cancelled && left > 0) {
    +            int32_t now = sOps->pending(sOps->ctx, job->port);
    +
    +            if (now < left) {
    +                left  = now;
    +                since = platformClock();
    +            } else if (platformClock() - since >= PLATFORM_MS_TO_TICKS(PRINT_BUSY_TIMEOUT_MS)) {
    +                char msg[PRINT_MSG_MAX];
    +
    +                snprintf(msg, sizeof(msg), "The printer on %s %s", job->where, sNotResponding);
    +
    +                if (askRetry(job, msg)) {
    +                    since = platformClock();
    +                }
    +            } else {
    +                idle(job);
    +            }
    +        }
    +    }
    +
    +    if (!job->fp && sOps->close) {
    +        sOps->close(sOps->ctx, job->port);
    +    }
    +
         bool ok = !job->cancelled;
     
         if (job->fp && fclose(job->fp) != 0) {
    @@ -250,7 +337,17 @@ PrintJobT *printJobOpen(PrintDestE dest, const char *filePath, const PrintHooksT
             job->idleEvery = PRINT_YIELD_BYTES;
     
             if ((int32_t)dest < 0 || (int32_t)dest >= PRINT_PORT_COUNT || !printPortPresent(job->port)) {
    -            snprintf(err, errSize, "This computer has no printer port %s.", job->where);
    +            snprintf(err, errSize, "This computer has no port %s.", job->where);
    +            free(job);
    +            return NULL;
    +        }
    +
    +        PrintSettingsT s;
    +
    +        printSettingsLoad(&s);
    +
    +        if (sOps->open && !sOps->open(sOps->ctx, job->port, s.baud, s.handshake)) {
    +            snprintf(err, errSize, "The port %s cannot be opened. Another program may be using it.", job->where);
                 free(job);
                 return NULL;
             }
    @@ -347,6 +444,22 @@ uint8_t printPortStatus(int32_t port) {
     }
     
     
    +// The serial settings stored with everything else as DVX.INI has it now.
    +bool printSerialStore(PrefsHandleT *h, int32_t baud, PrintHandshakeE handshake) {
    +    PrintSettingsT s;
    +
    +    if (baudOffered(baud) != baud || handshake < PrintHandshakeNoneE || handshake >= PrintHandshakeCountE) {
    +        return false;
    +    }
    +
    +    printSettingsLoad(&s);
    +    s.baud      = baud;
    +    s.handshake = handshake;
    +    printSettingsWrite(h, &s);
    +    return true;
    +}
    +
    +
     void printSetPortOps(const PrintPortOpsT *ops) {
         sOps = ops ? ops : &printSysPortOps;
     }
    @@ -356,6 +469,8 @@ void printSettingsDefaults(PrintSettingsT *s) {
         memset(s, 0, sizeof(*s));
         s->dest              = PrintDestLpt1E;
         s->model             = PrintModelTextE;
    +    s->baud              = PRINT_BAUD_DEFAULT;
    +    s->handshake         = PrintHandshakeDtrDsrE;
         s->page.columns      = PRINT_COLUMNS_DEFAULT;
         s->page.marginTop    = PRINT_MARGIN_TB_DEFAULT;
         s->page.marginBottom = PRINT_MARGIN_TB_DEFAULT;
    @@ -402,6 +517,8 @@ void printSettingsRead(PrefsHandleT *h, PrintSettingsT *s) {
         platformPathNative(s->filePath);
         snprintf(s->page.header, sizeof(s->page.header), "%s", prefsGetString(h, PRINT_SECTION, INI_HEADER, def.page.header));
         snprintf(s->page.footer, sizeof(s->page.footer), "%s", prefsGetString(h, PRINT_SECTION, INI_FOOTER, def.page.footer));
    +    s->baud              = baudOffered(prefsGetInt(h, PRINT_SECTION, INI_BAUD, def.baud));
    +    s->handshake         = (PrintHandshakeE)prefsGetChoice(h, PRINT_SECTION, INI_HANDSHAKE, printHandshakeChoices, PrintHandshakeCountE, printHandshakeChoices[def.handshake].iniName);
     
         if (printPageSetupProblem(&s->page)) {
             s->page = def.page;
    @@ -439,6 +556,8 @@ void printSettingsWrite(PrefsHandleT *h, const PrintSettingsT *s) {
         prefsSetBool(h, PRINT_SECTION, INI_WRAP, s->page.wrap);
         prefsSetString(h, PRINT_SECTION, INI_HEADER, s->page.header);
         prefsSetString(h, PRINT_SECTION, INI_FOOTER, s->page.footer);
    +    prefsSetInt(h, PRINT_SECTION, INI_BAUD, s->baud);
    +    prefsSetString(h, PRINT_SECTION, INI_HANDSHAKE, printHandshakeChoices[s->handshake].iniName);
     }
     
     
    diff --git a/src/libs/kpunch/print/printPcl.c b/src/libs/kpunch/print/printPcl.c
    new file mode 100644
    index 00000000..c2556d37
    --- /dev/null
    +++ b/src/libs/kpunch/print/printPcl.c
    @@ -0,0 +1,239 @@
    +// 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.
    +
    +// printPcl.c  -- Printing library: the HP PCL picture drivers (see
    +// printInt.h)
    +//
    +//   LaserJet      PCL, 150 dpi raster graphics (ESC * t150R), one
    +//                 ESC * b n W transfer per row (uncompressed, which every
    +//                 LaserJet understands), trailing white bytes left out.
    +//   LaserJet 300  PCL, 300 dpi (LaserJet III, DeskJet and later): rows in
    +//                 compression mode 2 (TIFF PackBits, ESC * b 2 M), blank
    +//                 rows skipped with ESC * b n Y instead of being sent.
    +//   DeskJet 500C  As the 300 dpi one, in three planes (ESC * r -3 U:
    +//                 cyan, magenta, yellow); black is the three together.
    +//   DeskJet color DeskJet 550C and later: four planes (ESC * r -4 U:
    +//                 black, cyan, magenta, yellow).
    +//
    +// A page is a raster graphic: its resolution, its planes on a color
    +// printer, and start (ESC * t, ESC * r U, the cursor to the top-left
    +// corner, ESC * r 1 A), the rows, then the end of the graphic (ESC * r B)
    +// and a form feed.  A row of several planes is each plane's transfer in
    +// the driver's ink order, ESC * b n V for all but the last, which is
    +// ESC * b n W.
    +
    +#include "printInt.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define ESC                 "\x1B"
    +#define FORM_FEED           "\f"
    +
    +#define PCL_RESET           ESC "E"
    +#define PCL_DPI             150
    +#define PCL_BAND_ROWS       8
    +#define PCL300_DPI          300
    +#define PCL300_BAND_ROWS    32
    +#define PCL_DPI_FMT         ESC "*t%" PRId32 "R"
    +#define PCL_PLANES_FMT      ESC "*r%" PRId32 "U"
    +#define PCL_START           ESC "*p0x0Y" ESC "*r1A"
    +#define PCL_PACKBITS        ESC "*b2M"
    +#define PCL_END_RASTER      ESC "*rB"
    +#define PCL_TRANSFER_FMT    ESC "*b%" PRId32 "%c"
    +#define PCL_PLANE_NEXT      'V'         // a row's plane, more to come
    +#define PCL_PLANE_LAST      'W'         // a row's last plane
    +#define PCL_SKIP_FMT        ESC "*b%" PRId32 "Y"
    +#define CMY_INKS            3
    +#define PCL_PLANES_CMY      (-CMY_INKS)             // ESC * r -3 U: cyan, magenta, yellow
    +#define PCL_PLANES_KCMY     (-PRINT_PLANES_MAX)     // ESC * r -4 U: black and the three
    +#define PCL_CMD_MAX         16
    +
    +// A PCL driver's own settings (PrintDriverT.params; NULL: the plain one)
    +typedef struct {
    +    bool    packBits;       // rows in mode 2, blank rows skipped
    +    int32_t planes;         // ESC * r n U (PCL_PLANES_*); 0: black only, not sent
    +} PclParamsT;
    +
    +static const PclParamsT sPcl300Params       = { true, 0 };
    +static const PclParamsT sDeskJet500CParams  = { true, PCL_PLANES_CMY };
    +static const PclParamsT sDeskJetColorParams = { true, PCL_PLANES_KCMY };
    +
    +// The DeskJet 500C's planes, in the order ESC * r -3 U gives them (the
    +// later ones' are printInksKcmy's)
    +static const PrintInkE sInksCmy[CMY_INKS] = { PrintInkCyanE, PrintInkMagentaE, PrintInkYellowE };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static int32_t inkBytes(const uint8_t *row, int32_t n);
    +static bool    pclBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes);
    +static bool    pclDocBegin(const PrintDriverT *drv, PrintJobT *job);
    +static bool    pclDocEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    pclPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots);
    +static bool    pclPageEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    pclSkip(PrintJobT *job, int32_t *blank);
    +
    +const PrintDriverT printDrvLaserJet = {
    +    PCL_DPI, PCL_DPI, PCL_DPI * PRINT_PAGE_WIDTH_IN, PCL_DPI * PRINT_PAGE_HEIGHT_IN, PCL_BAND_ROWS,
    +    PRINT_DITHER_SMALL, 1, printInksBlack, PCL_RESET, PCL_RESET, PRINT_LINES_LASER, NULL, NULL,
    +    pclDocBegin, pclPageBegin, pclBand, pclPageEnd, pclDocEnd, false
    +};
    +
    +const PrintDriverT printDrvLaserJet300 = {
    +    PCL300_DPI, PCL300_DPI, PCL300_DPI * PRINT_PAGE_WIDTH_IN, PCL300_DPI * PRINT_PAGE_HEIGHT_IN, PCL300_BAND_ROWS,
    +    PRINT_DITHER_LARGE, 1, printInksBlack, PCL_RESET, PCL_RESET, PRINT_LINES_LASER, NULL, &sPcl300Params,
    +    pclDocBegin, pclPageBegin, pclBand, pclPageEnd, pclDocEnd, false
    +};
    +
    +const PrintDriverT printDrvDeskJet500C = {
    +    PCL300_DPI, PCL300_DPI, PCL300_DPI * PRINT_PAGE_WIDTH_IN, PCL300_DPI * PRINT_PAGE_HEIGHT_IN, PCL300_BAND_ROWS,
    +    PRINT_DITHER_LARGE, CMY_INKS, sInksCmy, PCL_RESET, PCL_RESET, PRINT_LINES_LASER, NULL, &sDeskJet500CParams,
    +    pclDocBegin, pclPageBegin, pclBand, pclPageEnd, pclDocEnd, false
    +};
    +
    +const PrintDriverT printDrvDeskJetColor = {
    +    PCL300_DPI, PCL300_DPI, PCL300_DPI * PRINT_PAGE_WIDTH_IN, PCL300_DPI * PRINT_PAGE_HEIGHT_IN, PCL300_BAND_ROWS,
    +    PRINT_DITHER_LARGE, PRINT_PLANES_MAX, printInksKcmy, PCL_RESET, PCL_RESET, PRINT_LINES_LASER, NULL, &sDeskJetColorParams,
    +    pclDocBegin, pclPageBegin, pclBand, pclPageEnd, pclDocEnd, false
    +};
    +
    +
    +// How many of row's n bytes count: up to the last one with ink.
    +static int32_t inkBytes(const uint8_t *row, int32_t n) {
    +    while (n > 0 && row[n - 1] == 0) {
    +        n--;
    +    }
    +
    +    return n;
    +}
    +
    +
    +// Every row: each plane's transfer of its bytes up to the last one with
    +// ink; packed, with the blank rows before it skipped, on a PackBits
    +// printer.
    +static bool pclBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes) {
    +    const PclParamsT *pp     = (const PclParamsT *)drv->params;
    +    bool              pack   = pp && pp->packBits;
    +    uint8_t          *packed = pack ? (uint8_t *)malloc((size_t)PRINT_PACK_MAX(planes->rowBytes)) : NULL;
    +    int32_t           last   = planes->count - 1;
    +    int32_t           blank  = 0;
    +    bool              ok     = !pack || packed;
    +    char              cmd[PCL_CMD_MAX];
    +
    +    for (int32_t r = 0; ok && r < planes->rows; r++) {
    +        int32_t n[PRINT_PLANES_MAX];
    +        bool    ink = false;
    +
    +        for (int32_t p = 0; p < planes->count; p++) {
    +            n[p] = inkBytes(planes->bits[p] + r * planes->rowBytes, planes->rowBytes);
    +            ink  = ink || n[p] > 0;
    +        }
    +
    +        if (pack && !ink) {
    +            blank++;
    +            continue;
    +        }
    +
    +        ok = pclSkip(job, &blank);
    +
    +        for (int32_t p = 0; ok && p < planes->count; p++) {
    +            const uint8_t *row = planes->bits[p] + r * planes->rowBytes;
    +            int32_t        len = n[p];
    +
    +            if (pack) {
    +                len = printPackBits(row, len, packed);
    +                row = packed;
    +            }
    +
    +            snprintf(cmd, sizeof(cmd), PCL_TRANSFER_FMT, len, p == last ? PCL_PLANE_LAST : PCL_PLANE_NEXT);
    +            ok = printJobPuts(job, cmd) && printJobWrite(job, row, len);
    +        }
    +    }
    +
    +    // Blank rows at the end of the band: the next band starts below them
    +    ok = ok && pclSkip(job, &blank);
    +    free(packed);
    +    return ok;
    +}
    +
    +
    +static bool pclDocBegin(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, PCL_RESET);
    +}
    +
    +
    +static bool pclDocEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, PCL_RESET);
    +}
    +
    +
    +// The resolution, the planes (color printers), the start of the graphic,
    +// and PackBits rows (where the driver packs them).
    +static bool pclPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots) {
    +    const PclParamsT *pp = (const PclParamsT *)drv->params;
    +    char              cmd[PCL_CMD_MAX];
    +    bool              ok;
    +
    +    (void)widthDots;
    +    (void)heightDots;
    +    snprintf(cmd, sizeof(cmd), PCL_DPI_FMT, drv->dpiX);
    +    ok = printJobPuts(job, cmd);
    +
    +    if (ok && pp && pp->planes != 0) {
    +        snprintf(cmd, sizeof(cmd), PCL_PLANES_FMT, pp->planes);
    +        ok = printJobPuts(job, cmd);
    +    }
    +
    +    ok = ok && printJobPuts(job, PCL_START);
    +
    +    if (ok && pp && pp->packBits) {
    +        ok = printJobPuts(job, PCL_PACKBITS);
    +    }
    +
    +    return ok;
    +}
    +
    +
    +static bool pclPageEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, PCL_END_RASTER FORM_FEED);
    +}
    +
    +
    +// ESC * b n Y for the blank rows counted so far, and the count cleared.
    +static bool pclSkip(PrintJobT *job, int32_t *blank) {
    +    char cmd[PCL_CMD_MAX];
    +
    +    if (*blank == 0) {
    +        return true;
    +    }
    +
    +    snprintf(cmd, sizeof(cmd), PCL_SKIP_FMT, *blank);
    +    *blank = 0;
    +    return printJobPuts(job, cmd);
    +}
    diff --git a/src/libs/kpunch/print/printPs.c b/src/libs/kpunch/print/printPs.c
    new file mode 100644
    index 00000000..0d801f69
    --- /dev/null
    +++ b/src/libs/kpunch/print/printPs.c
    @@ -0,0 +1,946 @@
    +// 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.
    +
    +// printPs.c  -- Printing library: the PostScript drivers (see printInt.h)
    +//
    +//   PostScript        Level 2, pictures at 300 dpi: each band of a page an
    +//                     imagemask of its black dots.
    +//   PostScript color  each band a colorimage of 4 bits a dot (cyan,
    +//                     magenta, yellow, black: the dithered planes).
    +//
    +// Band data is packed (RunLengthDecode: the PackBits of PCL mode 2) and
    +// sent as ASCII85, which any port carries; a band with no ink is not
    +// sent.  The 8 x 10 inch printable area is centred across the paper and
    +// half an inch from its top, whatever size the printer has loaded
    +// (currentpagedevice).  A PostScript printer prints no plain text, so
    +// text jobs are PostScript too: Courier (Latin-1, CP437 characters
    +// mapped), sized so the page setup's columns fill the 8 inches and its
    +// lines the 10, a show per line.
    +//
    +// Vector pages (printVec*) are drawn by the printer itself: lines, arcs,
    +// and fills in the page's dots (y down, a dot's centre half a dot in from
    +// its corner), hatches as lines every PRINT_HATCH_DOTS dots clipped to
    +// the shape, text in the printer's Helvetica, Times, or Courier (made
    +// Latin-1 as a page first uses each), and pictures as images the printer
    +// scales (8 bits a colour, grey on the black-and-white model).  The pen
    +// (colour, width, dashes) and font are only sent when they change.
    +
    +#include "printInt.h"
    +#include "dvxFontFmt.h"
    +#include "dvxText.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define PS_DPI              300
    +#define PS_BAND_ROWS        32
    +#define PS_COLOR_BITS       4           // a dot of the color image: C, M, Y, K
    +#define PS_DOTS_PER_BYTE    2           // ... two to a byte
    +#define PS_LINE_MAX         75          // ASCII85 characters on a line
    +#define PS_STRING_LINE      200         // string characters on a line (a longer string goes on with \ and a line end)
    +#define PS_STRING_ESCAPE    4           // the most characters one character takes in a string (\ooo)
    +#define PS_STRING_CONTINUE  "\\\n"
    +#define PS_CMD_MAX          64
    +#define PS_VEC_CMD_MAX      320         // a vector command (a dash pattern of 16 runs, a hatch)
    +#define RLE_EOD             128         // RunLengthDecode's end of data
    +#define A85_GROUP           4
    +#define A85_CHARS           5
    +#define A85_BASE            85
    +#define A85_FIRST           '!'
    +#define A85_ZERO            'z'
    +#define A85_END             "~>"
    +#define POINTS_WIDTH        (72 * PRINT_PAGE_WIDTH_IN)
    +#define POINTS_HEIGHT       (72 * PRINT_PAGE_HEIGHT_IN)
    +#define HUNDREDTHS          100
    +#define COURIER_ADVANCE     60          // Courier's advance, hundredths of its size
    +#define LATIN1_FIRST        0xA0        // Latin-1 letters and signs PostScript's ISOLatin1Encoding has
    +#define LATIN1_LAST         0xFF
    +#define BOX_FIRST           0x2500      // Unicode box drawing (CP437 B3-DA)
    +#define BOX_H               0x2500
    +#define BOX_DOUBLE_H        0x2550
    +#define BOX_V               0x2502
    +#define BOX_DOUBLE_V        0x2551
    +#define BLOCK_FIRST         0x2580      // blocks and shades (CP437 B0-B2, DB-DF)
    +#define BLOCK_LAST          0x259F
    +#define OCTAL_ESCAPE        "\\%03o"
    +#define DOT_CENTRE          0.5         // a dot's centre from its top-left corner
    +#define DEGREES_PER_RADIAN  (180.0 / 3.14159265358979323846)
    +#define DASH_BITS           16          // a dash pattern: a bit a step along the line, from the top one
    +#define DASH_TOP            0x8000u
    +#define DASH_SOLID          0xFFFFu
    +#define DASH_NONE           0x0000u
    +#define TEXT_LINES_MAX      2           // an underline and a strike-through
    +#define HATCH_MASK          (PRINT_HATCH_DOTS - 1)
    +#define RGB_BYTES           3
    +#define GRAY_BYTES          1
    +#define RGB_RED_SHIFT       16
    +#define RGB_GREEN_SHIFT     8
    +#define RGB_MASK            0xFF
    +#define COLOR_LEVELS        255.0
    +#define PS_BLACK            0x000000u   // a page's colour at its start
    +
    +// The procedures every job starts with: dvxPage leaves the printable
    +// area's top-left corner (points); dvxBegin starts a page of dots (y
    +// down) at a resolution; dvxMask and dvxColor print a band (y, width,
    +// height) from the data after them; dvxRe makes a Latin-1 copy of a font;
    +// dvxTop and L start a page of text and show a line.  For vector pages:
    +// dvxL strokes a line (x0 y0 x1 y1); dvxEA is the path of an ellipse's
    +// arc (cx cy rx ry from to, degrees counterclockwise on the page), dvxE a
    +// whole one, dvxBox a box (x y w h); dvxD is a dot (x y radius); dvxHz,
    +// dvxVt, dvxUp, and dvxDn are hatch lines (first step last from to);
    +// dvxSF sets a font (name, size across and down); dvxT shows text (string
    +// x y); dvxImg prints a picture (x y w h, its width height, 1 grey or 3
    +// RGB) from the data after it.
    +static const char sProlog[] =
    +    "%!PS\n"
    +    "%%Creator: DVX\n"
    +    "%%LanguageLevel: 2\n"
    +    "%%EndComments\n"
    +    "/dvxPage { currentpagedevice /PageSize get aload pop 36 sub exch 576 sub 2 div exch } bind def\n"
    +    "/dvxBegin { /dvxDpi exch def gsave dvxPage translate 72 dvxDpi div dup neg scale } bind def\n"
    +    "/dvxData { /dvxA currentfile /ASCII85Decode filter def /dvxR dvxA /RunLengthDecode filter def } bind def\n"
    +    "/dvxDone { dvxR flushfile dvxA flushfile } bind def\n"
    +    "/dvxMask { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH true [dvxW 0 0 dvxH 0 0] dvxR imagemask dvxDone grestore } bind def\n"
    +    "/dvxColor { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH 1 [dvxW 0 0 dvxH 0 0] dvxR false 4 colorimage dvxDone grestore } bind def\n"
    +    "/dvxRe { findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall /Encoding ISOLatin1Encoding def currentdict end definefont pop } bind def\n"
    +    "/DvxCourier /Courier dvxRe /dvxF /DvxCourier findfont def\n"
    +    "/dvxTop { dvxPage /dvxY exch def /dvxX exch def dvxF dvxSize scalefont setfont } bind def\n"
    +    "/L { dvxX dvxY dvxLead sub dup /dvxY exch def moveto show } bind def\n"
    +    "/dvxM matrix def\n"
    +    "/dvxL { 4 2 roll moveto lineto stroke } bind def\n"
    +    "/dvxEA { dvxM currentmatrix pop 6 4 roll translate 4 2 roll scale 0 0 1 5 -2 roll neg exch neg exch arcn dvxM setmatrix } bind def\n"
    +    "/dvxE { 0 360 dvxEA closepath } bind def\n"
    +    "/dvxBox { 4 2 roll moveto 1 index 0 rlineto 0 exch rlineto neg 0 rlineto closepath } bind def\n"
    +    "/dvxD { 0 360 arc fill } bind def\n"
    +    "/dvxHz { /dvxQ exch def /dvxP exch def { 0.5 add dvxP exch moveto dvxQ dvxP sub 0 rlineto } for } bind def\n"
    +    "/dvxVt { /dvxQ exch def /dvxP exch def { 0.5 add dvxP moveto 0 dvxQ dvxP sub rlineto } for } bind def\n"
    +    "/dvxUp { /dvxQ exch def /dvxP exch def { 1 add dup dvxP sub dvxP moveto dvxQ sub dvxQ lineto } for } bind def\n"
    +    "/dvxDn { /dvxQ exch def /dvxP exch def { dup dvxP add dvxP moveto dvxQ add dvxQ lineto } for } bind def\n"
    +    "/dvxSF { /dvxSy exch def /dvxSx exch def findfont [dvxSx 0 0 dvxSy neg 0 0] makefont setfont } bind def\n"
    +    "/dvxT { moveto show } bind def\n"
    +    "/dvxImg { /dvxC exch def /dvxH exch def /dvxW exch def gsave 4 2 roll translate scale dvxData dvxW dvxH 8 [dvxW 0 0 dvxH 0 0] dvxR dvxC 1 eq { image } { false 3 colorimage } ifelse dvxDone grestore } bind def\n"
    +    "%%EndProlog\n";
    +
    +#define PS_BAND_FMT         "%" PRId32 " %" PRId32 " %" PRId32 " %s\n"
    +#define PS_PAGE_FMT         "%" PRId32 " dvxBegin\n"
    +#define PS_PAGE_END         "grestore showpage\n"
    +#define PS_SIZES_FMT        "/dvxSize %" PRId32 ".%02" PRId32 " def /dvxLead %" PRId32 ".%02" PRId32 " def\n"
    +#define PS_TEXT_PAGE        "dvxTop\n"
    +#define PS_TEXT_PAGE_END    "showpage\n"
    +#define PS_SHOW_LINE        " L\n"
    +#define PS_EOF              "%%EOF\n"
    +#define PS_VEC_PAGE_FMT     "0 0 %" PRId32 " %" PRId32 " rectclip 1 setlinecap 1 setlinejoin\n"
    +#define PS_HATCH_FMT        "%" PRId32 " %d %" PRId32 " %" PRId32 " %" PRId32 " %s\n"
    +
    +// A PostScript driver's own settings (PrintDriverT.params)
    +typedef struct {
    +    const char *bandProc;               // dvxMask or dvxColor
    +    bool        color;                  // 4 bits a dot from the four planes; vector pages in colour
    +} PsParamsT;
    +
    +// Bytes on their way out as ASCII85
    +typedef struct {
    +    PrintJobT *job;
    +    uint8_t    group[A85_GROUP];
    +    int32_t    n;                       // bytes in group
    +    char       line[PS_LINE_MAX + A85_CHARS + 1];
    +    int32_t    col;
    +    bool       ok;
    +} A85T;
    +
    +static const PsParamsT sPsMono  = { "dvxMask", false };
    +static const PsParamsT sPsColor = { "dvxColor", true };
    +
    +// The color image's bit for each ink, indexed by PrintInkE (C, M, Y, K
    +// from the top bit of a dot's four)
    +static const uint8_t sColorBit[PrintInkCountE] = { 0x1, 0x8, 0x4, 0x2 };
    +
    +// Words in a family's name that print it in Courier, and in Times (but
    +// not with Sans)
    +static const char *const sMonoWords[]  = { "Mono", "Courier" };
    +static const char *const sSerifWords[] = { "Serif", "Times", "Roman", "Tinos", "Ledger" };
    +static const char *const sSansWords[]  = { "Sans" };
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void    a85Byte(A85T *a, uint8_t b);
    +static bool    a85End(A85T *a);
    +static void    a85Group(A85T *a, int32_t count);
    +static void    a85Out(A85T *a, const char *text, int32_t len);
    +static void    a85Put(A85T *a, const uint8_t *data, int32_t len);
    +static bool    bandInk(const PrintPlanesT *planes);
    +static void    colorRow(const PrintPlanesT *planes, int32_t r, uint8_t *out);
    +static bool    dashOn(uint16_t dash, int32_t i);
    +static int32_t hatchFirst(int32_t lo);
    +static bool    nameHas(const char *name, const char *const *words, int32_t count);
    +double         printPsAscent(const PrintFontT *font);
    +int32_t        printPsFaceOf(const char *family, uint32_t style);
    +int32_t        printPsLatin1(uint8_t ch);
    +double         printPsWidth(const PrintFontT *font, const char *text, int32_t len);
    +bool           printVecBegin(PrintDocT *doc);
    +void           printVecDot(PrintDocT *doc, int32_t x, int32_t y, uint32_t color, int32_t width);
    +void           printVecEllipse(PrintDocT *doc, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t color, int32_t width, uint16_t dash, bool arc, double start, double end);
    +void           printVecEllipseFill(PrintDocT *doc, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t color, PrintFillE fill);
    +bool           printVecEnd(PrintDocT *doc);
    +void           printVecImage(PrintDocT *doc, const uint8_t *rgb, int32_t w, int32_t h, int32_t sx, int32_t sy, int32_t sw, int32_t sh, int32_t dx, int32_t dy, int32_t dw, int32_t dh);
    +void           printVecLine(PrintDocT *doc, double x0, double y0, double x1, double y1, uint32_t color, int32_t width, uint16_t dash);
    +void           printVecRect(PrintDocT *doc, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t color, PrintFillE fill);
    +void           printVecText(PrintDocT *doc, const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, uint32_t color);
    +static bool    psBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes);
    +static bool    psDocBegin(const PrintDriverT *drv, PrintJobT *job);
    +static bool    psDocEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    psPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots);
    +static bool    psPageEnd(const PrintDriverT *drv, PrintJobT *job);
    +static bool    psString(PrintJobT *job, const char *text, int32_t len);
    +static bool    psTextBegin(PrintJobT *job, int32_t columns, int32_t lines);
    +static bool    psTextEnd(PrintJobT *job);
    +static bool    psTextLine(PrintJobT *job, const char *text, int32_t len);
    +static bool    psTextPageBegin(PrintJobT *job);
    +static bool    psTextPageEnd(PrintJobT *job);
    +static void    vecColor(PrintDocT *doc, uint32_t color);
    +static void    vecDashes(PrintDocT *doc, uint16_t dash, double unit, double phase);
    +static void    vecFill(PrintDocT *doc, const char *shape, PrintFillE fill, int32_t x0, int32_t y0, int32_t x1, int32_t y1);
    +static void    vecFont(PrintDocT *doc, const PrintFontT *font);
    +static bool    vecOn(const PrintDocT *doc);
    +static void    vecPen(PrintDocT *doc, int32_t width, bool dashed);
    +static void    vecPrintf(PrintDocT *doc, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
    +
    +static const PrintTextOpsT sPsText = { psTextBegin, psTextPageBegin, psTextLine, psTextPageEnd, psTextEnd };
    +
    +const PrintDriverT printDrvPostScript = {
    +    PS_DPI, PS_DPI, PS_DPI * PRINT_PAGE_WIDTH_IN, PS_DPI * PRINT_PAGE_HEIGHT_IN, PS_BAND_ROWS,
    +    PRINT_DITHER_LARGE, 1, printInksBlack, "", "", PRINT_LINES_LASER, &sPsText, &sPsMono,
    +    psDocBegin, psPageBegin, psBand, psPageEnd, psDocEnd, true
    +};
    +
    +const PrintDriverT printDrvPostScriptColor = {
    +    PS_DPI, PS_DPI, PS_DPI * PRINT_PAGE_WIDTH_IN, PS_DPI * PRINT_PAGE_HEIGHT_IN, PS_BAND_ROWS,
    +    PRINT_DITHER_LARGE, PRINT_PLANES_MAX, printInksKcmy, "", "", PRINT_LINES_LASER, &sPsText, &sPsColor,
    +    psDocBegin, psPageBegin, psBand, psPageEnd, psDocEnd, true
    +};
    +
    +
    +static void a85Byte(A85T *a, uint8_t b) {
    +    a->group[a->n++] = b;
    +
    +    if (a->n == A85_GROUP) {
    +        a85Group(a, A85_GROUP);
    +    }
    +}
    +
    +
    +// The last, short group, the end mark, and a line end.
    +static bool a85End(A85T *a) {
    +    if (a->n > 0) {
    +        int32_t count = a->n;
    +
    +        memset(a->group + count, 0, (size_t)(A85_GROUP - count));
    +        a85Group(a, count);
    +    }
    +
    +    a85Out(a, A85_END "\n", (int32_t)strlen(A85_END "\n"));
    +    a->ok = a->ok && printJobWrite(a->job, a->line, a->col);
    +    a->col = 0;
    +    return a->ok;
    +}
    +
    +
    +// The group as five characters (z for four zero bytes); a short group of
    +// count bytes as count + 1 characters.
    +static void a85Group(A85T *a, int32_t count) {
    +    uint32_t v = ((uint32_t)a->group[0] << 24) | ((uint32_t)a->group[1] << 16) | ((uint32_t)a->group[2] << 8) | a->group[3];
    +    char     c[A85_CHARS];
    +
    +    a->n = 0;
    +
    +    if (v == 0 && count == A85_GROUP) {
    +        c[0] = A85_ZERO;
    +        a85Out(a, c, 1);
    +        return;
    +    }
    +
    +    for (int32_t i = A85_CHARS - 1; i >= 0; i--) {
    +        c[i] = (char)(A85_FIRST + v % A85_BASE);
    +        v   /= A85_BASE;
    +    }
    +
    +    a85Out(a, c, count + 1);
    +}
    +
    +
    +// Characters into the line, which goes out (with a line end) when full.
    +static void a85Out(A85T *a, const char *text, int32_t len) {
    +    memcpy(a->line + a->col, text, (size_t)len);
    +    a->col += len;
    +
    +    if (a->col >= PS_LINE_MAX) {
    +        a->line[a->col++] = '\n';
    +        a->ok             = a->ok && printJobWrite(a->job, a->line, a->col);
    +        a->col            = 0;
    +    }
    +}
    +
    +
    +static void a85Put(A85T *a, const uint8_t *data, int32_t len) {
    +    for (int32_t i = 0; i < len; i++) {
    +        a85Byte(a, data[i]);
    +    }
    +}
    +
    +
    +// Whether any plane has a dot in the band.
    +static bool bandInk(const PrintPlanesT *planes) {
    +    for (int32_t p = 0; p < planes->count; p++) {
    +        for (int32_t i = 0; i < planes->rowBytes * planes->rows; i++) {
    +            if (planes->bits[p][i]) {
    +                return true;
    +            }
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +// Row r of the band as the color image's dots: four bits each, two to a
    +// byte, from the planes.
    +static void colorRow(const PrintPlanesT *planes, int32_t r, uint8_t *out) {
    +    memset(out, 0, (size_t)((planes->widthDots + 1) / PS_DOTS_PER_BYTE));
    +
    +    for (int32_t p = 0; p < planes->count; p++) {
    +        uint8_t bit = sColorBit[planes->ink[p]];
    +
    +        for (int32_t x = 0; x < planes->widthDots; x++) {
    +            if (printPlaneDot(planes, p, r, x)) {
    +                out[x / PS_DOTS_PER_BYTE] |= (uint8_t)(x % PS_DOTS_PER_BYTE ? bit : bit << PS_COLOR_BITS);
    +            }
    +        }
    +    }
    +}
    +
    +
    +// Whether step i (any whole number: the pattern repeats) of a dash
    +// pattern is drawn.
    +static bool dashOn(uint16_t dash, int32_t i) {
    +    return (dash & (DASH_TOP >> (((i % DASH_BITS) + DASH_BITS) % DASH_BITS))) != 0;
    +}
    +
    +
    +// The first hatch line at or after lo: a multiple of PRINT_HATCH_DOTS.
    +static int32_t hatchFirst(int32_t lo) {
    +    return lo + ((PRINT_HATCH_DOTS - (lo & HATCH_MASK)) & HATCH_MASK);
    +}
    +
    +
    +// Whether name holds any of words (case ignored).
    +static bool nameHas(const char *name, const char *const *words, int32_t count) {
    +    for (int32_t w = 0; w < count; w++) {
    +        size_t n = strlen(words[w]);
    +
    +        for (const char *p = name; *p; p++) {
    +            if (strncasecmp(p, words[w], n) == 0) {
    +                return true;
    +            }
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +// The baseline below the top of a line: the ascender, and half the
    +// leading beyond the ascender and descender.
    +double printPsAscent(const PrintFontT *font) {
    +    const PrintPsFaceT *face  = &printPsFaces[font->psFace];
    +    int32_t             extra = PRINT_PS_LEADING - (face->ascender - face->descender);
    +
    +    return (face->ascender + extra / 2.0) * font->psSizeY / PRINT_PS_UNITS;
    +}
    +
    +
    +int32_t printPsFaceOf(const char *family, uint32_t style) {
    +    const char *name = family && family[0] ? family : FONT_SYSTEM_FAMILY;
    +    int32_t     face = PRINT_PS_SANS;
    +
    +    if (dvxFontFamilyFixed(name) || nameHas(name, sMonoWords, DVX_ARRAY_LEN(sMonoWords))) {
    +        face = PRINT_PS_MONO;
    +    } else if (nameHas(name, sSerifWords, DVX_ARRAY_LEN(sSerifWords)) && !nameHas(name, sSansWords, DVX_ARRAY_LEN(sSansWords))) {
    +        face = PRINT_PS_SERIF;
    +    }
    +
    +    face += (style & FONT_STYLE_BOLD) ? PRINT_PS_BOLD : 0;
    +    face += (style & FONT_STYLE_ITALIC) ? PRINT_PS_SLANT : 0;
    +    return face;
    +}
    +
    +
    +int32_t printPsLatin1(uint8_t ch) {
    +    uint32_t u = dfnCp437[ch];
    +
    +    if (ch >= PRINT_PS_FIRST_CHAR && ch < LATIN1_FIRST && u == ch) {
    +        return ch;
    +    }
    +
    +    if (u >= LATIN1_FIRST && u <= LATIN1_LAST) {
    +        return (int32_t)u;
    +    }
    +
    +    if (u == BOX_H || u == BOX_DOUBLE_H) {
    +        return '-';
    +    }
    +
    +    if (u == BOX_V || u == BOX_DOUBLE_V) {
    +        return '|';
    +    }
    +
    +    if (u >= BOX_FIRST && u < BLOCK_FIRST) {
    +        return '+';
    +    }
    +
    +    return (u >= BLOCK_FIRST && u <= BLOCK_LAST) ? '#' : '?';
    +}
    +
    +
    +// Dots across len characters of text in a printer's face (not rounded).
    +double printPsWidth(const PrintFontT *font, const char *text, int32_t len) {
    +    const PrintPsFaceT *face = &printPsFaces[font->psFace];
    +    int32_t             sum  = 0;
    +
    +    for (int32_t i = 0; i < len; i++) {
    +        sum += face->widths[printPsLatin1((uint8_t)text[i]) - PRINT_PS_FIRST_CHAR];
    +    }
    +
    +    return sum * font->psSizeX / PRINT_PS_UNITS;
    +}
    +
    +
    +// The page started, clipped to the printable area, with round line ends
    +// and corners; the pen and font as a page starts.
    +bool printVecBegin(PrintDocT *doc) {
    +    if (!doc || !doc->ok || !doc->drv->vector || doc->vecPage) {
    +        return false;
    +    }
    +
    +    const PrintDriverT *drv = doc->drv;
    +
    +    doc->ok = drv->pageBegin(drv, doc->job, drv->pageWidthDots, drv->pageHeightDots);
    +    vecPrintf(doc, PS_VEC_PAGE_FMT, drv->pageWidthDots, drv->pageHeightDots);
    +    doc->vecPage   = doc->ok;
    +    doc->vecColor  = PS_BLACK;
    +    doc->vecWidth  = 1;
    +    doc->vecDashed = false;
    +    doc->vecFace   = PRINT_PS_NONE;
    +    return doc->vecPage;
    +}
    +
    +
    +void printVecDot(PrintDocT *doc, int32_t x, int32_t y, uint32_t color, int32_t width) {
    +    if (!vecOn(doc)) {
    +        return;
    +    }
    +
    +    vecColor(doc, color);
    +    vecPrintf(doc, "%.6g %.6g %.6g dvxD\n", x + DOT_CENTRE, y + DOT_CENTRE, (width < 1 ? 1 : width) / 2.0);
    +}
    +
    +
    +// The outline through the dots' centres, dashed from angle 0 (an arc's
    +// path starts its start along the pattern); an ellipse with no width or
    +// height is the line it has become.
    +void printVecEllipse(PrintDocT *doc, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t color, int32_t width, uint16_t dash, bool arc, double start, double end) {
    +    if (!vecOn(doc) || dash == DASH_NONE) {
    +        return;
    +    }
    +
    +    if (rx <= 0 || ry <= 0) {
    +        printVecLine(doc, cx - rx, cy - ry, cx + rx, cy + ry, color, width, dash);
    +        return;
    +    }
    +
    +    vecColor(doc, color);
    +    vecPen(doc, width, dash != DASH_SOLID);
    +
    +    if (dash != DASH_SOLID) {
    +        vecDashes(doc, dash, 1.0, arc ? start * (rx + ry) / 2.0 : 0.0);
    +    }
    +
    +    if (arc) {
    +        vecPrintf(doc, "%.6g %.6g %" PRId32 " %" PRId32 " %.6g %.6g dvxEA stroke\n", cx + DOT_CENTRE, cy + DOT_CENTRE, rx, ry, start * DEGREES_PER_RADIAN, end * DEGREES_PER_RADIAN);
    +    } else {
    +        vecPrintf(doc, "%.6g %.6g %" PRId32 " %" PRId32 " dvxE stroke\n", cx + DOT_CENTRE, cy + DOT_CENTRE, rx, ry);
    +    }
    +}
    +
    +
    +// Out to the edges of the dots rx and ry from the centre.
    +void printVecEllipseFill(PrintDocT *doc, int32_t cx, int32_t cy, int32_t rx, int32_t ry, uint32_t color, PrintFillE fill) {
    +    char shape[PS_VEC_CMD_MAX];
    +
    +    if (!vecOn(doc)) {
    +        return;
    +    }
    +
    +    rx = rx < 0 ? -rx : rx;
    +    ry = ry < 0 ? -ry : ry;
    +    vecColor(doc, color);
    +    snprintf(shape, sizeof(shape), "%.6g %.6g %.6g %.6g dvxE", cx + DOT_CENTRE, cy + DOT_CENTRE, rx + DOT_CENTRE, ry + DOT_CENTRE);
    +    vecFill(doc, shape, fill, cx - rx, cy - ry, cx + rx, cy + ry);
    +}
    +
    +
    +bool printVecEnd(PrintDocT *doc) {
    +    if (!doc || !doc->vecPage) {
    +        return false;
    +    }
    +
    +    doc->vecPage = false;
    +    doc->ok      = doc->ok && doc->drv->pageEnd(doc->drv, doc->job) && !printJobCancelled(doc->job);
    +    return doc->ok;
    +}
    +
    +
    +// The part of the picture as an image of its own dots, grey on a
    +// black-and-white printer, the rows packed and in ASCII85.
    +void printVecImage(PrintDocT *doc, const uint8_t *rgb, int32_t w, int32_t h, int32_t sx, int32_t sy, int32_t sw, int32_t sh, int32_t dx, int32_t dy, int32_t dw, int32_t dh) {
    +    if (!vecOn(doc) || !rgb || sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0 || sx < 0 || sy < 0 || sx + sw > w || sy + sh > h) {
    +        return;
    +    }
    +
    +    const PsParamsT *pp     = (const PsParamsT *)doc->drv->params;
    +    int32_t          comps  = pp->color ? RGB_BYTES : GRAY_BYTES;
    +    int32_t          rowLen = sw * comps;
    +    uint8_t         *row    = (uint8_t *)malloc((size_t)rowLen);
    +    uint8_t         *packed = (uint8_t *)malloc((size_t)PRINT_PACK_MAX(rowLen));
    +    uint8_t          eod    = RLE_EOD;
    +    A85T             a;
    +
    +    if (!row || !packed) {
    +        free(row);
    +        free(packed);
    +        doc->ok = false;
    +        return;
    +    }
    +
    +    memset(&a, 0, sizeof(a));
    +    a.job = doc->job;
    +    a.ok  = true;
    +    vecPrintf(doc, "%" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " dvxImg\n", dx, dy, dw, dh, sw, sh, comps);
    +
    +    for (int32_t r = 0; a.ok && doc->ok && r < sh; r++) {
    +        const uint8_t *src = rgb + ((int64_t)(sy + r) * w + sx) * RGB_BYTES;
    +
    +        if (pp->color) {
    +            memcpy(row, src, (size_t)rowLen);
    +        } else {
    +            for (int32_t i = 0; i < sw; i++) {
    +                row[i] = (uint8_t)printLuma(src + i * RGB_BYTES);
    +            }
    +        }
    +
    +        a85Put(&a, packed, printPackBits(row, rowLen, packed));
    +    }
    +
    +    a85Put(&a, &eod, 1);
    +    doc->ok = doc->ok && a.ok && a85End(&a);
    +    free(row);
    +    free(packed);
    +}
    +
    +
    +// A solid line through the dots' centres, round at the ends; a dashed
    +// one in steps of its longer side in dots, a bit of dash each from the
    +// top one, each step centred on its dot (the ends half a step out, as
    +// dots), square at the ends.
    +void printVecLine(PrintDocT *doc, double x0, double y0, double x1, double y1, uint32_t color, int32_t width, uint16_t dash) {
    +    if (!vecOn(doc) || dash == DASH_NONE || !(fabs(x0) <= DBL_MAX && fabs(y0) <= DBL_MAX && fabs(x1) <= DBL_MAX && fabs(y1) <= DBL_MAX)) {
    +        return;
    +    }
    +
    +    x0 += DOT_CENTRE;
    +    y0 += DOT_CENTRE;
    +    x1 += DOT_CENTRE;
    +    y1 += DOT_CENTRE;
    +    vecColor(doc, color);
    +
    +    if (dash == DASH_SOLID) {
    +        vecPen(doc, width, false);
    +        vecPrintf(doc, "%.6g %.6g %.6g %.6g dvxL\n", x0, y0, x1, y1);
    +        return;
    +    }
    +
    +    double ax    = fabs(floor(x1) - floor(x0));
    +    double ay    = fabs(floor(y1) - floor(y0));
    +    double steps = ax > ay ? ax : ay;
    +
    +    if (steps == 0.0) {
    +        if (dash & DASH_TOP) {
    +            printVecDot(doc, (int32_t)floor(x0), (int32_t)floor(y0), color, width);
    +        }
    +
    +        return;
    +    }
    +
    +    double len  = sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0));
    +    double unit = len / steps;
    +    double ex   = (x1 - x0) / len * unit * DOT_CENTRE;
    +    double ey   = (y1 - y0) / len * unit * DOT_CENTRE;
    +
    +    vecPen(doc, width, true);
    +    vecDashes(doc, dash, unit, 0.0);
    +    vecPrintf(doc, "%.6g %.6g %.6g %.6g dvxL\n", x0 - ex, y0 - ey, x1 + ex, y1 + ey);
    +}
    +
    +
    +void printVecRect(PrintDocT *doc, int32_t x0, int32_t y0, int32_t x1, int32_t y1, uint32_t color, PrintFillE fill) {
    +    int32_t left   = x0 < x1 ? x0 : x1;
    +    int32_t right  = x0 < x1 ? x1 : x0;
    +    int32_t top    = y0 < y1 ? y0 : y1;
    +    int32_t bottom = y0 < y1 ? y1 : y0;
    +    char    shape[PS_VEC_CMD_MAX];
    +
    +    if (!vecOn(doc)) {
    +        return;
    +    }
    +
    +    vecColor(doc, color);
    +    snprintf(shape, sizeof(shape), "%" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " dvxBox", left, top, right - left + 1, bottom - top + 1);
    +    vecFill(doc, shape, fill, left, top, right, bottom);
    +}
    +
    +
    +// The text at its baseline, then its underline and strike-through as
    +// the face has them (the strike-through half the x-height up).
    +void printVecText(PrintDocT *doc, const PrintFontT *font, int32_t x, int32_t y, const char *text, int32_t len, uint32_t color) {
    +    if (!vecOn(doc) || !font || font->psFace == PRINT_PS_NONE || !text || len <= 0) {
    +        return;
    +    }
    +
    +    const PrintPsFaceT *face  = &printPsFaces[font->psFace];
    +    double              base  = y + printPsAscent(font);
    +    double              wide  = printPsWidth(font, text, len);
    +    double              thick = face->underlineThick * font->psSizeY / PRINT_PS_UNITS;
    +    double              lines[TEXT_LINES_MAX];
    +    int32_t             count = 0;
    +
    +    vecColor(doc, color);
    +    vecFont(doc, font);
    +    doc->ok = doc->ok && psString(doc->job, text, len);
    +    vecPrintf(doc, " %" PRId32 " %.6g dvxT\n", x, base);
    +
    +    if (font->style & FONT_STYLE_UNDERLINE) {
    +        lines[count++] = base - face->underlinePos * font->psSizeY / PRINT_PS_UNITS;
    +    }
    +
    +    if (font->style & FONT_STYLE_STRIKE) {
    +        lines[count++] = base - face->xHeight / 2.0 * font->psSizeY / PRINT_PS_UNITS;
    +    }
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        vecPrintf(doc, "%" PRId32 " %.6g %.6g %.6g rectfill\n", x, lines[i] - thick / 2.0, wide, thick);
    +    }
    +}
    +
    +
    +// A band with ink: its place and size, then its rows packed and in
    +// ASCII85 (the color image's rows built from the planes).
    +static bool psBand(const PrintDriverT *drv, PrintJobT *job, const PrintPlanesT *planes) {
    +    const PsParamsT *pp = (const PsParamsT *)drv->params;
    +
    +    if (!bandInk(planes)) {
    +        return true;
    +    }
    +
    +    int32_t  rowLen = pp->color ? (planes->widthDots + 1) / PS_DOTS_PER_BYTE : planes->rowBytes;
    +    uint8_t *row    = pp->color ? (uint8_t *)malloc((size_t)rowLen) : NULL;
    +    uint8_t *packed = (uint8_t *)malloc((size_t)PRINT_PACK_MAX(rowLen));
    +    char     cmd[PS_CMD_MAX];
    +    A85T     a;
    +
    +    if (!packed || (pp->color && !row)) {
    +        free(row);
    +        free(packed);
    +        return false;
    +    }
    +
    +    memset(&a, 0, sizeof(a));
    +    a.job = job;
    +    snprintf(cmd, sizeof(cmd), PS_BAND_FMT, planes->y0, planes->widthDots, planes->rows, pp->bandProc);
    +    a.ok = printJobPuts(job, cmd);
    +
    +    for (int32_t r = 0; a.ok && r < planes->rows; r++) {
    +        const uint8_t *src = planes->bits[0] + r * planes->rowBytes;
    +
    +        if (pp->color) {
    +            colorRow(planes, r, row);
    +            src = row;
    +        }
    +
    +        a85Put(&a, packed, printPackBits(src, rowLen, packed));
    +    }
    +
    +    uint8_t eod = RLE_EOD;
    +
    +    a85Put(&a, &eod, 1);
    +    free(row);
    +    free(packed);
    +    return a.ok && a85End(&a);
    +}
    +
    +
    +static bool psDocBegin(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, sProlog);
    +}
    +
    +
    +static bool psDocEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, PS_EOF);
    +}
    +
    +
    +static bool psPageBegin(const PrintDriverT *drv, PrintJobT *job, int32_t widthDots, int32_t heightDots) {
    +    char cmd[PS_CMD_MAX];
    +
    +    (void)widthDots;
    +    (void)heightDots;
    +    snprintf(cmd, sizeof(cmd), PS_PAGE_FMT, drv->dpiX);
    +    return printJobPuts(job, cmd);
    +}
    +
    +
    +static bool psPageEnd(const PrintDriverT *drv, PrintJobT *job) {
    +    (void)drv;
    +    return printJobPuts(job, PS_PAGE_END);
    +}
    +
    +
    +// (text): ( ) and \ escaped, CP437 as Latin-1 (past ASCII in octal), a
    +// long string going on over lines.
    +static bool psString(PrintJobT *job, const char *text, int32_t len) {
    +    char    out[PS_STRING_LINE + PS_STRING_ESCAPE + 1];
    +    int32_t n  = 0;
    +    bool    ok = true;
    +
    +    out[n++] = '(';
    +
    +    for (int32_t i = 0; i < len; i++) {
    +        int32_t c = printPsLatin1((uint8_t)text[i]);
    +
    +        if (c == '(' || c == ')' || c == '\\') {
    +            out[n++] = '\\';
    +            out[n++] = (char)c;
    +        } else if (c >= LATIN1_FIRST) {
    +            n += snprintf(out + n, sizeof(out) - (size_t)n, OCTAL_ESCAPE, (unsigned)c);
    +        } else {
    +            out[n++] = (char)c;
    +        }
    +
    +        if (n >= PS_STRING_LINE && i < len - 1) {
    +            ok = ok && printJobWrite(job, out, n) && printJobPuts(job, PS_STRING_CONTINUE);
    +            n  = 0;
    +        }
    +    }
    +
    +    out[n++] = ')';
    +    return ok && printJobWrite(job, out, n);
    +}
    +
    +
    +// The prolog and the text's size: Courier as large as fills the columns
    +// across and fits the lines down.
    +static bool psTextBegin(PrintJobT *job, int32_t columns, int32_t lines) {
    +    int32_t across = POINTS_WIDTH * HUNDREDTHS * HUNDREDTHS / (columns * COURIER_ADVANCE);
    +    int32_t lead   = POINTS_HEIGHT * HUNDREDTHS / lines;
    +    int32_t size   = across < lead ? across : lead;
    +    char    cmd[PS_CMD_MAX];
    +
    +    snprintf(cmd, sizeof(cmd), PS_SIZES_FMT, size / HUNDREDTHS, size % HUNDREDTHS, lead / HUNDREDTHS, lead % HUNDREDTHS);
    +    return printJobPuts(job, sProlog) && printJobPuts(job, cmd);
    +}
    +
    +
    +static bool psTextEnd(PrintJobT *job) {
    +    return printJobPuts(job, PS_EOF);
    +}
    +
    +
    +static bool psTextLine(PrintJobT *job, const char *text, int32_t len) {
    +    return psString(job, text, len) && printJobPuts(job, PS_SHOW_LINE);
    +}
    +
    +
    +static bool psTextPageBegin(PrintJobT *job) {
    +    return printJobPuts(job, PS_TEXT_PAGE);
    +}
    +
    +
    +static bool psTextPageEnd(PrintJobT *job) {
    +    return printJobPuts(job, PS_TEXT_PAGE_END);
    +}
    +
    +
    +// The colour, sent when it changed: grey (its brightness) on a
    +// black-and-white printer.
    +static void vecColor(PrintDocT *doc, uint32_t color) {
    +    const PsParamsT *pp = (const PsParamsT *)doc->drv->params;
    +    uint8_t          px[RGB_BYTES];
    +
    +    if (color == doc->vecColor) {
    +        return;
    +    }
    +
    +    px[0]         = (uint8_t)((color >> RGB_RED_SHIFT) & RGB_MASK);
    +    px[1]         = (uint8_t)((color >> RGB_GREEN_SHIFT) & RGB_MASK);
    +    px[2]         = (uint8_t)(color & RGB_MASK);
    +    doc->vecColor = color;
    +
    +    if (pp->color) {
    +        vecPrintf(doc, "%.3g %.3g %.3g setrgbcolor\n", px[0] / COLOR_LEVELS, px[1] / COLOR_LEVELS, px[2] / COLOR_LEVELS);
    +    } else {
    +        vecPrintf(doc, "%.3g setgray\n", printLuma(px) / COLOR_LEVELS);
    +    }
    +}
    +
    +
    +// A dash pattern (neither solid nor empty) as PostScript's: its runs from
    +// the start of a run of dashes, unit long a step, the path starting phase
    +// steps into the pattern.
    +static void vecDashes(PrintDocT *doc, uint16_t dash, double unit, double phase) {
    +    char    cmd[PS_VEC_CMD_MAX];
    +    int32_t from = 0;
    +    int32_t run  = 0;
    +    int32_t n;
    +    double  offset;
    +
    +    while (!dashOn(dash, from) || dashOn(dash, from - 1)) {
    +        from++;
    +    }
    +
    +    n = snprintf(cmd, sizeof(cmd), "[");
    +
    +    for (int32_t i = 0; i < DASH_BITS; i++) {
    +        run++;
    +
    +        if (dashOn(dash, from + i) != dashOn(dash, from + i + 1)) {
    +            n  += snprintf(cmd + n, sizeof(cmd) - (size_t)n, "%.6g ", run * unit);
    +            run = 0;
    +        }
    +    }
    +
    +    offset = phase - from;
    +    offset = offset - DASH_BITS * floor(offset / DASH_BITS);
    +    vecPrintf(doc, "%s] %.6g setdash\n", cmd, offset * unit);
    +}
    +
    +
    +// A shape (the operands and procedure of its path) filled: solid, or
    +// its hatch lines, one dot wide, over its box x0 .. x1, y0 .. y1 with
    +// the shape as the clip.
    +static void vecFill(PrintDocT *doc, const char *shape, PrintFillE fill, int32_t x0, int32_t y0, int32_t x1, int32_t y1) {
    +    if (fill == PrintFillSolidE) {
    +        vecPrintf(doc, "%s fill\n", shape);
    +        return;
    +    }
    +
    +    vecPrintf(doc, "gsave %s clip newpath\n", shape);
    +
    +    if (fill == PrintFillHorizontalE || fill == PrintFillCrossE) {
    +        vecPrintf(doc, PS_HATCH_FMT, hatchFirst(y0), PRINT_HATCH_DOTS, y1, x0, x1 + 1, "dvxHz");
    +    }
    +
    +    if (fill == PrintFillVerticalE || fill == PrintFillCrossE) {
    +        vecPrintf(doc, PS_HATCH_FMT, hatchFirst(x0), PRINT_HATCH_DOTS, x1, y0, y1 + 1, "dvxVt");
    +    }
    +
    +    if (fill == PrintFillUpDiagonalE || fill == PrintFillDiagonalCrossE) {
    +        vecPrintf(doc, PS_HATCH_FMT, hatchFirst(x0 + y0), PRINT_HATCH_DOTS, x1 + y1, y0, y1 + 1, "dvxUp");
    +    }
    +
    +    if (fill == PrintFillDownDiagonalE || fill == PrintFillDiagonalCrossE) {
    +        vecPrintf(doc, PS_HATCH_FMT, hatchFirst(x0 - y1), PRINT_HATCH_DOTS, x1 - y0, y0, y1 + 1, "dvxDn");
    +    }
    +
    +    vecPrintf(doc, "1 setlinewidth 0 setlinecap stroke grestore\n");
    +}
    +
    +
    +// The font's face made Latin-1 in the job (once), and the font sent
    +// when it changed.
    +static void vecFont(PrintDocT *doc, const PrintFontT *font) {
    +    uint16_t bit = (uint16_t)(1u << font->psFace);
    +
    +    if (!(doc->psFaces & bit)) {
    +        vecPrintf(doc, "/DvxF%" PRId32 " /%s dvxRe\n", font->psFace, printPsFaces[font->psFace].name);
    +        doc->psFaces |= bit;
    +    }
    +
    +    if (font->psFace != doc->vecFace || font->psSizeX != doc->vecSizeX || font->psSizeY != doc->vecSizeY) {
    +        vecPrintf(doc, "/DvxF%" PRId32 " %.6g %.6g dvxSF\n", font->psFace, font->psSizeX, font->psSizeY);
    +        doc->vecFace  = font->psFace;
    +        doc->vecSizeX = font->psSizeX;
    +        doc->vecSizeY = font->psSizeY;
    +    }
    +}
    +
    +
    +// Whether vector drawing goes out: on a vector page of a document that
    +// has not failed.
    +static bool vecOn(const PrintDocT *doc) {
    +    return doc && doc->vecPage && doc->ok;
    +}
    +
    +
    +// The line width (at least a dot) and ends (round, or square for dashes,
    +// whose pattern then each line sets), sent when they changed.
    +static void vecPen(PrintDocT *doc, int32_t width, bool dashed) {
    +    width = width < 1 ? 1 : width;
    +
    +    if (dashed != doc->vecDashed) {
    +        vecPrintf(doc, dashed ? "0 setlinecap\n" : "[] 0 setdash 1 setlinecap\n");
    +        doc->vecDashed = dashed;
    +    }
    +
    +    if (width != doc->vecWidth) {
    +        vecPrintf(doc, "%" PRId32 " setlinewidth\n", width);
    +        doc->vecWidth = width;
    +    }
    +}
    +
    +
    +static void vecPrintf(PrintDocT *doc, const char *fmt, ...) {
    +    char    buf[PS_VEC_CMD_MAX];
    +    va_list ap;
    +
    +    va_start(ap, fmt);
    +    vsnprintf(buf, sizeof(buf), fmt, ap);
    +    va_end(ap);
    +    doc->ok = doc->ok && printJobPuts(doc->job, buf);
    +}
    diff --git a/src/libs/kpunch/print/printPsAfm.c b/src/libs/kpunch/print/printPsAfm.c
    new file mode 100644
    index 00000000..fd429326
    --- /dev/null
    +++ b/src/libs/kpunch/print/printPsAfm.c
    @@ -0,0 +1,264 @@
    +// printPsAfm.c  -- Printing library: the widths of the PostScript
    +// printers' own text faces (printInt.h), made by src/tools/afmWidths.py
    +// -- do not edit.
    +//
    +// Modified from Adobe's Core 14 AFM files: only the character widths (in
    +// thousandths of the font size, in ISOLatin1Encoding order from code 32),
    +// the ascender, descender, x-height, and underline of twelve faces are
    +// kept, as C tables.  The files' notices:
    +//
    +//   Copyright (c) 1985, 1987, 1989, 1990, 1997 Adobe Systems Incorporated.
    +//   All Rights Reserved. Helvetica is a trademark of Linotype-Hell AG
    +//   and/or its subsidiaries.
    +//
    +//   Copyright (c) 1985, 1987, 1989, 1990, 1993, 1997 Adobe Systems
    +//   Incorporated. All Rights Reserved. Times is a trademark of
    +//   Linotype-Hell AG and/or its subsidiaries.
    +//
    +//   Copyright (c) 1989, 1990, 1991, 1992, 1993, 1997 Adobe Systems
    +//   Incorporated. All Rights Reserved.
    +//
    +//   Copyright (c) 1989, 1990, 1991, 1993, 1997 Adobe Systems Incorporated.
    +//   All Rights Reserved.
    +//
    +// The licence they come with (MustRead.html):
    +//
    +// This file and the 14 PostScript(R) AFM files it accompanies may be used,
    +// copied, and distributed for any purpose and without charge, with or
    +// without modification, provided that all copyright notices are retained;
    +// that the AFM files are not distributed without this file; that all
    +// modifications to this file or any of the AFM files are prominently noted
    +// in the modified file(s); and that this paragraph is not modified.
    +
    +#include "printInt.h"
    +
    +const PrintPsFaceT printPsFaces[PRINT_PS_FACES] = {
    +    {
    +        "Helvetica", 718, -207, 523, -100, 50,
    +        {
    +             278,  278,  355,  556,  556,  889,  667,  222,  333,  333,  389,  584,  278,  584,  278,  278,
    +             556,  556,  556,  556,  556,  556,  556,  556,  556,  556,  278,  278,  584,  584,  584,  556,
    +            1015,  667,  667,  722,  722,  667,  611,  778,  722,  278,  500,  667,  556,  833,  722,  778,
    +             667,  778,  722,  667,  611,  722,  667,  944,  667,  667,  611,  278,  278,  278,  469,  556,
    +             222,  556,  556,  500,  556,  556,  278,  556,  556,  222,  222,  500,  222,  833,  556,  556,
    +             556,  556,  333,  500,  278,  556,  500,  722,  500,  500,  500,  334,  260,  334,  584,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             278,  333,  556,  556,  556,  556,  260,  556,  333,  737,  370,  556,  584,  333,  737,  333,
    +             400,  584,  333,  333,  333,  556,  537,  278,  333,  333,  365,  556,  834,  834,  834,  611,
    +             667,  667,  667,  667,  667,  667, 1000,  722,  667,  667,  667,  667,  278,  278,  278,  278,
    +             722,  722,  778,  778,  778,  778,  778,  584,  778,  722,  722,  722,  722,  667,  667,  611,
    +             556,  556,  556,  556,  556,  556,  889,  500,  556,  556,  556,  556,  278,  278,  278,  278,
    +             556,  556,  556,  556,  556,  556,  556,  584,  611,  556,  556,  556,  556,  500,  556,  500
    +        }
    +    },
    +    {
    +        "Helvetica-Bold", 718, -207, 532, -100, 50,
    +        {
    +             278,  333,  474,  556,  556,  889,  722,  278,  333,  333,  389,  584,  278,  584,  278,  278,
    +             556,  556,  556,  556,  556,  556,  556,  556,  556,  556,  333,  333,  584,  584,  584,  611,
    +             975,  722,  722,  722,  722,  667,  611,  778,  722,  278,  556,  722,  611,  833,  722,  778,
    +             667,  778,  722,  667,  611,  722,  667,  944,  667,  667,  611,  333,  278,  333,  584,  556,
    +             278,  556,  611,  556,  611,  556,  333,  611,  611,  278,  278,  556,  278,  889,  611,  611,
    +             611,  611,  389,  556,  333,  611,  556,  778,  556,  556,  500,  389,  280,  389,  584,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             278,  333,  556,  556,  556,  556,  280,  556,  333,  737,  370,  556,  584,  333,  737,  333,
    +             400,  584,  333,  333,  333,  611,  556,  278,  333,  333,  365,  556,  834,  834,  834,  611,
    +             722,  722,  722,  722,  722,  722, 1000,  722,  667,  667,  667,  667,  278,  278,  278,  278,
    +             722,  722,  778,  778,  778,  778,  778,  584,  778,  722,  722,  722,  722,  667,  667,  611,
    +             556,  556,  556,  556,  556,  556,  889,  556,  556,  556,  556,  556,  278,  278,  278,  278,
    +             611,  611,  611,  611,  611,  611,  611,  584,  611,  611,  611,  611,  611,  556,  611,  556
    +        }
    +    },
    +    {
    +        "Helvetica-Oblique", 718, -207, 523, -100, 50,
    +        {
    +             278,  278,  355,  556,  556,  889,  667,  222,  333,  333,  389,  584,  278,  584,  278,  278,
    +             556,  556,  556,  556,  556,  556,  556,  556,  556,  556,  278,  278,  584,  584,  584,  556,
    +            1015,  667,  667,  722,  722,  667,  611,  778,  722,  278,  500,  667,  556,  833,  722,  778,
    +             667,  778,  722,  667,  611,  722,  667,  944,  667,  667,  611,  278,  278,  278,  469,  556,
    +             222,  556,  556,  500,  556,  556,  278,  556,  556,  222,  222,  500,  222,  833,  556,  556,
    +             556,  556,  333,  500,  278,  556,  500,  722,  500,  500,  500,  334,  260,  334,  584,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             278,  333,  556,  556,  556,  556,  260,  556,  333,  737,  370,  556,  584,  333,  737,  333,
    +             400,  584,  333,  333,  333,  556,  537,  278,  333,  333,  365,  556,  834,  834,  834,  611,
    +             667,  667,  667,  667,  667,  667, 1000,  722,  667,  667,  667,  667,  278,  278,  278,  278,
    +             722,  722,  778,  778,  778,  778,  778,  584,  778,  722,  722,  722,  722,  667,  667,  611,
    +             556,  556,  556,  556,  556,  556,  889,  500,  556,  556,  556,  556,  278,  278,  278,  278,
    +             556,  556,  556,  556,  556,  556,  556,  584,  611,  556,  556,  556,  556,  500,  556,  500
    +        }
    +    },
    +    {
    +        "Helvetica-BoldOblique", 718, -207, 532, -100, 50,
    +        {
    +             278,  333,  474,  556,  556,  889,  722,  278,  333,  333,  389,  584,  278,  584,  278,  278,
    +             556,  556,  556,  556,  556,  556,  556,  556,  556,  556,  333,  333,  584,  584,  584,  611,
    +             975,  722,  722,  722,  722,  667,  611,  778,  722,  278,  556,  722,  611,  833,  722,  778,
    +             667,  778,  722,  667,  611,  722,  667,  944,  667,  667,  611,  333,  278,  333,  584,  556,
    +             278,  556,  611,  556,  611,  556,  333,  611,  611,  278,  278,  556,  278,  889,  611,  611,
    +             611,  611,  389,  556,  333,  611,  556,  778,  556,  556,  500,  389,  280,  389,  584,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             278,  333,  556,  556,  556,  556,  280,  556,  333,  737,  370,  556,  584,  333,  737,  333,
    +             400,  584,  333,  333,  333,  611,  556,  278,  333,  333,  365,  556,  834,  834,  834,  611,
    +             722,  722,  722,  722,  722,  722, 1000,  722,  667,  667,  667,  667,  278,  278,  278,  278,
    +             722,  722,  778,  778,  778,  778,  778,  584,  778,  722,  722,  722,  722,  667,  667,  611,
    +             556,  556,  556,  556,  556,  556,  889,  556,  556,  556,  556,  556,  278,  278,  278,  278,
    +             611,  611,  611,  611,  611,  611,  611,  584,  611,  611,  611,  611,  611,  556,  611,  556
    +        }
    +    },
    +    {
    +        "Times-Roman", 683, -217, 450, -100, 50,
    +        {
    +             250,  333,  408,  500,  500,  833,  778,  333,  333,  333,  500,  564,  250,  564,  250,  278,
    +             500,  500,  500,  500,  500,  500,  500,  500,  500,  500,  278,  278,  564,  564,  564,  444,
    +             921,  722,  667,  667,  722,  611,  556,  722,  722,  333,  389,  722,  611,  889,  722,  722,
    +             556,  722,  667,  556,  611,  722,  722,  944,  722,  722,  611,  333,  278,  333,  469,  500,
    +             333,  444,  500,  444,  500,  444,  333,  500,  500,  278,  278,  500,  278,  778,  500,  500,
    +             500,  500,  333,  389,  278,  500,  500,  722,  500,  500,  444,  480,  200,  480,  541,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             250,  333,  500,  500,  500,  500,  200,  500,  333,  760,  276,  500,  564,  333,  760,  333,
    +             400,  564,  300,  300,  333,  500,  453,  250,  333,  300,  310,  500,  750,  750,  750,  444,
    +             722,  722,  722,  722,  722,  722,  889,  667,  611,  611,  611,  611,  333,  333,  333,  333,
    +             722,  722,  722,  722,  722,  722,  722,  564,  722,  722,  722,  722,  722,  722,  556,  500,
    +             444,  444,  444,  444,  444,  444,  667,  444,  444,  444,  444,  444,  278,  278,  278,  278,
    +             500,  500,  500,  500,  500,  500,  500,  564,  500,  500,  500,  500,  500,  500,  500,  500
    +        }
    +    },
    +    {
    +        "Times-Bold", 683, -217, 461, -100, 50,
    +        {
    +             250,  333,  555,  500,  500, 1000,  833,  333,  333,  333,  500,  570,  250,  570,  250,  278,
    +             500,  500,  500,  500,  500,  500,  500,  500,  500,  500,  333,  333,  570,  570,  570,  500,
    +             930,  722,  667,  722,  722,  667,  611,  778,  778,  389,  500,  778,  667,  944,  722,  778,
    +             611,  778,  722,  556,  667,  722,  722, 1000,  722,  722,  667,  333,  278,  333,  581,  500,
    +             333,  500,  556,  444,  556,  444,  333,  500,  556,  278,  333,  556,  278,  833,  556,  500,
    +             556,  556,  444,  389,  333,  556,  500,  722,  500,  500,  444,  394,  220,  394,  520,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             250,  333,  500,  500,  500,  500,  220,  500,  333,  747,  300,  500,  570,  333,  747,  333,
    +             400,  570,  300,  300,  333,  556,  540,  250,  333,  300,  330,  500,  750,  750,  750,  500,
    +             722,  722,  722,  722,  722,  722, 1000,  722,  667,  667,  667,  667,  389,  389,  389,  389,
    +             722,  722,  778,  778,  778,  778,  778,  570,  778,  722,  722,  722,  722,  722,  611,  556,
    +             500,  500,  500,  500,  500,  500,  722,  444,  444,  444,  444,  444,  278,  278,  278,  278,
    +             500,  556,  500,  500,  500,  500,  500,  570,  500,  556,  556,  556,  556,  500,  556,  500
    +        }
    +    },
    +    {
    +        "Times-Italic", 683, -217, 441, -100, 50,
    +        {
    +             250,  333,  420,  500,  500,  833,  778,  333,  333,  333,  500,  675,  250,  675,  250,  278,
    +             500,  500,  500,  500,  500,  500,  500,  500,  500,  500,  333,  333,  675,  675,  675,  500,
    +             920,  611,  611,  667,  722,  611,  611,  722,  722,  333,  444,  667,  556,  833,  667,  722,
    +             611,  722,  611,  500,  556,  722,  611,  833,  611,  556,  556,  389,  278,  389,  422,  500,
    +             333,  500,  500,  444,  500,  444,  278,  500,  500,  278,  278,  444,  278,  722,  500,  500,
    +             500,  500,  389,  389,  278,  500,  444,  667,  444,  444,  389,  400,  275,  400,  541,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             250,  389,  500,  500,  500,  500,  275,  500,  333,  760,  276,  500,  675,  333,  760,  333,
    +             400,  675,  300,  300,  333,  500,  523,  250,  333,  300,  310,  500,  750,  750,  750,  500,
    +             611,  611,  611,  611,  611,  611,  889,  667,  611,  611,  611,  611,  333,  333,  333,  333,
    +             722,  667,  722,  722,  722,  722,  722,  675,  722,  722,  722,  722,  722,  556,  611,  500,
    +             500,  500,  500,  500,  500,  500,  667,  444,  444,  444,  444,  444,  278,  278,  278,  278,
    +             500,  500,  500,  500,  500,  500,  500,  675,  500,  500,  500,  500,  500,  444,  500,  444
    +        }
    +    },
    +    {
    +        "Times-BoldItalic", 683, -217, 462, -100, 50,
    +        {
    +             250,  389,  555,  500,  500,  833,  778,  333,  333,  333,  500,  570,  250,  606,  250,  278,
    +             500,  500,  500,  500,  500,  500,  500,  500,  500,  500,  333,  333,  570,  570,  570,  500,
    +             832,  667,  667,  667,  722,  667,  667,  722,  778,  389,  500,  667,  611,  889,  722,  722,
    +             611,  722,  667,  556,  611,  722,  667,  889,  667,  611,  611,  333,  278,  333,  570,  500,
    +             333,  500,  500,  444,  500,  444,  333,  500,  556,  278,  278,  500,  278,  778,  556,  500,
    +             500,  500,  389,  389,  278,  556,  444,  667,  500,  444,  389,  348,  220,  348,  570,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             278,  333,  333,  333,  333,  333,  333,  333,  333,    0,  333,  333,    0,  333,  333,  333,
    +             250,  389,  500,  500,  500,  500,  220,  500,  333,  747,  266,  500,  606,  333,  747,  333,
    +             400,  570,  300,  300,  333,  576,  500,  250,  333,  300,  300,  500,  750,  750,  750,  500,
    +             667,  667,  667,  667,  667,  667,  944,  667,  667,  667,  667,  667,  389,  389,  389,  389,
    +             722,  722,  722,  722,  722,  722,  722,  570,  722,  722,  722,  722,  722,  611,  611,  500,
    +             500,  500,  500,  500,  500,  500,  722,  444,  444,  444,  444,  444,  278,  278,  278,  278,
    +             500,  556,  500,  500,  500,  500,  500,  570,  500,  556,  556,  556,  556,  444,  500,  444
    +        }
    +    },
    +    {
    +        "Courier", 629, -157, 426, -100, 50,
    +        {
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,    0,  600,  600,    0,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600
    +        }
    +    },
    +    {
    +        "Courier-Bold", 629, -157, 439, -100, 50,
    +        {
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,    0,  600,  600,    0,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600
    +        }
    +    },
    +    {
    +        "Courier-Oblique", 629, -157, 426, -100, 50,
    +        {
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,    0,  600,  600,    0,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600
    +        }
    +    },
    +    {
    +        "Courier-BoldOblique", 629, -157, 439, -100, 50,
    +        {
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,    0,
    +               0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,    0,  600,  600,    0,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,
    +             600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600,  600
    +        }
    +    }
    +};
    diff --git a/src/libs/kpunch/print/printRaster.c b/src/libs/kpunch/print/printRaster.c
    index d4b44a01..d5c00429 100644
    --- a/src/libs/kpunch/print/printRaster.c
    +++ b/src/libs/kpunch/print/printRaster.c
    @@ -20,303 +20,287 @@
     // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
     // IN THE SOFTWARE.
     
    -// printRaster.c  -- Printing library: pictures and the printer drivers
    -// (see print.h)
    +// printRaster.c  -- Printing library: documents of picture pages, and
    +// the page pipeline every printer driver is fed by (see print.h,
    +// printInt.h)
     //
    -// printRasterJob scales the picture (nearest pixel) to the largest size
    -// that fits the driver's printable area, keeping its shape on printers
    -// whose dots are not square, turns each pixel's brightness into black or
    -// white with a 4x4 ordered (Bayer) dither, and hands the driver one band
    -// of dot rows at a time, so only one band is ever in memory.
    +// A page is asked for a band at a time in RGB at the printer's
    +// resolution (a PrintBandFnT); each band is turned into the printer's
    +// inks and dithered, plane by plane, and handed to the driver
    +// (printEscp.c, printPcl.c), so only one band is ever in memory.
     //
    -// Drivers:
    -//   Epson 9-pin    ESC/P, 120 x 72 dpi (ESC * 1: double density), bands of
    -//                  8 rows (one pass of the print head), paper advanced
    -//                  with ESC J n (n/216 inch).
    -//   Epson 24-pin   ESC/P, 180 x 180 dpi (ESC * 39), bands of 24 rows (3
    -//                  bytes per column), paper advanced with ESC J n (n/180
    -//                  inch).
    -//   LaserJet       PCL, 150 dpi raster graphics (ESC * t150R), one
    -//                  ESC * b n W transfer per row (uncompressed, which every
    -//                  LaserJet understands), trailing white bytes left out.
    -// Blank bands on the dot-matrix printers are skipped with a paper feed
    -// only; columns after the last black dot of a band are not sent.
    +//   Inks     Black only: a dot where the brightness (luma) is below the
    +//            dither threshold.  Cyan, magenta and yellow: each plane from
    +//            its own primary (red, green and blue).  With black as well,
    +//            black takes what the three have in common (full undercolour
    +//            removal) and each colour what is left.
    +//   Dither   An ordered (Bayer) threshold, 4x4 on the dot-matrix
    +//            printers and 8x8 at 300 dpi, shifted for each plane so dots
    +//            of different inks do not land on each other in a regular
    +//            pattern.  The threshold follows the row's place on the page,
    +//            so the pattern runs on from band to band.
    +//
    +// A document is the driver's start, its pages, and its end, on a job it
    +// owns.  A page is the whole printable area drawn by the program
    +// (printDocPage), or a picture (printDocPicture) scaled (nearest pixel)
    +// to the largest size that fits, keeping its shape on printers whose
    +// dots are not square.
     
     #include "printInt.h"
     
    -#include 
     #include 
     #include 
     
    -#define ESC                 "\x1B"
    -#define ESC_CHAR            0x1B
    -#define CR_CHAR             '\r'
    -#define FORM_FEED           "\f"
     #define BITS_PER_BYTE       8
     #define TOP_BIT             0x80
    -#define PRINT_WIDTH_IN      8           // printable width, inches
    -#define PRINT_HEIGHT_IN     10          // printable height, inches
    -
    -// Epson ESC/P
    -#define EPSON_RESET         ESC "@"
    -#define EPSON_GRAPHICS      '*'
    -#define EPSON_FEED          'J'
    -#define EPSON9_DPI_X        120
    -#define EPSON9_DPI_Y        72
    -#define EPSON9_MODE         1           // ESC * 1: double density
    -#define EPSON9_PINS         8
    -#define EPSON9_FEED_UNITS   216         // ESC J n feeds n/216 inch
    -#define EPSON24_DPI         180
    -#define EPSON24_MODE        39          // ESC * 39: 180 dpi, 24 pins
    -#define EPSON24_PINS        24
    -#define EPSON24_FEED_UNITS  180         // ESC J n feeds n/180 inch
    -#define EPSON_FEED_MAX      255         // largest n of ESC J n
    -#define EPSON_COUNT_SHIFT   8           // nL nH
    -#define EPSON_COUNT_MASK    0xFF
    -#define EPSON_HEADER_LEN    5           // ESC * m nL nH
    -
    -// HP PCL
    -#define PCL_RESET           ESC "E"
    -#define PCL_DPI             150
    -#define PCL_BAND_ROWS       8
    -#define PCL_BEGIN_FMT       ESC "*t%dR" ESC "*p0x0Y" ESC "*r1A"
    -#define PCL_END_RASTER      ESC "*rB"
    -#define PCL_ROW_FMT         ESC "*b%dW"
    -#define PCL_CMD_MAX         16
    -#define PCL_BEGIN_MAX       48
    +#define RGB_BYTES           3
    +#define WHITE               0xFF
    +#define BRIGHTNESS_LEVELS   256
     
     // Luma weights (ITU-R BT.601, per mille)
     #define LUMA_R              299
     #define LUMA_G              587
     #define LUMA_B              114
     #define LUMA_SCALE          1000
    -#define RGB_BYTES           3
     
    -// 4x4 ordered dither: a pixel is black when its brightness is below the
    -// threshold for its position (brightness 0..255).
    -#define DITHER_SIZE         4
    -#define DITHER_MASK         (DITHER_SIZE - 1)
    -#define DITHER_LEVELS       (DITHER_SIZE * DITHER_SIZE)
    -#define BRIGHTNESS_LEVELS   256
    -#define DITHER_STEP         (BRIGHTNESS_LEVELS / DITHER_LEVELS)
    -#define DITHER_HALF_STEP    (DITHER_STEP / 2)
    +// TIFF PackBits (printPackBits): a run of n equal bytes (2..PACK_RUN_MAX)
    +// is (PACK_RUN_BASE - n) and the byte; a stretch of 1..PACK_RUN_MAX others
    +// is (n - 1) and the bytes.  Runs start at PACK_RUN_MIN bytes (a shorter
    +// one would cost as much as it saves and split the stretch around it), so
    +// n bytes pack into at most PRINT_PACK_MAX(n).
    +#define PACK_RUN_MIN        3
    +#define PACK_RUN_BASE       257
     
    -static const uint8_t sBayer[DITHER_SIZE][DITHER_SIZE] = {
    +// How a band's colours become a driver's inks
    +typedef enum {
    +    InkModeLumaE,           // black only
    +    InkModeCmyE,            // cyan, magenta, yellow
    +    InkModeKcmyE            // black and the three
    +} InkModeE;
    +
    +// A picture printDocPicture scales onto the page
    +typedef struct {
    +    const uint8_t *rgb;
    +    int32_t        w;
    +    int32_t        h;
    +    int32_t        pitch;
    +    int32_t        outW;
    +    int32_t        outH;
    +} PictureT;
    +
    +const PrintInkE printInksBlack[1]                = { PrintInkBlackE };
    +const PrintInkE printInksKcmy[PRINT_PLANES_MAX] = { PrintInkBlackE, PrintInkCyanE, PrintInkMagentaE, PrintInkYellowE };
    +
    +static const uint8_t sBayer4[PRINT_DITHER_SMALL][PRINT_DITHER_SMALL] = {
         {  0,  8,  2, 10 },
         { 12,  4, 14,  6 },
         {  3, 11,  1,  9 },
         { 15,  7, 13,  5 }
     };
     
    +static const uint8_t sBayer8[PRINT_DITHER_LARGE][PRINT_DITHER_LARGE] = {
    +    {  0, 32,  8, 40,  2, 34, 10, 42 },
    +    { 48, 16, 56, 24, 50, 18, 58, 26 },
    +    { 12, 44,  4, 36, 14, 46,  6, 38 },
    +    { 60, 28, 52, 20, 62, 30, 54, 22 },
    +    {  3, 35, 11, 43,  1, 33,  9, 41 },
    +    { 51, 19, 59, 27, 49, 17, 57, 25 },
    +    { 15, 47,  7, 39, 13, 45,  5, 37 },
    +    { 63, 31, 55, 23, 61, 29, 53, 21 }
    +};
    +
    +// Each plane's shift of the matrix (across, down), in quarters of it, so
    +// the inks' dots fall in different places; the first plane is unshifted
    +static const uint8_t sPlaneShift[PRINT_PLANES_MAX][2] = {
    +    { 0, 0 },
    +    { 2, 1 },
    +    { 1, 3 },
    +    { 3, 2 }
    +};
    +
     // ============================================================
     // Prototypes
     // ============================================================
     
    -static bool    bitSet(const uint8_t *bits, int32_t rowBytes, int32_t row, int32_t x);
    -static bool    epson24Band(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots);
    -static bool    epson9Band(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots);
    -static bool    epsonBand(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots, int32_t mode, int32_t pins, int32_t feedPerRow);
    -static bool    epsonBegin(PrintJobT *job, int32_t widthDots, int32_t heightDots);
    -static bool    epsonEnd(PrintJobT *job);
    -static bool    epsonFeed(PrintJobT *job, int32_t units);
    -static int32_t lastInkColumn(const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots);
    -static bool    pclBand(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots);
    -static bool    pclBegin(PrintJobT *job, int32_t widthDots, int32_t heightDots);
    -static bool    pclEnd(PrintJobT *job);
    +static void         bandToPlanes(const PrintDriverT *drv, InkModeE mode, const uint8_t *rgb, int32_t y0, PrintPlanesT *pl);
    +static InkModeE     inkModeOf(const PrintDriverT *drv);
    +static int32_t      inkValue(InkModeE mode, PrintInkE ink, const uint8_t *px);
    +static bool         pictureBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +bool                printDocCancelled(const PrintDocT *doc);
    +bool                printDocClose(PrintDocT *doc);
    +void                printDocInfo(const PrintDocT *doc, PrintPageInfoT *info);
    +PrintDocT          *printDocOpen(PrintJobT *job, PrintModelE model);
    +bool                printDocPage(PrintDocT *doc, PrintBandFnT fn, void *ctx);
    +bool                printDocPicture(PrintDocT *doc, const uint8_t *rgb, int32_t w, int32_t h, int32_t pitch);
     const PrintDriverT *printDriver(PrintModelE model);
    -bool           printRasterJob(PrintJobT *job, const PrintDriverT *drv, const uint8_t *rgb, int32_t w, int32_t h, int32_t pitch);
    -
    -static const PrintDriverT sEpson9 = {
    -    "Epson 9-pin (ESC/P)", EPSON9_DPI_X, EPSON9_DPI_Y, EPSON9_DPI_X * PRINT_WIDTH_IN, EPSON9_DPI_Y * PRINT_HEIGHT_IN, EPSON9_PINS,
    -    EPSON_RESET, "", PRINT_LINES_STANDARD, epsonBegin, epson9Band, epsonEnd
    -};
    -
    -static const PrintDriverT sEpson24 = {
    -    "Epson 24-pin (ESC/P)", EPSON24_DPI, EPSON24_DPI, EPSON24_DPI * PRINT_WIDTH_IN, EPSON24_DPI * PRINT_HEIGHT_IN, EPSON24_PINS,
    -    EPSON_RESET, "", PRINT_LINES_STANDARD, epsonBegin, epson24Band, epsonEnd
    -};
    -
    -static const PrintDriverT sLaserJet = {
    -    "HP LaserJet (PCL)", PCL_DPI, PCL_DPI, PCL_DPI * PRINT_WIDTH_IN, PCL_DPI * PRINT_HEIGHT_IN, PCL_BAND_ROWS,
    -    PCL_RESET, PCL_RESET, PRINT_LINES_LASER, pclBegin, pclBand, pclEnd
    -};
    +int32_t             printLuma(const uint8_t *px);
    +bool                printModelPage(PrintModelE model, PrintPageInfoT *info);
    +int32_t             printPackBits(const uint8_t *in, int32_t n, uint8_t *out);
    +bool                printPageRun(PrintJobT *job, const PrintDriverT *drv, int32_t widthDots, int32_t heightDots, PrintBandFnT fn, void *ctx);
    +bool                printPlaneDot(const PrintPlanesT *pl, int32_t plane, int32_t row, int32_t x);
    +static int32_t      threshold(int32_t size, int32_t plane, int32_t x, int32_t y);
     
     
    -static bool bitSet(const uint8_t *bits, int32_t rowBytes, int32_t row, int32_t x) {
    -    return (bits[row * rowBytes + x / BITS_PER_BYTE] & (TOP_BIT >> (x % BITS_PER_BYTE))) != 0;
    -}
    +// The band's colours as the driver's inks, dithered (the planes come
    +// clear).  Each row's thresholds are worked out once: the pattern repeats
    +// every ditherSize dots across.
    +static void bandToPlanes(const PrintDriverT *drv, InkModeE mode, const uint8_t *rgb, int32_t y0, PrintPlanesT *pl) {
    +    int32_t mask = drv->ditherSize - 1;
     
    +    for (int32_t r = 0; r < pl->rows; r++) {
    +        uint8_t        th[PRINT_PLANES_MAX][PRINT_DITHER_LARGE];
    +        const uint8_t *src = rgb + (int64_t)r * pl->widthDots * RGB_BYTES;
     
    -static bool epson24Band(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots) {
    -    return epsonBand(job, bits, rowBytes, rows, widthDots, EPSON24_MODE, EPSON24_PINS, EPSON24_FEED_UNITS / EPSON24_DPI);
    -}
    -
    -
    -static bool epson9Band(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots) {
    -    return epsonBand(job, bits, rowBytes, rows, widthDots, EPSON9_MODE, EPSON9_PINS, EPSON9_FEED_UNITS / EPSON9_DPI_Y);
    -}
    -
    -
    -// One pass of the print head: ESC * mode nL nH, then for every column
    -// pins/8 bytes (top dot in the high bit), CR, and a paper feed of the
    -// band's height.  A band with no ink only feeds.
    -static bool epsonBand(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots, int32_t mode, int32_t pins, int32_t feedPerRow) {
    -    int32_t cols        = lastInkColumn(bits, rowBytes, rows, widthDots) + 1;
    -    int32_t bytesPerCol = pins / BITS_PER_BYTE;
    -
    -    if (cols > 0) {
    -        uint8_t *line = (uint8_t *)malloc((size_t)(EPSON_HEADER_LEN + cols * bytesPerCol + 1));
    -
    -        if (!line) {
    -            return false;
    +        for (int32_t p = 0; p < pl->count; p++) {
    +            for (int32_t i = 0; i < drv->ditherSize; i++) {
    +                th[p][i] = (uint8_t)threshold(drv->ditherSize, p, i, y0 + r);
    +            }
             }
     
    -        int32_t n = 0;
    +        for (int32_t x = 0; x < pl->widthDots; x++) {
    +            const uint8_t *px = src + x * RGB_BYTES;
     
    -        line[n++] = ESC_CHAR;
    -        line[n++] = EPSON_GRAPHICS;
    -        line[n++] = (uint8_t)mode;
    -        line[n++] = (uint8_t)(cols & EPSON_COUNT_MASK);
    -        line[n++] = (uint8_t)(cols >> EPSON_COUNT_SHIFT);
    -
    -        for (int32_t x = 0; x < cols; x++) {
    -            for (int32_t b = 0; b < bytesPerCol; b++) {
    -                uint8_t v = 0;
    -
    -                for (int32_t bit = 0; bit < BITS_PER_BYTE; bit++) {
    -                    int32_t row = b * BITS_PER_BYTE + bit;
    -
    -                    if (row < rows && bitSet(bits, rowBytes, row, x)) {
    -                        v |= (uint8_t)(TOP_BIT >> bit);
    -                    }
    +            for (int32_t p = 0; p < pl->count; p++) {
    +                if (inkValue(mode, pl->ink[p], px) < th[p][x & mask]) {
    +                    pl->bits[p][r * pl->rowBytes + x / BITS_PER_BYTE] |= (uint8_t)(TOP_BIT >> (x % BITS_PER_BYTE));
                     }
    -
    -                line[n++] = v;
    -            }
    -        }
    -
    -        line[n++] = CR_CHAR;
    -
    -        bool ok = printJobWrite(job, line, n);
    -
    -        free(line);
    -
    -        if (!ok) {
    -            return false;
    -        }
    -    }
    -
    -    return epsonFeed(job, rows * feedPerRow);
    -}
    -
    -
    -static bool epsonBegin(PrintJobT *job, int32_t widthDots, int32_t heightDots) {
    -    (void)widthDots;
    -    (void)heightDots;
    -    return printJobPuts(job, EPSON_RESET);
    -}
    -
    -
    -static bool epsonEnd(PrintJobT *job) {
    -    return printJobPuts(job, FORM_FEED EPSON_RESET);
    -}
    -
    -
    -// ESC J n, split when more than one command's worth.
    -static bool epsonFeed(PrintJobT *job, int32_t units) {
    -    while (units > 0) {
    -        int32_t n      = units > EPSON_FEED_MAX ? EPSON_FEED_MAX : units;
    -        uint8_t cmd[3] = { ESC_CHAR, EPSON_FEED, (uint8_t)n };
    -
    -        if (!printJobWrite(job, cmd, (int32_t)sizeof(cmd))) {
    -            return false;
    -        }
    -
    -        units -= n;
    -    }
    -
    -    return true;
    -}
    -
    -
    -// Index of the last column with a black dot in the band, -1 when none.
    -static int32_t lastInkColumn(const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots) {
    -    for (int32_t x = widthDots - 1; x >= 0; x--) {
    -        for (int32_t r = 0; r < rows; r++) {
    -            if (bitSet(bits, rowBytes, r, x)) {
    -                return x;
                 }
             }
         }
    -
    -    return -1;
     }
     
     
    -// Every row: ESC * b n W and its bytes up to the last one with ink.
    -static bool pclBand(PrintJobT *job, const uint8_t *bits, int32_t rowBytes, int32_t rows, int32_t widthDots) {
    -    char cmd[PCL_CMD_MAX];
    +static InkModeE inkModeOf(const PrintDriverT *drv) {
    +    bool black  = false;
    +    bool colour = false;
     
    -    (void)widthDots;
    -
    -    for (int32_t r = 0; r < rows; r++) {
    -        const uint8_t *row = bits + r * rowBytes;
    -        int32_t        n   = rowBytes;
    -
    -        while (n > 0 && row[n - 1] == 0) {
    -            n--;
    -        }
    -
    -        snprintf(cmd, sizeof(cmd), PCL_ROW_FMT, (int)n);
    -
    -        if (!printJobPuts(job, cmd) || !printJobWrite(job, row, n)) {
    -            return false;
    -        }
    +    for (int32_t i = 0; i < drv->inkCount; i++) {
    +        black  = black || drv->inks[i] == PrintInkBlackE;
    +        colour = colour || drv->inks[i] != PrintInkBlackE;
         }
     
    -    return true;
    +    if (!colour) {
    +        return InkModeLumaE;
    +    }
    +
    +    return black ? InkModeKcmyE : InkModeCmyE;
     }
     
     
    -static bool pclBegin(PrintJobT *job, int32_t widthDots, int32_t heightDots) {
    -    char cmd[PCL_BEGIN_MAX];
    +// How light a pixel is in ink's terms (WHITE: none of it; a dot is made
    +// where this is below the threshold).
    +static int32_t inkValue(InkModeE mode, PrintInkE ink, const uint8_t *px) {
    +    int32_t hi;
     
    -    (void)widthDots;
    -    (void)heightDots;
    -    snprintf(cmd, sizeof(cmd), PCL_BEGIN_FMT, PCL_DPI);
    -    return printJobPuts(job, PCL_RESET) && printJobPuts(job, cmd);
    -}
    +    if (mode == InkModeLumaE) {
    +        return printLuma(px);
    +    }
     
    +    hi = px[0];
    +    hi = px[1] > hi ? px[1] : hi;
    +    hi = px[2] > hi ? px[2] : hi;
     
    -static bool pclEnd(PrintJobT *job) {
    -    return printJobPuts(job, PCL_END_RASTER FORM_FEED PCL_RESET);
    -}
    +    switch (ink) {
    +        case PrintInkBlackE:
    +            return hi;
     
    +        case PrintInkCyanE:
    +            return mode == InkModeKcmyE ? WHITE - hi + px[0] : px[0];
     
    -const PrintDriverT *printDriver(PrintModelE model) {
    -    switch (model) {
    -        case PrintModelEpson9E:
    -            return &sEpson9;
    -
    -        case PrintModelEpson24E:
    -            return &sEpson24;
    -
    -        case PrintModelLaserJetE:
    -            return &sLaserJet;
    +        case PrintInkMagentaE:
    +            return mode == InkModeKcmyE ? WHITE - hi + px[1] : px[1];
     
             default:
    -            return NULL;
    +            return mode == InkModeKcmyE ? WHITE - hi + px[2] : px[2];
         }
     }
     
     
    -bool printRasterJob(PrintJobT *job, const PrintDriverT *drv, const uint8_t *rgb, int32_t w, int32_t h, int32_t pitch) {
    -    if (!job || !drv || !rgb || w <= 0 || h <= 0) {
    +// A band of a picture scaled to outW x outH (nearest pixel).
    +static bool pictureBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    const PictureT *pic = (const PictureT *)ctx;
    +
    +    for (int32_t r = 0; r < rows; r++) {
    +        const uint8_t *src = pic->rgb + (int64_t)((int64_t)(y0 + r) * pic->h / pic->outH) * pic->pitch;
    +        uint8_t       *dst = rgb + (int64_t)r * width * RGB_BYTES;
    +
    +        for (int32_t x = 0; x < width; x++) {
    +            memcpy(dst + x * RGB_BYTES, src + (int64_t)x * pic->w / pic->outW * RGB_BYTES, RGB_BYTES);
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +bool printDocCancelled(const PrintDocT *doc) {
    +    return printJobCancelled(doc->job);
    +}
    +
    +
    +// A vector page left open is ended first.
    +bool printDocClose(PrintDocT *doc) {
    +    if (!doc) {
    +        return false;
    +    }
    +
    +    printVecEnd(doc);
    +
    +    bool ok = doc->ok && doc->drv->docEnd(doc->drv, doc->job) && !printJobCancelled(doc->job);
    +
    +    ok = printJobClose(doc->job) && ok;
    +    free(doc);
    +    return ok;
    +}
    +
    +
    +void printDocInfo(const PrintDocT *doc, PrintPageInfoT *info) {
    +    printModelPage(doc->model, info);
    +}
    +
    +
    +PrintDocT *printDocOpen(PrintJobT *job, PrintModelE model) {
    +    const PrintDriverT *drv = printDriver(model);
    +    PrintDocT          *doc = drv && job ? (PrintDocT *)calloc(1, sizeof(PrintDocT)) : NULL;
    +
    +    if (!doc) {
    +        printJobClose(job);
    +        return NULL;
    +    }
    +
    +    doc->job   = job;
    +    doc->drv   = drv;
    +    doc->model = model;
    +    doc->ok    = drv->docBegin(drv, job);
    +    return doc;
    +}
    +
    +
    +bool printDocPage(PrintDocT *doc, PrintBandFnT fn, void *ctx) {
    +    if (doc->vecPage) {
    +        return false;
    +    }
    +
    +    doc->ok = doc->ok && fn && printPageRun(doc->job, doc->drv, doc->drv->pageWidthDots, doc->drv->pageHeightDots, fn, ctx);
    +    return doc->ok;
    +}
    +
    +
    +// Fit the width; if that is too tall, fit the height.  Sizes in dots
    +// keep the picture's shape: dots per inch may differ across and down.
    +bool printDocPicture(PrintDocT *doc, const uint8_t *rgb, int32_t w, int32_t h, int32_t pitch) {
    +    const PrintDriverT *drv = doc->drv;
    +    PictureT            pic;
    +
    +    if (doc->vecPage) {
    +        return false;
    +    }
    +
    +    if (!rgb || w <= 0 || h <= 0) {
    +        doc->ok = false;
             return false;
         }
     
    -    // Fit the width; if that is too tall, fit the height.  Sizes in dots
    -    // keep the picture's shape: dots per inch may differ across and down.
         int64_t outW = drv->pageWidthDots;
         int64_t outH = outW * h * drv->dpiY / ((int64_t)w * drv->dpiX);
     
    @@ -325,52 +309,184 @@ bool printRasterJob(PrintJobT *job, const PrintDriverT *drv, const uint8_t *rgb,
             outW = outH * w * drv->dpiX / ((int64_t)h * drv->dpiY);
         }
     
    -    if (outW < 1) {
    -        outW = 1;
    +    pic.rgb   = rgb;
    +    pic.w     = w;
    +    pic.h     = h;
    +    pic.pitch = pitch;
    +    pic.outW  = outW < 1 ? 1 : (int32_t)outW;
    +    pic.outH  = outH < 1 ? 1 : (int32_t)outH;
    +    doc->ok   = doc->ok && printPageRun(doc->job, drv, pic.outW, pic.outH, pictureBand, &pic);
    +    return doc->ok;
    +}
    +
    +
    +const PrintDriverT *printDriver(PrintModelE model) {
    +    switch (model) {
    +        case PrintModelEpson9E:
    +            return &printDrvEpson9;
    +
    +        case PrintModelEpson24E:
    +            return &printDrvEpson24;
    +
    +        case PrintModelLaserJetE:
    +            return &printDrvLaserJet;
    +
    +        case PrintModelLaserJet300E:
    +            return &printDrvLaserJet300;
    +
    +        case PrintModelEpson9ColorE:
    +            return &printDrvEpson9Color;
    +
    +        case PrintModelEpson24ColorE:
    +            return &printDrvEpson24Color;
    +
    +        case PrintModelDeskJet500CE:
    +            return &printDrvDeskJet500C;
    +
    +        case PrintModelDeskJetColorE:
    +            return &printDrvDeskJetColor;
    +
    +        case PrintModelPostScriptE:
    +            return &printDrvPostScript;
    +
    +        case PrintModelPostScriptColorE:
    +            return &printDrvPostScriptColor;
    +
    +        case PrintModelImageWriterE:
    +            return &printDrvImageWriter;
    +
    +        case PrintModelImageWriterColorE:
    +            return &printDrvImageWriterColor;
    +
    +        default:
    +            return NULL;
         }
    +}
     
    -    if (outH < 1) {
    -        outH = 1;
    -    }
     
    -    int32_t  rowBytes  = (int32_t)((outW + BITS_PER_BYTE - 1) / BITS_PER_BYTE);
    -    size_t   bandBytes = (size_t)rowBytes * (size_t)drv->bandRows;
    -    uint8_t *band      = (uint8_t *)malloc(bandBytes);
    +int32_t printLuma(const uint8_t *px) {
    +    return (px[0] * LUMA_R + px[1] * LUMA_G + px[2] * LUMA_B) / LUMA_SCALE;
    +}
     
    -    if (!band) {
    +
    +// The page model prints on; false for text only.
    +bool printModelPage(PrintModelE model, PrintPageInfoT *info) {
    +    const PrintDriverT *drv = printDriver(model);
    +
    +    if (!drv) {
             return false;
         }
     
    -    bool ok = drv->begin(job, (int32_t)outW, (int32_t)outH);
    +    if (info) {
    +        info->dpiX       = drv->dpiX;
    +        info->dpiY       = drv->dpiY;
    +        info->widthDots  = drv->pageWidthDots;
    +        info->heightDots = drv->pageHeightDots;
    +        info->color      = drv->inkCount > 1;
    +        info->vector     = drv->vector;
    +    }
     
    -    for (int32_t y0 = 0; ok && y0 < outH; y0 += drv->bandRows) {
    -        int32_t rows = (int32_t)(outH - y0) < drv->bandRows ? (int32_t)(outH - y0) : drv->bandRows;
    +    return true;
    +}
     
    -        memset(band, 0, bandBytes);
     
    -        for (int32_t r = 0; r < rows; r++) {
    -            int32_t        y   = y0 + r;
    -            const uint8_t *src = rgb + (int64_t)(y * h / outH) * pitch;
    -            uint8_t       *dst = band + r * rowBytes;
    +int32_t printPackBits(const uint8_t *in, int32_t n, uint8_t *out) {
    +    int32_t i = 0;
    +    int32_t o = 0;
     
    -            for (int32_t x = 0; x < outW; x++) {
    -                const uint8_t *px        = src + (int64_t)(x * w / outW) * RGB_BYTES;
    -                int32_t        luma      = (px[0] * LUMA_R + px[1] * LUMA_G + px[2] * LUMA_B) / LUMA_SCALE;
    -                int32_t        threshold = sBayer[y & DITHER_MASK][x & DITHER_MASK] * DITHER_STEP + DITHER_HALF_STEP;
    +    while (i < n) {
    +        int32_t run = 1;
     
    -                if (luma < threshold) {
    -                    dst[x / BITS_PER_BYTE] |= (uint8_t)(TOP_BIT >> (x % BITS_PER_BYTE));
    -                }
    -            }
    +        while (i + run < n && run < PACK_RUN_MAX && in[i + run] == in[i]) {
    +            run++;
             }
     
    -        ok = drv->band(job, band, rowBytes, rows, (int32_t)outW);
    +        if (run >= PACK_RUN_MIN) {
    +            out[o++] = (uint8_t)(PACK_RUN_BASE - run);
    +            out[o++] = in[i];
    +            i       += run;
    +            continue;
    +        }
    +
    +        int32_t start = i;
    +
    +        // The stretch ends where a run starts
    +        do {
    +            i++;
    +        } while (i < n && i - start < PACK_RUN_MAX && !(i + 2 < n && in[i] == in[i + 1] && in[i] == in[i + 2]));
    +
    +        out[o++] = (uint8_t)(i - start - 1);
    +        memcpy(out + o, in + start, (size_t)(i - start));
    +        o += i - start;
         }
     
    -    if (ok) {
    -        ok = drv->end(job);
    +    return o;
    +}
    +
    +
    +bool printPageRun(PrintJobT *job, const PrintDriverT *drv, int32_t widthDots, int32_t heightDots, PrintBandFnT fn, void *ctx) {
    +    PrintPlanesT pl;
    +    InkModeE     mode       = inkModeOf(drv);
    +    size_t       rgbBytes   = (size_t)widthDots * (size_t)drv->bandRows * RGB_BYTES;
    +    uint8_t     *rgb        = (uint8_t *)malloc(rgbBytes);
    +    size_t       planeBytes;
    +    bool         ok         = rgb != NULL;
    +
    +    memset(&pl, 0, sizeof(pl));
    +    pl.count     = drv->inkCount;
    +    pl.rowBytes  = (widthDots + BITS_PER_BYTE - 1) / BITS_PER_BYTE;
    +    pl.widthDots = widthDots;
    +    planeBytes   = (size_t)pl.rowBytes * (size_t)drv->bandRows;
    +
    +    for (int32_t p = 0; p < pl.count; p++) {
    +        pl.ink[p]  = drv->inks[p];
    +        pl.bits[p] = (uint8_t *)malloc(planeBytes);
    +        ok         = ok && pl.bits[p] != NULL;
         }
     
    -    free(band);
    +    ok = ok && drv->pageBegin(drv, job, widthDots, heightDots);
    +
    +    for (int32_t y0 = 0; ok && y0 < heightDots; y0 += drv->bandRows) {
    +        pl.rows = heightDots - y0 < drv->bandRows ? heightDots - y0 : drv->bandRows;
    +        pl.y0   = y0;
    +        memset(rgb, WHITE, (size_t)widthDots * (size_t)pl.rows * RGB_BYTES);
    +        ok = fn(ctx, rgb, widthDots, y0, pl.rows);
    +
    +        for (int32_t p = 0; ok && p < pl.count; p++) {
    +            memset(pl.bits[p], 0, planeBytes);
    +        }
    +
    +        if (ok) {
    +            bandToPlanes(drv, mode, rgb, y0, &pl);
    +            ok = drv->band(drv, job, &pl);
    +        }
    +    }
    +
    +    ok = ok && drv->pageEnd(drv, job);
    +
    +    for (int32_t p = 0; p < pl.count; p++) {
    +        free(pl.bits[p]);
    +    }
    +
    +    free(rgb);
         return ok && !printJobCancelled(job);
     }
    +
    +
    +bool printPlaneDot(const PrintPlanesT *pl, int32_t plane, int32_t row, int32_t x) {
    +    return (pl->bits[plane][row * pl->rowBytes + x / BITS_PER_BYTE] & (TOP_BIT >> (x % BITS_PER_BYTE))) != 0;
    +}
    +
    +
    +// The dither threshold (0..255) for plane at (x, y): its place in the
    +// size x size Bayer matrix, shifted for the plane.
    +static int32_t threshold(int32_t size, int32_t plane, int32_t x, int32_t y) {
    +    int32_t mask  = size - 1;
    +    int32_t scale = size / PRINT_DITHER_SMALL;
    +    int32_t sx    = (x + sPlaneShift[plane][0] * scale) & mask;
    +    int32_t sy    = (y + sPlaneShift[plane][1] * scale) & mask;
    +    int32_t step  = BRIGHTNESS_LEVELS / (size * size);
    +    int32_t level = size == PRINT_DITHER_LARGE ? sBayer8[sy][sx] : sBayer4[sy][sx];
    +
    +    return level * step + step / 2;
    +}
    diff --git a/src/libs/kpunch/print/printSys.c b/src/libs/kpunch/print/printSys.c
    index 91822c28..9cb51f46 100644
    --- a/src/libs/kpunch/print/printSys.c
    +++ b/src/libs/kpunch/print/printSys.c
    @@ -22,23 +22,35 @@
     
     // printSys.c  -- Printing library: the real printer ports
     //
    -// DOS: the BIOS printer service (INT 17h) through DPMI function 0300h
    -// (simulate a real-mode interrupt) issued directly with INT 31h, which a
    -// module can use without extra loader exports.  A port exists when the
    -// BIOS data area holds an I/O address for it (0040:0008, one word per
    -// port).
    +// DOS: the parallel ports through the BIOS printer service (INT 17h),
    +// by DPMI function 0300h (simulate a real-mode interrupt) issued
    +// directly with INT 31h, which a module can use without extra loader
    +// exports; the serial ports through the serial library (rs232), whose
    +// interrupt handler sends from its buffer as the printer's handshake
    +// allows.  A port exists when the BIOS data area holds an I/O address
    +// for it (0040:0008 parallel, 0040:0000 serial, a word each).  A serial
    +// port with more than COM_BUSY_BYTES waiting is busy; it is closed once
    +// they and the UART's FIFO have gone.
     //
     // Elsewhere: every port exists and is always ready; bytes are appended
    -// to lpt1.prn .. lpt3.prn in the working directory (kept open, flushed
    -// after every byte so a reader sees them at once).
    +// to lpt1.prn .. lpt3.prn and com1.prn .. com4.prn in the working
    +// directory (kept open, flushed after every byte so a reader sees them
    +// at once).
     
     #include "printInt.h"
     
    +#include 
     #include 
     #include 
     
    +#define PORT_READY             (PRINT_STATUS_NOT_BUSY | PRINT_STATUS_SELECTED)
    +
     #ifdef __DJGPP__
     
    +#include "../serial/rs232/rs232.h"
    +#include "dvxPlat.h"
    +#include "taskSwch.h"
    +
     #include 
     #include 
     #include 
    @@ -48,14 +60,28 @@
     #define PRN_FN_WRITE           0x00
     #define PRN_FN_STATUS          0x02
     #define BIOS_LPT_BASE_ADDR     0x0408
    +#define BIOS_COM_BASE_ADDR     0x0400
     #define BIOS_PORT_ENTRY_SHIFT  1
     #define AH_SHIFT               8
    +#define COM_DATA_BITS          8
    +#define COM_PARITY             'N'
    +#define COM_STOP_BITS          1
    +#define COM_BUSY_BYTES         1024    // waiting in the send buffer: the port is busy
    +#define COM_FIFO_BYTES         16      // a 16550A's transmit FIFO
    +#define COM_BITS_PER_CHAR      10      // start, 8 data, stop
    +#define MS_PER_SECOND          1000
    +
    +// The serial library's handshake for each PrintHandshakeE
    +static const int32_t sRs232Handshake[PrintHandshakeCountE] = { RS232_HANDSHAKE_NONE, RS232_HANDSHAKE_XONXOFF, RS232_HANDSHAKE_RTSCTS, RS232_HANDSHAKE_DTRDSR };
    +
    +// Each serial port's speed while a job has it open
    +static int32_t sComBaud[PRINT_COM_COUNT];
     
     #else
     
    -#define HOST_PORT_FILE         "lpt%d.prn"
    +#define HOST_LPT_FILE          "lpt%" PRId32 ".prn"
    +#define HOST_COM_FILE          "com%" PRId32 ".prn"
     #define HOST_PORT_NAME_MAX     16
    -#define HOST_PORT_READY        (PRINT_STATUS_NOT_BUSY | PRINT_STATUS_SELECTED)
     
     static FILE *sHostPort[PRINT_PORT_COUNT];
     
    @@ -68,11 +94,14 @@ static FILE *sHostPort[PRINT_PORT_COUNT];
     #ifdef __DJGPP__
     static uint8_t biosPrinter(uint8_t fn, int32_t port, uint8_t byte);
     #endif
    +static void    sysClose(void *ctx, int32_t port);
    +static bool    sysOpen(void *ctx, int32_t port, int32_t baud, PrintHandshakeE handshake);
    +static int32_t sysPending(void *ctx, int32_t port);
     static bool    sysPresent(void *ctx, int32_t port);
     static uint8_t sysStatus(void *ctx, int32_t port);
     static uint8_t sysWrite(void *ctx, int32_t port, uint8_t byte);
     
    -const PrintPortOpsT printSysPortOps = { sysPresent, sysStatus, sysWrite, NULL };
    +const PrintPortOpsT printSysPortOps = { sysPresent, sysStatus, sysWrite, NULL, sysOpen, sysPending, sysClose };
     
     
     #ifdef __DJGPP__
    @@ -101,10 +130,69 @@ static uint8_t biosPrinter(uint8_t fn, int32_t port, uint8_t byte) {
     #endif
     
     
    +// A serial port, once what it holds has gone and its FIFO had time to
    +// empty.
    +static void sysClose(void *ctx, int32_t port) {
    +    (void)ctx;
    +#ifdef __DJGPP__
    +    if (port >= PRINT_PORT_COM1) {
    +        int32_t        com   = port - PRINT_PORT_COM1;
    +        PlatformTicksT since = platformClock();
    +
    +        while (platformClock() - since < PLATFORM_MS_TO_TICKS(COM_FIFO_BYTES * COM_BITS_PER_CHAR * MS_PER_SECOND / sComBaud[com] + 1)) {
    +            tsYield();
    +        }
    +
    +        rs232Close(com);
    +    }
    +#else
    +    (void)port;
    +#endif
    +}
    +
    +
    +// A serial port at baud, 8 data bits, no parity, 1 stop bit, with the
    +// handshake; the parallel ports need nothing.
    +static bool sysOpen(void *ctx, int32_t port, int32_t baud, PrintHandshakeE handshake) {
    +    (void)ctx;
    +#ifdef __DJGPP__
    +    if (port < PRINT_PORT_COM1) {
    +        return true;
    +    }
    +
    +    int32_t com = port - PRINT_PORT_COM1;
    +
    +    sComBaud[com] = baud;
    +    return rs232Open(com, baud, COM_DATA_BITS, COM_PARITY, COM_STOP_BITS, sRs232Handshake[handshake]) == RS232_SUCCESS;
    +#else
    +    (void)port;
    +    (void)baud;
    +    (void)handshake;
    +    return true;
    +#endif
    +}
    +
    +
    +// Bytes a serial port holds that the printer has not taken yet.
    +static int32_t sysPending(void *ctx, int32_t port) {
    +    (void)ctx;
    +#ifdef __DJGPP__
    +    int32_t n = port >= PRINT_PORT_COM1 ? rs232GetTxBuffered(port - PRINT_PORT_COM1) : 0;
    +
    +    return n > 0 ? n : 0;
    +#else
    +    (void)port;
    +    return 0;
    +#endif
    +}
    +
    +
     static bool sysPresent(void *ctx, int32_t port) {
         (void)ctx;
     #ifdef __DJGPP__
    -    return _farpeekw(_dos_ds, BIOS_LPT_BASE_ADDR + ((uint32_t)port << BIOS_PORT_ENTRY_SHIFT)) != 0;
    +    uint32_t addr = port < PRINT_PORT_COM1 ? BIOS_LPT_BASE_ADDR + ((uint32_t)port << BIOS_PORT_ENTRY_SHIFT) : BIOS_COM_BASE_ADDR + ((uint32_t)(port - PRINT_PORT_COM1) << BIOS_PORT_ENTRY_SHIFT);
    +
    +    return _farpeekw(_dos_ds, addr) != 0;
     #else
         (void)port;
         return true;
    @@ -112,13 +200,25 @@ static bool sysPresent(void *ctx, int32_t port) {
     }
     
     
    +// A parallel port's BIOS status; a serial port is busy while its buffer
    +// is well filled (the printer holding it back), else ready.
     static uint8_t sysStatus(void *ctx, int32_t port) {
         (void)ctx;
     #ifdef __DJGPP__
    +    if (port >= PRINT_PORT_COM1) {
    +        int32_t n = rs232GetTxBuffered(port - PRINT_PORT_COM1);
    +
    +        if (n < 0) {
    +            return PRINT_STATUS_SELECTED | PRINT_STATUS_IO_ERROR;
    +        }
    +
    +        return n < COM_BUSY_BYTES ? PORT_READY : PRINT_STATUS_SELECTED;
    +    }
    +
         return biosPrinter(PRN_FN_STATUS, port, 0);
     #else
         (void)port;
    -    return HOST_PORT_READY;
    +    return PORT_READY;
     #endif
     }
     
    @@ -126,12 +226,19 @@ static uint8_t sysStatus(void *ctx, int32_t port) {
     static uint8_t sysWrite(void *ctx, int32_t port, uint8_t byte) {
         (void)ctx;
     #ifdef __DJGPP__
    +    if (port >= PRINT_PORT_COM1) {
    +        char c = (char)byte;
    +
    +        return rs232WriteBuf(port - PRINT_PORT_COM1, &c, 1) == 1 ? PORT_READY : PRINT_STATUS_SELECTED | PRINT_STATUS_IO_ERROR;
    +    }
    +
         return biosPrinter(PRN_FN_WRITE, port, byte);
     #else
         if (!sHostPort[port]) {
             char name[HOST_PORT_NAME_MAX];
    +        bool com = port >= PRINT_PORT_COM1;
     
    -        snprintf(name, sizeof(name), HOST_PORT_FILE, (int)(port + PRINT_PORT_NUMBER_BASE));
    +        snprintf(name, sizeof(name), com ? HOST_COM_FILE : HOST_LPT_FILE, (com ? port - PRINT_PORT_COM1 : port) + PRINT_PORT_NUMBER_BASE);
             sHostPort[port] = fopen(name, "ab");
     
             if (!sHostPort[port]) {
    @@ -141,6 +248,6 @@ static uint8_t sysWrite(void *ctx, int32_t port, uint8_t byte) {
     
         fputc(byte, sHostPort[port]);
         fflush(sHostPort[port]);
    -    return HOST_PORT_READY;
    +    return PORT_READY;
     #endif
     }
    diff --git a/src/libs/kpunch/print/printText.c b/src/libs/kpunch/print/printText.c
    index fdac8584..3e0aaad3 100644
    --- a/src/libs/kpunch/print/printText.c
    +++ b/src/libs/kpunch/print/printText.c
    @@ -29,7 +29,9 @@
     // between may be columns - marginLeft - marginRight characters wide.
     //
     // The same layout pass runs twice: first without a job to count the
    -// pages (for &n and the page range), then once per copy to print.
    +// pages (for &n and the page range), then once per copy to print.  A
    +// driver with text ops of its own (PostScript) is handed the pages and
    +// lines instead of the plain text.
     
     #include "printInt.h"
     
    @@ -57,6 +59,7 @@
     // One layout pass
     typedef struct {
         PrintJobT             *job;         // NULL: count the pages only
    +    const PrintTextOpsT   *ops;         // the driver's own text output; NULL: plain text
         const PrintPageSetupT *ps;
         const PrintTextInfoT  *info;
         int32_t                pageLines;
    @@ -234,7 +237,8 @@ static void outHeader(TextOutT *o, const char *fmt) {
     }
     
     
    -// Left margin, text, CR LF (a blank line gets no margin).
    +// Left margin, text, CR LF (a blank line gets no margin); to the
    +// driver's text ops without the line end.
     static void outLine(TextOutT *o, const char *text, int32_t len) {
         char    buf[PRINT_COLUMNS_MAX + LINE_BUF];
         int32_t n = len > 0 ? o->ps->marginLeft : 0;
    @@ -242,6 +246,12 @@ static void outLine(TextOutT *o, const char *text, int32_t len) {
         memset(buf, ASCII_SPACE, (size_t)n);
         memcpy(buf + n, text, (size_t)len);
         n += len;
    +
    +    if (o->ops) {
    +        o->ok = o->ok && (!o->job || !o->emit || o->ops->line(o->job, buf, n));
    +        return;
    +    }
    +
         memcpy(buf + n, LINE_END, LINE_END_LEN);
         outBytes(o, buf, n + LINE_END_LEN);
     }
    @@ -254,6 +264,10 @@ static void pageBegin(TextOutT *o) {
         o->page++;
         o->emit = (o->info->firstPage <= 0 || o->page >= o->info->firstPage) && (o->info->lastPage <= 0 || o->page <= o->info->lastPage);
     
    +    if (o->ops && o->job && o->emit) {
    +        o->ok = o->ok && o->ops->pageBegin(o->job);
    +    }
    +
         for (int32_t row = 0; row < ps->marginTop; row++) {
             if (row == hdrRow) {
                 outHeader(o, ps->header);
    @@ -281,14 +295,22 @@ static void pageEnd(TextOutT *o) {
             outHeader(o, ps->footer);
         }
     
    -    outBytes(o, &ff, 1);
    +    if (o->ops) {
    +        o->ok = o->ok && (!o->job || !o->emit || o->ops->pageEnd(o->job));
    +    } else {
    +        outBytes(o, &ff, 1);
    +    }
    +
         o->line = 0;
     }
     
     
     static void passInit(TextOutT *o, PrintJobT *job, PrintModelE model, const PrintPageSetupT *ps, const PrintTextInfoT *info) {
    +    const PrintDriverT *drv = printDriver(model);
    +
         memset(o, 0, sizeof(*o));
         o->job       = job;
    +    o->ops       = drv ? drv->textOps : NULL;
         o->ps        = ps;
         o->info      = info ? info : &sNoInfo;
         o->pageLines = printPageLines(ps, model);
    @@ -403,7 +425,9 @@ bool printTextJob(PrintJobT *job, PrintModelE model, const char *text, int32_t l
         bool                ok     = true;
         TextOutT            o;
     
    -    if (drv) {
    +    if (drv && drv->textOps) {
    +        ok = drv->textOps->begin(job, ps->columns, printPageLines(ps, model));
    +    } else if (drv) {
             ok = printJobPuts(job, drv->textBegin);
         }
     
    @@ -414,7 +438,9 @@ bool printTextJob(PrintJobT *job, PrintModelE model, const char *text, int32_t l
             ok = o.ok;
         }
     
    -    if (ok && drv) {
    +    if (ok && drv && drv->textOps) {
    +        ok = drv->textOps->end(job);
    +    } else if (ok && drv) {
             ok = printJobPuts(job, drv->textEnd);
         }
     
    diff --git a/src/libs/kpunch/print/printUi.c b/src/libs/kpunch/print/printUi.c
    index 2ffb4d1d..cc2ac227 100644
    --- a/src/libs/kpunch/print/printUi.c
    +++ b/src/libs/kpunch/print/printUi.c
    @@ -113,12 +113,16 @@ typedef struct {
     static WidgetT     *addRow(WidgetT *parent, const char *label);
     static WidgetT     *addSpinner(WidgetT *parent, const char *name, const char *label, int32_t minVal, int32_t maxVal, int32_t value);
     static void         browseFile(WidgetT *w);
    +static void         choiceOf(const PrintSettingsT *s, PrintChoiceT *ch);
     static void         destChanged(WidgetT *w);
    +static bool         docClose(AppContextT *ctx, PrintDocT *doc, const char *failed);
     static PrintJobT   *openJob(AppContextT *ctx, PrintDestE dest, const char *filePath, const char *what, bool keepWindow, char *err, int32_t errSize);
     bool                printDialog(AppContextT *ctx, const char *title, int32_t pageCount, PrintChoiceT *choice);
     static bool         printDlgOk(WindowT *dlg, void *userCtx);
     bool                printPageSetupDialog(AppContextT *ctx);
     static bool         printRgb(AppContextT *ctx, const uint8_t *rgb, int32_t w, int32_t h, const char *title, bool askFirst);
    +bool                printUiDocClose(AppContextT *ctx, PrintDocT *doc);
    +PrintDocT          *printUiDocOpen(AppContextT *ctx, const char *title, const PrintChoiceT *choice);
     bool                printUiImageFile(AppContextT *ctx, const char *path, const char *title, bool askFirst);
     PrintJobT          *printUiOpenPort(AppContextT *ctx, int32_t port);
     bool                printUiText(AppContextT *ctx, const char *text, const char *title, bool askFirst);
    @@ -129,6 +133,7 @@ static void         progressClose(WindowT *win);
     static void         progressHide(UiRunT *run);
     static void         progressShow(UiRunT *run);
     static bool         setupDlgOk(WindowT *dlg, void *userCtx);
    +static void         textOnlyBox(AppContextT *ctx);
     static uint8_t     *toRgb(const AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch);
     static PrintAnswerE uiAsk(void *ctx, const char *message);
     static void         uiFinished(void *ctx, bool closing);
    @@ -142,7 +147,7 @@ static WidgetT *addRow(WidgetT *parent, const char *label) {
         WidgetT *lbl = wgtLabel(row, label);
     
         row->spacing = wgtPixels(UI_GAP);
    -    lbl->prefW   = wgtPixels(UI_LABEL_W);
    +    lbl->minW    = wgtPixels(UI_LABEL_W);
         return row;
     }
     
    @@ -151,7 +156,7 @@ static WidgetT *addRow(WidgetT *parent, const char *label) {
     static WidgetT *addSpinner(WidgetT *parent, const char *name, const char *label, int32_t minVal, int32_t maxVal, int32_t value) {
         WidgetT *spin = wgtSpinner(addRow(parent, label), minVal, maxVal, 1);
     
    -    spin->prefW = wgtChars(UI_SPIN_CHARS);
    +    spin->minW  = wgtChars(UI_SPIN_CHARS);
         wgtSetName(spin, name);
         wgtSpinnerSetValue(spin, value);
         return spin;
    @@ -173,6 +178,16 @@ static void browseFile(WidgetT *w) {
     
     
     // The file name only matters when printing to a file.
    +// A print choice from the settings: their destination, one copy, all
    +// pages.
    +static void choiceOf(const PrintSettingsT *s, PrintChoiceT *ch) {
    +    memset(ch, 0, sizeof(*ch));
    +    ch->dest   = s->dest;
    +    ch->copies = 1;
    +    snprintf(ch->filePath, sizeof(ch->filePath), "%s", s->filePath);
    +}
    +
    +
     static void destChanged(WidgetT *w) {
         PrintDlgT *d      = (PrintDlgT *)w->userData;
         bool       toFile = wgtDropdownGetSelected(d->dest) == PrintDestFileE;
    @@ -182,6 +197,23 @@ static void destChanged(WidgetT *w) {
     }
     
     
    +// Close a document; failed (unless cancelled) is told to the user.
    +static bool docClose(AppContextT *ctx, PrintDocT *doc, const char *failed) {
    +    if (!doc) {
    +        return false;
    +    }
    +
    +    bool cancelled = printDocCancelled(doc);
    +    bool ok        = printDocClose(doc);
    +
    +    if (!ok && !cancelled) {
    +        dvxErrorBox(ctx, UI_TITLE, failed);
    +    }
    +
    +    return ok;
    +}
    +
    +
     static PrintJobT *openJob(AppContextT *ctx, PrintDestE dest, const char *filePath, const char *what, bool keepWindow, char *err, int32_t errSize) {
         UiRunT     *run = (UiRunT *)calloc(1, sizeof(UiRunT));
         PrintHooksT hooks;
    @@ -244,7 +276,7 @@ bool printDialog(AppContextT *ctx, const char *title, int32_t pageCount, PrintCh
         WidgetT *fileRow = addRow(root, "File name:");
     
         d.file             = wgtTextInput(fileRow, PRINT_PATH_MAX - 1);
    -    d.file->prefW      = wgtChars(UI_FIELD_CHARS);
    +    d.file->minW       = wgtChars(UI_FIELD_CHARS);
         d.browse           = wgtButton(fileRow, "&Browse...");
         d.browse->userData = &d;
         d.browse->onClick  = browseFile;
    @@ -266,10 +298,10 @@ bool printDialog(AppContextT *ctx, const char *title, int32_t pageCount, PrintCh
             row->spacing  = wgtPixels(UI_GAP);
             wgtLabel(row, "From");
             d.from        = wgtSpinner(row, 1, pageCount, 1);
    -        d.from->prefW = wgtChars(UI_SPIN_CHARS);
    +        d.from->minW  = wgtChars(UI_SPIN_CHARS);
             wgtLabel(row, "to");
             d.to          = wgtSpinner(row, 1, pageCount, 1);
    -        d.to->prefW   = wgtChars(UI_SPIN_CHARS);
    +        d.to->minW    = wgtChars(UI_SPIN_CHARS);
             wgtSetName(d.pages, "printPages");
             wgtSetName(d.from, "printFrom");
             wgtSetName(d.to, "printTo");
    @@ -339,8 +371,8 @@ bool printPageSetupDialog(AppContextT *ctx) {
         d.footer   = wgtTextInput(addRow(root, "Footer:"), PRINT_HF_MAX - 1);
         d.wrap     = wgtCheckbox(root, "Wrap long lines (otherwise they are cut off)");
         d.page     = s.page;
    -    d.header->prefW = wgtChars(UI_FIELD_CHARS);
    -    d.footer->prefW = wgtChars(UI_FIELD_CHARS);
    +    d.header->minW  = wgtChars(UI_FIELD_CHARS);
    +    d.footer->minW  = wgtChars(UI_FIELD_CHARS);
         wgtSetName(d.header, "setupHeader");
         wgtSetName(d.footer, "setupFooter");
         wgtSetName(d.wrap, "setupWrap");
    @@ -368,51 +400,75 @@ bool printPageSetupDialog(AppContextT *ctx) {
     
     // Print a picture on the printer in the settings (it must print
     // pictures), one page per copy.
    +// Print a picture (R, G, B bytes) on a page of its own, copies times.
     static bool printRgb(AppContextT *ctx, const uint8_t *rgb, int32_t w, int32_t h, const char *title, bool askFirst) {
    -    PrintSettingsT      s;
    -    PrintChoiceT        ch;
    -    char                err[PRINT_MSG_MAX];
    -    const PrintDriverT *drv;
    +    PrintSettingsT s;
    +    PrintChoiceT   ch;
     
         printSettingsLoad(&s);
    -    drv = printDriver(s.model);
     
    -    if (!drv) {
    -        dvxMessageBox(ctx, UI_TITLE, "The printer model chosen in the Control Panel prints text only. To print pictures, choose your printer's model on the Control Panel's Printer page.", MB_OK | MB_ICONINFO);
    +    if (!printModelPage(s.model, NULL)) {
    +        textOnlyBox(ctx);
             return false;
         }
     
    -    memset(&ch, 0, sizeof(ch));
    -    ch.dest   = s.dest;
    -    ch.copies = 1;
    -    snprintf(ch.filePath, sizeof(ch.filePath), "%s", s.filePath);
    +    choiceOf(&s, &ch);
     
         if (askFirst && !printDialog(ctx, UI_TITLE, 0, &ch)) {
             return false;
         }
     
    -    PrintJobT *job = openJob(ctx, ch.dest, ch.filePath, title, true, err, sizeof(err));
    +    PrintDocT *doc = printUiDocOpen(ctx, title, &ch);
     
    -    if (!job) {
    -        dvxErrorBox(ctx, UI_TITLE, err);
    +    if (!doc) {
             return false;
         }
     
    -    bool ok = true;
    -
    -    for (int32_t c = 0; ok && c < ch.copies; c++) {
    -        ok = printRasterJob(job, drv, rgb, w, h, w * RGB_BYTES);
    +    for (int32_t c = 0; c < ch.copies; c++) {
    +        printDocPicture(doc, rgb, w, h, w * RGB_BYTES);
         }
     
    -    bool cancelled = printJobCancelled(job);
    +    return docClose(ctx, doc, "The picture could not be printed.");
    +}
     
    -    ok = printJobClose(job) && ok;
     
    -    if (!ok && !cancelled) {
    -        dvxErrorBox(ctx, UI_TITLE, "The picture could not be printed.");
    +bool printUiDocClose(AppContextT *ctx, PrintDocT *doc) {
    +    return docClose(ctx, doc, "The document could not be printed.");
    +}
    +
    +
    +PrintDocT *printUiDocOpen(AppContextT *ctx, const char *title, const PrintChoiceT *choice) {
    +    PrintSettingsT s;
    +    PrintChoiceT   ch;
    +    char           err[PRINT_MSG_MAX];
    +
    +    printSettingsLoad(&s);
    +
    +    if (!printModelPage(s.model, NULL)) {
    +        textOnlyBox(ctx);
    +        return NULL;
         }
     
    -    return ok;
    +    choiceOf(&s, &ch);
    +
    +    if (!choice) {
    +        choice = &ch;
    +    }
    +
    +    PrintJobT *job = openJob(ctx, choice->dest, choice->filePath, title, true, err, sizeof(err));
    +
    +    if (!job) {
    +        dvxErrorBox(ctx, UI_TITLE, err);
    +        return NULL;
    +    }
    +
    +    PrintDocT *doc = printDocOpen(job, s.model);
    +
    +    if (!doc) {
    +        dvxErrorBox(ctx, UI_TITLE, "Not enough memory to print.");
    +    }
    +
    +    return doc;
     }
     
     
    @@ -468,10 +524,7 @@ bool printUiText(AppContextT *ctx, const char *text, const char *title, bool ask
         PrintChoiceT   ch;
     
         printSettingsLoad(&s);
    -    memset(&ch, 0, sizeof(ch));
    -    ch.dest   = s.dest;
    -    ch.copies = 1;
    -    snprintf(ch.filePath, sizeof(ch.filePath), "%s", s.filePath);
    +    choiceOf(&s, &ch);
     
         if (askFirst && !printDialog(ctx, UI_TITLE, printTextPageCount(text, (int32_t)strlen(text), &s.page, s.model), &ch)) {
             return false;
    @@ -623,6 +676,12 @@ static bool setupDlgOk(WindowT *dlg, void *userCtx) {
     }
     
     
    +// Pictures need a printer model that prints them.
    +static void textOnlyBox(AppContextT *ctx) {
    +    dvxMessageBox(ctx, UI_TITLE, "The printer model chosen in the Control Panel prints text only. To print pictures, choose your printer's model on the Control Panel's Printer page.", MB_OK | MB_ICONINFO);
    +}
    +
    +
     // The display-format pixels of a picture or window as R, G, B bytes
     // (NULL when out of memory).
     static uint8_t *toRgb(const AppContextT *ctx, const uint8_t *data, int32_t w, int32_t h, int32_t pitch) {
    diff --git a/src/libs/kpunch/taskmgr/shellTaskMgr.c b/src/libs/kpunch/taskmgr/shellTaskMgr.c
    index 7d36613a..e301aa12 100644
    --- a/src/libs/kpunch/taskmgr/shellTaskMgr.c
    +++ b/src/libs/kpunch/taskmgr/shellTaskMgr.c
    @@ -51,7 +51,6 @@ DVX_ABI_STAMP(taskmgr);
     #define TM_COL_COUNT     6
     #define TM_WIN_W         580
     #define TM_WIN_H         280
    -#define TM_LIST_PREF_H   160
     #define TM_BTN_SPACING   8
     #define TM_BTN_W         90
     
    @@ -360,7 +359,6 @@ static void taskMgrCreate(AppContextT *ctx) {
     
         sTmListView = wgtListView(root);
         sTmListView->weight     = WGT_WEIGHT_FILL;
    -    sTmListView->prefH      = wgtPixels(TM_LIST_PREF_H);
         sTmListView->onDblClick = onTmSwitchTo;
         wgtListViewSetColumns(sTmListView, tmCols, TM_COL_COUNT);
     
    @@ -372,15 +370,15 @@ static void taskMgrCreate(AppContextT *ctx) {
     
         WidgetT *switchBtn = wgtButton(btnRow, "&Switch To");
         switchBtn->onClick = onTmSwitchTo;
    -    switchBtn->prefW   = wgtPixels(TM_BTN_W);
    +    switchBtn->minW    = wgtPixels(TM_BTN_W);
     
         WidgetT *endBtn = wgtButton(btnRow, "&End Task");
         endBtn->onClick = onTmEndTask;
    -    endBtn->prefW   = wgtPixels(TM_BTN_W);
    +    endBtn->minW    = wgtPixels(TM_BTN_W);
     
         WidgetT *runBtn = wgtButton(btnRow, "&Run...");
         runBtn->onClick = onTmRun;
    -    runBtn->prefW   = wgtPixels(TM_BTN_W);
    +    runBtn->minW    = wgtPixels(TM_BTN_W);
     
         shellRegisterDesktopUpdate(shellTaskMgrRefresh);
         shellRegisterIdle(onTmIdle, NULL);
    diff --git a/src/savers/README.md b/src/savers/README.md
    new file mode 100644
    index 00000000..ce7e0dbe
    --- /dev/null
    +++ b/src/savers/README.md
    @@ -0,0 +1,34 @@
    +# DVX Screen Savers
    +
    +A screen saver is an ordinary DVX program (`.app`) kept in a SAVERS
    +folder instead of APPS: `SHARED\SAVERS` (the ones DVX ships and add-ons
    +install) or the user's `SAVERS`.  Program Manager does not list them.
    +DVX.INI `[desktop] saver` names the one chosen (Control Panel's Desktop
    +page) and `saverWait` the minutes with no key or mouse input before the
    +shell starts it (`dvxshell/shellSaver.c`).
    +
    +The shell starts a saver with the argument `/S`.  The saver takes the
    +whole screen (full-screen mode: `dvxExclusiveBegin` in C, `SCREEN` and
    +`FULLSCREEN ON` in BASIC) and draws until its window is closed: at the
    +next key or mouse input (after a second's grace) the shell asks it to
    +close, and ends it if it is still there three seconds later.  A saver
    +needs no input handling of its own; the input that wakes the screen goes
    +to the saver, never to the program underneath.
    +
    +The savers here are built by `kpunch/Makefile` into
    +`bin/shared/savers/.app`; nothing lists them:
    +
    +* a directory `/` holding `.c` is a C saver (every `.c` in it
    +  linked, `.res` appended by `dvxres`);
    +* a directory `/` holding `.dbp` is a BASIC saver
    +  (`bascomp`).
    +
    +| Saver | Language | What it draws |
    +|-------|----------|---------------|
    +| `blank/` | BASIC | A black screen |
    +| `marquee/` | BASIC | DVX's name drifting across the screen in changing colours |
    +| `mystify/` | BASIC | Two bouncing four-cornered shapes trailing their outlines |
    +| `starfld/` | C | Stars flying out of the middle of the screen |
    +
    +The name Control Panel lists is the program's own: the `name` resource
    +of a C saver, the project's Name in a BASIC one.
    diff --git a/src/savers/kpunch/Makefile b/src/savers/kpunch/Makefile
    new file mode 100644
    index 00000000..50e70408
    --- /dev/null
    +++ b/src/savers/kpunch/Makefile
    @@ -0,0 +1,82 @@
    +# 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.
    +
    +# DVX screen savers Makefile  -- builds the savers DVX ships into
    +# bin/shared/savers (SHARED\SAVERS, DvxKindSaversE): programs the shell
    +# starts after the wait with no input (dvxshell/shellSaver.c).
    +#
    +# One directory per saver.  A directory / holding /.c
    +# is a C saver (all its .c files linked, .res appended by dvxres);
    +# one holding /.dbp is a BASIC saver (bascomp).  Nothing
    +# lists them.
    +
    +include ../../common.mk
    +
    +SAVER_INCLUDES = -I../../libs/kpunch -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
    +CFLAGS         = $(DOS_CFLAGS) $(SAVER_INCLUDES)
    +
    +OBJDIR  = ../../../obj/savers
    +BINDIR  = ../../../bin/shared/savers
    +DVXRES  = ../../../bin/host/dvxres
    +BASCOMP = ../../../bin/host/bascomp
    +
    +C_SAVERS     = $(sort $(foreach f,$(wildcard */*.c),$(if $(filter $(notdir $(basename $(f))),$(patsubst %/,%,$(dir $(f)))),$(patsubst %/,%,$(dir $(f))))))
    +BASIC_SAVERS = $(sort $(foreach f,$(wildcard */*.dbp),$(if $(filter $(notdir $(basename $(f))),$(patsubst %/,%,$(dir $(f)))),$(patsubst %/,%,$(dir $(f))))))
    +
    +saverSrcs = $(wildcard $(1)/*.c)
    +saverObjs = $(patsubst $(1)/%.c,$(OBJDIR)/$(1)/%.o,$(call saverSrcs,$(1)))
    +saverRes  = $(wildcard $(1)/$(1).res)
    +
    +SAVER_OUTS = $(foreach s,$(C_SAVERS) $(BASIC_SAVERS),$(BINDIR)/$(s).app)
    +
    +.PHONY: all clean
    +
    +all: $(SAVER_OUTS)
    +
    +# $(1) = saver directory name.
    +define C_SAVER_RULES
    +$(BINDIR)/$(1).app: $(call saverObjs,$(1)) $(call saverRes,$(1))
    +	@mkdir -p $$(dir $$@)
    +	$$(DXE3GEN) -o $$@ -U $(call saverObjs,$(1))
    +	$(if $(call saverRes,$(1)),cd $(1) && $$(abspath $$(DVXRES)) build $$(abspath $$@) $(1).res)
    +
    +$(OBJDIR)/$(1)/%.o: $(1)/%.c $$(DVX_MAKEFILES)
    +	@mkdir -p $$(dir $$@)
    +	$$(CC) $$(CFLAGS) -c -o $$@ $$<
    +endef
    +
    +# $(1) = saver directory name.
    +define BASIC_SAVER_RULES
    +$(BINDIR)/$(1).app: $(wildcard $(1)/*.dbp $(1)/*.bas $(1)/*.frm) $$(BASCOMP)
    +	@mkdir -p $$(dir $$@)
    +	$$(BASCOMP) $(1)/$(1).dbp -o $$@ -release
    +endef
    +
    +$(foreach s,$(C_SAVERS),$(eval $(call C_SAVER_RULES,$(s))))
    +$(foreach s,$(BASIC_SAVERS),$(eval $(call BASIC_SAVER_RULES,$(s))))
    +
    +C_SAVER_OBJS = $(foreach s,$(C_SAVERS),$(call saverObjs,$(s)))
    +
    +-include $(C_SAVER_OBJS:.o=.d)
    +
    +clean:
    +	rm -f $(C_SAVER_OBJS) $(C_SAVER_OBJS:.o=.d) $(SAVER_OUTS)
    diff --git a/src/savers/kpunch/blank/blank.bas b/src/savers/kpunch/blank/blank.bas
    new file mode 100644
    index 00000000..cbadbd06
    --- /dev/null
    +++ b/src/savers/kpunch/blank/blank.bas
    @@ -0,0 +1,32 @@
    +' 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.
    +
    +' Blank Screen: a screen saver (DVX starts it with /S after the wait with
    +' no input, and ends it at the next).  The whole screen, black.
    +
    +SCREEN 13
    +CLS
    +FULLSCREEN ON
    +
    +DO
    +    FRAMEWAIT 10
    +LOOP
    diff --git a/src/savers/kpunch/blank/blank.dbp b/src/savers/kpunch/blank/blank.dbp
    new file mode 100644
    index 00000000..acf260bb
    --- /dev/null
    +++ b/src/savers/kpunch/blank/blank.dbp
    @@ -0,0 +1,11 @@
    +[Project]
    +Name = Blank Screen
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = Screen saver: a black screen
    +
    +[Modules]
    +File0 = blank.bas
    +Count = 1
    diff --git a/src/savers/kpunch/marquee/marquee.bas b/src/savers/kpunch/marquee/marquee.bas
    new file mode 100644
    index 00000000..f20c43b5
    --- /dev/null
    +++ b/src/savers/kpunch/marquee/marquee.bas
    @@ -0,0 +1,85 @@
    +' 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.
    +
    +' Marquee: a screen saver (DVX starts it with /S after the wait with no
    +' input, and ends it at the next).  DVX's name drifts across the whole
    +' screen, bouncing off its edges, in a colour that changes as it goes.
    +
    +OPTION EXPLICIT
    +
    +CONST MESSAGE    = "The DOS Visual eXecutive"
    +CONST COLS       = 40      ' SCREEN 13's text grid
    +CONST ROWS       = 25
    +CONST STEP_TICKS = 4       ' frames between moves
    +CONST RATE       = 30      ' frames a second
    +CONST COLOR_MIN  = 32      ' the bright part of the standard palette
    +CONST COLOR_MAX  = 103
    +
    +DIM row AS INTEGER
    +DIM col AS INTEGER
    +DIM dRow AS INTEGER
    +DIM dCol AS INTEGER
    +DIM hue AS INTEGER
    +DIM tick AS INTEGER
    +
    +SCREEN 13
    +CLS
    +FULLSCREEN ON
    +RANDOMIZE TIMER
    +
    +row  = INT(RND * ROWS) + 1
    +col  = INT(RND * (COLS - LEN(MESSAGE))) + 1
    +dRow = 1
    +dCol = 1
    +hue  = COLOR_MIN
    +
    +DO
    +    tick = tick + 1
    +
    +    IF tick >= STEP_TICKS THEN
    +        tick = 0
    +        LOCATE row, col
    +        PRINT SPACE$(LEN(MESSAGE));
    +
    +        IF row + dRow < 1 OR row + dRow > ROWS THEN
    +            dRow = -dRow
    +        END IF
    +
    +        IF col + dCol < 1 OR col + dCol + LEN(MESSAGE) - 1 > COLS THEN
    +            dCol = -dCol
    +        END IF
    +
    +        row = row + dRow
    +        col = col + dCol
    +        hue = hue + 1
    +
    +        IF hue > COLOR_MAX THEN
    +            hue = COLOR_MIN
    +        END IF
    +
    +        COLOR hue
    +        LOCATE row, col
    +        PRINT MESSAGE;
    +    END IF
    +
    +    FRAMEWAIT RATE
    +LOOP
    diff --git a/src/savers/kpunch/marquee/marquee.dbp b/src/savers/kpunch/marquee/marquee.dbp
    new file mode 100644
    index 00000000..79b30a93
    --- /dev/null
    +++ b/src/savers/kpunch/marquee/marquee.dbp
    @@ -0,0 +1,11 @@
    +[Project]
    +Name = Marquee
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = Screen saver: DVX's name drifting across the screen in changing colours
    +
    +[Modules]
    +File0 = marquee.bas
    +Count = 1
    diff --git a/src/savers/kpunch/mystify/mystify.bas b/src/savers/kpunch/mystify/mystify.bas
    new file mode 100644
    index 00000000..c635e566
    --- /dev/null
    +++ b/src/savers/kpunch/mystify/mystify.bas
    @@ -0,0 +1,131 @@
    +' 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.
    +
    +' Mystify: a screen saver (DVX starts it with /S after the wait with no
    +' input, and ends it at the next).  Two outlines of four corners each
    +' bounce around the whole screen; each leaves a trail of its last shapes,
    +' the oldest wiped as the next is drawn, and changes colour now and then.
    +
    +OPTION EXPLICIT
    +
    +CONST SHAPES    = 2
    +CONST CORNERS   = 4
    +CONST TRAIL     = 6        ' outlines each shape leaves behind
    +CONST WIDTH_PX  = 320      ' SCREEN 13
    +CONST HEIGHT_PX = 200
    +CONST SPEED     = 4        ' most pixels a corner moves a frame
    +CONST RATE      = 30       ' frames a second
    +CONST RECOLOR   = 90       ' frames between colour changes
    +CONST COLOR_MIN = 32       ' the bright part of the standard palette
    +CONST COLOR_MAX = 103
    +
    +DIM SHARED x(SHAPES - 1, CORNERS - 1) AS INTEGER
    +DIM SHARED y(SHAPES - 1, CORNERS - 1) AS INTEGER
    +DIM SHARED dx(SHAPES - 1, CORNERS - 1) AS INTEGER
    +DIM SHARED dy(SHAPES - 1, CORNERS - 1) AS INTEGER
    +DIM SHARED trailX(SHAPES - 1, TRAIL - 1, CORNERS - 1) AS INTEGER
    +DIM SHARED trailY(SHAPES - 1, TRAIL - 1, CORNERS - 1) AS INTEGER
    +DIM hue(SHAPES - 1) AS INTEGER
    +DIM slot AS INTEGER
    +DIM frame AS LONG
    +DIM s AS INTEGER
    +DIM c AS INTEGER
    +
    +SCREEN 13
    +CLS
    +FULLSCREEN ON
    +RANDOMIZE TIMER
    +
    +FOR s = 0 TO SHAPES - 1
    +    hue(s) = COLOR_MIN + INT(RND * (COLOR_MAX - COLOR_MIN + 1))
    +
    +    FOR c = 0 TO CORNERS - 1
    +        x(s, c)  = INT(RND * WIDTH_PX)
    +        y(s, c)  = INT(RND * HEIGHT_PX)
    +        dx(s, c) = RandomSpeed()
    +        dy(s, c) = RandomSpeed()
    +    NEXT c
    +NEXT s
    +
    +DO
    +    FOR s = 0 TO SHAPES - 1
    +        ' The oldest outline of the trail goes; this frame's takes its place
    +        Outline s, slot, 0
    +
    +        FOR c = 0 TO CORNERS - 1
    +            MoveCorner s, c
    +            trailX(s, slot, c) = x(s, c)
    +            trailY(s, slot, c) = y(s, c)
    +        NEXT c
    +
    +        IF frame MOD RECOLOR = 0 THEN
    +            hue(s) = COLOR_MIN + INT(RND * (COLOR_MAX - COLOR_MIN + 1))
    +        END IF
    +
    +        Outline s, slot, hue(s)
    +    NEXT s
    +
    +    slot  = (slot + 1) MOD TRAIL
    +    frame = frame + 1
    +    FRAMEWAIT RATE
    +LOOP
    +
    +
    +' A corner's next place: it turns back at an edge.
    +SUB MoveCorner(BYVAL s AS INTEGER, BYVAL c AS INTEGER)
    +    IF x(s, c) + dx(s, c) < 0 OR x(s, c) + dx(s, c) >= WIDTH_PX THEN
    +        dx(s, c) = -dx(s, c)
    +    END IF
    +
    +    IF y(s, c) + dy(s, c) < 0 OR y(s, c) + dy(s, c) >= HEIGHT_PX THEN
    +        dy(s, c) = -dy(s, c)
    +    END IF
    +
    +    x(s, c) = x(s, c) + dx(s, c)
    +    y(s, c) = y(s, c) + dy(s, c)
    +END SUB
    +
    +
    +' One outline of the trail, drawn in colour shade (0: wiped).
    +SUB Outline(BYVAL s AS INTEGER, BYVAL slot AS INTEGER, BYVAL shade AS INTEGER)
    +    DIM c AS INTEGER
    +    DIM n AS INTEGER
    +
    +    FOR c = 0 TO CORNERS - 1
    +        n = (c + 1) MOD CORNERS
    +        LINE (trailX(s, slot, c), trailY(s, slot, c))-(trailX(s, slot, n), trailY(s, slot, n)), shade
    +    NEXT c
    +END SUB
    +
    +
    +' A speed between 1 and SPEED pixels a frame, either way.
    +FUNCTION RandomSpeed() AS INTEGER
    +    DIM v AS INTEGER
    +
    +    v = INT(RND * SPEED) + 1
    +
    +    IF RND < 0.5 THEN
    +        v = -v
    +    END IF
    +
    +    RandomSpeed = v
    +END FUNCTION
    diff --git a/src/savers/kpunch/mystify/mystify.dbp b/src/savers/kpunch/mystify/mystify.dbp
    new file mode 100644
    index 00000000..5e0c83bd
    --- /dev/null
    +++ b/src/savers/kpunch/mystify/mystify.dbp
    @@ -0,0 +1,11 @@
    +[Project]
    +Name = Mystify
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = Screen saver: bouncing outlines trailing their past shapes
    +
    +[Modules]
    +File0 = mystify.bas
    +Count = 1
    diff --git a/src/savers/kpunch/starfld/starfld.c b/src/savers/kpunch/starfld/starfld.c
    new file mode 100644
    index 00000000..a62c01e2
    --- /dev/null
    +++ b/src/savers/kpunch/starfld/starfld.c
    @@ -0,0 +1,210 @@
    +// 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.
    +
    +// starfld.c -- Starfield, a screen saver: stars fly out of the middle of
    +// the screen as if the screen were moving through them, the nearer the
    +// brighter and bigger.
    +//
    +// DVX starts it with /S after the wait with no input and ends it at the
    +// next input by closing its window (the shell's screen saver, shellSaver.c),
    +// so it does no input handling of its own: it takes the whole screen at
    +// 320x200 in 256 colours (full-screen mode) and draws a frame every
    +// FRAME_HZ-th of a second until its window is closed.
    +
    +#include "dvxApp.h"
    +#include "dvxPlat.h"
    +#include "dvxWm.h"
    +#include "shellApp.h"
    +#include "game/game.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define SAVER_TITLE     "Starfield"
    +#define FB_W            320
    +#define FB_H            200
    +#define FULL_BPP        8
    +#define WIN_W           160         // the window while the screen is the desktop's
    +#define WIN_H           100
    +#define FRAME_HZ        30
    +#define STARS           240
    +#define DEPTH_FAR       1024        // where a star starts
    +#define DEPTH_NEAR      16          // where it has passed the viewer
    +#define STAR_SPEED      12          // depth units a frame
    +#define FOCAL           128         // screen position = centre + x * FOCAL / depth
    +#define SPREAD_X        (FB_W * DEPTH_FAR / FOCAL)   // a new star anywhere the far plane shows
    +#define SPREAD_Y        (FB_H * DEPTH_FAR / FOCAL)
    +#define SHADES          16          // palette 1..SHADES: dark grey to white
    +#define SHADE_LOW       112         // the far stars' brightness
    +#define BIG_DEPTH       (DEPTH_FAR / 4)  // nearer than this: 2x2 pixels
    +#define PAL_ENTRIES     256
    +#define PAL_CHANNELS    3
    +#define LCG_MUL         1103515245U
    +#define LCG_ADD         12345U
    +#define RAND_SHIFT      8
    +
    +// A star: where it is across the screen (x, y about the centre) and how far
    +typedef struct {
    +    int32_t x;
    +    int32_t y;
    +    int32_t z;
    +} StarT;
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void     draw(void);
    +static void     onClose(WindowT *win);
    +static void     paletteInit(void);
    +static uint32_t randomNext(void);
    +static void     starNew(StarT *s, bool anyDepth);
    +int32_t         appMain(DxeAppContextT *ctx);
    +
    +// ============================================================
    +// App descriptor
    +// ============================================================
    +
    +SHELL_APP_DESCRIPTOR(
    +    .name          = SAVER_TITLE,
    +    .hasMainLoop   = true,
    +    .multiInstance = false,
    +    .stackSize     = SHELL_STACK_DEFAULT,
    +    .priority      = TS_PRIORITY_NORMAL
    +);
    +
    +static AppContextT *sAc   = NULL;
    +static WindowT     *sWin  = NULL;
    +static bool         sQuit = false;
    +static uint32_t     sSeed = 1;
    +static StarT        sStars[STARS];
    +static uint8_t      sFb[FB_W * FB_H];
    +static uint8_t      sPal[PAL_ENTRIES * PAL_CHANNELS];
    +
    +
    +// Each star a step nearer; one that passed the viewer or left the screen
    +// starts again far away.
    +static void draw(void) {
    +    memset(sFb, 0, sizeof(sFb));
    +
    +    for (int32_t i = 0; i < STARS; i++) {
    +        StarT *s = &sStars[i];
    +
    +        s->z -= STAR_SPEED;
    +
    +        if (s->z < DEPTH_NEAR) {
    +            starNew(s, false);
    +        }
    +
    +        int32_t sx   = FB_W / 2 + s->x * FOCAL / s->z;
    +        int32_t sy   = FB_H / 2 + s->y * FOCAL / s->z;
    +        int32_t size = s->z < BIG_DEPTH ? 2 : 1;
    +
    +        if (sx < 0 || sy < 0 || sx + size > FB_W || sy + size > FB_H) {
    +            starNew(s, false);
    +            continue;
    +        }
    +
    +        uint8_t shade = (uint8_t)(1 + (DEPTH_FAR - s->z) * (SHADES - 1) / DEPTH_FAR);
    +
    +        for (int32_t dy = 0; dy < size; dy++) {
    +            memset(&sFb[(sy + dy) * FB_W + sx], shade, (size_t)size);
    +        }
    +    }
    +}
    +
    +
    +static void onClose(WindowT *win) {
    +    (void)win;
    +    sQuit = true;
    +}
    +
    +
    +// Black, then SHADES greys from SHADE_LOW up to white.
    +static void paletteInit(void) {
    +    memset(sPal, 0, sizeof(sPal));
    +
    +    for (int32_t i = 1; i <= SHADES; i++) {
    +        uint8_t level = (uint8_t)(SHADE_LOW + (UINT8_MAX - SHADE_LOW) * (i - 1) / (SHADES - 1));
    +
    +        memset(&sPal[i * PAL_CHANNELS], level, PAL_CHANNELS);
    +    }
    +}
    +
    +
    +static uint32_t randomNext(void) {
    +    sSeed = sSeed * LCG_MUL + LCG_ADD;
    +    return sSeed >> RAND_SHIFT;
    +}
    +
    +
    +// Somewhere the far plane shows, far away (anyDepth: at any depth, so the
    +// first frame is already full of stars).
    +static void starNew(StarT *s, bool anyDepth) {
    +    s->x = (int32_t)(randomNext() % SPREAD_X) - SPREAD_X / 2;
    +    s->y = (int32_t)(randomNext() % SPREAD_Y) - SPREAD_Y / 2;
    +    s->z = anyDepth ? DEPTH_NEAR + (int32_t)(randomNext() % (DEPTH_FAR - DEPTH_NEAR)) : DEPTH_FAR;
    +}
    +
    +
    +int32_t appMain(DxeAppContextT *ctx) {
    +    GameFramePacerT pacer;
    +
    +    sAc  = ctx->shellCtx;
    +    sWin = dvxCreateWindowCentered(sAc, SAVER_TITLE, WIN_W, WIN_H, false);
    +
    +    if (!sWin) {
    +        return -1;
    +    }
    +
    +    sWin->gameInput = true;
    +    sWin->onClose   = onClose;
    +    sQuit           = false;
    +    sSeed           = (uint32_t)platformMicros() | 1;
    +
    +    if (dvxExclusiveBegin(sAc, sWin, FB_W, FB_H, FULL_BPP, NULL, NULL) != DVX_EXCL_OK) {
    +        dvxLog("Starfield: no full-screen mode");
    +        dvxDestroyWindow(sAc, sWin);
    +        return -1;
    +    }
    +
    +    paletteInit();
    +    dvxExclusiveSetPalette(sAc, sPal, 0, PAL_ENTRIES);
    +
    +    for (int32_t i = 0; i < STARS; i++) {
    +        starNew(&sStars[i], true);
    +    }
    +
    +    gameFrameInit(&pacer, FRAME_HZ);
    +
    +    // Present8 does nothing while another window has the screen.
    +    while (!sQuit) {
    +        gameFrameWait(&pacer);
    +        draw();
    +        dvxExclusivePresent8(sAc, sFb, FB_W, FB_H, FB_W);
    +    }
    +
    +    dvxDestroyWindow(sAc, sWin);
    +    sWin = NULL;
    +    return 0;
    +}
    diff --git a/src/savers/kpunch/starfld/starfld.res b/src/savers/kpunch/starfld/starfld.res
    new file mode 100644
    index 00000000..eb14c535
    --- /dev/null
    +++ b/src/savers/kpunch/starfld/starfld.res
    @@ -0,0 +1,6 @@
    +# starfld.res -- Resource manifest for the Starfield screen saver
    +name        text  "Starfield"
    +author      text  "Scott Duensing"
    +copyright   text  "Copyright 2026 Scott Duensing"
    +publisher   text  "Kangaroo Punch Studios"
    +description text  "Screen saver: stars flying out of the middle of the screen"
    diff --git a/src/sdkHeaders.mk b/src/sdkHeaders.mk
    index 6265fcd0..9b2d42c8 100644
    --- a/src/sdkHeaders.mk
    +++ b/src/sdkHeaders.mk
    @@ -31,7 +31,7 @@
     # with a trailing slash (empty at the top level).
     
     SDK_CORE_H     = $(addprefix $(SDK_ROOT)src/libs/kpunch/libdvx/,dvxApp.h dvxTypes.h dvxWgt.h dvxWgtP.h dvxWm.h dvxDraw.h dvxVideo.h dvxComp.h dvxPrefs.h dvxDlg.h dvxRes.h dvxFont.h dvxText.h dvxFlow.h dvxCur.h dvxPal.h dvxMem.h dvxDrv.h platform/dvxPlat.h)
    -SDK_SHELL_H    = $(addprefix $(SDK_ROOT)src/libs/kpunch/dvxshell/,shellApp.h shellAssoc.h shellInf.h shellProg.h shellRun.h pmModel.h) $(SDK_ROOT)src/libs/kpunch/taskmgr/shellTaskMgr.h
    +SDK_SHELL_H    = $(addprefix $(SDK_ROOT)src/libs/kpunch/dvxshell/,shellApp.h shellAssoc.h shellInf.h shellProg.h shellRun.h shellSaver.h pmModel.h) $(SDK_ROOT)src/libs/kpunch/taskmgr/shellTaskMgr.h
     SDK_TASKS_H    = $(SDK_ROOT)src/libs/kpunch/libtasks/taskSwch.h
     SDK_SQL_H      = $(SDK_ROOT)src/libs/kpunch/sql/dvxSql.h
     SDK_TEXTHELP_H = $(SDK_ROOT)src/libs/kpunch/texthelp/textHelp.h
    diff --git a/src/test/abi/Makefile b/src/test/abi/Makefile
    index 8b048bca..68e7a6d6 100644
    --- a/src/test/abi/Makefile
    +++ b/src/test/abi/Makefile
    @@ -29,6 +29,10 @@
     #   make update   regenerate abiSizes.txt after a DVX_ABI_VERSION bump, or
     #                 after adding a public struct (refuses a changed struct
     #                 without a bump)
    +#   make update KEEP_VERSION=1
    +#                 regenerate it at the version it has, changed structs too
    +#                 (nothing built against DVX is released yet, so layouts
    +#                 change without a bump)
     #
     # The sizes come from the DOS cross compiler (CC), so nothing is run and
     # SAN/COV do not apply.
    @@ -51,7 +55,7 @@ check:
     	./abiCheck.py check $(TABLE) $(OBJDIR) $(CC) $(ABI_FLAGS) -- $(HEADERS)
     
     update:
    -	./abiCheck.py update $(TABLE) $(OBJDIR) $(CC) $(ABI_FLAGS) -- $(HEADERS)
    +	./abiCheck.py $(if $(KEEP_VERSION),keep,update) $(TABLE) $(OBJDIR) $(CC) $(ABI_FLAGS) -- $(HEADERS)
     
     clean:
     	rm -rf $(OBJDIR)
    diff --git a/src/test/abi/abiCheck.py b/src/test/abi/abiCheck.py
    index 021c49c6..008c70c8 100755
    --- a/src/test/abi/abiCheck.py
    +++ b/src/test/abi/abiCheck.py
    @@ -23,7 +23,7 @@
     
     # abiCheck.py -- public struct sizes against the committed ABI table.
     #
    -# Usage: abiCheck.py check|update TABLE OUTDIR CC [CFLAGS...] -- HEADERS...
    +# Usage: abiCheck.py check|update|keep TABLE OUTDIR CC [CFLAGS...] -- HEADERS...
     #
     # Every struct typedef in the SDK headers (a line "} NameT;") is measured
     # with the DOS cross compiler: a generated C file includes every header and
    @@ -37,6 +37,9 @@
     #         another version, or missing a new struct, needs `make update`.
     # update  rewrites the table, but refuses to record a changed or removed
     #         struct without a DVX_ABI_VERSION bump.
    +# keep    rewrites the table at the version it has, changed and removed
    +#         structs too (listed): while nothing built against DVX is out in
    +#         the world, layouts change without a bump.
     
     import os
     import re
    @@ -125,7 +128,7 @@ def writeTable(path, version, sizes):
     
     def main():
         if len(sys.argv) < 6 or '--' not in sys.argv:
    -        print('usage: abiCheck.py check|update TABLE OUTDIR CC [CFLAGS...] -- HEADERS...')
    +        print('usage: abiCheck.py check|update|keep TABLE OUTDIR CC [CFLAGS...] -- HEADERS...')
             sys.exit(2)
     
         mode    = sys.argv[1]
    @@ -148,6 +151,13 @@ def main():
                 breaking.append('%s changed size %d -> %d' % (n, oldSizes[n], sizes[n]))
         added = sorted(n for n in sizes if n not in oldSizes)
     
    +    if mode == 'keep':
    +        for b in breaking:
    +            print('abiCheck: recorded at the same version: ' + b)
    +        writeTable(table, version, sizes)
    +        print('abiCheck: wrote %s (DVX_ABI_VERSION %d kept, %d structs)' % (table, version, len(sizes)))
    +        return
    +
         if mode == 'update':
             if breaking and version == oldVersion:
                 for b in breaking:
    diff --git a/src/test/abi/abiSizes.txt b/src/test/abi/abiSizes.txt
    index 73b6c13b..2d926e78 100644
    --- a/src/test/abi/abiSizes.txt
    +++ b/src/test/abi/abiSizes.txt
    @@ -7,19 +7,22 @@ AccelEntryT           12
     AccelTableT           12
     AnsiTermApiT          36
     AnsiTermExApiT        20
    -AppContextT           3480
    +AppContextT           3484
     AppDescriptorT        80
    -AudioDriverT          28
    +AudioDriverT          36
     AudioDrvConfigT       16
    -AudioDrvInfoT         84
    +AudioDrvInfoT         92
     AudioFmInfoT          76
     AudioFormatT          12
    -AudioInfoT            100
    +AudioInfoT            108
     AudioLockItemT        8
    -AudioMidiInfoT        66
    +AudioMidiInfoT        68
    +AudioMidiMsgT         8
     AudioStatsT           24
    -AudioSynthDriverT     36
    -AudioSynthDrvInfoT    72
    +AudioSynthDriverT     64
    +AudioSynthDrvInfoT    84
    +AudioWtInfoT          92
    +AudioWtVoiceT         96
     BevelStyleT           16
     BitmapFontT           64
     BlitOpsT              12
    @@ -121,9 +124,10 @@ PopupStateT           52
     PrefsChoiceT          12
     PrintChoiceT          276
     PrintHooksT           16
    +PrintPageInfoT        20
     PrintPageSetupT       192
    -PrintPortOpsT         16
    -PrintSettingsT        460
    +PrintPortOpsT         28
    +PrintSettingsT        468
     PrintTextInfoT        24
     ProgressBarApiT       32
     RadioApiT             36
    @@ -170,7 +174,7 @@ WgtPropDescT          20
     WgtSigInfoT           7
     WgtValueT             12
     WidgetClassT          136
    -WidgetT               244
    +WidgetT               236
     WindowStackT          60
     WindowT               308
     WrapBoxApiT           12
    diff --git a/src/test/audio/Makefile b/src/test/audio/Makefile
    index 81fdab77..36255599 100644
    --- a/src/test/audio/Makefile
    +++ b/src/test/audio/Makefile
    @@ -42,24 +42,24 @@ WORKDIR    = $(BINDIR)/work
     DRVDIR     = $(WORKDIR)/libs/kpunch/audio/drivers
     GOLDEN     = $(abspath golden/mixer.txt)
     
    -AUDIO_SRCS = $(filter-out $(AUDIO_DIR)/audioShell.c,$(wildcard $(AUDIO_DIR)/*.c)) $(AUDIO_DIR)/drivers/sb/sbCalc.c
    +AUDIO_SRCS = $(filter-out $(AUDIO_DIR)/audioShell.c,$(wildcard $(AUDIO_DIR)/*.c)) $(AUDIO_DIR)/drivers/sb/sbCalc.c $(AUDIO_DIR)/drivers/wss/wssCalc.c $(AUDIO_DIR)/codec/codecCalc.c $(AUDIO_DIR)/gus/gusCalc.c $(AUDIO_DIR)/drivers/awe/aweCalc.c $(AUDIO_DIR)/math/drvMath.c $(AUDIO_DIR)/isa/isaCalc.c
     AUDIO_OBJS = $(patsubst $(AUDIO_DIR)/%.c,$(BINDIR)/lib/%.o,$(AUDIO_SRCS))
     LIBS_A     = $(DVXTEST_ARCHIVES) $(DVXTEST_TASKS_A) $(DVXTEST_BASRT_A)
     
    -CFLAGS     = $(DVXTEST_CFLAGS) -I$(AUDIO_DIR) -I$(AUDIO_DIR)/drivers/sb -I$(DVXTEST_ROOT)/src/libs/kpunch -DAUDIO_GOLDEN='"$(GOLDEN)"' -DAUDIO_WORKDIR='"$(WORKDIR)"' -I$(DVXTEST_BASICDIR) -DBASIC_INCLUDE_DIR='"$(DVXTEST_ROOT)/src/include/basic"'
    +CFLAGS     = $(DVXTEST_CFLAGS) -I$(AUDIO_DIR) -I$(AUDIO_DIR)/drivers/sb -I$(AUDIO_DIR)/drivers/wss -I$(AUDIO_DIR)/codec -I$(AUDIO_DIR)/drivers/awe -I$(AUDIO_DIR)/gus -I$(AUDIO_DIR)/math -I$(DVXTEST_ROOT)/src/libs/kpunch -DAUDIO_GOLDEN='"$(GOLDEN)"' -DAUDIO_WORKDIR='"$(WORKDIR)"' -I$(DVXTEST_BASICDIR) -DBASIC_INCLUDE_DIR='"$(DVXTEST_ROOT)/src/include/basic"'
     
     # The fake drivers: name, priority, open / play succeed, rate, bits,
     # channels, DAC, tone generator, clocked
    -FAKE_DEFS  = -DFAKE_CLOCK=0 -DFAKE_TONE=0 -DFAKE_DAC=1 -DFAKE_RATE=22050 -DFAKE_BITS=16 -DFAKE_CHANNELS=2 -DFAKE_OPEN_OK=1 -DFAKE_PLAY_OK=1
    +FAKE_DEFS  = -DFAKE_REC_SOURCES=0 -DFAKE_CLOCK=0 -DFAKE_TONE=0 -DFAKE_DAC=1 -DFAKE_RATE=22050 -DFAKE_BITS=16 -DFAKE_CHANNELS=2 -DFAKE_OPEN_OK=1 -DFAKE_PLAY_OK=1
     FAKE_alpha  = -DFAKE_NAME='"alpha"' -DFAKE_PRIORITY=50 -UFAKE_OPEN_OK -DFAKE_OPEN_OK=0
     FAKE_beta   = -DFAKE_NAME='"beta"' -DFAKE_PRIORITY=40 -UFAKE_PLAY_OK -DFAKE_PLAY_OK=0
    -FAKE_gamma  = -DFAKE_NAME='"gamma"' -DFAKE_PRIORITY=30
    +FAKE_gamma  = -DFAKE_NAME='"gamma"' -DFAKE_PRIORITY=30 -UFAKE_REC_SOURCES -DFAKE_REC_SOURCES=3
     FAKE_delta  = -DFAKE_NAME='"delta"' -DFAKE_PRIORITY=20 -UFAKE_DAC -DFAKE_DAC=0 -UFAKE_TONE -DFAKE_TONE=1 -UFAKE_CLOCK -DFAKE_CLOCK=1 -UFAKE_RATE -DFAKE_RATE=11025 -UFAKE_BITS -DFAKE_BITS=8 -UFAKE_CHANNELS -DFAKE_CHANNELS=1
     FAKE_mono8  = -DFAKE_NAME='"mono8"' -DFAKE_PRIORITY=10 -UFAKE_BITS -DFAKE_BITS=8 -UFAKE_CHANNELS -DFAKE_CHANNELS=1
     FAKE_badabi = -DFAKE_NAME='"badabi"' -DFAKE_PRIORITY=90 -DFAKE_BAD_ABI
     FAKE_nodesc = -DFAKE_NO_DESC
     FAKES      = alpha beta gamma delta mono8 badabi nodesc
    -FAKE_DRVS  = $(patsubst %,$(DRVDIR)/%.drv,$(FAKES)) $(DRVDIR)/fmfake.drv $(DRVDIR)/midifake.drv
    +FAKE_DRVS  = $(patsubst %,$(DRVDIR)/%.drv,$(FAKES)) $(DRVDIR)/fmfake.drv $(DRVDIR)/midifake.drv $(DRVDIR)/wtfake.drv
     
     .PHONY: all run clean
     
    @@ -73,7 +73,8 @@ $(DRVDIR)/%.drv: fakeDrv.c $(AUDIO_DIR)/audioDrv.h $(DVX_MAKEFILES)
     	@mkdir -p $(DRVDIR)
     	$(HOSTCC) $(CFLAGS) -shared $(FAKE_DEFS) $(FAKE_$*) -o $@ fakeDrv.c
     
    -# The fake synthesizers (fakeSynth.c): an FM chip and a MIDI port
    +# The fake synthesizers (fakeSynth.c): an FM chip, a MIDI port, and a
    +# wavetable synthesizer
     $(DRVDIR)/fmfake.drv: fakeSynth.c $(AUDIO_DIR)/audioDrv.h $(DVX_MAKEFILES)
     	@mkdir -p $(DRVDIR)
     	$(HOSTCC) $(CFLAGS) -shared -DFAKE_SYN_FM -o $@ fakeSynth.c
    @@ -82,6 +83,10 @@ $(DRVDIR)/midifake.drv: fakeSynth.c $(AUDIO_DIR)/audioDrv.h $(DVX_MAKEFILES)
     	@mkdir -p $(DRVDIR)
     	$(HOSTCC) $(CFLAGS) -shared -o $@ fakeSynth.c
     
    +$(DRVDIR)/wtfake.drv: fakeSynth.c $(AUDIO_DIR)/audioDrv.h $(DVX_MAKEFILES)
    +	@mkdir -p $(DRVDIR)
    +	$(HOSTCC) $(CFLAGS) -shared -DFAKE_SYN_WT -o $@ fakeSynth.c
    +
     $(AUDIO_TEST): audioTest.c $(AUDIO_OBJS) $(DVX_MAKEFILES) $(LIBS_A) | dvxtest-libs
     	@mkdir -p $(BINDIR)
     	$(HOSTCC) $(CFLAGS) -o $@ $< $(AUDIO_OBJS) $(DVXTEST_BASRT_A) $(DVXTEST_LDFLAGS) -Wl,--whole-archive $(DVXTEST_LIBDVX_A) $(DVXTEST_TASKS_A) $(DVXTEST_LIB) -Wl,--no-whole-archive -ldl -lm
    diff --git a/src/test/audio/audioTest.c b/src/test/audio/audioTest.c
    index e994115a..273a19df 100644
    --- a/src/test/audio/audioTest.c
    +++ b/src/test/audio/audioTest.c
    @@ -73,6 +73,12 @@
     #include "dvxPlatIsr.h"
     #include "audioInt.h"
     #include "sbCalc.h"
    +#include "codecCalc.h"
    +#include "wssCalc.h"
    +#include "gusCalc.h"
    +#include "aweCalc.h"
    +#include "drvMath.h"
    +#include "isa/isaCalc.h"
     #include "stb_ds.h"
     #include "compiler/compiler.h"
     #include "runtime/vm.h"
    @@ -94,6 +100,8 @@
     #define LINE_MAX_LEN        128
     #define WAV_MAX             4096
     #define PCM_FRAMES          64
    +#define PAUSE_FRAMES        8           // a voice paused this far into the ramp
    +#define MIX_HELD_FRAMES     2           // a voice's current and next frames (read ahead to interpolate)
     #define FNV_OFFSET          0xcbf29ce484222325ULL
     #define FNV_PRIME           0x100000001b3ULL
     #define RATE_DEV            22050
    @@ -117,6 +125,11 @@
     #define FM_OPL3_VOICES      18
     #define FM_TONE_FRAMES      256
     #define MIDI_BYTES_MAX      64
    +#define MIDI_IN_WAIT_MS     5           // the input timer's calls (1 ms each) to read what came
    +#define WT_FAKE_MEMORY      (1024 * 1024)
    +#define WT_FAKE_VOICES_MAX  32
    +#define WT_ALIGN            32
    +#define WT_BANK             0x40000
     #define NOTE_US             10000       // 220.5 frames at 22,050 Hz
     #define NOTE_FRAMES         220         // the first note's frames (the half is carried)
     #define NOTE_MHZ_LOW        1000000     // 1000 Hz
    @@ -127,6 +140,28 @@
     #define NOTE_SQ_FLIP        12          // a 1000 Hz square is negative by this frame
     #define DESK_PCM_FRAMES     1103        // 0.1 s at 11,025 Hz
     #define DESK_PCM_LEVEL      200
    +#define REC_BLOCK_FRAMES    64          // one block "the card recorded"
    +#define REC_LEVEL           16000       // the block's samples: +/- this
    +#define REC_RATE_OFF        5           // fakeDrv FAKE_REC_RATE_OFF: the card's rate is this far above
    +#define REC_RATE            11025
    +#define REC_BAD_BITS        12
    +#define REC_FILL_BLOCKS     1100        // more than the default buffer holds
    +#define REC_FILE_BLOCKS     3
    +#define REC_FILE            "rec.wav"
    +#define REC_BAD_FILE        "nodir" DVX_PATH_SEP "rec.wav"
    +#define REC_U8_LOW          64
    +#define REC_U8_HIGH         192
    +#define REC_HEAD_DATA       1000
    +#define REC_HEAD_RATE       22050
    +#define WAV_HEAD_RIFF_SIZE  4
    +#define WAV_HEAD_WAVE       8           // "WAVEfmt "
    +#define WAV_HEAD_WAVE_LEN   8
    +#define WAV_HEAD_DATA_TAG   36          // "data"
    +#define WAV_FOURCC_LEN      4
    +#define WAV_HEAD_ALIGN      32
    +#define WAV_HEAD_BITS       34
    +#define WAV_HEAD_DATA_SIZE  40
    +#define WAV_HEAD_RIFF_EXTRA 36
     #define DESK_WAV_BYTES      2048
     #define DESK_WAIT_MIN_US    90000
     #define DESK_WAIT_MAX_US    1000000
    @@ -137,6 +172,7 @@
     #define DRIVERS_FAKE        5           // the fake drivers that load (alpha .. mono8)
     #define MIDI_FM_INDEX       2
     #define MIDI_MPU_INDEX      1
    +#define MODS_SOFT_INDEX     1
     #define PERCENT_FULL        100
     #define PERCENT_HALF        50
     #define PERCENT_INI         40
    @@ -149,6 +185,7 @@
     #define BAS_STREAM_FRAMES   100
     #define BAS_RENDER_FRAMES   32
     #define BAS_PITCH_DOUBLE    200
    +#define BAS_WT_FRAMES       4           // WtUpload's samples in the BASIC test
     #define BAS_FM_REG          0xB0        // FM_KEY_BLOCK
     #define BAS_FM_RAW_REG      0xB3
     #define BAS_MIDI_NOTE_ON    0x90
    @@ -171,6 +208,8 @@ typedef struct {
     } GoldenT;
     
     static char     sEvents[EVENT_MAX][EVENT_TEXT_MAX];
    +static int32_t  sSynthOpens  = 0;
    +static int32_t  sSynthCloses = 0;
     static int32_t  sEventCount = 0;
     static uint32_t sToneHz     = 0;
     static int32_t  sToneCalls  = 0;
    @@ -188,21 +227,35 @@ static int16_t  sFillValue   = 0;
     static uint8_t  sFmRegs[FM_REGS];
     static uint32_t sFmWrites    = 0;
     static uint8_t  sMidiBytes[MIDI_BYTES_MAX];
    +static uint8_t  sMidiIn[MIDI_BYTES_MAX];            // what the fake port's input brings next
    +static int32_t  sMidiInHead = 0;
    +static int32_t  sMidiInTail = 0;
     static int32_t  sMidiCount   = 0;
     static bool     sMidiReady   = true;
    +static uint8_t  sWtMemory[WT_FAKE_MEMORY];         // the fake wavetable synthesizer's memory
    +static AudioWtVoiceT sWtVoice[WT_FAKE_VOICES_MAX]; // the last note started or updated on each voice
    +static uint32_t sWtStarts;
    +static uint32_t sWtUpdates;
    +static uint32_t sWtWhat;                           // the last update's AUDIO_WT_SET_* bits
    +static uint32_t sWtReleased;                       // a bit per voice
    +static uint32_t sWtStopped;
    +static uint32_t sWtActive;                         // a bit per voice: still sounding
     static char     sCapture[BAS_CAPTURE_MAX];
     
     // ============================================================
     // Prototypes
     // ============================================================
     
    +static void     aweTests(void);
     static void     basicLiveTests(void);
    +static void     basicRecordTests(void);
     static void     basicSampleTests(void);
     static void     basicStreamTests(void);
     static void     basicSynthTests(void);
     static void     basicTests(void);
     static void     captureOut(void *ctx, const char *text, bool newline);
     static void     checkGolden(const char *name, uint64_t hash);
    +static void     codecTests(void);
     static void     countCall(void *userData);
     static void     deskTests(void);
     static void     driverTests(void);
    @@ -210,13 +263,22 @@ static void     eventsClear(void);
     static bool     eventSeen(const char *text);
     void            fakeDrvEvent(const char *driver, const char *event);
     void            fakeDrvTone(uint32_t hz);
    +void            fakeSynthEvent(const char *synth, const char *event);
    +bool            fakeSynthGet(uint8_t *byte);
     bool            fakeSynthPut(uint8_t byte);
     void            fakeSynthWrite(uint32_t reg, uint8_t value);
    +bool            fakeWtActive(uint32_t voice);
    +void            fakeWtRelease(uint32_t voice);
    +void            fakeWtStart(uint32_t voice, const AudioWtVoiceT *v);
    +void            fakeWtStop(uint32_t voice);
    +void            fakeWtUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +bool            fakeWtUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
     static void     fileTests(void);
     static uint32_t fillRamp(AudioStreamT *stream, void *buf, uint32_t frames, void *userData);
     static void     fmRawTests(void);
     static void     fmTests(void);
     static void     fmToneTests(void);
    +static void     gusTests(void);
     static uint64_t hashOut(uint32_t frames, uint32_t frameBytes);
     static void     hookA(void);
     static void     hookB(void);
    @@ -224,6 +286,8 @@ static void     idleTests(void);
     static void     loadGoldens(void);
     static uint32_t makeVoc(uint8_t *buf, uint16_t checksumDelta, const uint8_t *blocks, uint32_t blockBytes);
     static uint32_t makeWav(uint8_t *buf, uint32_t tag, uint32_t channels, uint32_t rate, uint32_t bits, uint32_t align, const void *data, uint32_t dataBytes, bool listFirst);
    +static void     midiIn(const uint8_t *bytes, int32_t count);
    +static void     midiInTests(void);
     static void     midiTests(void);
     static void     mixerTests(void);
     static void     notesTests(void);
    @@ -233,20 +297,80 @@ static void     platformTests(void);
     static void     programTests(void);
     static void     put16(uint8_t *p, uint32_t v);
     static void     put32(uint8_t *p, uint32_t v);
    +static void     recordTests(void);
     static void     render(uint32_t frames);
     static void     saveGoldens(void);
     static void     sbTests(void);
     static void     setIni(const char *extra);
     static void     settingsBasicTests(void);
     static void     settingsTests(void);
    +static void     suspendTests(void);
     static void     toneTests(void);
     static void     voiceTests(void);
    +static void     wssTests(void);
    +static void     wtTests(void);
     
     // prefs.bas (libdvx dvxPrefsBas.c)
     void            PrefsClose(int32_t handle);
     int32_t         PrefsLoad(const char *path);
     
     
    +// The EMU8000's units (aweCalc.c) and the drivers' 2^x (drvMath.c).
    +static void aweTests(void) {
    +    tCase("awe: 2^x in 16.16");
    +    TEST_EQ(drvPow2(0), 65536);
    +    TEST_EQ(drvPow2(1200), 131072);
    +    TEST_EQ(drvPow2(-1200), 32768);
    +    TEST_EQ(drvPow2(100), 69433);
    +    TEST_EQ(drvPow2(50), (65536 + 69433) / 2);
    +    TEST_EQ(drvPow2(-2400), 16384);
    +
    +    tCase("awe: pitch, loudness, filter, pan, sends");
    +    TEST_EQ(awePitch(0), AWE_IP_UNITY);
    +    TEST_EQ(awePitch(1200), 0xF000);
    +    TEST_EQ(awePitch(-1200), 0xD000);
    +    TEST_EQ(awePitch(12000), 0xFFFF);
    +    TEST_EQ(aweAtten(0), 0);
    +    TEST_EQ(aweAtten(375), 100);
    +    TEST_EQ(aweAtten(2000), 0xFF);
    +    TEST_EQ(aweCutoff(4721), 0);
    +    TEST_EQ(aweCutoff(4721 + 2250), 80);
    +    TEST_EQ(aweCutoff(13500), 0xFF);
    +    TEST_EQ(aweQ(96), 8);
    +    TEST_EQ(aweQ(500), 15);
    +    TEST_EQ(awePan(-500), 0xFF);
    +    TEST_EQ(awePan(500), 0);
    +    TEST_EQ(awePan(0), 127);
    +    TEST_EQ(aweSend(1000), 0xFF);
    +    TEST_EQ(aweSend(500), 127);
    +
    +    tCase("awe: envelope and LFO times");
    +    TEST_EQ(aweDelay(-12000), AWE_DELAY_NONE);
    +    TEST_EQ(aweDelay(0), AWE_DELAY_NONE - 1000 * 40 / 29);
    +
    +    // The guide's example: a 100 ms release is decay code 5Ch
    +    TEST_EQ(aweDecay(-3986), 0x5C);
    +    TEST_EQ(aweDecay(2400), 11);
    +    TEST_EQ(aweAttack(-3986), 61);
    +    TEST_EQ(aweAttack(0), 11);
    +    TEST_EQ(aweAttack(-12000), 127);
    +    TEST_EQ(aweHold(-12000), 0x7F);
    +    TEST_EQ(aweHold(0), 0x7F - 1000 / 92);
    +    TEST_EQ(aweVolSustain(0), 0x7F);
    +    TEST_EQ(aweVolSustain(960), 0);
    +    TEST_EQ(aweModSustain(1000), 0);
    +    TEST_EQ(aweLfoFreq(0), 194);
    +    TEST_EQ(aweLfoFreq(-1200), 97);
    +    TEST_EQ(aweLfoFreq(2000), 0xFF);
    +
    +    tCase("awe: signed depths");
    +    TEST_EQ(aweDepth(600, 75, 8), 64);
    +    TEST_EQ(aweDepth(1200, 75, 8), 127);
    +    TEST_EQ(aweDepth(-1200, 75, 8), 0x80);
    +    TEST_EQ(aweDepth(-75, 75, 8), 0xF8);
    +}
    +
    +
     // sound.bas and music.bas compiled with a program whose DECLARE LIBRARY
     // calls reach the library for real: BINARY STRING data with zero bytes
     // arrives whole (its length shows in SoundLength and the bytes a stream
    @@ -320,6 +444,64 @@ static void basicLiveTests(void) {
     }
     
     
    +// sound.bas: recording into a file (the gamma fake records; the test
    +// pushes the blocks), the format, the level and length, one recording
    +// at a time, and a program's recording that is not another's.
    +static void basicRecordTests(void) {
    +    int16_t block[REC_BLOCK_FRAMES];
    +
    +    for (int32_t i = 0; i < REC_BLOCK_FRAMES; i++) {
    +        block[i] = (int16_t)((i & 1) ? REC_LEVEL : -REC_LEVEL);
    +    }
    +
    +    tCase("basic: RecordStart / RecordStop into a WAV file");
    +    setIni("driver = gamma\n");
    +    sAppId = APP_A;
    +    TEST_EQ(SoundCaps("record"), BAS_TRUE);
    +    TEST_EQ(SoundCaps("RecordSources"), (1 << AUDIO_REC_MIC) | (1 << AUDIO_REC_LINE));
    +    TEST_EQ(Recording(), BAS_FALSE);
    +    TEST_EQ(RecordStop(), BAS_FALSE);
    +    TEST_EQ(RecordStart(REC_FILE, -1, 0, 0, AUDIO_REC_MIC), BAS_FALSE);
    +    TEST_STR_EQ(SoundError(), "Invalid argument");
    +    TEST_EQ(RecordStart(REC_FILE, 0, 0, 0, AUDIO_REC_CD), BAS_FALSE);
    +    TEST_STR_EQ(SoundError(), "The sound device cannot record from that input");
    +    TEST_EQ(RecordStart(REC_FILE, 0, 0, 0, AUDIO_REC_MIC), BAS_TRUE);
    +    TEST_EQ(Recording(), BAS_TRUE);
    +    TEST_EQ(RecordRate(), AUDIO_RATE_DEFAULT + REC_RATE_OFF);
    +    TEST_EQ(RecordBits(), AUDIO_REC_BITS_DEFAULT);
    +    TEST_EQ(RecordChannels(), 1);
    +    TEST_EQ(RecordStart(REC_FILE, 0, 0, 0, AUDIO_REC_MIC), BAS_FALSE);
    +    audioRecPush(block, sizeof(block));
    +    audioPump();
    +    TEST_EQ(RecordLevel(), REC_LEVEL * PERCENT_FULL / INT16_MAX);
    +    TEST_EQ(RecordLength(), REC_BLOCK_FRAMES * MS_PER_SEC / (AUDIO_RATE_DEFAULT + REC_RATE_OFF));
    +    TEST_EQ(RecordLost(), 0);
    +
    +    // Another program sees no recording of its own.
    +    sAppId = APP_B;
    +    TEST_EQ(Recording(), BAS_FALSE);
    +    TEST_EQ(RecordStop(), BAS_FALSE);
    +    sAppId = APP_A;
    +    TEST_EQ(RecordStop(), BAS_TRUE);
    +    TEST_EQ(Recording(), BAS_FALSE);
    +    TEST_EQ(RecordRate(), 0);
    +
    +    int32_t size = 0;
    +    char   *wav  = platformReadFile(REC_FILE, &size);
    +
    +    TEST_EQ(size, AUDIO_WAV_HEADER_BYTES + (int32_t)sizeof(block));
    +    free(wav);
    +
    +    tCase("basic: a program's end closes its recording");
    +    TEST_EQ(RecordStart(REC_FILE, 0, 0, 0, AUDIO_REC_LINE), BAS_TRUE);
    +    eventsClear();
    +    audioAppEnded(APP_A);
    +    TEST_TRUE(eventSeen("gamma recordstop"));
    +    TEST_EQ(Recording(), BAS_FALSE);
    +    audioAppEnded(APP_B);
    +}
    +
    +
     // sound.bas: loaded and created sounds, voices changed while playing,
     // device facts.
     static void basicSampleTests(void) {
    @@ -358,6 +540,8 @@ static void basicSampleTests(void) {
         TEST_STR_EQ(SoundError(), "Invalid argument");
         SoundSetFrequency(tri, -1);
         TEST_STR_EQ(SoundError(), "Invalid argument");
    +    SoundPause(tri);
    +    TEST_STR_EQ(SoundError(), "Invalid argument");
         StopWave(tri);
         SoundSetVolume(tri, PERCENT_HALF);
         TEST_STR_EQ(SoundError(), audioErrorText(AUDIO_ERR_VOICE));
    @@ -400,6 +584,14 @@ static void basicSampleTests(void) {
         TEST_EQ(audioMixer.voices[(v - 1) % 256].step, MIX_FRAC_ONE * 2);
         SoundSetPitch(v, PERCENT_FULL * PERCENT_FULL);
         TEST_EQ(audioMixer.voices[(v - 1) % 256].step, MIX_FRAC_ONE * AUDIO_PITCH_MAX / AUDIO_PITCH_NORMAL);
    +    SoundPause(v);
    +    TEST_STR_EQ(SoundError(), "No error");
    +    render(BAS_RENDER_FRAMES);
    +    TEST_EQ(outPeak(BAS_RENDER_FRAMES), 0);
    +    TEST_EQ(SoundPlaying(v), BAS_TRUE);
    +    SoundResume(v);
    +    render(BAS_RENDER_FRAMES);
    +    TEST_TRUE(outPeak(BAS_RENDER_FRAMES) > 0);
         SoundSetVolume(v, 0);
         render(BAS_RENDER_FRAMES);
         TEST_EQ(outPeak(BAS_RENDER_FRAMES), 0);
    @@ -521,9 +713,15 @@ static void basicStreamTests(void) {
         sAppId = APP_B;
         TEST_EQ(SoundStreamQueued(h), 0);
         TEST_EQ(SoundStreamVoice(h), 0);
    +    SoundStreamFlush(h);
         SoundStreamClose(h);
         sAppId = APP_A;
         TEST_TRUE(SoundStreamQueued(h) > 0);
    +
    +    // A flush drops what is queued; the stream stays open.
    +    SoundStreamFlush(h);
    +    TEST_EQ(SoundStreamQueued(h), 0);
    +    TEST_TRUE(SoundStreamSpace(h) > 0);
         SoundStreamClose(h);
         TEST_EQ(SoundStreamQueued(h), 0);
         TEST_EQ(SoundStreamWrite(h, (const uint8_t *)pcm, (int32_t)sizeof(pcm)), 0);
    @@ -589,6 +787,93 @@ static void basicSynthTests(void) {
         audioAppEnded(APP_A);
         audioAppEnded(APP_B);
     
    +    tCase("basic: wavetable voices and memory");
    +    setIni("driver = gamma\nfakeWtVoices = 4\n");
    +    sAppId = APP_A;
    +    TEST_STR_EQ(WtInfo("description"), "Fake wavetable (4 voices)");
    +    TEST_STR_EQ(WtInfo("Voices"), "4");
    +    TEST_STR_EQ(WtInfo("free"), "4");
    +    snprintf(text, sizeof(text), "%d", WT_FAKE_MEMORY);
    +    TEST_STR_EQ(WtInfo("memory"), text);
    +    TEST_STR_EQ(WtInfo("freememory"), text);
    +    TEST_STR_EQ(WtInfo("level"), "0");
    +    TEST_STR_EQ(WtInfo("envelopes"), "-1");
    +    TEST_STR_EQ(WtInfo("8bit"), "0");
    +    TEST_STR_EQ(WtInfo("rom"), "0");
    +    TEST_STR_EQ(WtInfo("nonsense"), "");
    +
    +    static const int16_t wtPcm[BAS_WT_FRAMES] = { 0x0102, 0x0304, -2, 0x7F00 };
    +    int32_t              wtAddr                = WtMemAlloc(BAS_WT_FRAMES * 2, 16);
    +
    +    TEST_EQ(wtAddr, 0);
    +    TEST_EQ(WtMemAlloc(0, 16), -1);
    +    TEST_STR_EQ(SoundError(), "Invalid argument");
    +    TEST_EQ(WtUpload(wtAddr, (const uint8_t *)wtPcm, (int32_t)sizeof(wtPcm), 16), BAS_TRUE);
    +    TEST_EQ(sWtMemory[1], 0x01);
    +    TEST_EQ(WtUpload(wtAddr, (const uint8_t *)wtPcm, 1, 16), 0);
    +
    +    int32_t wv = WtAlloc();
    +
    +    TEST_TRUE(wv > 0);
    +    TEST_STR_EQ(WtInfo("free"), "3");
    +    TEST_EQ(WtStart(wv, wtAddr, 2, BAS_WT_FRAMES, 1, BAS_WT_FRAMES, AUDIO_WT_LOOP_ON, 16, 1200, 60, 100), BAS_TRUE);
    +    TEST_EQ(sWtVoice[0].pitch, 1200);
    +    TEST_EQ(sWtVoice[0].attenuation, 60);
    +    TEST_EQ(sWtVoice[0].pan, AUDIO_WT_PAN_MAX);
    +    TEST_EQ(sWtVoice[0].loop, AUDIO_WT_LOOP_ON);
    +    TEST_EQ(sWtVoice[0].offset, 2);
    +    TEST_EQ(WtStart(wv, wtAddr, BAS_WT_FRAMES + 1, BAS_WT_FRAMES, 0, 0, 0, 16, 0, 0, 0), 0);
    +    TEST_EQ(sWtVoice[0].volEnv[AUDIO_WT_ENV_SUSTAIN], 0);
    +    TEST_EQ(WtActive(wv), BAS_TRUE);
    +    TEST_EQ(WtStart(wv, wtAddr, 0, BAS_WT_FRAMES, 3, 2, AUDIO_WT_LOOP_ON, 16, 0, 0, 0), 0);
    +    TEST_EQ(WtStart(wv, wtAddr, 0, BAS_WT_FRAMES, 0, 0, 2, 16, 0, 0, 0), 0);
    +    TEST_EQ(WtStart(wv, wtAddr, 0, BAS_WT_FRAMES, 0, 0, 0, 12, 0, 0, 0), 0);
    +    TEST_EQ(WtSetPitch(wv, 1300), BAS_TRUE);
    +    TEST_EQ(sWtVoice[0].pitch, 1300);
    +    TEST_EQ(sWtWhat, AUDIO_WT_SET_PITCH);
    +    TEST_EQ(WtSetAttenuation(wv, AUDIO_WT_ATTEN_MAX + 5), BAS_TRUE);
    +    TEST_EQ(sWtVoice[0].attenuation, AUDIO_WT_ATTEN_MAX);
    +    TEST_EQ(WtSetPan(wv, -100), BAS_TRUE);
    +    TEST_EQ(sWtVoice[0].pan, -AUDIO_WT_PAN_MAX);
    +    TEST_EQ(sWtVoice[0].pitch, 1300);
    +    TEST_EQ(WtSetPitch(wv + 1, 0), 0);
    +    sWtReleased = 0;
    +    WtRelease(wv);
    +    TEST_EQ(sWtReleased, 1u);
    +    WtStop(wv);
    +    TEST_EQ(WtActive(wv), 0);
    +
    +    // Another program cannot touch it
    +    sAppId = APP_B;
    +    TEST_EQ(WtSetPitch(wv, 0), 0);
    +    WtFree(wv);
    +    TEST_TRUE(strcmp(SoundError(), "No error") != 0);
    +    sAppId = APP_A;
    +    WtFree(wv);
    +    TEST_STR_EQ(SoundError(), "No error");
    +    TEST_STR_EQ(WtInfo("free"), "4");
    +    TEST_EQ(WtSetPitch(wv, 0), 0);
    +    WtMemFree(wtAddr);
    +    TEST_STR_EQ(SoundError(), "No error");
    +    WtMemFree(wtAddr);
    +    TEST_TRUE(strcmp(SoundError(), "No error") != 0);
    +
    +    // A program's voices go back with its context (asked without opening
    +    // one: the next case needs the device closed)
    +    AudioWtInfoT wtInfo;
    +
    +    wv = WtAlloc();
    +    TEST_EQ(WtStart(wv, 0, 0, BAS_WT_FRAMES, 0, 0, AUDIO_WT_LOOP_NONE, 16, 0, 0, 0), BAS_TRUE);
    +    sAppId = APP_B;
    +    TEST_EQ(WtAlloc() > 0, true);
    +    audioWtGetInfo(&wtInfo);
    +    TEST_EQ(wtInfo.freeVoices, 2);
    +    audioAppEnded(APP_B);
    +    audioWtGetInfo(&wtInfo);
    +    TEST_EQ(wtInfo.freeVoices, 3);
    +    audioAppEnded(APP_A);
    +    sAppId = APP_A;
    +
         tCase("basic: the raw MIDI port (music.bas)");
         setIni("driver = gamma\nmidi = mpu401\n");
         TEST_STR_EQ(MidiPortInfo("description"), "Fake MIDI port");
    @@ -697,6 +982,35 @@ static void checkGolden(const char *name, uint64_t hash) {
     }
     
     
    +// The AD1848 codec's rates and format byte (codecCalc.c), shared by the
    +// Windows Sound System board and the UltraSound MAX.
    +static void codecTests(void) {
    +    uint32_t rate = 0;
    +
    +    tCase("codec: the rates");
    +    TEST_EQ(codecRateCode(22050, &rate), 0x07);
    +    TEST_EQ(rate, 22050);
    +    TEST_EQ(codecRateCode(44100, &rate), 0x0B);
    +    TEST_EQ(codecRateCode(48000, &rate), 0x0C);
    +    TEST_EQ(codecRateCode(8000, &rate), 0x00);
    +    TEST_EQ(codecRateCode(27000, &rate), 0x04);
    +    TEST_EQ(rate, 27429);
    +
    +    // Nearest: 20,000 is 18,900 (1,100 off, not 2,050); out of range
    +    // takes an end
    +    codecRateCode(20000, &rate);
    +    TEST_EQ(rate, 18900);
    +    TEST_EQ(codecRateCode(1, &rate), 0x01);
    +    TEST_EQ(rate, 5513);
    +    codecRateCode(100000, &rate);
    +    TEST_EQ(rate, 48000);
    +
    +    tCase("codec: the data format");
    +    TEST_EQ(codecFormat(16, 2), CODEC_FMT_16BIT | CODEC_FMT_STEREO);
    +    TEST_EQ(codecFormat(8, 1), CODEC_FMT_8BIT);
    +}
    +
    +
     static void countCall(void *userData) {
         (*(uint32_t *)userData)++;
     }
    @@ -910,6 +1224,29 @@ void fakeDrvTone(uint32_t hz) {
     }
     
     
    +// The fake synthesizers' opens and closes (fakeSynth.c), counted.
    +void fakeSynthEvent(const char *synth, const char *event) {
    +    (void)synth;
    +
    +    if (strcmp(event, "open") == 0) {
    +        sSynthOpens++;
    +    } else {
    +        sSynthCloses++;
    +    }
    +}
    +
    +
    +// The fake MIDI port's input: the bytes midiIn queued.
    +bool fakeSynthGet(uint8_t *byte) {
    +    if (sMidiInTail == sMidiInHead) {
    +        return false;
    +    }
    +
    +    *byte = sMidiIn[sMidiInTail++];
    +    return true;
    +}
    +
    +
     bool fakeSynthPut(uint8_t byte) {
         if (!sMidiReady || sMidiCount >= MIDI_BYTES_MAX) {
             return false;
    @@ -928,6 +1265,61 @@ void fakeSynthWrite(uint32_t reg, uint8_t value) {
     }
     
     
    +bool fakeWtActive(uint32_t voice) {
    +    return (sWtActive >> voice) & 1;
    +}
    +
    +
    +void fakeWtRelease(uint32_t voice) {
    +    sWtReleased |= 1u << voice;
    +}
    +
    +
    +void fakeWtStart(uint32_t voice, const AudioWtVoiceT *v) {
    +    sWtVoice[voice] = *v;
    +    sWtActive      |= 1u << voice;
    +    sWtStarts++;
    +}
    +
    +
    +void fakeWtStop(uint32_t voice) {
    +    sWtStopped |= 1u << voice;
    +    sWtActive  &= ~(1u << voice);
    +}
    +
    +
    +void fakeWtUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    sWtVoice[voice] = *v;
    +    sWtWhat         = what;
    +    sWtUpdates++;
    +}
    +
    +
    +// Frames into the fake memory as 8-bit (signed, the top byte) or 16-bit
    +// little-endian samples.
    +bool fakeWtUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits) {
    +    uint32_t bytes = bits / 8;
    +
    +    if (addr > WT_FAKE_MEMORY || count > (WT_FAKE_MEMORY - addr) / bytes) {
    +        return false;
    +    }
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        uint16_t v = (uint16_t)frames[i];
    +
    +        if (bits == 8) {
    +            sWtMemory[addr + i] = (uint8_t)(v >> 8);
    +        } else {
    +            sWtMemory[addr + i * 2]     = (uint8_t)v;
    +            sWtMemory[addr + i * 2 + 1] = (uint8_t)(v >> 8);
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +
     static void fileTests(void) {
         static uint8_t wav[WAV_MAX];
         uint8_t        pcm8[PCM_FRAMES];
    @@ -1390,6 +1782,109 @@ static void fmToneTests(void) {
     }
     
     
    +// The Gravis UltraSound's arithmetic (gusCalc.c): ULTRASND, the latch
    +// codes, rates and frequency controls, addresses, volumes.
    +static void gusTests(void) {
    +    GusEnvT env;
    +
    +    tCase("gus: the ULTRASND variable");
    +    TEST_TRUE(gusParseEnv("220,1,1,11,5", &env));
    +    TEST_EQ(env.base, 0x220);
    +    TEST_EQ(env.dma1, 1);
    +    TEST_EQ(env.dma2, 1);
    +    TEST_EQ(env.irq1, 11);
    +    TEST_EQ(env.irq2, 5);
    +    TEST_TRUE(gusParseEnv("240, 5, 6, 12, 7", &env));
    +    TEST_EQ(env.base, 0x240);
    +    TEST_EQ(env.dma2, 6);
    +    TEST_EQ(env.irq2, 7);
    +    TEST_TRUE(gusParseEnv("260", &env));
    +    TEST_EQ(env.dma1, GUS_NONE);
    +    TEST_TRUE(!gusParseEnv(NULL, &env));
    +    TEST_TRUE(!gusParseEnv("", &env));
    +    TEST_TRUE(!gusParseEnv(",1,1", &env));
    +
    +    tCase("gus: latch codes");
    +    TEST_EQ(gusIrqCode(2), 1);
    +    TEST_EQ(gusIrqCode(9), 1);
    +    TEST_EQ(gusIrqCode(5), 2);
    +    TEST_EQ(gusIrqCode(11), 5);
    +    TEST_EQ(gusIrqCode(15), 7);
    +    TEST_EQ(gusIrqCode(10), 0);
    +    TEST_EQ(gusDmaCode(1), 1);
    +    TEST_EQ(gusDmaCode(3), 2);
    +    TEST_EQ(gusDmaCode(7), 5);
    +    TEST_EQ(gusDmaCode(0), 0);
    +
    +    // Both channels latched: ULTRASND's second in bits 5:3, or recording
    +    // put on the first (40h) when the second is the same or unusable; the
    +    // channel recording uses
    +    tCase("gus: the DMA latch and the record channel");
    +    TEST_EQ(gusDmaLatch(1, 1), 0x01 | GUS_DMA_LATCH_COMBINE);
    +    TEST_EQ(gusDmaLatch(1, 3), 0x01 | 0x02 << 3);
    +    TEST_EQ(gusDmaLatch(5, 6), 0x03 | 0x04 << 3);
    +    TEST_EQ(gusDmaLatch(1, GUS_NONE), 0x01 | GUS_DMA_LATCH_COMBINE);
    +    TEST_EQ(gusDmaLatch(1, 0), 0x01 | GUS_DMA_LATCH_COMBINE);
    +    TEST_EQ(gusRecordDma(1, 3), 3);
    +    TEST_EQ(gusRecordDma(1, GUS_NONE), 1);
    +    TEST_EQ(gusRecordDma(5, 2), 5);
    +
    +    // Sampling at 617,400 / (value + 2) Hz, clamped to 5,510..44,100
    +    tCase("gus: the sampling rate");
    +    uint32_t rate = 0;
    +
    +    TEST_EQ(gusSampleRate(22050, &rate), 26);
    +    TEST_EQ(rate, 22050);
    +    TEST_EQ(gusSampleRate(44100, &rate), 12);
    +    TEST_EQ(rate, 44100);
    +    TEST_EQ(gusSampleRate(96000, &rate), 12);
    +    TEST_EQ(rate, GUS_SAMPLE_RATE_MAX);
    +    TEST_EQ(gusSampleRate(8000, &rate), 75);
    +    TEST_EQ(rate, 8018);
    +    TEST_EQ(gusSampleRate(1, &rate), 110);
    +    TEST_EQ(rate, 5513);
    +
    +    // A MAX reads version 10 or 11 (an ICS board 5..9, the ACE 30h, the
    +    // Extreme 50h, an older board FFh); its control register: the base's
    +    // bits 7-4 (the codec at base + 10Ch), the 16-bit channels, the codec
    +    // on (40h)
    +    tCase("gus: the MAX");
    +    TEST_TRUE(gusIsMax(10));
    +    TEST_TRUE(gusIsMax(11));
    +    TEST_TRUE(!gusIsMax(9));
    +    TEST_TRUE(!gusIsMax(0x30));
    +    TEST_TRUE(!gusIsMax(0x50));
    +    TEST_TRUE(!gusIsMax(0xFF));
    +    TEST_EQ(gusMaxControl(0x220, 1, 1), 0x42);
    +    TEST_EQ(gusMaxControl(0x240, 5, 6), 0x74);
    +    TEST_EQ(gusMaxControl(0x260, 3, GUS_NONE), 0x46);
    +
    +    tCase("gus: output rates and frequency controls");
    +    TEST_EQ(gusOutputRate(14), 44100);
    +    TEST_EQ(gusOutputRate(28), 22050);
    +    TEST_EQ(gusOutputRate(32), 19293);
    +    TEST_EQ(gusOutputRate(2), 44100);
    +    TEST_EQ(gusOutputRate(40), 19293);
    +    TEST_EQ(gusFc(22050, 22050), 0x0400);
    +    TEST_EQ(gusFc(44100, 22050), 0x0800);
    +    TEST_EQ(gusFc(11025, 22050), 0x0200);
    +
    +    tCase("gus: addresses");
    +    TEST_EQ(gusAddrHigh(0x12345), 0x0246);
    +    TEST_EQ(gusAddrLow(0x12345), 0x45 << 9);
    +    TEST_EQ(gusAddr16(0x00002), 0x00001);
    +    TEST_EQ(gusAddr16(0x40000), 0x40000);
    +    TEST_EQ(gusAddr16(0x7FFFE), 0x5FFFF);
    +
    +    tCase("gus: volumes");
    +    TEST_EQ(gusVolume(0), 0);
    +    TEST_EQ(gusVolume(65535), GUS_VOLUME_MAX);
    +    TEST_EQ(gusVolume(32768), 0x0F00);
    +    TEST_EQ(gusVolume(1), 0x0000);
    +    TEST_EQ(gusVolume(3), 0x0180);
    +}
    +
    +
     // FNV-1a over the rendered bytes.
     static uint64_t hashOut(uint32_t frames, uint32_t frameBytes) {
         const uint8_t *p = (const uint8_t *)sOut;
    @@ -1552,6 +2047,106 @@ static uint32_t makeWav(uint8_t *buf, uint32_t tag, uint32_t channels, uint32_t
     }
     
     
    +// Bytes for the fake port's input (from the start of the queue).
    +static void midiIn(const uint8_t *bytes, int32_t count) {
    +    sMidiInHead = 0;
    +    sMidiInTail = 0;
    +
    +    for (int32_t i = 0; i < count && i < MIDI_BYTES_MAX; i++) {
    +        sMidiIn[sMidiInHead++] = bytes[i];
    +    }
    +}
    +
    +
    +// MIDI input through the fake port (midiIn = mpu401): one program reads
    +// it; the timer takes the bytes with their times (and keeps the clock
    +// they are on); messages come whole, running status filled in, clock
    +// bytes dropped, system exclusive skipped; closing the context closes
    +// it.
    +static void midiInTests(void) {
    +    static const uint8_t played[] = {
    +        0x90, 0x3C, 0x64,           // note on, middle C
    +        0xF8,                       // clock: dropped
    +        0x40, 0x50,                 // running status: another note on
    +        0xF0, 0x7E, 0x01, 0xF7,     // system exclusive: skipped
    +        0xC2, 0x05,                 // program change, one data byte
    +        0x45                        // a stray data byte (running status C2h: another program change)
    +    };
    +    AudioMidiInfoT info;
    +    AudioMidiMsgT  msg;
    +
    +    tCase("midi in: none unless DVX.INI asks for it");
    +    setIni("driver = gamma\nmidi = mpu401\n");
    +
    +    AudioCtxT *a = audioOpen();
    +
    +    audioMidiGetInfo(&info);
    +    TEST_TRUE(info.present && !info.inPresent);
    +    TEST_EQ(audioMidiInOpen(a), AUDIO_ERR_NO_SYNTH);
    +    TEST_TRUE(!audioMidiInRead(a, &msg));
    +    audioClose(a);
    +
    +    tCase("midi in: one program reads it; whole messages with their times");
    +    setIni("driver = gamma\nmidiIn = mpu401\n");
    +    a = audioOpen();
    +
    +    AudioCtxT *b = audioOpen();
    +
    +    audioMidiGetInfo(&info);
    +    TEST_TRUE(info.inPresent && !info.inOwned);
    +    TEST_EQ(audioMidiInOpen(a), AUDIO_OK);
    +    TEST_EQ(audioMidiInOpen(a), AUDIO_OK);
    +    TEST_EQ(audioMidiInOpen(b), AUDIO_ERR_BUSY);
    +    audioMidiGetInfo(&info);
    +    TEST_TRUE(info.inOwned);
    +
    +    midiIn(played, (int32_t)sizeof(played));
    +    hostClockAdvance(MIDI_IN_WAIT_MS);
    +    TEST_TRUE(audioMidiInRead(a, &msg));
    +    TEST_EQ(msg.status, 0x90);
    +    TEST_EQ(msg.data1, 0x3C);
    +    TEST_EQ(msg.data2, 0x64);
    +    TEST_EQ(msg.length, 3);
    +    TEST_TRUE(msg.ms >= 1 && msg.ms <= MIDI_IN_WAIT_MS);
    +    TEST_TRUE(audioMidiInRead(a, &msg));
    +    TEST_EQ(msg.status, 0x90);
    +    TEST_EQ(msg.data1, 0x40);
    +    TEST_EQ(msg.data2, 0x50);
    +    TEST_TRUE(audioMidiInRead(a, &msg));
    +    TEST_EQ(msg.status, 0xC2);
    +    TEST_EQ(msg.data1, 0x05);
    +    TEST_EQ(msg.length, 2);
    +    TEST_TRUE(audioMidiInRead(a, &msg));
    +    TEST_EQ(msg.status, 0xC2);
    +    TEST_EQ(msg.data1, 0x45);
    +    TEST_TRUE(!audioMidiInRead(a, &msg));
    +    TEST_TRUE(!audioMidiInRead(b, &msg));
    +    TEST_EQ(audioMidiInLost(a), 0);
    +
    +    // The clock the times are on runs on; another program has none
    +    uint32_t clock = audioMidiInClock(a);
    +
    +    TEST_TRUE(clock >= msg.ms && clock <= MIDI_IN_WAIT_MS);
    +    hostClockAdvance(MIDI_IN_WAIT_MS);
    +    TEST_TRUE(audioMidiInClock(a) > clock);
    +    TEST_EQ(audioMidiInClock(b), 0);
    +    TEST_EQ(audioMidiInClock(NULL), 0);
    +
    +    tCase("midi in: closing the reader lets another read");
    +    audioClose(a);
    +    audioMidiGetInfo(&info);
    +    TEST_TRUE(!info.inOwned);
    +    TEST_EQ(audioMidiInOpen(b), AUDIO_OK);
    +    audioMidiInClose(b);
    +    TEST_EQ(audioMidiInOpen(b), AUDIO_OK);
    +    audioSuspend(true);
    +    audioSuspend(false);
    +    audioMidiGetInfo(&info);
    +    TEST_TRUE(!info.inOwned);
    +    audioClose(b);
    +}
    +
    +
     static void midiTests(void) {
         AudioMidiInfoT info;
     
    @@ -1777,6 +2372,46 @@ static void mixerTests(void) {
             audioStreamClose(stream);
         }
     
    +    tCase("mixer: a paused voice holds its place, a flushed stream drops its frames");
    +    {
    +        AudioStreamT *stream = NULL;
    +
    +        v = audioPlay(s, FULL, AUDIO_PAN_CENTER, false);
    +        render(PAUSE_FRAMES);
    +        TEST_EQ(audioVoicePause(ctx, v, true), AUDIO_OK);
    +        render(PAUSE_FRAMES);
    +        TEST_EQ(outPeak(PAUSE_FRAMES), 0);
    +        TEST_TRUE(audioVoiceIsPlaying(ctx, v));
    +        TEST_EQ(audioVoicePause(ctx, v, false), AUDIO_OK);
    +        render(1);
    +        TEST_EQ(sOut[0], ramp[PAUSE_FRAMES]);
    +        audioVoiceStop(ctx, v);
    +        TEST_EQ(audioVoicePause(ctx, v, true), AUDIO_ERR_VOICE);
    +        v = audioTone(ctx, AUDIO_WAVE_SQUARE, TONE_HZ, FULL, 0);
    +        TEST_EQ(audioVoicePause(ctx, v, true), AUDIO_ERR_ARG);
    +        audioVoiceStop(ctx, v);
    +
    +        TEST_EQ(audioStreamOpen(ctx, &mono16, 0, NULL, NULL, &stream), AUDIO_OK);
    +        audioStreamWrite(stream, ramp, PCM_FRAMES);
    +        render(PAUSE_FRAMES);
    +        audioVoicePause(ctx, audioStreamVoice(stream), true);
    +
    +        uint32_t queued = audioStreamQueued(stream);
    +
    +        render(PAUSE_FRAMES);
    +        TEST_EQ(outPeak(PAUSE_FRAMES), 0);
    +        TEST_EQ(audioStreamQueued(stream), queued);
    +        audioStreamFlush(stream);
    +        audioStreamFlush(NULL);
    +        TEST_EQ(audioStreamQueued(stream), 0);
    +        audioVoicePause(ctx, audioStreamVoice(stream), false);
    +        render(MIX_HELD_FRAMES);
    +        render(PAUSE_FRAMES);
    +        TEST_EQ(outPeak(PAUSE_FRAMES), 0);
    +        audioStreamClose(stream);
    +        audioPump();
    +    }
    +
         tCase("mixer: several programs, streams, voices and tones at once");
         {
             AudioCtxT    *other  = NULL;
    @@ -2047,6 +2682,173 @@ static void put32(uint8_t *p, uint32_t v) {
     }
     
     
    +// Recording with the gamma fake (it records from the microphone and the
    +// line; the test pushes the blocks the card's interrupt would): the card
    +// changes hands and comes back, the frames arrive in order with their
    +// level, a full buffer drops whole blocks, a file is a WAV that reads
    +// back, and a closing program and a DOS program end the recording.
    +static void recordTests(void) {
    +    AudioFormatT fmt;
    +    AudioInfoT   info;
    +    AudioRecT   *rec    = NULL;
    +    AudioRecT   *second = NULL;
    +    int16_t      block[REC_BLOCK_FRAMES];
    +    int16_t      back[REC_BLOCK_FRAMES];
    +    uint8_t      block8[REC_BLOCK_FRAMES * 2];
    +
    +    for (int32_t i = 0; i < REC_BLOCK_FRAMES; i++) {
    +        block[i]          = (int16_t)((i & 1) ? REC_LEVEL : -REC_LEVEL);
    +        block8[i * 2]     = REC_U8_LOW;
    +        block8[i * 2 + 1] = REC_U8_HIGH;
    +    }
    +
    +    tCase("recording: what the device records from");
    +    setIni("driver = gamma\n");
    +
    +    AudioCtxT *ctx = audioOpen();
    +
    +    audioGetInfo(&info);
    +    TEST_TRUE(info.canRecord);
    +    TEST_EQ(info.recSources, (1U << AUDIO_REC_MIC) | (1U << AUDIO_REC_LINE));
    +
    +    tCase("recording: bad arguments, a source the card lacks");
    +    memset(&fmt, 0, sizeof(fmt));
    +    TEST_EQ(audioRecOpen(ctx, &fmt, -1, 0, &rec), AUDIO_ERR_ARG);
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_SOURCES, 0, &rec), AUDIO_ERR_ARG);
    +    fmt.bits = REC_BAD_BITS;
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_MIC, 0, &rec), AUDIO_ERR_ARG);
    +    fmt.bits = 0;
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_CD, 0, &rec), AUDIO_ERR_NO_RECORD);
    +    TEST_TRUE(rec == NULL);
    +
    +    tCase("recording: the card changes hands, the format, voices refused");
    +    int32_t tone = audioTone(ctx, AUDIO_WAVE_SQUARE, TONE_HZ, FULL, 0);
    +
    +    TEST_TRUE(tone > 0);
    +    eventsClear();
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_LINE, 0, &rec), AUDIO_OK);
    +    TEST_TRUE(rec != NULL);
    +    TEST_TRUE(eventSeen("gamma close"));
    +    TEST_TRUE(eventSeen("gamma record 1"));
    +    TEST_EQ(fmt.rate, AUDIO_RATE_DEFAULT + REC_RATE_OFF);
    +    TEST_EQ(fmt.bits, AUDIO_REC_BITS_DEFAULT);
    +    TEST_EQ(fmt.channels, 1);
    +    TEST_EQ(audioTone(ctx, AUDIO_WAVE_SQUARE, TONE_HZ, FULL, TONE_MS), AUDIO_ERR_BUSY);
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_MIC, 0, &second), AUDIO_ERR_BUSY);
    +
    +    tCase("recording: frames arrive in order with their level");
    +    audioRecPush(block, sizeof(block));
    +    TEST_EQ(audioRecQueued(rec), REC_BLOCK_FRAMES);
    +    TEST_EQ(audioRecRead(rec, back, REC_BLOCK_FRAMES), REC_BLOCK_FRAMES);
    +    TEST_TRUE(memcmp(back, block, sizeof(block)) == 0);
    +    TEST_EQ(audioRecFrames(rec), REC_BLOCK_FRAMES);
    +    TEST_EQ(audioRecPeak(rec), REC_LEVEL * PERCENT_FULL / INT16_MAX);
    +    TEST_EQ(audioRecPeak(rec), 0);
    +    TEST_EQ(audioRecRead(rec, back, REC_BLOCK_FRAMES), 0);
    +
    +    tCase("recording: a full buffer drops whole blocks");
    +    for (int32_t i = 0; i < REC_FILL_BLOCKS; i++) {
    +        audioRecPush(block, sizeof(block));
    +    }
    +
    +    TEST_TRUE(audioRecLost(rec) > 0);
    +    TEST_EQ(audioRecQueued(rec) + audioRecLost(rec), REC_FILL_BLOCKS * REC_BLOCK_FRAMES);
    +    TEST_EQ(audioRecQueued(rec) % REC_BLOCK_FRAMES, 0);
    +
    +    while (audioRecRead(rec, back, REC_BLOCK_FRAMES) > 0) {
    +        TEST_TRUE(memcmp(back, block, sizeof(block)) == 0);
    +    }
    +
    +    tCase("recording: closing gives the card back, the held tone plays on");
    +    eventsClear();
    +    TEST_EQ(audioRecClose(rec), AUDIO_OK);
    +    TEST_TRUE(eventSeen("gamma recordstop"));
    +    TEST_TRUE(eventSeen("gamma open"));
    +    TEST_TRUE(eventSeen("gamma play"));
    +    TEST_TRUE(audioVoiceIsPlaying(ctx, tone));
    +    audioVoiceStop(ctx, tone);
    +    TEST_TRUE(audioTone(ctx, AUDIO_WAVE_SQUARE, TONE_HZ, FULL, TONE_MS) > 0);
    +    TEST_EQ(audioRecClose(NULL), AUDIO_OK);
    +
    +    tCase("recording into a WAV file that reads back");
    +    AudioFormatT stereo8 = { REC_RATE, 8, 2 };
    +
    +    TEST_EQ(audioRecFile(ctx, REC_FILE, &stereo8, AUDIO_REC_MIC, &rec), AUDIO_OK);
    +    TEST_EQ(stereo8.rate, REC_RATE + REC_RATE_OFF);
    +    audioRecPush(block8, sizeof(block8));
    +    audioRecPush(block8, sizeof(block8));
    +    audioPump();
    +    TEST_EQ(audioRecFrames(rec), 2 * REC_BLOCK_FRAMES);
    +    TEST_EQ(audioRecRead(rec, back, REC_BLOCK_FRAMES), 0);
    +    audioRecPush(block8, sizeof(block8));
    +    TEST_EQ(audioRecClose(rec), AUDIO_OK);
    +
    +    int32_t  size   = 0;
    +    uint8_t *data   = (uint8_t *)platformReadFile(REC_FILE, &size);
    +    int16_t *pcm    = NULL;
    +    uint32_t frames = 0;
    +    AudioFormatT got;
    +
    +    TEST_EQ(size, AUDIO_WAV_HEADER_BYTES + REC_FILE_BLOCKS * (int32_t)sizeof(block8));
    +    TEST_EQ(audioDecode(data, (uint32_t)size, &pcm, &frames, &got), AUDIO_OK);
    +    TEST_EQ(frames, REC_FILE_BLOCKS * REC_BLOCK_FRAMES);
    +    TEST_EQ(got.rate, REC_RATE + REC_RATE_OFF);
    +    TEST_EQ(got.bits, 8);
    +    TEST_EQ(got.channels, 2);
    +    TEST_TRUE(pcm && pcm[0] < 0 && pcm[1] > 0);
    +    free(pcm);
    +    free(data);
    +
    +    tCase("recording into a file that cannot be made; a refused one leaves a file alone");
    +    TEST_EQ(audioRecFile(ctx, REC_BAD_FILE, &fmt, AUDIO_REC_MIC, &rec), AUDIO_ERR_WRITE);
    +    TEST_EQ(audioRecFile(ctx, REC_FILE, &fmt, AUDIO_REC_CD, &rec), AUDIO_ERR_NO_RECORD);
    +    data = (uint8_t *)platformReadFile(REC_FILE, &size);
    +    TEST_EQ(size, AUDIO_WAV_HEADER_BYTES + REC_FILE_BLOCKS * (int32_t)sizeof(block8));
    +    free(data);
    +
    +    tCase("recording: a DOS program and the program's end close it");
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_MIC, 0, &rec), AUDIO_OK);
    +    eventsClear();
    +    audioSuspend(true);
    +    TEST_TRUE(eventSeen("gamma recordstop"));
    +    audioSuspend(false);
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_MIC, 0, &rec), AUDIO_OK);
    +    eventsClear();
    +    audioClose(ctx);
    +    TEST_TRUE(eventSeen("gamma recordstop"));
    +
    +    tCase("recording: a card that cannot record");
    +    setIni("driver = mono8\n");
    +    ctx = audioOpen();
    +    audioGetInfo(&info);
    +    TEST_TRUE(!info.canRecord);
    +    TEST_EQ(info.recSources, 0);
    +    TEST_EQ(audioRecOpen(ctx, &fmt, AUDIO_REC_MIC, 0, &rec), AUDIO_ERR_NO_RECORD);
    +    audioClose(ctx);
    +
    +    tCase("audioWavHeader: a header audioWavParse reads");
    +    uint8_t      head[AUDIO_WAV_HEADER_BYTES];
    +    AudioFormatT hf = { REC_HEAD_RATE, 16, 2 };
    +    AudioFormatT pf;
    +    uint32_t     at    = 0;
    +    uint32_t     bytes = 0;
    +
    +    audioWavHeader(head, &hf, REC_HEAD_DATA);
    +    TEST_TRUE(memcmp(head, "RIFF", WAV_FOURCC_LEN) == 0 && memcmp(head + WAV_HEAD_WAVE, "WAVEfmt ", WAV_HEAD_WAVE_LEN) == 0 && memcmp(head + WAV_HEAD_DATA_TAG, "data", WAV_FOURCC_LEN) == 0);
    +    TEST_EQ(head[WAV_HEAD_RIFF_SIZE] | (head[WAV_HEAD_RIFF_SIZE + 1] << 8), REC_HEAD_DATA + WAV_HEAD_RIFF_EXTRA);
    +    TEST_EQ(head[WAV_HEAD_ALIGN], hf.bits / 8 * hf.channels);
    +    TEST_EQ(head[WAV_HEAD_BITS], hf.bits);
    +    TEST_EQ(head[WAV_HEAD_DATA_SIZE] | (head[WAV_HEAD_DATA_SIZE + 1] << 8), REC_HEAD_DATA);
    +    TEST_EQ(audioWavParse(head, sizeof(head), sizeof(head) + REC_HEAD_DATA, &pf, &at, &bytes), AUDIO_OK);
    +    TEST_EQ(pf.rate, REC_HEAD_RATE);
    +    TEST_EQ(pf.channels, 2);
    +    TEST_EQ(at, AUDIO_WAV_HEADER_BYTES);
    +    TEST_EQ(bytes, REC_HEAD_DATA);
    +    TEST_STR_EQ(audioErrorText(AUDIO_ERR_NO_RECORD), "The sound device cannot record from that input");
    +    TEST_STR_EQ(audioErrorText(AUDIO_ERR_WRITE), "The file could not be written");
    +}
    +
    +
     static void render(uint32_t frames) {
         memset(sOut, 0, sizeof(sOut));
         audioMixRender(sOut, frames);
    @@ -2089,6 +2891,10 @@ static void sbTests(void) {
         TEST_EQ(bl.dma8, 1);
         TEST_EQ(bl.dma16, 5);
         TEST_EQ(bl.type, 6);
    +    TEST_EQ(bl.emu, SB_NONE);
    +    TEST_TRUE(sbParseBlaster("A220 I5 D1 H5 P330 E620 T6", &bl));
    +    TEST_EQ(bl.emu, 0x620);
    +    TEST_EQ(bl.type, 6);
         TEST_TRUE(sbParseBlaster("a240  i7 d3", &bl));
         TEST_EQ(bl.base, 0x240);
         TEST_EQ(bl.dma16, SB_NONE);
    @@ -2134,13 +2940,41 @@ static void sbTests(void) {
         TEST_STR_EQ(sbModelName(0x0201), "Sound Blaster 2.0");
         TEST_STR_EQ(sbModelName(0x0105), "Sound Blaster");
     
    -    tCase("sb: DMA buffer placement");
    -    TEST_EQ(sbDmaPlace(0x20000, 8192, 4096, SB_DMA_BOUNDARY_8), 0);
    -    TEST_EQ(sbDmaPlace(0x2F800, 8192, 4096, SB_DMA_BOUNDARY_8), 0x800);
    -    TEST_EQ(sbDmaPlace(0x2F000, 8192, 4096, SB_DMA_BOUNDARY_8), 0);
    -    TEST_EQ(sbDmaPlace(0x2F001, 4096, 4096, SB_DMA_BOUNDARY_8), SB_NONE);
    -    TEST_EQ(sbDmaPlace(0x3F800, 8192, 4096, SB_DMA_BOUNDARY_16), 0x800);
    -    TEST_EQ(sbDmaPlace(0x2F800, 8192, 4096, SB_DMA_BOUNDARY_16), 0);
    +    tCase("sb: recording plans by DSP version");
    +    sbRecordPlan(0x0405, RATE_DEV, 16, 2, true, &plan);
    +    TEST_EQ(plan.mode, SB_MODE_SB16);
    +    TEST_EQ(plan.rate, RATE_DEV);
    +    TEST_EQ(plan.bits, 16);
    +    TEST_EQ(plan.channels, 2);
    +    sbRecordPlan(0x0302, RATE_DEV, 16, 2, false, &plan);
    +    TEST_EQ(plan.mode, SB_MODE_HIGH);
    +    TEST_EQ(plan.bits, 8);
    +    TEST_EQ(plan.channels, 2);
    +    sbRecordPlan(0x0201, RATE_DEV, 16, 2, false, &plan);
    +    TEST_EQ(plan.mode, SB_MODE_HIGH);
    +    TEST_EQ(plan.bits, 8);
    +    TEST_EQ(plan.channels, 1);
    +    TEST_EQ(plan.timeConstant, 189);
    +    TEST_EQ(plan.rate, 14925);
    +    sbRecordPlan(0x0201, 8000, 8, 1, false, &plan);
    +    TEST_EQ(plan.mode, SB_MODE_AUTO);
    +    TEST_EQ(plan.rate, 8000);
    +    sbRecordPlan(0x0200, RATE_DEV, 8, 1, false, &plan);
    +    TEST_EQ(plan.mode, SB_MODE_AUTO);
    +    TEST_EQ(plan.rate, 12987);
    +    sbRecordPlan(0x0105, RATE_HALF, 8, 1, false, &plan);
    +    TEST_EQ(plan.mode, SB_MODE_SINGLE);
    +    TEST_EQ(plan.rate, 10989);
    +
    +    tCase("isa: DMA buffer placement");
    +    TEST_EQ(isaDmaPlace(0x20000, 8192, 4096, ISA_DMA_BOUNDARY_8), 0);
    +    TEST_EQ(isaDmaPlace(0x2F800, 8192, 4096, ISA_DMA_BOUNDARY_8), 0x800);
    +    TEST_EQ(isaDmaPlace(0x2F000, 8192, 4096, ISA_DMA_BOUNDARY_8), 0);
    +    TEST_EQ(isaDmaPlace(0x2F001, 4096, 4096, ISA_DMA_BOUNDARY_8), ISA_NONE);
    +    TEST_EQ(isaDmaPlace(0x3F800, 8192, 4096, ISA_DMA_BOUNDARY_16), 0x800);
    +    TEST_EQ(isaDmaPlace(0x2F800, 8192, 4096, ISA_DMA_BOUNDARY_16), 0);
    +    TEST_EQ(isaDmaBoundary(1), ISA_DMA_BOUNDARY_8);
    +    TEST_EQ(isaDmaBoundary(5), ISA_DMA_BOUNDARY_16);
     }
     
     
    @@ -2220,18 +3054,24 @@ static void settingsBasicTests(void) {
         TEST_EQ(SoundMidiCount(), AUDIO_MIDI_CHOICE_COUNT);
         TEST_STR_EQ(SoundMidiLabel(0), "Automatic");
         TEST_STR_EQ(SoundMidiLabel(AUDIO_MIDI_CHOICE_COUNT), "");
    +    TEST_EQ(SoundModulesCount(), AUDIO_MODULES_CHOICE_COUNT);
    +    TEST_STR_EQ(SoundModulesLabel(MODS_SOFT_INDEX), "Mixed in software");
    +    TEST_STR_EQ(SoundModulesLabel(-1), "");
     
         tCase("basic: [audio] settings through a settings handle");
         TEST_STR_EQ(SoundSettingDriver(h), "gamma");
         TEST_EQ(SoundSettingMidi(h), MIDI_FM_INDEX);
    +    TEST_EQ(SoundSettingModules(h), 0);
         TEST_EQ(SoundSettingVolume(h), PERCENT_INI);
    -    SoundSettingsStore(h, "beta", MIDI_MPU_INDEX, PERCENT_STORED);
    +    SoundSettingsStore(h, "beta", MIDI_MPU_INDEX, MODS_SOFT_INDEX, PERCENT_STORED);
         TEST_STR_EQ(SoundSettingDriver(h), "beta");
         TEST_EQ(SoundSettingMidi(h), MIDI_MPU_INDEX);
    +    TEST_EQ(SoundSettingModules(h), MODS_SOFT_INDEX);
         TEST_EQ(SoundSettingVolume(h), PERCENT_STORED);
    -    SoundSettingsStore(BAS_BAD_HANDLE, "beta", 0, 0);
    +    SoundSettingsStore(BAS_BAD_HANDLE, "beta", 0, 0, 0);
         TEST_STR_EQ(SoundSettingDriver(BAS_BAD_HANDLE), AUDIO_DRIVER_AUTO);
         TEST_EQ(SoundSettingMidi(BAS_BAD_HANDLE), 0);
    +    TEST_EQ(SoundSettingModules(BAS_BAD_HANDLE), 0);
         TEST_EQ(SoundSettingVolume(BAS_BAD_HANDLE), PERCENT_FULL);
         PrefsClose(h);
     }
    @@ -2257,13 +3097,16 @@ static void settingsTests(void) {
         audioDeskSetFile(prefs, SoundCountE, "x.wav");
         TEST_STR_EQ(audioDeskFile(prefs, SoundWarningE), "w.wav");
         TEST_STR_EQ(prefsGetString(prefs, AUDIO_SOUNDS_SECTION, "question", "?"), "");
    -    audioSettingsStore(prefs, "", AUDIO_MIDI_CHOICE_COUNT, -1);
    +    audioSettingsStore(prefs, "", AUDIO_MIDI_CHOICE_COUNT, AUDIO_MODULES_CHOICE_COUNT, -1);
         TEST_STR_EQ(audioSettingDriver(prefs), AUDIO_DRIVER_AUTO);
         TEST_STR_EQ(prefsGetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, ""), AUDIO_MIDI_AUTO);
    +    TEST_STR_EQ(prefsGetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MODULES, ""), AUDIO_MODULES_WAVETABLE);
         TEST_EQ(audioSettingVolume(prefs), 0);
    -    audioSettingsStore(prefs, "delta", MIDI_MPU_INDEX, PERCENT_HALF);
    +    audioSettingsStore(prefs, "delta", MIDI_MPU_INDEX, MODS_SOFT_INDEX, PERCENT_HALF);
         TEST_STR_EQ(prefsGetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_DRIVER, ""), "delta");
         TEST_STR_EQ(prefsGetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, ""), AUDIO_MIDI_MPU401);
    +    TEST_STR_EQ(prefsGetString(prefs, AUDIO_INI_SECTION, AUDIO_INI_MODULES, ""), AUDIO_MODULES_SOFTWARE);
    +    TEST_EQ(audioSettingModules(prefs), MODS_SOFT_INDEX);
         TEST_EQ(prefsGetInt(prefs, AUDIO_INI_SECTION, AUDIO_INI_VOLUME, 0), PERCENT_HALF);
         prefsClose(prefs);
     
    @@ -2287,6 +3130,59 @@ static void settingsTests(void) {
     }
     
     
    +// DVX steps aside for a DOS program (audioSuspend, the library's
    +// platform suspend hook): the sound stops, the output and the
    +// synthesizers close; back, the synthesizers open again, and the next
    +// sound opens the output, as after a silence.  With no device open it
    +// does nothing.
    +static void suspendTests(void) {
    +    AudioSampleT *s = NULL;
    +    int16_t       pcm[PCM_FRAMES];
    +    AudioFormatT  mono16 = { RATE_DEV, 16, 1 };
    +
    +    for (int32_t i = 0; i < PCM_FRAMES; i++) {
    +        pcm[i] = FULL;
    +    }
    +
    +    tCase("suspend: the sound stops, the output and the synthesizers close and come back");
    +    setIni("driver = gamma\n");
    +
    +    AudioCtxT *ctx = audioOpen();
    +
    +    audioSampleCreate(ctx, pcm, PCM_FRAMES, &mono16, &s);
    +
    +    int32_t v = audioPlay(s, FULL, 0, true);
    +
    +    TEST_TRUE(v > 0);
    +    eventsClear();
    +    sSynthOpens  = 0;
    +    sSynthCloses = 0;
    +    audioSuspend(true);
    +    TEST_TRUE(!audioVoiceIsPlaying(ctx, v));
    +    TEST_TRUE(eventSeen("gamma close"));
    +    TEST_EQ(sSynthCloses, 1);
    +    TEST_EQ(sSynthOpens, 0);
    +    audioSuspend(false);
    +    TEST_EQ(sSynthOpens, 1);
    +    TEST_TRUE(!eventSeen("gamma open"));
    +    TEST_TRUE(audioPlay(s, FULL, 0, false) > 0);
    +    TEST_TRUE(eventSeen("gamma open"));
    +    TEST_TRUE(eventSeen("gamma play"));
    +    render(PCM_FRAMES);
    +    TEST_TRUE(outPeak(PCM_FRAMES) > 0);
    +    audioSampleFree(s);
    +    audioClose(ctx);
    +
    +    tCase("suspend: nothing open, nothing done");
    +    eventsClear();
    +    sSynthCloses = 0;
    +    audioSuspend(true);
    +    audioSuspend(false);
    +    TEST_EQ(sEventCount, 0);
    +    TEST_EQ(sSynthCloses, 0);
    +}
    +
    +
     static void toneTests(void) {
         AudioCtxT *ctx;
     
    @@ -2340,6 +3236,7 @@ static void toneTests(void) {
     }
     
     
    +
     static void voiceTests(void) {
         AudioCtxT    *ctx;
         AudioCtxT    *other;
    @@ -2422,6 +3319,149 @@ static void voiceTests(void) {
     }
     
     
    +// The Windows Sound System board's configuration register (wssCalc.c).
    +static void wssTests(void) {
    +    uint8_t value = 0;
    +
    +    tCase("wss: the configuration register");
    +    TEST_TRUE(wssConfig(7, 1, &value));
    +    TEST_EQ(value, 0x0A);
    +    TEST_TRUE(wssConfig(7, 3, &value));
    +    TEST_EQ(value, 0x0B);
    +    TEST_TRUE(wssConfig(10, 1, &value));
    +    TEST_EQ(value, 0x1A);
    +    TEST_TRUE(wssConfig(11, 0, &value));
    +    TEST_EQ(value, 0x21);
    +    TEST_TRUE(!wssConfig(5, 1, &value));
    +    TEST_TRUE(!wssConfig(7, 2, &value));
    +}
    +
    +
    +// The wavetable synthesizer: voices shared like FM ones, the calls passed
    +// to the driver with its own voice numbers, sample memory first fit and
    +// aligned (16-bit samples kept inside 256 KB where the chip asks), uploads
    +// only into a block of the context's own, everything given back when the
    +// context closes.
    +static void wtTests(void) {
    +    AudioWtInfoT  info;
    +    AudioWtVoiceT note;
    +    int32_t       ids[5];
    +    uint32_t      addr  = 0;
    +    uint32_t      addr2 = 0;
    +    int16_t       ramp[4] = { 0x0102, 0x0304, -2, 0x7F00 };
    +
    +    tCase("wavetable: none unless the driver finds one");
    +    setIni("driver = gamma\n");
    +
    +    AudioCtxT *a = audioOpen();
    +
    +    audioWtGetInfo(&info);
    +    TEST_TRUE(!info.present);
    +    TEST_EQ(audioWtAlloc(a), AUDIO_ERR_NO_SYNTH);
    +    TEST_EQ(audioWtMemAlloc(a, 64, 16, &addr), AUDIO_ERR_NO_SYNTH);
    +    audioClose(a);
    +
    +    tCase("wavetable: shared voices, least recently freed first");
    +    setIni("driver = gamma\nfakeWtVoices = 4\n");
    +    a = audioOpen();
    +
    +    AudioCtxT *b = audioOpen();
    +
    +    audioWtGetInfo(&info);
    +    TEST_TRUE(info.present);
    +    TEST_STR_EQ(info.description, "Fake wavetable (4 voices)");
    +    TEST_EQ(info.voices, 4);
    +    TEST_EQ(info.memory, WT_FAKE_MEMORY);
    +    TEST_EQ(info.freeMemory, WT_FAKE_MEMORY);
    +    TEST_EQ(info.flags, AUDIO_WT_ENVELOPES | AUDIO_WT_LFOS | AUDIO_WT_FILTER | AUDIO_WT_EFFECTS);
    +
    +    for (int32_t i = 0; i < 4; i++) {
    +        ids[i] = audioWtAlloc(i < 2 ? a : b);
    +        TEST_TRUE(ids[i] > 0);
    +    }
    +
    +    ids[4] = audioWtAlloc(a);
    +    TEST_EQ(ids[4], AUDIO_ERR_NO_VOICE);
    +    audioWtGetInfo(&info);
    +    TEST_EQ(info.freeVoices, 0);
    +
    +    memset(¬e, 0, sizeof(note));
    +    note.pitch       = 1200;
    +    note.attenuation = 100;
    +    note.bits        = 16;
    +    sWtStarts        = 0;
    +    TEST_EQ(audioWtStart(a, ids[1], ¬e), AUDIO_OK);
    +    TEST_EQ(sWtStarts, 1);
    +    TEST_EQ(sWtVoice[1].pitch, 1200);
    +    TEST_TRUE(audioWtActive(a, ids[1]));
    +    TEST_EQ(audioWtStart(b, ids[1], ¬e), AUDIO_ERR_VOICE);
    +    TEST_TRUE(!audioWtActive(b, ids[1]));
    +    note.pitch = 1300;
    +    TEST_EQ(audioWtUpdate(a, ids[1], ¬e, AUDIO_WT_SET_PITCH), AUDIO_OK);
    +    TEST_EQ(sWtVoice[1].pitch, 1300);
    +    TEST_EQ(sWtWhat, AUDIO_WT_SET_PITCH);
    +    sWtReleased = 0;
    +    TEST_EQ(audioWtRelease(a, ids[1]), AUDIO_OK);
    +    TEST_EQ(sWtReleased, 1u << 1);
    +    sWtStopped = 0;
    +    TEST_EQ(audioWtStop(a, ids[1]), AUDIO_OK);
    +    TEST_EQ(sWtStopped, 1u << 1);
    +    TEST_TRUE(!audioWtActive(a, ids[1]));
    +
    +    // Voice 0 freed first, then 2: 0 comes back first, with a new id
    +    TEST_EQ(audioWtFree(a, ids[0]), AUDIO_OK);
    +    TEST_EQ(audioWtFree(b, ids[2]), AUDIO_OK);
    +    TEST_EQ(audioWtFree(b, ids[2]), AUDIO_ERR_VOICE);
    +
    +    int32_t again = audioWtAlloc(b);
    +
    +    TEST_EQ((again - 1) % 64, 0);
    +    TEST_TRUE(again != ids[0]);
    +    TEST_EQ(audioWtStart(a, ids[0], ¬e), AUDIO_ERR_VOICE);
    +
    +    tCase("wavetable: sample memory, first fit and aligned");
    +    TEST_EQ(audioWtMemAlloc(a, 1000, 16, &addr), AUDIO_OK);
    +    TEST_EQ(addr, 0);
    +    TEST_EQ(audioWtMemAlloc(b, 1000, 16, &addr2), AUDIO_OK);
    +    TEST_EQ(addr2, 1024);
    +    TEST_EQ(audioWtMemAlloc(a, 64, 8, &addr2), AUDIO_ERR_ARG);
    +    TEST_EQ(audioWtMemAlloc(a, WT_FAKE_MEMORY, 16, &addr2), AUDIO_ERR_NO_ROOM);
    +    TEST_EQ(audioWtUpload(a, addr, ramp, 4, 16), AUDIO_OK);
    +    TEST_EQ(sWtMemory[0], 0x02);
    +    TEST_EQ(sWtMemory[1], 0x01);
    +    TEST_EQ(sWtMemory[7], 0x7F);
    +    TEST_EQ(audioWtUpload(b, addr, ramp, 4, 16), AUDIO_ERR_ARG);
    +    TEST_EQ(audioWtUpload(a, addr + 998, ramp, 2, 16), AUDIO_ERR_ARG);
    +    TEST_EQ(audioWtMemFree(b, addr), AUDIO_ERR_ARG);
    +    TEST_EQ(audioWtMemFree(a, addr), AUDIO_OK);
    +    TEST_EQ(audioWtMemAlloc(a, 500, 16, &addr), AUDIO_OK);
    +    TEST_EQ(addr, 0);
    +
    +    tCase("wavetable: closing a context gives its voices and memory back");
    +    audioClose(a);
    +    audioWtGetInfo(&info);
    +    TEST_EQ(info.freeVoices, 2);
    +    TEST_EQ(audioWtMemAlloc(b, 1024, 16, &addr), AUDIO_OK);
    +    TEST_EQ(addr, 0);
    +    audioClose(b);
    +
    +    tCase("wavetable: 8-bit samples, and 16-bit ones inside 256 KB");
    +    setIni("driver = gamma\nfakeWtVoices = 2\nfakeWtFlags = 48\n");
    +    a = audioOpen();
    +    TEST_EQ(audioWtMemAlloc(a, WT_BANK - 256, 8, &addr), AUDIO_OK);
    +    TEST_EQ(addr, 0);
    +    TEST_EQ(audioWtMemAlloc(a, 512, 16, &addr2), AUDIO_OK);
    +    TEST_EQ(addr2, WT_BANK);
    +    TEST_EQ(audioWtMemAlloc(a, 128, 8, &addr2), AUDIO_OK);
    +    TEST_EQ(addr2, WT_BANK - 256);
    +    TEST_EQ(audioWtMemAlloc(a, WT_BANK + 2, 16, &addr2), AUDIO_ERR_NO_ROOM);
    +    TEST_EQ(audioWtUpload(a, addr, ramp, 4, 8), AUDIO_OK);
    +    TEST_EQ(sWtMemory[0], 0x01);
    +    TEST_EQ(sWtMemory[2], 0xFF);
    +    audioClose(a);
    +}
    +
    +
     int main(void) {
         tSuiteBegin("audio");
         sUpdate = getenv("UPDATE_GOLDENS") && strcmp(getenv("UPDATE_GOLDENS"), "1") == 0;
    @@ -2440,14 +3480,23 @@ int main(void) {
         voiceTests();
         programTests();
         idleTests();
    +    suspendTests();
    +    recordTests();
         toneTests();
         fileTests();
         sbTests();
    +    codecTests();
    +    wssTests();
    +    gusTests();
    +    aweTests();
         basicTests();
         basicSampleTests();
    +    basicRecordTests();
         basicStreamTests();
         fmTests();
         midiTests();
    +    midiInTests();
    +    wtTests();
         basicSynthTests();
         basicLiveTests();
         notesTests();
    diff --git a/src/test/audio/fakeDrv.c b/src/test/audio/fakeDrv.c
    index 555b7a24..10207948 100644
    --- a/src/test/audio/fakeDrv.c
    +++ b/src/test/audio/fakeDrv.c
    @@ -35,11 +35,24 @@
     //   FAKE_CLOCK                 play through audioClockStart
     //   FAKE_BAD_ABI               descriptor built for another DVX version
     //   FAKE_NO_DESC               no descriptor at all
    +//   FAKE_REC_SOURCES           what it records from (bits 1 << AUDIO_REC_*;
    +//                              0, the default: it cannot record).  It
    +//                              records the format asked at FAKE_REC_RATE_OFF
    +//                              Hz more (a card's nearest rate); the test
    +//                              pushes the "recorded" blocks itself
    +//                              (audioRecPush), as the card's interrupt would.
     
     #include "audioDrv.h"
     
     #include 
     
    +#ifndef FAKE_REC_SOURCES
    +#define FAKE_REC_SOURCES  0
    +#endif
    +
    +#define FAKE_REC_RATE_OFF 5
    +#define FAKE_EVENT_MAX    32
    +
     // Test hooks (audioTest.c)
     void fakeDrvEvent(const char *driver, const char *event);
     void fakeDrvTone(uint32_t hz);
    @@ -55,16 +68,20 @@ const uint32_t        fakeNotADriver = 1;
     static void fakeClose(void);
     static bool fakeOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info);
     static bool fakePlay(void);
    +static bool fakeRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got);
    +static void fakeRecordStop(void);
     static void fakeTone(uint32_t hz);
     
     #ifdef FAKE_BAD_ABI
     extern const AudioDriverT audioDriver;
    -const AudioDriverT        audioDriver = { .hdr = { DVX_ABI_VERSION + 1, sizeof(AudioDriverT), FAKE_NAME, FAKE_PRIORITY }, .open = fakeOpen, .play = fakePlay, .close = fakeClose };
    +const AudioDriverT        audioDriver = { .hdr = { DVX_ABI_VERSION + 1, sizeof(AudioDriverT), FAKE_NAME, FAKE_PRIORITY }, .open = fakeOpen, .play = fakePlay, .close = fakeClose, .recordStart = FAKE_REC_SOURCES ? fakeRecordStart : NULL, .recordStop = FAKE_REC_SOURCES ? fakeRecordStop : NULL };
     #else
     AUDIO_DRIVER(FAKE_NAME, FAKE_PRIORITY,
    -    .open     = fakeOpen,
    -    .play     = fakePlay,
    -    .close    = fakeClose
    +    .open        = fakeOpen,
    +    .play        = fakePlay,
    +    .close       = fakeClose,
    +    .recordStart = FAKE_REC_SOURCES ? fakeRecordStart : NULL,
    +    .recordStop  = FAKE_REC_SOURCES ? fakeRecordStop : NULL
     );
     #endif
     
    @@ -87,7 +104,8 @@ static bool fakeOpen(const AudioDrvConfigT *cfg, AudioDrvInfoT *info) {
         info->bits     = FAKE_BITS;
         info->channels = FAKE_CHANNELS;
         info->hasDac   = FAKE_DAC;
    -    info->tone     = FAKE_TONE ? fakeTone : NULL;
    +    info->tone       = FAKE_TONE ? fakeTone : NULL;
    +    info->recSources = FAKE_REC_SOURCES;
         return FAKE_OPEN_OK;
     }
     
    @@ -103,6 +121,23 @@ static bool fakePlay(void) {
     }
     
     
    +// Reports "record "; the card's nearest rate is a little above.
    +static bool fakeRecordStart(const AudioFormatT *want, int32_t source, AudioFormatT *got) {
    +    char text[FAKE_EVENT_MAX];
    +
    +    snprintf(text, sizeof(text), "record %ld", (long)source);
    +    fakeDrvEvent(FAKE_NAME, text);
    +    *got       = *want;
    +    got->rate += FAKE_REC_RATE_OFF;
    +    return true;
    +}
    +
    +
    +static void fakeRecordStop(void) {
    +    fakeDrvEvent(FAKE_NAME, "recordstop");
    +}
    +
    +
     static void fakeTone(uint32_t hz) {
         fakeDrvTone(hz);
     }
    diff --git a/src/test/audio/fakeSynth.c b/src/test/audio/fakeSynth.c
    index 7eea3d46..7d7a3955 100644
    --- a/src/test/audio/fakeSynth.c
    +++ b/src/test/audio/fakeSynth.c
    @@ -23,12 +23,19 @@
     // fakeSynth.c  -- fake synthesizer drivers for the audio and music suites
     //
     // Built into .drv shared objects in a suite's work tree (FAKE_SYN_FM for
    -// the FM chip, otherwise the MIDI port), where audio.lib discovers them
    -// like real synth drivers.  Register writes go to fakeSynthWrite and
    -// bytes to fakeSynthPut in the test program.  The FM fake has DVX.INI
    -// [audio] fakeFmVoices voices (default 9; 0: no chip) and is stereo with
    -// fakeFmStereo = true; the MIDI fake, like the real one, is there only
    -// with [audio] midi = mpu401.
    +// the FM chip, FAKE_SYN_WT for a wavetable synthesizer, otherwise the
    +// MIDI port), where audio.lib discovers them like real synth drivers.
    +// Register writes go to fakeSynthWrite and bytes to fakeSynthPut in the
    +// test program, MIDI input comes from its fakeSynthGet, a wavetable's
    +// calls go to the fakeWt* functions.  The FM
    +// fake has DVX.INI [audio] fakeFmVoices voices (default 9; 0: no chip)
    +// and is stereo with fakeFmStereo = true; the MIDI fake, like the real
    +// one, is there only with [audio] midi or midiIn = mpu401; the wavetable fake has
    +// fakeWtVoices voices (default 0: not there), fakeWtMemory bytes
    +// (default FAKE_WT_MEMORY), does what fakeWtFlags says (AUDIO_WT_*,
    +// default FAKE_WT_FLAGS: an AWE), and reports fakeWtLevel as its level
    +// (default 0).  An open that finds the fake there and every close go to
    +// fakeSynthEvent.
     
     #include "audioDrv.h"
     
    @@ -38,43 +45,95 @@
     #define FAKE_FM_VOICES      9
     #define INI_FAKE_VOICES     "fakeFmVoices"
     #define INI_FAKE_STEREO     "fakeFmStereo"
    +#define INI_WT_VOICES       "fakeWtVoices"
    +#define INI_WT_MEMORY       "fakeWtMemory"
    +#define INI_WT_FLAGS        "fakeWtFlags"
    +#define INI_WT_LEVEL        "fakeWtLevel"
    +#define FAKE_WT_MEMORY      (1024 * 1024)
    +#define FAKE_WT_FLAGS       (AUDIO_WT_ENVELOPES | AUDIO_WT_LFOS | AUDIO_WT_FILTER | AUDIO_WT_EFFECTS)
    +
    +#if defined(FAKE_SYN_FM)
    +#define FAKE_SYN_NAME       "fmfake"
    +#elif defined(FAKE_SYN_WT)
    +#define FAKE_SYN_NAME       "wtfake"
    +#else
    +#define FAKE_SYN_NAME       "midifake"
    +#endif
     
     // Test hooks (the suite's test program)
    +void fakeSynthEvent(const char *synth, const char *event);
    +bool fakeSynthGet(uint8_t *byte);
     bool fakeSynthPut(uint8_t byte);
     void fakeSynthWrite(uint32_t reg, uint8_t value);
    +bool fakeWtActive(uint32_t voice);
    +void fakeWtRelease(uint32_t voice);
    +void fakeWtStart(uint32_t voice, const AudioWtVoiceT *v);
    +void fakeWtStop(uint32_t voice);
    +void fakeWtUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +bool fakeWtUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
     
     static void fakeClose(void);
     static bool fakeOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info);
     
    -#ifdef FAKE_SYN_FM
    -AUDIO_SYNTH_DRIVER("fmfake", AUDIO_PRIORITY_CARD,
    +#if defined(FAKE_SYN_FM)
    +AUDIO_SYNTH_DRIVER(FAKE_SYN_NAME, AUDIO_PRIORITY_CARD,
         .kind     = AUDIO_SYNTH_FM,
         .open     = fakeOpen,
         .close    = fakeClose,
         .write    = fakeSynthWrite
     );
    +#elif defined(FAKE_SYN_WT)
    +AUDIO_SYNTH_DRIVER(FAKE_SYN_NAME, AUDIO_PRIORITY_CARD,
    +    .kind     = AUDIO_SYNTH_WAVETABLE,
    +    .open     = fakeOpen,
    +    .close    = fakeClose,
    +    .upload   = fakeWtUpload,
    +    .start    = fakeWtStart,
    +    .update   = fakeWtUpdate,
    +    .release  = fakeWtRelease,
    +    .stop     = fakeWtStop,
    +    .active   = fakeWtActive
    +);
     #else
    -AUDIO_SYNTH_DRIVER("midifake", AUDIO_PRIORITY_CARD,
    +AUDIO_SYNTH_DRIVER(FAKE_SYN_NAME, AUDIO_PRIORITY_CARD,
         .kind     = AUDIO_SYNTH_MIDI,
         .open     = fakeOpen,
         .close    = fakeClose,
    -    .put      = fakeSynthPut
    +    .put      = fakeSynthPut,
    +    .get      = fakeSynthGet
     );
     #endif
     
     
     static void fakeClose(void) {
    +    fakeSynthEvent(FAKE_SYN_NAME, "close");
     }
     
     
     static bool fakeOpen(const AudioDrvConfigT *cfg, AudioSynthDrvInfoT *info) {
    -#ifdef FAKE_SYN_FM
    +#if defined(FAKE_SYN_FM)
         info->voices = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_FAKE_VOICES, FAKE_FM_VOICES);
         info->stereo = prefsGetBool(cfg->prefs, AUDIO_INI_SECTION, INI_FAKE_STEREO, false);
         snprintf(info->description, sizeof(info->description), "Fake FM (%u voices)", (unsigned)info->voices);
    -    return info->voices > 0;
    +
    +    bool there = info->voices > 0;
    +#elif defined(FAKE_SYN_WT)
    +    info->voices = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_WT_VOICES, 0);
    +    info->memory = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_WT_MEMORY, FAKE_WT_MEMORY);
    +    info->flags  = (uint32_t)prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_WT_FLAGS, FAKE_WT_FLAGS);
    +    info->level  = prefsGetInt(cfg->prefs, AUDIO_INI_SECTION, INI_WT_LEVEL, 0);
    +    snprintf(info->description, sizeof(info->description), "Fake wavetable (%u voices)", (unsigned)info->voices);
    +
    +    bool there = info->voices > 0;
     #else
         snprintf(info->description, sizeof(info->description), "Fake MIDI port");
    -    return strcasecmp(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, AUDIO_MIDI_AUTO), AUDIO_MIDI_MPU401) == 0;
    +
    +    bool there = strcasecmp(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI, AUDIO_MIDI_AUTO), AUDIO_MIDI_MPU401) == 0 || strcasecmp(prefsGetString(cfg->prefs, AUDIO_INI_SECTION, AUDIO_INI_MIDI_IN, AUDIO_MIDI_IN_NONE), AUDIO_MIDI_MPU401) == 0;
     #endif
    +
    +    if (there) {
    +        fakeSynthEvent(FAKE_SYN_NAME, "open");
    +    }
    +
    +    return there;
     }
    diff --git a/src/test/basdev/testBasDev.c b/src/test/basdev/testBasDev.c
    index 4735930d..25058d88 100644
    --- a/src/test/basdev/testBasDev.c
    +++ b/src/test/basdev/testBasDev.c
    @@ -42,6 +42,7 @@
     #include "dvxRes.h"
     #include "dvxTypes.h"
     #include "pmModel.h"
    +#include "shellSaver.h"
     #include "../../libs/kpunch/audio/audioDrv.h"
     #include "../../libs/kpunch/joystick/joystick.h"
     #include "../../libs/kpunch/fileops/fileOps.h"
    @@ -644,6 +645,9 @@ static void caseIncludeConsts(void) {
             { "system.bas",   "SYS_VIDEO_DEFAULT_WIDTH",   DVX_DEFAULT_VIDEO_W       },
             { "system.bas",   "SYS_VIDEO_DEFAULT_HEIGHT",  DVX_DEFAULT_VIDEO_H       },
             { "system.bas",   "SYS_VIDEO_DEFAULT_BPP",     DVX_DEFAULT_VIDEO_BPP     },
    +        { "system.bas",   "SCREEN_SAVER_WAIT_MIN",     SHELL_SAVER_WAIT_MIN      },
    +        { "system.bas",   "SCREEN_SAVER_WAIT_MAX",     SHELL_SAVER_WAIT_MAX      },
    +        { "system.bas",   "SCREEN_SAVER_WAIT_DEFAULT", SHELL_SAVER_WAIT_DEFAULT  },
             { "shell.bas",    "SHELL_VIEW_LARGE",          PmViewLargeE              },
             { "shell.bas",    "SHELL_VIEW_SMALL",          PmViewSmallE              },
             { "shell.bas",    "SHELL_VIEW_LIST",           PmViewListE               },
    @@ -660,6 +664,7 @@ static void caseIncludeConsts(void) {
             { "fileops.bas",  "FILEOPS_KIND_THEMES",       DvxKindThemesE            },
             { "fileops.bas",  "FILEOPS_KIND_WPAPER",       DvxKindWpaperE            },
             { "fileops.bas",  "FILEOPS_KIND_BANKS",        DvxKindBanksE             },
    +        { "fileops.bas",  "FILEOPS_KIND_SAVERS",       DvxKindSaversE            },
             { "pkg.bas",      "PKG_FIELD_ID",              PkgFieldIdE               },
             { "pkg.bas",      "PKG_FIELD_NAME",            PkgFieldNameE             },
             { "pkg.bas",      "PKG_FIELD_VERSION",         PkgFieldVersionE          },
    @@ -739,6 +744,10 @@ static void caseIncludeConsts(void) {
             { "sound.bas",    "SOUND_STREAM_MS_MIN",       AUDIO_STREAM_MS_MIN       },
             { "sound.bas",    "SOUND_STREAM_MS_DEFAULT",   AUDIO_STREAM_MS_DEFAULT   },
             { "sound.bas",    "SOUND_STREAM_MS_MAX",       AUDIO_STREAM_MS_MAX       },
    +        { "sound.bas",    "RECORD_MIC",                AUDIO_REC_MIC             },
    +        { "sound.bas",    "RECORD_LINE",               AUDIO_REC_LINE            },
    +        { "sound.bas",    "RECORD_CD",                 AUDIO_REC_CD              },
    +        { "music.bas",    "MIDI_NONE",                 AUDIO_MIDI_PACKED_NONE    },
             { "sound.bas",    "FM_VOICES_MAX",             AUDIO_FM_VOICES_MAX       },
             { "sound.bas",    "FM_CHIP_HZ",                AUDIO_FM_CHIP_HZ          },
             { "sound.bas",    "FM_FNUM_MAX",               AUDIO_FM_FNUM_MAX         },
    diff --git a/src/test/dos/fixtures/dosrun/dosprog.bat b/src/test/dos/fixtures/dosrun/dosprog.bat
    new file mode 100644
    index 00000000..b8ca3ef0
    --- /dev/null
    +++ b/src/test/dos/fixtures/dosrun/dosprog.bat
    @@ -0,0 +1,3 @@
    +@ECHO OFF
    +REM dosprog.scr: run by DVX from TEST\DOSRUN.APP, in its own folder (TEST)
    +ECHO ran %1>DOSRAN.TXT
    diff --git a/src/test/dos/fixtures/dosrun/dosrun.dbp b/src/test/dos/fixtures/dosrun/dosrun.dbp
    new file mode 100644
    index 00000000..fe60b1cf
    --- /dev/null
    +++ b/src/test/dos/fixtures/dosrun/dosrun.dbp
    @@ -0,0 +1,18 @@
    +[Project]
    +Name = DOS Run
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = DOS harness fixture: asks DVX to run a DOS program (dosprog.scr)
    +
    +[Modules]
    +File0 = ../../../../include/basic/shell.bas
    +Count = 1
    +
    +[Forms]
    +File0 = dosrun.frm
    +Count = 1
    +
    +[Settings]
    +StartupForm = DosRun
    diff --git a/src/test/dos/fixtures/dosrun/dosrun.frm b/src/test/dos/fixtures/dosrun/dosrun.frm
    new file mode 100644
    index 00000000..84702674
    --- /dev/null
    +++ b/src/test/dos/fixtures/dosrun/dosrun.frm
    @@ -0,0 +1,44 @@
    +VERSION DVX 1.00
    +
    +Begin Form DosRun
    +    Caption   = "DOS Run"
    +    Layout    = VBox
    +    AutoSize  = False
    +    Resizable = False
    +    Width     = 200
    +    Height    = 60
    +
    +    Begin Label lblNote
    +        Caption = "Runs TEST\DOSPROG.BAT"
    +        Weight  = 1
    +    End
    +End
    +
    +' 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.
    +
    +' Asks for TEST\DOSPROG.BAT with "hello" (dosprog.scr): DVX runs it once
    +' this procedure has returned, after asking this program to close.
    +
    +SUB DosRun_Load
    +    ShellRunDos "TEST\DOSPROG.BAT", "hello", "", False
    +END SUB
    diff --git a/src/test/dos/fixtures/parcp/parcp.frm b/src/test/dos/fixtures/parcp/parcp.frm
    index cc21f93e..123c6c26 100644
    --- a/src/test/dos/fixtures/parcp/parcp.frm
    +++ b/src/test/dos/fixtures/parcp/parcp.frm
    @@ -410,12 +410,19 @@ FUNCTION CheckSound() AS STRING
             END IF
         NEXT i
     
    +    FOR i = 0 TO SoundModulesCount() - 1
    +        IF SoundModulesLabel$(i) = "" THEN
    +            what = "modules choice " + LTRIM$(STR$(i))
    +            GOTO SoundOut
    +        END IF
    +    NEXT i
    +
         ' Settings into a handle of their own (DVX.INI is not touched)
         saved = PrefsCreate()
         SoundSetEventFile saved, 0, "PARCP.WAV"
    -    SoundSettingsStore saved, SoundDriverAuto$(), 0, TEST_SND_VOLUME
    +    SoundSettingsStore saved, SoundDriverAuto$(), 0, 1, TEST_SND_VOLUME
     
    -    IF SoundEventFile$(saved, 0) <> "PARCP.WAV" OR SoundSettingVolume(saved) <> TEST_SND_VOLUME OR SoundSettingDriver$(saved) <> SoundDriverAuto$() OR SoundSettingMidi(saved) <> 0 THEN
    +    IF SoundEventFile$(saved, 0) <> "PARCP.WAV" OR SoundSettingVolume(saved) <> TEST_SND_VOLUME OR SoundSettingDriver$(saved) <> SoundDriverAuto$() OR SoundSettingMidi(saved) <> 0 OR SoundSettingModules(saved) <> 1 THEN
             what = "sound settings"
         END IF
     
    diff --git a/src/test/dos/fixtures/prdraw/prdraw.dbp b/src/test/dos/fixtures/prdraw/prdraw.dbp
    new file mode 100644
    index 00000000..443b33b7
    --- /dev/null
    +++ b/src/test/dos/fixtures/prdraw/prdraw.dbp
    @@ -0,0 +1,14 @@
    +[Project]
    +Name = Printer Drawing
    +Author = Scott Duensing
    +Publisher = Kangaroo Punch Studios
    +Version = 1.00
    +Copyright = Copyright 2026 Scott Duensing
    +Description = DOS harness fixture: a page drawn with the Printer object (lines, boxes, circles, hatches, dashes, text, and a picture) printed where DVX.INI says
    +
    +[Forms]
    +File0 = prdraw.frm
    +Count = 1
    +
    +[Settings]
    +StartupForm = PrDraw
    diff --git a/src/test/dos/fixtures/prdraw/prdraw.frm b/src/test/dos/fixtures/prdraw/prdraw.frm
    new file mode 100644
    index 00000000..806aed36
    --- /dev/null
    +++ b/src/test/dos/fixtures/prdraw/prdraw.frm
    @@ -0,0 +1,66 @@
    +VERSION DVX 1.00
    +
    +Begin Form PrDraw
    +    Caption   = "Printer starting"
    +    Layout    = VBox
    +    AutoSize  = False
    +    Resizable = False
    +    Width     = 480
    +    Height    = 120
    +
    +    Begin PictureBox picSource
    +        Width  = 32
    +        Height = 32
    +    End
    +
    +    Begin Timer tmrStart
    +        Interval = 50
    +        Enabled  = True
    +    End
    +End
    +
    +
    +
    +' One page on the printer DVX.INI names, drawn with the Printer object:
    +' a title and a line of text in two families, a thick rule, a box filled
    +' solid, a circle cross-hatched, a dashed line and arc, and a picture of
    +' four colored squares (drawn in picSource) stretched to 2 inches.
    +Sub tmrStart_Timer ()
    +    tmrStart.Enabled = False
    +    picSource.BackColor = RGB(255, 255, 255)
    +    picSource.Cls
    +    picSource.Line (0, 0)-(15, 15), RGB(255, 0, 0), BF
    +    picSource.Line (16, 0)-(31, 15), RGB(0, 255, 0), BF
    +    picSource.Line (0, 16)-(15, 31), RGB(0, 0, 255), BF
    +    picSource.Line (16, 16)-(31, 31), RGB(0, 0, 0), BF
    +
    +    Printer.ScaleMode = vbInches
    +    Printer.FontName = "DejaVu Serif"
    +    Printer.FontSize = 18
    +    Printer.FontBold = True
    +    Printer.CurrentX = 1
    +    Printer.CurrentY = 1
    +    Printer.Print "Printer Object"
    +    Printer.FontBold = False
    +    Printer.FontName = "DejaVu Sans"
    +    Printer.FontSize = 11
    +    Printer.CurrentX = 1
    +    Printer.Print "Shapes, text, and a picture on "; Printer.DeviceName
    +    Printer.DrawWidth = 3
    +    Printer.Line (1, 2)-(7, 2), RGB(0, 0, 128)
    +    Printer.DrawWidth = 1
    +    Printer.FillStyle = vbFSSolid
    +    Printer.FillColor = RGB(255, 200, 0)
    +    Printer.Line (1, 2.5)-(3, 4), RGB(0, 0, 0), B
    +    Printer.FillStyle = vbCross
    +    Printer.FillColor = RGB(0, 128, 0)
    +    Printer.Circle (4.5, 3.25), 0.75, RGB(0, 128, 0)
    +    Printer.FillStyle = vbFSTransparent
    +    Printer.DrawStyle = vbDash
    +    Printer.Line (1, 4.5)-(7, 4.5), RGB(128, 0, 128)
    +    Printer.Circle (6.25, 3.25), 0.6, RGB(200, 0, 0), 0, 3
    +    Printer.DrawStyle = vbSolid
    +    Printer.PaintPicture picSource, 1, 5, 2, 2
    +    Printer.EndDoc
    +    PrDraw.Caption = "Printer done"
    +End Sub
    diff --git a/src/test/dos/golden/awemod/86box/audio.json b/src/test/dos/golden/awemod/86box/audio.json
    new file mode 100644
    index 00000000..c745137d
    --- /dev/null
    +++ b/src/test/dos/golden/awemod/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.0247,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.03408,
    +    0.00015,
    +    0.00017,
    +    0.00036
    +   ]
    +  },
    +  {
    +   "startMs": 520,
    +   "lengthMs": 360,
    +   "level": 0.04542,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00059,
    +    0.06303,
    +    0.00432,
    +    0.001
    +   ]
    +  },
    +  {
    +   "startMs": 1240,
    +   "lengthMs": 360,
    +   "level": 0.04529,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00015,
    +    0.00327,
    +    0.06196,
    +    0.00419
    +   ]
    +  },
    +  {
    +   "startMs": 1960,
    +   "lengthMs": 360,
    +   "level": 0.0449,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.0009,
    +    0.00224,
    +    0.00318,
    +    0.05981
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/awerom/86box/audio.json b/src/test/dos/golden/awerom/86box/audio.json
    new file mode 100644
    index 00000000..a64318fc
    --- /dev/null
    +++ b/src/test/dos/golden/awerom/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.02626,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.03638,
    +    0.00017,
    +    0.00019,
    +    0.00033
    +   ]
    +  },
    +  {
    +   "startMs": 480,
    +   "lengthMs": 420,
    +   "level": 0.07811,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00152,
    +    0.07818,
    +    0.00605,
    +    0.06582
    +   ]
    +  },
    +  {
    +   "startMs": 1180,
    +   "lengthMs": 420,
    +   "level": 0.0785,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.02357,
    +    0.00574,
    +    0.07915,
    +    0.00558
    +   ]
    +  },
    +  {
    +   "startMs": 1880,
    +   "lengthMs": 420,
    +   "level": 0.07718,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00362,
    +    0.00186,
    +    0.00579,
    +    0.07902
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/awewave/86box/audio.json b/src/test/dos/golden/awewave/86box/audio.json
    new file mode 100644
    index 00000000..7f8ecca7
    --- /dev/null
    +++ b/src/test/dos/golden/awewave/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.02503,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.03475,
    +    0.00017,
    +    0.00019,
    +    0.00039
    +   ]
    +  },
    +  {
    +   "startMs": 1020,
    +   "lengthMs": 500,
    +   "level": 0.04321,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00074,
    +    0.05295,
    +    0.00358,
    +    0.00895
    +   ]
    +  },
    +  {
    +   "startMs": 1720,
    +   "lengthMs": 480,
    +   "level": 0.04809,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.02669,
    +    0.00385,
    +    0.05703,
    +    0.00377
    +   ]
    +  },
    +  {
    +   "startMs": 2420,
    +   "lengthMs": 460,
    +   "level": 0.04926,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.0009,
    +    0.00125,
    +    0.0039,
    +    0.05662
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/dosprog/dosran.txt b/src/test/dos/golden/dosprog/dosran.txt
    new file mode 100644
    index 00000000..79b5d71f
    --- /dev/null
    +++ b/src/test/dos/golden/dosprog/dosran.txt
    @@ -0,0 +1 @@
    +ran hello
    diff --git a/src/test/dos/golden/gus/86box/audio.json b/src/test/dos/golden/gus/86box/audio.json
    new file mode 100644
    index 00000000..a1467bb3
    --- /dev/null
    +++ b/src/test/dos/golden/gus/86box/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.06775,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.09192,
    +    0.00099,
    +    0.00192,
    +    0.00073,
    +    0.0012,
    +    0.00159
    +   ]
    +  },
    +  {
    +   "startMs": 520,
    +   "lengthMs": 420,
    +   "level": 0.0735,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00121,
    +    0.09461,
    +    0.00248,
    +    0.00698,
    +    0.00893,
    +    0.03025
    +   ]
    +  },
    +  {
    +   "startMs": 1220,
    +   "lengthMs": 420,
    +   "level": 0.04284,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00085,
    +    0.0015,
    +    0.05922,
    +    0.00398,
    +    0.00061,
    +    0.00127
    +   ]
    +  },
    +  {
    +   "startMs": 1960,
    +   "lengthMs": 420,
    +   "level": 0.06624,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.01866,
    +    0.00649,
    +    0.00592,
    +    0.08661,
    +    0.0042,
    +    0.00165
    +   ]
    +  },
    +  {
    +   "startMs": 2640,
    +   "lengthMs": 420,
    +   "level": 0.06825,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00118,
    +    0.00756,
    +    0.00251,
    +    0.00425,
    +    0.08805,
    +    0.00207
    +   ]
    +  },
    +  {
    +   "startMs": 3300,
    +   "lengthMs": 420,
    +   "level": 0.06127,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00199,
    +    0.00181,
    +    0.00217,
    +    0.00175,
    +    0.00131,
    +    0.08235
    +   ]
    +  },
    +  {
    +   "startMs": 4000,
    +   "lengthMs": 320,
    +   "level": 0.06814,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00607,
    +    0.01621,
    +    0.01331,
    +    0.01601,
    +    0.01745,
    +    0.00842
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/gus/dosbox/audio.json b/src/test/dos/golden/gus/dosbox/audio.json
    new file mode 100644
    index 00000000..3a64376f
    --- /dev/null
    +++ b/src/test/dos/golden/gus/dosbox/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.08856,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.11654,
    +    0.00155,
    +    0.00212,
    +    0.00176,
    +    0.00194,
    +    0.00364
    +   ]
    +  },
    +  {
    +   "startMs": 520,
    +   "lengthMs": 440,
    +   "level": 0.11074,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00155,
    +    0.14015,
    +    0.00435,
    +    0.00955,
    +    0.01395,
    +    0.04442
    +   ]
    +  },
    +  {
    +   "startMs": 1260,
    +   "lengthMs": 440,
    +   "level": 0.06494,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00139,
    +    0.00222,
    +    0.08783,
    +    0.00626,
    +    0.00118,
    +    0.00211
    +   ]
    +  },
    +  {
    +   "startMs": 2000,
    +   "lengthMs": 420,
    +   "level": 0.10305,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.02862,
    +    0.00949,
    +    0.00916,
    +    0.13482,
    +    0.00675,
    +    0.00255
    +   ]
    +  },
    +  {
    +   "startMs": 2720,
    +   "lengthMs": 440,
    +   "level": 0.10514,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00206,
    +    0.01296,
    +    0.00485,
    +    0.00584,
    +    0.12971,
    +    0.0026
    +   ]
    +  },
    +  {
    +   "startMs": 3460,
    +   "lengthMs": 440,
    +   "level": 0.09226,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00292,
    +    0.0027,
    +    0.00275,
    +    0.00315,
    +    0.00184,
    +    0.12242
    +   ]
    +  },
    +  {
    +   "startMs": 4200,
    +   "lengthMs": 340,
    +   "level": 0.09964,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00814,
    +    0.02278,
    +    0.01584,
    +    0.0232,
    +    0.02338,
    +    0.01361
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/gusmod/86box/audio.json b/src/test/dos/golden/gusmod/86box/audio.json
    new file mode 100644
    index 00000000..a31b2574
    --- /dev/null
    +++ b/src/test/dos/golden/gusmod/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.06374,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.08554,
    +    0.00211,
    +    0.00084,
    +    0.00175
    +   ]
    +  },
    +  {
    +   "startMs": 480,
    +   "lengthMs": 380,
    +   "level": 0.02091,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00031,
    +    0.02846,
    +    0.0021,
    +    0.00045
    +   ]
    +  },
    +  {
    +   "startMs": 1200,
    +   "lengthMs": 380,
    +   "level": 0.02092,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00012,
    +    0.00144,
    +    0.02791,
    +    0.00202
    +   ]
    +  },
    +  {
    +   "startMs": 1920,
    +   "lengthMs": 380,
    +   "level": 0.02081,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00045,
    +    0.00108,
    +    0.00132,
    +    0.02679
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/gusmod/dosbox/audio.json b/src/test/dos/golden/gusmod/dosbox/audio.json
    new file mode 100644
    index 00000000..f7d2e789
    --- /dev/null
    +++ b/src/test/dos/golden/gusmod/dosbox/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.15614,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.20895,
    +    0.00242,
    +    0.00268,
    +    0.00401
    +   ]
    +  },
    +  {
    +   "startMs": 500,
    +   "lengthMs": 380,
    +   "level": 0.0702,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00091,
    +    0.09492,
    +    0.00678,
    +    0.00151
    +   ]
    +  },
    +  {
    +   "startMs": 1260,
    +   "lengthMs": 380,
    +   "level": 0.06989,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00027,
    +    0.005,
    +    0.09303,
    +    0.00665
    +   ]
    +  },
    +  {
    +   "startMs": 2000,
    +   "lengthMs": 400,
    +   "level": 0.0669,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00132,
    +    0.00344,
    +    0.00458,
    +    0.0821
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/guspat/86box/audio.json b/src/test/dos/golden/guspat/86box/audio.json
    new file mode 100644
    index 00000000..8876d2e4
    --- /dev/null
    +++ b/src/test/dos/golden/guspat/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.05491,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.07306,
    +    0.00074,
    +    0.00082,
    +    0.00195
    +   ]
    +  },
    +  {
    +   "startMs": 480,
    +   "lengthMs": 400,
    +   "level": 0.01578,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00016,
    +    0.02214,
    +    0.00145,
    +    0.00052
    +   ]
    +  },
    +  {
    +   "startMs": 1160,
    +   "lengthMs": 420,
    +   "level": 0.01532,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.0002,
    +    0.0014,
    +    0.02125,
    +    0.00144
    +   ]
    +  },
    +  {
    +   "startMs": 1860,
    +   "lengthMs": 420,
    +   "level": 0.01519,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00032,
    +    0.00046,
    +    0.00141,
    +    0.02105
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/guspat/dosbox/audio.json b/src/test/dos/golden/guspat/dosbox/audio.json
    new file mode 100644
    index 00000000..5d71ffac
    --- /dev/null
    +++ b/src/test/dos/golden/guspat/dosbox/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 240,
    +   "level": 0.13558,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.17834,
    +    0.00202,
    +    0.00265,
    +    0.00369
    +   ]
    +  },
    +  {
    +   "startMs": 480,
    +   "lengthMs": 440,
    +   "level": 0.05284,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.0005,
    +    0.07101,
    +    0.00498,
    +    0.00183
    +   ]
    +  },
    +  {
    +   "startMs": 1220,
    +   "lengthMs": 440,
    +   "level": 0.05267,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00056,
    +    0.00489,
    +    0.07143,
    +    0.00482
    +   ]
    +  },
    +  {
    +   "startMs": 1960,
    +   "lengthMs": 440,
    +   "level": 0.05172,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00105,
    +    0.00194,
    +    0.00535,
    +    0.07062
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/guswave/86box/audio.json b/src/test/dos/golden/guswave/86box/audio.json
    new file mode 100644
    index 00000000..f3f3e355
    --- /dev/null
    +++ b/src/test/dos/golden/guswave/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.05794,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.07972,
    +    0.00043,
    +    0.00119,
    +    0.00188
    +   ]
    +  },
    +  {
    +   "startMs": 520,
    +   "lengthMs": 360,
    +   "level": 0.0108,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00019,
    +    0.01346,
    +    0.00093,
    +    0.00221
    +   ]
    +  },
    +  {
    +   "startMs": 1200,
    +   "lengthMs": 380,
    +   "level": 0.01091,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00563,
    +    0.00086,
    +    0.01302,
    +    0.00085
    +   ]
    +  },
    +  {
    +   "startMs": 1880,
    +   "lengthMs": 400,
    +   "level": 0.01081,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00017,
    +    0.00026,
    +    0.00084,
    +    0.01272
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/guswave/dosbox/audio.json b/src/test/dos/golden/guswave/dosbox/audio.json
    new file mode 100644
    index 00000000..ddfe5ec1
    --- /dev/null
    +++ b/src/test/dos/golden/guswave/dosbox/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.09523,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.12723,
    +    0.0021,
    +    0.00144,
    +    0.00268
    +   ]
    +  },
    +  {
    +   "startMs": 560,
    +   "lengthMs": 440,
    +   "level": 0.03381,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00055,
    +    0.04161,
    +    0.00278,
    +    0.00668
    +   ]
    +  },
    +  {
    +   "startMs": 1280,
    +   "lengthMs": 460,
    +   "level": 0.03435,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.01706,
    +    0.00293,
    +    0.04009,
    +    0.00281
    +   ]
    +  },
    +  {
    +   "startMs": 2020,
    +   "lengthMs": 440,
    +   "level": 0.03557,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00074,
    +    0.00088,
    +    0.00293,
    +    0.04236
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/guswt/86box/audio.json b/src/test/dos/golden/guswt/86box/audio.json
    new file mode 100644
    index 00000000..8acd3842
    --- /dev/null
    +++ b/src/test/dos/golden/guswt/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.02435,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.03432,
    +    0.00025,
    +    0.00026,
    +    0.00049
    +   ]
    +  },
    +  {
    +   "startMs": 600,
    +   "lengthMs": 500,
    +   "level": 0.00985,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00016,
    +    0.01206,
    +    0.00082,
    +    0.00196
    +   ]
    +  },
    +  {
    +   "startMs": 1360,
    +   "lengthMs": 520,
    +   "level": 0.01014,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00534,
    +    0.00081,
    +    0.01207,
    +    0.00078
    +   ]
    +  },
    +  {
    +   "startMs": 2200,
    +   "lengthMs": 500,
    +   "level": 0.0103,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00016,
    +    0.00026,
    +    0.00082,
    +    0.01185
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/guswt/dosbox/audio.json b/src/test/dos/golden/guswt/dosbox/audio.json
    new file mode 100644
    index 00000000..838ee741
    --- /dev/null
    +++ b/src/test/dos/golden/guswt/dosbox/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.04937,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.06782,
    +    0.00057,
    +    0.0006,
    +    0.00107
    +   ]
    +  },
    +  {
    +   "startMs": 560,
    +   "lengthMs": 460,
    +   "level": 0.03255,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.0005,
    +    0.0406,
    +    0.00278,
    +    0.00659
    +   ]
    +  },
    +  {
    +   "startMs": 1260,
    +   "lengthMs": 480,
    +   "level": 0.03316,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.01635,
    +    0.00298,
    +    0.0391,
    +    0.00271
    +   ]
    +  },
    +  {
    +   "startMs": 1980,
    +   "lengthMs": 500,
    +   "level": 0.03228,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00065,
    +    0.00102,
    +    0.00249,
    +    0.03755
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/lptcovox/86box/audio.json b/src/test/dos/golden/lptcovox/86box/audio.json
    new file mode 100644
    index 00000000..3df75496
    --- /dev/null
    +++ b/src/test/dos/golden/lptcovox/86box/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.07822,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.09952,
    +    0.00397,
    +    0.00591,
    +    0.00379,
    +    0.00412,
    +    0.00351
    +   ]
    +  },
    +  {
    +   "startMs": 540,
    +   "lengthMs": 420,
    +   "level": 0.09116,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00179,
    +    0.11732,
    +    0.00353,
    +    0.00808,
    +    0.01183,
    +    0.03664
    +   ]
    +  },
    +  {
    +   "startMs": 1200,
    +   "lengthMs": 420,
    +   "level": 0.05295,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.0011,
    +    0.00201,
    +    0.07267,
    +    0.00525,
    +    0.0008,
    +    0.00146
    +   ]
    +  },
    +  {
    +   "startMs": 1960,
    +   "lengthMs": 420,
    +   "level": 0.08074,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.02067,
    +    0.00775,
    +    0.00776,
    +    0.10648,
    +    0.00522,
    +    0.00233
    +   ]
    +  },
    +  {
    +   "startMs": 2640,
    +   "lengthMs": 420,
    +   "level": 0.08509,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00192,
    +    0.01027,
    +    0.00364,
    +    0.00492,
    +    0.1091,
    +    0.00237
    +   ]
    +  },
    +  {
    +   "startMs": 3300,
    +   "lengthMs": 420,
    +   "level": 0.07481,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00262,
    +    0.00269,
    +    0.00343,
    +    0.00341,
    +    0.00247,
    +    0.09852
    +   ]
    +  },
    +  {
    +   "startMs": 4000,
    +   "lengthMs": 320,
    +   "level": 0.08399,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00815,
    +    0.02331,
    +    0.01623,
    +    0.01948,
    +    0.01586,
    +    0.01076
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/lptcovox/dosbox/audio.json b/src/test/dos/golden/lptcovox/dosbox/audio.json
    new file mode 100644
    index 00000000..c59db7e7
    --- /dev/null
    +++ b/src/test/dos/golden/lptcovox/dosbox/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 240,
    +   "level": 0.13205,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.17046,
    +    0.00225,
    +    0.00369,
    +    0.00321,
    +    0.00239,
    +    0.00432
    +   ]
    +  },
    +  {
    +   "startMs": 520,
    +   "lengthMs": 440,
    +   "level": 0.17894,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00385,
    +    0.22412,
    +    0.00655,
    +    0.0161,
    +    0.02431,
    +    0.07227
    +   ]
    +  },
    +  {
    +   "startMs": 1260,
    +   "lengthMs": 440,
    +   "level": 0.10489,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00217,
    +    0.0035,
    +    0.1394,
    +    0.00969,
    +    0.00185,
    +    0.00313
    +   ]
    +  },
    +  {
    +   "startMs": 2000,
    +   "lengthMs": 440,
    +   "level": 0.15971,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.04278,
    +    0.01532,
    +    0.01477,
    +    0.20645,
    +    0.01042,
    +    0.00389
    +   ]
    +  },
    +  {
    +   "startMs": 2740,
    +   "lengthMs": 420,
    +   "level": 0.17254,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00306,
    +    0.01997,
    +    0.00716,
    +    0.00999,
    +    0.22042,
    +    0.00445
    +   ]
    +  },
    +  {
    +   "startMs": 3460,
    +   "lengthMs": 440,
    +   "level": 0.14902,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00482,
    +    0.00477,
    +    0.00573,
    +    0.00534,
    +    0.00353,
    +    0.19638
    +   ]
    +  },
    +  {
    +   "startMs": 4200,
    +   "lengthMs": 340,
    +   "level": 0.16269,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.01644,
    +    0.04366,
    +    0.03618,
    +    0.04198,
    +    0.0339,
    +    0.02029
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/lptdss/86box/audio.json b/src/test/dos/golden/lptdss/86box/audio.json
    new file mode 100644
    index 00000000..8cac1a0f
    --- /dev/null
    +++ b/src/test/dos/golden/lptdss/86box/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.06781,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.08542,
    +    0.00115,
    +    0.00489,
    +    0.00152,
    +    0.00176,
    +    0.00207
    +   ]
    +  },
    +  {
    +   "startMs": 500,
    +   "lengthMs": 420,
    +   "level": 0.09022,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00195,
    +    0.11705,
    +    0.00328,
    +    0.00808,
    +    0.01144,
    +    0.03509
    +   ]
    +  },
    +  {
    +   "startMs": 1200,
    +   "lengthMs": 420,
    +   "level": 0.05141,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00108,
    +    0.00182,
    +    0.07173,
    +    0.00498,
    +    0.00076,
    +    0.00171
    +   ]
    +  },
    +  {
    +   "startMs": 1940,
    +   "lengthMs": 420,
    +   "level": 0.07963,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.01899,
    +    0.00751,
    +    0.00761,
    +    0.10595,
    +    0.00486,
    +    0.0017
    +   ]
    +  },
    +  {
    +   "startMs": 2640,
    +   "lengthMs": 420,
    +   "level": 0.08437,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00115,
    +    0.00947,
    +    0.00426,
    +    0.00485,
    +    0.10911,
    +    0.00218
    +   ]
    +  },
    +  {
    +   "startMs": 3300,
    +   "lengthMs": 420,
    +   "level": 0.07142,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00244,
    +    0.00353,
    +    0.00275,
    +    0.0025,
    +    0.00421,
    +    0.09561
    +   ]
    +  },
    +  {
    +   "startMs": 4000,
    +   "lengthMs": 320,
    +   "level": 0.08258,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00746,
    +    0.02262,
    +    0.0178,
    +    0.01756,
    +    0.01638,
    +    0.01038
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/lptdss/dosbox/audio.json b/src/test/dos/golden/lptdss/dosbox/audio.json
    new file mode 100644
    index 00000000..bce4c2bd
    --- /dev/null
    +++ b/src/test/dos/golden/lptdss/dosbox/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.14797,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.18154,
    +    0.00334,
    +    0.01315,
    +    0.00397,
    +    0.00417,
    +    0.00517
    +   ]
    +  },
    +  {
    +   "startMs": 520,
    +   "lengthMs": 440,
    +   "level": 0.17947,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00339,
    +    0.22489,
    +    0.00653,
    +    0.01702,
    +    0.02384,
    +    0.06852
    +   ]
    +  },
    +  {
    +   "startMs": 1260,
    +   "lengthMs": 440,
    +   "level": 0.10196,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00259,
    +    0.00329,
    +    0.13812,
    +    0.00937,
    +    0.00175,
    +    0.00326
    +   ]
    +  },
    +  {
    +   "startMs": 2000,
    +   "lengthMs": 440,
    +   "level": 0.15761,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.03724,
    +    0.01663,
    +    0.01558,
    +    0.20348,
    +    0.01177,
    +    0.00415
    +   ]
    +  },
    +  {
    +   "startMs": 2720,
    +   "lengthMs": 440,
    +   "level": 0.16603,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00462,
    +    0.02128,
    +    0.00818,
    +    0.01037,
    +    0.20401,
    +    0.00554
    +   ]
    +  },
    +  {
    +   "startMs": 3460,
    +   "lengthMs": 440,
    +   "level": 0.14546,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00464,
    +    0.00835,
    +    0.00638,
    +    0.00587,
    +    0.00739,
    +    0.18603
    +   ]
    +  },
    +  {
    +   "startMs": 4180,
    +   "lengthMs": 360,
    +   "level": 0.15196,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.01409,
    +    0.03593,
    +    0.02529,
    +    0.0328,
    +    0.03127,
    +    0.02188
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/lptst86/86box/audio.json b/src/test/dos/golden/lptst86/86box/audio.json
    new file mode 100644
    index 00000000..f938cd30
    --- /dev/null
    +++ b/src/test/dos/golden/lptst86/86box/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.0642,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.08045,
    +    0.00548,
    +    0.00524,
    +    0.0034,
    +    0.00199,
    +    0.00216
    +   ]
    +  },
    +  {
    +   "startMs": 480,
    +   "lengthMs": 420,
    +   "level": 0.08822,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.0017,
    +    0.11638,
    +    0.0031,
    +    0.00812,
    +    0.01057,
    +    0.034
    +   ]
    +  },
    +  {
    +   "startMs": 1180,
    +   "lengthMs": 420,
    +   "level": 0.05078,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00113,
    +    0.00173,
    +    0.07047,
    +    0.00498,
    +    0.00076,
    +    0.00153
    +   ]
    +  },
    +  {
    +   "startMs": 1960,
    +   "lengthMs": 420,
    +   "level": 0.07832,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.01744,
    +    0.00745,
    +    0.00744,
    +    0.10467,
    +    0.00546,
    +    0.0025
    +   ]
    +  },
    +  {
    +   "startMs": 2620,
    +   "lengthMs": 420,
    +   "level": 0.08358,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00152,
    +    0.01036,
    +    0.00342,
    +    0.00534,
    +    0.10867,
    +    0.0021
    +   ]
    +  },
    +  {
    +   "startMs": 3300,
    +   "lengthMs": 420,
    +   "level": 0.06846,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00185,
    +    0.00216,
    +    0.00243,
    +    0.00332,
    +    0.00209,
    +    0.0917
    +   ]
    +  },
    +  {
    +   "startMs": 4000,
    +   "lengthMs": 320,
    +   "level": 0.07942,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00566,
    +    0.02065,
    +    0.01286,
    +    0.02026,
    +    0.02075,
    +    0.00963
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/lptston/dosbox/audio.json b/src/test/dos/golden/lptston/dosbox/audio.json
    new file mode 100644
    index 00000000..18fdcd9f
    --- /dev/null
    +++ b/src/test/dos/golden/lptston/dosbox/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 240,
    +   "level": 0.14199,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.17474,
    +    0.00357,
    +    0.00375,
    +    0.00403,
    +    0.00309,
    +    0.00552
    +   ]
    +  },
    +  {
    +   "startMs": 540,
    +   "lengthMs": 440,
    +   "level": 0.17877,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00436,
    +    0.22634,
    +    0.0063,
    +    0.01589,
    +    0.02178,
    +    0.07162
    +   ]
    +  },
    +  {
    +   "startMs": 1280,
    +   "lengthMs": 440,
    +   "level": 0.10461,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00185,
    +    0.00419,
    +    0.14149,
    +    0.01009,
    +    0.0019,
    +    0.0036
    +   ]
    +  },
    +  {
    +   "startMs": 2000,
    +   "lengthMs": 440,
    +   "level": 0.1603,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.04442,
    +    0.01447,
    +    0.01448,
    +    0.20645,
    +    0.01027,
    +    0.0048
    +   ]
    +  },
    +  {
    +   "startMs": 2740,
    +   "lengthMs": 460,
    +   "level": 0.15941,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00328,
    +    0.01831,
    +    0.00687,
    +    0.01037,
    +    0.20004,
    +    0.00477
    +   ]
    +  },
    +  {
    +   "startMs": 3480,
    +   "lengthMs": 440,
    +   "level": 0.14975,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00475,
    +    0.00443,
    +    0.00539,
    +    0.00512,
    +    0.00377,
    +    0.19665
    +   ]
    +  },
    +  {
    +   "startMs": 4220,
    +   "lengthMs": 340,
    +   "level": 0.16183,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.01679,
    +    0.04285,
    +    0.03553,
    +    0.03457,
    +    0.04073,
    +    0.02275
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/musmod/86box/audio.json b/src/test/dos/golden/musmod/86box/audio.json
    index 9e4f9cd9..2b240e14 100644
    --- a/src/test/dos/golden/musmod/86box/audio.json
    +++ b/src/test/dos/golden/musmod/86box/audio.json
    @@ -10,37 +10,49 @@
       {
        "startMs": 0,
        "lengthMs": 220,
    -   "level": 0.02665,
    +   "level": 0.02577,
        "loudest": 2000,
        "spectrum": [
    -    0.03692,
    +    0.03484,
         0.00042,
    -    0.00043,
    +    0.00039,
         0.00078
        ]
       },
       {
    -   "startMs": 540,
    -   "lengthMs": 380,
    -   "level": 0.04395,
    +   "startMs": 560,
    +   "lengthMs": 360,
    +   "level": 0.04537,
        "loudest": 440,
        "spectrum": [
    -    0.0006,
    -    0.0599,
    -    0.0045,
    +    0.00064,
    +    0.06311,
    +    0.00446,
         0.001
        ]
       },
    +  {
    +   "startMs": 1280,
    +   "lengthMs": 360,
    +   "level": 0.04506,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00012,
    +    0.00332,
    +    0.06183,
    +    0.00439
    +   ]
    +  },
       {
        "startMs": 1980,
        "lengthMs": 380,
    -   "level": 0.04317,
    +   "level": 0.04251,
        "loudest": 880,
        "spectrum": [
    -    0.00089,
    -    0.00209,
    -    0.00344,
    -    0.05669
    +    0.00087,
    +    0.00204,
    +    0.00306,
    +    0.05662
        ]
       }
      ]
    diff --git a/src/test/dos/golden/player/86box/audio.json b/src/test/dos/golden/player/86box/audio.json
    new file mode 100644
    index 00000000..316266a8
    --- /dev/null
    +++ b/src/test/dos/golden/player/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 320,
    +   "level": 0.0728,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.10019,
    +    0.00102,
    +    0.00168,
    +    0.00215
    +   ]
    +  },
    +  {
    +   "startMs": 660,
    +   "lengthMs": 380,
    +   "level": 0.08192,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00097,
    +    0.11334,
    +    0.00796,
    +    0.00282
    +   ]
    +  },
    +  {
    +   "startMs": 1380,
    +   "lengthMs": 380,
    +   "level": 0.08138,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00088,
    +    0.0078,
    +    0.11258,
    +    0.00812
    +   ]
    +  },
    +  {
    +   "startMs": 2100,
    +   "lengthMs": 380,
    +   "level": 0.08062,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00157,
    +    0.00295,
    +    0.0081,
    +    0.11148
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/player/dosbox/audio.json b/src/test/dos/golden/player/dosbox/audio.json
    new file mode 100644
    index 00000000..02fcf2da
    --- /dev/null
    +++ b/src/test/dos/golden/player/dosbox/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 240,
    +   "level": 0.11082,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.15102,
    +    0.00113,
    +    0.00109,
    +    0.00208
    +   ]
    +  },
    +  {
    +   "startMs": 600,
    +   "lengthMs": 360,
    +   "level": 0.12726,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00132,
    +    0.178,
    +    0.01205,
    +    0.00418
    +   ]
    +  },
    +  {
    +   "startMs": 1300,
    +   "lengthMs": 380,
    +   "level": 0.12126,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.00135,
    +    0.01154,
    +    0.16756,
    +    0.0121
    +   ]
    +  },
    +  {
    +   "startMs": 2020,
    +   "lengthMs": 380,
    +   "level": 0.11975,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00243,
    +    0.00434,
    +    0.01305,
    +    0.16058
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/prdjcol/prdjcol.prn b/src/test/dos/golden/prdjcol/prdjcol.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..ec4bf0cf77f58b53692befe7aa26db41a825892f
    GIT binary patch
    literal 66375
    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	sUv9
    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
    N3$H)=+ui#f{xgL9$I1Wz
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/dos/golden/prps/prps.prn b/src/test/dos/golden/prps/prps.prn
    new file mode 100644
    index 00000000..0d33f281
    --- /dev/null
    +++ b/src/test/dos/golden/prps/prps.prn
    @@ -0,0 +1,104 @@
    +%!PS
    +%%Creator: DVX
    +%%LanguageLevel: 2
    +%%EndComments
    +/dvxPage { currentpagedevice /PageSize get aload pop 36 sub exch 576 sub 2 div exch } bind def
    +/dvxBegin { /dvxDpi exch def gsave dvxPage translate 72 dvxDpi div dup neg scale } bind def
    +/dvxData { /dvxA currentfile /ASCII85Decode filter def /dvxR dvxA /RunLengthDecode filter def } bind def
    +/dvxDone { dvxR flushfile dvxA flushfile } bind def
    +/dvxMask { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH true [dvxW 0 0 dvxH 0 0] dvxR imagemask dvxDone grestore } bind def
    +/dvxColor { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH 1 [dvxW 0 0 dvxH 0 0] dvxR false 4 colorimage dvxDone grestore } bind def
    +/dvxRe { findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall /Encoding ISOLatin1Encoding def currentdict end definefont pop } bind def
    +/DvxCourier /Courier dvxRe /dvxF /DvxCourier findfont def
    +/dvxTop { dvxPage /dvxY exch def /dvxX exch def dvxF dvxSize scalefont setfont } bind def
    +/L { dvxX dvxY dvxLead sub dup /dvxY exch def moveto show } bind def
    +/dvxM matrix def
    +/dvxL { 4 2 roll moveto lineto stroke } bind def
    +/dvxEA { dvxM currentmatrix pop 6 4 roll translate 4 2 roll scale 0 0 1 5 -2 roll neg exch neg exch arcn dvxM setmatrix } bind def
    +/dvxE { 0 360 dvxEA closepath } bind def
    +/dvxBox { 4 2 roll moveto 1 index 0 rlineto 0 exch rlineto neg 0 rlineto closepath } bind def
    +/dvxD { 0 360 arc fill } bind def
    +/dvxHz { /dvxQ exch def /dvxP exch def { 0.5 add dvxP exch moveto dvxQ dvxP sub 0 rlineto } for } bind def
    +/dvxVt { /dvxQ exch def /dvxP exch def { 0.5 add dvxP moveto 0 dvxQ dvxP sub rlineto } for } bind def
    +/dvxUp { /dvxQ exch def /dvxP exch def { 1 add dup dvxP sub dvxP moveto dvxQ sub dvxQ lineto } for } bind def
    +/dvxDn { /dvxQ exch def /dvxP exch def { dup dvxP add dvxP moveto dvxQ add dvxQ lineto } for } bind def
    +/dvxSF { /dvxSy exch def /dvxSx exch def findfont [dvxSx 0 0 dvxSy neg 0 0] makefont setfont } bind def
    +/dvxT { moveto show } bind def
    +/dvxImg { /dvxC exch def /dvxH exch def /dvxW exch def gsave 4 2 roll translate scale dvxData dvxW dvxH 8 [dvxW 0 0 dvxH 0 0] dvxR dvxC 1 eq { image } { false 3 colorimage } ifelse dvxDone grestore } bind def
    +%%EndProlog
    +300 dvxBegin
    +0 0 2400 3000 rectclip 1 setlinecap 1 setlinejoin
    +/DvxF5 /Times-Bold dvxRe
    +/DvxF5 75 75 dvxSF
    +(Printer Object) 300 362.475 dvxT
    +/DvxF0 /Helvetica dvxRe
    +/DvxF0 45.8333 45.8333 dvxSF
    +(Shapes, text, and a picture on ) 300 429.21 dvxT
    +(PostScript \(color\)) 922 429.21 dvxT
    +0 0 0.502 setrgbcolor
    +3 setlinewidth
    +300.5 600.5 2100.5 600.5 dvxL
    +1 0.784 0 setrgbcolor
    +300 750 601 451 dvxBox fill
    +0 0 0 setrgbcolor
    +1 setlinewidth
    +300.5 750.5 900.5 750.5 dvxL
    +900.5 751.5 900.5 1200.5 dvxL
    +899.5 1200.5 300.5 1200.5 dvxL
    +300.5 1199.5 300.5 751.5 dvxL
    +0 0.502 0 setrgbcolor
    +gsave 1350.5 975.5 225.5 225.5 dvxE clip newpath
    +752 8 1200 1125 1576 dvxHz
    +1128 8 1575 750 1201 dvxVt
    +1 setlinewidth 0 setlinecap stroke grestore
    +1350.5 975.5 225 225 dvxE stroke
    +0.502 0 0.502 setrgbcolor
    +0 setlinecap
    +[12 4 ] 0 setdash
    +300 1350.5 2101 1350.5 dvxL
    +0.784 0 0 setrgbcolor
    +[12 4 ] 0 setdash
    +1875.5 975.5 180 180 0 171.887 dvxEA stroke
    +300 1500 600 600 32 32 3 dvxImg
    +/cPeNrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*&u!&"9O!<3$!rr<'!
    +!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!%n3N!<3$!rr<'!!!*$!!<3$!rr<'!
    +!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!rW"SN!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!
    +rr<'!!!*$!!<3$!rr=YN!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!
    +s8E!Nrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$Nrr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<<#u0)knOrr<'!!!*$!!<3$!rr<'!
    +!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!/cPeNrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!
    +!!*$!!<3$!rr<'!!!*&u!&"9O!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!
    +rr<'!!%n3N!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!rW"SN!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr=YN!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!s8E!Nrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!
    +!!*$!!<3$!rr<'!!!*$!!<3$Nrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!
    +!!*$!!<<#u0)knOrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!/cPeN
    +rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*&u!&"9O!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!%n3N!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!rW"SN!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!
    +!!*$!!<3$!rr=YN!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!s8E!N
    +rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$Nrr<'!!!*$!!<3$!
    +rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<<#u0)knOrr<'!!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!/cPeNrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*&u!&"9O!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!
    +!%n3N!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!rW"SN!!*$!!<3$!
    +rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr=YN!!*$!!<3$!rr<'!!!*$!!<3$!
    +rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!s8E!Nrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$Nrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!
    +!<<#u0)knOrr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!0)u"P!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!s3Ug"!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!!<3$!rr<'!d/Yg"!<3$!rr<'!!!*$!!<3$!rr<'!!!*$!
    +!<3$!rr<'!!!*$!!<3$!rr<'!!!*&H!&"
    +grestore showpage
    +%%EOF
    diff --git a/src/test/dos/golden/softwave/86box/audio.json b/src/test/dos/golden/softwave/86box/audio.json
    new file mode 100644
    index 00000000..4c3cd570
    --- /dev/null
    +++ b/src/test/dos/golden/softwave/86box/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.02681,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.03688,
    +    0.00015,
    +    0.00019,
    +    0.00033
    +   ]
    +  },
    +  {
    +   "startMs": 720,
    +   "lengthMs": 480,
    +   "level": 0.02958,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00046,
    +    0.03678,
    +    0.00246,
    +    0.00588
    +   ]
    +  },
    +  {
    +   "startMs": 1420,
    +   "lengthMs": 480,
    +   "level": 0.03084,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.0165,
    +    0.00247,
    +    0.03693,
    +    0.00248
    +   ]
    +  },
    +  {
    +   "startMs": 2120,
    +   "lengthMs": 480,
    +   "level": 0.03111,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00055,
    +    0.00084,
    +    0.00251,
    +    0.03648
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/softwave/dosbox/audio.json b/src/test/dos/golden/softwave/dosbox/audio.json
    new file mode 100644
    index 00000000..901781ca
    --- /dev/null
    +++ b/src/test/dos/golden/softwave/dosbox/audio.json
    @@ -0,0 +1,59 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  659,
    +  880
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 200,
    +   "level": 0.05695,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.07839,
    +    0.0002,
    +    0.00029,
    +    0.00058
    +   ]
    +  },
    +  {
    +   "startMs": 760,
    +   "lengthMs": 480,
    +   "level": 0.04438,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00072,
    +    0.05534,
    +    0.00376,
    +    0.00899
    +   ]
    +  },
    +  {
    +   "startMs": 1460,
    +   "lengthMs": 480,
    +   "level": 0.04618,
    +   "loudest": 659,
    +   "spectrum": [
    +    0.02449,
    +    0.00372,
    +    0.0551,
    +    0.0037
    +   ]
    +  },
    +  {
    +   "startMs": 2160,
    +   "lengthMs": 480,
    +   "level": 0.04611,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.0008,
    +    0.00119,
    +    0.00364,
    +    0.05041
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/golden/wss/86box/audio.json b/src/test/dos/golden/wss/86box/audio.json
    new file mode 100644
    index 00000000..8d337dc2
    --- /dev/null
    +++ b/src/test/dos/golden/wss/86box/audio.json
    @@ -0,0 +1,111 @@
    +{
    + "windowMs": 20,
    + "freqs": [
    +  2000,
    +  440,
    +  880,
    +  660,
    +  330,
    +  1320
    + ],
    + "sounds": [
    +  {
    +   "startMs": 0,
    +   "lengthMs": 220,
    +   "level": 0.13786,
    +   "loudest": 2000,
    +   "spectrum": [
    +    0.18281,
    +    0.00445,
    +    0.00266,
    +    0.00127,
    +    0.00268,
    +    0.00321
    +   ]
    +  },
    +  {
    +   "startMs": 480,
    +   "lengthMs": 420,
    +   "level": 0.1818,
    +   "loudest": 440,
    +   "spectrum": [
    +    0.00255,
    +    0.23549,
    +    0.00632,
    +    0.01685,
    +    0.02205,
    +    0.0752
    +   ]
    +  },
    +  {
    +   "startMs": 1180,
    +   "lengthMs": 420,
    +   "level": 0.10675,
    +   "loudest": 880,
    +   "spectrum": [
    +    0.00226,
    +    0.00363,
    +    0.14743,
    +    0.01016,
    +    0.00144,
    +    0.00364
    +   ]
    +  },
    +  {
    +   "startMs": 1920,
    +   "lengthMs": 420,
    +   "level": 0.16404,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.04626,
    +    0.01499,
    +    0.01568,
    +    0.21563,
    +    0.01059,
    +    0.00371
    +   ]
    +  },
    +  {
    +   "startMs": 2620,
    +   "lengthMs": 420,
    +   "level": 0.17278,
    +   "loudest": 330,
    +   "spectrum": [
    +    0.00289,
    +    0.02086,
    +    0.00683,
    +    0.01049,
    +    0.22091,
    +    0.00475
    +   ]
    +  },
    +  {
    +   "startMs": 3300,
    +   "lengthMs": 420,
    +   "level": 0.15361,
    +   "loudest": 1320,
    +   "spectrum": [
    +    0.00518,
    +    0.00441,
    +    0.00452,
    +    0.00568,
    +    0.00348,
    +    0.20646
    +   ]
    +  },
    +  {
    +   "startMs": 4000,
    +   "lengthMs": 320,
    +   "level": 0.16767,
    +   "loudest": 660,
    +   "spectrum": [
    +    0.01558,
    +    0.04174,
    +    0.02895,
    +    0.04341,
    +    0.04062,
    +    0.02266
    +   ]
    +  }
    + ]
    +}
    diff --git a/src/test/dos/mkMedia.py b/src/test/dos/mkMedia.py
    new file mode 100755
    index 00000000..9d83bd32
    --- /dev/null
    +++ b/src/test/dos/mkMedia.py
    @@ -0,0 +1,175 @@
    +#!/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.
    +
    +# mkMedia.py -- Media Player's test files for runDos.sh (player.scr),
    +# written into the staged TEST folder as each script is staged:
    +#
    +#   PLAYER.WAV  22,050 Hz 16-bit mono: a 2000 Hz sync tone, then A4, E5,
    +#               and A5 (SYNC_MS, then NOTE_MS each, GAP_MS apart), the
    +#               sounds player.scr's # AUDIO line lists
    +#   PLAYER.MOD  a ProTracker module titled MOD_TITLE: a looping sine
    +#               sample playing C-3, E-3, and G-3 eight rows apart, then the
    +#               song ends
    +#   PLAYER.MID  a type 0 MIDI file whose track is named MIDI_TITLE: C4, E4,
    +#               and G4 on the piano, a beat each at 120 beats a minute
    +#   PLAYER.VOC  a Creative Voice File: a second of a 660 Hz sine, 8-bit at
    +#               VOC_RATE
    +#
    +#   mkMedia.py FOLDER
    +
    +import math
    +import struct
    +import sys
    +
    +WAV_RATE    = 22050
    +WAV_AMP     = 12000
    +SYNC_HZ     = 2000
    +SYNC_MS     = 300
    +NOTE_MS     = 360
    +GAP_MS      = 360
    +NOTES_HZ    = (440, 659, 880)
    +FADE_MS     = 5          # each sound fades in and out (no clicks)
    +
    +MOD_TITLE   = "Player Test Module"
    +MOD_SAMPLE  = 32         # bytes of the looping sine
    +MOD_AMP     = 100
    +MOD_ROWS    = 64
    +MOD_CHANNELS = 4
    +MOD_NOTE_GAP = 8         # rows between the notes
    +MOD_PERIODS = (428, 339, 285)   # C-3, E-3, G-3
    +MOD_VOLUME  = 64
    +
    +MIDI_TITLE  = "Player Test MIDI"
    +MIDI_DIVISION = 96
    +MIDI_NOTES  = (60, 64, 67)
    +MIDI_VELOCITY = 100
    +MIDI_PIANO  = 0
    +
    +VOC_RATE    = 10000      # a time constant of exactly 156 (256 - 1,000,000 / rate)
    +VOC_HZ      = 660
    +VOC_AMP     = 60
    +VOC_VERSION = 0x010A
    +VOC_CHECK   = 0x1234
    +VOC_HEADER  = 26
    +
    +
    +def tone(rate, hz, ms, amp):
    +    count = rate * ms // 1000
    +    fade  = rate * FADE_MS // 1000
    +    out   = []
    +
    +    for i in range(count):
    +        level = min(1.0, i / fade, (count - 1 - i) / fade)
    +        out.append(int(round(amp * level * math.sin(2.0 * math.pi * hz * i / rate))))
    +
    +    return out
    +
    +
    +def silence(rate, ms):
    +    return [0] * (rate * ms // 1000)
    +
    +
    +def writeWav(path):
    +    frames = tone(WAV_RATE, SYNC_HZ, SYNC_MS, WAV_AMP) + silence(WAV_RATE, GAP_MS)
    +
    +    for hz in NOTES_HZ:
    +        frames += tone(WAV_RATE, hz, NOTE_MS, WAV_AMP) + silence(WAV_RATE, GAP_MS)
    +
    +    data = struct.pack("<%dh" % len(frames), *frames)
    +    head = b"RIFF" + struct.pack("HBBHH", words, 0, MOD_VOLUME, 0, words)
    +    header += (b"\0" * 22 + struct.pack(">HBBHH", 0, 0, 0, 0, 1)) * 30
    +    header += bytes([1, 127]) + bytes([0] * 128) + b"M.K."
    +
    +    pattern = bytearray(MOD_ROWS * MOD_CHANNELS * 4)
    +
    +    for n, period in enumerate(MOD_PERIODS):
    +        at = n * MOD_NOTE_GAP * MOD_CHANNELS * 4
    +        pattern[at:at + 4] = bytes([period >> 8, period & 0xFF, 0x10, 0])
    +
    +    # The row after the last note's eight: a pattern break ends the song.
    +    at = len(MOD_PERIODS) * MOD_NOTE_GAP * MOD_CHANNELS * 4
    +    pattern[at + 2:at + 4] = bytes([0x0D, 0])
    +
    +    sample = bytes((int(round(MOD_AMP * math.sin(2.0 * math.pi * i / MOD_SAMPLE))) & 0xFF) for i in range(MOD_SAMPLE))
    +
    +    with open(path, "wb") as f:
    +        f.write(header + bytes(pattern) + sample)
    +
    +
    +# A MIDI variable-length number: seven bits a byte, the high bit set on
    +# every byte but the last.
    +def vlq(n):
    +    out = bytes([n & 0x7F])
    +
    +    while n > 0x7F:
    +        n   >>= 7
    +        out   = bytes([0x80 | (n & 0x7F)]) + out
    +
    +    return out
    +
    +
    +def writeMid(path):
    +    title = MIDI_TITLE.encode("ascii")
    +    track = b"\x00\xFF\x03" + bytes([len(title)]) + title + bytes([0, 0xC0, MIDI_PIANO])
    +
    +    for note in MIDI_NOTES:
    +        track += bytes([0, 0x90, note, MIDI_VELOCITY]) + vlq(MIDI_DIVISION) + bytes([0x80, note, 0])
    +
    +    track += b"\x00\xFF\x2F\x00"
    +
    +    with open(path, "wb") as f:
    +        f.write(b"MThd" + struct.pack(">IHHH", 6, 0, 1, MIDI_DIVISION) + b"MTrk" + struct.pack(">I", len(track)) + track)
    +
    +
    +def writeVoc(path):
    +    samples = bytes(128 + v for v in tone(VOC_RATE, VOC_HZ, 1000, VOC_AMP))
    +    body    = bytes([256 - 1000000 // VOC_RATE, 0]) + samples
    +    head    = b"Creative Voice File\x1A" + struct.pack(" ended at + ms (due +) ok|FAIL
     //   MUSTEST:  PASS | FAIL 
     //   MUSTEST:  done
    -// Arguments: "mod" (the module; plays on the sound stream), "fm" (the
    -// MIDI file; must play on the FM chip) and "mpu" (the MIDI file; must
    -// play on the MPU-401 port - the script sets DVX.INI [audio] midi =
    -// mpu401).
    +// Arguments: "mod" (the module; plays on the sound stream), "modwave" (the
    +// module; must play on the wavetable synthesizer's voices), "fm" (the
    +// MIDI file; must play on the FM chip), "mpu" (the MIDI file; must play
    +// on the MPU-401 port - the script sets DVX.INI [audio] midi = mpu401),
    +// "wave" (the MIDI file; must play on the wavetable synthesizer, with
    +// the General MIDI SoundFont add-on staged; its samples loaded before the
    +// timing starts), "softwave" (as wave, on the SoundFont software
    +// synthesizer: the script sets DVX.INI [audio] midi = softwave), "rom"
    +// (as wave, on a SoundFont written here whose flute is the AWE's General
    +// MIDI ROM's own: it plays with nothing loaded, on an AWE only), "pat"
    +// (as wave, on a Gravis patch set written here: its flute a sine wave)
    +// and "load" (no music test: how much of the CPU the sound takes, a busy
    +// loop's work in two seconds with nothing playing (after a run that only
    +// warms up), then with eight
    +// looping sample voices (at the output's rate, then at half of it), then
    +// with a four-channel module (where it plays said), logged as
    +//   MUSTEST: load:   units, % of the CPU).
     
     #include "dvxApp.h"
     #include "audio/audio.h"
    @@ -52,6 +65,13 @@
     #define MUS_TITLE           "Music Test"
     #define MUS_MOD_FILE        "TEST\\MUSTEST.MOD"
     #define MUS_MID_FILE        "TEST\\MUSTEST.MID"
    +#define MUS_ROM_FILE        "TEST\\ROMTEST.SF2"
    +#define MUS_LOAD_MODE       "load"
    +#define MUS_PAT_DIR         "TEST\\MUSPAT"
    +#define MUS_PAT_MIDI_DIR    MUS_PAT_DIR "\\MIDI"
    +#define MUS_PAT_INI         MUS_PAT_DIR "\\" MUSIC_PATCH_SET_INI
    +#define MUS_PAT_FILE        MUS_PAT_MIDI_DIR "\\FLUTE.PAT"
    +#define MUS_PAT_MAP         "[Melodic Bank 0]\r\n73=FLUTE\r\n"
     #define MUS_LEAD_MS         1500    // the epoch is this far after the start
     #define MUS_SYNC_HZ         2000
     #define MUS_SYNC_MS         200
    @@ -97,6 +117,7 @@
     #define MOD_ROW_END         15      // A5 silenced and a pattern break: the song ends after this row
     #define MOD_AMPLITUDE       100
     #define MOD_TWO_PI          6.283185307
    +#define MOD_CHANNELS        4
     
     // MIDI: one track, 500 ticks a beat at 120 beats a minute (1 tick = 1 ms)
     #define MID_DIVISION        500
    @@ -107,17 +128,122 @@
     #define MID_E5              76
     #define MID_A5              81
     
    +// The load test (see the top)
    +#define LOAD_MS             2000
    +#define LOAD_TURNS          2000    // a unit of work: this many turns of a loop
    +#define LOAD_VOICES         8
    +#define LOAD_FRAMES         22050   // their sample: a second of a sine, mono 16-bit
    +#define LOAD_RATE           22050
    +#define LOAD_BITS           16
    +#define LOAD_CHANNELS       1
    +#define LOAD_PERIOD         50
    +#define LOAD_AMPLITUDE      12000
    +#define LOAD_VOLUME         100
    +#define LOAD_PERCENT        100
    +
    +// The ROM test's SoundFont 2: a preset (the flute, 0:73) over an
    +// instrument whose one zone loops the AWE ROM's flute (its addresses in
    +// the ROM, in frames; it sounds at key 89 and 28 cents)
    +#define ROM_FLUTE_START     0x2018D
    +#define ROM_FLUTE_END       0x20724
    +#define ROM_FLUTE_LOOP      0x206E2
    +#define ROM_FLUTE_LOOP_END  0x20720
    +#define ROM_RATE            44100
    +#define ROM_ROOT            89
    +#define ROM_CORRECTION      (-28)
    +#define ROM_SAMPLE_TYPE     0x8001  // a ROM sample, mono
    +#define SF_VERSION_MAJOR    2
    +#define SF_VERSION_MINOR    1
    +#define SF_GEN_INSTRUMENT   41
    +#define SF_GEN_SAMPLE_ID    53
    +#define SF_GEN_SAMPLE_MODES 54
    +#define SF_LOOP_ON          1
    +#define SF_NAME_BYTES       20
    +#define SF_PHDR_BYTES       38
    +#define SF_BAG_BYTES        4
    +#define SF_MOD_BYTES        10
    +#define SF_GEN_BYTES        4
    +#define SF_INST_BYTES       22
    +#define SF_SHDR_BYTES       46
    +#define SF_PHDR_PRESET      20
    +#define SF_PHDR_BAG         24
    +#define SF_INST_BAG         20
    +#define SF_SHDR_START       20
    +#define SF_SHDR_END         24
    +#define SF_SHDR_LOOP        28
    +#define SF_SHDR_LOOP_END    32
    +#define SF_SHDR_RATE        36
    +#define SF_SHDR_KEY         40
    +#define SF_SHDR_CORRECTION  41
    +#define SF_SHDR_TYPE        44
    +#define RIFF_ID_BYTES       4
    +#define RIFF_HEAD_BYTES     8       // an id and a length
    +#define RIFF_FORM_BYTES     12      // "RIFF", its length, "sfbk"
    +
    +// The patch set's flute: one 16-bit wave, ten cycles of a sine of 50
    +// frames at 22,050 Hz (441 Hz), looping from the third
    +#define PAT_HEAD_BYTES      129
    +#define PAT_SKIP_BYTES      (63 + 47)   // the instrument and layer headers
    +#define PAT_WAVE_BYTES      96
    +#define PAT_WAVES_AT        85
    +#define PAT_W_SIZE          8
    +#define PAT_W_LOOP_START    12
    +#define PAT_W_LOOP_END      16
    +#define PAT_W_RATE          20
    +#define PAT_W_LOW           22
    +#define PAT_W_HIGH          26
    +#define PAT_W_ROOT          30
    +#define PAT_W_BALANCE       36
    +#define PAT_W_MODES         55
    +#define PAT_W_SCALE_KEY     56
    +#define PAT_W_SCALE_FACTOR  58
    +#define PAT_MODES           0x05        // 16-bit, looping
    +#define PAT_RATE            22050
    +#define PAT_PERIOD          50
    +#define PAT_FRAMES          500
    +#define PAT_LOOP            100
    +#define PAT_ROOT_MILLIHZ    441000
    +#define PAT_LOW_MILLIHZ     8176        // every key
    +#define PAT_HIGH_MILLIHZ    12543853
    +#define PAT_PIVOT           60
    +#define PAT_SEMITONE        1024        // scale factor: a semitone a key
    +#define PAT_BALANCE         7
    +#define PAT_AMPLITUDE       16000
    +#define PAT_FRAME_BYTES     2
    +
    +// What a mode plays and where it must play; a bank it writes first and
    +// the name musicSetBank takes it by (NULL: DVX.INI's)
    +typedef struct {
    +    const char *name;
    +    int32_t     where;
    +    bool        module;
    +    bool      (*writeBank)(void);
    +    const char *bank;
    +} ModeT;
    +
     // ============================================================
     // Prototypes
     // ============================================================
     
    +static void     loadSay(const char *what, uint32_t units, uint32_t idle);
    +static void     loadTest(AudioCtxT *audio);
    +static uint32_t loadUnits(void);
    +static uint32_t makeLoadMod(uint8_t *buf);
     static uint32_t makeMid(uint8_t *buf);
     static uint32_t makeMod(uint8_t *buf);
    +static uint32_t makeRomFont(uint8_t *buf);
     static uint32_t midEvent(uint8_t *buf, uint32_t at, uint32_t delta, uint8_t status, uint8_t data1, uint8_t data2, bool twoData);
     static int64_t  nowMs(void);
     static void     onEnd(MusicT *music, void *userData);
    +static void     put16(uint8_t *p, uint32_t v);
    +static void     put32(uint8_t *p, uint32_t v);
     static void     putCell(uint8_t *pattern, int32_t row, int32_t channel, uint32_t period, uint8_t effect, uint8_t param);
    +static uint32_t riffChunk(uint8_t *buf, uint32_t at, const char *id, const uint8_t *body, uint32_t len);
    +static uint32_t riffListClose(uint8_t *buf, uint32_t at, uint32_t from);
    +static uint32_t riffListOpen(uint8_t *buf, uint32_t at, const char *type);
     static bool     writeFile(const char *path, const uint8_t *data, uint32_t size);
    +static bool     writePatchSet(void);
    +static bool     writeRomFont(void);
     int32_t         appMain(DxeAppContextT *ctx);
     
     SHELL_APP_DESCRIPTOR(
    @@ -128,10 +254,115 @@ SHELL_APP_DESCRIPTOR(
         .priority      = TS_PRIORITY_NORMAL
     );
     
    +static const ModeT sModes[] = {
    +    { "mod",      MUSIC_OUT_STREAM,   true,  NULL,          NULL         },
    +    { "modwave",  MUSIC_OUT_WAVE,     true,  NULL,          NULL         },
    +    { "fm",       MUSIC_OUT_FM,       false, NULL,          NULL         },
    +    { "mpu",      MUSIC_OUT_MPU401,   false, NULL,          NULL         },
    +    { "wave",     MUSIC_OUT_WAVE,     false, NULL,          NULL         },
    +    { "softwave", MUSIC_OUT_SOFTWAVE, false, NULL,          NULL         },
    +    { "rom",      MUSIC_OUT_WAVE,     false, writeRomFont,  MUS_ROM_FILE },
    +    { "pat",      MUSIC_OUT_WAVE,     false, writePatchSet, MUS_PAT_INI  }
    +};
    +
     static uint64_t sEpochUs = 0;
     static int64_t  sEndedMs = -1;
     
     
    +static void loadSay(const char *what, uint32_t units, uint32_t idle) {
    +    dvxLog("MUSTEST: load: %s %lu units, %lu%% of the CPU", what, (unsigned long)units, (unsigned long)(idle > units ? (idle - units) * LOAD_PERCENT / idle : 0));
    +}
    +
    +
    +// Nothing playing (after a first run that only warms up), the voices,
    +// the module (see the top).
    +static void loadTest(AudioCtxT *audio) {
    +    static int16_t pcm[LOAD_FRAMES];
    +    static uint8_t mod[MUS_BUF_MAX];
    +    AudioFormatT   fmt;
    +    AudioSampleT  *sample = NULL;
    +    MusicT        *music  = NULL;
    +    uint32_t       idle;
    +
    +    loadUnits();
    +    idle = loadUnits();
    +
    +    loadSay("nothing playing", idle, idle);
    +
    +    for (int32_t i = 0; i < LOAD_FRAMES; i++) {
    +        pcm[i] = (int16_t)(LOAD_AMPLITUDE * sin(MOD_TWO_PI * i / LOAD_PERIOD));
    +    }
    +
    +    fmt.bits     = LOAD_BITS;
    +    fmt.channels = LOAD_CHANNELS;
    +
    +    // At the output's rate, then at half of it (each output frame between
    +    // two of the sample's)
    +    for (int32_t half = 0; half < 2; half++) {
    +        fmt.rate = half ? LOAD_RATE / 2 : LOAD_RATE;
    +
    +        if (audioSampleCreate(audio, pcm, LOAD_FRAMES, &fmt, &sample) == AUDIO_OK) {
    +            for (int32_t v = 0; v < LOAD_VOICES; v++) {
    +                audioPlay(sample, LOAD_VOLUME, 0, true);
    +            }
    +
    +            loadSay(half ? "8 sample voices at half the rate" : "8 sample voices", loadUnits(), idle);
    +            audioStopAll(audio);
    +            audioSampleFree(sample);
    +        }
    +    }
    +
    +    if (writeFile(MUS_MOD_FILE, mod, makeLoadMod(mod)) && musicLoad(MUS_MOD_FILE, &music) == MUSIC_OK && musicPlay(music, true) == MUSIC_OK) {
    +        MusicInfoT info;
    +
    +        musicGetInfo(music, &info);
    +        loadSay(info.where == MUSIC_OUT_WAVE ? "a 4-channel module on the wavetable synthesizer" : "a 4-channel module mixed in software", loadUnits(), idle);
    +    }
    +
    +    musicFree(music);
    +    remove(MUS_MOD_FILE);
    +}
    +
    +
    +// Units of work done in LOAD_MS, the sound and music libraries pumped
    +// after each (as the shell's idle time would).
    +static uint32_t loadUnits(void) {
    +    volatile uint32_t sum   = 0;
    +    uint64_t          until = platformMicros() + (uint64_t)LOAD_MS * MUS_US_PER_MS;
    +    uint32_t          units = 0;
    +
    +    while (platformMicros() < until) {
    +        for (uint32_t i = 0; i < LOAD_TURNS; i++) {
    +            sum += i;
    +        }
    +
    +        audioPump();
    +        musicPump();
    +        units++;
    +    }
    +
    +    return units;
    +}
    +
    +
    +// The load test's module: makeMod's sample on all four channels from
    +// row 0 through the pattern, the song starting over.
    +static uint32_t makeLoadMod(uint8_t *buf) {
    +    static const uint32_t periods[MOD_CHANNELS] = { MOD_PERIOD_A4, MOD_PERIOD_E5, MOD_PERIOD_A5, MOD_PERIOD_E5 };
    +    uint32_t              len                   = makeMod(buf);
    +    uint8_t              *pattern               = buf + MOD_PATTERN_AT;
    +
    +    memset(pattern, 0, MOD_PATTERN_BYTES);
    +
    +    for (int32_t c = 0; c < MOD_CHANNELS; c++) {
    +        putCell(pattern, 0, c, periods[c], 0, 0);
    +    }
    +
    +    buf[MOD_RESTART] = 0;
    +    return len;
    +}
    +
    +
     // Flute: A4, E5, A5, 400 ms each, 300 ms apart; the track ends at 2000 ms.
     static uint32_t makeMid(uint8_t *buf) {
         const uint8_t header[] = { 'M', 'T', 'h', 'd', 0, 0, 0, 6, 0, 0, 0, 1, MID_DIVISION >> 8, MID_DIVISION & 0xFF, 'M', 'T', 'r', 'k', 0, 0, 0, 0 };
    @@ -199,6 +430,77 @@ static uint32_t makeMod(uint8_t *buf) {
     
     // One event: variable-length delta (under 2^14), status, one or two data
     // bytes (a meta event: type, then length 0).
    +// The ROM test's SoundFont (see ROM_FLUTE_*); no sample data of its own.
    +static uint32_t makeRomFont(uint8_t *buf) {
    +    uint8_t  rec[SF_SHDR_BYTES * 2];
    +    uint32_t at;
    +    uint32_t list;
    +
    +    memcpy(buf, "RIFF", RIFF_ID_BYTES);
    +    memcpy(buf + RIFF_HEAD_BYTES, "sfbk", RIFF_ID_BYTES);
    +    at = RIFF_FORM_BYTES;
    +
    +
    +    // INFO: the version; sdta: no samples
    +    put16(rec, SF_VERSION_MAJOR);
    +    put16(rec + 2, SF_VERSION_MINOR);
    +    list = at;
    +    at   = riffListOpen(buf, at, "INFO");
    +    at   = riffChunk(buf, at, "ifil", rec, SF_GEN_BYTES);
    +    at   = riffListClose(buf, at, list);
    +    list = at;
    +    at   = riffListOpen(buf, at, "sdta");
    +    at   = riffChunk(buf, at, "smpl", rec, 0);
    +    at   = riffListClose(buf, at, list);
    +
    +    // pdta: the preset and its terminal, the instrument, the sample
    +    list = at;
    +    at   = riffListOpen(buf, at, "pdta");
    +    memset(rec, 0, sizeof(rec));
    +    memcpy(rec, "Flute", 5);
    +    put16(rec + SF_PHDR_PRESET, MID_FLUTE);
    +    memcpy(rec + SF_PHDR_BYTES, "EOP", 3);
    +    put16(rec + SF_PHDR_BYTES + SF_PHDR_BAG, 1);
    +    at = riffChunk(buf, at, "phdr", rec, SF_PHDR_BYTES * 2);
    +    memset(rec, 0, sizeof(rec));
    +    put16(rec + SF_BAG_BYTES, 1);
    +    at = riffChunk(buf, at, "pbag", rec, SF_BAG_BYTES * 2);
    +    memset(rec, 0, sizeof(rec));
    +    at = riffChunk(buf, at, "pmod", rec, SF_MOD_BYTES);
    +    put16(rec, SF_GEN_INSTRUMENT);
    +    at = riffChunk(buf, at, "pgen", rec, SF_GEN_BYTES * 2);
    +    memset(rec, 0, sizeof(rec));
    +    memcpy(rec, "Flute", 5);
    +    memcpy(rec + SF_INST_BYTES, "EOI", 3);
    +    put16(rec + SF_INST_BYTES + SF_INST_BAG, 1);
    +    at = riffChunk(buf, at, "inst", rec, SF_INST_BYTES * 2);
    +    memset(rec, 0, sizeof(rec));
    +    put16(rec + SF_BAG_BYTES, 2);
    +    at = riffChunk(buf, at, "ibag", rec, SF_BAG_BYTES * 2);
    +    memset(rec, 0, sizeof(rec));
    +    at = riffChunk(buf, at, "imod", rec, SF_MOD_BYTES);
    +    put16(rec, SF_GEN_SAMPLE_MODES);
    +    put16(rec + 2, SF_LOOP_ON);
    +    put16(rec + SF_GEN_BYTES, SF_GEN_SAMPLE_ID);
    +    at = riffChunk(buf, at, "igen", rec, SF_GEN_BYTES * 3);
    +    memset(rec, 0, sizeof(rec));
    +    memcpy(rec, "flutec4", 7);
    +    put32(rec + SF_SHDR_START, ROM_FLUTE_START);
    +    put32(rec + SF_SHDR_END, ROM_FLUTE_END);
    +    put32(rec + SF_SHDR_LOOP, ROM_FLUTE_LOOP);
    +    put32(rec + SF_SHDR_LOOP_END, ROM_FLUTE_LOOP_END);
    +    put32(rec + SF_SHDR_RATE, ROM_RATE);
    +    rec[SF_SHDR_KEY]        = ROM_ROOT;
    +    rec[SF_SHDR_CORRECTION] = (uint8_t)(int8_t)ROM_CORRECTION;
    +    put16(rec + SF_SHDR_TYPE, ROM_SAMPLE_TYPE);
    +    memcpy(rec + SF_SHDR_BYTES, "EOS", 3);
    +    at = riffChunk(buf, at, "shdr", rec, SF_SHDR_BYTES * 2);
    +    at = riffListClose(buf, at, list);
    +    put32(buf + RIFF_ID_BYTES, at - RIFF_HEAD_BYTES);
    +    return at;
    +}
    +
    +
     static uint32_t midEvent(uint8_t *buf, uint32_t at, uint32_t delta, uint8_t status, uint8_t data1, uint8_t data2, bool twoData) {
         if (delta >= 0x80) {
             buf[at++] = (uint8_t)(0x80 | (delta >> 7));
    @@ -228,6 +530,18 @@ static void onEnd(MusicT *music, void *userData) {
     }
     
     
    +static void put16(uint8_t *p, uint32_t v) {
    +    p[0] = (uint8_t)v;
    +    p[1] = (uint8_t)(v >> 8);
    +}
    +
    +
    +static void put32(uint8_t *p, uint32_t v) {
    +    put16(p, v);
    +    put16(p + 2, v >> 16);
    +}
    +
    +
     static void putCell(uint8_t *pattern, int32_t row, int32_t channel, uint32_t period, uint8_t effect, uint8_t param) {
         uint8_t *cell = pattern + row * MOD_ROW_BYTES + channel * MOD_CELL_BYTES;
     
    @@ -238,6 +552,32 @@ static void putCell(uint8_t *pattern, int32_t row, int32_t channel, uint32_t per
     }
     
     
    +// A chunk (id, length, body) at at; where the next one goes (no SoundFont
    +// chunk here has an odd length).
    +static uint32_t riffChunk(uint8_t *buf, uint32_t at, const char *id, const uint8_t *body, uint32_t len) {
    +    memcpy(buf + at, id, RIFF_ID_BYTES);
    +    put32(buf + at + RIFF_ID_BYTES, len);
    +    memcpy(buf + at + RIFF_HEAD_BYTES, body, len);
    +    return at + RIFF_HEAD_BYTES + len;
    +}
    +
    +
    +// The LIST opened at from ends at at: its id and length.
    +static uint32_t riffListClose(uint8_t *buf, uint32_t at, uint32_t from) {
    +    memcpy(buf + from, "LIST", RIFF_ID_BYTES);
    +    put32(buf + from + RIFF_ID_BYTES, at - from - RIFF_HEAD_BYTES);
    +    return at;
    +}
    +
    +
    +// A LIST of type at at (its id and length written as it closes); where
    +// its first chunk goes.
    +static uint32_t riffListOpen(uint8_t *buf, uint32_t at, const char *type) {
    +    memcpy(buf + at + RIFF_HEAD_BYTES, type, RIFF_ID_BYTES);
    +    return at + RIFF_FORM_BYTES;
    +}
    +
    +
     static bool writeFile(const char *path, const uint8_t *data, uint32_t size) {
         FILE *f = fopen(path, "wb");
     
    @@ -252,17 +592,70 @@ static bool writeFile(const char *path, const uint8_t *data, uint32_t size) {
     }
     
     
    +// The patch set: its INI (program 73, the flute, is FLUTE) and the flute
    +// (see PAT_*).
    +static bool writePatchSet(void) {
    +    static uint8_t pat[PAT_HEAD_BYTES + PAT_SKIP_BYTES + PAT_WAVE_BYTES + PAT_FRAMES * PAT_FRAME_BYTES];
    +    uint8_t       *w    = pat + PAT_HEAD_BYTES + PAT_SKIP_BYTES;
    +    uint8_t       *data = w + PAT_WAVE_BYTES;
    +
    +    memset(pat, 0, sizeof(pat));
    +    memcpy(pat, "GF1PATCH110", 11);
    +    put16(pat + PAT_WAVES_AT, 1);
    +    put32(w + PAT_W_SIZE, PAT_FRAMES * PAT_FRAME_BYTES);
    +    put32(w + PAT_W_LOOP_START, PAT_LOOP * PAT_FRAME_BYTES);
    +    put32(w + PAT_W_LOOP_END, PAT_FRAMES * PAT_FRAME_BYTES);
    +    put16(w + PAT_W_RATE, PAT_RATE);
    +    put32(w + PAT_W_LOW, PAT_LOW_MILLIHZ);
    +    put32(w + PAT_W_HIGH, PAT_HIGH_MILLIHZ);
    +    put32(w + PAT_W_ROOT, PAT_ROOT_MILLIHZ);
    +    w[PAT_W_BALANCE] = PAT_BALANCE;
    +    w[PAT_W_MODES]   = PAT_MODES;
    +    put16(w + PAT_W_SCALE_KEY, PAT_PIVOT);
    +    put16(w + PAT_W_SCALE_FACTOR, PAT_SEMITONE);
    +
    +    for (int32_t i = 0; i < PAT_FRAMES; i++) {
    +        put16(data + i * PAT_FRAME_BYTES, (uint32_t)(uint16_t)(int16_t)(PAT_AMPLITUDE * sin(MOD_TWO_PI * i / PAT_PERIOD)));
    +    }
    +
    +    return platformMkdirRecursive(MUS_PAT_MIDI_DIR) == 0 && writeFile(MUS_PAT_INI, (const uint8_t *)MUS_PAT_MAP, (uint32_t)strlen(MUS_PAT_MAP)) && writeFile(MUS_PAT_FILE, pat, sizeof(pat));
    +}
    +
    +
    +static bool writeRomFont(void) {
    +    static uint8_t font[MUS_BUF_MAX];
    +
    +    return writeFile(MUS_ROM_FILE, font, makeRomFont(font));
    +}
    +
    +
     int32_t appMain(DxeAppContextT *ctx) {
         static uint8_t buf[MUS_BUF_MAX];
    -    const char    *mode     = ctx->args[0] ? ctx->args : "mod";
    -    bool           isMod    = strcmp(mode, "mod") == 0;
    -    int32_t        wantOut  = isMod ? MUSIC_OUT_STREAM : (strcmp(mode, "mpu") == 0 ? MUSIC_OUT_MPU401 : MUSIC_OUT_FM);
    -    const char    *path     = isMod ? MUS_MOD_FILE : MUS_MID_FILE;
    -    uint32_t       size     = isMod ? makeMod(buf) : makeMid(buf);
    +    const char    *mode     = ctx->args[0] ? ctx->args : sModes[0].name;
    +    const ModeT   *m        = &sModes[0];
         int32_t        problems = 0;
         MusicT        *music    = NULL;
         MusicInfoT     info;
     
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(sModes); i++) {
    +        m = strcmp(mode, sModes[i].name) == 0 ? &sModes[i] : m;
    +    }
    +
    +    if (strcmp(mode, MUS_LOAD_MODE) == 0) {
    +        AudioCtxT *audio = audioOpen();
    +
    +        if (audio) {
    +            loadTest(audio);
    +            audioClose(audio);
    +        }
    +
    +        dvxLog("MUSTEST: %s done", mode);
    +        return 0;
    +    }
    +
    +    const char *path = m->module ? MUS_MOD_FILE : MUS_MID_FILE;
    +    uint32_t    size = m->module ? makeMod(buf) : makeMid(buf);
    +
         AudioCtxT *audio = audioOpen();
     
         if (!audio || !writeFile(path, buf, size) || musicLoad(path, &music) != MUSIC_OK) {
    @@ -272,6 +665,18 @@ int32_t appMain(DxeAppContextT *ctx) {
         }
     
         musicSetEndFn(music, onEnd, NULL);
    +
    +    if (m->writeBank && (!m->writeBank() || musicSetBank(music, m->bank) != MUSIC_OK)) {
    +        dvxLog("MUSTEST: %s: the bank was refused", mode);
    +        problems++;
    +    }
    +
    +    // A wavetable music's samples go into the synthesizer as it is set up
    +    // (setting its position does that), before the timing starts
    +    if (m->where == MUSIC_OUT_WAVE || m->where == MUSIC_OUT_SOFTWAVE) {
    +        musicSetPosition(music, 0);
    +    }
    +
         sEpochUs = platformMicros() + (uint64_t)MUS_LEAD_MS * MUS_US_PER_MS;
     
         while (nowMs() < 0) {
    @@ -287,10 +692,10 @@ int32_t appMain(DxeAppContextT *ctx) {
     
         musicPlay(music, false);
         musicGetInfo(music, &info);
    -    dvxLog("MUSTEST: %s: %s on %d: %s", mode, isMod ? "module" : "MIDI", (int)info.where, info.output);
    +    dvxLog("MUSTEST: %s: %s on %d: %s", mode, m->module ? "module" : "MIDI", (int)info.where, info.output);
     
    -    if (info.where != wantOut) {
    -        dvxLog("MUSTEST: %s plays in the wrong place (%d, not %d)", mode, (int)info.where, (int)wantOut);
    +    if (info.where != m->where) {
    +        dvxLog("MUSTEST: %s plays in the wrong place (%d, not %d)", mode, (int)info.where, (int)m->where);
             problems++;
         }
     
    @@ -299,7 +704,7 @@ int32_t appMain(DxeAppContextT *ctx) {
             tsYield();
         }
     
    -    int64_t due = MUS_START_MS + (isMod ? MUS_MOD_MS : MUS_MIDI_MS);
    +    int64_t due = MUS_START_MS + (m->module ? MUS_MOD_MS : MUS_MIDI_MS);
         bool    ok  = sEndedMs >= due - MUS_EARLY_MS && sEndedMs <= due + MUS_LATE_MS;
     
         dvxLog("MUSTEST: %s ended at +%ld ms (due +%ld) %s", mode, (long)sEndedMs, (long)due, ok ? "ok" : "FAIL");
    diff --git a/src/test/dos/runDos.sh b/src/test/dos/runDos.sh
    index 80b22784..1db2c41a 100755
    --- a/src/test/dos/runDos.sh
    +++ b/src/test/dos/runDos.sh
    @@ -83,6 +83,7 @@ GOLDEN_DIR="${HERE}/golden"
     SHOTS_DIR="${DVX_ROOT}/screenshots"
     MASKS_DIR="${SHOTS_DIR}/masks"
     FIXTURES_DIR="${HERE}/fixtures"
    +MKMEDIA_PY="${HERE}/mkMedia.py"   # Media Player's test files (stageTree)
     NVR_DIR="${HERE}/nvr"
     IMGCMP="${DVX_ROOT}/obj/test/dos/imgCmp"
     BIN_DIR="${DVX_ROOT}/bin"
    @@ -239,13 +240,29 @@ NEED_JOYDRIVE=0
     # (--update-goldens writes it unless the recording still matches it);
     # the first frequency is the sync tone.
     # Without the line neither emulator has a sound card (DOSBox's card would
    -# stall with its sound output off) and both are silent.  "# INI: section key value"
    +# stall with its sound output off) and both are silent; a script with a
    +# "# AUDIO-CARD:" line (below) and no "# AUDIO:" one has that card and is
    +# recorded, but the recording is not compared (a measurement).  "# INI: section key value"
     # sets a key in the staged DVX.INI (e.g. "# INI: audio driver speaker").
     # "# SDK-GAMES" also stages the SDK's game templates (sdkGame, sdkBGame).
     # "# AUDIO-CARD: sb1|sb2|sbpro2" plays through an older Sound Blaster
     # instead (86Box sb / sb2.0 / sbprov2 at their defaults A220 I7 D1, the
     # sb2.0 on IRQ 5, BLASTER set in AUTOEXEC; DOSBox sb1 / sb2 / sbpro2,
    -# A220 I7 D1).
    +# A220 I7 D1); "wss" through 86Box's Windows Sound System (an AD1848 at
    +# 534h behind the configuration register at 530h, OPL3 at 388h; DOSBox
    +# has none: its scripts are # ONLY: 86box); "awe32" through 86Box's Sound
    +# Blaster AWE32 (its EMU8000 at 620h, 512 KB, the ROM from the pinned ROM
    +# set; BLASTER with E620; DOSBox has none); "gus" through a Gravis
    +# UltraSound alone (86Box gus at 220h, ULTRASND naming DMA 1 and IRQ 7 for
    +# its latches; DOSBox's at 240h, which sets ULTRASND itself); "gusmax"
    +# through 86Box's UltraSound MAX (the same, with its CS4231 codec at 32Ch
    +# once its control register turns it on; DOSBox has none: # ONLY: 86box);
    +# "sb16gus"
    +# through the Sound Blaster 16 with a GUS beside it for music (86Box's
    +# second card, moved to 240h, ULTRASND naming DMA 3 and IRQ 7; DOSBox's
    +# both at their defaults); "covox|disney|ston1" through
    +# a DAC on LPT1 (378h) and no sound card (86Box lpt_dac / dss /
    +# lpt_dac_stereo, DOSBox lpt_dac covox / disney / ston1, its filter off).
     # "# AUDIO-MIDI: fluidsynth" (86Box) connects the Sound Blaster 16's
     # MPU-401 port (330h) to 86Box's FluidSynth with the pinned General MIDI
     # sound font (toolchains/soundfont, src/test/music/installMusicRefs.sh),
    @@ -258,8 +275,14 @@ NEED_SOUND=0
     AUDIO_FREQS=""
     SOUND_TEXT=""
     BOX86_SNDCARD="none"
    +BOX86_SNDCARD2="none"
    +BOX86_GUS_BASE=""
    +BOX86_LPT1=0
    +BOX86_LPT1_DEVICE="none"
     DOSBOX_NOSOUND="true"
     DOSBOX_SBTYPE="none"
    +DOSBOX_LPT_DAC="none"
    +DOSBOX_GUS="false"
     AUDIO_ENV=()
     INI_SETS=()
     DOSBOX_MAPPER_LINE=""
    @@ -319,7 +342,9 @@ SETUPLOG_EXPECTS=()
     # text file from fixtures/ into the staged DVX tree (DOS line ends).
     # "# PACKAGE: fixture path" builds an add-on package (.DPK) there with the
     # package tool from fixtures/: its PACKAGE.INI and the files
    -# beside it.
    +# beside it.  "# ADDON: name" puts a DVX add-on's files, as the build made
    +# them in obj/addons/, into the staged tree (names in lower case,
    +# as bin/ has them), as if it were installed.
     # "# WIDGET: fixture" installs a control project fixture (a control file
     # written in BASIC, built like every fixture) as WIDGETS\\
     # .WGT, so DVX loads it at boot.
    @@ -339,6 +364,7 @@ SECLINK_PIDS=()
     PROXY_EXPECTS=()
     STAGES=()
     PACKAGES=()
    +ADDONS=()
     WIDGET_FIXTURES=()
     BROKEN_FIXTURES=()
     BOX86_SERIAL2=0
    @@ -634,6 +660,10 @@ stageTree() {
             cp -r "${FIXTURES_DIR}/files/." "${dir}/DVX/test/"
         fi
     
    +    # Media Player's music and sound files (player.scr), made here rather
    +    # than kept as binaries
    +    python3 "${MKMEDIA_PY}" "${dir}/DVX/test"
    +
         local project
         local fixture
     
    @@ -723,6 +753,25 @@ PY
             fi
         done
     
    +    # "# ADDON: name": a built add-on's files, as if installed
    +    local addon
    +    local file
    +
    +    for addon in "${ADDONS[@]}"; do
    +        if [ ! -d "${DVX_ROOT}/obj/addons/${addon}" ]; then
    +            echo "runDos:   add-on ${addon} is not built (obj/addons/${addon})"
    +            continue
    +        fi
    +
    +        while IFS= read -r file; do
    +            local lower
    +
    +            lower="$(printf '%s' "${file}" | tr '[:upper:]' '[:lower:]')"
    +            mkdir -p "$(dirname "${dir}/DVX/${lower}")"
    +            cp "${DVX_ROOT}/obj/addons/${addon}/${file}" "${dir}/DVX/${lower}"
    +        done < <(cd "${DVX_ROOT}/obj/addons/${addon}" && find . -type f | sed 's|^\./||')
    +    done
    +
         # "# WIDGET: fixture": the fixture's control file installed in WIDGETS
         local widget
     
    @@ -890,6 +939,7 @@ joystick_type = ${JOY_BOX86}
     
     [Sound]
     sndcard = ${BOX86_SNDCARD}
    +sndcard2 = ${BOX86_SNDCARD2}
     midi_device = ${BOX86_MIDI}
     
     [Storage controllers]
    @@ -904,7 +954,8 @@ serial1_enabled = 1
     serial1_device = pipe
     serial2_enabled = ${BOX86_SERIAL2}
     serial2_device = pipe
    -lpt1_enabled = 0
    +lpt1_enabled = ${BOX86_LPT1}
    +lpt1_device = ${BOX86_LPT1_DEVICE}
     
     [Named Pipe (COM) #1]
     path = $3
    @@ -930,6 +981,14 @@ reverb = 0
     EOF
         fi
     
    +    if [ -n "${BOX86_GUS_BASE}" ]; then
    +        cat >> "${vm}/86box.cfg" <> "${vm}/86box.cfg" </dev/null || true
    -        find "${RUN}/stage" -maxdepth 2 -ipath "*/${SETUP_SHOTS_DIR}/*.bmp" -exec cp {} "${RUN}/out/" \; 2>/dev/null || true
    +        find "${RUN}/stage" -maxdepth 2 -ipath "${RUN}/stage/${SETUP_SHOTS_DIR}/*.bmp" -exec cp {} "${RUN}/out/" \; 2>/dev/null || true
         fi
     
         local expect
    @@ -1372,7 +1438,10 @@ runDosbox() {
         for expect in "${EXPECTS[@]}"; do
             find "${RUN}/stage/DVX" -ipath "${RUN}/stage/DVX/${expect%% *}" -exec cp {} "${RUN}/out/" \; 2>/dev/null || true
         done
    -    find "${RUN}/stage/DVX" -maxdepth 2 -ipath '*/test/*.bmp' -exec cp {} "${RUN}/out/" \; 2>/dev/null || true
    +    # -ipath matches the whole path, which has a test folder of its own
    +    # (obj/test/dos): the pattern starts at the staged DVX folder, so only
    +    # DVX\TEST's pictures are shots (not SYSTEM\DOSPROG.BMP).
    +    find "${RUN}/stage/DVX" -maxdepth 2 -ipath "${RUN}/stage/DVX/test/*.bmp" -exec cp {} "${RUN}/out/" \; 2>/dev/null || true
     }
     
     
    @@ -1546,7 +1615,7 @@ checkAudio() {
         local rec="${RUN}/audio.wav"
         local rc
     
    -    if [ "${NEED_SOUND}" -eq 0 ]; then
    +    if [ -z "${AUDIO_FREQS}" ]; then
             return 0
         fi
     
    @@ -1827,6 +1896,12 @@ for script in "${SCRIPTS[@]}"; do
             PACKAGES+=("${expect}")
         done < <(sed -n 's/^#[[:space:]]*PACKAGE:[[:space:]]*\([^[:space:]]*\)[[:space:]]\{1,\}\([^[:space:]]*\).*$/\1 \2/p' "${script}")
     
    +    ADDONS=()
    +
    +    while IFS= read -r expect; do
    +        ADDONS+=("${expect}")
    +    done < <(sed -n 's/^#[[:space:]]*ADDON:[[:space:]]*\([^[:space:]]*\).*$/\1/p' "${script}")
    +
         WIDGET_FIXTURES=()
     
         while IFS= read -r expect; do
    @@ -1865,16 +1940,26 @@ for script in "${SCRIPTS[@]}"; do
         NEED_SOUND=0
         SOUND_TEXT=""
         BOX86_SNDCARD="none"
    +    BOX86_SNDCARD2="none"
    +    BOX86_GUS_BASE=""
    +    BOX86_LPT1=0
    +    BOX86_LPT1_DEVICE="none"
         BOX86_MIDI="none"
         DOSBOX_NOSOUND="true"
         DOSBOX_SBTYPE="none"
    +    DOSBOX_LPT_DAC="none"
    +    DOSBOX_GUS="false"
     
    -    if [ -n "${AUDIO_FREQS}" ]; then
    +    # A card without "# AUDIO:" is recorded but not compared
    +    AUDIO_CARD="$(sed -n 's/^#[[:space:]]*AUDIO-CARD:[[:space:]]*\([a-z0-9]*\).*$/\1/p' "${script}" | head -n 1)"
    +
    +    if [ -n "${AUDIO_FREQS}" ] || [ -n "${AUDIO_CARD}" ]; then
             NEED_SOUND=1
             DOSBOX_NOSOUND="false"
     
             # 86Box card, the BLASTER value that matches it, DOSBox sbtype
    -        case "$(sed -n 's/^#[[:space:]]*AUDIO-CARD:[[:space:]]*\([a-z0-9]*\).*$/\1/p' "${script}" | head -n 1)" in
    +
    +        case "${AUDIO_CARD}" in
                 sb1)
                     BOX86_SNDCARD="sb"
                     SOUND_TEXT="SET BLASTER=A220 I7 D1 T1"
    @@ -1890,6 +1975,40 @@ for script in "${SCRIPTS[@]}"; do
                     SOUND_TEXT="SET BLASTER=A220 I7 D1 T4"
                     DOSBOX_SBTYPE="sbpro2"
                     ;;
    +            wss)
    +                BOX86_SNDCARD="wss"
    +                ;;
    +            awe32)
    +                BOX86_SNDCARD="sbawe32"
    +                SOUND_TEXT="SET BLASTER=A220 I5 D1 H5 P330 E620 T6"
    +                ;;
    +            gus)
    +                BOX86_SNDCARD="gus"
    +                SOUND_TEXT="SET ULTRASND=220,1,1,7,7"
    +                DOSBOX_GUS="true"
    +                ;;
    +            gusmax)
    +                BOX86_SNDCARD="gusmax"
    +                SOUND_TEXT="SET ULTRASND=220,1,1,7,7"
    +                ;;
    +            sb16gus)
    +                BOX86_SNDCARD="sb16"
    +                BOX86_SNDCARD2="gus"
    +                BOX86_GUS_BASE="0240"
    +                SOUND_TEXT="SET BLASTER=A220 I5 D1 H5 T6"$'\n'"SET ULTRASND=240,3,3,7,7"
    +                DOSBOX_SBTYPE="sb16"
    +                DOSBOX_GUS="true"
    +                ;;
    +            covox|disney|ston1)
    +                BOX86_LPT1=1
    +                DOSBOX_LPT_DAC="${AUDIO_CARD}"
    +
    +                case "${AUDIO_CARD}" in
    +                    covox) BOX86_LPT1_DEVICE="lpt_dac" ;;
    +                    disney) BOX86_LPT1_DEVICE="dss" ;;
    +                    *) BOX86_LPT1_DEVICE="lpt_dac_stereo" ;;
    +                esac
    +                ;;
                 *)
                     BOX86_SNDCARD="sb16"
                     SOUND_TEXT="SET BLASTER=A220 I5 D1 H5 T6"
    diff --git a/src/test/dos/scripts/apps.scr b/src/test/dos/scripts/apps.scr
    index 5c411f9d..e8807d95 100644
    --- a/src/test/dos/scripts/apps.scr
    +++ b/src/test/dos/scripts/apps.scr
    @@ -97,6 +97,13 @@ SHOT welcome
     KEY Alt+F4
     WAITNOWIN "Welcome to DVX"
     
    +LAUNCH apps/kpunch/player/player.app
    +WAITWIN "Media Player"
    +WAIT 10
    +SHOT player
    +KEY Alt+F4
    +WAITNOWIN "Media Player"
    +
     LAUNCH apps/kpunch/dvxbasic/dvxbasic.app
     WAITWIN "DVX BASIC*"
     WAIT 20
    diff --git a/src/test/dos/scripts/awemod.scr b/src/test/dos/scripts/awemod.scr
    new file mode 100644
    index 00000000..372642ca
    --- /dev/null
    +++ b/src/test/dos/scripts/awemod.scr
    @@ -0,0 +1,20 @@
    +# awemod.scr -- a tracker module on the Sound Blaster AWE32's EMU8000,
    +# recorded.
    +#
    +# 86Box's Sound Blaster AWE32 (# AUDIO-CARD).  TEST\MUSTEST.APP writes a
    +# MOD (a sine sample: A4, E5, A5, 360 ms each, 360 ms apart) and plays it
    +# after a 2000 Hz sync tone; it must play on the EMU8000's voices (its
    +# sample in the EMU8000's memory, libxmp's frames played by the music
    +# timer) and end on time (# LOG).  The recording must match the golden
    +# (# AUDIO).  DOSBox has no AWE32.
    +# ONLY: 86box
    +# AUDIO-CARD: awe32
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: modwave: module on 5: Sound Blaster AWE EMU8000 at 620h
    +# LOG: MUSTEST: modwave PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP modwave
    +WAITLOG "MUSTEST: modwave done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/awerom.scr b/src/test/dos/scripts/awerom.scr
    new file mode 100644
    index 00000000..decc42d1
    --- /dev/null
    +++ b/src/test/dos/scripts/awerom.scr
    @@ -0,0 +1,21 @@
    +# awerom.scr -- a MIDI file on the Sound Blaster AWE32's sample ROM,
    +# recorded.
    +#
    +# 86Box's Sound Blaster AWE32 (# AUDIO-CARD) and no SoundFont add-on.
    +# TEST\MUSTEST.APP writes a MIDI file (a flute: A4, E5, A5, 400 ms each,
    +# 300 ms apart) and a SoundFont whose flute is the AWE's General MIDI
    +# ROM's own (ROM samples, no sample data), and plays it after a 2000 Hz
    +# sync tone: it must play on the EMU8000 from its ROM, with nothing
    +# loaded into its memory, and end on time (# LOG).  The recording must
    +# match the golden (# AUDIO).  DOSBox has no AWE32.
    +# ONLY: 86box
    +# AUDIO-CARD: awe32
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: rom: MIDI on 5: Sound Blaster AWE EMU8000 at 620h
    +# LOG: MUSTEST: rom PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP rom
    +WAITLOG "MUSTEST: rom done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/awewave.scr b/src/test/dos/scripts/awewave.scr
    new file mode 100644
    index 00000000..c10766e9
    --- /dev/null
    +++ b/src/test/dos/scripts/awewave.scr
    @@ -0,0 +1,23 @@
    +# awewave.scr -- a MIDI file on the Sound Blaster AWE32's EMU8000,
    +# recorded.
    +#
    +# 86Box's Sound Blaster AWE32 (# AUDIO-CARD) with the DVX General MIDI
    +# SoundFont add-on installed (# ADDON).  awe.drv finds the EMU8000
    +# through BLASTER's E, sets it up and sizes its memory; TEST\MUSTEST.APP
    +# writes a MIDI file (a flute: A4, E5, A5, 400 ms each, 300 ms apart) and
    +# plays it after a 2000 Hz sync tone; it must play on the EMU8000 (the
    +# flute's samples loaded into its memory, its own envelopes, filter and
    +# effects) and end on time (# LOG).  The recording must match the golden
    +# (# AUDIO).  DOSBox has no AWE32.
    +# ONLY: 86box
    +# AUDIO-CARD: awe32
    +# ADDON: dvxgm
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: wave: MIDI on 5: Sound Blaster AWE EMU8000 at 620h
    +# LOG: MUSTEST: wave PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP wave
    +WAITLOG "MUSTEST: wave done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/dosprog.scr b/src/test/dos/scripts/dosprog.scr
    new file mode 100644
    index 00000000..98e175aa
    --- /dev/null
    +++ b/src/test/dos/scripts/dosprog.scr
    @@ -0,0 +1,34 @@
    +# dosprog.scr -- a DOS program run from DVX: DVX steps aside and comes back.
    +#
    +# TEST\DOSRUN.APP (fixtures/dosrun) asks for TEST\DOSPROG.BAT with
    +# ShellRunDos.  DVX names it among the programs that close first ("Run
    +# DOS Program"; Enter answers Yes), lets the screen and its hardware go,
    +# and runs the batch file through the command interpreter in its own
    +# folder: it writes TEST\DOSRAN.TXT (# EXPECT).  DVX comes back (the log
    +# says so), sets its mode again and repaints (VIDEOCHECK: the screen is
    +# what DVX drew; dp-back), and the keyboard,
    +# the mouse, and the timer work again (F1 opens the help, its window is
    +# closed, a second screenshot).
    +# STAGE: dosrun/dosprog.bat test/DOSPROG.BAT
    +# EXPECT: TEST/DOSRAN.TXT dosran.txt
    +TIMEOUT 60
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/DOSRUN.APP
    +WAITWIN "Run DOS Program"
    +WAIT 10
    +SHOT dp-ask
    +KEY Enter
    +WAITLOG "DOS: back from"
    +WAITNOWIN "DOS Run"
    +WAIT 20
    +VIDEOCHECK
    +SHOT dp-back
    +KEY F1
    +WAITWIN "* - DVX Help"
    +WAITMS 500
    +KEY Alt+F4
    +WAITNOWIN "* - DVX Help"
    +WAIT 10
    +SHOT dp-after
    +QUIT
    diff --git a/src/test/dos/scripts/endbas.scr b/src/test/dos/scripts/endbas.scr
    new file mode 100644
    index 00000000..36589166
    --- /dev/null
    +++ b/src/test/dos/scripts/endbas.scr
    @@ -0,0 +1,22 @@
    +# endbas.scr -- End Task on a BASIC program: the BASIC runtime's kill
    +# hook ends the program's VM, forms, and module while its window is
    +# still there (the log says so), then the shell unloads it and DVX goes
    +# on.  The Task Manager lists Program Manager and Clock; Down selects
    +# Clock, Alt+E ends it.
    +# LOG: BASIC: 'Clock' ended by force; its runtime freed
    +TIMEOUT 90
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH apps/kpunch/clock/clock.app
    +WAITWIN "Clock"
    +WAIT 10
    +KEY Ctrl+Esc
    +WAITWIN "Task Manager"
    +KEY Down
    +WAIT 10
    +KEY Alt+E
    +WAITNOWIN "Clock"
    +WAIT 10
    +ASSERTWIN "Program Manager"
    +ASSERTWIN "Task Manager"
    +QUIT
    diff --git a/src/test/dos/scripts/f1.scr b/src/test/dos/scripts/f1.scr
    index 56f7c71f..fcb7adc1 100644
    --- a/src/test/dos/scripts/f1.scr
    +++ b/src/test/dos/scripts/f1.scr
    @@ -169,5 +169,18 @@ WAIT 10
     KEY Alt+F4
     WAITNOWIN "Welcome to DVX"
     
    +LAUNCH apps/kpunch/player/player.app
    +WAITWIN "Media Player"
    +WAIT 10
    +KEY F1
    +WAITWIN "* - DVX Help"
    +WAIT 20
    +SHOT h-player
    +KEY Alt+F4
    +WAITNOWIN "* - DVX Help"
    +WAIT 10
    +KEY Alt+F4
    +WAITNOWIN "Media Player"
    +
     ASSERTWIN "Program Manager"
     QUIT
    diff --git a/src/test/dos/scripts/gus.scr b/src/test/dos/scripts/gus.scr
    new file mode 100644
    index 00000000..0ceb231e
    --- /dev/null
    +++ b/src/test/dos/scripts/gus.scr
    @@ -0,0 +1,17 @@
    +# gus.scr -- sound through a Gravis UltraSound, recorded.
    +#
    +# A Gravis UltraSound alone (# AUDIO-CARD: no Sound Blaster), found by
    +# gus.drv through ULTRASND: digital sound on two of its voices from a
    +# ring in its memory, refilled by DMA from its interrupts.
    +# TEST\SNDTEST.APP plays its "full" timeline; every sound must end on
    +# time and the recording must match the golden.
    +# AUDIO-CARD: gus
    +# AUDIO: 2000 440 880 660 330 1320
    +# LOG: SNDTEST: full: device gus: Gravis UltraSound at
    +# LOG: SNDTEST: full PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/SNDTEST.APP full
    +WAITLOG "SNDTEST: full done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/gusmod.scr b/src/test/dos/scripts/gusmod.scr
    new file mode 100644
    index 00000000..14e56c1c
    --- /dev/null
    +++ b/src/test/dos/scripts/gusmod.scr
    @@ -0,0 +1,19 @@
    +# gusmod.scr -- a tracker module on the Gravis UltraSound's own voices,
    +# recorded.
    +#
    +# A Gravis UltraSound alone (# AUDIO-CARD).  TEST\MUSTEST.APP writes a
    +# MOD (a sine sample: A4, E5, A5, 360 ms each, 360 ms apart) and plays it
    +# after a 2000 Hz sync tone; it must play on the GUS's voices (its sample
    +# in the GUS's memory, libxmp's frames played by the music timer, nothing
    +# mixed in software) and end on time (# LOG).  The recording must match
    +# the golden (# AUDIO).
    +# AUDIO-CARD: gus
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: modwave: module on 5: Gravis UltraSound wavetable at
    +# LOG: MUSTEST: modwave PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP modwave
    +WAITLOG "MUSTEST: modwave done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/guspat.scr b/src/test/dos/scripts/guspat.scr
    new file mode 100644
    index 00000000..18422d98
    --- /dev/null
    +++ b/src/test/dos/scripts/guspat.scr
    @@ -0,0 +1,19 @@
    +# guspat.scr -- a MIDI file on a Gravis UltraSound patch set, recorded.
    +#
    +# A Gravis UltraSound alone (# AUDIO-CARD) and no SoundFont add-on.
    +# TEST\MUSTEST.APP writes a MIDI file (a flute: A4, E5, A5, 400 ms each,
    +# 300 ms apart) and a Gravis patch set (ULTRASND.INI naming FLUTE.PAT for
    +# program 73, a looping 441 Hz sine wave) and plays the file on it after
    +# a 2000 Hz sync tone: it must play on the GUS's own voices (the patch's
    +# wave loaded into its memory, tuned to A4) and end on time (# LOG).  The
    +# recording must match the golden (# AUDIO).
    +# AUDIO-CARD: gus
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: pat: MIDI on 5: Gravis UltraSound wavetable at
    +# LOG: MUSTEST: pat PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP pat
    +WAITLOG "MUSTEST: pat done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/guswave.scr b/src/test/dos/scripts/guswave.scr
    new file mode 100644
    index 00000000..d4057576
    --- /dev/null
    +++ b/src/test/dos/scripts/guswave.scr
    @@ -0,0 +1,20 @@
    +# guswave.scr -- a MIDI file on the Gravis UltraSound's wavetable
    +# synthesizer, recorded.
    +#
    +# A Gravis UltraSound alone (# AUDIO-CARD) with the DVX General MIDI
    +# SoundFont add-on installed (# ADDON).  TEST\MUSTEST.APP writes a MIDI
    +# file (a flute: A4, E5, A5, 400 ms each, 300 ms apart) and plays it after
    +# a 2000 Hz sync tone; it must play on the GUS's own voices (the flute's
    +# samples loaded into its memory) and end on time (# LOG).  The recording
    +# must match the golden (# AUDIO).
    +# AUDIO-CARD: gus
    +# ADDON: dvxgm
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: wave: MIDI on 5: Gravis UltraSound wavetable at
    +# LOG: MUSTEST: wave PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP wave
    +WAITLOG "MUSTEST: wave done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/guswt.scr b/src/test/dos/scripts/guswt.scr
    new file mode 100644
    index 00000000..9447f924
    --- /dev/null
    +++ b/src/test/dos/scripts/guswt.scr
    @@ -0,0 +1,23 @@
    +# guswt.scr -- a MIDI file on a Gravis UltraSound beside a Sound Blaster,
    +# recorded.
    +#
    +# A Sound Blaster 16 and a Gravis UltraSound (# AUDIO-CARD: the GUS at
    +# 240h) with the DVX General MIDI SoundFont add-on installed (# ADDON).
    +# Digital sound plays on the Sound Blaster, so the GUS is only the
    +# wavetable synthesizer (guswt.drv, all its voices for music).
    +# TEST\MUSTEST.APP writes a MIDI file (a flute: A4, E5, A5, 400 ms each,
    +# 300 ms apart) and plays it after a 2000 Hz sync tone; it must play on
    +# the GUS's voices and end on time (# LOG).  The recording must match the
    +# golden (# AUDIO).
    +# AUDIO-CARD: sb16gus
    +# ADDON: dvxgm
    +# AUDIO: 2000 440 659 880
    +# LOG: Audio: using 'sb': Sound Blaster 16
    +# LOG: MUSTEST: wave: MIDI on 5: Gravis UltraSound wavetable at 240h
    +# LOG: MUSTEST: wave PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP wave
    +WAITLOG "MUSTEST: wave done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/lptcovox.scr b/src/test/dos/scripts/lptcovox.scr
    new file mode 100644
    index 00000000..5d8b3d60
    --- /dev/null
    +++ b/src/test/dos/scripts/lptcovox.scr
    @@ -0,0 +1,18 @@
    +# lptcovox.scr -- sound through a Covox Speech Thing, recorded.
    +#
    +# No sound card; a Covox on LPT1 (# AUDIO-CARD).  A Covox cannot be
    +# found, so DVX.INI names it (lptDac covox); lpt.drv writes a sample to
    +# the port on every timer interrupt at the mixing rate.  TEST\SNDTEST.APP
    +# plays its "full" timeline; every sound must end on time and the
    +# recording must match the golden.
    +# AUDIO-CARD: covox
    +# AUDIO: 2000 440 880 660 330 1320
    +# LOG: SNDTEST: full: device lpt: Covox at 378h
    +# LOG: SNDTEST: full PASS
    +# INI: audio lptDac covox
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/SNDTEST.APP full
    +WAITLOG "SNDTEST: full done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/lptdss.scr b/src/test/dos/scripts/lptdss.scr
    new file mode 100644
    index 00000000..c5dd1523
    --- /dev/null
    +++ b/src/test/dos/scripts/lptdss.scr
    @@ -0,0 +1,17 @@
    +# lptdss.scr -- sound through a Disney Sound Source, recorded.
    +#
    +# No sound card; a Disney Sound Source on LPT1 (# AUDIO-CARD), which
    +# lpt.drv finds by itself (lptDac auto: its FIFO fills and drains) at the
    +# port the BIOS lists, and feeds from the timer at its own 7 kHz.
    +# TEST\SNDTEST.APP plays its "full" timeline; every sound must end on
    +# time and the recording must match the golden.
    +# AUDIO-CARD: disney
    +# AUDIO: 2000 440 880 660 330 1320
    +# LOG: SNDTEST: full: device lpt: Disney Sound Source at 378h
    +# LOG: SNDTEST: full PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/SNDTEST.APP full
    +WAITLOG "SNDTEST: full done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/lptst86.scr b/src/test/dos/scripts/lptst86.scr
    new file mode 100644
    index 00000000..b1e24b1a
    --- /dev/null
    +++ b/src/test/dos/scripts/lptst86.scr
    @@ -0,0 +1,20 @@
    +# lptst86.scr -- sound through 86Box's stereo LPT DAC, recorded.
    +#
    +# No sound card; 86Box's "Stereo LPT DAC" on LPT1 (# AUDIO-CARD ston1),
    +# which takes a sample for the left channel with STROBE high and for the
    +# right with it low: the Stereo-on-1's writes play on it, but it has no
    +# D7 to BUSY wire to be found by, so DVX.INI names it (lptDac stereo).
    +# TEST\SNDTEST.APP plays its "full" timeline; every sound must end on
    +# time and the recording must match the golden.
    +# ONLY: 86box
    +# AUDIO-CARD: ston1
    +# AUDIO: 2000 440 880 660 330 1320
    +# LOG: SNDTEST: full: device lpt: Stereo-on-1 at 378h
    +# LOG: SNDTEST: full PASS
    +# INI: audio lptDac stereo
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/SNDTEST.APP full
    +WAITLOG "SNDTEST: full done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/lptston.scr b/src/test/dos/scripts/lptston.scr
    new file mode 100644
    index 00000000..3f02d6b6
    --- /dev/null
    +++ b/src/test/dos/scripts/lptston.scr
    @@ -0,0 +1,19 @@
    +# lptston.scr -- sound through a Stereo-on-1, recorded.
    +#
    +# No sound card; a Stereo-on-1 on LPT1 (# AUDIO-CARD), which lpt.drv
    +# finds by itself (lptDac auto: D7 wired back to BUSY) and feeds a frame,
    +# left then right, on every timer interrupt.  DOSBox only: 86Box's stereo
    +# LPT DAC is a different device (lptst86.scr).  TEST\SNDTEST.APP plays
    +# its "full" timeline; every sound must end on time and the recording
    +# must match the golden.
    +# ONLY: dosbox
    +# AUDIO-CARD: ston1
    +# AUDIO: 2000 440 880 660 330 1320
    +# LOG: SNDTEST: full: device lpt: Stereo-on-1 at 378h
    +# LOG: SNDTEST: full PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/SNDTEST.APP full
    +WAITLOG "SNDTEST: full done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/midiin.scr b/src/test/dos/scripts/midiin.scr
    new file mode 100644
    index 00000000..3835a72f
    --- /dev/null
    +++ b/src/test/dos/scripts/midiin.scr
    @@ -0,0 +1,28 @@
    +# midiin.scr -- a MIDI keyboard into the Sound Recorder: DVX.INI [audio]
    +# midiIn = mpu401 opens the Sound Blaster 16's MPU-401 port for input.
    +# MIDI Keyboard (Options, K) can be chosen; Record opens the port's
    +# input (the log).  Nothing comes in on an emulator, so Stop closes the
    +# input with nothing lost and leaves no recording to save (rc-midi: the
    +# window after Stop, the format MIDI).
    +# AUDIO-CARD: sb16
    +# INI: audio midiIn mpu401
    +# LOG: Audio: MIDI input open
    +# LOG: Audio: MIDI input closed (0 bytes lost)
    +TIMEOUT 90
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH apps/kpunch/recorder/recorder.app
    +WAITWIN "New recording - Sound Recorder"
    +WAIT 10
    +KEY Alt+O
    +KEY K
    +KEY Alt+R
    +WAITLOG "Audio: MIDI input open"
    +WAITMS 1000
    +KEY Alt+T
    +WAITLOG "Audio: MIDI input closed"
    +WAIT 10
    +SHOT rc-midi
    +KEY Alt+F4
    +WAITNOWIN "*Sound Recorder"
    +QUIT
    diff --git a/src/test/dos/scripts/player.scr b/src/test/dos/scripts/player.scr
    new file mode 100644
    index 00000000..69d0d418
    --- /dev/null
    +++ b/src/test/dos/scripts/player.scr
    @@ -0,0 +1,46 @@
    +# player.scr -- Media Player plays a WAV file, a module, a MIDI file, and
    +# a VOC file.
    +#
    +# The files are written into TEST\ as the run is staged (mkMedia.py).
    +# Started with PLAYER.WAV, the player streams it from the disk to its end:
    +# a 2000 Hz sync tone, then A4, E5, and A5, recorded and compared with the
    +# golden (# AUDIO).  It stops there (the last track, Repeat Off) and is
    +# captured.  The volume slider (Alt+U) then goes to 0, so the recording
    +# holds the WAV only, and each of the other files is handed to the running
    +# player by another start (NewArgs): it is added to the list and plays,
    +# Ctrl+S stops it, and the window is captured (its title: the module's
    +# own, the MIDI file's track name, the VOC's file name; what it is and
    +# where it plays).
    +# AUDIO: 2000 440 659 880
    +TIMEOUT 60
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +
    +LAUNCH apps/kpunch/player/player.app TEST/PLAYER.WAV
    +WAITWIN "PLAYER.WAV - Media Player"
    +WAITMS 6000
    +SHOT p-wav
    +KEY Alt+U
    +KEY Home
    +
    +LAUNCH apps/kpunch/player/player.app TEST/PLAYER.MOD
    +WAITWIN "Player Test Module - Media Player"
    +KEY Ctrl+S
    +WAIT 10
    +SHOT p-mod
    +
    +LAUNCH apps/kpunch/player/player.app TEST/PLAYER.MID
    +WAITWIN "Player Test MIDI - Media Player"
    +KEY Ctrl+S
    +WAIT 10
    +SHOT p-mid
    +
    +LAUNCH apps/kpunch/player/player.app TEST/PLAYER.VOC
    +WAITWIN "PLAYER.VOC - Media Player"
    +KEY Ctrl+S
    +WAIT 10
    +SHOT p-voc
    +
    +KEY Alt+F4
    +WAITNOWIN "*Media Player"
    +QUIT
    diff --git a/src/test/dos/scripts/prdjcol.scr b/src/test/dos/scripts/prdjcol.scr
    new file mode 100644
    index 00000000..d5268a6d
    --- /dev/null
    +++ b/src/test/dos/scripts/prdjcol.scr
    @@ -0,0 +1,18 @@
    +# prdjcol.scr -- the Printer object on the DeskJet 550C: the page in
    +# four PCL planes (black, cyan, magenta, yellow).
    +#
    +# TEST\PRDRAW.APP (fixtures/prdraw) draws a page with the Printer object
    +# and ends the document; DVX.INI sends it to the file TEST\PRDJCOL.PRN,
    +# which is compared with golden/prdjcol/prdjcol.prn.
    +#
    +# INI: printer model deskjetcolor
    +# INI: printer port FILE
    +# INI: printer file TEST\PRDJCOL.PRN
    +# EXPECT: TEST\PRDJCOL.PRN prdjcol.prn
    +TIMEOUT 90
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/PRDRAW.APP
    +WAITLOG "Print: finished,"
    +WAITWIN "Printer done" 60
    +QUIT
    diff --git a/src/test/dos/scripts/print.scr b/src/test/dos/scripts/print.scr
    index 5b8b44f1..19b63b2d 100644
    --- a/src/test/dos/scripts/print.scr
    +++ b/src/test/dos/scripts/print.scr
    @@ -12,8 +12,9 @@ LAUNCH apps/kpunch/notepad/notepad.app TEST\PRTEST.TXT
     WAITWIN "*PRTEST.TXT - Notepad"
     KEY Alt+F P
     WAITWIN "Print"
    -# Print to: the drop-down has the focus; File is the fourth entry
    -KEY Down Down Down
    +# Print to: the drop-down has the focus; F picks File (after the LPT and
    +# COM ports)
    +TYPE "F"
     KEY Tab
     KEY Ctrl+A
     TYPE "TEST\PRINT.PRN"
    diff --git a/src/test/dos/scripts/prlj300.scr b/src/test/dos/scripts/prlj300.scr
    new file mode 100644
    index 00000000..8f5b757a
    --- /dev/null
    +++ b/src/test/dos/scripts/prlj300.scr
    @@ -0,0 +1,18 @@
    +# prlj300.scr -- the Printer object on the 300 dpi LaserJet: the page as
    +# packed PCL rows, black only, its text in DVX's own fonts.
    +#
    +# TEST\PRDRAW.APP (fixtures/prdraw) draws a page with the Printer object
    +# and ends the document; DVX.INI sends it to the file TEST\PRLJ300.PRN,
    +# which is compared with golden/prlj300/prlj300.prn.
    +#
    +# INI: printer model laserjet300
    +# INI: printer port FILE
    +# INI: printer file TEST\PRLJ300.PRN
    +# EXPECT: TEST\PRLJ300.PRN prlj300.prn
    +TIMEOUT 90
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/PRDRAW.APP
    +WAITLOG "Print: finished,"
    +WAITWIN "Printer done" 60
    +QUIT
    diff --git a/src/test/dos/scripts/prps.scr b/src/test/dos/scripts/prps.scr
    new file mode 100644
    index 00000000..ef70965f
    --- /dev/null
    +++ b/src/test/dos/scripts/prps.scr
    @@ -0,0 +1,19 @@
    +# prps.scr -- the Printer object on a color PostScript printer: the
    +# page drawn by the printer itself (vector PostScript), its text in the
    +# printer's Times and Helvetica.
    +#
    +# TEST\PRDRAW.APP (fixtures/prdraw) draws a page with the Printer object
    +# and ends the document; DVX.INI sends it to the file TEST\PRPS.PRN,
    +# which is compared with golden/prps/prps.prn.
    +#
    +# INI: printer model postscriptcolor
    +# INI: printer port FILE
    +# INI: printer file TEST\PRPS.PRN
    +# EXPECT: TEST\PRPS.PRN prps.prn
    +TIMEOUT 90
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/PRDRAW.APP
    +WAITLOG "Print: finished,"
    +WAITWIN "Printer done" 60
    +QUIT
    diff --git a/src/test/dos/scripts/record.scr b/src/test/dos/scripts/record.scr
    new file mode 100644
    index 00000000..6d481f22
    --- /dev/null
    +++ b/src/test/dos/scripts/record.scr
    @@ -0,0 +1,42 @@
    +# record.scr -- the Sound Recorder records from the sound card, plays
    +# the recording, and saves it.
    +#
    +# The window as it opens (rc-open).  Record (Alt+R) takes the card for
    +# recording (the log names the format); two seconds later Stop (Alt+T)
    +# gives it back (the log counts the frames, which depend on the emulated
    +# card, so only the lines are checked: # LOG).  Play (Alt+P), stopped;
    +# File > Save As writes REC.WAV (in the folder the dialog opens in) and
    +# the title follows.  An emulated card records silence, so the sound
    +# itself is not compared (# AUDIO-CARD: the Sound Blaster 16, recorded
    +# but not compared).
    +# AUDIO-CARD: sb16
    +# LOG: Audio: recording at
    +# LOG: Audio: recording ended
    +# NOLOG: did not start recording
    +# NOLOG: no interrupt from the card
    +TIMEOUT 90
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH apps/kpunch/recorder/recorder.app
    +WAITWIN "New recording - Sound Recorder"
    +WAIT 10
    +SHOT rc-open
    +KEY Alt+R
    +WAITLOG "Audio: recording at"
    +WAITMS 2000
    +KEY Alt+T
    +WAITLOG "Audio: recording ended"
    +WAITWIN "New recording (not saved) - Sound Recorder"
    +KEY Alt+P
    +WAITMS 500
    +KEY Alt+T
    +KEY Alt+F
    +KEY A
    +WAITWIN "Save As"
    +KEY Alt+N
    +TYPE "REC.WAV"
    +KEY Enter
    +WAITWIN "REC.WAV - Sound Recorder"
    +KEY Alt+F4
    +WAITNOWIN "*Sound Recorder"
    +QUIT
    diff --git a/src/test/dos/scripts/saver.scr b/src/test/dos/scripts/saver.scr
    new file mode 100644
    index 00000000..a5d843eb
    --- /dev/null
    +++ b/src/test/dos/scripts/saver.scr
    @@ -0,0 +1,68 @@
    +# saver.scr -- the screen saver: after the wait with no input it takes
    +# the whole screen, and the next input gives the screen back.
    +#
    +# DVX.INI chooses Blank Screen with a one-minute wait (# INI).  With no
    +# input the shell starts it (the log says so) and it shows a black
    +# screen in full-screen mode (sv-blank); a key ends it (the log), and the
    +# desktop is back as it was, the key not passed to Program Manager
    +# (VIDEOCHECK, sv-back).  Then Control Panel's Desktop page: its saver
    +# row (sv-cpd), and Test with each of the other savers (Marquee,
    +# Mystify, and Starfield, picked with Down and Enter in the list): each
    +# starts, draws for a while, and a key ends it.  Closing Control Panel
    +# cancels.
    +# INI: desktop saver blank.app
    +# INI: desktop saverWait 1
    +TIMEOUT 240
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +WAITLOG "Shell: screen saver blank.app started"
    +WAITMS 2000
    +SHOT sv-blank
    +KEY Space
    +WAITLOG "Shell: screen saver blank.app ended"
    +WAIT 20
    +VIDEOCHECK
    +SHOT sv-back
    +
    +LAUNCH apps/kpunch/cpanel/cpanel.app
    +WAITWIN "Control Panel"
    +WAIT 10
    +CLICK "Control Panel" 38 59
    +MOVE 639 479
    +KEY Right
    +KEY Right
    +WAIT 10
    +KEY Alt+V
    +KEY Down
    +KEY Enter
    +WAIT 10
    +SHOT sv-cpd
    +KEY Alt+T
    +WAITLOG "Shell: screen saver marquee.app started"
    +WAITMS 3000
    +KEY Space
    +WAITLOG "Shell: screen saver marquee.app ended"
    +WAITWIN "Control Panel"
    +KEY Alt+V
    +KEY Down
    +KEY Enter
    +KEY Alt+T
    +WAITLOG "Shell: screen saver mystify.app started"
    +WAITMS 3000
    +KEY Space
    +WAITLOG "Shell: screen saver mystify.app ended"
    +WAITWIN "Control Panel"
    +KEY Alt+V
    +KEY Down
    +KEY Enter
    +KEY Alt+T
    +WAITLOG "Shell: screen saver starfld.app started"
    +WAITMS 3000
    +KEY Space
    +WAITLOG "Shell: screen saver starfld.app ended"
    +WAITWIN "Control Panel"
    +WAIT 20
    +VIDEOCHECK
    +KEY Alt+F4
    +WAITNOWIN "Control Panel"
    +QUIT
    diff --git a/src/test/dos/scripts/sndload.scr b/src/test/dos/scripts/sndload.scr
    new file mode 100644
    index 00000000..3a035952
    --- /dev/null
    +++ b/src/test/dos/scripts/sndload.scr
    @@ -0,0 +1,18 @@
    +# sndload.scr -- how much of a 486 the sound takes (a measurement).
    +#
    +# A Gravis UltraSound alone (# AUDIO-CARD, recorded but not compared).
    +# TEST\MUSTEST.APP load runs a busy loop for two seconds with nothing
    +# playing, then with eight looping sample voices (mixed in software into
    +# the GUS's digital sound), then with a four-channel module (on the GUS's
    +# own voices); DVX.LOG gets each one's work and its share of the CPU
    +# ("MUSTEST: load: ...").  Nothing here fails on a slow run; the numbers
    +# are recorded in claudeDocs/soundCardsPlan.md (S7).
    +# ONLY: 86box
    +# AUDIO-CARD: gus
    +# LOG: MUSTEST: load: a 4-channel module on the wavetable synthesizer
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP load
    +WAITLOG "MUSTEST: load done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/softwave.scr b/src/test/dos/scripts/softwave.scr
    new file mode 100644
    index 00000000..4cdf515b
    --- /dev/null
    +++ b/src/test/dos/scripts/softwave.scr
    @@ -0,0 +1,21 @@
    +# softwave.scr -- a MIDI file on the SoundFont software synthesizer,
    +# recorded.
    +#
    +# A Sound Blaster 16 (# AUDIO-CARD default) with the DVX General MIDI
    +# SoundFont add-on installed (# ADDON) and DVX.INI [audio] midi =
    +# softwave (# INI).  TEST\MUSTEST.APP writes a MIDI file (a flute: A4,
    +# E5, A5, 400 ms each, 300 ms apart) and plays it after a 2000 Hz sync
    +# tone: it must play on the SoundFont software synthesizer (the flute's
    +# samples in memory, mixed into the sound stream) and end on time
    +# (# LOG).  The recording must match the golden (# AUDIO).
    +# ADDON: dvxgm
    +# INI: audio midi softwave
    +# AUDIO: 2000 440 659 880
    +# LOG: MUSTEST: softwave: MIDI on 6: SoundFont software synthesizer
    +# LOG: MUSTEST: softwave PASS
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/MUSTEST.APP softwave
    +WAITLOG "MUSTEST: softwave done" 90
    +QUIT
    diff --git a/src/test/dos/scripts/svdbg.scr b/src/test/dos/scripts/svdbg.scr
    new file mode 100644
    index 00000000..f29a6c8b
    --- /dev/null
    +++ b/src/test/dos/scripts/svdbg.scr
    @@ -0,0 +1,20 @@
    +# svdbg.scr -- TEMPORARY: how long a full-screen saver takes to close.
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH shared/savers/blank.app
    +WAITLOG "basrt: program screen full screen on"
    +WAITMS 2000
    +LOGTIME blank-close
    +KEY Alt+F4
    +WAITNOWIN "Blank Screen"
    +LOGTIME blank-gone
    +WAITMS 1000
    +LAUNCH shared/savers/starfld.app
    +WAITWIN "Starfield"
    +WAITMS 2000
    +LOGTIME star-close
    +KEY Alt+F4
    +WAITNOWIN "Starfield"
    +LOGTIME star-gone
    +QUIT
    diff --git a/src/test/dos/scripts/wss.scr b/src/test/dos/scripts/wss.scr
    new file mode 100644
    index 00000000..2c80a230
    --- /dev/null
    +++ b/src/test/dos/scripts/wss.scr
    @@ -0,0 +1,20 @@
    +# wss.scr -- sound through a Windows Sound System board, recorded.
    +#
    +# 86Box's Windows Sound System (# AUDIO-CARD): an AD1848 codec behind
    +# Microsoft's configuration register at 530h, no Sound Blaster.  wss.drv
    +# finds the codec, routes it to IRQ 7 and DMA 1, and plays 16-bit stereo
    +# at 22,050 Hz.  TEST\SNDTEST.APP plays its "full" timeline; every sound
    +# must end on time and the recording must match the golden.  DOSBox has
    +# no Windows Sound System.
    +# ONLY: 86box
    +# AUDIO-CARD: wss
    +# AUDIO: 2000 440 880 660 330 1320
    +# LOG: SNDTEST: full: device wss: Windows Sound System (AD1848 codec) at 530h
    +# LOG: SNDTEST: full PASS
    +# INI: audio fmTones false
    +TIMEOUT 120
    +WAITWIN "Program Manager"
    +MOVE 639 479
    +LAUNCH TEST/SNDTEST.APP full
    +WAITLOG "SNDTEST: full done" 90
    +QUIT
    diff --git a/src/test/formrt/testFormRt.c b/src/test/formrt/testFormRt.c
    index 61feebe4..065744f4 100644
    --- a/src/test/formrt/testFormRt.c
    +++ b/src/test/formrt/testFormRt.c
    @@ -341,6 +341,7 @@ static void        caseDragDrop(void);
     static void        caseDrawMethods(void);
     static void        caseDrawPaint(void);
     static void        caseDrawScale(void);
    +static void        caseDrawStyle(void);
     static void        caseDrawThemed(void);
     static void        caseDynamicControl(void);
     static void        caseEditorMembers(void);
    @@ -2965,6 +2966,71 @@ static void caseDrawScale(void) {
     }
     
     
    +// DrawStyle on a PictureBox: dashes and dots along lines and circles
    +// (solid with a wider pen), no outlines when invisible (fills still
    +// drawn), and the error for a bad value.
    +static void caseDrawStyle(void) {
    +    tCase("DrawStyle");
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +
    +    const char *frm =
    +        "Begin Form Form1\n"
    +        "    Caption = \"Style\"\n"
    +        "    Begin PictureBox Pic\n"
    +        "    End\n"
    +        "End\n"
    +        "Sub Form1_T ()\n"
    +        "    Pic.Resize 60, 40\n"
    +        "    Pic.BackColor = RGB(255, 255, 255)\n"
    +        "    Pic.Cls\n"
    +        "    Print Pic.DrawStyle\n"
    +        "    Pic.DrawStyle = vbDash\n"
    +        "    Pic.Line (0, 5)-(59, 5), RGB(0, 0, 0)\n"
    +        "    Print Pic.Point(2, 5); Pic.Point(13, 5); Pic.Point(17, 5)\n"
    +        "    Pic.DrawStyle = vbDot\n"
    +        "    Pic.Line (0, 8)-(59, 8)\n"
    +        "    Print Pic.Point(0, 8); Pic.Point(2, 8)\n"
    +        "    Pic.DrawWidth = 3\n"
    +        "    Pic.Line (0, 12)-(59, 12)\n"
    +        "    Print Pic.Point(13, 12)\n"
    +        "    Pic.DrawWidth = 1\n"
    +        "    Pic.DrawStyle = vbInvisible\n"
    +        "    Pic.Line (0, 16)-(59, 16)\n"
    +        "    Print Pic.Point(5, 16)\n"
    +        "    Pic.FillStyle = vbFSSolid\n"
    +        "    Pic.FillColor = RGB(255, 0, 0)\n"
    +        "    Pic.Line (10, 20)-(20, 30), , B\n"
    +        "    Print Pic.Point(15, 25); Pic.Point(10, 25)\n"
    +        "    Pic.FillStyle = vbFSTransparent\n"
    +        "    Pic.DrawStyle = vbDash\n"
    +        "    Pic.Circle (45, 25), 10\n"
    +        "    Print Pic.Point(55, 25); Pic.Point(45, 15); Pic.DrawStyle\n"
    +        "    Pic.DrawStyle = 7\n"
    +        "End Sub\n";
    +    FrtAppT app;
    +
    +    if (!expectBuild(&app, "", &frm, 1)) {
    +        tShutdown();
    +        return;
    +    }
    +
    +    frtStart(&app, NULL);
    +    frtFire(&app, "Form1", "T");
    +    TEST_STR_EQ(frtOut(&app),
    +        "0 \n"
    +        "0 16777215 0 \n"
    +        "0 16777215 \n"
    +        "0 \n"
    +        "16777215 \n"
    +        "255 255 \n"
    +        "0 16777215 1 \n");
    +    TEST_TRUE(app.rt->terminated);
    +    frtClearError(&app);
    +    frtDestroy(&app);
    +    tShutdown();
    +}
    +
    +
     // The drawing members that draw DVX's own look through libdvx on a
     // PictureBox (DrawBevel, DrawButtonFace, DrawArrow, DrawScrollBar upright
     // and horizontal, DrawScrollArrow, PrintAccel), the measuring ones
    @@ -10709,6 +10775,7 @@ int main(void) {
         caseScrollPaneChange();
         caseDrawPaint();
         caseDrawScale();
    +    caseDrawStyle();
         caseEventLoopExits();
         caseExternCalls();
         caseExternMarshal();
    diff --git a/src/test/ide/Makefile b/src/test/ide/Makefile
    index 9b00aeec..33876db1 100644
    --- a/src/test/ide/Makefile
    +++ b/src/test/ide/Makefile
    @@ -61,7 +61,7 @@ STUBTARGET := $(BINDIR)/testStub
     IDE_SRCS   := ideDesigner.c ideProperties.c ideProject.c ideMenuEditor.c ideToolbox.c idePackage.c
     COMP_SRCS  := lexer.c ast.c scope.c parse.c parseExpr.c parseStmt.c parseDecl.c resolve.c resolveExpr.c typecheck.c fold.c gen.c genExpr.c genStmt.c compiler.c codegen.c strip.c obfuscate.c compact.c
     RT_SRCS    := vm.c values.c dates.c serialize.c gfx.c screen.c
    -FRM_SRCS   := formrt.c formGfx.c formDesign.c formCtl.c frmParser.c frmModel.c frmModelBas.c basNativeHost.c basRunHost.c
    +FRM_SRCS   := formrt.c formGfx.c formPrinter.c formDesign.c formCtl.c frmParser.c frmModel.c frmModelBas.c basNativeHost.c basRunHost.c
     DEV_SRCS   := basValidate.c basDebug.c basProcs.c basSource.c basHelpMap.c basProject.c
     
     OBJS       := $(BINDIR)/testIde.o $(BINDIR)/ideStubs.o
    diff --git a/src/test/lint/analyzerBaseline.txt b/src/test/lint/analyzerBaseline.txt
    index 8e1b91cd..678e2e9a 100644
    --- a/src/test/lint/analyzerBaseline.txt
    +++ b/src/test/lint/analyzerBaseline.txt
    @@ -6,8 +6,10 @@
     # staticGate.sh --update keeps these comments with their keys, drops
     # keys whose count is now 0, and marks new or grown keys UNTRIAGED.
     
    -# FP: value is stored via arrput in sFileValues (freed on page close); stb_ds OOM hook never returns, so arrput cannot fail and drop it.
    -1 src/apps/kpunch/cpanel/cpSounds.c|addFile|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    +# FP: the strdup'd value is stored via arrput in c->values (freed with the choices); the stb_ds OOM hook never returns, so arrput cannot fail and drop it.
    +1 src/apps/kpunch/cpanel/cpSounds.c|choiceAdd|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    +# FP: the strdup'd label is stored via arrput in c->owned (freed with the choices), or freed at once; the stb_ds OOM hook never returns, so arrput cannot drop it.
    +1 src/apps/kpunch/cpanel/cpSounds.c|choiceSetLabel|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: the malloc is stored in the module being built (mod->classes / mod->withEvents entries or an interface map), which basModuleFree releases on success and on every failure path; the analyzer loses the store into the caller-owned module.
     3 src/apps/kpunch/dvxbasic/compiler/compiler.c|buildWithEvents|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: the malloc is stored in sw->full (sw is rt->screenWin, heap), freed by screenModeChanged and basFormRtScreenClose; analyzer loses the store into the caller's heap struct.
    @@ -28,7 +30,7 @@
     4 src/apps/kpunch/dvxbasic/runtime/vm.c|execStringOp|use of uninitialized value  [CWE-457] [-Wanalyzer-use-of-uninitialized-value]
     # FP: copy is stored via arrins at m->history[0] (trimmed by the pop loop, freed with the model); stb_ds grow aborts rather than failing.
     1 src/libs/kpunch/dvxshell/pmModel.c|pmHistoryAdd|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    -# FP: allocSlot appends the calloc'd slot to sApps with arrput; the stb_ds OOM hook never returns, so the slot is never dropped (freed with the slot index).
    +# FP: allocSlot (inlined) stores the calloc'd ShellAppT via arrput in sApps (slots live as long as the shell and are reused); the stb_ds OOM hook never returns, so arrput cannot drop it.
     1 src/libs/kpunch/dvxshell/shellApp.c|shellLoadAppInternal|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: dialogCreate stores &state->base (the first member) as the window's userData; dvxDialogRun frees it, and every caller (fnShowLicence included) runs the dialog it creates.
     1 src/libs/kpunch/libdvx/dvxDialog.c|dvxDialogCreate|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    @@ -42,16 +44,18 @@
     1 src/libs/kpunch/libdvx/dvxText.c|idleTrim|dereference of NULL  [CWE-476] [-Wanalyzer-null-dereference]
     # FP: scanFonts stores sourceMake's allocation in face->src, an element of the global sFamilies stb_ds array (faceFree releases it); the analyzer loses the pointer in the array.  Unchanged loop; the path set moved when the font list came from platformKindReadDir (never NULL).
     1 src/libs/kpunch/libdvx/dvxText.c|scanFonts.part.0|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    -# FP: workArea gets width * height of a glyph whose width and height (uint8_t) are both positive on the paths that use the area, so the stb_ds array is allocated; the path assumes the area <= 0 and then enters the row loop.
    -1 src/libs/kpunch/libdvx/dvxText.c|workArea|use of NULL where non-null expected [CWE-476] [-Wanalyzer-null-argument]
     # FP: name is stored via arrput into the returned array (freed by dvxReadDirFree); was the stb_ds.h|dvxReadDir key.
     3 src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c|dvxReadDir|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    +# FP: each name (dvxReadDir's, or a strdup'd VENDOR\NAME) moves into the returned list by arrput, which platformKindReadDir hands on or frees; the stb_ds OOM hook never returns.
    +3 src/libs/kpunch/libdvx/platform/dvxPlatformUtil.c|kindList|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: same arrput store as ideProperties.c|addPropRow, reported at the stb_ds macro expansion.
     1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|addPropRow|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: li->count > idx guarantees widgetAdoptOwnedStrings copies count > 0 entries; the path needs its loop to run 0 times.  Reached directly and via listPopupRemoveItem; the analyzer reports the path once or twice from run to run.
     2 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|listItemsRemove|dereference of NULL  [CWE-476] [-Wanalyzer-null-dereference]
     # FP: strdup("") is stored via arrput in remapped, which becomes lv->ownedCells (freed with the widget).
     1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|listViewRemapOwnedCells|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
    +# FP: scanDir grows the caller's module list with arrput (inside stb_ds's arrgrow); the list is the loader's for the whole run, and the analyzer loses the store into *mods.
    +1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|loadAllModules|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: row stored into d->rows via arrput; freeCache releases it (the real partial-load leak was fixed: freeCache walks arrlen).
     1 src/libs/kpunch/libdvx/thirdparty/stb_ds.h|loadCache|leak of  [CWE-401] [-Wanalyzer-malloc-leak]
     # FP: copy/m.path are stored in sMemFiles via arrput and held on purpose until DVX exits (read-only media keeps settings in memory); stb_ds OOM hook never returns.
    diff --git a/src/test/lint/clangTidyBaseline.txt b/src/test/lint/clangTidyBaseline.txt
    index 8ff32475..c39cfa25 100644
    --- a/src/test/lint/clangTidyBaseline.txt
    +++ b/src/test/lint/clangTidyBaseline.txt
    @@ -22,6 +22,8 @@
     1 src/apps/kpunch/dvxbasic/formrt/frmModel.c|clang-analyzer-core.NullDereference|Access to field 'level' results in a dereference of a null pointer (loaded from variable 'mi')
     # FP: frmModelReparent refuses a new parent inside ctrl: frmModelIsDescendant(m, parent, ctrl) asks whether parent descends from ancestor ctrl, the intended order
     1 src/apps/kpunch/dvxbasic/formrt/frmModel.c|readability-suspicious-call-argument|2nd argument 'parent' (passed to 'ctrl') looks like it might be swapped with the 3rd, 'ctrl' (passed to 'ancestor')
    +# FP: pkgDpkFolderFiles(folder, prefix, out) reads the staged files from scratch and names them under the package's install folder (the prefix), its parameter order; only the names differ
    +1 src/apps/kpunch/dvxbasic/ide/idePackage.c|readability-suspicious-call-argument|1st argument 'scratch' (passed to 'folder') looks like it might be swapped with the 2nd, 'folder' (passed to 'prefix')
     # FP: the project tree keeps a file index (or -1 for group rows) in the widget's void *userData, the only per-item slot the tree widget offers; it is cast back, never dereferenced
     3 src/apps/kpunch/dvxbasic/ide/ideProject.c|performance-no-int-to-ptr|integer to pointer cast pessimizes optimization opportunities
     # FP: validateNewName(newValue, max, oldName): the new name is the one validated and oldName the one excepted from the in-use check; the order is right
    @@ -76,6 +78,8 @@
     1 src/libs/kpunch/dvxshell/shellApp.c|performance-no-int-to-ptr|integer to pointer cast pessimizes optimization opportunities
     # FP: the BASIC hotkey id is passed as dvxRegisterHotkey's user pointer on purpose
     1 src/libs/kpunch/dvxshell/shellBas.c|performance-no-int-to-ptr|integer to pointer cast pessimizes optimization opportunities
    +# FP: bodyWrite gets at most the bytes the socket read into the 4096-byte buffer (a body takes part of them), never more
    +1 src/libs/kpunch/http/http.c|clang-analyzer-unix.StdCLibraryFunctions|The 1st argument to 'fwrite' is a buffer with size 4096 but should be a buffer with size equal to or greater than the value of the 2nd argument (which is 1) times the 3rd argument
     # FP: platformAltScanToChar returns 0 or an ASCII letter or digit from its table, never a negative char
     1 src/libs/kpunch/libdvx/dvxApp.c|bugprone-signed-char-misuse,cert-str34-c|'signed char' to 'int32_t' (aka 'int') conversion; consider casting to 'unsigned char' first.
     # FP: executeSysMenuCmd switches on the SysMenu*E ids openSysMenu put in the menu; handleMouseButton leaves the other HIT_* parts (none, border) without action on purpose
    @@ -94,6 +98,8 @@
     1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp|declaration uses identifier '__wrap_abort', which is a reserved identifier
     # FP: GNU ld --wrap=exit (HOST_APP_LDFLAGS) requires exactly the name __wrap_exit
     1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp|declaration uses identifier '__wrap_exit', which is a reserved identifier
    +# FP: platformDosRun runs a DOS command line by definition (the host stands in for DOS with system(), as the DOS build does)
    +1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|cert-env33-c|calling 'system' uses a command processor
     # FP: platformVideoExclusiveCapture's signature is the platform interface; the DOS layer writes out, the host stub has nothing to write
     1 src/libs/kpunch/libdvx/platform/dvxPlatformHost.c|readability-non-const-parameter|pointer parameter 'out' can be pointer to const
     # FP: platformMkdirRecursive returns -1 when strlen(path) >= sizeof(buf), the test right before the strcpy
    @@ -104,6 +110,14 @@
     1 src/libs/kpunch/libtasks/taskswitch.c|performance-no-int-to-ptr|integer to pointer cast pessimizes optimization opportunities
     # FP: sVibrato is a signed int8_t table of pitch steps (-2..2); the sign is meant
     1 src/libs/kpunch/music/musicSynth.c|bugprone-signed-char-misuse,cert-str34-c|'signed char' to 'int32_t' (aka 'int') conversion; consider casting to 'unsigned char' first.
    +# FP: platformPathJoin(out, size, dir, name) builds the SYSTEM folder (sys) and then the database folder in it, written to the variable named dir; only the names differ
    +1 src/libs/kpunch/pkg/pkgDb.c|readability-suspicious-call-argument|1st argument 'dir' (passed to 'out') looks like it might be swapped with the 3rd, 'sys' (passed to 'dir')
    +# FP: joinName(out, size, dir, name) writes the package name of a file: the prefix, then its path inside the folder (sub), to the variable named name; only the names differ
    +1 src/libs/kpunch/pkg/pkgDpk.c|readability-suspicious-call-argument|1st argument 'name' (passed to 'out') looks like it might be swapped with the 4th, 'sub' (passed to 'name')
    +# FP: platformPathJoin(out, size, dir, name) builds the SYSTEM folder (sys) and then the keys folder in it, written to the variable named dir; only the names differ
    +1 src/libs/kpunch/pkg/pkgTrust.c|readability-suspicious-call-argument|1st argument 'dir' (passed to 'out') looks like it might be swapped with the 3rd, 'sys' (passed to 'dir')
    +# FP: FontGlyphT's left and top are signed int8_t pixel offsets (a bearing or a glyph's top can be negative); the sign is meant
    +2 src/libs/kpunch/print/printFont.c|bugprone-signed-char-misuse,cert-str34-c|'signed char' to 'int32_t' (aka 'int') conversion; consider casting to 'unsigned char' first.
     # FP: outLine builds a length-delimited line (margin, text, CR LF) that outBytes writes with its length; no NUL is wanted
     1 src/libs/kpunch/print/printText.c|bugprone-not-null-terminated-result|the result from calling 'memcpy' is not null-terminated
     # FP: the frame-type switch drops unknown types from the wire on purpose, and rxState only ever holds RX_STATE_HUNT, ACTIVE or ESCAPE, each with a case
    @@ -118,6 +132,10 @@
     1 src/tools/dvxhlpc.c|bugprone-not-null-terminated-result|the result from calling 'memcpy' is not null-terminated
     # FP: _POSIX_C_SOURCE is the POSIX feature-test macro; the program must define it before any include
     1 src/tools/dvxhlpc.c|bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp|declaration uses identifier '_POSIX_C_SOURCE', which is a reserved identifier
    +# FP: chunkEnd: the path assumes arrlen(*out) found no array, but chunkBegin always put the chunk's 8 header bytes there first, so the array the length goes into exists
    +1 src/tools/mksf.c|clang-analyzer-core.NullDereference|Array access results in a null pointer dereference
    +# FP: pickSlots: the path takes max (a step's regions) as 0, but every step in sMelodicSteps and sDrumSteps keeps at least 1 and fitSlots only calls it with count > 0, so mids[0] is always written
    +1 src/tools/mksf.c|clang-analyzer-core.UndefinedBinaryOperatorResult|The right operand of '<' is a garbage value
     # FP: sTelState only holds the eight TS_* states and each has a case
     1 src/tools/proxy/proxy.c|bugprone-switch-missing-default-case|switching on non-enum value without default case may not cover all cases
     # FP: parseState only holds PARSE_NORMAL, PARSE_ESC or PARSE_CSI, each with a case
    diff --git a/src/test/lint/codespellWords.txt b/src/test/lint/codespellWords.txt
    index 4fec884d..4d955109 100644
    --- a/src/test/lint/codespellWords.txt
    +++ b/src/test/lint/codespellWords.txt
    @@ -51,3 +51,11 @@ alph
     pard
     headerr
     caf
    +#
    +# The singemaster project the package server is built on (server/,
    +# SINGE_HOME), and "\tyes" in C strings (a tab, then yes).
    +singe
    +tyes
    +# The AD1848 codec's Auto-Calibrate In-progress bit, as its data sheet
    +# names it (wssDrv.c)
    +aci
    diff --git a/src/test/lint/cppcheckBaseline.txt b/src/test/lint/cppcheckBaseline.txt
    index 5faefb4e..dd254b6c 100644
    --- a/src/test/lint/cppcheckBaseline.txt
    +++ b/src/test/lint/cppcheckBaseline.txt
    @@ -9,6 +9,8 @@
     
     # FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
     # FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/cpanel/cpSounds.c|constParameterCallback|Parameter 'w' can be declared as pointer to const. However it seems that 'onBankTest' is a callback function, if 'w' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
     1 src/apps/kpunch/cpanel/cpTypes.c|constParameterCallback|Parameter 'dlg' can be declared as pointer to const. However it seems that 'typeDlgOk' is a callback function, if 'dlg' is declared with const you might also need to cast function pointer(s).
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/apps/kpunch/cpanel/cpTypes.c|unsignedLessThanZero|Checking if unsigned expression '(sCells)?((struct stbds_array_header*)(sCells)-1)->capacity:0' is less than zero.
    @@ -21,8 +23,6 @@
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/apps/kpunch/dvxbasic/basdev/basDevBas.c|unsignedLessThanZero|Checking if unsigned expression '(sCompError)?((struct stbds_array_header*)(sCompError)-1)->capacity:0' is less than zero.
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
    -1 src/apps/kpunch/dvxbasic/basdev/basDevBas.c|unsignedLessThanZero|Checking if unsigned expression '(sText)?((struct stbds_array_header*)(sText)-1)->capacity:0' is less than zero.
    -# FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/apps/kpunch/dvxbasic/basdev/basDevBas.c|unsignedLessThanZero|Checking if unsigned expression 'r->breakpoints?((struct stbds_array_header*)(r->breakpoints)-1)->capacity:0' is less than zero.
     # FP: style suggestion only (variable scope), no defect.
     1 src/apps/kpunch/dvxbasic/compiler/compact.c|variableScope|The scope of the variable 'lineCount' can be reduced.
    @@ -56,6 +56,20 @@
     1 src/apps/kpunch/dvxbasic/formrt/formDesign.c|constParameterCallback|Parameter 'win' can be declared as pointer to const. However it seems that 'dsgnOnMouse' is a callback function, if 'win' is declared with const you might also need to cast function pointer(s).
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/apps/kpunch/dvxbasic/formrt/formDesign.c|unsignedLessThanZero|Checking if unsigned expression '(sText)?((struct stbds_array_header*)(sText)-1)->capacity:0' is less than zero.
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'prBandSpan' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'prVecDot' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'prVecEllipse' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'prVecEllipseFill' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'prVecLine' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'prVecRect' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: prShapeDo draws on the measuring surface, whose span callback (prMeasureSpan) moves p->touchBottom; cppcheck does not follow the callback.
    +1 src/apps/kpunch/dvxbasic/formrt/formPrinter.c|knownConditionTrueFalse|Condition 'p->touchBottom<0' is always true
     # FP: upperCopy bounds the destination; src[i] is read inside the NUL-terminated source string, so the order is safe.
     1 src/apps/kpunch/dvxbasic/formrt/formrt.c|arrayIndexThenCheck|Array index 'i' is used before limits check.
     # FP: signature fixed by the BasDeviceCallbacksT frameBusy pointer type (void *ctx shared with callbacks that write through it).
    @@ -88,8 +102,6 @@
     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.
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     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.
    -# FP: style suggestion only (variable scope), no defect.
    -1 src/apps/kpunch/dvxbasic/ide/ideMain.c|variableScope|The scope of the variable 'len' can be reduced.
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     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.
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
    @@ -176,19 +188,21 @@
     1 src/libs/kpunch/dvxshell/shellAssoc.c|variableScope|The scope of the variable 'tokenLen' can be reduced.
     # FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
     1 src/libs/kpunch/dvxshell/shellBas.c|constParameterCallback|Parameter 'userCtx' can be declared as pointer to const. However it seems that 'hotkeyPressed' is a callback function, if 'userCtx' is declared with const you might also need to cast function pointer(s).
    +# FP: bootNotesShow returns when the notes are empty, so the loop has written msg[0 .. len - 1] before the snprintf at msg + len - 1.
    +1 src/libs/kpunch/dvxshell/shellMain.c|uninitvar|Uninitialized variable: msg
     # FP: style suggestion only (variable scope), no defect.
     1 src/libs/kpunch/dvxshell/shellRun.c|variableScope|The scope of the variable 'resolved' can be reduced.
     # FP: the strdup'd name moves into scan->entries by arrput; stb_ds OOM hook never returns, so it cannot be dropped.
     1 src/libs/kpunch/fileops/fileOps.c|memleak|Memory leak: entry.name
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
    -1 src/libs/kpunch/fileops/fileOpsBas.c|unsignedLessThanZero|Checking if unsigned expression '(sText)?((struct stbds_array_header*)(sText)-1)->capacity:0' is less than zero.
    -# FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/libs/kpunch/hlpfile/hlpFileBas.c|unsignedLessThanZero|Checking if unsigned expression 'hd->found?((struct stbds_array_header*)(hd->found)-1)->capacity:0' is less than zero.
     # FP: style suggestion only (could be const), no defect.
     1 src/libs/kpunch/libdvx/dvxApp.c|constVariable|Variable 'keyRec' can be declared as const array
     # FP: style suggestion only (could be const), no defect.
     1 src/libs/kpunch/libdvx/dvxApp.c|constVariable|Variable 'mouseRec' can be declared as const array
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
    +1 src/libs/kpunch/libdvx/dvxBas.c|unsignedLessThanZero|Checking if unsigned expression 't->buf?((struct stbds_array_header*)(t->buf)-1)->capacity:0' is less than zero.
    +# FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/libs/kpunch/libdvx/dvxFlow.c|unsignedLessThanZero|Checking if unsigned expression 'flow->items?((struct stbds_array_header*)(flow->items)-1)->capacity:0' is less than zero.
     # FP: inside the stb_ds arrsetcap/arrsetlen macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/libs/kpunch/libdvx/dvxFlow.c|unsignedLessThanZero|Checking if unsigned expression 'flow->lines?((struct stbds_array_header*)(flow->lines)-1)->capacity:0' is less than zero.
    @@ -216,6 +230,22 @@
     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.
     # FP: inside the stb_ds arrdel macro expansion (unsigned capacity compared by the library macro), not DVX logic.
     1 src/libs/kpunch/music/musicMmlPlay.c|unsignedLessThanZero|Checking if unsigned expression 'mml->parts?((struct stbds_array_header*)(mml->parts)-1)->capacity:0' is less than zero.
    +# FP: parseOpts gets main's argv (char *const there); C does not convert char ** to const char *const * without a cast.
    +1 src/libs/kpunch/pkg/pkgCli.c|constParameter|Parameter 'argv' can be declared as const array
    +# FP: strtol's long is 64 bits on 64-bit hosts (the host tools and tests), where n > INT32_MAX is a real check; cppcheck runs as unix32.
    +1 src/libs/kpunch/pkg/pkgManifest.c|compareValueOutOfTypeRangeError|Comparing expression of type 'signed long' against value 2147483647. Condition is always false.
    +# FP: strtoul's unsigned long is 64 bits on 64-bit hosts (the host tools and tests), where n > UINT32_MAX is a real check; cppcheck runs as unix32.
    +1 src/libs/kpunch/pkg/pkgManifest.c|compareValueOutOfTypeRangeError|Comparing expression of type 'unsigned long' against value 4294967295. Condition is always false.
    +# FP: the strdup'd name moves into m->files by arrput (freed by pkgManifestFree); the stb_ds OOM hook never returns, so it cannot be dropped.
    +1 src/libs/kpunch/pkg/pkgManifest.c|memleak|Memory leak: f.name
    +# FP: the strdup'd name moves into op->drop by arrput (freed with the operation); the stb_ds OOM hook never returns, so it cannot be dropped.
    +1 src/libs/kpunch/pkg/pkgOp.c|memleak|Memory leak: copy.name
    +# FP: &detached is passed only when pkgSigRead filled it (hasDetached); taking its address otherwise reads nothing.
    +1 src/libs/kpunch/pkg/pkgPlan.c|uninitvar|Uninitialized variable: &detached
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/libs/kpunch/print/printFont.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'bandSpan' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
    +# FP: the function is used as a callback whose signature is fixed by the callback type; the parameter cannot be const.
    +1 src/libs/kpunch/print/printRaster.c|constParameterCallback|Parameter 'ctx' can be declared as pointer to const. However it seems that 'pictureBand' is a callback function, if 'ctx' is declared with const you might also need to cast function pointer(s).
     # FP: style suggestion only (could be const), no defect.
     1 src/libs/kpunch/print/printText.c|constVariable|Variable 'order' can be declared as const array
     # FP: style suggestion only (could be const), no defect.
    diff --git a/src/test/lint/staticGate.sh b/src/test/lint/staticGate.sh
    index 26db6784..b1334f28 100755
    --- a/src/test/lint/staticGate.sh
    +++ b/src/test/lint/staticGate.sh
    @@ -491,7 +491,7 @@ fi
     # its jobserver, and the query then prints directory lines into the value.
     REAL_CC="${DJGPP_CC:-$(env -u MAKEFLAGS -u MFLAGS -u MAKELEVEL make -s --no-print-directory -f "$ROOT/src/common.mk" --eval 'printCc: ; @echo $(CC)' printCc)}"
     # The copied tree has no toolchains/, so its make gets the real tree's.
    -read -ra TOOLCHAIN_ARGS < <(env -u MAKEFLAGS -u MFLAGS -u MAKELEVEL make -s --no-print-directory -f "$ROOT/src/common.mk" --eval 'printTc: ; @echo DJGPP_PREFIX=$(DJGPP_PREFIX) CWSDSTUB=$(CWSDSTUB) FREETYPE_DIR=$(FREETYPE_DIR) FONT_SOURCES=$(FONT_SOURCES)' printTc)
    +read -ra TOOLCHAIN_ARGS < <(env -u MAKEFLAGS -u MFLAGS -u MAKELEVEL make -s --no-print-directory -f "$ROOT/src/common.mk" --eval 'printTc: ; @echo DJGPP_PREFIX=$(DJGPP_PREFIX) CWSDSTUB=$(CWSDSTUB) FREETYPE_DIR=$(FREETYPE_DIR) FONT_SOURCES=$(FONT_SOURCES) SOUNDFONT_SOURCES=$(SOUNDFONT_SOURCES)' printTc)
     
     # ------------------------------------------------------------
     # cppcheck
    diff --git a/src/test/music/Makefile b/src/test/music/Makefile
    index c17aa6bc..837a3e7c 100644
    --- a/src/test/music/Makefile
    +++ b/src/test/music/Makefile
    @@ -30,6 +30,13 @@
     # installMusicRefs.sh, compiled into this test only; lsan.supp covers its
     # one known leak).  Module renders are
     # checked against golden/render.txt (UPDATE_GOLDENS=1 rewrites it).
    +#
    +# playerTest runs the Media Player program (compiled from its project) in
    +# the shell fixture with the same libraries, the shell, and fileops
    +# linked in, on the fake sound driver.  recorderTest runs the Sound
    +# Recorder program the same way; the fake driver records (the test hands
    +# it the blocks).  The tests write their music and sound files with
    +# musicFiles.c.
     
     include ../dvxtest/dvxtest.mk
     
    @@ -42,25 +49,37 @@ XMP_DIR    = $(MUSIC_DIR)/thirdparty/libxmp-lite
     EMU_DIR    = $(DVXTEST_ROOT)/toolchains/emu8950
     BINDIR     = $(DVXTEST_BINDIR)/music
     MUSIC_TEST = $(BINDIR)/musicTest
    +PLAYER_TEST = $(BINDIR)/playerTest
    +RECORDER_TEST = $(BINDIR)/recorderTest
    +FILES_OBJ  = $(BINDIR)/musicFiles.o
    +SHELLDIR   = $(DVXTEST_ROOT)/src/libs/kpunch/dvxshell
    +SHELLTEST  = $(DVXTEST_ROOT)/src/test/shell
    +FILEOPS    = $(DVXTEST_ROOT)/src/libs/kpunch/fileops
    +APPS_SO    = $(DVXTEST_OBJDIR)/apps
    +SHELL_OBJS = $(patsubst $(SHELLDIR)/%.c,$(BINDIR)/shell/%.o,$(wildcard $(SHELLDIR)/*.c)) $(BINDIR)/shell/shellFixture.o
    +FOPS_OBJS  = $(patsubst $(FILEOPS)/%.c,$(BINDIR)/fileops/%.o,$(wildcard $(FILEOPS)/*.c))
     WORKDIR    = $(BINDIR)/work
     DRVDIR     = $(WORKDIR)/libs/kpunch/audio/drivers
     BANKFILE   = $(WORKDIR)/libs/kpunch/music/genmidi.op2
     GOLDEN     = $(abspath golden/render.txt)
    +DVXGM      = $(DVXTEST_ROOT)/obj/addons/dvxgm/SHARED/BANKS/KPUNCH/DVXGM.SF2
     
     LIB_SRCS   = $(filter-out $(MUSIC_DIR)/musicShell.c,$(wildcard $(MUSIC_DIR)/*.c)) $(filter-out $(AUDIO_DIR)/audioShell.c,$(wildcard $(AUDIO_DIR)/*.c))
     LIB_OBJS   = $(patsubst $(DVXTEST_ROOT)/src/libs/kpunch/%.c,$(BINDIR)/lib/%.o,$(LIB_SRCS))
     XMP_SRCS   = $(wildcard $(XMP_DIR)/src/*.c) $(wildcard $(XMP_DIR)/src/loaders/*.c)
    +XMP_HDRS   = $(wildcard $(XMP_DIR)/include/*.h $(XMP_DIR)/src/*.h $(XMP_DIR)/src/loaders/*.h)
     XMP_OBJS   = $(patsubst $(XMP_DIR)/%.c,$(BINDIR)/xmp/%.o,$(XMP_SRCS))
     XMP_OPTS   = -DLIBXMP_CORE_PLAYER -DLIBXMP_NO_PROWIZARD -DLIBXMP_NO_DEPACKERS -DBUILDING_STATIC -I$(XMP_DIR)/include -I$(XMP_DIR)/src
     EMU_OBJS   = $(BINDIR)/emu8950.o $(BINDIR)/emuadpcm.o
     
    -CFLAGS     = $(DVXTEST_CFLAGS) -I$(MUSIC_DIR) -I$(AUDIO_DIR) -I$(XMP_DIR)/include -I$(EMU_DIR) -I$(DVXTEST_ROOT)/src/libs/kpunch -DMUSIC_GOLDEN='"$(GOLDEN)"' -DMUSIC_WORKDIR='"$(WORKDIR)"' -DXMP_TEST_DIR='"$(XMP_DIR)/test"'
    +CFLAGS     = $(DVXTEST_CFLAGS) -I$(MUSIC_DIR) -I$(AUDIO_DIR) -I$(XMP_DIR)/include -I$(EMU_DIR) -I$(DVXTEST_ROOT)/src/libs/kpunch -DMUSIC_GOLDEN='"$(GOLDEN)"' -DMUSIC_WORKDIR='"$(WORKDIR)"' -DXMP_TEST_DIR='"$(XMP_DIR)/test"' -DMUSIC_ADDON_BANKS='"$(DVXTEST_ROOT)/src/addons/banks"' -DMUSIC_DVXGM='"$(DVXGM)"'
    +APP_CFLAGS = $(CFLAGS) -fno-omit-frame-pointer -I$(SHELLDIR) -I$(SHELLTEST) -I$(DVXTEST_BASICDIR) -DDVXTEST_APPS_DIR='"$(APPS_SO)"' -DDVXTEST_FIXTURES_DIR='"$(BINDIR)"' -DSHELLTEST_BASSTUB_SO='"$(DVXTEST_BASSTUB_SO)"' -DSHELLTEST_BASCOMP='"$(DVXTEST_BASCOMP)"' -DPLAYER_DRV_DIR='"$(DRVDIR)"' -DPLAYER_BANK='"$(BANKFILE)"'
     XMP_CFLAGS = $(filter-out $(WARNFLAGS) $(DEPFLAGS),$(DVXTEST_CFLAGS)) -w $(XMP_OPTS)
     
    -FAKE_DEFS  = -DFAKE_NAME='"pcm"' -DFAKE_PRIORITY=30 -DFAKE_CLOCK=0 -DFAKE_TONE=0 -DFAKE_DAC=1 -DFAKE_RATE=22050 -DFAKE_BITS=16 -DFAKE_CHANNELS=2 -DFAKE_OPEN_OK=1 -DFAKE_PLAY_OK=1
    -FAKE_DRVS  = $(DRVDIR)/pcm.drv $(DRVDIR)/fmfake.drv $(DRVDIR)/midifake.drv
    +FAKE_DEFS  = -DFAKE_NAME='"pcm"' -DFAKE_PRIORITY=30 -DFAKE_REC_SOURCES=7 -DFAKE_CLOCK=0 -DFAKE_TONE=0 -DFAKE_DAC=1 -DFAKE_RATE=22050 -DFAKE_BITS=16 -DFAKE_CHANNELS=2 -DFAKE_OPEN_OK=1 -DFAKE_PLAY_OK=1
    +FAKE_DRVS  = $(DRVDIR)/pcm.drv $(DRVDIR)/fmfake.drv $(DRVDIR)/midifake.drv $(DRVDIR)/wtfake.drv
     
    -.PHONY: all run clean
    +.PHONY: all run clean dvxgm host-tools
     
     all: run
     
    @@ -72,7 +91,23 @@ $(BINDIR)/lib/%.o: $(DVXTEST_ROOT)/src/libs/kpunch/%.c $(DVX_MAKEFILES)
     	@mkdir -p $(dir $@)
     	$(HOSTCC) $(CFLAGS) -c -o $@ $<
     
    -$(BINDIR)/xmp/%.o: $(XMP_DIR)/%.c $(DVX_MAKEFILES)
    +$(FILES_OBJ): musicFiles.c musicFiles.h $(DVX_MAKEFILES)
    +	@mkdir -p $(BINDIR)
    +	$(HOSTCC) $(CFLAGS) -c -o $@ $<
    +
    +$(BINDIR)/shell/shellFixture.o: $(SHELLTEST)/shellFixture.c $(DVX_MAKEFILES)
    +	@mkdir -p $(dir $@)
    +	$(HOSTCC) $(APP_CFLAGS) -c -o $@ $<
    +
    +$(BINDIR)/shell/%.o: $(SHELLDIR)/%.c $(DVX_MAKEFILES)
    +	@mkdir -p $(dir $@)
    +	$(HOSTCC) $(APP_CFLAGS) -c -o $@ $<
    +
    +$(BINDIR)/fileops/%.o: $(FILEOPS)/%.c $(DVX_MAKEFILES)
    +	@mkdir -p $(dir $@)
    +	$(HOSTCC) $(APP_CFLAGS) -c -o $@ $<
    +
    +$(BINDIR)/xmp/%.o: $(XMP_DIR)/%.c $(XMP_HDRS) $(DVX_MAKEFILES)
     	@mkdir -p $(dir $@)
     	$(HOSTCC) $(XMP_CFLAGS) -c -o $@ $<
     
    @@ -92,18 +127,38 @@ $(DRVDIR)/midifake.drv: $(FAKE_DIR)/fakeSynth.c $(AUDIO_DIR)/audioDrv.h $(DVX_MA
     	@mkdir -p $(DRVDIR)
     	$(HOSTCC) $(CFLAGS) -shared -o $@ $<
     
    +$(DRVDIR)/wtfake.drv: $(FAKE_DIR)/fakeSynth.c $(AUDIO_DIR)/audioDrv.h $(DVX_MAKEFILES)
    +	@mkdir -p $(DRVDIR)
    +	$(HOSTCC) $(CFLAGS) -shared -DFAKE_SYN_WT -o $@ $<
    +
     $(BANKFILE): $(MUSIC_DIR)/thirdparty/genmidi/GENMIDI.OP2
     	@mkdir -p $(dir $@)
     	cp $< $@
     
    -$(MUSIC_TEST): musicTest.c $(LIB_OBJS) $(XMP_OBJS) $(EMU_OBJS) $(DVX_MAKEFILES) $(DVXTEST_ARCHIVES) | dvxtest-libs
    +$(MUSIC_TEST): musicTest.c $(FILES_OBJ) $(LIB_OBJS) $(XMP_OBJS) $(EMU_OBJS) $(DVX_MAKEFILES) $(DVXTEST_ARCHIVES) | dvxtest-libs
     	@mkdir -p $(BINDIR)
    -	$(HOSTCC) $(CFLAGS) -o $@ $< $(LIB_OBJS) $(XMP_OBJS) $(EMU_OBJS) $(DVXTEST_LINK)
    +	$(HOSTCC) $(CFLAGS) -o $@ $< $(FILES_OBJ) $(LIB_OBJS) $(XMP_OBJS) $(EMU_OBJS) $(DVXTEST_LINK)
     
    --include $(LIB_OBJS:.o=.d) $(MUSIC_TEST).d $(FAKE_DRVS:.drv=.d)
    +$(PLAYER_TEST) $(RECORDER_TEST): $(BINDIR)/%: %.c $(FILES_OBJ) $(LIB_OBJS) $(XMP_OBJS) $(SHELL_OBJS) $(FOPS_OBJS) $(DVX_MAKEFILES) $(DVXTEST_ARCHIVES) $(DVXTEST_TASKS_A) $(DVXTEST_BASRT_A) $(DVXTEST_BASSTUB_SO) | dvxtest-libs dvxtest-apps dvxtest-bascomp
    +	@mkdir -p $(BINDIR)
    +	$(HOSTCC) $(APP_CFLAGS) -o $@ $< $(FILES_OBJ) $(LIB_OBJS) $(XMP_OBJS) $(SHELL_OBJS) $(FOPS_OBJS) $(DVXTEST_LDFLAGS) -Wl,--whole-archive $(DVXTEST_BASRT_A) $(DVXTEST_LIBDVX_A) $(DVXTEST_TASKS_A) $(DVXTEST_LIB) -Wl,--no-whole-archive -ldl -lm
     
    -run: $(MUSIC_TEST) $(FAKE_DRVS) $(BANKFILE)
    +host-tools:
    +	$(MAKE) -C $(DVXTEST_ROOT)/src/tools host COV=0
    +
    +dvxtest-apps: | host-tools
    +
    +-include $(LIB_OBJS:.o=.d) $(FILES_OBJ:.o=.d) $(SHELL_OBJS:.o=.d) $(FOPS_OBJS:.o=.d) $(MUSIC_TEST).d $(PLAYER_TEST).d $(RECORDER_TEST).d $(FAKE_DRVS:.drv=.d)
    +
    +# The General MIDI SoundFont add-on, as the build makes it (src/tools
    +# makes it again when it is out of date)
    +dvxgm:
    +	$(MAKE) -C $(DVXTEST_ROOT)/src/tools ../../obj/addons/dvxgm.stamp
    +
    +run: $(MUSIC_TEST) $(PLAYER_TEST) $(RECORDER_TEST) $(FAKE_DRVS) $(BANKFILE) dvxgm
     	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1 LSAN_OPTIONS=suppressions=$(abspath lsan.supp) ./musicTest
    +	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1:detect_odr_violation=0 LSAN_OPTIONS=suppressions=$(abspath lsan.supp) ./playerTest
    +	cd $(BINDIR) && ASAN_OPTIONS=detect_leaks=1:detect_odr_violation=0 LSAN_OPTIONS=suppressions=$(abspath lsan.supp) ./recorderTest
     
     clean:
     	rm -rf $(BINDIR)
    diff --git a/src/test/music/musicFiles.c b/src/test/music/musicFiles.c
    new file mode 100644
    index 00000000..f15236ea
    --- /dev/null
    +++ b/src/test/music/musicFiles.c
    @@ -0,0 +1,203 @@
    +// 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.
    +
    +// musicFiles.c  -- music and sound files the music tests write (see
    +// musicFiles.h)
    +
    +#include "musicFiles.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +
    +// An SMF: header of the type and division, then the tracks as given.
    +uint32_t buildSmf(uint8_t *buf, uint16_t type, uint16_t division, const uint8_t *const *tracks, const uint32_t *lens, int32_t count) {
    +    memcpy(buf, "MThd", 4);
    +    put32be(buf + 4, 6);
    +    buf[8]  = (uint8_t)(type >> 8);
    +    buf[9]  = (uint8_t)type;
    +    buf[10] = (uint8_t)(count >> 8);
    +    buf[11] = (uint8_t)count;
    +    buf[12] = (uint8_t)(division >> 8);
    +    buf[13] = (uint8_t)division;
    +
    +    uint32_t at = 14;
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        memcpy(buf + at, "MTrk", 4);
    +        put32be(buf + at + 4, lens[i]);
    +        memcpy(buf + at + 8, tracks[i], lens[i]);
    +        at += 8 + lens[i];
    +    }
    +
    +    return at;
    +}
    +
    +
    +// A flute (FLUTE_PROGRAM) playing A4 for half a second.
    +uint32_t fluteSmf(uint8_t *smf) {
    +    static const uint8_t track[] = { 0x00, 0xC0, FLUTE_PROGRAM, 0x00, 0x90, 69, 127, 0x60, 0x80, 69, 0, 0x00, 0xFF, 0x2F, 0x00 };
    +    const uint8_t *const tracks[] = { track };
    +    const uint32_t       lens[]   = { sizeof(track) };
    +
    +    return buildSmf(smf, 0, 96, tracks, lens, 1);
    +}
    +
    +
    +// A ProTracker MOD: one 32-byte square-wave sample (looping), one
    +// pattern: C-3 (period 428) on channel 1, row 16 a note cut, E-3 (339)
    +// on channel 2 at row 32, speed 6 (default tempo), then the song ends.
    +uint32_t makeMod(uint8_t *buf) {
    +    const uint32_t sampleWords = 16;
    +    uint32_t       at;
    +
    +    memset(buf, 0, 1084 + 1024 + sampleWords * 2);
    +    memcpy(buf, "dvx test", 8);
    +
    +    // Sample 1: length (words), finetune, volume, loop start, loop length.
    +    buf[20 + 22]     = 0;
    +    buf[20 + 23]     = (uint8_t)sampleWords;
    +    buf[20 + 25]     = 64;
    +    buf[20 + 29]     = (uint8_t)sampleWords;
    +    buf[950]         = 1;           // song length
    +    buf[951]         = 127;
    +    buf[952]         = 0;           // order 0: pattern 0
    +    memcpy(buf + 1080, "M.K.", 4);
    +    at               = 1084;
    +
    +    // Row 0 channel 0: sample 1, period 428
    +    buf[at]     = 0x01;
    +    buf[at + 1] = 0xAC;
    +    buf[at + 2] = 0x10;
    +    // Row 16 channel 0: note cut (EC0)
    +    buf[at + 16 * 16 + 2] = 0x0E;
    +    buf[at + 16 * 16 + 3] = 0xC0;
    +    // Row 32 channel 1: sample 1, period 339
    +    buf[at + 32 * 16 + 4]     = 0x01;
    +    buf[at + 32 * 16 + 4 + 1] = 0x53;
    +    buf[at + 32 * 16 + 4 + 2] = 0x10;
    +    at += 1024;
    +
    +    for (uint32_t i = 0; i < sampleWords * 2; i++) {
    +        buf[at + i] = (uint8_t)(i < sampleWords ? 100 : (uint8_t)-100);
    +    }
    +
    +    return at + sampleWords * 2;
    +}
    +
    +
    +void put16le(uint8_t *p, uint32_t v) {
    +    p[0] = (uint8_t)v;
    +    p[1] = (uint8_t)(v >> 8);
    +}
    +
    +
    +void put32be(uint8_t *p, uint32_t v) {
    +    p[0] = (uint8_t)(v >> 24);
    +    p[1] = (uint8_t)(v >> 16);
    +    p[2] = (uint8_t)(v >> 8);
    +    p[3] = (uint8_t)v;
    +}
    +
    +
    +void put32le(uint8_t *p, uint32_t v) {
    +    put16le(p, v);
    +    put16le(p + 2, v >> 16);
    +}
    +
    +
    +// Frame i of the sound files' sine (frames long), at amp.
    +int32_t soundSine(uint32_t i, uint32_t frames, int32_t amp) {
    +    uint32_t period = i < frames / 2 ? SOUND_PERIOD : SOUND_PERIOD * 2;
    +
    +    return (int32_t)lround(amp * sin(2.0 * M_PI * (double)(i % period) / period));
    +}
    +
    +
    +// A VOC file of soundSine: one sound block, 8-bit mono at SOUND_VOC_RATE.
    +uint32_t vocBuild(uint8_t *out, uint32_t frames) {
    +    uint32_t body = VOC_SOUND_BYTES + frames;
    +    uint8_t *p    = out + VOC_HEADER_BYTES + VOC_BLOCK_BYTES + VOC_SOUND_BYTES;
    +
    +    memcpy(out, "Creative Voice File\x1A", 20);
    +    put16le(out + 20, VOC_HEADER_BYTES);
    +    put16le(out + 22, VOC_VERSION);
    +    put16le(out + 24, (~VOC_VERSION + VOC_CHECK_MAGIC) & 0xFFFF);
    +    out[VOC_HEADER_BYTES] = VOC_BLOCK_SOUND;
    +    put16le(out + VOC_HEADER_BYTES + 1, body);
    +    out[VOC_HEADER_BYTES + 3] = (uint8_t)(body >> 16);
    +    out[VOC_HEADER_BYTES + 4] = (uint8_t)(VOC_TC_BASE - VOC_TC_CLOCK / SOUND_VOC_RATE);
    +    out[VOC_HEADER_BYTES + 5] = VOC_CODEC_PCM8;
    +
    +    for (uint32_t i = 0; i < frames; i++) {
    +        *p++ = (uint8_t)(SOUND_SILENCE_8 + soundSine(i, frames, SOUND_AMP_8));
    +    }
    +
    +    *p++ = 0;
    +    return (uint32_t)(p - out);
    +}
    +
    +
    +// A WAV file of soundSine at SOUND_RATE: bits 8 or 16, the sine on the
    +// left (stereo: the right silent), format tag tag.
    +uint32_t wavBuild(uint8_t *out, uint32_t bits, uint32_t channels, uint32_t frames, uint32_t tag) {
    +    uint32_t frameBytes = channels * bits / 8;
    +    uint32_t dataBytes  = frames * frameBytes;
    +    uint8_t *p          = out + WAV_HEADER_BYTES;
    +
    +    memcpy(out, "RIFF", 4);
    +    put32le(out + 4, WAV_HEADER_BYTES - 8 + dataBytes);
    +    memcpy(out + 8, "WAVEfmt ", 8);
    +    put32le(out + 16, 16);
    +    put16le(out + 20, tag);
    +    put16le(out + 22, channels);
    +    put32le(out + 24, SOUND_RATE);
    +    put32le(out + 28, SOUND_RATE * frameBytes);
    +    put16le(out + 32, frameBytes);
    +    put16le(out + 34, bits);
    +    memcpy(out + 36, "data", 4);
    +    put32le(out + 40, dataBytes);
    +
    +    for (uint32_t i = 0; i < frames; i++) {
    +        for (uint32_t c = 0; c < channels; c++) {
    +            if (bits == 8) {
    +                *p++ = (uint8_t)(c == 0 ? SOUND_SILENCE_8 + soundSine(i, frames, SOUND_AMP_8) : SOUND_SILENCE_8);
    +            } else {
    +                put16le(p, (uint32_t)(c == 0 ? soundSine(i, frames, SOUND_AMP_16) : 0));
    +                p += 2;
    +            }
    +        }
    +    }
    +
    +    return WAV_HEADER_BYTES + dataBytes;
    +}
    +
    +
    +void writeFile(const char *path, const void *data, uint32_t size) {
    +    FILE *f = fopen(path, "wb");
    +
    +    if (f) {
    +        fwrite(data, 1, size, f);
    +        fclose(f);
    +    }
    +}
    diff --git a/src/test/music/musicFiles.h b/src/test/music/musicFiles.h
    new file mode 100644
    index 00000000..7160dc2c
    --- /dev/null
    +++ b/src/test/music/musicFiles.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.
    +
    +// musicFiles.h  -- music and sound files the music tests write
    +// (musicTest.c, playerTest.c): Standard MIDI Files, a ProTracker module,
    +// and WAV and VOC files of a test sine, built in memory.
    +#ifndef MUSIC_FILES_H
    +#define MUSIC_FILES_H
    +
    +#include 
    +
    +// fluteSmf's instrument
    +#define FLUTE_PROGRAM       73                              // General MIDI's flute (0-based)
    +
    +// The sound files: a second of a sine, SOUND_PERIOD frames a turn for the
    +// first half and twice that for the second
    +#define SOUND_RATE          11025
    +#define SOUND_FRAMES        11025                           // a second
    +#define SOUND_MS            1000
    +#define SOUND_PERIOD        25
    +#define SOUND_HZ_FIRST      441.0                           // 11,025 / 25
    +#define SOUND_HZ_SECOND     220.5
    +#define SOUND_VOC_RATE      10000                           // a VOC's rate is a time constant: 256 - 1,000,000 / rate
    +#define SOUND_VOC_FRAMES    10000                           // a second
    +#define SOUND_VOC_HZ_FIRST  400.0
    +#define SOUND_VOC_HZ_SECOND 200.0
    +#define SOUND_AMP_16        12000
    +#define SOUND_AMP_8         60
    +#define SOUND_SILENCE_8     128
    +#define SOUND_BYTES_MAX     (SOUND_FRAMES * 4 + 64)         // 16-bit stereo and a header
    +
    +// WAV and VOC layout
    +#define WAV_HEADER_BYTES    44
    +#define WAV_TAG_PCM         1
    +#define WAV_TAG_ADPCM       2
    +#define VOC_HEADER_BYTES    26
    +#define VOC_VERSION         0x010A
    +#define VOC_CHECK_MAGIC     0x1234
    +#define VOC_BLOCK_SOUND     1
    +#define VOC_BLOCK_BYTES     4                               // its type and 24-bit size
    +#define VOC_SOUND_BYTES     2                               // the time constant and the codec
    +#define VOC_TC_BASE         256
    +#define VOC_TC_CLOCK        1000000
    +#define VOC_CODEC_PCM8      0
    +
    +uint32_t buildSmf(uint8_t *buf, uint16_t type, uint16_t division, const uint8_t *const *tracks, const uint32_t *lens, int32_t count);
    +uint32_t fluteSmf(uint8_t *smf);
    +uint32_t makeMod(uint8_t *buf);
    +void     put16le(uint8_t *p, uint32_t v);
    +void     put32be(uint8_t *p, uint32_t v);
    +void     put32le(uint8_t *p, uint32_t v);
    +int32_t  soundSine(uint32_t i, uint32_t frames, int32_t amp);
    +uint32_t vocBuild(uint8_t *out, uint32_t frames);
    +uint32_t wavBuild(uint8_t *out, uint32_t bits, uint32_t channels, uint32_t frames, uint32_t tag);
    +void     writeFile(const char *path, const void *data, uint32_t size);
    +
    +#endif
    diff --git a/src/test/music/musicTest.c b/src/test/music/musicTest.c
    index c386c43e..900893c1 100644
    --- a/src/test/music/musicTest.c
    +++ b/src/test/music/musicTest.c
    @@ -48,6 +48,29 @@
     //              software synthesizer), the MPU-401 port, live MIDI, pause,
     //              stop, position, volume, end calls as the owning program,
     //              two programs at once, clean-up when a program ends
    +//   wave       General MIDI on wavetable voices (a recording backend):
    +//              the region a note plays, its pitch, loudness, pan and
    +//              loop, bend and its range, volume, pan, the sustain pedal,
    +//              drums and exclusive classes, taking a voice, notes the bank
    +//              cannot play passed to the overflow, envelopes run here,
    +//              GM System On
    +//   waveload   SoundFont samples in the fake wavetable driver's memory:
    +//              what a song needs, full quality, 8-bit, half and a quarter
    +//              of the rate, samples left out, what stays for the next
    +//              song and what makes room
    +//   waveplay   whole musics on the wavetable synthesizer: played by the
    +//              timer, samples kept for the next music a while, notes the
    +//              SoundFont lacks on the FM chip, one music at a time,
    +//              midi = fm, a missing SoundFont, musicSetBank, live MIDI
    +//   softwave   the SoundFont software synthesizer: a live note at its
    +//              pitch (a loop's period), live MIDI loading a program's
    +//              samples as it is chosen, a note the bank lacks on the FM
    +//              software synthesizer, MusicInfo's "where"
    +//   dvxgm      the General MIDI SoundFont add-on as mksf makes it: every
    +//              instrument and the standard kit, mono, one velocity layer,
    +//              a few key regions an instrument, loops with their guard
    +//              frames, and a song of ten instruments and drums fitting a
    +//              512 KB card at full quality
     //   basic      the music.bas functions
     //   bank       the MIDI bank DVX.INI names (a vendor's, by name), else the
     //              bundled one (none named, missing, or damaged)
    @@ -64,11 +87,13 @@
     #include "dvxtest.h"
     #include "dvxPlat.h"
     #include "dvxPlatHost.h"
    +#include "musicFiles.h"
     #include "musicInt.h"
     #include "audioInt.h"
     #include "emu8950.h"
     #include "stb_ds.h"
     
    +#include 
     #include 
     #include 
     #include 
    @@ -78,10 +103,23 @@
     #define BANK_ASIDE          "libs/kpunch/music/genmidi.off"   // the bundled bank, moved aside
     #define BANK_ALT_DIR        DVX_USER_DIR "/" DVX_BANKS_DIR "/ACME"
     #define BANK_ALT_FILE       BANK_ALT_DIR "/ALT.OP2"
    -#define BANK_ALT_NOTE       77                              // instrument 0's fixed note in ALT.OP2
    +#define BANK_ALT_NAME       "ACME\\ALT.OP2"                // as DVX.INI and musicSetBank name it
    +// ALT.OP2 is the standard bank with the flute an octave up, so the pitch
    +// of a flute A4 says which bank played it: 440 Hz, or 880 Hz on ALT.OP2.
     #define BANK_SIGNATURE      8                               // "#OPL_II#", then the instruments
    -#define BANK_FIXED_NOTE     3                               // an instrument's fixed note byte
    -#define BANK_FIXED_NOTE_AT  (BANK_SIGNATURE + BANK_FIXED_NOTE)
    +#define BANK_INSTR_BYTES    36
    +#define BANK_VOICE_AT       4                               // in an instrument: its two voices
    +#define BANK_VOICE_BYTES    16
    +#define BANK_NOTE_AT        14                              // in a voice: the note offset (16 bits, little-endian)
    +#define BANK_OCTAVE         12
    +#define FLUTE_HZ            440.0                           // the A4 it plays
    +#define FLUTE3_DIVISION     500                             // the three-note song: 1 tick a millisecond
    +#define FLUTE3_APART_MS     700                             // its notes start this far apart
    +#define FLUTE3_SETTLE_MS    100                             // looked at from this far into each
    +#define FLUTE3_WINDOW       (RATE_DEV / 5)                  // for 200 ms
    +#define ADDON_BANK_SUBDIR   "/SHARED/BANKS/KPUNCH"          // where an add-on bank package's bank is
    +#define ADDON_BANK_COPY     DVX_USER_DIR "/" DVX_BANKS_DIR "/ADDON.OP2"
    +#define FLUTE_MACRO         "T120 I74 O3 A2"                // the same note as a macro (I is 1-based; O3 holds A 440)
     #define BUF_MAX             8192
     #define REC_MAX             256
     #define SYSEX_REC_MAX       64
    @@ -95,6 +133,22 @@
     #define LCG_ADD             12345U
     #define APP_A               5
     #define APP_B               6
    +
    +// MIDI recordings (midiRecordTests)
    +#define MIDIREC_FILE        "MIDIREC.MID"
    +#define MIDIREC_NO_DIR      "NODIR/MIDIREC.MID"
    +#define MIDIREC_CHANNEL     2
    +#define MIDIREC_FLUTE       73
    +#define MIDIREC_NOTE        60
    +#define MIDIREC_NOTE2       64
    +#define MIDIREC_VELOCITY    100
    +#define MIDIREC_FIRST_MS    250
    +#define MIDIREC_EARLY_MS    200      // before the last: counts as MIDIREC_FIRST_MS
    +#define MIDIREC_OFF_MS      1000
    +#define MIDIREC_LAST_MS     20000
    +#define MIDIREC_CLOCK       0xF8     // a real-time byte, not a channel message
    +#define MIDIREC_BAS_SLOTS   8        // musicBas.c's BAS_RECORD_SLOTS
    +#define US_PER_MS           1000
     #define RATE_DEV            22050
     #define FULL                MUSIC_VOLUME_MAX
     #define US_SECOND           1000000U
    @@ -127,6 +181,135 @@
     #define OPL2_CLOCK          3579545
     #define EMU_TYPE_YM3812     2
     
    +// The wavetable synthesizer
    +#define WT_FAKE_MEMORY      (1024 * 1024)                   // the fake driver's memory as the tests keep it
    +#define WT_VOICES_MAX       32
    +#define WT_RAMP_ADDR        1000                            // where the renderer tests say the samples are
    +#define WT_HIT_ADDR         5000
    +#define WT_RAMP_CENTS       (-1200)                         // Ramp: 22,050 Hz
    +#define WT_HIT_CENTS        (-2400)                         // Hit: 11,025 Hz
    +#define WT_RAMP_FRAMES      108                             // Ramp's 100 and 8 guard frames (Hit's)
    +#define WT_HIT_FRAMES       100                             // Hit's (nothing after it)
    +#define WT_RAMP_BYTES       (WT_RAMP_FRAMES * 2)            // Ramp in memory, 16-bit
    +#define WT_PIANO_PITCH      (-3190)                         // Piano key 40: Ramp 20 keys under its root, fine tune 10
    +#define WT_PIANO_ATTEN      618                             // the region's 1440 x 0.4, CC7 100's 42
    +#define WT_CC7_ATTEN        42                              // 400 log10(127 / 100)
    +#define WT_DRUM_PITCH       (-4405)                         // Kit key 40: Hit 20 keys under 60, -5 cents
    +#define WT_DRUM_ATTEN       84                              // velocity 100's 42 and CC7's 42
    +#define WT_BEND_UP          199                             // +8191 at 2 semitones
    +#define WT_FLAGS_HW         (AUDIO_WT_ENVELOPES | AUDIO_WT_LFOS)
    +#define GM_PROGRAMS         128
    +#define GM_REGIONS_MAX      4                               // mksf's most key regions an instrument
    +#define GM_RATE_MAX         23000                           // mksf's 22,050 Hz, moved up to keep a short loop whole
    +#define GM_GUARD            8                               // frames after a loop: its first ones again
    +#define SF_TEST_NAME        "TEST.SF2"
    +#define SF_TEST_FILE        DVX_USER_DIR "/" DVX_BANKS_DIR "/" SF_TEST_NAME
    +#define SF_LIVE_NAME        "LIVE.SF2"                      // Piano on Kit: Hit only
    +#define SF_LIVE_FILE        DVX_USER_DIR "/" DVX_BANKS_DIR "/" SF_LIVE_NAME
    +#define SOFT_KEY            59                              // Piano: Ramp's loop, 90 cents under its root
    +#define SOFT_HZ             261.6                           // 22,050 / 80 frames x 2^(-90 / 1200)
    +
    +// Modules on the wavetable synthesizer (modHwTests)
    +#define MODHW_SAMPLE_BYTES  80                              // makeMod's 32 frames and 8 guard frames, 16-bit
    +#define MODHW_PITCH_428     (-2895)                         // period 428: 8,287 Hz (libxmp's PAL rate)
    +#define MODHW_PITCH_339     (-2495)                         // period 339: its note, a major third up
    +#define MODHW_PITCH_XM_C4   (-2879)                         // an Extended Module's C-4: 8,363 Hz
    +#define MODHW_ATTEN_FULL    80                              // volume 64: libxmp's level
    +#define MODHW_ATTEN_HALF    60                              // the music at half: 6 dB more
    +#define MODHW_FRAME_MS      20                              // a tick at 125 BPM
    +#define MODHW_CUT_MS        2000                            // after row 16's note cut (1,920 ms)
    +#define MODHW_NOTE2_MS      3900                            // after row 32's note (3,840 ms)
    +#define MODHW_ORDER_MS      7680                            // makeMod's pattern: 64 rows of 6 ticks
    +#define MODHW_SEEK_MS       9000                            // into the twice-played song's second pattern
    +#define MODHW_SEEK_TOL_MS   (MODHW_FRAME_MS * 2)
    +#define MODHW_SMALL_MEMORY  256                             // the module's sample or Ramp, not both
    +#define XM_FRAMES           16                              // makeXm's sample: frame i is i x 8, 8-bit
    +#define XM_LOOP_START       4                               // ... a ping-pong loop of 4 .. 11
    +#define XM_LOOP_LEN         8
    +#define XM_STORED           20                              // through the loop's end, then its way back
    +#define XM_FRAME_STEP       8
    +#define XM_NOTE_C4          49
    +#define XM_HEADER_SIZE      276
    +#define XM_INSTRUMENT_SIZE  263
    +#define XM_SAMPLE_HEADER    40
    +#define XM_ROWS             64
    +#define XM_PINGPONG         2
    +
    +// Gravis UltraSound patch sets the tests write (patWrite): the file's
    +// layout (a 129-byte header with the wave count at 85, a 63-byte
    +// instrument, a 47-byte layer, then each wave's 96-byte header and data)
    +#define PAT_DIR             "GUS"                           // in the work folder: ULTRASND.INI, MIDI, OTHER
    +#define PAT_LEGACY_DIR      "GUS2"                          // [Melodic Patches] / [Drum Patches]
    +#define PAT_EMPTY_DIR       "GUS3"                          // an INI naming no patch there is
    +#define PAT_NONE_DIR        "GUS4"                          // no INI
    +#define PAT_HEAD_BYTES      129
    +#define PAT_SKIP_BYTES      (63 + 47)
    +#define PAT_WAVE_HEAD       96
    +#define PAT_WAVES_AT        85
    +#define PAT_W_SIZE          8
    +#define PAT_W_LOOP_START    12
    +#define PAT_W_LOOP_END      16
    +#define PAT_W_RATE          20
    +#define PAT_W_LOW           22
    +#define PAT_W_HIGH          26
    +#define PAT_W_ROOT          30
    +#define PAT_W_BALANCE       36
    +#define PAT_W_MODES         55
    +#define PAT_W_SCALE_KEY     56
    +#define PAT_W_SCALE_FACTOR  58
    +#define PAT_MODE_16BIT      0x01
    +#define PAT_MODE_UNSIGNED   0x02
    +#define PAT_MODE_LOOP       0x04
    +#define PAT_RATE            22050
    +#define PAT_LOW_ALL         8176                            // milli-Hz: key 0 ...
    +#define PAT_HIGH_ALL        12543853                        // ... to key 127
    +#define PAT_SINE_ROOT       441000                          // a 50-frame period at 22,050 Hz
    +#define PAT_SINE_PERIOD     50
    +#define PAT_SINE_FRAMES     500
    +#define PAT_SINE_LOOP       100
    +#define PAT_SINE_CENTS      (-904)                          // fine tune: key 60 less 441 Hz's 69.04
    +#define PAT_SPLIT_HIGH      270000                          // TWO's first wave: up to key 60
    +#define PAT_PIVOT           60
    +#define PAT_SEMI            1024                            // scale factor: a semitone a key
    +#define PAT_AMP_16          16000
    +#define PAT_AMP_8           100
    +#define PAT_SILENCE_8       128
    +#define PAT_KICK_KEY        36
    +#define PAT_KEY_TOP         127
    +#define PAT_A4_KEY          69
    +#define PAT_A4_HZ           440.0
    +#define PAT_FILE_MAX        2048
    +
    +// The SoundFont 1 bank sf1Build writes: a ROM sample, then one of its own
    +#define SF1_TEST_NAME       "TEST.SBK"
    +#define SF1_TEST_FILE       DVX_USER_DIR "/" DVX_BANKS_DIR "/" SF1_TEST_NAME
    +#define SF1_ROM_START       0x1EE
    +#define SF1_ROM_END         0x400
    +#define SF1_ROM_LOOP        0x300
    +#define SF1_ROM_LOOP_END    0x3F0
    +#define SF1_RAM_FRAMES      120
    +#define SF1_RAM_END         100
    +#define SF1_RAM_LOOP        10
    +#define SF1_RAM_LOOP_END    90
    +#define SF1_ROOT_PITCH      6950        // samplePitch: 69 and 50 cents
    +#define SF1_ATTEN_64        59          // 0..127 level 64: 200 log10(127 / 64) cB
    +#define SF1_SOUND_DIR       "SB16"      // in the work folder: SOUND's
    +
    +// soundTests: its steps of time, what is heard, and places in the sound
    +// files (musicFiles.h)
    +#define SOUND_STEP          (RATE_DEV / 4)                  // pump(SOUND_STEP) lets SOUND_STEP_MS pass
    +#define SOUND_STEP_MS       250
    +#define SOUND_SHORT         (RATE_DEV / 10)
    +#define SOUND_SHORT_MS      100
    +#define SOUND_HELD          4                               // device frames: the mixer's current and next source frames
    +#define SOUND_TONE_RATIO    4.0                             // a tone heard stands this far over the other
    +#define SOUND_POS_TOL_MS    10
    +#define SOUND_SEEK_MS       800                             // into the second half
    +#define SOUND_LOOP_MS       900
    +#define SOUND_CUT_MS        400                             // a WAV whose file ends this far into its frames
    +#define SOUND_CUT_BYTES     30                              // a WAV that ends inside its fmt chunk
    +#define MS_SECOND           1000
    +
     // A recording sink
     typedef struct {
         MidiSinkT sink;
    @@ -147,6 +330,20 @@ typedef struct {
         int32_t releases;
     } RecBackT;
     
    +// A recording wavetable backend: voices 1..limit, each as last started or
    +// updated
    +typedef struct {
    +    AudioWtVoiceT voice[WT_VOICES_MAX + 1];
    +    uint32_t      what[WT_VOICES_MAX + 1];
    +    bool          used[WT_VOICES_MAX + 1];
    +    bool          active[WT_VOICES_MAX + 1];
    +    bool          released[WT_VOICES_MAX + 1];
    +    int32_t       limit;
    +    int32_t       last;         // the voice started last
    +    int32_t       starts;
    +    int32_t       frees;
    +} RecWaveT;
    +
     typedef struct {
         char     name[GOLDEN_NAME_MAX];
         uint64_t hash;
    @@ -163,16 +360,46 @@ static int32_t  sGoldenCount = 0;
     static bool     sGoldenDirty = false;
     static bool     sUpdate      = false;
     static int32_t  sEnds        = 0;
    +static uint8_t  sWtMemory[WT_FAKE_MEMORY];
    +static AudioWtVoiceT sWtVoice[WT_VOICES_MAX];
    +static uint32_t sWtActive    = 0;   // the fake driver's voices sounding (bits)
    +static uint32_t sWtReleased  = 0;
    +static uint32_t sWtLast      = 0;
    +static int32_t  sWtStarts    = 0;
    +static int32_t  sWtUploads   = 0;
     static int32_t  sEndApp      = -1;
     static uint32_t sSeed        = 1;
     
    +// A wave patWrite writes: frames of a sine of period frames (16-bit
    +// signed, or 8-bit unsigned), looping from loopStart (none: 0)
    +typedef struct {
    +    uint32_t frames;
    +    uint32_t period;
    +    uint32_t loopStart;
    +    uint32_t low;           // milli-Hz
    +    uint32_t high;
    +    uint32_t root;
    +    uint16_t scaleFactor;
    +    uint8_t  modes;
    +} PatWaveSpecT;
    +
    +// A SoundFont the tests build (sfBuild), and what a test may spoil in it
    +typedef struct {
    +    uint16_t version;       // ifil major (2)
    +    uint32_t hitEnd;        // the second sample's end (200)
    +    uint16_t instrument;    // preset 0's instrument (0)
    +} SfSpecT;
    +
    +#define SF_TEST_MAX         4096
    +#define SF_TEST_FRAMES      200
    +
     // ============================================================
     // Prototypes
     // ============================================================
     
    +static double   bankPitch(MusicT *m);
     static void     bankTests(void);
     static void     basicTests(void);
    -static uint32_t buildSmf(uint8_t *buf, uint16_t type, uint16_t division, const uint8_t *const *tracks, const uint32_t *lens, int32_t count);
     static void     checkGolden(const char *name, uint64_t hash);
     static double   cmpGoertzel(const float *x, int32_t n, double hz, double rate);
     static double   cmpPitch(const float *x, int32_t n, double rate);
    @@ -180,26 +407,38 @@ static void     cmpRender(const uint8_t *regs, const uint8_t *vals, int32_t coun
     static void     cmpTests(void);
     void            fakeDrvEvent(const char *driver, const char *event);
     void            fakeDrvTone(uint32_t hz);
    +void            fakeSynthEvent(const char *synth, const char *event);
    +bool            fakeSynthGet(uint8_t *byte);
     bool            fakeSynthPut(uint8_t byte);
     void            fakeSynthWrite(uint32_t reg, uint8_t value);
    +bool            fakeWtActive(uint32_t voice);
    +void            fakeWtRelease(uint32_t voice);
    +void            fakeWtStart(uint32_t voice, const AudioWtVoiceT *v);
    +void            fakeWtStop(uint32_t voice);
    +void            fakeWtUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what);
    +bool            fakeWtUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits);
     static void     fmTests(void);
    +static void     gmBankTests(void);
     static uint64_t hash16(const int16_t *p, uint32_t count);
     static int32_t  keyedChannel(void);
     static void     loadGoldens(void);
    -static uint32_t makeMod(uint8_t *buf);
     static uint32_t makeS3m(uint8_t *buf);
    +static uint32_t makeXm(uint8_t *buf);
    +static void     midiRecordTests(void);
     static void     mmlPlayTests(void);
     static void     mmlTests(void);
    +static void     modHwTests(void);
    +static int16_t  modHwWord(uint32_t addr, uint32_t frame);
     static void     mt32Tests(void);
     static void     onEnd(MusicT *music, void *userData);
     static void     op2Tests(void);
     static int32_t  outPeak(uint32_t frames);
    +static int32_t  outPeakFrom(uint32_t from, uint32_t frames);
     static double   outTone(uint32_t from, uint32_t n, double hz);
    +static void     patTests(void);
    +static void     patWrite(const char *path, const PatWaveSpecT *waves, int32_t count);
     static void     playTests(void);
     static void     pump(uint32_t frames);
    -static void     put16le(uint8_t *p, uint32_t v);
    -static void     put32be(uint8_t *p, uint32_t v);
    -static void     put32le(uint8_t *p, uint32_t v);
     static int32_t  recAlloc(void *owner);
     static void     recMessage(MidiSinkT *sink, uint8_t status, uint8_t data1, uint8_t data2);
     static void     recRelease(void *owner, int32_t voice);
    @@ -211,57 +450,205 @@ static uint32_t rng(void);
     static void     saveGoldens(void);
     static void     seqTests(void);
     static void     setIni(const char *text);
    +static uint32_t sf1Build(uint8_t *out);
    +static void     sf1Tests(void);
    +static uint32_t sfBuild(const SfSpecT *spec, uint8_t *out);
    +static void     sfChunk(uint8_t *out, uint32_t *len, const char *id, const uint8_t *body, uint32_t bodyLen);
    +static void     sfTests(void);
     static void     smfTests(void);
    -static void     writeFile(const char *path, const void *data, uint32_t size);
    +static void     softwaveTests(void);
    +static bool     soundNear(uint32_t ms, uint32_t want);
    +static void     soundTests(void);
    +static void     waveLoadTests(void);
    +static void     wavePlayTests(void);
    +static void     waveTests(void);
    +static bool     wrecActive(void *owner, int32_t voice);
    +static int32_t  wrecAlloc(void *owner);
    +static void     wrecFree(void *owner, int32_t voice);
    +static void     wrecRelease(void *owner, int32_t voice);
    +static void     wrecStart(void *owner, int32_t voice, const AudioWtVoiceT *v);
    +static void     wrecUpdate(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what);
     static void     xmpTests(void);
     
     
    -// The MIDI bank DVX.INI names, found by name in the banks folders (an
    -// add-on vendor's folder too); the bundled one when it names none, or
    -// one that is missing or damaged.
    +// Play m (a flute A4) a moment, free it, and say which octave sounded:
    +// FLUTE_HZ (the standard bank), twice it (ALT.OP2), or 0 for neither.
    +static double bankPitch(MusicT *m) {
    +    double low;
    +    double high;
    +
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    musicFree(m);
    +    low  = outTone(RATE_DEV / 20, RATE_DEV / 8, FLUTE_HZ);
    +    high = outTone(RATE_DEV / 20, RATE_DEV / 8, FLUTE_HZ * 2);
    +
    +    if (low > 4.0 * outTone(RATE_DEV / 20, RATE_DEV / 8, FLUTE_HZ / 2) && low > high) {
    +        return FLUTE_HZ;
    +    }
    +
    +    return high > 4.0 * low ? FLUTE_HZ * 2 : 0;
    +}
    +
    +
     static void bankTests(void) {
    -    int32_t         len  = 0;
    -    char           *data = platformReadFile(MUSIC_BANK_DEFAULT, &len);
    -    const Op2BankT *bank;
    -    int32_t         err;
    +    static uint8_t smf[BUF_MAX];
    +    int32_t        len  = 0;
    +    char          *data = platformReadFile(MUSIC_BANK_DEFAULT, &len);
    +    uint32_t       size = fluteSmf(smf);
    +    MusicT        *m    = NULL;
    +    MusicT        *m2   = NULL;
     
    -    tCase("bank: the one DVX.INI names, else the bundled one");
    -    TEST_TRUE(data != NULL && len > BANK_FIXED_NOTE_AT);
    +    tCase("bank: DVX.INI's choice, read as each MIDI music starts");
    +    TEST_TRUE(data != NULL && len >= BANK_SIGNATURE + (FLUTE_PROGRAM + 1) * BANK_INSTR_BYTES);
     
    -    if (!data || len <= BANK_FIXED_NOTE_AT) {
    +    if (!data || len < BANK_SIGNATURE + (FLUTE_PROGRAM + 1) * BANK_INSTR_BYTES) {
             free(data);
             return;
         }
     
    +    for (int32_t v = 0; v < 2; v++) {
    +        uint8_t *note   = (uint8_t *)data + BANK_SIGNATURE + FLUTE_PROGRAM * BANK_INSTR_BYTES + BANK_VOICE_AT + v * BANK_VOICE_BYTES + BANK_NOTE_AT;
    +        int32_t  offset = (int16_t)(note[0] | (note[1] << 8)) + BANK_OCTAVE;
    +
    +        note[0] = (uint8_t)offset;
    +        note[1] = (uint8_t)(offset >> 8);
    +    }
    +
         TEST_EQ(platformMkdirRecursive(BANK_ALT_DIR), 0);
    -    data[BANK_FIXED_NOTE_AT] = BANK_ALT_NOTE;
         writeFile(BANK_ALT_FILE, data, (uint32_t)len);
         free(data);
     
    -    setIni("midiBank = ACME\\ALT.OP2\n");
    -    bank = musicBank(&err);
    -    TEST_EQ(err, MUSIC_OK);
    -    TEST_TRUE(bank && bank->instruments[0].fixedNote == BANK_ALT_NOTE);
    -    musicShutdown();
    +    setIni("midi = software\nmidiBank = " BANK_ALT_NAME "\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ * 2);
     
    -    setIni("midiBank = NOBANK.OP2\n");
    -    bank = musicBank(&err);
    -    TEST_EQ(err, MUSIC_OK);
    -    TEST_TRUE(bank && bank->instruments[0].fixedNote != BANK_ALT_NOTE);
    -    musicShutdown();
    +    // Changed in DVX.INI: the next music has it, with no restart
    +    setIni("midi = software\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ);
     
    +    tCase("bank: a music playing keeps its bank when the choice changes");
    +    setIni("midi = software\nmidiBank = " BANK_ALT_NAME "\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, true), MUSIC_OK);
    +    setIni("midi = software\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m2), MUSIC_OK);
    +    TEST_EQ(musicPlay(m2, true), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outTone(RATE_DEV / 20, RATE_DEV / 8, FLUTE_HZ) > 0);
    +    TEST_TRUE(outTone(RATE_DEV / 20, RATE_DEV / 8, FLUTE_HZ * 2) > 0);
    +    musicFree(m2);
    +    pump(RATE_DEV / 8);
    +    musicFree(m);
    +
    +    tCase("bank: a missing or damaged choice is the standard one");
    +    setIni("midi = software\nmidiBank = NOBANK.OP2\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ);
    +
    +    tCase("bank: musicSetBank for one music, read at once");
    +    setIni("midi = software\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, BANK_ALT_NAME), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ * 2);
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, BANK_ALT_NAME), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, NULL), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ);
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, "NOBANK.OP2"), MUSIC_ERR_BANK);
    +    TEST_EQ(musicSetBank(m, ""), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, BANK_ALT_NAME), MUSIC_ERR_ARG);
    +    musicFree(m);
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, BANK_ALT_NAME), MUSIC_ERR_ARG);
    +    musicFree(m);
    +    TEST_EQ(musicSetBank(NULL, ""), MUSIC_ERR_ARG);
    +
    +    tCase("bank: a damaged bank");
         writeFile(BANK_ALT_FILE, "junk", 4);
    -    setIni("midiBank = ACME/ALT.OP2\n");
    -    bank = musicBank(&err);
    -    TEST_EQ(err, MUSIC_OK);
    -    TEST_TRUE(bank && bank->instruments[0].fixedNote != BANK_ALT_NOTE);
    -    musicShutdown();
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, BANK_ALT_NAME), MUSIC_ERR_BANK);
    +    musicFree(m);
    +    setIni("midi = software\nmidiBank = " BANK_ALT_NAME "\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ);
    +
    +    tCase("bank: musicLoadMacro plays the macro on its instruments");
    +    setIni("midi = software\n");
    +    TEST_EQ(musicLoadMacro(FLUTE_MACRO, &m), MUSIC_OK);
    +    TEST_TRUE(bankPitch(m) == FLUTE_HZ);
    +    TEST_EQ(musicLoadMacro("T120 O3 A2", &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outTone(RATE_DEV / 20, RATE_DEV / 8, FLUTE_HZ) > 0);
    +    musicFree(m);
    +    TEST_EQ(musicLoadMacro("C D X", &m), MUSIC_ERR_MACRO);
    +    TEST_TRUE(m == NULL);
    +    TEST_EQ(musicLoadMacro(NULL, &m), MUSIC_ERR_ARG);
         remove(BANK_ALT_FILE);
    +
    +    // Each of DVX's own add-on OP2 banks (src/addons/banks/NAME) is a
    +    // bank the music plays a flute A on, in one octave or the other
    +    tCase("bank: DVX's add-on banks");
    +    DIR    *dir    = opendir(MUSIC_ADDON_BANKS);
    +    int32_t addons = 0;
    +
    +    TEST_TRUE(dir != NULL);
    +
    +    for (struct dirent *e = dir ? readdir(dir) : NULL; e; e = readdir(dir)) {
    +        char  folder[DVX_MAX_PATH];
    +        DIR  *sub;
    +
    +        if (e->d_name[0] == '.' || e->d_type != DT_DIR) {
    +            continue;
    +        }
    +
    +        snprintf(folder, sizeof(folder), "%s/%s" ADDON_BANK_SUBDIR, MUSIC_ADDON_BANKS, e->d_name);
    +        sub = opendir(folder);
    +        TEST_TRUE(sub != NULL);
    +
    +        for (struct dirent *f = sub ? readdir(sub) : NULL; f; f = readdir(sub)) {
    +            char    path[DVX_MAX_PATH * 2];
    +            char   *bytes;
    +            int32_t bankSize = 0;
    +            double  hz;
    +
    +            if (!dvxHasExt(f->d_name, ".OP2")) {
    +                continue;
    +            }
    +
    +            snprintf(path, sizeof(path), "%s/%s", folder, f->d_name);
    +            bytes = platformReadFile(path, &bankSize);
    +            TEST_TRUE(bytes != NULL);
    +            writeFile(ADDON_BANK_COPY, bytes, (uint32_t)bankSize);
    +            free(bytes);
    +            TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +            TEST_EQ(musicSetBank(m, "ADDON.OP2"), MUSIC_OK);
    +            hz = bankPitch(m);
    +            TEST_TRUE(hz == FLUTE_HZ || hz == FLUTE_HZ * 2);
    +            addons++;
    +        }
    +
    +        if (sub) {
    +            closedir(sub);
    +        }
    +    }
    +
    +    if (dir) {
    +        closedir(dir);
    +    }
    +
    +    TEST_TRUE(addons > 0);
    +    remove(ADDON_BANK_COPY);
     }
     
     
     static void basicTests(void) {
         static uint8_t smf[BUF_MAX];
    +    static uint8_t wav[SOUND_BYTES_MAX];
         const uint8_t  track[] = { 0x00, 0xC0, 73, 0x00, 0x90, 69, 100, 0x81, 0x40, 0x80, 69, 0, 0x00, 0xFF, 0x2F, 0x00 };
         const uint8_t *tracks[] = { track };
         uint32_t       lens[] = { sizeof(track) };
    @@ -306,6 +693,19 @@ static void basicTests(void) {
         TEST_EQ(MusicPlaying(h), 0);
         TEST_EQ(MusicPosition(h), 0);
     
    +    tCase("basic: a sound file; one that cannot be played");
    +    writeFile("BASTEST.WAV", wav, wavBuild(wav, 16, 1, SOUND_FRAMES, WAV_TAG_PCM));
    +
    +    int32_t snd = MusicLoad("BASTEST.WAV");
    +
    +    TEST_TRUE(snd > 0);
    +    TEST_STR_EQ(MusicInfo(snd, "kind"), "sound");
    +    TEST_EQ(MusicLength(snd), SOUND_MS);
    +    MusicFree(snd);
    +    writeFile("BASTEST.WAV", wav, wavBuild(wav, 16, 1, SOUND_FRAMES, WAV_TAG_ADPCM));
    +    TEST_EQ(MusicLoad("BASTEST.WAV"), 0);
    +    TEST_STR_EQ(MusicError(), audioErrorText(AUDIO_ERR_UNSUPPORTED));
    +
         tCase("basic: live MIDI with channels 1..16");
         int32_t live = MusicMidiOpen();
     
    @@ -324,6 +724,23 @@ static void basicTests(void) {
         MusicNoteOn(live, 0, 60, 100);
         TEST_STR_EQ(MusicError(), "Invalid argument");
     
    +    tCase("basic: MusicLoadMacro, MusicSetBank");
    +
    +    int32_t tune = MusicLoadMacro(FLUTE_MACRO);
    +
    +    TEST_TRUE(tune > 0);
    +    TEST_STR_EQ(MusicInfo(tune, "kind"), "midi");
    +    MusicSetBank(tune, "NOBANK.OP2");
    +    TEST_STR_EQ(MusicError(), "The instrument bank for MIDI music is missing or damaged");
    +    MusicSetBank(tune, "");
    +    TEST_STR_EQ(MusicError(), "No error");
    +    MusicPlay(tune, 0);
    +    MusicSetBank(tune, "");
    +    TEST_STR_EQ(MusicError(), "Invalid argument");
    +    MusicFree(tune);
    +    TEST_EQ(MusicLoadMacro("C D X"), 0);
    +    TEST_STR_EQ(MusicError(), "The music string has a mistake in it");
    +
         tCase("basic: numbers belong to their program; errors");
         sAppId = APP_B;
         MusicStop(h);
    @@ -349,30 +766,6 @@ static void basicTests(void) {
     }
     
     
    -// An SMF: header of the type and division, then the tracks as given.
    -static uint32_t buildSmf(uint8_t *buf, uint16_t type, uint16_t division, const uint8_t *const *tracks, const uint32_t *lens, int32_t count) {
    -    memcpy(buf, "MThd", 4);
    -    put32be(buf + 4, 6);
    -    buf[8]  = (uint8_t)(type >> 8);
    -    buf[9]  = (uint8_t)type;
    -    buf[10] = (uint8_t)(count >> 8);
    -    buf[11] = (uint8_t)count;
    -    buf[12] = (uint8_t)(division >> 8);
    -    buf[13] = (uint8_t)division;
    -
    -    uint32_t at = 14;
    -
    -    for (int32_t i = 0; i < count; i++) {
    -        memcpy(buf + at, "MTrk", 4);
    -        put32be(buf + at + 4, lens[i]);
    -        memcpy(buf + at + 8, tracks[i], lens[i]);
    -        at += 8 + lens[i];
    -    }
    -
    -    return at;
    -}
    -
    -
     static void checkGolden(const char *name, uint64_t hash) {
         for (int32_t i = 0; i < sGoldenCount; i++) {
             if (strcmp(sGolden[i].name, name) == 0) {
    @@ -645,6 +1038,19 @@ void fakeDrvTone(uint32_t hz) {
     }
     
     
    +void fakeSynthEvent(const char *synth, const char *event) {
    +    (void)synth;
    +    (void)event;
    +}
    +
    +
    +// The fake MIDI port's input: nothing comes here.
    +bool fakeSynthGet(uint8_t *byte) {
    +    (void)byte;
    +    return false;
    +}
    +
    +
     bool fakeSynthPut(uint8_t byte) {
         if (sMidiCount >= MIDI_BYTES_MAX) {
             return false;
    @@ -663,6 +1069,61 @@ void fakeSynthWrite(uint32_t reg, uint8_t value) {
     }
     
     
    +bool fakeWtActive(uint32_t voice) {
    +    return (sWtActive >> voice) & 1;
    +}
    +
    +
    +void fakeWtRelease(uint32_t voice) {
    +    sWtReleased |= 1u << voice;
    +}
    +
    +
    +void fakeWtStart(uint32_t voice, const AudioWtVoiceT *v) {
    +    sWtVoice[voice]  = *v;
    +    sWtActive       |= 1u << voice;
    +    sWtReleased     &= ~(1u << voice);
    +    sWtLast          = voice;
    +    sWtStarts++;
    +}
    +
    +
    +void fakeWtStop(uint32_t voice) {
    +    sWtActive &= ~(1u << voice);
    +}
    +
    +
    +void fakeWtUpdate(uint32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    (void)what;
    +    sWtVoice[voice] = *v;
    +}
    +
    +
    +// Frames into the fake memory as 8-bit (the top byte) or 16-bit
    +// little-endian samples.
    +bool fakeWtUpload(uint32_t addr, const int16_t *frames, uint32_t count, uint32_t bits) {
    +    uint32_t bytes = bits / 8;
    +
    +    if (addr > WT_FAKE_MEMORY || count > (WT_FAKE_MEMORY - addr) / bytes) {
    +        return false;
    +    }
    +
    +    for (uint32_t i = 0; i < count; i++) {
    +        uint16_t v = (uint16_t)frames[i];
    +
    +        if (bytes == 1) {
    +            sWtMemory[addr + i] = (uint8_t)(v >> 8);
    +        } else {
    +            sWtMemory[addr + i * 2]     = (uint8_t)v;
    +            sWtMemory[addr + i * 2 + 1] = (uint8_t)(v >> 8);
    +        }
    +    }
    +
    +    sWtUploads++;
    +    return true;
    +}
    +
    +
     static void fmTests(void) {
         static RecBackT back;
         static FmSynthT synth;
    @@ -821,6 +1282,118 @@ static void fmTests(void) {
     }
     
     
    +// The General MIDI SoundFont add-on as mksf made it from FluidR3_GM.
    +static void gmBankTests(void) {
    +    static const uint8_t song[]  = { 0, 4, 24, 32, 33, 40, 48, 56, 61, 73 };
    +    static const uint8_t drums[] = { 35, 36, 38, 40, 42, 44, 46, 49, 51 };
    +    WaveBankT            bank;
    +    WaveCacheT           cache;
    +    AudioCtxT           *ctx;
    +    uint8_t             *need;
    +    uint32_t             missing = 0;
    +    int32_t              mono    = 0;
    +    int32_t              layered = 0;
    +    int32_t              wide    = 0;
    +    int32_t              guards  = 0;
    +    int32_t              loops   = 0;
    +
    +    tCase("dvxgm: every instrument and the standard kit");
    +    TEST_EQ(sf2Load(MUSIC_DVXGM, &bank), MUSIC_OK);
    +    TEST_EQ(bank.presetCount, GM_PROGRAMS + 1);
    +
    +    for (uint32_t p = 0; p < GM_PROGRAMS; p++) {
    +        const WavePresetT *preset = waveBankPreset(&bank, 0, (uint8_t)p);
    +
    +        TEST_TRUE(preset && preset->bank == 0 && preset->program == p);
    +        wide += preset && preset->count > GM_REGIONS_MAX;
    +    }
    +
    +    TEST_EQ(wide, 0);
    +    TEST_EQ(waveBankPreset(&bank, SF_DRUM_BANK, 0)->bank, SF_DRUM_BANK);
    +
    +    tCase("dvxgm: mono, one layer, loops that run on");
    +    for (uint32_t i = 0; i < bank.sampleCount; i++) {
    +        const WaveSampleT *s = &bank.samples[i];
    +        int16_t            at[GM_GUARD];
    +        int16_t            after[GM_GUARD];
    +
    +        mono += s->type == SF_SAMPLE_MONO && s->rate <= GM_RATE_MAX;
    +
    +        if (s->loopEnd + GM_GUARD <= s->end) {
    +            loops++;
    +            guards += waveBankRead(&bank, s->loopStart, GM_GUARD, at) && waveBankRead(&bank, s->loopEnd, GM_GUARD, after) && memcmp(at, after, sizeof(at)) == 0;
    +        }
    +    }
    +
    +    for (uint32_t i = 0; i < bank.regionCount; i++) {
    +        layered += bank.regions[i].velLo != 0 || bank.regions[i].velHi != MUSIC_MIDI_DATA_MAX;
    +    }
    +
    +    TEST_EQ(mono, (int32_t)bank.sampleCount);
    +    TEST_EQ(layered, 0);
    +    TEST_TRUE(loops > 0);
    +    TEST_EQ(guards, loops);
    +
    +    tCase("dvxgm: a song of ten instruments and nine drums fits 512 KB at full quality");
    +    need = (uint8_t *)calloc(bank.sampleCount, 1);
    +    TEST_TRUE(need != NULL);
    +
    +    for (uint32_t i = 0; i < sizeof(song); i++) {
    +        waveNeedsPreset(&bank, 0, song[i], need);
    +    }
    +
    +    const WavePresetT *kit = waveBankPreset(&bank, SF_DRUM_BANK, 0);
    +
    +    for (uint32_t i = 0; i < kit->count; i++) {
    +        const WaveRegionT *r = &bank.regions[kit->first + i];
    +
    +        for (uint32_t d = 0; d < sizeof(drums); d++) {
    +            need[r->sample] |= drums[d] >= r->keyLo && drums[d] <= r->keyHi;
    +        }
    +    }
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 524288\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(missing, 0);
    +
    +    for (uint32_t i = 0; i < bank.sampleCount; i++) {
    +        TEST_TRUE(!need[i] || (cache.loaded[i].bits == 16 && cache.loaded[i].shift == 0));
    +    }
    +
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +    free(need);
    +    waveBankFree(&bank);
    +
    +    tCase("dvxgm: a flute's A4, E5, and A5 in software, each at its time");
    +    static const uint8_t flute3[] = { 0x00, 0xC0, FLUTE_PROGRAM, 0x00, 0x90, 69, 110, 0x83, 0x10, 0x80, 69, 0, 0x82, 0x2C, 0x90, 76, 110, 0x83, 0x10, 0x80, 76, 0, 0x82, 0x2C, 0x90, 81, 110, 0x83, 0x10, 0x80, 81, 0, 0x81, 0x48, 0xFF, 0x2F, 0x00 };
    +    static const double  hz[]     = { 440.0, 659.3, 880.0 };
    +    static uint8_t       smf[BUF_MAX];
    +    const uint8_t *const tracks[] = { flute3 };
    +    const uint32_t       lens[]   = { sizeof(flute3) };
    +    char                 text[DVX_MAX_PATH + LINE_MAX_LEN];
    +    MusicT              *m        = NULL;
    +
    +    snprintf(text, sizeof(text), "midi = softwave\nsoundFont = %s\n", MUSIC_DVXGM);
    +    setIni(text);
    +    TEST_EQ(musicLoadMem(smf, buildSmf(smf, 0, FLUTE3_DIVISION, tracks, lens, 1), &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    pump(RATE_DEV * 2);
    +
    +    for (int32_t n = 0; n < DVX_ARRAY_LEN(hz); n++) {
    +        uint32_t from = (uint32_t)(n * FLUTE3_APART_MS + FLUTE3_SETTLE_MS) * RATE_DEV / 1000;
    +        double   own  = outTone(from, FLUTE3_WINDOW, hz[n]);
    +
    +        for (int32_t o = 0; o < DVX_ARRAY_LEN(hz); o++) {
    +            TEST_TRUE(o == n || own > 4.0 * outTone(from, FLUTE3_WINDOW, hz[o]));
    +        }
    +    }
    +
    +    musicFree(m);
    +}
    +
    +
     static uint64_t hash16(const int16_t *p, uint32_t count) {
         uint64_t h = FNV_OFFSET;
     
    @@ -866,48 +1439,6 @@ static void loadGoldens(void) {
     }
     
     
    -// A ProTracker MOD: one 32-byte square-wave sample (looping), one
    -// pattern: C-3 (period 428) on channel 1, row 16 a note cut, E-3 (339)
    -// on channel 2 at row 32, speed 6 (default tempo), then the song ends.
    -static uint32_t makeMod(uint8_t *buf) {
    -    const uint32_t sampleWords = 16;
    -    uint32_t       at;
    -
    -    memset(buf, 0, 1084 + 1024 + sampleWords * 2);
    -    memcpy(buf, "dvx test", 8);
    -
    -    // Sample 1: length (words), finetune, volume, loop start, loop length.
    -    buf[20 + 22]     = 0;
    -    buf[20 + 23]     = (uint8_t)sampleWords;
    -    buf[20 + 25]     = 64;
    -    buf[20 + 29]     = (uint8_t)sampleWords;
    -    buf[950]         = 1;           // song length
    -    buf[951]         = 127;
    -    buf[952]         = 0;           // order 0: pattern 0
    -    memcpy(buf + 1080, "M.K.", 4);
    -    at               = 1084;
    -
    -    // Row 0 channel 0: sample 1, period 428
    -    buf[at]     = 0x01;
    -    buf[at + 1] = 0xAC;
    -    buf[at + 2] = 0x10;
    -    // Row 16 channel 0: note cut (EC0)
    -    buf[at + 16 * 16 + 2] = 0x0E;
    -    buf[at + 16 * 16 + 3] = 0xC0;
    -    // Row 32 channel 1: sample 1, period 339
    -    buf[at + 32 * 16 + 4]     = 0x01;
    -    buf[at + 32 * 16 + 4 + 1] = 0x53;
    -    buf[at + 32 * 16 + 4 + 2] = 0x10;
    -    at += 1024;
    -
    -    for (uint32_t i = 0; i < sampleWords * 2; i++) {
    -        buf[at + i] = (uint8_t)(i < sampleWords ? 100 : (uint8_t)-100);
    -    }
    -
    -    return at + sampleWords * 2;
    -}
    -
    -
     // A Scream Tracker 3 module: one instrument (a 64-byte sine-ish loop),
     // one pattern with two notes and a note cut, eight channels.
     static uint32_t makeS3m(uint8_t *buf) {
    @@ -979,6 +1510,207 @@ static uint32_t makeS3m(uint8_t *buf) {
     }
     
     
    +// An Extended Module: two channels, one pattern with a C-4 at row 0 (the
    +// rest empty), one instrument whose 8-bit sample has a ping-pong loop.
    +static uint32_t makeXm(uint8_t *buf) {
    +    uint32_t at = 60 + XM_HEADER_SIZE;
    +
    +    memset(buf, 0, BUF_MAX);
    +    memcpy(buf, "Extended Module: ", 17);
    +    memcpy(buf + 17, "dvx xm", 6);
    +    buf[37] = 0x1A;
    +    put16le(buf + 58, 0x0104);
    +    put32le(buf + 60, XM_HEADER_SIZE);
    +    put16le(buf + 64, 1);           // song length
    +    put16le(buf + 68, 2);           // channels
    +    put16le(buf + 70, 1);           // patterns
    +    put16le(buf + 72, 1);           // instruments
    +    put16le(buf + 74, 1);           // linear frequencies
    +    put16le(buf + 76, 6);           // speed
    +    put16le(buf + 78, 125);         // BPM
    +
    +    // The pattern: row 0 channel 0 a note and the instrument, every other
    +    // cell empty (packed)
    +    put32le(buf + at, 9);
    +    put16le(buf + at + 5, XM_ROWS);
    +    put16le(buf + at + 7, 3 + XM_ROWS * 2 - 1);
    +    at          += 9;
    +    buf[at]      = 0x83;
    +    buf[at + 1]  = XM_NOTE_C4;
    +    buf[at + 2]  = 1;
    +    at          += 3;
    +
    +    for (uint32_t i = 1; i < XM_ROWS * 2; i++) {
    +        buf[at++] = 0x80;
    +    }
    +
    +    // The instrument (every key on its one sample), then the sample's
    +    // header and its frames as deltas
    +    put32le(buf + at, XM_INSTRUMENT_SIZE);
    +    put16le(buf + at + 27, 1);
    +    put32le(buf + at + 29, XM_SAMPLE_HEADER);
    +    at += XM_INSTRUMENT_SIZE;
    +    put32le(buf + at, XM_FRAMES);
    +    put32le(buf + at + 4, XM_LOOP_START);
    +    put32le(buf + at + 8, XM_LOOP_LEN);
    +    buf[at + 12] = 64;              // volume
    +    buf[at + 14] = XM_PINGPONG;
    +    buf[at + 15] = 128;             // pan
    +    at += XM_SAMPLE_HEADER;
    +
    +    for (uint32_t i = 0; i < XM_FRAMES; i++) {
    +        buf[at + i] = i ? XM_FRAME_STEP : 0;
    +    }
    +
    +    return at + XM_FRAMES;
    +}
    +
    +
    +// MIDI recordings: messages kept in time order (a time before the last
    +// held to it), what is not a channel message refused, the saved file
    +// read back (times, one-byte messages, a note still held let go at the
    +// end) and loaded as music, a file that cannot be written, MusicMidiSend,
    +// BASIC's numbers and packed messages, and a program's end freeing its
    +// recordings.
    +static void midiRecordTests(void) {
    +    MusicMidiRecordT *rec  = NULL;
    +    MusicT           *m    = NULL;
    +    MidiFileT         file;
    +    MusicInfoT        info;
    +    int32_t           len  = 0;
    +
    +    tCase("midi record: messages, and the ones refused");
    +    sAppId = APP_A;
    +    TEST_EQ(musicMidiRecordNew(&rec), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordCount(rec), 0);
    +    TEST_EQ(musicMidiRecordLength(rec), 0);
    +    TEST_EQ(musicMidiRecordAdd(rec, 0, MIDI_PROGRAM | MIDIREC_CHANNEL, MIDIREC_FLUTE, MIDIREC_VELOCITY), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_FIRST_MS, MIDI_NOTE_ON | MIDIREC_CHANNEL, MIDIREC_NOTE, MIDIREC_VELOCITY), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_EARLY_MS, MIDI_NOTE_ON | MIDIREC_CHANNEL, MIDIREC_NOTE2, MIDIREC_VELOCITY), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_OFF_MS, MIDI_NOTE_ON | MIDIREC_CHANNEL, MIDIREC_NOTE, 0), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_LAST_MS, MIDI_PITCH_BEND | MIDIREC_CHANNEL, 0, MIDI_CENTER), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_LAST_MS, MIDI_DATA_MASK, 0, 0), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_LAST_MS, MIDIREC_CLOCK, 0, 0), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiRecordAdd(rec, MIDIREC_LAST_MS, MIDI_NOTE_ON, MUSIC_MIDI_DATA_MAX + 1, 0), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiRecordAdd(NULL, 0, MIDI_NOTE_ON, MIDIREC_NOTE, MIDIREC_VELOCITY), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiRecordCount(rec), 5);
    +    TEST_EQ(musicMidiRecordLength(rec), MIDIREC_LAST_MS);
    +
    +    tCase("midi record: the file read back, and played");
    +    TEST_EQ(musicMidiRecordSave(rec, MIDIREC_FILE), MUSIC_OK);
    +
    +    char *data = platformReadFile(MIDIREC_FILE, &len);
    +
    +    TEST_TRUE(data != NULL);
    +    memset(&file, 0, sizeof(file));
    +    TEST_EQ(midiParse((const uint8_t *)data, (uint32_t)len, &file), MUSIC_OK);
    +    TEST_EQ(file.length, MIDIREC_LAST_MS * US_PER_MS);
    +    TEST_EQ(file.count, 6);
    +
    +    if (file.count == 6) {
    +        TEST_EQ(file.events[0].status, MIDI_PROGRAM | MIDIREC_CHANNEL);
    +        TEST_EQ(file.events[0].data1, MIDIREC_FLUTE);
    +        TEST_EQ(file.events[1].status, MIDI_NOTE_ON | MIDIREC_CHANNEL);
    +        TEST_EQ(file.events[1].time, MIDIREC_FIRST_MS * US_PER_MS);
    +        TEST_EQ(file.events[2].data1, MIDIREC_NOTE2);
    +        TEST_EQ(file.events[2].time, MIDIREC_FIRST_MS * US_PER_MS);
    +        TEST_EQ(file.events[3].status, MIDI_NOTE_OFF | MIDIREC_CHANNEL);
    +        TEST_EQ(file.events[3].data1, MIDIREC_NOTE);
    +        TEST_EQ(file.events[3].time, MIDIREC_OFF_MS * US_PER_MS);
    +        TEST_EQ(file.events[4].status, MIDI_PITCH_BEND | MIDIREC_CHANNEL);
    +        TEST_EQ(file.events[4].data2, MIDI_CENTER);
    +        TEST_EQ(file.events[5].status, MIDI_NOTE_OFF | MIDIREC_CHANNEL);
    +        TEST_EQ(file.events[5].data1, MIDIREC_NOTE2);
    +        TEST_EQ(file.events[5].time, MIDIREC_LAST_MS * US_PER_MS);
    +    }
    +
    +    midiFileFree(&file);
    +    free(data);
    +    TEST_EQ(musicLoad(MIDIREC_FILE, &m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.kind, MUSIC_KIND_MIDI);
    +    TEST_EQ(info.lengthMs, MIDIREC_LAST_MS);
    +    musicFree(m);
    +
    +    tCase("midi record: an empty recording; a file that cannot be written");
    +    musicMidiRecordFree(rec);
    +    musicMidiRecordFree(NULL);
    +    TEST_EQ(musicMidiRecordNew(&rec), MUSIC_OK);
    +    TEST_EQ(musicMidiRecordSave(rec, MIDIREC_FILE), MUSIC_OK);
    +    data = platformReadFile(MIDIREC_FILE, &len);
    +    memset(&file, 0, sizeof(file));
    +    TEST_EQ(midiParse((const uint8_t *)data, (uint32_t)len, &file), MUSIC_OK);
    +    TEST_EQ(file.count, 0);
    +    midiFileFree(&file);
    +    free(data);
    +    TEST_EQ(musicMidiRecordSave(rec, MIDIREC_NO_DIR), MUSIC_ERR_WRITE);
    +    TEST_STR_EQ(musicErrorText(MUSIC_ERR_WRITE), "The file could not be written");
    +    TEST_EQ(musicMidiRecordSave(rec, ""), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiRecordSave(NULL, MIDIREC_FILE), MUSIC_ERR_ARG);
    +    musicMidiRecordFree(rec);
    +
    +    tCase("midi record: MusicMidiSend plays a keyboard's messages");
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    TEST_EQ(musicMidiSend(m, MIDI_NOTE_ON | MIDIREC_CHANNEL, MIDIREC_NOTE, MIDIREC_VELOCITY), MUSIC_OK);
    +    TEST_EQ(musicMidiSend(m, MIDI_PROGRAM, MIDIREC_FLUTE, MUSIC_MIDI_DATA_MAX + 1), MUSIC_OK);
    +    TEST_EQ(musicMidiSend(m, MIDI_DATA_MASK, 0, 0), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiSend(m, MIDIREC_CLOCK, 0, 0), MUSIC_ERR_ARG);
    +    TEST_EQ(musicMidiSend(NULL, MIDI_NOTE_ON, MIDIREC_NOTE, MIDIREC_VELOCITY), MUSIC_ERR_ARG);
    +    musicFree(m);
    +
    +    tCase("midi record: BASIC numbers and packed messages");
    +    int32_t r    = MidiRecordNew();
    +    int32_t live = MusicMidiOpen();
    +
    +    TEST_TRUE(r > 0 && live > 0);
    +    MidiRecordAdd(r, MIDIREC_FIRST_MS, MIDI_NOTE_ON | (MIDIREC_NOTE << AUDIO_MIDI_PACKED_DATA1_SHIFT) | (MIDIREC_VELOCITY << AUDIO_MIDI_PACKED_DATA2_SHIFT));
    +    TEST_STR_EQ(MusicError(), "No error");
    +    MidiRecordAdd(r, -1, MIDI_NOTE_OFF | (MIDIREC_NOTE << AUDIO_MIDI_PACKED_DATA1_SHIFT));
    +    TEST_EQ(MidiRecordCount(r), 2);
    +    TEST_EQ(MidiRecordLength(r), MIDIREC_FIRST_MS);
    +    MidiRecordAdd(r, MIDIREC_LAST_MS, AUDIO_MIDI_PACKED_NONE);
    +    TEST_STR_EQ(MusicError(), "Invalid argument");
    +    TEST_EQ(MidiRecordCount(r), 2);
    +    TEST_EQ(MidiRecordSave(r, MIDIREC_FILE), -1);
    +    TEST_EQ(MidiRecordSave(r, MIDIREC_NO_DIR), 0);
    +    TEST_STR_EQ(MusicError(), "The file could not be written");
    +    MusicMidiSend(live, MIDI_NOTE_ON | (MIDIREC_NOTE << AUDIO_MIDI_PACKED_DATA1_SHIFT) | (MIDIREC_VELOCITY << AUDIO_MIDI_PACKED_DATA2_SHIFT));
    +    TEST_STR_EQ(MusicError(), "No error");
    +    MusicMidiSend(live, MIDIREC_CLOCK);
    +    TEST_STR_EQ(MusicError(), "Invalid argument");
    +
    +    // Another program's number does not work; a freed one neither
    +    sAppId = APP_B;
    +    TEST_EQ(MidiRecordCount(r), 0);
    +    TEST_STR_EQ(MusicError(), "Invalid argument");
    +    sAppId = APP_A;
    +    MidiRecordFree(r);
    +    TEST_EQ(MidiRecordCount(r), 0);
    +    TEST_STR_EQ(MusicError(), "Invalid argument");
    +
    +    // Every number taken: no room
    +    for (int32_t i = 0; i < MIDIREC_BAS_SLOTS; i++) {
    +        TEST_TRUE(MidiRecordNew() > 0);
    +    }
    +
    +    TEST_EQ(MidiRecordNew(), 0);
    +    TEST_STR_EQ(MusicError(), "Not enough memory");
    +
    +    // The program's end frees its recordings (LeakSanitizer would say)
    +    // and forgets their numbers
    +    sAppId = APP_B;
    +    TEST_EQ(musicMidiRecordNew(&rec), MUSIC_OK);
    +    sAppId = 0;
    +    musicAppEnded(APP_A);
    +    musicAppEnded(APP_B);
    +    sAppId = APP_A;
    +    TEST_TRUE(MidiRecordNew() == 1);
    +    musicAppEnded(APP_A);
    +    sAppId = 0;
    +    unlink(MIDIREC_FILE);
    +}
    +
    +
     static void mmlPlayTests(void) {
         MusicMmlT *m = NULL;
         char       bg[MML_BG_NOTES + 8];
    @@ -1196,6 +1928,253 @@ static void mmlTests(void) {
     }
     
     
    +// Modules on the fake wavetable synthesizer's voices: the samples there,
    +// the frames the timer plays, pause, volume, seek, the end, giving the
    +// synthesizer up to MIDI, a ping-pong loop unrolled, and a stream when
    +// the module cannot play there.
    +static void modHwTests(void) {
    +    static uint8_t       mod[BUF_MAX];
    +    static uint8_t       xm[BUF_MAX];
    +    static uint8_t       sf[SF_TEST_MAX];
    +    static uint8_t       smf[BUF_MAX];
    +    static const uint8_t track[] = { 0x00, 0xC0, 0, 0x00, 0x90, 40, 127, 0x60, 0x80, 40, 0, 0x00, 0xFF, 0x2F, 0x00 };
    +    const uint8_t *const tracks[] = { track };
    +    const uint32_t       lens[]   = { sizeof(track) };
    +    SfSpecT              spec     = { 2, SF_TEST_FRAMES, 0 };
    +    static uint8_t       twice[BUF_MAX];
    +    uint32_t             modSize  = makeMod(mod);
    +    uint32_t             xmSize   = makeXm(xm);
    +    uint32_t             smfSize  = buildSmf(smf, 0, 96, tracks, lens, 1);
    +    MusicInfoT           info;
    +    AudioWtInfoT         wt;
    +    MusicT              *m        = NULL;
    +    MusicT              *m2       = NULL;
    +    int32_t              starts;
    +    int32_t              atten;
    +    uint32_t             at;
    +
    +    // The module with its pattern played twice (a seek goes to the start
    +    // of a pattern)
    +    memcpy(twice, mod, modSize);
    +    twice[950] = 2;
    +    musicShutdown();
    +    TEST_EQ(platformMkdirRecursive(DVX_USER_DIR "/" DVX_BANKS_DIR), 0);
    +    writeFile(SF_TEST_FILE, sf, sfBuild(&spec, sf));
    +
    +    tCase("modhw: a module on the wavetable synthesizer's voices");
    +    setIni("fakeWtVoices = 8\n");
    +    sWtStarts  = 0;
    +    sWtUploads = 0;
    +    TEST_EQ(musicLoadMem(mod, modSize, &m), MUSIC_OK);
    +    musicSetEndFn(m, onEnd, NULL);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    TEST_STR_EQ(info.output, "Fake wavetable (8 voices)");
    +    TEST_EQ(sWtUploads, 1);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, MODHW_SAMPLE_BYTES);
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtStarts, 1);
    +    TEST_TRUE(sWtActive != 0);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, MODHW_PITCH_428);
    +    TEST_EQ(sWtVoice[sWtLast].attenuation, MODHW_ATTEN_FULL);
    +    TEST_TRUE(sWtVoice[sWtLast].pan < 0);
    +    TEST_EQ(sWtVoice[sWtLast].loop, AUDIO_WT_LOOP_ON);
    +    TEST_EQ(sWtVoice[sWtLast].loopStart, 0);
    +    TEST_EQ(sWtVoice[sWtLast].loopEnd, 32);
    +    TEST_EQ(sWtVoice[sWtLast].end, 32);
    +    TEST_EQ(sWtVoice[sWtLast].bits, 16);
    +    TEST_EQ(modHwWord(sWtVoice[sWtLast].addr, 0), 100 << 8);
    +    TEST_EQ(modHwWord(sWtVoice[sWtLast].addr, 16), -100 * 256);
    +    TEST_EQ(modHwWord(sWtVoice[sWtLast].addr, 32), 100 << 8);
    +
    +    tCase("modhw: the note cut silences the voice; the next note starts one");
    +    pump(RATE_DEV * (MODHW_CUT_MS - MODHW_FRAME_MS) / 1000);
    +    TEST_EQ(sWtStarts, 1);
    +    TEST_EQ(sWtVoice[sWtLast].attenuation, AUDIO_WT_ATTEN_MAX);
    +    pump(RATE_DEV * (MODHW_NOTE2_MS - MODHW_CUT_MS) / 1000);
    +    TEST_EQ(sWtStarts, 2);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, MODHW_PITCH_339);
    +    TEST_TRUE(sWtVoice[sWtLast].pan > 0);
    +    at = musicGetPosition(m);
    +    TEST_TRUE(at >= MODHW_NOTE2_MS - MODHW_FRAME_MS && at <= MODHW_NOTE2_MS + MODHW_FRAME_MS);
    +
    +    tCase("modhw: pause stops the voices; resume starts them again");
    +    TEST_EQ(musicPause(m), MUSIC_OK);
    +    TEST_EQ(sWtActive, 0);
    +    at = musicGetPosition(m);
    +    pump(RATE_DEV / 10);
    +    TEST_EQ(musicGetPosition(m), at);
    +    starts = sWtStarts;
    +    TEST_EQ(musicResume(m), MUSIC_OK);
    +    pump(RATE_DEV / 100);
    +    TEST_TRUE(sWtStarts > starts);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, MODHW_PITCH_339);
    +    TEST_TRUE(sWtActive != 0);
    +
    +    tCase("modhw: the music's volume reaches the voices at the next frame");
    +    atten = sWtVoice[sWtLast].attenuation;
    +    TEST_EQ(musicSetVolume(m, FULL / 2), MUSIC_OK);
    +    pump(RATE_DEV * 2 * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtVoice[sWtLast].attenuation, atten + MODHW_ATTEN_HALF);
    +    TEST_EQ(musicSetVolume(m, FULL), MUSIC_OK);
    +
    +    tCase("modhw: the song ends; played again it starts over");
    +    sEnds = 0;
    +    pump(RATE_DEV * 5);
    +    TEST_EQ(sEnds, 1);
    +    TEST_TRUE(!musicIsPlaying(m));
    +    TEST_EQ(sWtActive, 0);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    TEST_TRUE(musicGetPosition(m) < MODHW_FRAME_MS);
    +    starts = sWtStarts;
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtStarts, starts + 1);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, MODHW_PITCH_428);
    +
    +    tCase("modhw: a looping song goes on past its end; a stop");
    +    TEST_EQ(musicStop(m), MUSIC_OK);
    +    TEST_EQ(musicGetPosition(m), 0);
    +    TEST_EQ(sWtActive, 0);
    +    TEST_EQ(musicPlay(m, true), MUSIC_OK);
    +    sEnds = 0;
    +    pump(RATE_DEV * 9);
    +    TEST_TRUE(musicIsPlaying(m));
    +    TEST_EQ(sEnds, 0);
    +    musicFree(m);
    +
    +    tCase("modhw: a seek; one made while stopped is where the play starts");
    +    TEST_EQ(musicLoadMem(twice, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(musicSetPosition(m, MODHW_SEEK_MS), MUSIC_OK);
    +    at = musicGetPosition(m);
    +    TEST_TRUE(at + MODHW_SEEK_TOL_MS >= MODHW_ORDER_MS && at <= MODHW_ORDER_MS + MODHW_SEEK_TOL_MS);
    +    starts = sWtStarts;
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtStarts, starts + 1);
    +    TEST_EQ(musicStop(m), MUSIC_OK);
    +    TEST_EQ(musicSetPosition(m, MODHW_SEEK_MS), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    TEST_TRUE(musicGetPosition(m) + MODHW_SEEK_TOL_MS >= MODHW_ORDER_MS);
    +    TEST_EQ(musicStop(m), MUSIC_OK);
    +
    +    tCase("modhw: a stopped module gives the synthesizer up to MIDI");
    +    setIni("fakeWtVoices = 8\nsoundFont = " SF_TEST_NAME "\n");
    +    TEST_EQ(musicLoadMem(smf, smfSize, &m2), MUSIC_OK);
    +    TEST_EQ(musicPlay(m2, false), MUSIC_OK);
    +    musicGetInfo(m2, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_NONE);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, WT_RAMP_BYTES);
    +
    +    // ... and while MIDI plays there, the module plays on a stream
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_STREAM);
    +    musicFree(m2);
    +    musicFree(m);
    +    musicShutdown();
    +
    +    tCase("modhw: the SoundFont kept in the synthesizer gives way to a module");
    +    setIni("fakeWtVoices = 8\nfakeWtMemory = 256\nsoundFont = " SF_TEST_NAME "\n");
    +    TEST_EQ(musicLoadMem(smf, smfSize, &m2), MUSIC_OK);
    +    TEST_EQ(musicPlay(m2, false), MUSIC_OK);
    +    musicFree(m2);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, WT_RAMP_BYTES);
    +    TEST_EQ(musicLoadMem(mod, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, MODHW_SAMPLE_BYTES);
    +    musicFree(m);
    +    musicShutdown();
    +
    +    tCase("modhw: a chip louder than digital sound plays a module's voices that much quieter");
    +    setIni("fakeWtVoices = 8\nfakeWtLevel = 76\n");
    +    TEST_EQ(musicLoadMem(mod, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtVoice[sWtLast].attenuation, MODHW_ATTEN_FULL + 76);
    +    musicFree(m);
    +    musicShutdown();
    +
    +    tCase("modhw: an 8-bit sample stays 8-bit where the chip plays those");
    +    setIni("fakeWtVoices = 8\nfakeWtFlags = 19\n");
    +    TEST_EQ(musicLoadMem(mod, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtVoice[sWtLast].bits, 8);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, MODHW_SAMPLE_BYTES / 2);
    +    musicFree(m);
    +    musicShutdown();
    +
    +    tCase("modhw: a ping-pong loop unrolled forward");
    +    setIni("fakeWtVoices = 8\n");
    +    TEST_EQ(musicLoadMem(xm, xmSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    pump(RATE_DEV * MODHW_FRAME_MS / 1000);
    +    TEST_EQ(sWtVoice[sWtLast].loop, AUDIO_WT_LOOP_ON);
    +    TEST_EQ(sWtVoice[sWtLast].loopStart, XM_LOOP_START);
    +    TEST_EQ(sWtVoice[sWtLast].loopEnd, XM_STORED);
    +    TEST_EQ(sWtVoice[sWtLast].end, XM_STORED);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, MODHW_PITCH_XM_C4);
    +
    +    // There: frames 0 .. 11, then libxmp's way back (11 read twice at
    +    // the turn, down to 5), then the loop again as the guard
    +    static const uint8_t stored[XM_STORED + 8] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 11, 11, 10, 9, 8, 7, 6, 5, 4, 5, 6, 7, 8, 9, 10, 11 };
    +
    +    for (uint32_t i = 0; i < sizeof(stored); i++) {
    +        TEST_EQ(modHwWord(sWtVoice[sWtLast].addr, i), stored[i] * XM_FRAME_STEP * 256);
    +    }
    +
    +    musicFree(m);
    +    musicShutdown();
    +
    +    tCase("modhw: DVX.INI modules = software, too few voices, or too little memory: a stream");
    +    setIni("fakeWtVoices = 8\nmodules = software\n");
    +    TEST_EQ(musicLoadMem(mod, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_STREAM);
    +    musicFree(m);
    +    musicShutdown();
    +    setIni("fakeWtVoices = 2\n");
    +    TEST_EQ(musicLoadMem(twice, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicSetPosition(m, MODHW_SEEK_MS), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_STREAM);
    +    TEST_TRUE(musicGetPosition(m) + MODHW_SEEK_TOL_MS >= MODHW_ORDER_MS);
    +    musicFree(m);
    +    musicShutdown();
    +    setIni("fakeWtVoices = 8\nfakeWtMemory = 64\n");
    +    TEST_EQ(musicLoadMem(mod, modSize, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_STREAM);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.freeMemory, wt.memory);
    +    musicFree(m);
    +    musicShutdown();
    +}
    +
    +
    +// Frame frame of the 16-bit sample at addr in the fake synthesizer's
    +// memory.
    +static int16_t modHwWord(uint32_t addr, uint32_t frame) {
    +    return (int16_t)(sWtMemory[addr + frame * 2] | (sWtMemory[addr + frame * 2 + 1] << 8));
    +}
    +
    +
     static void mt32Tests(void) {
         static MpuSinkT mpu;
     
    @@ -1371,9 +2350,15 @@ static void op2Tests(void) {
     
     // The largest sample (either side) of the rendered 16-bit stereo frames.
     static int32_t outPeak(uint32_t frames) {
    +    return outPeakFrom(0, frames);
    +}
    +
    +
    +// outPeak of frames frames from frame from.
    +static int32_t outPeakFrom(uint32_t from, uint32_t frames) {
         int32_t peak = 0;
     
    -    for (uint32_t i = 0; i < frames * 2; i++) {
    +    for (uint32_t i = from * 2; i < (from + frames) * 2; i++) {
             int32_t v = sOut[i] < 0 ? -sOut[i] : sOut[i];
     
             peak = v > peak ? v : peak;
    @@ -1395,6 +2380,209 @@ static double outTone(uint32_t from, uint32_t n, double hz) {
     }
     
     
    +// Gravis UltraSound patch sets the tests write: the bank patLoadSet
    +// makes of them (programs, keys, pitch, loops, the drum kit, the data
    +// read from the files), the INI's forms and mistakes, ULTRADIR's own
    +// set, and its notes played at their pitch.
    +static void patTests(void) {
    +    static const PatWaveSpecT sine[] = { { PAT_SINE_FRAMES, PAT_SINE_PERIOD, PAT_SINE_LOOP, PAT_LOW_ALL, PAT_HIGH_ALL, PAT_SINE_ROOT, PAT_SEMI, PAT_MODE_16BIT | PAT_MODE_LOOP } };
    +    static const PatWaveSpecT two[]  = {
    +        { 200, 20, 0, PAT_LOW_ALL, PAT_SPLIT_HIGH, PAT_SPLIT_HIGH, PAT_SEMI, PAT_MODE_UNSIGNED },
    +        { 200, 10, 0, PAT_SPLIT_HIGH, PAT_HIGH_ALL, PAT_HIGH_ALL, PAT_SEMI, PAT_MODE_UNSIGNED }
    +    };
    +    static const PatWaveSpecT kick[] = { { 300, 30, 0, PAT_LOW_ALL, PAT_HIGH_ALL, PAT_SINE_ROOT, 0, PAT_MODE_16BIT } };
    +    char        cwd[DVX_MAX_PATH];
    +    char        dir[DVX_MAX_PATH];
    +    char        ini[DVX_MAX_PATH];
    +    char        path[DVX_MAX_PATH];
    +    char        text[PAT_FILE_MAX];
    +    int16_t     frames[4];
    +    WaveBankT   bank;
    +    MusicInfoT  info;
    +    MusicT     *m = NULL;
    +
    +    TEST_TRUE(getcwd(cwd, sizeof(cwd)) != NULL);
    +    TEST_EQ(platformMkdirRecursive(PAT_DIR "/MIDI"), 0);
    +    TEST_EQ(platformMkdirRecursive(PAT_DIR "/OTHER"), 0);
    +    TEST_EQ(platformMkdirRecursive(PAT_LEGACY_DIR "/MIDI"), 0);
    +    TEST_EQ(platformMkdirRecursive(PAT_EMPTY_DIR), 0);
    +    TEST_EQ(platformMkdirRecursive(PAT_NONE_DIR), 0);
    +    patWrite(PAT_DIR "/MIDI/SINE.PAT", sine, 1);
    +    patWrite(PAT_DIR "/MIDI/TWO.PAT", two, 2);
    +    patWrite(PAT_DIR "/MIDI/KICK.PAT", kick, 1);
    +    patWrite(PAT_DIR "/OTHER/OTHER.PAT", sine, 1);
    +    patWrite(PAT_LEGACY_DIR "/MIDI/SINE.PAT", sine, 1);
    +    patWrite(PAT_LEGACY_DIR "/MIDI/KICK.PAT", kick, 1);
    +    writeFile(PAT_DIR "/MIDI/DAMAGED.PAT", "GF1PATCH?? not a patch", 22);
    +    snprintf(text, sizeof(text), "[Ultrasound]\nPatchDir=%s/" PAT_DIR "/OTHER\n[Melodic Bank 0]\n0=SINE\n1=TWO\n2=MISSING\n3=DAMAGED\n4=BLANK\n5=OTHER\n[Drum Bank 0]\n%d=KICK\n", cwd, PAT_KICK_KEY);
    +    writeFile(PAT_DIR "/" MUSIC_PATCH_SET_INI, text, (uint32_t)strlen(text));
    +    snprintf(text, sizeof(text), "[Melodic Patches]\n0=SINE\n[Drum Patches]\n%d=KICK\n", PAT_KICK_KEY);
    +    writeFile(PAT_LEGACY_DIR "/" MUSIC_PATCH_SET_INI, text, (uint32_t)strlen(text));
    +    snprintf(text, sizeof(text), "[Melodic Bank 0]\n0=MISSING\n");
    +    writeFile(PAT_EMPTY_DIR "/" MUSIC_PATCH_SET_INI, text, (uint32_t)strlen(text));
    +
    +    tCase("pat: a patch set's programs and kit");
    +    TEST_EQ(patLoadSet(PAT_DIR "/" MUSIC_PATCH_SET_INI, &bank), MUSIC_OK);
    +    TEST_EQ(bank.presetCount, 4);
    +    TEST_EQ(bank.sampleCount, 5);
    +    TEST_EQ(bank.fileCount, 4);
    +
    +    if (bank.presetCount == 4) {
    +        const WavePresetT *p = bank.presets;
    +
    +        TEST_EQ(p[0].program, 0);
    +        TEST_EQ(p[1].program, 1);
    +        TEST_EQ(p[2].program, 5);
    +        TEST_EQ(p[3].bank, SF_DRUM_BANK);
    +        TEST_STR_EQ(p[0].name, "SINE.PAT");
    +
    +        tCase("pat: one wave over every key, its pitch and loop");
    +        const WaveRegionT *r = &bank.regions[p[0].first];
    +        const WaveSampleT *w = &bank.samples[r->sample];
    +
    +        TEST_EQ(p[0].count, 1);
    +        TEST_EQ(r->keyLo, 0);
    +        TEST_EQ(r->keyHi, PAT_KEY_TOP);
    +        TEST_EQ(w->rate, PAT_RATE);
    +        TEST_EQ(w->rootKey, PAT_PIVOT);
    +        TEST_EQ(w->end - w->start, PAT_SINE_FRAMES);
    +        TEST_EQ(w->loopStart - w->start, PAT_SINE_LOOP);
    +        TEST_EQ(w->loopEnd - w->start, PAT_SINE_FRAMES);
    +        TEST_EQ(r->gen[SF_GEN_SAMPLE_MODES], SF_LOOP_ON);
    +        TEST_EQ(r->gen[SF_GEN_SCALE_TUNING], 100);
    +        int32_t tune = r->gen[SF_GEN_COARSE_TUNE] * 100 + r->gen[SF_GEN_FINE_TUNE];
    +
    +        TEST_TRUE(tune >= PAT_SINE_CENTS - 1 && tune <= PAT_SINE_CENTS + 1);
    +        TEST_TRUE(r->gen[SF_GEN_FINE_TUNE] > -100 && r->gen[SF_GEN_FINE_TUNE] < 100);
    +        TEST_TRUE(waveBankRead(&bank, w->start, 4, frames));
    +        TEST_EQ(frames[0], 0);
    +        TEST_EQ(frames[1], (int16_t)lround(PAT_AMP_16 * sin(2.0 * M_PI / PAT_SINE_PERIOD)));
    +
    +        tCase("pat: two waves split at their frequencies, 8-bit unsigned");
    +        r = &bank.regions[p[1].first];
    +        TEST_EQ(p[1].count, 2);
    +        TEST_EQ(r[0].keyLo, 0);
    +        TEST_EQ(r[0].keyHi, PAT_PIVOT);
    +        TEST_EQ(r[1].keyLo, PAT_PIVOT + 1);
    +        TEST_EQ(r[1].keyHi, PAT_KEY_TOP);
    +        TEST_EQ(r[0].gen[SF_GEN_SAMPLE_MODES], SF_LOOP_NONE);
    +        TEST_TRUE(waveBankRead(&bank, bank.samples[r[0].sample].start, 4, frames));
    +        TEST_EQ(frames[0], 0);
    +        TEST_EQ(frames[1], (int16_t)(((int32_t)lround(PAT_AMP_8 * sin(2.0 * M_PI / 20)) & 0xFF) << 8));
    +
    +        tCase("pat: PatchDir's patch, the kit's key, unscaled");
    +        TEST_EQ(bank.regions[p[2].first].keyHi, PAT_KEY_TOP);
    +        r = &bank.regions[p[3].first];
    +        TEST_EQ(p[3].count, 1);
    +        TEST_EQ(r->keyLo, PAT_KICK_KEY);
    +        TEST_EQ(r->keyHi, PAT_KICK_KEY);
    +        TEST_EQ(r->gen[SF_GEN_SCALE_TUNING], 0);
    +    }
    +
    +    waveBankFree(&bank);
    +
    +    tCase("pat: the older sections, and an INI with nothing to play");
    +    TEST_EQ(patLoadSet(PAT_LEGACY_DIR "/" MUSIC_PATCH_SET_INI, &bank), MUSIC_OK);
    +    TEST_EQ(bank.presetCount, 2);
    +    waveBankFree(&bank);
    +    TEST_EQ(patLoadSet(PAT_EMPTY_DIR "/" MUSIC_PATCH_SET_INI, &bank), MUSIC_ERR_BANK);
    +    TEST_EQ(patLoadSet(PAT_NONE_DIR "/" MUSIC_PATCH_SET_INI, &bank), MUSIC_ERR_OPEN);
    +
    +    tCase("pat: ULTRADIR's patch set");
    +    unsetenv(MUSIC_PATCH_SET_ENV);
    +    TEST_TRUE(!musicPatchSetPath(path, sizeof(path)));
    +    snprintf(dir, sizeof(dir), "%s/" PAT_NONE_DIR, cwd);
    +    setenv(MUSIC_PATCH_SET_ENV, dir, 1);
    +    TEST_TRUE(!musicPatchSetPath(path, sizeof(path)));
    +    snprintf(dir, sizeof(dir), "%s/" PAT_DIR, cwd);
    +    snprintf(ini, sizeof(ini), "%s/" MUSIC_PATCH_SET_INI, dir);
    +    setenv(MUSIC_PATCH_SET_ENV, dir, 1);
    +    TEST_TRUE(musicPatchSetPath(path, sizeof(path)));
    +    TEST_STR_EQ(path, ini);
    +    TEST_STR_EQ(MusicPatchSet(), ini);
    +
    +    tCase("pat: in place of the standard SoundFont, at its pitch");
    +    TEST_TRUE(!platformKindFind(path, sizeof(path), DvxKindBanksE, MUSIC_SOUND_FONT_DEFAULT));
    +    setIni("midi = softwave\n");
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_SOFTWAVE);
    +    TEST_EQ(musicMidiNoteOn(m, 0, PAT_A4_KEY, 127), MUSIC_OK);
    +    pump(RATE_DEV / 2);
    +
    +    double a4 = outTone(RATE_DEV / 10, RATE_DEV / 4, PAT_A4_HZ);
    +
    +    TEST_TRUE(a4 > 4.0 * outTone(RATE_DEV / 10, RATE_DEV / 4, PAT_A4_HZ * 0.75));
    +    TEST_TRUE(a4 > 4.0 * outTone(RATE_DEV / 10, RATE_DEV / 4, PAT_A4_HZ * 1.25));
    +    TEST_EQ(musicMidiNoteOn(m, MIDI_PERCUSSION, PAT_KICK_KEY, 127), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outPeak(RATE_DEV / 4) > 0);
    +    musicFree(m);
    +
    +    tCase("pat: named by DVX.INI and by musicSetBank");
    +    unsetenv(MUSIC_PATCH_SET_ENV);
    +    snprintf(text, sizeof(text), "midi = softwave\nsoundFont = %s\n", ini);
    +    setIni(text);
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    TEST_EQ(musicMidiNoteOn(m, 0, PAT_A4_KEY, 127), MUSIC_OK);
    +    pump(RATE_DEV / 2);
    +    TEST_TRUE(outTone(RATE_DEV / 10, RATE_DEV / 4, PAT_A4_HZ) > 4.0 * outTone(RATE_DEV / 10, RATE_DEV / 4, PAT_A4_HZ * 0.75));
    +    musicFree(m);
    +    setIni("midi = softwave\n");
    +    TEST_EQ(musicLoadMacro("T120 L4 O3 A", &m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, ini), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_SOFTWAVE);
    +    musicFree(m);
    +    TEST_STR_EQ(MusicPatchSet(), "");
    +}
    +
    +
    +// A patch of count waves (see PatWaveSpecT) at path.
    +static void patWrite(const char *path, const PatWaveSpecT *waves, int32_t count) {
    +    static uint8_t buf[BUF_MAX];
    +    uint32_t       len = PAT_HEAD_BYTES + PAT_SKIP_BYTES;
    +
    +    memset(buf, 0, sizeof(buf));
    +    memcpy(buf, "GF1PATCH110\0ID#000002", 21);
    +    put16le(buf + PAT_WAVES_AT, (uint32_t)count);
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        const PatWaveSpecT *w     = &waves[i];
    +        uint32_t            bytes = w->modes & PAT_MODE_16BIT ? 2 : 1;
    +        uint8_t            *h     = buf + len;
    +
    +        put32le(h + PAT_W_SIZE, w->frames * bytes);
    +        put32le(h + PAT_W_LOOP_START, w->loopStart * bytes);
    +        put32le(h + PAT_W_LOOP_END, w->frames * bytes);
    +        put16le(h + PAT_W_RATE, PAT_RATE);
    +        put32le(h + PAT_W_LOW, w->low);
    +        put32le(h + PAT_W_HIGH, w->high);
    +        put32le(h + PAT_W_ROOT, w->root);
    +        h[PAT_W_BALANCE] = 7;
    +        h[PAT_W_MODES]   = w->modes;
    +        put16le(h + PAT_W_SCALE_KEY, PAT_PIVOT);
    +        put16le(h + PAT_W_SCALE_FACTOR, w->scaleFactor);
    +        len += PAT_WAVE_HEAD;
    +
    +        for (uint32_t f = 0; f < w->frames; f++) {
    +            double v = sin(2.0 * M_PI * f / w->period);
    +
    +            if (bytes == 2) {
    +                put16le(buf + len, (uint32_t)(uint16_t)(int16_t)lround(PAT_AMP_16 * v));
    +            } else {
    +                buf[len] = (uint8_t)(PAT_SILENCE_8 + lround(PAT_AMP_8 * v));
    +            }
    +
    +            len += bytes;
    +        }
    +    }
    +
    +    writeFile(path, buf, len);
    +}
    +
    +
     static void playTests(void) {
         static uint8_t smf[BUF_MAX];
         static uint8_t mod[BUF_MAX];
    @@ -1403,16 +2591,13 @@ static void playTests(void) {
         AudioMidiInfoT port;
         MusicT        *m    = NULL;
         MusicT        *m2   = NULL;
    -    const uint8_t  flute[] = { 0x00, 0xC0, 73, 0x00, 0x90, 69, 127, 0x60, 0x80, 69, 0, 0x00, 0xFF, 0x2F, 0x00 };
         const uint8_t  chord[] = { 0x00, 0xC0, 73, 0x00, 0x90, 60, 127, 0x00, 64, 127, 0x00, 67, 127, 0x60, 0x80, 60, 0, 0x00, 64, 0, 0x00, 67, 0, 0x00, 0xFF, 0x2F, 0x00 };
         const uint8_t *tracks[1];
         uint32_t       lens[1];
         uint32_t       size;
         uint32_t       modSize = makeMod(mod);
     
    -    tracks[0] = flute;
    -    lens[0]   = sizeof(flute);
    -    size      = buildSmf(smf, 0, 96, tracks, lens, 1);
    +    size = fluteSmf(smf);
     
         tCase("play: without an instrument bank MIDI needs the MPU-401 port");
         TEST_EQ(rename(MUSIC_BANK_DEFAULT, BANK_ASIDE), 0);
    @@ -1553,9 +2738,7 @@ static void playTests(void) {
     
         tCase("play: the MPU-401 port for one music; the next one uses the FM chip");
         setIni("midi = mpu401\n");
    -    tracks[0] = flute;
    -    lens[0]   = sizeof(flute);
    -    size      = buildSmf(smf, 0, 96, tracks, lens, 1);
    +    size = fluteSmf(smf);
         sMidiCount = 0;
         TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
         TEST_EQ(musicLoadMem(smf, size, &m2), MUSIC_OK);
    @@ -1683,26 +2866,6 @@ static void pump(uint32_t frames) {
     }
     
     
    -static void put16le(uint8_t *p, uint32_t v) {
    -    p[0] = (uint8_t)v;
    -    p[1] = (uint8_t)(v >> 8);
    -}
    -
    -
    -static void put32be(uint8_t *p, uint32_t v) {
    -    p[0] = (uint8_t)(v >> 24);
    -    p[1] = (uint8_t)(v >> 16);
    -    p[2] = (uint8_t)(v >> 8);
    -    p[3] = (uint8_t)v;
    -}
    -
    -
    -static void put32le(uint8_t *p, uint32_t v) {
    -    put16le(p, v);
    -    put16le(p + 2, v >> 16);
    -}
    -
    -
     static int32_t recAlloc(void *owner) {
         RecBackT *b = (RecBackT *)owner;
     
    @@ -1874,6 +3037,525 @@ static void setIni(const char *text) {
     }
     
     
    +// A SoundFont 1 bank: a ROM sample (SF1_ROM_*), then one of its own (a
    +// ramp, SF1_RAM_*), each loop kept one and two frames lower; "Organ" 0:0
    +// (a global zone of scale tuning 1 and reverb 255, a zone of pan 0 over
    +// "Organ": samplePitch 69.50, sustain 100, attack 500 ms, pan 127, level
    +// 64, coarse tune 2 keys on the RAM sample, looping) and "Rom" 0:1 over
    +// "Rom" (the ROM sample).  No modulator arrays.
    +static uint32_t sf1Build(uint8_t *out) {
    +    static const uint16_t pgen[][2] = { { 56, 1 }, { 16, 255 }, { 17, 0 }, { 41, 0 }, { 41, 1 }, { 0, 0 } };
    +    static const uint16_t pbag[]    = { 0, 2, 4, 5 };
    +    static const uint16_t igen[][2] = { { 55, SF1_ROOT_PITCH }, { 37, 100 }, { 34, 500 }, { 17, 127 }, { 48, 64 }, { 51, 2 }, { 54, 1 }, { 53, 1 }, { 53, 0 }, { 0, 0 } };
    +    static const uint16_t ibag[]    = { 0, 8, 9 };
    +    static const uint32_t shdr[][4] = { { SF1_ROM_START, SF1_ROM_END, SF1_ROM_LOOP - 1, SF1_ROM_LOOP_END - 2 }, { 0, SF1_RAM_END, SF1_RAM_LOOP - 1, SF1_RAM_LOOP_END - 2 }, { 0, 0, 0, 0 } };
    +    static uint8_t        body[SF_TEST_MAX];
    +    static uint8_t        list[SF_TEST_MAX];
    +    uint32_t              len;
    +    uint32_t              listLen;
    +
    +    memcpy(list, "INFO", 4);
    +    listLen = 4;
    +    put16le(body, 1);
    +    put16le(body + 2, 0);
    +    sfChunk(list, &listLen, "ifil", body, 4);
    +    memcpy(out + 12, "LIST", 4);
    +    put32le(out + 16, listLen);
    +    memcpy(out + 20, list, listLen);
    +    len = 20 + listLen;
    +
    +    memcpy(list, "sdta", 4);
    +    listLen = 4;
    +    memset(body, 0, sizeof(body));
    +    memcpy(body, "RomSample", 9);
    +    memcpy(body + 20, "RamSample", 9);
    +    sfChunk(list, &listLen, "snam", body, 2 * 20);
    +
    +    for (int32_t i = 0; i < SF1_RAM_FRAMES; i++) {
    +        put16le(body + i * 2, (uint32_t)(uint16_t)(int16_t)(i * 100));
    +    }
    +
    +    sfChunk(list, &listLen, "smpl", body, SF1_RAM_FRAMES * 2);
    +    memcpy(out + len, "LIST", 4);
    +    put32le(out + len + 4, listLen);
    +    memcpy(out + len + 8, list, listLen);
    +    len += 8 + listLen;
    +
    +    memcpy(list, "pdta", 4);
    +    listLen = 4;
    +    memset(body, 0, sizeof(body));
    +    memcpy(body, "Organ", 5);
    +    put16le(body + 24, 0);
    +    memcpy(body + 38, "Rom", 3);
    +    put16le(body + 38 + 20, 1);
    +    put16le(body + 38 + 24, 2);
    +    memcpy(body + 76, "EOP", 3);
    +    put16le(body + 76 + 24, 3);
    +    sfChunk(list, &listLen, "phdr", body, 3 * 38);
    +    memset(body, 0, sizeof(body));
    +
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(pbag); i++) {
    +        put16le(body + i * 4, pbag[i]);
    +    }
    +
    +    sfChunk(list, &listLen, "pbag", body, DVX_ARRAY_LEN(pbag) * 4);
    +
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(pgen); i++) {
    +        put16le(body + i * 4, pgen[i][0]);
    +        put16le(body + i * 4 + 2, pgen[i][1]);
    +    }
    +
    +    sfChunk(list, &listLen, "pgen", body, DVX_ARRAY_LEN(pgen) * 4);
    +    memset(body, 0, sizeof(body));
    +    memcpy(body, "Organ", 5);
    +    memcpy(body + 22, "Rom", 3);
    +    put16le(body + 22 + 20, 1);
    +    memcpy(body + 44, "EOI", 3);
    +    put16le(body + 44 + 20, 2);
    +    sfChunk(list, &listLen, "inst", body, 3 * 22);
    +    memset(body, 0, sizeof(body));
    +
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(ibag); i++) {
    +        put16le(body + i * 4, ibag[i]);
    +    }
    +
    +    sfChunk(list, &listLen, "ibag", body, DVX_ARRAY_LEN(ibag) * 4);
    +
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(igen); i++) {
    +        put16le(body + i * 4, igen[i][0]);
    +        put16le(body + i * 4 + 2, igen[i][1]);
    +    }
    +
    +    sfChunk(list, &listLen, "igen", body, DVX_ARRAY_LEN(igen) * 4);
    +
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(shdr); i++) {
    +        for (int32_t k = 0; k < 4; k++) {
    +            put32le(body + i * 16 + k * 4, shdr[i][k]);
    +        }
    +    }
    +
    +    sfChunk(list, &listLen, "shdr", body, DVX_ARRAY_LEN(shdr) * 16);
    +    memcpy(out + len, "LIST", 4);
    +    put32le(out + len + 4, listLen);
    +    memcpy(out + len + 8, list, listLen);
    +    len += 8 + listLen;
    +
    +    memcpy(out, "RIFF", 4);
    +    put32le(out + 4, len - 8);
    +    memcpy(out + 8, "sfbk", 4);
    +    return len;
    +}
    +
    +
    +// SoundFont 1 (sf1Build): its samples (ROM and its own, the loops put
    +// back), a region's values made SoundFont 2's (the instrument's first,
    +// the preset's where it sets none, the tuning and the sample's pitch),
    +// ROM samples where the chip has the ROM and left out elsewhere, and its
    +// notes in the software synthesizer.
    +static void sf1Tests(void) {
    +    static uint8_t data[SF_TEST_MAX];
    +    uint32_t       len = sf1Build(data);
    +    WaveBankT      bank;
    +    WaveCacheT     cache;
    +    AudioWtInfoT   wt;
    +    AudioCtxT     *ctx;
    +    MusicT        *m = NULL;
    +    uint8_t        need[2];
    +    uint32_t       missing = 0;
    +
    +    tCase("sf1: the samples, ROM first");
    +    TEST_EQ(sf2Parse(data, len, &bank), MUSIC_OK);
    +    TEST_EQ(bank.sampleCount, 2);
    +    TEST_EQ(bank.presetCount, 2);
    +
    +    if (bank.sampleCount != 2 || bank.presetCount != 2) {
    +        waveBankFree(&bank);
    +        return;
    +    }
    +
    +    const WaveSampleT *rom = &bank.samples[0];
    +    const WaveSampleT *ram = &bank.samples[1];
    +
    +    TEST_TRUE((rom->type & SF_SAMPLE_ROM) != 0);
    +    TEST_EQ(rom->start, SF1_ROM_START);
    +    TEST_EQ(rom->loopStart, SF1_ROM_LOOP);
    +    TEST_EQ(rom->loopEnd, SF1_ROM_LOOP_END);
    +    TEST_EQ(ram->type, SF_SAMPLE_MONO);
    +    TEST_EQ(ram->end, SF1_RAM_END);
    +    TEST_EQ(ram->loopStart, SF1_RAM_LOOP);
    +    TEST_EQ(ram->loopEnd, SF1_RAM_LOOP_END);
    +    TEST_EQ(ram->rate, 44100);
    +    TEST_EQ(ram->rootKey, 60);
    +
    +    tCase("sf1: a region's values in SoundFont 2's units");
    +    const WaveRegionT *r = &bank.regions[bank.presets[0].first];
    +
    +    TEST_EQ(bank.presets[0].count, 1);
    +    TEST_EQ(r->sample, 1);
    +    TEST_EQ(r->gen[SF_GEN_ROOT_KEY], 70);
    +    TEST_EQ(r->gen[SF_GEN_SCALE_TUNING], 50);
    +    TEST_EQ(r->gen[SF_GEN_COARSE_TUNE] * 100 + r->gen[SF_GEN_FINE_TUNE], 150);
    +    TEST_EQ(r->gen[SF_GEN_VOL_ENV_SUSTAIN], 0);
    +    TEST_EQ(r->gen[SF_GEN_VOL_ENV_ATTACK], -1200);
    +    TEST_EQ(r->gen[SF_GEN_PAN], 500);
    +    TEST_EQ(r->gen[SF_GEN_REVERB_SEND], 1000);
    +    TEST_TRUE(r->gen[SF_GEN_ATTENUATION] >= SF1_ATTEN_64 - 1 && r->gen[SF_GEN_ATTENUATION] <= SF1_ATTEN_64 + 1);
    +    TEST_EQ(r->gen[SF_GEN_MOD_ENV_SUSTAIN], 1000);
    +    TEST_EQ(r->gen[SF_GEN_MOD_LFO_FREQ], -725);
    +    TEST_EQ(r->gen[SF_GEN_VIB_LFO_FREQ], -15600);
    +    TEST_EQ(r->gen[SF_GEN_VOL_ENV_DECAY], -12000);
    +    TEST_EQ(r->gen[SF_GEN_SAMPLE_MODES], SF_LOOP_ON);
    +    TEST_EQ(bank.regions[bank.presets[1].first].sample, 0);
    +
    +    tCase("sf1: ROM samples on a chip with the ROM, left out elsewhere");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 1024\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_TRUE(ctx != NULL);
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    need[0] = 1;
    +    need[1] = 0;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW | AUDIO_WT_ROM, &missing), MUSIC_OK);
    +    TEST_EQ(missing, 0);
    +    TEST_EQ(cache.loaded[0].addr, AUDIO_WT_ADDR_ROM | SF1_ROM_START * 2);
    +    TEST_EQ(cache.loaded[0].frames, SF1_ROM_END - SF1_ROM_START);
    +    TEST_EQ(cache.loaded[0].rateCents, 0);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.freeMemory, 1024);
    +    waveCacheFree(ctx, &cache);
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_ERR_NO_DEVICE);
    +    TEST_EQ(missing, 1);
    +    need[1] = 1;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(missing, 1);
    +    TEST_EQ(cache.loaded[0].addr, WAVE_NOT_LOADED);
    +    TEST_TRUE(cache.loaded[1].addr != WAVE_NOT_LOADED);
    +    waveCacheFree(ctx, &cache);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.freeMemory, 1024);
    +    audioClose(ctx);
    +    waveBankFree(&bank);
    +
    +    tCase("sf1: in the software synthesizer, ROM notes on FM");
    +    TEST_EQ(platformMkdirRecursive(DVX_USER_DIR "/" DVX_BANKS_DIR), 0);
    +    writeFile(SF1_TEST_FILE, data, len);
    +    setIni("midi = softwave\nsoundFont = " SF1_TEST_NAME "\n");
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    TEST_EQ(musicMidiNoteOn(m, 0, 60, 127), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outPeak(RATE_DEV / 4) > 0);
    +    TEST_EQ(musicMidiNoteOff(m, 0, 60), MUSIC_OK);
    +    TEST_EQ(musicMidiProgram(m, 1, 1), MUSIC_OK);
    +    TEST_EQ(musicMidiNoteOn(m, 1, 60, 127), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outPeak(RATE_DEV / 4) > 0);
    +    musicFree(m);
    +    remove(SF1_TEST_FILE);
    +
    +    tCase("sf1: the AWE's ROM bank where SOUND says, in place of the standard SoundFont");
    +    char cwd[DVX_MAX_PATH];
    +    char dir[DVX_MAX_PATH];
    +    char path[DVX_MAX_PATH];
    +    char want[DVX_MAX_PATH];
    +
    +    TEST_TRUE(getcwd(cwd, sizeof(cwd)) != NULL);
    +    snprintf(dir, sizeof(dir), "%s/" SF1_SOUND_DIR, cwd);
    +    remove(SF1_SOUND_DIR "/" MUSIC_ROM_BANK_DIR "/SYNTHGM.SBK");
    +    remove(SF1_SOUND_DIR "/" MUSIC_ROM_BANK_DIR "/SYNTHGM.SF2");
    +    unsetenv(MUSIC_ROM_BANK_ENV);
    +    TEST_TRUE(!musicRomBankPath(path, sizeof(path)));
    +    TEST_STR_EQ(MusicRomBank(), "");
    +    setenv(MUSIC_ROM_BANK_ENV, dir, 1);
    +    TEST_TRUE(!musicRomBankPath(path, sizeof(path)));
    +    TEST_EQ(platformMkdirRecursive(SF1_SOUND_DIR "/" MUSIC_ROM_BANK_DIR), 0);
    +    writeFile(SF1_SOUND_DIR "/" MUSIC_ROM_BANK_DIR "/SYNTHGM.SBK", data, len);
    +    snprintf(want, sizeof(want), "%s/" MUSIC_ROM_BANK_DIR "/SYNTHGM.SBK", dir);
    +    TEST_TRUE(musicRomBankPath(path, sizeof(path)));
    +    TEST_STR_EQ(path, want);
    +    TEST_STR_EQ(MusicRomBank(), want);
    +    TEST_TRUE(!platformKindFind(path, sizeof(path), DvxKindBanksE, MUSIC_SOUND_FONT_DEFAULT));
    +    setIni("midi = softwave\n");
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    TEST_EQ(musicMidiNoteOn(m, 0, 60, 127), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outPeak(RATE_DEV / 4) > 0);
    +    musicFree(m);
    +
    +    // An SF2 one first
    +    writeFile(SF1_SOUND_DIR "/" MUSIC_ROM_BANK_DIR "/SYNTHGM.SF2", data, len);
    +    snprintf(want, sizeof(want), "%s/" MUSIC_ROM_BANK_DIR "/SYNTHGM.SF2", dir);
    +    TEST_TRUE(musicRomBankPath(path, sizeof(path)));
    +    TEST_STR_EQ(path, want);
    +    unsetenv(MUSIC_ROM_BANK_ENV);
    +}
    +
    +
    +// A SoundFont of two samples ("Ramp": frames 0-99, a loop 10-89;
    +// "Hit": 100-199, unpitched), two instruments ("Keys": a global zone of
    +// attenuation 100, Ramp below key 60 looping, Hit from 60; "Kit": Hit on
    +// keys 35-81 rooted at 60), and three presets ("Piano" 0:0 over Keys, a
    +// global zone of pan 100, its zone fine tune 10 and attenuation 2000;
    +// "Narrow" 0:1 over Keys on keys 50-70; "Drums" 128:0 over Kit).
    +static uint32_t sfBuild(const SfSpecT *spec, uint8_t *out) {
    +    static const uint16_t igen[][2] = {
    +        { 48, 100 }, { 43, 59 << 8 }, { 54, 1 }, { 53, 0 }, { 43, 60 | 127 << 8 }, { 53, 1 }, { 43, 35 | 81 << 8 }, { 58, 60 }, { 53, 1 }, { 0, 0 }
    +    };
    +    static const uint16_t ibag[] = { 0, 1, 4, 6, 9 };
    +    static const uint16_t pbag[] = { 0, 1, 4, 6, 7 };
    +    uint16_t pgen[][2] = {
    +        { 17, 100 }, { 52, 10 }, { 48, 2000 }, { 41, spec->instrument }, { 43, 50 | 70 << 8 }, { 41, 0 }, { 41, 1 }, { 0, 0 }
    +    };
    +    static uint8_t body[SF_TEST_MAX];
    +    static uint8_t list[SF_TEST_MAX];
    +    uint32_t       len     = 0;
    +    uint32_t       listLen = 0;
    +    uint32_t       n;
    +
    +    // INFO: the version
    +    memcpy(list, "INFO", 4);
    +    listLen = 4;
    +    put16le(body, spec->version);
    +    put16le(body + 2, 1);
    +    sfChunk(list, &listLen, "ifil", body, 4);
    +    memcpy(out + 12, "LIST", 4);
    +    put32le(out + 16, listLen);
    +    memcpy(out + 20, list, listLen);
    +    len = 20 + listLen;
    +
    +    // sdta: a ramp, then a different ramp
    +    memcpy(list, "sdta", 4);
    +    listLen = 4;
    +
    +    for (int32_t i = 0; i < SF_TEST_FRAMES; i++) {
    +        put16le(body + i * 2, (uint32_t)(uint16_t)(int16_t)(i < 100 ? i * 100 : -i * 50));
    +    }
    +
    +    sfChunk(list, &listLen, "smpl", body, SF_TEST_FRAMES * 2);
    +    memcpy(out + len, "LIST", 4);
    +    put32le(out + len + 4, listLen);
    +    memcpy(out + len + 8, list, listLen);
    +    len += 8 + listLen;
    +
    +    // pdta: the hydra
    +    memcpy(list, "pdta", 4);
    +    listLen = 4;
    +    memset(body, 0, sizeof(body));
    +    n = 0;
    +    memcpy(body + n, "Piano", 5);
    +    put16le(body + n + 20, 0);
    +    put16le(body + n + 22, 0);
    +    put16le(body + n + 24, 0);
    +    n += 38;
    +    memcpy(body + n, "Narrow", 6);
    +    put16le(body + n + 20, 1);
    +    put16le(body + n + 22, 0);
    +    put16le(body + n + 24, 2);
    +    n += 38;
    +    memcpy(body + n, "Drums", 5);
    +    put16le(body + n + 20, 0);
    +    put16le(body + n + 22, 128);
    +    put16le(body + n + 24, 3);
    +    n += 38;
    +    memcpy(body + n, "EOP", 3);
    +    put16le(body + n + 24, 4);
    +    n += 38;
    +    sfChunk(list, &listLen, "phdr", body, n);
    +    memset(body, 0, sizeof(body));
    +
    +    for (int32_t i = 0; i < 5; i++) {
    +        put16le(body + i * 4, pbag[i]);
    +    }
    +
    +    sfChunk(list, &listLen, "pbag", body, 5 * 4);
    +    memset(body, 0, sizeof(body));
    +    sfChunk(list, &listLen, "pmod", body, 10);
    +
    +    for (int32_t i = 0; i < 8; i++) {
    +        put16le(body + i * 4, pgen[i][0]);
    +        put16le(body + i * 4 + 2, pgen[i][1]);
    +    }
    +
    +    sfChunk(list, &listLen, "pgen", body, 8 * 4);
    +    memset(body, 0, sizeof(body));
    +    memcpy(body, "Keys", 4);
    +    put16le(body + 20, 0);
    +    memcpy(body + 22, "Kit", 3);
    +    put16le(body + 42, 3);
    +    memcpy(body + 44, "EOI", 3);
    +    put16le(body + 64, 4);
    +    sfChunk(list, &listLen, "inst", body, 3 * 22);
    +    memset(body, 0, sizeof(body));
    +
    +    for (int32_t i = 0; i < 5; i++) {
    +        put16le(body + i * 4, ibag[i]);
    +    }
    +
    +    sfChunk(list, &listLen, "ibag", body, 5 * 4);
    +    memset(body, 0, sizeof(body));
    +    sfChunk(list, &listLen, "imod", body, 10);
    +
    +    for (int32_t i = 0; i < 10; i++) {
    +        put16le(body + i * 4, igen[i][0]);
    +        put16le(body + i * 4 + 2, igen[i][1]);
    +    }
    +
    +    sfChunk(list, &listLen, "igen", body, 10 * 4);
    +    memset(body, 0, sizeof(body));
    +    memcpy(body, "Ramp", 4);
    +    put32le(body + 20, 0);
    +    put32le(body + 24, 100);
    +    put32le(body + 28, 10);
    +    put32le(body + 32, 90);
    +    put32le(body + 36, 22050);
    +    body[40] = 60;
    +    put16le(body + 44, 1);
    +    memcpy(body + 46, "Hit", 3);
    +    put32le(body + 66, 100);
    +    put32le(body + 70, spec->hitEnd);
    +    put32le(body + 74, 100);
    +    put32le(body + 78, 200);
    +    put32le(body + 82, 11025);
    +    body[86] = 255;
    +    body[87] = (uint8_t)(int8_t)-5;
    +    put16le(body + 90, 1);
    +    memcpy(body + 92, "EOS", 3);
    +    sfChunk(list, &listLen, "shdr", body, 3 * 46);
    +    memcpy(out + len, "LIST", 4);
    +    put32le(out + len + 4, listLen);
    +    memcpy(out + len + 8, list, listLen);
    +    len += 8 + listLen;
    +
    +    memcpy(out, "RIFF", 4);
    +    put32le(out + 4, len - 8);
    +    memcpy(out + 8, "sfbk", 4);
    +    return len;
    +}
    +
    +
    +// A chunk (id, length, body, a pad byte when odd) at the end of out.
    +static void sfChunk(uint8_t *out, uint32_t *len, const char *id, const uint8_t *body, uint32_t bodyLen) {
    +    memcpy(out + *len, id, 4);
    +    put32le(out + *len + 4, bodyLen);
    +    memcpy(out + *len + 8, body, bodyLen);
    +    *len += 8 + bodyLen;
    +
    +    if (bodyLen & 1) {
    +        out[(*len)++] = 0;
    +    }
    +}
    +
    +
    +static void sfTests(void) {
    +    static uint8_t data[SF_TEST_MAX];
    +    static uint8_t bad[SF_TEST_MAX];
    +    SfSpecT        spec = { 2, SF_TEST_FRAMES, 0 };
    +    WaveBankT      bank;
    +    uint32_t       len  = sfBuild(&spec, data);
    +    int16_t        frames[3];
    +
    +    tCase("sf2: presets, zones, and samples");
    +    TEST_EQ(sf2Parse(data, len, &bank), MUSIC_OK);
    +    TEST_EQ(bank.presetCount, 3);
    +    TEST_EQ(bank.sampleCount, 2);
    +    TEST_EQ(bank.dataFrames, SF_TEST_FRAMES);
    +    TEST_STR_EQ(bank.presets[0].name, "Piano");
    +    TEST_EQ(bank.samples[0].loopStart, 10);
    +    TEST_EQ(bank.samples[0].loopEnd, 90);
    +    TEST_EQ(bank.samples[0].rate, 22050);
    +    TEST_EQ(bank.samples[1].rootKey, 60);
    +    TEST_EQ(bank.samples[1].correction, -5);
    +
    +    // Piano: the instrument's global zone under each zone, the preset's
    +    // values added (attenuation clamped at 1440), its global zone's pan
    +    const WavePresetT *piano = waveBankPreset(&bank, 0, 0);
    +
    +    TEST_TRUE(piano == &bank.presets[0]);
    +    TEST_EQ(piano->count, 2);
    +
    +    const WaveRegionT *low  = &bank.regions[piano->first];
    +    const WaveRegionT *high = &bank.regions[piano->first + 1];
    +
    +    TEST_EQ(low->keyLo, 0);
    +    TEST_EQ(low->keyHi, 59);
    +    TEST_EQ(low->sample, 0);
    +    TEST_EQ(low->gen[SF_GEN_ATTENUATION], 1440);
    +    TEST_EQ(low->gen[SF_GEN_PAN], 100);
    +    TEST_EQ(low->gen[SF_GEN_FINE_TUNE], 10);
    +    TEST_EQ(low->gen[SF_GEN_SAMPLE_MODES], SF_LOOP_ON);
    +    TEST_EQ(low->gen[SF_GEN_SCALE_TUNING], 100);
    +    TEST_EQ(low->gen[SF_GEN_FILTER_FC], 13500);
    +    TEST_EQ(low->gen[SF_GEN_VOL_ENV_ATTACK], -12000);
    +    TEST_EQ(low->gen[SF_GEN_ROOT_KEY], SF_ROOT_NONE);
    +    TEST_EQ(high->keyLo, 60);
    +    TEST_EQ(high->keyHi, 127);
    +    TEST_EQ(high->sample, 1);
    +    TEST_EQ(high->gen[SF_GEN_SAMPLE_MODES], SF_LOOP_NONE);
    +
    +    // Narrow: the preset's key range cuts both zones
    +    const WavePresetT *narrow = waveBankPreset(&bank, 0, 1);
    +
    +    TEST_EQ(narrow->count, 2);
    +    TEST_EQ(bank.regions[narrow->first].keyLo, 50);
    +    TEST_EQ(bank.regions[narrow->first].keyHi, 59);
    +    TEST_EQ(bank.regions[narrow->first + 1].keyLo, 60);
    +    TEST_EQ(bank.regions[narrow->first + 1].keyHi, 70);
    +    TEST_EQ(bank.regions[narrow->first].gen[SF_GEN_ATTENUATION], 100);
    +    TEST_EQ(bank.regions[narrow->first].gen[SF_GEN_PAN], 0);
    +
    +    // Drums and the fallbacks
    +    const WavePresetT *drums = waveBankPreset(&bank, SF_DRUM_BANK, 0);
    +
    +    TEST_TRUE(drums == &bank.presets[2]);
    +    TEST_EQ(bank.regions[drums->first].keyLo, 35);
    +    TEST_EQ(bank.regions[drums->first].keyHi, 81);
    +    TEST_EQ(bank.regions[drums->first].gen[SF_GEN_ROOT_KEY], 60);
    +    TEST_TRUE(waveBankPreset(&bank, SF_DRUM_BANK, 25) == drums);
    +    TEST_TRUE(waveBankPreset(&bank, 1, 0) == piano);
    +    TEST_TRUE(waveBankPreset(&bank, 0, 99) == NULL);
    +
    +    TEST_TRUE(waveBankRead(&bank, 1, 3, frames));
    +    TEST_EQ(frames[0], 100);
    +    TEST_EQ(frames[2], 300);
    +    TEST_TRUE(!waveBankRead(&bank, SF_TEST_FRAMES - 1, 2, frames));
    +    waveBankFree(&bank);
    +
    +    tCase("sf2: a bank in a file");
    +    FILE *f = fopen("test.sf2", "wb");
    +
    +    TEST_TRUE(f && fwrite(data, 1, len, f) == len);
    +
    +    if (f) {
    +        fclose(f);
    +    }
    +
    +    TEST_EQ(sf2Load("test.sf2", &bank), MUSIC_OK);
    +    TEST_EQ(bank.presetCount, 3);
    +    TEST_TRUE(waveBankRead(&bank, 101, 1, frames));
    +    TEST_EQ(frames[0], -101 * 50);
    +    waveBankFree(&bank);
    +    TEST_EQ(sf2Load("missing.sf2", &bank), MUSIC_ERR_OPEN);
    +
    +    tCase("sf2: damaged banks are refused");
    +    memcpy(bad, data, len);
    +    bad[3] = 'X';
    +    TEST_EQ(sf2Parse(bad, len, &bank), MUSIC_ERR_BANK);
    +    TEST_EQ(sf2Parse(data, len - 1, &bank), MUSIC_ERR_BANK);
    +    TEST_EQ(sf2Parse(NULL, 0, &bank), MUSIC_ERR_BANK);
    +    spec.version = 3;
    +    len = sfBuild(&spec, bad);
    +    TEST_EQ(sf2Parse(bad, len, &bank), MUSIC_ERR_BANK);
    +    spec.version = 2;
    +    spec.hitEnd  = SF_TEST_FRAMES + 1;
    +    len = sfBuild(&spec, bad);
    +    TEST_EQ(sf2Parse(bad, len, &bank), MUSIC_ERR_BANK);
    +    spec.hitEnd     = SF_TEST_FRAMES;
    +    spec.instrument = 7;
    +    len = sfBuild(&spec, bad);
    +    TEST_EQ(sf2Parse(bad, len, &bank), MUSIC_ERR_BANK);
    +    TEST_TRUE(bank.presets == NULL && bank.regions == NULL);
    +}
    +
    +
     static void smfTests(void) {
         static uint8_t smf[BUF_MAX];
         static uint8_t cut[BUF_MAX];
    @@ -2061,13 +3743,834 @@ static void smfTests(void) {
     }
     
     
    -static void writeFile(const char *path, const void *data, uint32_t size) {
    -    FILE *f = fopen(path, "wb");
     
    -    if (f) {
    -        fwrite(data, 1, size, f);
    -        fclose(f);
    +// MIDI on the SoundFont software synthesizer (DVX.INI midi = softwave),
    +// mixed into the music's stream.
    +static void softwaveTests(void) {
    +    static uint8_t data[SF_TEST_MAX];
    +    static uint8_t flute[BUF_MAX];
    +    SfSpecT        spec     = { 2, SF_TEST_FRAMES, 0 };
    +    MusicInfoT     info;
    +    MusicT        *m        = NULL;
    +    uint32_t       fluteLen = fluteSmf(flute);
    +
    +    TEST_EQ(platformMkdirRecursive(DVX_USER_DIR "/" DVX_BANKS_DIR), 0);
    +    writeFile(SF_TEST_FILE, data, sfBuild(&spec, data));
    +
    +    tCase("softwave: a live note at its pitch");
    +    setIni("midi = softwave\nsoundFont = " SF_TEST_NAME "\n");
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_SOFTWAVE);
    +    TEST_STR_EQ(info.output, "SoundFont software synthesizer");
    +    TEST_EQ(musicMidiNoteOn(m, 0, SOFT_KEY, 127), MUSIC_OK);
    +    pump(RATE_DEV / 2);
    +    TEST_TRUE(outPeak(RATE_DEV / 2) > 0);
    +
    +    double tone = outTone(RATE_DEV / 10, RATE_DEV / 4, SOFT_HZ);
    +
    +    TEST_TRUE(tone > 4.0 * outTone(RATE_DEV / 10, RATE_DEV / 4, SOFT_HZ * 0.75));
    +    TEST_TRUE(tone > 4.0 * outTone(RATE_DEV / 10, RATE_DEV / 4, SOFT_HZ * 1.25));
    +
    +    tCase("softwave: a program's samples loaded as it is chosen");
    +    TEST_EQ(musicMidiProgram(m, 0, 1), MUSIC_OK);
    +    TEST_EQ(musicMidiNoteOn(m, 0, 55, 127), MUSIC_OK);
    +    pump(RATE_DEV / 4);
    +    TEST_TRUE(outPeak(RATE_DEV / 4) > 0);
    +    musicFree(m);
    +
    +    tCase("softwave: a note the bank lacks on the FM software synthesizer");
    +    TEST_EQ(musicLoadMem(flute, fluteLen, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_SOFTWAVE);
    +    pump(RATE_DEV / 4);
    +
    +    // The flute's A4 (FM) stands out
    +    double a4 = outTone(RATE_DEV / 20, RATE_DEV / 8, 440.0);
    +
    +    TEST_TRUE(a4 > 4.0 * outTone(RATE_DEV / 20, RATE_DEV / 8, 330.0));
    +    musicFree(m);
    +    remove(SF_TEST_FILE);
    +}
    +
    +
    +// A position heard within SOUND_POS_TOL_MS of want.
    +static bool soundNear(uint32_t ms, uint32_t want) {
    +    return ms + SOUND_POS_TOL_MS >= want && ms <= want + SOUND_POS_TOL_MS;
    +}
    +
    +
    +// WAV and VOC files as music: a WAV played from the disk (its tone, the
    +// position heard, pause, resume, seek, the end, again, stop, loop), 8-bit
    +// stereo from memory, a VOC, damaged, cut-short, and compressed WAVs; a
    +// MIDI file's title; a module's pause on a stream heard at once.
    +static void soundTests(void) {
    +    static uint8_t wav[SOUND_BYTES_MAX];
    +    static uint8_t mod[BUF_MAX];
    +    static uint8_t smf[BUF_MAX];
    +    MusicInfoT     info;
    +    MusicT        *m    = NULL;
    +    uint32_t       size = wavBuild(wav, 16, 1, SOUND_FRAMES, WAV_TAG_PCM);
    +    uint32_t       pos;
    +
    +    tCase("sound: a WAV file played from the disk");
    +    setIni("");
    +    writeFile("TONE.WAV", wav, size);
    +    TEST_EQ(musicLoad("TONE.WAV", &m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.kind, MUSIC_KIND_SOUND);
    +    TEST_EQ(info.where, MUSIC_OUT_NONE);
    +    TEST_EQ(info.lengthMs, SOUND_MS);
    +    TEST_STR_EQ(info.title, "");
    +    TEST_EQ(musicMidiNoteOn(m, 0, 60, 100), MUSIC_ERR_ARG);
    +    musicSetEndFn(m, onEnd, NULL);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_STREAM);
    +    TEST_STR_EQ(info.output, "Sound device");
    +    pump(SOUND_STEP);
    +    TEST_TRUE(outTone(SOUND_HELD, SOUND_STEP - SOUND_HELD, SOUND_HZ_FIRST) > SOUND_TONE_RATIO * outTone(SOUND_HELD, SOUND_STEP - SOUND_HELD, SOUND_HZ_SECOND));
    +    TEST_TRUE(soundNear(musicGetPosition(m), SOUND_STEP_MS));
    +
    +    // Paused after the mixer took frames the fill has not replaced yet: the
    +    // room is not filled with silence while paused.
    +    tCase("sound: paused where it is, resumed, sought");
    +    audioMixRender(sOut, RENDER_CHUNK);
    +    TEST_EQ(musicPause(m), MUSIC_OK);
    +    pos = musicGetPosition(m);
    +    pump(SOUND_STEP);
    +    TEST_EQ(outPeak(SOUND_STEP), 0);
    +    TEST_EQ(musicGetPosition(m), pos);
    +    TEST_EQ(musicResume(m), MUSIC_OK);
    +    pump(SOUND_SHORT);
    +    TEST_TRUE(outPeak(SOUND_HELD) > 0);
    +    TEST_TRUE(soundNear(musicGetPosition(m), pos + SOUND_SHORT_MS));
    +
    +    // The second half's tone at once: what the stream held is dropped.
    +    TEST_EQ(musicSetPosition(m, SOUND_SEEK_MS), MUSIC_OK);
    +    TEST_EQ(musicGetPosition(m), SOUND_SEEK_MS);
    +    pump(SOUND_SHORT);
    +    TEST_TRUE(outTone(SOUND_HELD, SOUND_SHORT - SOUND_HELD, SOUND_HZ_SECOND) > SOUND_TONE_RATIO * outTone(SOUND_HELD, SOUND_SHORT - SOUND_HELD, SOUND_HZ_FIRST));
    +    TEST_TRUE(soundNear(musicGetPosition(m), SOUND_SEEK_MS + SOUND_SHORT_MS));
    +
    +    tCase("sound: the end, played again, stopped, looped");
    +    sEnds = 0;
    +    pump(SOUND_STEP);
    +    TEST_EQ(sEnds, 1);
    +    TEST_TRUE(!musicIsPlaying(m));
    +    TEST_EQ(musicGetPosition(m), SOUND_MS);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    TEST_EQ(musicGetPosition(m), 0);
    +    pump(SOUND_SHORT);
    +    TEST_TRUE(outTone(SOUND_HELD, SOUND_SHORT - SOUND_HELD, SOUND_HZ_FIRST) > SOUND_TONE_RATIO * outTone(SOUND_HELD, SOUND_SHORT - SOUND_HELD, SOUND_HZ_SECOND));
    +    TEST_TRUE(soundNear(musicGetPosition(m), SOUND_SHORT_MS));
    +    TEST_EQ(musicStop(m), MUSIC_OK);
    +    TEST_EQ(musicGetPosition(m), 0);
    +    pump(SOUND_SHORT);
    +    TEST_EQ(outPeakFrom(SOUND_HELD, SOUND_SHORT - SOUND_HELD), 0);
    +    TEST_EQ(musicPlay(m, true), MUSIC_OK);
    +    TEST_EQ(musicSetPosition(m, SOUND_LOOP_MS), MUSIC_OK);
    +    pump(SOUND_STEP);
    +    TEST_TRUE(musicIsPlaying(m));
    +    TEST_EQ(sEnds, 1);
    +    TEST_TRUE(soundNear(musicGetPosition(m), SOUND_LOOP_MS + SOUND_STEP_MS - SOUND_MS));
    +    musicFree(m);
    +
    +    tCase("sound: 8-bit stereo from memory");
    +    size = wavBuild(wav, 8, 2, SOUND_FRAMES, WAV_TAG_PCM);
    +    TEST_EQ(musicLoadMem(wav, size, &m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.kind, MUSIC_KIND_SOUND);
    +    TEST_EQ(info.lengthMs, SOUND_MS);
    +    musicPlay(m, false);
    +    pump(SOUND_STEP);
    +    TEST_TRUE(outTone(SOUND_HELD, SOUND_STEP - SOUND_HELD, SOUND_HZ_FIRST) > SOUND_TONE_RATIO * outTone(SOUND_HELD, SOUND_STEP - SOUND_HELD, SOUND_HZ_SECOND));
    +
    +    int32_t right = 0;
    +
    +    for (uint32_t i = 0; i < SOUND_STEP; i++) {
    +        right = abs(sOut[i * 2 + 1]) > right ? abs(sOut[i * 2 + 1]) : right;
         }
    +
    +    TEST_EQ(right, 0);
    +    musicFree(m);
    +
    +    tCase("sound: a VOC file");
    +    size = vocBuild(wav, SOUND_VOC_FRAMES);
    +    writeFile("TONE.VOC", wav, size);
    +    TEST_EQ(musicLoad("TONE.VOC", &m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.kind, MUSIC_KIND_SOUND);
    +    TEST_EQ(info.lengthMs, SOUND_MS);
    +    musicPlay(m, false);
    +    pump(SOUND_STEP);
    +    TEST_TRUE(outTone(SOUND_HELD, SOUND_STEP - SOUND_HELD, SOUND_VOC_HZ_FIRST) > SOUND_TONE_RATIO * outTone(SOUND_HELD, SOUND_STEP - SOUND_HELD, SOUND_VOC_HZ_SECOND));
    +    musicFree(m);
    +
    +    tCase("sound: damaged, cut-short, and compressed WAV files");
    +    size = wavBuild(wav, 16, 1, SOUND_FRAMES, WAV_TAG_PCM);
    +    writeFile("CUT.WAV", wav, SOUND_CUT_BYTES);
    +    TEST_EQ(musicLoad("CUT.WAV", &m), MUSIC_ERR_CORRUPT);
    +    TEST_TRUE(m == NULL);
    +    TEST_EQ(musicLoadMem(wav, SOUND_CUT_BYTES, &m), MUSIC_ERR_CORRUPT);
    +    TEST_EQ(musicLoad("NOSUCH.WAV", &m), MUSIC_ERR_OPEN);
    +    writeFile("SHORT.WAV", wav, WAV_HEADER_BYTES + SOUND_RATE * SOUND_CUT_MS / MS_SECOND * sizeof(int16_t));
    +    TEST_EQ(musicLoad("SHORT.WAV", &m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.lengthMs, SOUND_CUT_MS);
    +    musicSetEndFn(m, onEnd, NULL);
    +    sEnds = 0;
    +    musicPlay(m, false);
    +    pump(RATE_DEV);
    +    TEST_EQ(sEnds, 1);
    +    TEST_EQ(musicGetPosition(m), SOUND_CUT_MS);
    +    musicFree(m);
    +    size = wavBuild(wav, 16, 1, SOUND_FRAMES, WAV_TAG_ADPCM);
    +    writeFile("ADPCM.WAV", wav, size);
    +    TEST_EQ(musicLoad("ADPCM.WAV", &m), MUSIC_ERR_UNSUPPORTED);
    +    TEST_EQ(musicLoadMem(wav, size, &m), MUSIC_ERR_UNSUPPORTED);
    +    TEST_STR_EQ(musicErrorText(MUSIC_ERR_UNSUPPORTED), audioErrorText(AUDIO_ERR_UNSUPPORTED));
    +    TEST_STR_EQ(musicErrorText(-MUSIC_ERR_COUNT), "Unknown error");
    +
    +    tCase("sound: a MIDI file's title is its first track's name");
    +    {
    +        const uint8_t  named[] = { 0x00, 0xFF, 0x03, 0x08, 'D', 'V', 'X', '\t', 'T', 'u', 'n', 'e', 0x00, 0xFF, 0x2F, 0x00 };
    +        const uint8_t  other[] = { 0x00, 0xFF, 0x03, 0x05, 'O', 't', 'h', 'e', 'r', 0x00, 0xFF, 0x2F, 0x00 };
    +        const uint8_t *tracks[] = { named, other };
    +        uint32_t       lens[]   = { sizeof(named), sizeof(other) };
    +
    +        size = buildSmf(smf, 1, 96, tracks, lens, 2);
    +        TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +        musicGetInfo(m, &info);
    +        TEST_STR_EQ(info.title, "DVX Tune");
    +        musicFree(m);
    +        size = buildSmf(smf, 1, 96, tracks + 1, lens + 1, 1);
    +        TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +        musicGetInfo(m, &info);
    +        TEST_STR_EQ(info.title, "Other");
    +        musicFree(m);
    +    }
    +
    +    tCase("sound: a module paused on a stream is silent at once");
    +    size = makeMod(mod);
    +    TEST_EQ(musicLoadMem(mod, size, &m), MUSIC_OK);
    +    musicPlay(m, false);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_STREAM);
    +    pump(SOUND_STEP);
    +    TEST_TRUE(outPeak(SOUND_STEP) > 0);
    +    musicPause(m);
    +    pump(SOUND_SHORT);
    +    TEST_EQ(outPeak(SOUND_SHORT), 0);
    +    musicResume(m);
    +    pump(SOUND_SHORT);
    +    TEST_TRUE(outPeak(SOUND_SHORT) > 0);
    +    musicFree(m);
    +}
    +
    +
    +// SoundFont samples in the fake wavetable driver's memory, through
    +// audio.lib: what a song needs, each quality in turn, what is left out,
    +// what stays, and what makes room.
    +static void waveLoadTests(void) {
    +    static uint8_t       data[SF_TEST_MAX];
    +    static uint8_t       smf[BUF_MAX];
    +    static const uint8_t keys[]  = { 0x00, 0xC0, 0, 0x00, 0x90, 40, 100, 0x60, 0x80, 40, 0, 0x00, 0xFF, 0x2F, 0x00 };
    +    static const uint8_t both[]  = { 0x00, 0xC0, 0, 0x00, 0x90, 40, 100, 0x00, 0x99, 40, 100, 0x60, 0x80, 40, 0, 0x00, 0xFF, 0x2F, 0x00 };
    +    static const uint8_t none[]  = { 0x00, 0xC0, 1, 0x00, 0x90, 40, 100, 0x60, 0x80, 40, 0, 0x00, 0xFF, 0x2F, 0x00 };
    +    const uint8_t       *tracks[1];
    +    uint32_t             lens[1];
    +    SfSpecT              spec = { 2, SF_TEST_FRAMES, 0 };
    +    WaveBankT            bank;
    +    WaveCacheT           cache;
    +    MidiFileT            f;
    +    AudioWtInfoT         wt;
    +    AudioCtxT           *ctx;
    +    uint8_t              need[2];
    +    uint32_t             missing = 0;
    +    uint32_t             addr;
    +
    +    TEST_EQ(sf2Parse(data, sfBuild(&spec, data), &bank), MUSIC_OK);
    +
    +    tCase("waveload: the samples a song plays");
    +    tracks[0] = keys;
    +    lens[0]   = sizeof(keys);
    +    TEST_EQ(midiParse(smf, buildSmf(smf, 0, 96, tracks, lens, 1), &f), MUSIC_OK);
    +    waveNeedsFile(&bank, &f, need);
    +    TEST_EQ(need[0], 1);
    +    TEST_EQ(need[1], 0);
    +    midiFileFree(&f);
    +    tracks[0] = both;
    +    lens[0]   = sizeof(both);
    +    TEST_EQ(midiParse(smf, buildSmf(smf, 0, 96, tracks, lens, 1), &f), MUSIC_OK);
    +    waveNeedsFile(&bank, &f, need);
    +    TEST_EQ(need[0], 1);
    +    TEST_EQ(need[1], 1);
    +    midiFileFree(&f);
    +
    +    // Narrow holds no key 40
    +    tracks[0] = none;
    +    lens[0]   = sizeof(none);
    +    TEST_EQ(midiParse(smf, buildSmf(smf, 0, 96, tracks, lens, 1), &f), MUSIC_OK);
    +    waveNeedsFile(&bank, &f, need);
    +    TEST_EQ(need[0] + need[1], 0);
    +    midiFileFree(&f);
    +
    +    // A preset at every key: how many it adds
    +    TEST_EQ(waveNeedsPreset(&bank, 0, 0, need), 2);
    +    TEST_EQ(waveNeedsPreset(&bank, SF_DRUM_BANK, 0, need), 0);
    +    TEST_EQ(waveNeedsPreset(&bank, 0, 99, need), 0);
    +
    +    tCase("waveload: full quality when they fit");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 1024\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_TRUE(ctx != NULL);
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    need[0] = 1;
    +    need[1] = 1;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(missing, 0);
    +    TEST_EQ(cache.loaded[0].bits, 16);
    +    TEST_EQ(cache.loaded[0].shift, 0);
    +    TEST_EQ(cache.loaded[0].frames, WT_RAMP_FRAMES);
    +    TEST_EQ(cache.loaded[0].rateCents, WT_RAMP_CENTS);
    +    TEST_EQ(cache.loaded[1].frames, WT_HIT_FRAMES);
    +    TEST_EQ(cache.loaded[1].rateCents, WT_HIT_CENTS);
    +
    +    // Ramp's third frame (200) little-endian; its guard frames are Hit's
    +    // first (-5000)
    +    addr = cache.loaded[0].addr;
    +    TEST_EQ(sWtMemory[addr + 4], 200);
    +    TEST_EQ(sWtMemory[addr + 5], 0);
    +    TEST_EQ((int16_t)(sWtMemory[addr + 200] | sWtMemory[addr + 201] << 8), -5000);
    +
    +    // Given back with the cache
    +    waveCacheFree(ctx, &cache);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.freeMemory, 1024);
    +    audioClose(ctx);
    +
    +    tCase("waveload: 8-bit when 16-bit does not fit");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 300\nfakeWtFlags = 19\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW | AUDIO_WT_8BIT, &missing), MUSIC_OK);
    +    TEST_EQ(cache.loaded[0].bits, 8);
    +    TEST_EQ(cache.loaded[1].bits, 8);
    +    TEST_EQ(cache.loaded[0].shift, 0);
    +
    +    // Frame 99 (9900) as its top byte
    +    TEST_EQ(sWtMemory[cache.loaded[0].addr + 99], 9900 >> 8);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +
    +    tCase("waveload: half the rate, then a quarter");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 300\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(cache.loaded[0].bits, 16);
    +    TEST_EQ(cache.loaded[0].shift, 1);
    +    TEST_EQ(cache.loaded[0].frames, WT_RAMP_FRAMES / 2);
    +    TEST_EQ(cache.loaded[0].rateCents, WT_RAMP_CENTS - 1200);
    +    TEST_EQ(cache.loaded[1].rateCents, WT_HIT_CENTS - 1200);
    +
    +    // Frame 1 is frames 2 and 3 averaged: 250
    +    TEST_EQ(sWtMemory[cache.loaded[0].addr + 2], 250);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 130\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(cache.loaded[0].shift, 2);
    +    TEST_EQ(cache.loaded[1].shift, 2);
    +    TEST_EQ(cache.loaded[0].rateCents, WT_RAMP_CENTS - 2400);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +
    +    tCase("waveload: what still does not fit is left out");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 64\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(missing, 1);
    +    TEST_TRUE(cache.loaded[0].addr != WAVE_NOT_LOADED);
    +    TEST_EQ(cache.loaded[1].addr, WAVE_NOT_LOADED);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 32\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_ERR_NO_DEVICE);
    +    TEST_EQ(missing, 2);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +
    +    tCase("waveload: what a song loads stays while it fits");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 1024\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    need[1] = 0;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    addr    = cache.loaded[0].addr;
    +    need[0] = 0;
    +    need[1] = 1;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(cache.loaded[0].addr, addr);
    +    TEST_TRUE(cache.loaded[1].addr != WAVE_NOT_LOADED);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +
    +    tCase("waveload: samples a song does not need make room");
    +    setIni("fakeWtVoices = 4\nfakeWtMemory = 230\nfakeWtFlags = 3\n");
    +    ctx = audioOpen();
    +    TEST_EQ(waveCacheInit(&cache, &bank), MUSIC_OK);
    +    need[0] = 1;
    +    need[1] = 0;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_TRUE(cache.loaded[0].addr != WAVE_NOT_LOADED);
    +    need[0] = 0;
    +    need[1] = 1;
    +    TEST_EQ(waveLoadNeeded(ctx, &bank, &cache, need, WT_FLAGS_HW, &missing), MUSIC_OK);
    +    TEST_EQ(cache.loaded[0].addr, WAVE_NOT_LOADED);
    +    TEST_EQ(cache.loaded[1].shift, 0);
    +    waveCacheFree(ctx, &cache);
    +    audioClose(ctx);
    +    waveBankFree(&bank);
    +}
    +
    +
    +// Whole musics on the fake wavetable synthesizer (fakeWtVoices) with the
    +// test SoundFont in the user's banks folder.
    +static void wavePlayTests(void) {
    +    static uint8_t       data[SF_TEST_MAX];
    +    static uint8_t       smf[BUF_MAX];
    +    static uint8_t       flute[BUF_MAX];
    +    static const uint8_t track[] = { 0x00, 0xC0, 0, 0x00, 0x90, 40, 127, 0x60, 0x80, 40, 0, 0x00, 0xFF, 0x2F, 0x00 };
    +    const uint8_t *const tracks[] = { track };
    +    const uint32_t       lens[]   = { sizeof(track) };
    +    SfSpecT              spec     = { 2, SF_TEST_FRAMES, 0 };
    +    MusicInfoT           info;
    +    AudioWtInfoT         wt;
    +    MusicT              *m        = NULL;
    +    MusicT              *m2       = NULL;
    +    MusicT              *m3       = NULL;
    +    uint32_t             size     = buildSmf(smf, 0, 96, tracks, lens, 1);
    +    uint32_t             fluteLen = fluteSmf(flute);
    +    int32_t              starts;
    +    int32_t              uploads;
    +
    +    TEST_EQ(platformMkdirRecursive(DVX_USER_DIR "/" DVX_BANKS_DIR), 0);
    +    writeFile(SF_TEST_FILE, data, sfBuild(&spec, data));
    +
    +    tCase("waveplay: MIDI on the wavetable synthesizer, played by the timer");
    +    setIni("fakeWtVoices = 8\nsoundFont = " SF_TEST_NAME "\n");
    +    sWtStarts = 0;
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    musicSetEndFn(m, onEnd, NULL);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    TEST_STR_EQ(info.output, "Fake wavetable (8 voices)");
    +    hostClockAdvance(10);
    +    TEST_EQ(sWtStarts, 1);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, WT_PIANO_PITCH);
    +    TEST_EQ(sWtVoice[sWtLast].attenuation, WT_PIANO_ATTEN);
    +    TEST_EQ(sWtVoice[sWtLast].pan, 100);
    +
    +    // Only the song's sample (Ramp) is in the synthesizer
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, WT_RAMP_BYTES);
    +
    +    // The music's volume reaches the voice
    +    TEST_EQ(musicSetVolume(m, FULL / 2), MUSIC_OK);
    +    TEST_TRUE(sWtVoice[sWtLast].attenuation > WT_PIANO_ATTEN);
    +    TEST_EQ(musicSetVolume(m, FULL), MUSIC_OK);
    +    sEnds = 0;
    +    hostClockAdvance(500);
    +    TEST_TRUE(sWtReleased & (1u << sWtLast));
    +    musicIdle(NULL);
    +    TEST_EQ(sEnds, 1);
    +
    +    tCase("waveplay: the samples stay for the next music a while");
    +    musicFree(m);
    +    audioWtGetInfo(&wt);
    +    TEST_TRUE(wt.present);
    +    TEST_EQ(wt.memory - wt.freeMemory, WT_RAMP_BYTES);
    +    uploads = sWtUploads;
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    TEST_EQ(sWtUploads, uploads);
    +    musicFree(m);
    +    hostClockAdvance(MUSIC_FONT_KEEP_SEC * 1000 - 1000);
    +    musicIdle(NULL);
    +    audioWtGetInfo(&wt);
    +    TEST_TRUE(wt.present);
    +    hostClockAdvance(1000);
    +    musicIdle(NULL);
    +    audioWtGetInfo(&wt);
    +    TEST_TRUE(!wt.present);
    +
    +    // musicSuspend, the library's platform suspend hook
    +    tCase("waveplay: a DOS program run stops the music and lets the samples go at once");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    hostClockAdvance(10);
    +    musicSuspend(true);
    +    TEST_TRUE(!musicIsPlaying(m));
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_NONE);
    +    musicSuspend(false);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    musicFree(m);
    +    audioWtGetInfo(&wt);
    +    TEST_TRUE(wt.present);
    +    musicSuspend(true);
    +    audioWtGetInfo(&wt);
    +    TEST_TRUE(!wt.present);
    +    musicSuspend(false);
    +
    +    tCase("waveplay: notes the SoundFont lacks play on the FM chip");
    +    setIni("fakeWtVoices = 8\nfakeFmVoices = 9\nsoundFont = " SF_TEST_NAME "\n");
    +    memset(sFmRegs, 0, sizeof(sFmRegs));
    +    starts = sWtStarts;
    +    TEST_EQ(musicLoadMem(flute, fluteLen, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, true), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    hostClockAdvance(10);
    +    TEST_EQ(sWtStarts, starts);
    +
    +    // Flute: A4, 580 at block 4, on channel 0
    +    TEST_EQ(sFmRegs[0xA0], 580 & 0xFF);
    +    TEST_EQ(sFmRegs[0xB0], AUDIO_FM_KEY_ON | (4 << 2) | (580 >> 8));
    +
    +    tCase("waveplay: one music at a time; a stopped one gives it up");
    +    TEST_EQ(musicLoadMem(smf, size, &m2), MUSIC_OK);
    +    TEST_EQ(musicPlay(m2, false), MUSIC_OK);
    +    musicGetInfo(m2, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_FM);
    +    TEST_EQ(musicStop(m), MUSIC_OK);
    +    TEST_EQ(musicLoadMem(smf, size, &m3), MUSIC_OK);
    +    TEST_EQ(musicPlay(m3, true), MUSIC_OK);
    +    musicGetInfo(m3, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_NONE);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_FM);
    +    musicFree(m);
    +    musicFree(m2);
    +    musicFree(m3);
    +
    +    tCase("waveplay: midi = fm, or a missing SoundFont: the FM chip");
    +    setIni("midi = fm\nfakeWtVoices = 8\nfakeFmVoices = 9\nsoundFont = " SF_TEST_NAME "\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_FM);
    +    musicFree(m);
    +    setIni("fakeWtVoices = 8\nfakeFmVoices = 9\nsoundFont = NOPE.SF2\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_FM);
    +    musicFree(m);
    +
    +    tCase("waveplay: musicSetBank chooses a SoundFont");
    +    setIni("fakeWtVoices = 8\nfakeFmVoices = 9\n");
    +    TEST_EQ(musicLoadMem(smf, size, &m), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m, SF_TEST_NAME), MUSIC_OK);
    +    TEST_EQ(musicPlay(m, false), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    TEST_EQ(musicLoadMem(smf, size, &m2), MUSIC_OK);
    +    TEST_EQ(musicSetBank(m2, "NOPE.SF2"), MUSIC_ERR_BANK);
    +    musicFree(m);
    +    musicFree(m2);
    +
    +    tCase("waveplay: live MIDI");
    +    setIni("fakeWtVoices = 8\nsoundFont = " SF_TEST_NAME "\n");
    +    starts = sWtStarts;
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    musicGetInfo(m, &info);
    +    TEST_EQ(info.where, MUSIC_OUT_WAVE);
    +    TEST_EQ(musicMidiNoteOn(m, 0, 40, 127), MUSIC_OK);
    +    TEST_EQ(sWtStarts, starts + 1);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, WT_PIANO_PITCH);
    +    TEST_EQ(musicMidiProgram(m, 9, 0), MUSIC_OK);
    +    TEST_EQ(musicMidiNoteOn(m, 9, 40, 100), MUSIC_OK);
    +    TEST_EQ(sWtVoice[sWtLast].pitch, WT_DRUM_PITCH);
    +    musicFree(m);
    +
    +    // A program's samples loaded as it is chosen (the notes sounding
    +    // stopped first): LIVE.SF2's first program needs only Hit, Narrow
    +    // Ramp as well
    +    spec.instrument = 1;
    +    writeFile(SF_LIVE_FILE, data, sfBuild(&spec, data));
    +    setIni("fakeWtVoices = 8\nsoundFont = " SF_LIVE_NAME "\n");
    +    TEST_EQ(musicMidiOpen(&m), MUSIC_OK);
    +    audioWtGetInfo(&wt);
    +    TEST_EQ(wt.memory - wt.freeMemory, WT_HIT_FRAMES * 2);
    +    TEST_EQ(musicMidiNoteOn(m, 0, 40, 127), MUSIC_OK);
    +    TEST_TRUE(sWtActive & (1u << sWtLast));
    +    uploads = sWtUploads;
    +    TEST_EQ(musicMidiProgram(m, 0, 1), MUSIC_OK);
    +    TEST_EQ(sWtUploads, uploads + 1);
    +    TEST_EQ(sWtActive, 0);
    +    TEST_EQ(musicMidiNoteOn(m, 0, 55, 127), MUSIC_OK);
    +    TEST_EQ(sWtVoice[sWtLast].loop, AUDIO_WT_LOOP_ON);
    +    TEST_EQ(musicMidiProgram(m, 0, 0), MUSIC_OK);
    +    TEST_EQ(sWtUploads, uploads + 1);
    +    musicFree(m);
    +    remove(SF_LIVE_FILE);
    +
    +    // The context goes after a while with no music
    +    hostClockAdvance(MUSIC_FONT_KEEP_SEC * 1000);
    +    musicIdle(NULL);
    +    audioWtGetInfo(&wt);
    +    TEST_TRUE(!wt.present);
    +    remove(SF_TEST_FILE);
    +}
    +
    +
    +// General MIDI on wavetable voices: a recording backend, the test
    +// SoundFont, its two samples where the loaded table says.
    +static void waveTests(void) {
    +    static uint8_t    data[SF_TEST_MAX];
    +    static RecWaveT   rec;
    +    static WaveSynthT synth;
    +    static const uint8_t gmOn[] = { 0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7 };
    +    WaveLoadedT       loaded[] = { { WT_RAMP_ADDR, WT_RAMP_FRAMES, WT_RAMP_CENTS, 16, 0 }, { WT_HIT_ADDR, WT_HIT_FRAMES, WT_HIT_CENTS, 16, 0 } };
    +    SfSpecT           spec     = { 2, SF_TEST_FRAMES, 0 };
    +    WaveBackendT      be       = { wrecAlloc, wrecFree, wrecStart, wrecUpdate, wrecRelease, wrecActive, &rec, WT_FLAGS_HW };
    +    WaveBankT         bank;
    +    RecSinkT          over;
    +    int32_t           id;
    +    int32_t           frees;
    +    int32_t           starts;
    +
    +    TEST_EQ(sf2Parse(data, sfBuild(&spec, data), &bank), MUSIC_OK);
    +
    +    tCase("wave: rates in cents");
    +    TEST_EQ(waveRatioCents(44100, 44100), 0);
    +    TEST_EQ(waveRatioCents(22050, 44100), -1200);
    +    TEST_EQ(waveRatioCents(88200, 44100), 1200);
    +
    +    // 1200 log2(32000 / 44100) = -555.4
    +    int32_t cents = waveRatioCents(32000, 44100);
    +
    +    TEST_TRUE(cents >= -556 && cents <= -555);
    +
    +    tCase("wave: a note's sample, loop, pitch, loudness, and pan");
    +    memset(&rec, 0, sizeof(rec));
    +    rec.limit = WT_VOICES_MAX;
    +    recSinkInit(&over);
    +    waveSynthInit(&synth, &be, &bank, loaded, &over.sink, FULL);
    +    synth.sink.message(&synth.sink, 0x90, 40, 127);
    +    TEST_EQ(rec.starts, 1);
    +    id = rec.last;
    +
    +    const AudioWtVoiceT *v = &rec.voice[id];
    +
    +    TEST_EQ(v->addr, WT_RAMP_ADDR);
    +    TEST_EQ(v->end, 100);
    +    TEST_EQ(v->loopStart, 10);
    +    TEST_EQ(v->loopEnd, 90);
    +    TEST_EQ(v->loop, AUDIO_WT_LOOP_ON);
    +    TEST_EQ(v->bits, 16);
    +    TEST_EQ(v->pitch, WT_PIANO_PITCH);
    +    TEST_EQ(v->attenuation, WT_PIANO_ATTEN);
    +    TEST_EQ(v->pan, 100);
    +    TEST_EQ(v->filterFc, 13500);
    +    TEST_EQ(over.count, 0);
    +
    +    tCase("wave: bend, volume, pan, and the bend range");
    +    synth.sink.message(&synth.sink, 0xE0, 0x7F, 0x7F);
    +    TEST_EQ(rec.what[id], AUDIO_WT_SET_PITCH);
    +    TEST_EQ(v->pitch, WT_PIANO_PITCH + WT_BEND_UP);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_VOLUME, 127);
    +    TEST_EQ(rec.what[id], AUDIO_WT_SET_VOLUME);
    +    TEST_EQ(v->attenuation, WT_PIANO_ATTEN - WT_CC7_ATTEN);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_PAN, 0);
    +    TEST_EQ(rec.what[id], AUDIO_WT_SET_PAN);
    +    TEST_EQ(v->pan, 100 - AUDIO_WT_PAN_MAX);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_RPN_MSB, 0);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_RPN_LSB, MIDI_RPN_BEND_RANGE);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_DATA, 12);
    +    TEST_EQ(v->pitch, WT_PIANO_PITCH + 8191 * 12 * 100 / 8192);
    +
    +    // All of it passed on (to keep the overflow's channels in step)
    +    TEST_EQ(over.count, 6);
    +
    +    tCase("wave: note off and the sustain pedal");
    +    synth.sink.message(&synth.sink, 0x80, 40, 0);
    +    TEST_TRUE(rec.released[id]);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_SUSTAIN, 127);
    +    synth.sink.message(&synth.sink, 0x90, 41, 100);
    +
    +    int32_t held = rec.last;
    +
    +    synth.sink.message(&synth.sink, 0x80, 41, 0);
    +    TEST_TRUE(!rec.released[held]);
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_SUSTAIN, 0);
    +    TEST_TRUE(rec.released[held]);
    +
    +    tCase("wave: voices that fall silent are given back");
    +    rec.active[id] = false;
    +    waveSynthTick(&synth, MUSIC_TICK_US);
    +    TEST_TRUE(!rec.used[id]);
    +    TEST_TRUE(rec.used[held]);
    +    synth.sink.silence(&synth.sink);
    +    TEST_TRUE(!rec.used[held]);
    +    TEST_EQ(over.silences, 1);
    +
    +    tCase("wave: drums on channel 10; exclusive classes");
    +    synth.sink.message(&synth.sink, 0x99, 40, 100);
    +    v = &rec.voice[rec.last];
    +    TEST_EQ(v->addr, WT_HIT_ADDR);
    +    TEST_EQ(v->pitch, WT_DRUM_PITCH);
    +    TEST_EQ(v->attenuation, WT_DRUM_ATTEN);
    +    TEST_EQ(v->loop, AUDIO_WT_LOOP_NONE);
    +    TEST_EQ(v->end, 100);
    +    frees = rec.frees;
    +    synth.sink.message(&synth.sink, 0x99, 42, 100);
    +    TEST_EQ(rec.frees, frees);
    +    synth.sink.silence(&synth.sink);
    +
    +    // A class (an open and a closed hi-hat): each note stops the last
    +    const WavePresetT *kit = waveBankPreset(&bank, SF_DRUM_BANK, 0);
    +
    +    bank.regions[kit->first].gen[SF_GEN_EXCLUSIVE_CLASS] = 1;
    +    synth.sink.message(&synth.sink, 0x99, 42, 100);
    +    frees = rec.frees;
    +    synth.sink.message(&synth.sink, 0x99, 44, 100);
    +    TEST_EQ(rec.frees, frees + 1);
    +    bank.regions[kit->first].gen[SF_GEN_EXCLUSIVE_CLASS] = 0;
    +    synth.sink.silence(&synth.sink);
    +
    +    tCase("wave: with no voice free, a released one, else the oldest");
    +    rec.limit = 2;
    +    synth.sink.message(&synth.sink, 0x90, 40, 127);
    +    synth.sink.message(&synth.sink, 0x90, 41, 127);
    +    TEST_EQ(rec.last, 2);
    +    synth.sink.message(&synth.sink, 0x80, 41, 0);
    +    frees = rec.frees;
    +    synth.sink.message(&synth.sink, 0x90, 43, 127);
    +    TEST_EQ(rec.frees, frees + 1);
    +    TEST_EQ(rec.last, 2);
    +    synth.sink.message(&synth.sink, 0x90, 45, 127);
    +    TEST_EQ(rec.frees, frees + 2);
    +    TEST_EQ(rec.last, 1);
    +    synth.sink.silence(&synth.sink);
    +    rec.limit = WT_VOICES_MAX;
    +
    +    tCase("wave: notes the bank cannot play go to the overflow");
    +    loaded[1].addr = WAVE_NOT_LOADED;
    +    recSinkInit(&over);
    +    starts = rec.starts;
    +    synth.sink.message(&synth.sink, 0x90, 70, 100);
    +    TEST_EQ(rec.starts, starts);
    +    TEST_EQ(over.count, 1);
    +    TEST_EQ(over.msgs[0], 0x90u | 70u << 8 | 100u << 16);
    +    synth.sink.message(&synth.sink, 0xC0, 99, 0);
    +    synth.sink.message(&synth.sink, 0x90, 40, 100);
    +    TEST_EQ(rec.starts, starts);
    +    TEST_EQ(over.count, 3);
    +    synth.sink.message(&synth.sink, 0xC0, 0, 0);
    +    synth.sink.message(&synth.sink, 0x90, 40, 100);
    +    TEST_EQ(rec.starts, starts + 1);
    +    TEST_EQ(over.count, 4);
    +    loaded[1].addr = WT_HIT_ADDR;
    +    synth.sink.silence(&synth.sink);
    +
    +    tCase("wave: GM System On resets the channels");
    +    synth.sink.message(&synth.sink, 0xB0, MIDI_CC_VOLUME, 0);
    +    synth.sink.sysex(&synth.sink, gmOn, sizeof(gmOn));
    +    TEST_EQ(over.sysexCount, 1);
    +    synth.sink.message(&synth.sink, 0x90, 40, 127);
    +    TEST_EQ(rec.voice[rec.last].attenuation, WT_PIANO_ATTEN);
    +    synth.sink.silence(&synth.sink);
    +
    +    tCase("wave: envelopes run here when the backend has none");
    +    be.flags = 0;
    +    waveSynthInit(&synth, &be, &bank, loaded, NULL, FULL);
    +    synth.sink.message(&synth.sink, 0x90, 40, 127);
    +    id = rec.last;
    +    TEST_EQ(rec.voice[id].attenuation, AUDIO_WT_ATTEN_MAX);
    +
    +    for (int32_t i = 0; i < 10; i++) {
    +        waveSynthTick(&synth, MUSIC_TICK_US);
    +    }
    +
    +    TEST_EQ(rec.voice[id].attenuation, WT_PIANO_ATTEN);
    +    frees = rec.frees;
    +    synth.sink.message(&synth.sink, 0x80, 40, 0);
    +
    +    for (int32_t i = 0; i < 10; i++) {
    +        waveSynthTick(&synth, MUSIC_TICK_US);
    +    }
    +
    +    TEST_EQ(rec.frees, frees + 1);
    +    TEST_TRUE(!rec.used[id]);
    +    waveBankFree(&bank);
    +}
    +
    +
    +static bool wrecActive(void *owner, int32_t voice) {
    +    return ((RecWaveT *)owner)->active[voice];
    +}
    +
    +
    +static int32_t wrecAlloc(void *owner) {
    +    RecWaveT *r = (RecWaveT *)owner;
    +
    +    for (int32_t v = 1; v <= r->limit; v++) {
    +        if (!r->used[v]) {
    +            r->used[v] = true;
    +            return v;
    +        }
    +    }
    +
    +    return AUDIO_ERR_NO_VOICE;
    +}
    +
    +
    +static void wrecFree(void *owner, int32_t voice) {
    +    RecWaveT *r = (RecWaveT *)owner;
    +
    +    r->used[voice]   = false;
    +    r->active[voice] = false;
    +    r->frees++;
    +}
    +
    +
    +static void wrecRelease(void *owner, int32_t voice) {
    +    ((RecWaveT *)owner)->released[voice] = true;
    +}
    +
    +
    +static void wrecStart(void *owner, int32_t voice, const AudioWtVoiceT *v) {
    +    RecWaveT *r = (RecWaveT *)owner;
    +
    +    r->voice[voice]    = *v;
    +    r->active[voice]   = true;
    +    r->released[voice] = false;
    +    r->last            = voice;
    +    r->starts++;
    +}
    +
    +
    +static void wrecUpdate(void *owner, int32_t voice, const AudioWtVoiceT *v, uint32_t what) {
    +    RecWaveT *r = (RecWaveT *)owner;
    +
    +    r->voice[voice] = *v;
    +    r->what[voice]  = what;
     }
     
     
    @@ -2115,6 +4618,7 @@ static void xmpTests(void) {
             }
     
             TEST_TRUE(xmpLength(p) > 0);
    +        TEST_EQ(xmpBidiAdjust(p), strcmp(names[i], "it") == 0 ? 1U : 0U);
             memset(pcm, 0, sizeof(pcm));
             xmpRender(p, pcm, RATE_DEV, false);
     
    @@ -2158,12 +4662,23 @@ int main(void) {
         smfTests();
         seqTests();
         op2Tests();
    +    sfTests();
    +    sf1Tests();
         fmTests();
    +    waveTests();
         mt32Tests();
         cmpTests();
         xmpTests();
         playTests();
    +    soundTests();
    +    waveLoadTests();
    +    wavePlayTests();
    +    modHwTests();
    +    softwaveTests();
    +    patTests();
    +    gmBankTests();
         basicTests();
    +    midiRecordTests();
         mmlTests();
         mmlPlayTests();
         musicShutdown();
    diff --git a/src/test/music/playerTest.c b/src/test/music/playerTest.c
    new file mode 100644
    index 00000000..5373eb1c
    --- /dev/null
    +++ b/src/test/music/playerTest.c
    @@ -0,0 +1,635 @@
    +// 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.
    +
    +// playerTest.c  -- the Media Player program (src/apps/kpunch/player),
    +// compiled from its project and run in the shell fixture with music.lib,
    +// audio.lib, and fileops linked in, on the fake sound driver (pcm.drv:
    +// 22,050 Hz, mixed here a tick at a time) and the bundled FM bank.
    +//
    +// Covered: files from the command line (labels, sound, time), pause and
    +// resume, the position and volume sliders, the end moving on, Next and
    +// Previous, Repeat and Shuffle, Move / Remove / Clear, a file that cannot
    +// be played (on its own and in the middle of the list), a second start
    +// handing over its files (NewArgs), Open and Add Folder through their
    +// dialogs, and the settings and list kept in PLAYER.INI.
    +
    +#include "shellFixture.h"
    +#include "formrt/formrt.h"
    +#include "audioInt.h"
    +#include "musicFiles.h"
    +#include "musicInt.h"
    +#include "dvxPlat.h"
    +#include "dvxPlatHost.h"
    +#include "dvxWm.h"
    +#include "listBox/listBox.h"
    +#include "slider/slider.h"
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#define APP_PLAYER          "apps/kpunch/player/player.app"
    +#define PLAYER_DBP          "src/apps/kpunch/player/player.dbp"   // under fixRepoRoot
    +#define PLAYER_INI          "PLAYER.INI"                          // in the program's user folder
    +#define PLAYER_TITLE        "Media Player"
    +#define START_FRAMES_MAX    400
    +#define MODE_DIR            0755
    +#define TEXT_MAX            256
    +
    +#define RATE_DEV            22050
    +#define TICK_MS             100
    +#define TICK_FRAMES         (RATE_DEV * TICK_MS / 1000)
    +#define HELD_FRAMES         4           // the mixer's current and next frames, heard after a stop
    +#define TONE_SECONDS        3
    +#define TONE_FRAMES         (SOUND_RATE * TONE_SECONDS)
    +#define TONE_BYTES_MAX      (WAV_HEADER_BYTES + TONE_FRAMES * 2)
    +#define MEDIA_BYTES_MAX     8192
    +#define END_TICKS_MAX       120         // longer than any file here (the module's 7.7 s)
    +#define SETTLE_TICKS        6           // the slider's seek: two timer ticks of 250 ms, and a little
    +
    +#define TONE_FILE           "TONE.WAV"
    +#define TUNE_FILE           "TUNE.MOD"
    +#define FLUTE_FILE          "FLUTE.MID"
    +#define BAD_FILE            "BAD.WAV"
    +#define MEDIA_DIR           "MEDIA"
    +#define TUNE_TITLE          "dvx test"
    +#define TONE_INFO           "Sound (WAV) - Sound device"
    +#define TUNE_INFO           "Module (MOD) - Sound device"
    +#define FLUTE_INFO          "MIDI music (MID) - FM software synthesizer"
    +#define NOTHING_PLAYING     "Nothing is playing"
    +#define PLAY_CAPTION        "Pl&ay"
    +#define PAUSE_CAPTION       "P&ause"
    +
    +#define MENU_OPEN           "&Open..."
    +#define MENU_ADD_FOLDER     "Add Fol&der..."
    +#define MENU_MOVE_UP        "Move &Up"
    +#define MENU_MOVE_DOWN      "Move Do&wn"
    +#define MENU_CLEAR          "&Clear List"
    +#define MENU_PLAY_PAUSE     "&Play / Pause"
    +#define MENU_STOP           "&Stop"
    +#define MENU_PREV           "Pre&vious"
    +#define MENU_NEXT           "&Next"
    +#define MENU_REPEAT_OFF     "Repeat O&ff"
    +#define MENU_REPEAT_LIST    "Repeat &List"
    +#define MENU_REPEAT_TRACK   "Repeat &Track"
    +#define MENU_SHUFFLE        "S&huffle"
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void        audioHooks(bool on);
    +static void        caseErrorsAndNewArgs(void);
    +static void        caseList(void);
    +static void        caseNextPrevious(void);
    +static void        caseOpenAndFolder(void);
    +static void        casePauseSeekVolume(void);
    +static void        caseRepeatShuffle(void);
    +static void        caseSettings(void);
    +static void        caseStart(void);
    +void               fakeDrvEvent(const char *driver, const char *event);
    +void               fakeDrvTone(uint32_t hz);
    +static bool        fileHas(const char *path, const char *text);
    +static int32_t     heard(void);
    +static const char *infoText(void);
    +static int32_t     launch(const char *args);
    +static WidgetT    *listOf(void);
    +static void        makeMedia(void);
    +static void        menu(const char *caption);
    +static bool        menuChecked(const char *caption);
    +static void        pressKey(const char *widget, int32_t key);
    +static const char *text(const char *widget);
    +static void        tick(int32_t n);
    +static const char *titleText(void);
    +static bool        untilTitle(const char *title);
    +
    +static int16_t  sOut[TICK_FRAMES * 2];
    +static int32_t  sId;                // the program running
    +static WindowT *sWin;               // its window
    +
    +
    +// The shell's idle time and program ends for the two libraries, as their
    +// shell hooks (musicShell.c, audioShell.c, not linked here) do.
    +static void audioHooks(bool on) {
    +    if (on) {
    +        shellRegisterIdle(audioIdle, NULL);
    +        shellRegisterIdle(musicIdle, NULL);
    +        shellRegisterAppCleanup(audioAppEnded);
    +        shellRegisterAppCleanup(musicAppEnded);
    +        return;
    +    }
    +
    +    shellUnregisterAppCleanup(musicAppEnded);
    +    shellUnregisterAppCleanup(audioAppEnded);
    +    shellUnregisterIdle(musicIdle, NULL);
    +    shellUnregisterIdle(audioIdle, NULL);
    +}
    +
    +
    +// A file that cannot be played says why, on its own and when the list
    +// reaches it (the list goes on past it); a second start of the program
    +// hands its files over, a file already in the list played where it is.
    +static void caseErrorsAndNewArgs(void) {
    +    tCase("player: a second start hands its files over; a file that cannot be played");
    +    launch(FLUTE_FILE " " BAD_FILE " " TONE_FILE);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 3);
    +    TEST_STR_EQ(titleText(), FLUTE_FILE);
    +    TEST_STR_EQ(infoText(), FLUTE_INFO);
    +    tick(1);
    +    TEST_TRUE(heard() > 0);
    +
    +    // The flute ends, BAD.WAV is said to be unplayable, TONE.WAV plays.
    +    fixDialogsOn("OK");
    +    fixDialogs = 0;
    +    TEST_TRUE(untilTitle(TONE_FILE));
    +    fixDialogsOff();
    +    TEST_EQ(fixDialogs, 1);
    +    TEST_EQ(wgtListBoxGetSelected(listOf()), 2);
    +
    +    // Chosen by itself: the reason stays shown.
    +    wgtListBoxSetSelected(listOf(), 1);
    +    fixDialogsOn("OK");
    +    fixDialogs = 0;
    +    pressKey("lstTracks", KEY_ENTER);
    +    fixDialogsOff();
    +    TEST_EQ(fixDialogs, 1);
    +    TEST_STR_EQ(titleText(), BAD_FILE);
    +    TEST_STR_EQ(infoText(), audioErrorText(AUDIO_ERR_UNSUPPORTED));
    +    TEST_STR_EQ(text("btnPlay"), PLAY_CAPTION);
    +
    +    // A file already listed is not added again.
    +    launch(FLUTE_FILE);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 3);
    +    TEST_EQ(wgtListBoxGetSelected(listOf()), 0);
    +    menu(MENU_CLEAR);
    +}
    +
    +
    +// Move Up and Move Down (the track playing follows its file), Del, the
    +// track playing taken out, Clear List.
    +static void caseList(void) {
    +    tCase("player: move, remove, clear");
    +    launch(TONE_FILE " " TUNE_FILE " " FLUTE_FILE);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 3);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +
    +    menu(MENU_MOVE_DOWN);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 0), TUNE_FILE);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 1), TONE_FILE);
    +    TEST_EQ(wgtListBoxGetSelected(listOf()), 1);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    menu(MENU_MOVE_DOWN);
    +    menu(MENU_MOVE_DOWN);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 2), TONE_FILE);
    +    menu(MENU_MOVE_UP);
    +    menu(MENU_MOVE_UP);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 0), TONE_FILE);
    +
    +    // Next follows the new order.
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +
    +    wgtListBoxSetSelected(listOf(), 2);
    +    pressKey("lstTracks", KEY_DELETE);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 2);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    wgtListBoxSetSelected(listOf(), 1);
    +    pressKey("lstTracks", KEY_DELETE);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 1);
    +    TEST_STR_EQ(titleText(), NOTHING_PLAYING);
    +    TEST_STR_EQ(sWin->title, PLAYER_TITLE);
    +    tick(1);
    +    TEST_EQ(heard(), 0);
    +    menu(MENU_CLEAR);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 0);
    +}
    +
    +
    +// The end of a track plays the next; Next at the end of the list does
    +// nothing; Previous near a track's start goes back, later starts it again;
    +// the end of the list stops.
    +static void caseNextPrevious(void) {
    +    tCase("player: the end goes on, Next, Previous, the end of the list");
    +    TEST_TRUE(untilTitle(TUNE_TITLE));
    +    TEST_STR_EQ(infoText(), TUNE_INFO);
    +    TEST_EQ(wgtListBoxGetSelected(listOf()), 1);
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    menu(MENU_PREV);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    tick(SETTLE_TICKS);
    +
    +    // Before the first track is the first track.
    +    menu(MENU_PREV);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    TEST_STR_EQ(text("lblTime"), "0:00 / 0:03");
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +
    +    // Four seconds in, Previous starts the module again.
    +    tick(40);
    +    TEST_STR_EQ(text("lblTime"), "0:04 / 0:07");
    +    menu(MENU_PREV);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    TEST_STR_EQ(text("lblTime"), "0:00 / 0:07");
    +
    +    // The end of the list: stopped at the start of the last track.
    +    TEST_TRUE(untilTitle(NULL));
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    TEST_STR_EQ(text("btnPlay"), PLAY_CAPTION);
    +    TEST_STR_EQ(text("lblTime"), "0:00 / 0:07");
    +    tick(1);
    +    TEST_EQ(heard(), 0);
    +
    +    // Play goes on with the track selected.
    +    wgtListBoxSetSelected(listOf(), 0);
    +    menu(MENU_PLAY_PAUSE);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    TEST_STR_EQ(text("btnPlay"), PAUSE_CAPTION);
    +}
    +
    +
    +// Open's file dialog plays the file chosen (the list's own entry when it
    +// is there); Add Folder adds the folder's music and sound files only.
    +static void caseOpenAndFolder(void) {
    +    tCase("player: Open and Add Folder");
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 0);
    +    fixDialogsOn("Open");
    +    fixDialogsType(TUNE_FILE, "Open");
    +    menu(MENU_OPEN);
    +    fixDialogsOff();
    +    tick(1);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 1);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    TEST_TRUE(heard() > 0);
    +
    +    // The folder dialog starts where Open was: MEDIA is in it.
    +    fixDialogsOn("OK");
    +    fixDialogsType(MEDIA_DIR, "OK");
    +    menu(MENU_ADD_FOLDER);
    +    fixDialogsOff();
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 3);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 1), "A.WAV");
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 2), "B.MID");
    +    menu(MENU_CLEAR);
    +}
    +
    +
    +// Pause and resume (the button and the menu), the position slider (the
    +// music goes there once the slider stands still), the volume slider.
    +static void casePauseSeekVolume(void) {
    +    tCase("player: pause, resume, seek, volume");
    +    tWidgetClick(tFind(sWin, "btnPlay"));
    +    TEST_STR_EQ(text("btnPlay"), PLAY_CAPTION);
    +    tick(1);
    +    TEST_EQ(heard(), 0);
    +
    +    const char *at = text("lblTime");
    +    char        held[TEXT_MAX];
    +
    +    snprintf(held, sizeof(held), "%s", at);
    +    tick(SETTLE_TICKS);
    +    TEST_STR_EQ(text("lblTime"), held);
    +    menu(MENU_PLAY_PAUSE);
    +    TEST_STR_EQ(text("btnPlay"), PAUSE_CAPTION);
    +    tick(1);
    +    TEST_TRUE(heard() > 0);
    +
    +    // One step right of the start: two seconds in once it settles.
    +    menu(MENU_PREV);
    +    pressKey("sldPosition", KEY_RIGHT);
    +    pressKey("sldPosition", KEY_RIGHT);
    +    TEST_STR_EQ(text("lblTime"), "0:02 / 0:03");
    +    tick(SETTLE_TICKS);
    +    TEST_STR_EQ(text("lblTime"), "0:02 / 0:03");
    +    TEST_EQ(wgtSliderGetValue(tFind(sWin, "sldPosition")), 2);
    +
    +    pressKey("sldVolume", KEY_HOME);
    +    tick(1);
    +    TEST_EQ(heard(), 0);
    +    pressKey("sldVolume", KEY_END);
    +    tick(1);
    +    TEST_TRUE(heard() > 0);
    +}
    +
    +
    +// Repeat List goes round; Repeat Track plays the track again; Shuffle
    +// with two tracks takes the other one next.
    +static void caseRepeatShuffle(void) {
    +    tCase("player: Repeat List, Repeat Track, Shuffle");
    +    menu(MENU_REPEAT_LIST);
    +    TEST_TRUE(menuChecked(MENU_REPEAT_LIST));
    +    TEST_TRUE(!menuChecked(MENU_REPEAT_OFF));
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +
    +    menu(MENU_REPEAT_TRACK);
    +    tick(TONE_SECONDS * 1000 / TICK_MS + SETTLE_TICKS);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    TEST_STR_EQ(text("btnPlay"), PAUSE_CAPTION);
    +    tick(1);
    +    TEST_TRUE(heard() > 0);
    +
    +    menu(MENU_REPEAT_LIST);
    +    menu(MENU_SHUFFLE);
    +    TEST_TRUE(menuChecked(MENU_SHUFFLE));
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    menu(MENU_NEXT);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    menu(MENU_SHUFFLE);
    +    menu(MENU_REPEAT_OFF);
    +    menu(MENU_STOP);
    +    TEST_STR_EQ(text("btnPlay"), PLAY_CAPTION);
    +    TEST_STR_EQ(text("lblTime"), "0:00 / 0:03");
    +    menu(MENU_CLEAR);
    +}
    +
    +
    +// Volume, Repeat, Shuffle, the list, and the selected track kept in
    +// PLAYER.INI and read back by the next start.
    +static void caseSettings(void) {
    +    char dir[TEXT_MAX];
    +    char ini[TEXT_MAX];
    +
    +    tCase("player: settings and the list kept");
    +    TEST_TRUE(platformAppUserDir(dir, sizeof(dir), APP_PLAYER));
    +    snprintf(ini, sizeof(ini), "%s/%s", dir, PLAYER_INI);
    +    launch(TONE_FILE " " TUNE_FILE);
    +    menu(MENU_REPEAT_LIST);
    +    menu(MENU_SHUFFLE);
    +    pressKey("sldVolume", KEY_END);
    +
    +    for (int32_t i = 0; i < 10; i++) {
    +        pressKey("sldVolume", KEY_LEFT);
    +    }
    +
    +    wgtListBoxSetSelected(listOf(), 1);
    +    TEST_TRUE(fixCloseApp(sWin));
    +    TEST_TRUE(fileHas(ini, "Volume = 90"));
    +    TEST_TRUE(fileHas(ini, "Repeat = list"));
    +    TEST_TRUE(fileHas(ini, "Shuffle = true"));
    +    TEST_TRUE(fileHas(ini, "Track = 1"));
    +    TEST_TRUE(fileHas(ini, "File1 = " TONE_FILE));
    +    TEST_TRUE(fileHas(ini, "File2 = " TUNE_FILE));
    +
    +    launch(NULL);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 2);
    +    TEST_EQ(wgtListBoxGetSelected(listOf()), 1);
    +    TEST_STR_EQ(titleText(), NOTHING_PLAYING);
    +    TEST_EQ(wgtSliderGetValue(tFind(sWin, "sldVolume")), 90);
    +    TEST_TRUE(menuChecked(MENU_REPEAT_LIST));
    +    TEST_TRUE(menuChecked(MENU_SHUFFLE));
    +    tick(1);
    +    TEST_EQ(heard(), 0);
    +    menu(MENU_PLAY_PAUSE);
    +    TEST_STR_EQ(titleText(), TUNE_TITLE);
    +    TEST_TRUE(fixCloseApp(sWin));
    +}
    +
    +
    +// Started with two files: both listed, the first playing, heard, its
    +// time shown.
    +static void caseStart(void) {
    +    tCase("player: files from the command line");
    +    launch(TONE_FILE " " TUNE_FILE);
    +    TEST_TRUE(sWin != NULL);
    +    TEST_EQ(wgtListBoxGetItemCount(listOf()), 2);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 0), TONE_FILE);
    +    TEST_STR_EQ(wgtListBoxGetItem(listOf(), 1), TUNE_FILE);
    +    TEST_EQ(wgtListBoxGetSelected(listOf()), 0);
    +    TEST_STR_EQ(titleText(), TONE_FILE);
    +    TEST_STR_EQ(infoText(), TONE_INFO);
    +    TEST_STR_EQ(sWin->title, TONE_FILE " - " PLAYER_TITLE);
    +    TEST_STR_EQ(text("btnPlay"), PAUSE_CAPTION);
    +    tick(SETTLE_TICKS * 2);
    +    TEST_TRUE(heard() > 0);
    +    TEST_STR_EQ(text("lblTime"), "0:01 / 0:03");
    +    TEST_EQ(wgtSliderGetValue(tFind(sWin, "sldPosition")), 1);
    +}
    +
    +
    +// The fake sound driver's calls back (pcm.drv): nothing to record here.
    +void fakeDrvEvent(const char *driver, const char *event) {
    +    (void)driver;
    +    (void)event;
    +}
    +
    +
    +void fakeDrvTone(uint32_t hz) {
    +    (void)hz;
    +}
    +
    +
    +static bool fileHas(const char *path, const char *want) {
    +    int32_t len  = 0;
    +    char   *data = platformReadFile(path, &len);
    +    bool    has  = data && strstr(data, want) != NULL;
    +
    +    free(data);
    +    return has;
    +}
    +
    +
    +// The loudest sample of the last tick, after the frames the mixer held.
    +static int32_t heard(void) {
    +    int32_t peak = 0;
    +
    +    for (int32_t i = HELD_FRAMES * 2; i < TICK_FRAMES * 2; i++) {
    +        int32_t v = abs(sOut[i]);
    +
    +        peak = v > peak ? v : peak;
    +    }
    +
    +    return peak;
    +}
    +
    +
    +static const char *infoText(void) {
    +    return text("lblInfo");
    +}
    +
    +
    +// Start the program with args (NULL: none), or hand them to it when it
    +// runs; its window afterwards.
    +static int32_t launch(const char *args) {
    +    sId = shellLoadAppWithArgs(tCtx(), APP_PLAYER, args ? args : "");
    +
    +    for (int32_t i = 0; i < START_FRAMES_MAX && !fixWindowForApp(sId); i++) {
    +        fixPumpShell(1);
    +    }
    +
    +    fixPumpShell(SETTLE_FRAMES);
    +    sWin = fixWindowForApp(sId);
    +    return sId;
    +}
    +
    +
    +static WidgetT *listOf(void) {
    +    return tFind(sWin, "lstTracks");
    +}
    +
    +
    +// TONE.WAV (three seconds of the test sine), TUNE.MOD (7.7 s), FLUTE.MID
    +// (half a second), BAD.WAV (compressed), and MEDIA\ with a sound, a MIDI
    +// file, and a text file.
    +static void makeMedia(void) {
    +    static uint8_t tone[TONE_BYTES_MAX];
    +    static uint8_t buf[MEDIA_BYTES_MAX];
    +
    +    writeFile(TONE_FILE, tone, wavBuild(tone, 16, 1, TONE_FRAMES, WAV_TAG_PCM));
    +    writeFile(TUNE_FILE, buf, makeMod(buf));
    +    writeFile(FLUTE_FILE, buf, fluteSmf(buf));
    +    writeFile(BAD_FILE, buf, wavBuild(buf, 8, 1, MEDIA_BYTES_MAX / 2, WAV_TAG_ADPCM));
    +    TEST_EQ(mkdir(MEDIA_DIR, MODE_DIR), 0);
    +    writeFile(MEDIA_DIR "/A.WAV", tone, wavBuild(tone, 16, 1, SOUND_FRAMES, WAV_TAG_PCM));
    +    writeFile(MEDIA_DIR "/B.MID", buf, fluteSmf(buf));
    +    writeFile(MEDIA_DIR "/NOTE.TXT", "not music", 9);
    +}
    +
    +
    +// Choose a menu command by its caption, as the user would: a check item
    +// turns over and a radio item is checked (as a click in the menu does),
    +// then the program gets the command.
    +static void menu(const char *caption) {
    +    AppContextT *ctx   = tCtx();
    +    int32_t      id    = fixMenuId(sWin, caption);
    +    int32_t      saved = ctx->currentAppId;
    +    MenuItemT   *item  = NULL;
    +
    +    TEST_TRUE(id >= 0);
    +
    +    if (id < 0) {
    +        return;
    +    }
    +
    +    for (int32_t i = 0; !item && i < sWin->menuBar->menuCount; i++) {
    +        item = wmMenuFindItemInMenu(sWin->menuBar->menus[i], id);
    +    }
    +
    +    if (item && item->type != MenuItemNormalE) {
    +        wmMenuItemSetChecked(sWin->menuBar, id, item->type == MenuItemRadioE || !item->checked);
    +    }
    +
    +    ctx->currentAppId = sWin->appId;
    +    sWin->onMenu(sWin, id);
    +    ctx->currentAppId = saved;
    +    fixPumpShell(SETTLE_FRAMES);
    +}
    +
    +
    +static bool menuChecked(const char *caption) {
    +    return wmMenuItemIsChecked(sWin->menuBar, fixMenuId(sWin, caption));
    +}
    +
    +
    +// A key to a control, as the user would press it with the control focused.
    +static void pressKey(const char *widget, int32_t key) {
    +    wgtSetFocused(tFind(sWin, widget));
    +    tWidgetKey(sWin, key, 0);
    +    fixPumpShell(SETTLE_FRAMES);
    +}
    +
    +
    +static const char *text(const char *widget) {
    +    WidgetT *w = tFind(sWin, widget);
    +
    +    return w ? wgtGetText(w) : "";
    +}
    +
    +
    +// n ticks of TICK_MS: the clock (the program's timer, MIDI), the mixer
    +// (into sOut, the last tick kept), the shell (the libraries' idle time,
    +// the program).
    +static void tick(int32_t n) {
    +    for (int32_t i = 0; i < n; i++) {
    +        tAdvanceClock(TICK_MS);
    +        audioMixRender(sOut, TICK_FRAMES);
    +        fixPumpShell(1);
    +    }
    +}
    +
    +
    +static const char *titleText(void) {
    +    return text("lblTitle");
    +}
    +
    +
    +// Tick until the title is title (NULL: until nothing plays); true when it
    +// came.
    +static bool untilTitle(const char *title) {
    +    for (int32_t i = 0; i < END_TICKS_MAX; i++) {
    +        if (title ? strcmp(titleText(), title) == 0 : strcmp(text("btnPlay"), PLAY_CAPTION) == 0) {
    +            return true;
    +        }
    +
    +        tick(1);
    +    }
    +
    +    return false;
    +}
    +
    +
    +int main(void) {
    +    char ini[TEXT_MAX];
    +
    +    tSuiteBegin("player");
    +
    +    if (!fixProcessInit("playerTest.log")) {
    +        return 1;
    +    }
    +
    +    basNativeHostSetLive(true);
    +    fixShellUp(SCREEN_BPP);
    +
    +    // The sound driver and the FM bank where the libraries look, and
    +    // DVX.INI choosing them.
    +    TEST_EQ(platformMkdirRecursive(AUDIO_DRV_DIR), 0);
    +    TEST_EQ(platformMkdirRecursive("libs/kpunch/music"), 0);
    +    TEST_EQ(platformMkdirRecursive(DVX_USER_DIR), 0);
    +    TEST_TRUE(fixCopyFileTo(PLAYER_DRV_DIR "/pcm.drv", AUDIO_DRV_DIR "/pcm.drv"));
    +    TEST_TRUE(fixCopyFileTo(PLAYER_BANK, MUSIC_BANK_DEFAULT));
    +    snprintf(ini, sizeof(ini), "[audio]\ndriver = pcm\nmidi = software\n");
    +    TEST_TRUE(tWriteFile(platformDvxIniPath(), ini, (int32_t)strlen(ini)));
    +    TEST_TRUE(fixInstallBasicApp(SHELLTEST_BASCOMP, SHELLTEST_BASSTUB_SO, PLAYER_DBP, APP_PLAYER));
    +    makeMedia();
    +    audioHooks(true);
    +
    +    caseStart();
    +    casePauseSeekVolume();
    +    caseNextPrevious();
    +    caseRepeatShuffle();
    +    caseList();
    +    caseErrorsAndNewArgs();
    +    caseOpenAndFolder();
    +    caseSettings();
    +
    +    fixKillAllApps();
    +    audioHooks(false);
    +    musicShutdown();
    +    audioShutdown();
    +    fixShellDown();
    +    return tSuiteEnd();
    +}
    diff --git a/src/test/music/recorderTest.c b/src/test/music/recorderTest.c
    new file mode 100644
    index 00000000..ae9eae4f
    --- /dev/null
    +++ b/src/test/music/recorderTest.c
    @@ -0,0 +1,601 @@
    +// 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.
    +
    +// recorderTest.c  -- the Sound Recorder program (src/apps/kpunch/recorder),
    +// compiled from its project and run in the shell fixture with audio.lib
    +// and fileops linked in, on the fake sound driver (pcm.drv), which
    +// records: the test hands it the blocks a card's interrupt would
    +// (audioRecPush).
    +//
    +// Covered: the window at start, a recording (the state, the length, the
    +// level, the buttons), playing it back to its end, Save As, the quality
    +// and the input (what the card is asked for), a recording not saved
    +// asked about (New), Open, the settings kept in RECORDER.INI, and a MIDI
    +// keyboard recorded through the fake MIDI port (midifake.drv: what the
    +// test feeds its input, what comes out of it), heard as it is played,
    +// played back, saved, and opened.
    +
    +#include "shellFixture.h"
    +#include "formrt/formrt.h"
    +#include "audioInt.h"
    +#include "musicFiles.h"
    +#include "musicInt.h"
    +#include "dvxPlat.h"
    +#include "dvxPlatHost.h"
    +#include "dvxWm.h"
    +#include "progressBar/progress.h"
    +
    +#include 
    +#include 
    +#include 
    +
    +#define APP_RECORDER        "apps/kpunch/recorder/recorder.app"
    +#define RECORDER_DBP        "src/apps/kpunch/recorder/recorder.dbp"   // under fixRepoRoot
    +#define RECORDER_TITLE      "Sound Recorder"
    +#define RECORDER_INI        "RECORDER.INI"                            // in the program's user folder
    +#define TEMP_WAV            "RECORDING.WAV"
    +#define START_FRAMES_MAX    400
    +#define TEXT_MAX            256
    +
    +#define RATE_DEV            22050
    +#define TICK_MS             100
    +#define TICK_FRAMES         (RATE_DEV * TICK_MS / 1000)
    +#define HELD_FRAMES         4           // the mixer's current and next frames
    +#define END_TICKS_MAX       60
    +#define REC_RATE_OFF        5           // fakeDrv FAKE_REC_RATE_OFF
    +#define REC_BLOCK_BYTES     1024        // one block "the card recorded"
    +#define REC_BLOCKS          44          // 45,056 8-bit mono frames: about two seconds at 22,055 Hz
    +#define REC_LEVEL           200         // the block's 8-bit samples swing between this and its mirror
    +#define U8_MIDDLE           128
    +#define U8_SCALE            256         // an 8-bit sample's step in 16-bit terms
    +#define PERCENT             100
    +#define TONE_SECONDS        1
    +#define TONE_FRAMES         (SOUND_RATE * TONE_SECONDS)
    +#define TONE_BYTES_MAX      (WAV_HEADER_BYTES + TONE_FRAMES * 2)
    +
    +#define SAVED_WAV           "MYREC.WAV"
    +#define TONE_FILE           "TONE.WAV"
    +#define NEW_TITLE           "New recording - " RECORDER_TITLE
    +#define UNSAVED_TITLE       "New recording (not saved) - " RECORDER_TITLE
    +#define STATE_STOPPED       "Stopped"
    +#define STATE_PLAYING       "Playing"
    +#define STATE_MIC           "Recording from the microphone..."
    +#define STATE_LINE          "Recording from the line input..."
    +#define RADIO_FORMAT        "22,055 Hz, 8-bit, mono"
    +#define CD_FORMAT           "44,105 Hz, 16-bit, stereo"
    +
    +#define MENU_NEW            "&New"
    +#define MENU_OPEN           "&Open..."
    +#define MENU_SAVE_AS        "Save &As..."
    +#define MENU_CD             "&CD Quality"
    +#define MENU_RADIO          "&Radio Quality"
    +#define MENU_LINE           "&Line In"
    +#define MENU_MIC            "&Microphone"
    +#define MENU_MIDI           "MIDI &Keyboard"
    +
    +// The MIDI keyboard (caseMidi)
    +#define TEMP_MID            "RECORDING.MID"
    +#define SAVED_MID           "MYKEYS.mid"
    +#define STATE_MIDI          "Recording from the MIDI keyboard..."
    +#define MIDI_FORMAT         "MIDI"
    +#define MIDI_IN_MAX         64
    +#define MIDI_OUT_MAX        4096
    +#define NOTE_ON             0x90
    +#define NOTE_OFF            0x80
    +#define NOTE_C              60
    +#define NOTE_VELOCITY       100
    +#define MIDI_EVENTS         2
    +#define TIME_ZERO           "0:00.0"
    +#define VELOCITY_MAX        127
    +#define LEVEL_NOTE          (NOTE_VELOCITY * PERCENT / VELOCITY_MAX)   // the recorder's level for that velocity
    +
    +// ============================================================
    +// Prototypes
    +// ============================================================
    +
    +static void        audioHooks(bool on);
    +static void        caseFormatAndInput(void);
    +static void        caseMidi(void);
    +static void        caseNewAndOpen(void);
    +static void        caseRecordPlaySave(void);
    +static void        caseSettings(void);
    +static void        caseStart(void);
    +static void        click(const char *widget);
    +void               fakeDrvEvent(const char *driver, const char *event);
    +void               fakeDrvTone(uint32_t hz);
    +void               fakeSynthEvent(const char *synth, const char *event);
    +bool               fakeSynthGet(uint8_t *byte);
    +bool               fakeSynthPut(uint8_t byte);
    +static bool        fileHas(const char *path, const char *text);
    +static int32_t     fileSize(const char *path);
    +static int32_t     heard(void);
    +static int32_t     launch(void);
    +static void        menu(const char *caption);
    +static void        midiFeed(uint8_t status, uint8_t data1, uint8_t data2);
    +static bool        midiSent(uint8_t status, uint8_t data1, uint8_t data2);
    +static void        pushBlocks(int32_t count);
    +static const char *text(const char *widget);
    +static void        tick(int32_t n);
    +static void        userPath(const char *name, char *out, int32_t outSize);
    +
    +static int16_t  sOut[TICK_FRAMES * 2];
    +static int32_t  sId;                    // the program running
    +static WindowT *sWin;                   // its window
    +static char     sLastEvent[TEXT_MAX];   // the fake driver's last report
    +static uint8_t  sMidiIn[MIDI_IN_MAX];   // the fake MIDI port's input: fed, not yet read
    +static int32_t  sMidiInHead;
    +static int32_t  sMidiInTail;
    +static uint8_t  sMidiOut[MIDI_OUT_MAX]; // what was sent out of it
    +static int32_t  sMidiOutCount;
    +
    +
    +// The shell's idle time and program ends for the sound library, as its
    +// shell hook (audioShell.c, not linked here) does.
    +static void audioHooks(bool on) {
    +    if (on) {
    +        shellRegisterIdle(audioIdle, NULL);
    +        shellRegisterAppCleanup(audioAppEnded);
    +        return;
    +    }
    +
    +    shellUnregisterAppCleanup(audioAppEnded);
    +    shellUnregisterIdle(audioIdle, NULL);
    +}
    +
    +
    +// CD quality asks the card for 44,100 Hz 16-bit stereo (it gives a little
    +// more); Line In records from the line input.
    +static void caseFormatAndInput(void) {
    +    tCase("recorder: the quality and the input asked of the card");
    +    menu(MENU_CD);
    +    menu(MENU_LINE);
    +    click("btnRecord");
    +    TEST_STR_EQ(sLastEvent, "pcm record 1");
    +    TEST_STR_EQ(text("lblState"), STATE_LINE);
    +    TEST_STR_EQ(text("lblFormat"), CD_FORMAT);
    +    pushBlocks(1);
    +    click("btnStop");
    +    menu(MENU_RADIO);
    +    menu(MENU_MIC);
    +}
    +
    +
    +// MIDI Keyboard: a note played sounds at once (out of the MIDI port) and
    +// shows on the level; Stop writes RECORDING.MID (the note on and off);
    +// Play plays it back out of the port to its end; Save As keeps it as a
    +// .mid file, which Open opens again.
    +static void caseMidi(void) {
    +    char    temp[DVX_MAX_PATH];
    +    int32_t len  = 0;
    +    char   *data = NULL;
    +
    +    tCase("recorder: a MIDI keyboard recorded, heard as it is played, played back");
    +    menu(MENU_MIDI);
    +    TEST_TRUE(tFind(sWin, "btnRecord")->enabled);
    +    click("btnRecord");
    +    TEST_STR_EQ(text("lblState"), STATE_MIDI);
    +    TEST_STR_EQ(text("lblFormat"), MIDI_FORMAT);
    +    sMidiOutCount = 0;
    +    midiFeed(NOTE_ON, NOTE_C, NOTE_VELOCITY);
    +    tick(1);
    +    TEST_EQ(wgtProgressBarGetValue(tFind(sWin, "prgLevel")), LEVEL_NOTE);
    +
    +    // What the program plays leaves the port at the synthesizer's next
    +    // interrupt; the clock has moved (where a tick boundary falls depends
    +    // on the timer's phase)
    +    tick(1);
    +    TEST_TRUE(midiSent(NOTE_ON, NOTE_C, NOTE_VELOCITY));
    +    TEST_TRUE(strcmp(text("lblTime"), TIME_ZERO) != 0);
    +    midiFeed(NOTE_OFF, NOTE_C, 0);
    +    tick(2);
    +    TEST_TRUE(midiSent(NOTE_OFF, NOTE_C, 0));
    +
    +    char length[TEXT_MAX];
    +
    +    click("btnStop");
    +    TEST_STR_EQ(text("lblState"), STATE_STOPPED);
    +    TEST_STR_EQ(sWin->title, UNSAVED_TITLE);
    +    snprintf(length, sizeof(length), "%s", text("lblTime"));
    +    TEST_TRUE(strcmp(length, TIME_ZERO) != 0);
    +    TEST_TRUE(tFind(sWin, "btnPlay")->enabled);
    +    userPath(TEMP_MID, temp, sizeof(temp));
    +    data = platformReadFile(temp, &len);
    +    TEST_TRUE(data != NULL);
    +
    +    MidiFileT file;
    +
    +    memset(&file, 0, sizeof(file));
    +    TEST_EQ(midiParse((const uint8_t *)data, (uint32_t)len, &file), MUSIC_OK);
    +    TEST_EQ(file.count, MIDI_EVENTS);
    +
    +    if (file.count == MIDI_EVENTS) {
    +        TEST_EQ(file.events[0].status, NOTE_ON);
    +        TEST_EQ(file.events[0].data1, NOTE_C);
    +        TEST_EQ(file.events[1].status, NOTE_OFF);
    +        TEST_TRUE(file.events[1].time > file.events[0].time);
    +    }
    +
    +    midiFileFree(&file);
    +    free(data);
    +
    +    // Played back out of the port, to its end
    +    sMidiOutCount = 0;
    +    click("btnPlay");
    +    TEST_STR_EQ(text("lblState"), STATE_PLAYING);
    +
    +    for (int32_t i = 0; i < END_TICKS_MAX && strcmp(text("lblState"), STATE_PLAYING) == 0; i++) {
    +        tick(1);
    +    }
    +
    +    TEST_STR_EQ(text("lblState"), STATE_STOPPED);
    +    TEST_TRUE(midiSent(NOTE_ON, NOTE_C, NOTE_VELOCITY));
    +
    +    // Saved as a .mid file; opened again
    +    fixDialogsOn("Save");
    +    fixDialogsType("MYKEYS", "Save");
    +    menu(MENU_SAVE_AS);
    +    fixDialogsOff();
    +    TEST_STR_EQ(sWin->title, SAVED_MID " - " RECORDER_TITLE);
    +    TEST_EQ(fileSize(SAVED_MID), len);
    +    menu(MENU_NEW);
    +    fixDialogsOn("Open");
    +    fixDialogsType(SAVED_MID, "Open");
    +    menu(MENU_OPEN);
    +    fixDialogsOff();
    +    TEST_STR_EQ(sWin->title, SAVED_MID " - " RECORDER_TITLE);
    +    TEST_STR_EQ(text("lblTime"), length);
    +    menu(MENU_MIC);
    +}
    +
    +
    +// New asks about the recording not saved (No: it goes); Open plays a WAV
    +// file and shows its length.
    +static void caseNewAndOpen(void) {
    +    tCase("recorder: New asks about a recording not saved; Open");
    +    TEST_STR_EQ(sWin->title, UNSAVED_TITLE);
    +    fixDialogsOn("No");
    +    fixDialogs = 0;
    +    menu(MENU_NEW);
    +    fixDialogsOff();
    +    TEST_EQ(fixDialogs, 1);
    +    TEST_STR_EQ(sWin->title, NEW_TITLE);
    +    TEST_TRUE(!tFind(sWin, "btnPlay")->enabled);
    +
    +    fixDialogsOn("Open");
    +    fixDialogsType(TONE_FILE, "Open");
    +    menu(MENU_OPEN);
    +    fixDialogsOff();
    +    TEST_STR_EQ(sWin->title, TONE_FILE " - " RECORDER_TITLE);
    +    TEST_STR_EQ(text("lblTime"), "0:01.0");
    +    click("btnPlay");
    +    tick(1);
    +    TEST_TRUE(heard() > 0);
    +    click("btnStop");
    +}
    +
    +
    +// Record (the length and level shown as the blocks come), Stop, Play to
    +// the end, Save As.
    +static void caseRecordPlaySave(void) {
    +    char temp[DVX_MAX_PATH];
    +
    +    tCase("recorder: record, stop, play, save as");
    +    click("btnRecord");
    +    TEST_STR_EQ(sLastEvent, "pcm record 0");
    +    TEST_STR_EQ(text("lblState"), STATE_MIC);
    +    TEST_STR_EQ(text("lblFormat"), RADIO_FORMAT);
    +    TEST_TRUE(!tFind(sWin, "btnRecord")->enabled);
    +    TEST_TRUE(tFind(sWin, "btnStop")->enabled);
    +    pushBlocks(REC_BLOCKS);
    +    tick(2);
    +    TEST_STR_EQ(text("lblTime"), "0:02.0");
    +    TEST_EQ(wgtProgressBarGetValue(tFind(sWin, "prgLevel")), (REC_LEVEL - U8_MIDDLE) * U8_SCALE * PERCENT / INT16_MAX);
    +
    +    click("btnStop");
    +    TEST_STR_EQ(sLastEvent, "pcm recordstop");
    +    TEST_STR_EQ(text("lblState"), STATE_STOPPED);
    +    TEST_STR_EQ(sWin->title, UNSAVED_TITLE);
    +    TEST_TRUE(tFind(sWin, "btnPlay")->enabled);
    +    userPath(TEMP_WAV, temp, sizeof(temp));
    +    TEST_EQ(fileSize(temp), WAV_HEADER_BYTES + REC_BLOCKS * REC_BLOCK_BYTES);
    +
    +    // Played to its end, back to stopped.
    +    click("btnPlay");
    +    TEST_STR_EQ(text("lblState"), STATE_PLAYING);
    +    tick(1);
    +    TEST_TRUE(heard() > 0);
    +
    +    for (int32_t i = 0; i < END_TICKS_MAX && strcmp(text("lblState"), STATE_PLAYING) == 0; i++) {
    +        tick(1);
    +    }
    +
    +    TEST_STR_EQ(text("lblState"), STATE_STOPPED);
    +
    +    fixDialogsOn("Save");
    +    fixDialogsType(SAVED_WAV, "Save");
    +    menu(MENU_SAVE_AS);
    +    fixDialogsOff();
    +    TEST_STR_EQ(sWin->title, SAVED_WAV " - " RECORDER_TITLE);
    +    TEST_EQ(fileSize(SAVED_WAV), WAV_HEADER_BYTES + REC_BLOCKS * REC_BLOCK_BYTES);
    +}
    +
    +
    +// The quality, the input, and the folder are kept in RECORDER.INI.
    +static void caseSettings(void) {
    +    char ini[DVX_MAX_PATH];
    +
    +    tCase("recorder: the settings are kept");
    +    menu(MENU_CD);
    +    menu(MENU_LINE);
    +    TEST_TRUE(fixCloseApp(sWin));
    +    launch();
    +
    +    userPath(RECORDER_INI, ini, sizeof(ini));
    +    TEST_TRUE(fileHas(ini, "cd"));
    +    TEST_TRUE(wmMenuItemIsChecked(sWin->menuBar, fixMenuId(sWin, MENU_CD)));
    +    TEST_TRUE(wmMenuItemIsChecked(sWin->menuBar, fixMenuId(sWin, MENU_LINE)));
    +    TEST_TRUE(fixCloseApp(sWin));
    +}
    +
    +
    +static void caseStart(void) {
    +    tCase("recorder: the window at start");
    +    launch();
    +    TEST_TRUE(sWin != NULL);
    +    TEST_STR_EQ(sWin->title, NEW_TITLE);
    +    TEST_STR_EQ(text("lblState"), STATE_STOPPED);
    +    TEST_STR_EQ(text("lblTime"), "0:00.0");
    +    TEST_TRUE(tFind(sWin, "btnRecord")->enabled);
    +    TEST_TRUE(!tFind(sWin, "btnStop")->enabled);
    +    TEST_TRUE(!tFind(sWin, "btnPlay")->enabled);
    +}
    +
    +
    +static void click(const char *widget) {
    +    tWidgetClick(tFind(sWin, widget));
    +    fixPumpShell(SETTLE_FRAMES);
    +}
    +
    +
    +// The fake driver's reports (the test's driver hooks).
    +void fakeDrvEvent(const char *driver, const char *event) {
    +    snprintf(sLastEvent, sizeof(sLastEvent), "%s %s", driver, event);
    +}
    +
    +
    +void fakeDrvTone(uint32_t hz) {
    +    (void)hz;
    +}
    +
    +
    +// The fake MIDI port (midifake.drv): its opens and closes, its input (the
    +// bytes midiFeed queued), and its output (kept for midiSent).
    +void fakeSynthEvent(const char *synth, const char *event) {
    +    (void)synth;
    +    (void)event;
    +}
    +
    +
    +bool fakeSynthGet(uint8_t *byte) {
    +    if (sMidiInTail == sMidiInHead) {
    +        return false;
    +    }
    +
    +    *byte = sMidiIn[sMidiInTail++ % MIDI_IN_MAX];
    +    return true;
    +}
    +
    +
    +bool fakeSynthPut(uint8_t byte) {
    +    if (sMidiOutCount < MIDI_OUT_MAX) {
    +        sMidiOut[sMidiOutCount++] = byte;
    +    }
    +
    +    return true;
    +}
    +
    +
    +static bool fileHas(const char *path, const char *want) {
    +    int32_t len  = 0;
    +    char   *data = platformReadFile(path, &len);
    +    bool    has  = data && strstr(data, want) != NULL;
    +
    +    free(data);
    +    return has;
    +}
    +
    +
    +static int32_t fileSize(const char *path) {
    +    int32_t len  = 0;
    +    char   *data = platformReadFile(path, &len);
    +
    +    free(data);
    +    return data ? len : -1;
    +}
    +
    +
    +// The loudest sample of the last tick, after the frames the mixer held.
    +static int32_t heard(void) {
    +    int32_t peak = 0;
    +
    +    for (int32_t i = HELD_FRAMES * 2; i < TICK_FRAMES * 2; i++) {
    +        int32_t v = abs(sOut[i]);
    +
    +        peak = v > peak ? v : peak;
    +    }
    +
    +    return peak;
    +}
    +
    +
    +static int32_t launch(void) {
    +    sId = shellLoadAppWithArgs(tCtx(), APP_RECORDER, "");
    +
    +    for (int32_t i = 0; i < START_FRAMES_MAX && !fixWindowForApp(sId); i++) {
    +        fixPumpShell(1);
    +    }
    +
    +    fixPumpShell(SETTLE_FRAMES);
    +    sWin = fixWindowForApp(sId);
    +    return sId;
    +}
    +
    +
    +// A menu command as a click gives it (a radio item checked first).
    +static void menu(const char *caption) {
    +    AppContextT *ctx   = tCtx();
    +    int32_t      id    = fixMenuId(sWin, caption);
    +    int32_t      saved = ctx->currentAppId;
    +    MenuItemT   *item  = NULL;
    +
    +    TEST_TRUE(id >= 0);
    +
    +    if (id < 0) {
    +        return;
    +    }
    +
    +    for (int32_t i = 0; !item && i < sWin->menuBar->menuCount; i++) {
    +        item = wmMenuFindItemInMenu(sWin->menuBar->menus[i], id);
    +    }
    +
    +    if (item && item->type != MenuItemNormalE) {
    +        wmMenuItemSetChecked(sWin->menuBar, id, item->type == MenuItemRadioE || !item->checked);
    +    }
    +
    +    ctx->currentAppId = sWin->appId;
    +    sWin->onMenu(sWin, id);
    +    ctx->currentAppId = saved;
    +    fixPumpShell(SETTLE_FRAMES);
    +}
    +
    +
    +// A message played on the keyboard, into the fake port's input.
    +static void midiFeed(uint8_t status, uint8_t data1, uint8_t data2) {
    +    const uint8_t msg[] = { status, data1, data2 };
    +
    +    for (int32_t i = 0; i < (int32_t)sizeof(msg); i++) {
    +        sMidiIn[sMidiInHead++ % MIDI_IN_MAX] = msg[i];
    +    }
    +}
    +
    +
    +// The message went out of the fake port since sMidiOutCount was cleared
    +// (its status byte, or running status before its data).
    +static bool midiSent(uint8_t status, uint8_t data1, uint8_t data2) {
    +    uint8_t running = 0;
    +
    +    for (int32_t i = 0; i < sMidiOutCount; i++) {
    +        uint8_t b = sMidiOut[i];
    +
    +        if (b & NOTE_OFF) {
    +            running = b;
    +            continue;
    +        }
    +
    +        if (running == status && i + 1 < sMidiOutCount && b == data1 && sMidiOut[i + 1] == data2) {
    +            return true;
    +        }
    +    }
    +
    +    return false;
    +}
    +
    +
    +// count blocks of 8-bit samples swinging around the middle, as the card
    +// records them.
    +static void pushBlocks(int32_t count) {
    +    uint8_t block[REC_BLOCK_BYTES];
    +
    +    for (int32_t i = 0; i < REC_BLOCK_BYTES; i++) {
    +        block[i] = (uint8_t)((i & 1) ? REC_LEVEL : 2 * U8_MIDDLE - REC_LEVEL);
    +    }
    +
    +    for (int32_t i = 0; i < count; i++) {
    +        audioRecPush(block, sizeof(block));
    +    }
    +}
    +
    +
    +static const char *text(const char *widget) {
    +    WidgetT *w = tFind(sWin, widget);
    +
    +    return w ? wgtGetText(w) : "";
    +}
    +
    +
    +// n ticks of TICK_MS: the clock (the program's timer), the mixer (into
    +// sOut, the last tick kept), the shell (the library's idle time, the
    +// program).
    +static void tick(int32_t n) {
    +    for (int32_t i = 0; i < n; i++) {
    +        tAdvanceClock(TICK_MS);
    +        audioMixRender(sOut, TICK_FRAMES);
    +        fixPumpShell(1);
    +    }
    +}
    +
    +
    +// A file in the program's own user folder (App.User).
    +static void userPath(const char *name, char *out, int32_t outSize) {
    +    char dir[DVX_MAX_PATH];
    +
    +    platformAppUserDir(dir, sizeof(dir), APP_RECORDER);
    +    snprintf(out, (size_t)outSize, "%s" DVX_PATH_SEP "%s", dir, name);
    +}
    +
    +
    +int main(void) {
    +    static uint8_t tone[TONE_BYTES_MAX];
    +    char           ini[TEXT_MAX];
    +
    +    tSuiteBegin("recorder");
    +
    +    if (!fixProcessInit("recorderTest.log")) {
    +        return 1;
    +    }
    +
    +    basNativeHostSetLive(true);
    +    fixShellUp(SCREEN_BPP);
    +
    +    TEST_EQ(platformMkdirRecursive(AUDIO_DRV_DIR), 0);
    +    TEST_EQ(platformMkdirRecursive(DVX_USER_DIR), 0);
    +    TEST_TRUE(fixCopyFileTo(PLAYER_DRV_DIR "/pcm.drv", AUDIO_DRV_DIR "/pcm.drv"));
    +    TEST_TRUE(fixCopyFileTo(PLAYER_DRV_DIR "/midifake.drv", AUDIO_DRV_DIR "/midifake.drv"));
    +    snprintf(ini, sizeof(ini), "[audio]\ndriver = pcm\nmidi = mpu401\nmidiIn = mpu401\n");
    +    TEST_TRUE(tWriteFile(platformDvxIniPath(), ini, (int32_t)strlen(ini)));
    +    TEST_TRUE(fixInstallBasicApp(SHELLTEST_BASCOMP, SHELLTEST_BASSTUB_SO, RECORDER_DBP, APP_RECORDER));
    +    writeFile(TONE_FILE, tone, wavBuild(tone, 16, 1, TONE_FRAMES, WAV_TAG_PCM));
    +    audioHooks(true);
    +
    +    caseStart();
    +    caseRecordPlaySave();
    +    caseFormatAndInput();
    +    caseNewAndOpen();
    +    caseMidi();
    +    caseSettings();
    +
    +    fixKillAllApps();
    +    audioHooks(false);
    +    audioShutdown();
    +    fixShellDown();
    +    return tSuiteEnd();
    +}
    diff --git a/src/test/pkg/pkgTest.c b/src/test/pkg/pkgTest.c
    index cfbb1fab..1fe1c14e 100644
    --- a/src/test/pkg/pkgTest.c
    +++ b/src/test/pkg/pkgTest.c
    @@ -161,14 +161,14 @@ static void caseCli(void) {
         const char *checkBad[] = { "dvxpkg", "check", "/nonexistent/X.DPK" };
         char        checked[PATH_BUF];
         PkgDpkInfoT ci;
    -    int32_t     clen = 0;
    +    int32_t     checkedLen = 0;
         char       *ctext;
     
         pathIn(checked, "check.out");
         TEST_EQ(runCliTo(checked, 3, check), 0);
    -    ctext = platformReadFile(checked, &clen);
    +    ctext = platformReadFile(checked, &checkedLen);
         TEST_TRUE(pkgDpkReadInfo(out, &ci, err, sizeof(err)));
    -    TEST_TRUE(ctext && clen == ci.textLen && memcmp(ctext, ci.text, (size_t)clen) == 0);
    +    TEST_TRUE(ctext && checkedLen == ci.textLen && memcmp(ctext, ci.text, (size_t)checkedLen) == 0);
         pkgDpkInfoFree(&ci);
         free(ctext);
         TEST_EQ(runCli(3, checkBad), 1);
    @@ -522,6 +522,7 @@ static void caseKindFolders(void) {
             { PkgKindThemeE,     "shared/themes/acme/blue.thm",    "shared/sounds/acme/blue.thm" },
             { PkgKindWallpaperE, "shared/wpaper/acme/sky.jpg",     "user/wpaper/acme/sky.jpg" },
             { PkgKindSoundE,     "shared/sounds/acme/ding.wav",    "system/ding.wav" },
    +        { PkgKindSaverE,     "shared/savers/acme/stars.app",   "shared/savers/stars.app" },
             { PkgKindHelpE,      "libs/acme/hello/hello.dhs",      "apps/kpunch/hello/hello.hlp" },
             { PkgKindBundleE,    NULL,                             "apps/acme/hello/x" }
         };
    diff --git a/src/test/pkg/pkgTestBas.c b/src/test/pkg/pkgTestBas.c
    index 4a260419..6a5af735 100644
    --- a/src/test/pkg/pkgTestBas.c
    +++ b/src/test/pkg/pkgTestBas.c
    @@ -390,6 +390,12 @@ void testBasBinding(void) {
         TEST_STR_EQ(PkgField(s, "acme.chess", PkgFieldSignerNameE), "Acme Games");
         TEST_STR_EQ(PkgField(s, "acme.gone", PkgFieldNameE), "");
         TEST_STR_EQ(PkgFilesRows(s, "acme.cards"), "LIBS/ACME/CARDS/CARDS.LIB\t5\n");
    +    TEST_STR_EQ(PkgFileOwner(s, "LIBS/ACME/CARDS/CARDS.LIB"), "acme.cards");
    +    TEST_STR_EQ(PkgFileOwner(s, "libs\\acme\\cards\\cards.lib"), "acme.cards");
    +    TEST_STR_EQ(PkgFileOwner(s, "APPS/ACME/CHESS/CHESS.APP"), "acme.chess");
    +    TEST_STR_EQ(PkgFileOwner(s, "LIBS/ACME/CARDS/OTHER.LIB"), "");
    +    TEST_STR_EQ(PkgFileOwner(s, NULL), "");
    +    TEST_STR_EQ(PkgFileOwner(BT_BAD, "LIBS/ACME/CARDS/CARDS.LIB"), "");
         TEST_STR_EQ(PkgDependsRows(s, "acme.chess"), "acme.cards\t1.0.0\t1.0.0\n");
         TEST_STR_EQ(PkgNeededBy(s, "acme.cards"), "acme.chess\tChess\n");
         TEST_STR_EQ(PkgDependents(s, "acme.cards"), "acme.chess\tChess\n");
    diff --git a/src/test/print/Makefile b/src/test/print/Makefile
    index 672d8f4f..bd8a9c80 100644
    --- a/src/test/print/Makefile
    +++ b/src/test/print/Makefile
    @@ -33,10 +33,19 @@ PRINT_DIR   = $(DVXTEST_ROOT)/src/libs/kpunch/print
     BINDIR      = $(DVXTEST_BINDIR)/print
     PRINT_TEST  = $(BINDIR)/printTest
     PRINT_SRCS  = $(wildcard $(PRINT_DIR)/*.c)
    +
    +# DejaVu Sans as the build makes it: the base set's file and the
    +# printer-fonts add-on's (src/tools/Makefile), for the text at printer
    +# sizes
    +PRINT_FONT_BASE  = $(DVXTEST_ROOT)/bin/shared/fonts/DJVSANS.DFN
    +PRINT_FONT_LARGE = $(DVXTEST_ROOT)/obj/addons/prfonts/SHARED/FONTS/KPUNCH/PSANS.DFN
    +
    +# Ghostscript renders the PostScript drivers' output back to dots and text
    +GS ?= gs
     PRINT_OBJS  = $(patsubst $(PRINT_DIR)/%.c,$(BINDIR)/print/%.o,$(PRINT_SRCS))
     LIBS_A      = $(DVXTEST_ARCHIVES) $(DVXTEST_TASKS_A) $(DVXTEST_BASRT_A)
     
    -CFLAGS      = $(DVXTEST_CFLAGS) -I$(DVXTEST_ROOT)/src/libs/kpunch -I$(PRINT_DIR) -I$(DVXTEST_BASICDIR) -DPRINT_GOLDEN_DIR='"$(abspath golden)"' -DBASIC_INCLUDE_DIR='"$(DVXTEST_ROOT)/src/include/basic"'
    +CFLAGS      = $(DVXTEST_CFLAGS) -I$(DVXTEST_ROOT)/src/libs/kpunch -I$(PRINT_DIR) -I$(DVXTEST_BASICDIR) -DPRINT_GOLDEN_DIR='"$(abspath golden)"' -DBASIC_INCLUDE_DIR='"$(DVXTEST_ROOT)/src/include/basic"' -DPRINT_FONT_BASE='"$(PRINT_FONT_BASE)"' -DPRINT_FONT_LARGE='"$(PRINT_FONT_LARGE)"' -DPRINT_GS='"$(GS)"'
     
     .PHONY: all run clean
     
    diff --git a/src/test/print/golden/deskjet500c.prn b/src/test/print/golden/deskjet500c.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..b4dac09d069294c621020a23c501bec1a0102286
    GIT binary patch
    literal 96899
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/print/golden/deskjetcolor.prn b/src/test/print/golden/deskjetcolor.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..f9d0900ad305811d6332ff0ff950bb19ad1f29e3
    GIT binary patch
    literal 125094
    zcmeHQO>11q6)hVF%#eizqm?&Ct3eQu6(O{Ag1iijgSwkQyqRARhVdwyjJ*{46-F!3
    z24!!-_&;REyOF%^Hz03R)i92woO`S4b=CXo4_RUCISU)N+6@RubDz@nGezK28~sybZ{h=TZiWE+)cmVzlzUo4qfT$nXjC&ftC*!J
    z@!Q;}drd~sQ$mO7sU{JEYpS+mTI3pfikreNAj_U{CC8+Z(?&KbBuq-cw^*E49P}4%;5T+nzcD8jR*0rcym7sMDy6ZOQr;{)?mh_Xt{d21)v~}d-OwH=@7(C0
    z7$?d`ZroEC0P+s#{kV_7b8eQ>!VsRJrO8=Pi53L7blP9^++Lmj5wp-rnxdO$Dj
    zJ9KEi8YjGQ#?1-c+<$_S#uaxU{IO=96cQ`7z$fM#PxAJs87MO-qq%IO*ZkxTy(Vk1
    z=eX9SK+hd2>IreJln)sV@_5G>8hVG6&oT4R=~u>3#!%@FVU|0oDKUors$-~Ui)Eu`
    zy==BMI9-hG*`f1vO>@arH$*}u{P9ect$&Z-nlpSw>yB6>J@;rr`ch7M9R>!8erOJz
    zABap%TpNte+)<6-_S~|$6Aiv@1A_!e5Bh>P*PtgOT!Tr{8GFLHix=J`#KEkAWdWF>
    z2kAjbBg|$J$-YdEFj=I1B+O>6x=H9kdXSu?y#qiBot^n0>Vik8jDGLRURULuOLJ4Y
    zFWjMg*4m&rwl8ZW@o{5g2P&(6{;|_URG)_C$mOJXMgyNa8R1kG`Q|8>)ibR#$Qq<3XT
    znU2_4#;y4=C+$&Uhza4JZWP&@2~Tx|pLyel&2O3TFyUdsb4>{kHW%2b+g3UV{7=;Rt4?TM~6A(bYJc5HT3Z_vwW9!PFCI9<$Y4S;P)0o*a#
    zcDL#lgY@IffNEVo5eG_R>ECMRovsP`7ndt>{R(anAH)g7Uq$~Q&k}#-@-^ZE@gdZT
    zi4Q(IBt9_z6d<8EZIAC+6^nFmHRjP@~s^fLn&x57f;67W_3!xF*q5zqS0Jc
    zDQ>xKZ?Ih|ZY*Rl9ZUx!kWCs^xPl20z?5V~5F!W>*@7TM5F!W>NtuXgnjY1g#0)|N
    zA%YM=h-j)&htBu~AtGb;BjfIsV{LY5_X|Casy~{H1_XAW(B)BSiZ5m~cV3
    zsD=>21>qu;rm9PWa6!1Jt_i{g;ev4CtC!bFtBV#VZw^L#?(iyN0BWv7w`%SI+hlsd
    z+;)5ZaRa+5-2=Yz9;AoxS&ce|3J$*;KQuCT(54B9XrYZCnel+7Xu60m
    ziwGI?c;oblE*E-S8fm8XEFfXx8YilxONFRHRKbq8qLNNjA*v8nu!)!*e!%)1QH7{N
    zR3WPTI$8coqeQV{Z%Wew1Oo8<Y;R<6uciAO_rC_7lL41(Zk8k7b#!?7r6eqNY
    z&_Az3nrJXOktR0f$x~I87~|5dc@*RzHT*LOHQEg_NadSGt7pqRhtPeR^dLIh7&;q*
    zH$=CF6~dM6N3J{`5k?vp2{bXn2w?;Pn=ry`WELHaFp?D;nEK_%))PhuBZQI8K7BGs
    z-=uThbX_$De@tRY)=P~6WFKjtc2jJ15%qAsLu-i$cvoP2BTND`21ihjtOXo4Kxa^m
    zB#ek;IzW@zm}nW=Lv5_MkE?3sP|MF;oDJ!;-(OpqeZYJIi4doI&|f0co~n{G*-y83
    zw#+oJceu?lCDVnIJo6V&75tpj4u_ZTpb`Pd>=IxkfbN8$s
    zk!j_i4ouU;^Dt(OO7TF>-1f+?tvt@3)lsz_KVuI+cYCwVG|Oee1(FPI2@$T+B`GyX
    zGW2>&<0QQv�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
    XNhvdUUt!8{^b<=;zy9;@?>_t=ksg`%
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/print/golden/epson24color.prn b/src/test/print/golden/epson24color.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..43e0a165dc52cb25bac31189041eb67c0fbcff2f
    GIT binary patch
    literal 171440
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/print/golden/imagewriter.prn b/src/test/print/golden/imagewriter.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..fd9ba65561be5ecb837806ce08a7bb30058a07d8
    GIT binary patch
    literal 32114
    zcmeHQv2xl#5EUM~@g$`yHK|jH!3?O$q{uiw0F#-t{(<}f`4mczn|y-(DXCMX^iZHY
    z34tUmG`o;@Iv8(5aR_jdR0meVP|kH7!?5P$i68UKhs#dk@P4CAlKaFRYoS;Ub!
    z>{8@?JWGbxo91U|{u{f{{Nec8IOvnNHVzi>SB6kxAdjs8h<)YRs*%k-Jf!oaF
    zD+f1-8L@
    z12tF27>WI0-~#OsNnkNB8hOEhp~{GZz);QWnKR5;-joE$fz=RPj+s#g7ix~0hy&xG
    zXAZ1}&{}ikvGOVki~$cA5Z4efZnJb1D~QlWV{g_^;#Gt(z?kW4Y#IX^v>3X^00UKx
    zO=CcV7DG2Q22xS=5J={FtLViCIhun9%sl%SG{0OI-;VPQW23R-3Gt!D&e6C&q67Vl
    z;mYBl!gaepa~;Vq?|gfjz*pA_nL)RAc};>Rz#d8_ZSPkJrXV2$uu_>rw|5DVgav?=
    zO78^ZP7opqUI43!FeAazQCo)BzqdCgup;3G-c6CfB4Lth(tuKzwMsA!J!pIa`oAR#a$>gtC8bPqiUNVk2!C!|FE9vHTr5m*h|EY>aG?aLT
    z>;7-}^Kd?7xrx83&Idnb>3@T5nKbgxp-y`IeiMIMB?@?QY#aU@T%~*&{u2BLgJ{5!
    z3V*DId9SFWD00eYyY!Q!iPYuvdI5p-aWP+Tqyf?b+r&B7Dc;ic-O`h4{}ps;`}vvm
    zLCMP#^aeldfALJO{+dP~E`R1soQ~V+@^5WV>
    z-MRMM%{WQox@6D#K*CoVqg528`NNHpX-aEQJ=4;DMA~n?o*^a0tU_vgAj5OSx@e9R
    zjJIw%pdlr73%#CkpvUZgf8(qx=
    zd%DpiFE07t|Hc7}F;^T2%W%?iT|-ad%#Aqc|FF8vfqAYtP~gOJx7==n19{(>+pjdX
    lPs>Hdfd=bCy5Vua3^&=xYD~
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/print/golden/imagewritercolor.prn b/src/test/print/golden/imagewritercolor.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..e51eebc09429755fda92db26df3275a3b0795712
    GIT binary patch
    literal 83843
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/print/golden/laserjet300.prn b/src/test/print/golden/laserjet300.prn
    new file mode 100644
    index 0000000000000000000000000000000000000000..cf1f90613e67a527257c926e43399e85921b77f0
    GIT binary patch
    literal 32237
    zcmeI4yN=U96ov;xK|ui$RBjMussO7MB_L8F0RkcuzV1^H-w5
    z!wc8bKU?FE3pM8f2q@uixL|wyeUZFS&;7Y|)DJZYh$2@C30_6nMqeu=o~kDS9wMsn
    z5F{XBO#*c_Ig}>!CleLh?vt`@k0*9|S0MN;Vv_?gka#s$5sUOhZjk}4>=Q?nJ
    zgFgD`l`kHs`$8pjqXF9lh
    z=QjCo*3lo-`xepUUf~Mwx|3kCiA;2_eN$hnwcrpnLZ~QYcX5OaWUO=e`smU-H>Jmo
    z^SDKwnJo6RYpQ`{$etdDi<*H9WVX_!-=?}Ly8z>?9BD5~gPx!zm
    z@$V{Zj()VxYr7)u@2}%q)F1L^XVwio;qR=s=%+{edFPn7w5=zuc=G2yf{(wRz^5O4
    z4qDa|SHHX3$?vZx@PUuMlWFF+E+4ttH(fv0?>eJSFX#DId$CURuA2hl$)8zmR<66N
    zJmCYMIKW3PKXy$&H`R?+_2iT~*(#p=e!sv6K6X9ntG`C-npij5{$5u+`Ewt^$6rt2
    zv*CR75^x$@yVcfgtk%0GIE#dyB5>mgTy>?)xCZ(REIT(9foNU}lz0-YvstQekhABTI}
    z6G_hUJjFw)G7fkm;R(;wrXGb{Zg#QsX{pUrD>6EWOU$G;IA+sC_oyNHM{6FGbTy672F1-|^1&~w?dV0)!5{QT
    zxmi&@m`${mQMG(oJ`lBXvR&z=5>xpAFL;@&5LHdQw8zBqW%*!~b37=DT>0&xX$-G|
    c1e#cG$(OF;!Cz0{1E2inUZI4Yt0$t-Z>xqk_5c6?
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/print/golden/postscript.prn b/src/test/print/golden/postscript.prn
    new file mode 100644
    index 00000000..877db6ed
    --- /dev/null
    +++ b/src/test/print/golden/postscript.prn
    @@ -0,0 +1,360 @@
    +%!PS
    +%%Creator: DVX
    +%%LanguageLevel: 2
    +%%EndComments
    +/dvxPage { currentpagedevice /PageSize get aload pop 36 sub exch 576 sub 2 div exch } bind def
    +/dvxBegin { /dvxDpi exch def gsave dvxPage translate 72 dvxDpi div dup neg scale } bind def
    +/dvxData { /dvxA currentfile /ASCII85Decode filter def /dvxR dvxA /RunLengthDecode filter def } bind def
    +/dvxDone { dvxR flushfile dvxA flushfile } bind def
    +/dvxMask { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH true [dvxW 0 0 dvxH 0 0] dvxR imagemask dvxDone grestore } bind def
    +/dvxColor { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH 1 [dvxW 0 0 dvxH 0 0] dvxR false 4 colorimage dvxDone grestore } bind def
    +/dvxRe { findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall /Encoding ISOLatin1Encoding def currentdict end definefont pop } bind def
    +/DvxCourier /Courier dvxRe /dvxF /DvxCourier findfont def
    +/dvxTop { dvxPage /dvxY exch def /dvxX exch def dvxF dvxSize scalefont setfont } bind def
    +/L { dvxX dvxY dvxLead sub dup /dvxY exch def moveto show } bind def
    +/dvxM matrix def
    +/dvxL { 4 2 roll moveto lineto stroke } bind def
    +/dvxEA { dvxM currentmatrix pop 6 4 roll translate 4 2 roll scale 0 0 1 5 -2 roll neg exch neg exch arcn dvxM setmatrix } bind def
    +/dvxE { 0 360 dvxEA closepath } bind def
    +/dvxBox { 4 2 roll moveto 1 index 0 rlineto 0 exch rlineto neg 0 rlineto closepath } bind def
    +/dvxD { 0 360 arc fill } bind def
    +/dvxHz { /dvxQ exch def /dvxP exch def { 0.5 add dvxP exch moveto dvxQ dvxP sub 0 rlineto } for } bind def
    +/dvxVt { /dvxQ exch def /dvxP exch def { 0.5 add dvxP moveto 0 dvxQ dvxP sub rlineto } for } bind def
    +/dvxUp { /dvxQ exch def /dvxP exch def { 1 add dup dvxP sub dvxP moveto dvxQ sub dvxQ lineto } for } bind def
    +/dvxDn { /dvxQ exch def /dvxP exch def { dup dvxP add dvxP moveto dvxQ add dvxQ lineto } for } bind def
    +/dvxSF { /dvxSy exch def /dvxSx exch def findfont [dvxSx 0 0 dvxSy neg 0 0] makefont setfont } bind def
    +/dvxT { moveto show } bind def
    +/dvxImg { /dvxC exch def /dvxH exch def /dvxW exch def gsave 4 2 roll translate scale dvxData dvxW dvxH 8 [dvxW 0 0 dvxH 0 0] dvxR dvxC 1 eq { image } { false 3 colorimage } ifelse dvxDone grestore } bind def
    +%%EndProlog
    +300 dvxBegin
    +0 375 32 dvxMask
    +dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.
    +dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&J,~>
    +32 375 32 dvxMask
    +dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.
    +dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&J,~>
    +64 375 32 dvxMask
    +dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.
    +dh)Qkd]3"@h,Mh&dh)Qkd]2.mogI6.dh)Qkd]2t>hF,elJ,~>
    +96 375 32 dvxMask
    +h@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`
    +dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elJ,~>
    +128 375 32 dvxMask
    +h@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`
    +dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elJ,~>
    +160 375 32 dvxMask
    +h@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`
    +dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`p(?*ld]1_`d].f~>
    +192 375 32 dvxMask
    +dfBFKd]1_`d]3!Qh%8#7dR;N`dfBFKd]1_`d]3!Qh%8#7dR;N`dfBFKd]1_`d]3!Qh%8#7dR;N`
    +dfBFKd]1_`d]3!Qh%8#7dR;N`J,~>
    +224 375 32 dvxMask
    +dfBFKd]1_`d]3!Qh%8#7dR;N`dfBFKd]1_`d]3!Qh%8#7dR;N`dfBFKd]1_`d]3!Qh%8#7dR;N`
    +dfBFKd]1_`d]3!Qh%8#7dR;N`J,~>
    +256 375 32 dvxMask
    +dfBFKd]1_`d]3!Qh%8#7dR;N`dfBFKd]1_`d]3!Qh%8#7dR;N`dfBFKd]1_`d]3!Qh%8#7dR;N`
    +dfBFKd]1_`d]2OCl2]84dR;N`J,~>
    +288 375 32 dvxMask
    +dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`d]2OCl2]84dR;N`dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`
    +d]2OCl2]84dR;N`J,~>
    +320 375 32 dvxMask
    +dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`d]2OCl2]84dR;N`dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`
    +d]2OCl2]84dR;N`J,~>
    +352 375 32 dvxMask
    +dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`d]2OCl2]84dR;N`dK%^qdR;N`kn*+[d]1_`d]1^qd]1^u
    +d]1^qd]2S#ks3[sJ,~>
    +384 375 32 dvxMask
    +dK%^qdKJ!udK%^ql9sl%d]1^qd]1^ud]1^qd]2S#ks3[sdK%^qdKJ!udK%^ql9sl%d]1^qd]1^u
    +d]1^qd]2S#ks3[sJ,~>
    +416 375 32 dvxMask
    +dK%^qdKJ!udK%^ql9sl%d]1^qd]1^ud]1^qd]2S#ks3[sdK%^qdKJ!udK%^ql9sl%d]1^qd]1^u
    +d]1^qd]2S#ks3[sJ,~>
    +448 375 32 dvxMask
    +dK%^qdKJ!udK%^ql9sl%d]1^qd]1^ud]1^qd]2S#ks3[sdK%^qdKJ!udK%^qoi0d9d]2.9p&P-n
    +d]1_`d]1b.!"dSYoi0d9d].f~>
    +480 375 32 dvxMask
    +h@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`
    +dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elJ,~>
    +512 375 32 dvxMask
    +h@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`
    +dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elJ,~>
    +544 375 32 dvxMask
    +h@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`dh)Qkd]2t>hF,elh@]G^!!(HIdR;N`
    +l4N7ld]1_`d]1as!!(HIdR;N`l4N7ld]1_`d].f~>
    +576 375 32 dvxMask
    +dfBFKd]1_`d]2REkn):CdR;N`dfBFKd]1_`d]2REkn):CdR;N`dfBFKd]1_`d]2REkn):CdR;N`
    +dfBFKd]1_`d]2REkn):CdR;N`J,~>
    +608 375 32 dvxMask
    +dfBFKd]1_`d]2REkn):CdR;N`dfBFKd]1_`d]2REkn):CdR;N`dfBFKd]1_`d]2REkn):CdR;N`
    +dfBFKd]1_`d]2REkn):CdR;N`J,~>
    +640 375 32 dvxMask
    +dfBFKd]1_`d]2REkn):CdR;N`dfBFKd]1_`d]2REkn):CdR;N`dfBFKd]1_`d]2OCl2]84dR;N`
    +dK%^qdR;N`kn*+[d]1_`d].f~>
    +672 375 32 dvxMask
    +dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`d]2OCl2]84dR;N`dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`
    +d]2OCl2]84dR;N`J,~>
    +704 375 32 dvxMask
    +dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`d]2OCl2]84dR;N`dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`
    +d]2OCl2]84dR;N`J,~>
    +736 375 32 dvxMask
    +dK%^qdR;N`kn*+[d]1_`d]1^qd]1_`d]2OCl2]84l9sl%d]1^qd]1^ud]1^qd]2S#ks3[sdK%^q
    +dKJ!udK%^ql9sl%d].f~>
    +768 375 32 dvxMask
    +dK%^qdKJ!udK%^ql9sl%d]1^qd]1^ud]1^qd]2S#ks3[sdK%^qdKJ!udK%^ql9sl%d]1^qd]1^u
    +d]1^qd]2S#ks3[sJ,~>
    +800 375 32 dvxMask
    +dK%^qdKJ!udK%^ql9sl%d]1^qd]1^ud]1^qd]2S#ks3[sdK%^qdKJ!udK%^ql9sl%d]1^qd]1^u
    +d]1^qd]2S#ks3[sJ,~>
    +832 375 32 dvxMask
    +dK%^qdKJ!udK%^ql9sl%d]1^qd]1^ud]3!@!!1tW!*%CHp-8UDhc0YUor?m:p&G'nhc0YUd]3!D
    +!!V7[!*&Wkl0e8Jp&G'nhc0YUd]3"+!(#LB!*&WkhX:(;!!1tW!*%CHp&k@!hc0YUor@9Ep9+)h
    +!!1tW!*%CHp-8UDhc0YUor?m:J,~>
    +864 375 32 dvxMask
    +p&G'nhc0YUd]3!D!!V7[!*&Wkl0e8Jp&G'nhc0YUd]3"+!(#LB!*&WkhX:(;!!1tW!*%CHp&k@!
    +hc0YUor@9Ep9+)h!!1tW!*%CHp-8UDhc0YUor?m:p&G'nhc0YUd]3!D!!V7[!*&Wkl0e8Jp&G'n
    +hc0YUd]3"+!(#LB!*&WkhX:(;!!1tW!*%CHp&k@!hc0YUor@9Ep9+)h!!1tW!*%CHp-8UDhc0YU
    +or?m:J,~>
    +896 375 32 dvxMask
    +p&G'nhc0YUd]3!D!!V7[!*&Wkl0e8Jp&G'nhc0YUd]3"+!(#LB!*&WkhX:(;!!1tW!*%CHp&k@!
    +hc0YUor@9Ep9+)h!!1tW!*%CHp-8UDhc0YUor?m:p&G'nhc0YUd]3!D!!V7[!*&Wkl0e8Jp&G'n
    +hc0YUd]3"+!(#LB!*&WkhX:(;!!1tW!*%CHp&k@!hc0YUor@9Ep9+)h!!1tW!*%CHp-8UDhc0YU
    +or?m:J,~>
    +928 375 32 dvxMask
    +p&G'nhc0YUd]3!D!!V7[!*&Wkl0e8Jp&G'nhc0YUd]3"+!(#LB!*&WkhX:(;!!1tW!*%CHdo?BI
    +lL4!*do?BIe%5Ukdo?BIdf.]q!*%FZ!4pWZ!*&7Pl0d##!*%FZ!4pWZ!*%DGdo?BIe%5Ukdo?BI
    +lL4!*do?BIe%5Ukdo?BIdf+a~>
    +960 375 32 dvxMask
    +do?BIe%5Ukdo?BIlL4!*do?BIe%5Ukdo?BIdf.]q!*%FZ!4pWZ!*&7Pl0d##!*%FZ!4pWZ!*%DG
    +do?BIe%5Ukdo?BIlL4!*do?BIe%5Ukdo?BIdf.]q!*%FZ!4pWZ!*&7Pl0d##!*%FZ!4pWZ!*%DG
    +J,~>
    +992 375 32 dvxMask
    +do?BIe%5Ukdo?BIlL4!*do?BIe%5Ukdo?BIdf.]q!*%FZ!4pWZ!*&7Pl0d##!*%FZ!4pWZ!*%DG
    +do?BIe%5Ukdo?BIlL4!*do?BIe%5Ukdo?BIdf.]q!*%FZ!4pWZ!*&7Pl0d##!*%FZ!4pWZ!*%DG
    +J,~>
    +1024 375 32 dvxMask
    +do?BIe%5Ukdo?BIlL4!*do?BIe%5Ukdo?BIdf.]q!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +J,~>
    +1056 375 32 dvxMask
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +J,~>
    +1088 375 32 dvxMask
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +J,~>
    +1120 375 32 dvxMask
    +do?BIl+?8^!34LJ!*%D7do?BIp:K.ldo?BIddGRa!*%gUor?J@!*%D7do?BIp:K.ldo?BIddGRa
    +!*%gUor?J@!*%D7do?BIp:K.ldo?BIddGRa!*%gUor?J@!*%D7do?BIp:K.ldo?BIddDV~>
    +1152 375 32 dvxMask
    +do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BI
    +hQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7J,~>
    +1184 375 32 dvxMask
    +do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BI
    +hQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7J,~>
    +1216 375 32 dvxMask
    +do?BIhQ-=Ldo?BIhdo?BIot'"[do?BIddGRa!*%CHdo?BIhdo?BIot'"[do?BIddGRa
    +!*%CHdo?BIhdo?BIot'"[do?BIddGRa!*%CHdo?BIhdo?BIot'"[do?BIddDV~>
    +1248 375 32 dvxMask
    +do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BI
    +d]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7J,~>
    +1280 375 32 dvxMask
    +do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BI
    +d]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7J,~>
    +1312 375 32 dvxMask
    +do?BId]1br!*%CLdo?BId]1br!*&XZhWiQi!*%CHdo?BId]V&!!*%CHdo?BIp$U]%do?BId]1br
    +!*%CLdo?BId]1br!*&XZhWiQi!*%CHdo?BId]V&!!*%CHdo?BIp$U]%J,~>
    +1344 375 32 dvxMask
    +do?BId]1br!*%CLdo?BId]1br!*&XZhWiQi!*%CHdo?BId]V&!!*%CHdo?BIp$U]%do?BId]1br
    +!*%CLdo?BId]1br!*&XZhWiQi!*%CHdo?BId]V&!!*%CHdo?BIp$U]%J,~>
    +1376 375 32 dvxMask
    +do?BId]1br!*%CLdo?BId]1br!*&XZhWiQi!*%CHdo?BId]V&!!*%CHdo?BIp$U]%do?BId]1br
    +!*%CLdo?BId]1br!*&XZhWiQi!*%CHdo?BId]V&!!*%CHdo?BIddDV~>
    +1408 375 32 dvxMask
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +J,~>
    +1440 375 32 dvxMask
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +J,~>
    +1472 375 32 dvxMask
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*%FZ!4pWZ!*%D7
    +do?BIl+?8^!34LJ!*%D7do?BIe%5Ukdo?BIddGRa!*&3pl`1#cdo?BIddGRa!*&[(h7D'8!*%D7
    +J,~>
    +1504 375 32 dvxMask
    +do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BI
    +hQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7J,~>
    +1536 375 32 dvxMask
    +do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BI
    +hQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7J,~>
    +1568 375 32 dvxMask
    +do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BI
    +hQ-=Ldo?BIddGRa!*&[(h7D'8!*%D7do?BIhQ-=Ldo?BIhdo?BIot'"[do?BIddDV~>
    +1600 375 32 dvxMask
    +do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BI
    +d]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7J,~>
    +1632 375 32 dvxMask
    +do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BI
    +d]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7J,~>
    +1664 375 32 dvxMask
    +do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BId]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BI
    +d]1br!*%eBp9)kE!*&X&hQ#%)!*%D7do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+
    +!3b_g!<27`!+"pH!8R88p?DYHli-taJ,~>
    +1696 375 32 dvxMask
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +J,~>
    +1728 375 32 dvxMask
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +J,~>
    +1760 375 32 dvxMask
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<27`!+"pH!8R88p?DYHli-ta
    +do?BIlDaifli-tal;[gileMQrlE1,jli-takuI%l!-IM+!3b_g!<1j2pAG!he,KFIhGs8n!-Zik
    +!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIJ,~>
    +1792 375 32 dvxMask
    +hGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*
    +pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik
    +!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIJ,~>
    +
    +1824 375 32 dvxMask
    +hGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*
    +pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik
    +!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIJ,~>
    +
    +1856 375 32 dvxMask
    +hGs8n!-Zik!<26=p0@Z*pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*
    +pYPu-e,KFIhGs8n!-Zik!<1j2pAG!he,KFIhGs8n!-Zik!<26=p0@Z*pYPu-e,KFIdrte8e,KFI
    +hYm0(!8Q''!<1Gk!-Zik!<2]LhVA1ee,KFIdrte8e,KFIhYm0(!8Q''!<.K~>
    +1888 375 32 dvxMask
    +drte8e,KFIp>=`_!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIdrte8e,KFIp>=`_!8Q''!<1Gk!-Zik
    +!<1jRp>#`(e,KFIdrte8e,KFIp>=`_!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIdrte8e,KFIp>=`_
    +!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIJ,~>
    +1920 375 32 dvxMask
    +drte8e,KFIp>=`_!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIdrte8e,KFIp>=`_!8Q''!<1Gk!-Zik
    +!<1jRp>#`(e,KFIdrte8e,KFIp>=`_!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIdrte8e,KFIp>=`_
    +!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIJ,~>
    +1952 375 32 dvxMask
    +drte8e,KFIp>=`_!8Q''!<1Gk!-Zik!<1jRp>#`(e,KFIdrte8e,KFIp>=`_!8Q''!<1Gk!-Zik
    +!<1jRp>#`(e,KFIdrte8lMgk\lDj.*!7:!2lMgk\lDj,n!-%3'lMgk\lDj.*!7:!rlMgk\lDjN$
    +p/_5jl2^,L!;bu\lJ2Hel2^,L!;bu\l?<5Il2^,L!;bu\lJ2Hel9OY7!;bu\J,~>
    +1984 375 32 dvxMask
    +olp+2!)Vq\lMgk\lDj.*!7:!2lMgk\lDj,n!-%3'lMgk\lDj.*!7:!rlMgk\lDjN$p/_5jl2^,L
    +!;bu\lJ2Hel2^,L!;bu\l?<5Il2^,L!;bu\lJ2Hel9OY7!;bu\olp+2!)Vq\lMgk\lDj.*!7:!2
    +lMgk\lDj,n!-%3'lMgk\lDj.*!7:!rlMgk\lDjN$p/_5jl2^,L!;bu\lJ2Hel2^,L!;bu\l?<5I
    +l2^,L!;bu\lJ2Hel9OY7!;bu\J,~>
    +2016 375 32 dvxMask
    +olp+2!)Vq\lMgk\lDj.*!7:!2lMgk\lDj,n!-%3'lMgk\lDj.*!7:!rlMgk\lDjN$p/_5jl2^,L
    +!;bu\lJ2Hel2^,L!;bu\l?<5Il2^,L!;bu\lJ2Hel9OY7!;bu\olp+2!)Vq\lMgk\lDj.*!7:!2
    +lMgk\lDj,n!-%3'lMgk\lDj.*!7:!rlMgk\lDjN$p/_5jl2^,L!;bu\lJ2Hel2^,L!;bu\l?<5I
    +l2^,L!;bu\lJ2Hel9OY7!;bu\J,~>
    +2048 375 32 dvxMask
    +olp+2!)Vq\lMgk\lDj.*!7:!2lMgk\lDj,n!-%3'lMgk\lDj.*!7:!rlMgk\lDjN$p/_5jl2^,L
    +!;bu\lJ2Hel2^,L!;bu\l?<5Il2^,L!;bu\lJ2Hel9NdtdK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'
    +p+"Jjd]1^qd]2t*h>l!(J,~>
    +2080 375 32 dvxMask
    +dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(
    +dK&+'p+"Jjd]1^qd]2t*h>l!(J,~>
    +2112 375 32 dvxMask
    +dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(
    +dK&+'p+"Jjd]1^qd]2t*h>l!(J,~>
    +2144 375 32 dvxMask
    +dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qor?lCdK%^qdK&,Rochfad]1^qor?lCdK%^q
    +dK&,Rochfad]1^qor?lCdK%^qJ,~>
    +2176 375 32 dvxMask
    +dK&,Rochfad]1^qor?lCdK%^qdK&,Rochfad]1^qor?lCdK%^qdK&,Rochfad]1^qor?lCdK%^q
    +dK&,Rochfad]1^qor?lCdK%^qJ,~>
    +2208 375 32 dvxMask
    +dK&,Rochfad]1^qor?lCdK%^qdK&,Rochfad]1^qor?lCdK%^qdK&,Rochfad]1^qor?lCdK%^q
    +dK&,Rochfad]1^qor?lCdK%^qJ,~>
    +2240 375 32 dvxMask
    +dK&,Rochfad]1^qor?lCdK%^qdK&,Roci5mo`W^CdK&s7l663Xp8ZsL!!N!`d]1^qhBDqLp8ZsL
    +o`W^CdK&s7l663Xp8ZsL!!N!`d]1^qhBDqLp8ZsLo`W^CdK&s7l663Xp8ZsL!!N!`d].f~>
    +2272 375 32 dvxMask
    +dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNYp-d?/dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNY
    +p-d?/dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNYp-d?/dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]B
    +h>dNYp-d?/J,~>
    +2304 375 32 dvxMask
    +dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNYp-d?/dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNY
    +p-d?/dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNYp-d?/dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]B
    +h>dNYp-d?/J,~>
    +2336 375 32 dvxMask
    +dK&,J!%R]Bh>m3Od]1^qoqL^q!%R]Bh>dNYp-d?/dK%^qh?
    +2368 375 32 dvxMask
    +dK%^qh?
    +2400 375 32 dvxMask
    +dK%^qh?
    +2432 375 32 dvxMask
    +dK%^qh?l!(dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(
    +dK&+'p+"Jjd]1^qd]2t*h>l!(J,~>
    +2464 375 32 dvxMask
    +dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(
    +dK&+'p+"Jjd]1^qd]2t*h>l!(J,~>
    +2496 375 32 dvxMask
    +dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(dK&+'p+"Jjd]1^qd]2t*h>l!(
    +dK&+'p+"Jjd]1^qd]2t*h>l!(J,~>
    +2528 375 32 dvxMask
    +dK&+'p+"Jjd]2R4!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4
    +!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4!!V[g!*&Wkp7gfO
    +l2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4!!V[g!*&Wkp7gfOl2UeflW!pal)O%6
    +J,~>
    +2560 375 32 dvxMask
    +l2UeflW!pakpktul2UeflW!pad]2R4!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktu
    +l2UeflW!pad]2R4!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4
    +!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4!!V[g!*&Wkp7gfO
    +l2UeflW!pal)O%6J,~>
    +2592 375 32 dvxMask
    +l2UeflW!pakpktul2UeflW!pad]2R4!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktu
    +l2UeflW!pad]2R4!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4
    +!!V[g!*&Wkp7gfOl2UeflW!pal)O%6l2UeflW!pakpktul2UeflW!pad]2R4!!V[g!*&Wkp7gfO
    +l2UeflW!pal)O%6J,~>
    +2624 375 32 dvxMask
    +l2UeflW!pad]1br!*%CHdo?BId]1br!*%CHkuI$a!#3k]do?BId]1br!*%CHdo?BId]2P3lP0C6
    +d]1br!*%CHdo?BId]1br!*%CHkuI$a!#3k]do?BId]1br!*%CHdo?BId].f~>
    +2656 375 32 dvxMask
    +kuI$a!#3k]do?BId]1br!*%CHdo?BId]2P3lP0C6d]1br!*%CHdo?BId]1br!*%CHkuI$a!#3k]
    +do?BId]1br!*%CHdo?BId]2P3lP0C6d]1br!*%CHdo?BId]1br!*%CHJ,~>
    +2688 375 32 dvxMask
    +kuI$a!#3k]do?BId]1br!*%CHdo?BId]2P3lP0C6d]1br!*%CHdo?BId]1br!*%CHkuI$a!#3k]
    +do?BId]1br!*%CHdo?BId]2P3lP0C6d]1br!*%CHdo?BId]1br!*%gT!3Z%`J,~>
    +2720 375 32 dvxMask
    +l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`l4rt(!#>6.!*%CHdo?BIhPpRY
    +p?pd7p/M)ld]1br!*%gT!3Z%`l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`
    +l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`J,~>
    +2752 375 32 dvxMask
    +l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`l4rt(!#>6.!*%CHdo?BIhPpRY
    +p?pd7p/M)ld]1br!*%gT!3Z%`l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`
    +l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`J,~>
    +2784 375 32 dvxMask
    +l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`l4rt(!#>6.!*%CHdo?BIhPpRY
    +p?pd7p/M)ld]1br!*%gT!3Z%`l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%gT!3Z%`
    +l4rt(!#>6.!*%CHdo?BIhPpRYp?pd7p/M)ld]1br!*%hCp$.&~>
    +2816 375 32 dvxMask
    +do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BI
    +d]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%J,~>
    +2848 375 32 dvxMask
    +do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BI
    +d]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%J,~>
    +2880 375 32 dvxMask
    +do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BId]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BI
    +d]1br!*&XZhQG=-!*%CHdo?BIhX:(%do?BId]1br!*&6dl)N34!*%CHdo?BIdcu>~>
    +2912 375 32 dvxMask
    +do?BId]1br!*&6dl)N34!*%CHdo?BIdd#:]!*%CHdo?BIlE9BOdo?BId]1br!*%D3do?BId]1br
    +!*&6dl)N34!*%CHdo?BIdd#:]!*%CHdo?BIlE9BOdo?BId]1br!*%D3J,~>
    +2944 375 32 dvxMask
    +do?BId]1br!*&6dl)N34!*%CHdo?BIdd#:]!*%CHdo?BIlE9BOdo?BId]1br!*%D3do?BId]1br
    +!*&6dl)N34!*%CHdo?BIdd#:]!*%CHdo?BIlE9BOdo?BId]1br!*%D3J,~>
    +2976 375 24 dvxMask
    +do?BId]1br!*&6dl)N34!*%CHdo?BIdd#:]!*%CHdo?BIlE9BOdo?BId]1br!*%D3do?BId]1br
    +!*&6dl)N34!*%CHdo?BIdcu>~>
    +grestore showpage
    +%%EOF
    diff --git a/src/test/print/golden/postscriptcolor.prn b/src/test/print/golden/postscriptcolor.prn
    new file mode 100644
    index 00000000..782818fb
    --- /dev/null
    +++ b/src/test/print/golden/postscriptcolor.prn
    @@ -0,0 +1,6450 @@
    +%!PS
    +%%Creator: DVX
    +%%LanguageLevel: 2
    +%%EndComments
    +/dvxPage { currentpagedevice /PageSize get aload pop 36 sub exch 576 sub 2 div exch } bind def
    +/dvxBegin { /dvxDpi exch def gsave dvxPage translate 72 dvxDpi div dup neg scale } bind def
    +/dvxData { /dvxA currentfile /ASCII85Decode filter def /dvxR dvxA /RunLengthDecode filter def } bind def
    +/dvxDone { dvxR flushfile dvxA flushfile } bind def
    +/dvxMask { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH true [dvxW 0 0 dvxH 0 0] dvxR imagemask dvxDone grestore } bind def
    +/dvxColor { /dvxH exch def /dvxW exch def gsave 0 exch translate dvxW dvxH scale dvxData dvxW dvxH 1 [dvxW 0 0 dvxH 0 0] dvxR false 4 colorimage dvxDone grestore } bind def
    +/dvxRe { findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall /Encoding ISOLatin1Encoding def currentdict end definefont pop } bind def
    +/DvxCourier /Courier dvxRe /dvxF /DvxCourier findfont def
    +/dvxTop { dvxPage /dvxY exch def /dvxX exch def dvxF dvxSize scalefont setfont } bind def
    +/L { dvxX dvxY dvxLead sub dup /dvxY exch def moveto show } bind def
    +/dvxM matrix def
    +/dvxL { 4 2 roll moveto lineto stroke } bind def
    +/dvxEA { dvxM currentmatrix pop 6 4 roll translate 4 2 roll scale 0 0 1 5 -2 roll neg exch neg exch arcn dvxM setmatrix } bind def
    +/dvxE { 0 360 dvxEA closepath } bind def
    +/dvxBox { 4 2 roll moveto 1 index 0 rlineto 0 exch rlineto neg 0 rlineto closepath } bind def
    +/dvxD { 0 360 arc fill } bind def
    +/dvxHz { /dvxQ exch def /dvxP exch def { 0.5 add dvxP exch moveto dvxQ dvxP sub 0 rlineto } for } bind def
    +/dvxVt { /dvxQ exch def /dvxP exch def { 0.5 add dvxP moveto 0 dvxQ dvxP sub rlineto } for } bind def
    +/dvxUp { /dvxQ exch def /dvxP exch def { 1 add dup dvxP sub dvxP moveto dvxQ sub dvxQ lineto } for } bind def
    +/dvxDn { /dvxQ exch def /dvxP exch def { dup dvxP add dvxP moveto dvxQ add dvxQ lineto } for } bind def
    +/dvxSF { /dvxSy exch def /dvxSx exch def findfont [dvxSx 0 0 dvxSy neg 0 0] makefont setfont } bind def
    +/dvxT { moveto show } bind def
    +/dvxImg { /dvxC exch def /dvxH exch def /dvxW exch def gsave 4 2 roll translate scale dvxData dvxW dvxH 8 [dvxW 0 0 dvxH 0 0] dvxR dvxC 1 eq { image } { false 3 colorimage } ifelse dvxDone grestore } bind def
    +%%EndProlog
    +300 dvxBegin
    +0 375 32 dvxColor
    +d].eSWf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!e`Wf!e`Wf!e`
    +Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`WX=UjM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4L$Ao1J$Ao1J$Ao1J!.h/iWW8^>WW4OJWW4OJWW4OJWW4OJWW4OJWW4OJWW4OJ
    +WW4OJWW4OJWW4OJWW4OJWa@6U$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$D=/o!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!P4rPWdfo+MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao6!M@,4JM@,4JM@,4@JZ?_i
    +!2.`>Ik%HJW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"UWX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WXB06$E=H5$E=H5$D>eH!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00"1kM4T[KiiMQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6$EAu`$EAu`$EAu`$EAu`$EAu`
    +$EAu`$EAu`$EAu`$EAu`$EAu`$EAu`$E=Gj$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$jqk!M@,4JM@,4JM?&4-`i9$3dAd0rri,rJri,rJri,rJri,rJri,rJri,rJri,rJri,rJri,rJ
    +ri,rJri0a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>DoWW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WWB](d].eSWf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6WX=Uj
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4L$Ao1J$Ao1J$Ao1J!.h/iWW8^>WdcLu.]Otu
    +.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=I@WX>a5WX>a5WX>EHWW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WWTi4Wh@e?Iu:7`Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`
    +WX>b`WX>b`WX>b`WX>a5M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@9iL$Ao1J$Ao1J
    +$Anh7WlD%=T[Bb>.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQGT.]NiU
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H+UT+=*ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri-"1T[KiiMQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ.#@$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$jqk!M@,4JM@,4JM?&4-`i9$3dAh[GW[b"uW[b"uW[b"uW[b"u
    +W[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uWX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a6$E=H5$E=H5
    +$E=H+dAd2Xri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri-(3WiD`?Wdfo+MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`
    +MQ.#`$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao6!M@,4JM@,4JM@,4@JZ?_i!2.`>
    +!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ?Ri&_WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5TVJLe])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]1\tIKO
    +Iu:7`Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!e`M@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@9iL$Ao1J$Ao1J$Anh7WlD%=T[Bch.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu
    +.]Otu.]Otu.]Otu.]Otu.]NiU$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$OWm@WX>a5WX>a5T[Bb>])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]3\uMd'J,~>
    +32 375 32 dvxColor
    +d].eSWf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!e`Wf!e`Wf!e`
    +Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`WX=UjM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4L$Ao1J$Ao1J$Ao1J!.h/iWW8^>WW4OJWW4OJWW4OJWW4OJWW4OJWW4OJWW4OJ
    +WW4OJWW4OJWW4OJWW4OJWa@6U$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$D=/o!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!P4rPWdfo+MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao6!M@,4JM@,4JM@,4@JZ?_i
    +!2.`>Ik%HJW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"UWX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WXB06$E=H5$E=H5$D>eH!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00"1kM4T[KiiMQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6$EAu`$EAu`$EAu`$EAu`$EAu`
    +$EAu`$EAu`$EAu`$EAu`$EAu`$EAu`$E=Gj$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$jqk!M@,4JM@,4JM?&4-`i9$3dAd0rri,rJri,rJri,rJri,rJri,rJri,rJri,rJri,rJri,rJ
    +ri,rJri0a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>DoWW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WWB](d].eSWf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6WX=Uj
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4L$Ao1J$Ao1J$Ao1J!.h/iWW8^>WdcLu.]Otu
    +.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=I@WX>a5WX>a5WX>EHWW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WWTi4Wh@e?Iu:7`Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`
    +WX>b`WX>b`WX>b`WX>a5M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@9iL$Ao1J$Ao1J
    +$Anh7WlD%=T[Bb>.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQGT.]NiU
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H+UT+=*ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri-"1T[KiiMQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ.#@$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$jqk!M@,4JM@,4JM?&4-`i9$3dAh[GW[b"uW[b"uW[b"uW[b"u
    +W[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uWX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a6$E=H5$E=H5
    +$E=H+dAd2Xri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri-(3WiD`?Wdfo+MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`
    +MQ.#`$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao6!M@,4JM@,4JM@,4@JZ?_i!2.`>
    +!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ?Ri&_WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5TVJLe])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]1\tIKO
    +Iu:7`Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!e`M@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@9iL$Ao1J$Ao1J$Anh7WlD%=T[Bch.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu
    +.]Otu.]Otu.]Otu.]Otu.]NiU$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$OWm@WX>a5WX>a5T[Bb>])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]3\uMd'J,~>
    +64 375 32 dvxColor
    +d].eSWf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!e`Wf!e`Wf!e`
    +Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`Wf!e`WX=UjM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4L$Ao1J$Ao1J$Ao1J!.h/iWW8^>WW4OJWW4OJWW4OJWW4OJWW4OJWW4OJWW4OJ
    +WW4OJWW4OJWW4OJWW4OJWa@6U$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$D=/o!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!P4rPWdfo+MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao6!M@,4JM@,4JM@,4@JZ?_i
    +!2.`>Ik%HJW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"UWX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WXB06$E=H5$E=H5$D>eH!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00
    +!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00!4r00"1kM4T[KiiMQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6$EAu`$EAu`$EAu`$EAu`$EAu`
    +$EAu`$EAu`$EAu`$EAu`$EAu`$EAu`$E=Gj$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$jqk!M@,4JM@,4JM?&4-`i9$3dAd0rri,rJri,rJri,rJri,rJri,rJri,rJri,rJri,rJri,rJ
    +ri,rJri0a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>DoWW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WWB](d].eSWf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6WX=Uj
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4L$Ao1J$Ao1J$Ao1J!.h/iWW8^>WdcLu.]Otu
    +.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu.]Otu$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=I@WX>a5WX>a5WX>EHWW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WWTi4Wh@e?Iu:7`Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`WX>b`
    +WX>b`WX>b`WX>b`WX>a5M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@9iL$Ao1J$Ao1J
    +$Anh7WlD%=T[Bb>.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQFJ.fQGT.]NiU
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5
    +$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H5$E=H+UT+=*ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri-"1T[KiiMQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ.#@$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$jqk!M@,4JM@,4JM?&4-`i9$3dAh[GW[b"uW[b"uW[b"uW[b"u
    +W[b"uW[b"uW[b"uW[b"uW[b"uW[b"uW[b"uWX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a6$E=H5$E=H5
    +$E=H+dAd2Xri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0ri,t0
    +ri,t0ri,t0ri,t0ri,t0ri,t0ri-(3WiD`?Wdfo+MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ2Q6
    +MQ2Q6MQ2Q6MQ2Q6MQ2Q6MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`MQ.#`
    +MQ.#`$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao6!M@,4JM@,4JM@,4@JZ?_i!2.`>
    +!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ!%RuJ?Ri&_WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5WX>a5
    +WX>a5WX>a5WX>a5WX>a5WX>a5TVJLe])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]1\tIKO
    +Iu:7`Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6
    +Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!g6Wf!e`M@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@9iL$Ao1J$Ao1J$Anh7WlD%=TE,CWrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_rX&X_rX&X_rX&X_JI;#V!!%M[Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo&j\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld+J,~>
    +96 375 32 dvxColor
    +0T$3p$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$B"fi!/ga_!/ga_
    +!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/dNY!!(HI?)Xd%Wi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2rHVuWLi$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_
    +$35s7$M"N(!!91#])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J^Z\uN?A
    +\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\>m'=\>m'=\>m'=\>m'=
    +\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5
    +\>ld5%CiFBWjo(5Wjo(5Wjn^.M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,7?$35t_
    +$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t2$31(SWa;X(W2cerW2cerW2cer
    +W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-
    +\>lL-\>lL-\>lL-\>lL-\>lL-\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE.\>Z&$$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/c=7oEkQu
    +J%DYkWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4?Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=
    +Wjo(=Wjo(=W]R_j\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yb3\-$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_UC$u$d]-TOWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWmdmHT`,8uMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_
    +MZ*q_MZ*q_MZ*q_MLc-*$31&-WiEZ0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WdhU[\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN9?\>m'=
    +\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yc>45Yc8/BWj&M5Wj&M5Wj&M5TO*K_M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MU;b2!7IlRW2cer
    +W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2dA-W2dA-W2dA-W2dA-
    +W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dB,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,
    +VuX@,VuX@,VuX@,VujK-TE,CWrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_JI;#V!!%M[Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWjo(=Wjo(=Wjo(=Wjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo&j\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld+0T$3p$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/dNY!!(HI
    +?)Xd%Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2rHVuWLi$35t_$35t_$35t_$35t_$35t_
    +$35t_$35t_$35t_$35t_$35t_$35s7$M"N(!!91#])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J^Z\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A
    +\uN?A\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5%CiFBWjo(5Wjo(5Wjn^.M@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,7?$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t2
    +$31(SWa;X(W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer\>lL-
    +\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE,\GiE,\GiE,\GiE,\GiE,\GiE.\>Z&$$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_
    +!/ga_!/ga_!/ga_!/c=7oEkQuJ%DYkWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4?Wjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=W]R_j\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yb3\-$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$Ao1J$Ao1J$Ao1JrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_UC$u$d]-TOWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWmdmHT`,8uMZ*q_MZ*q_
    +MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MLc-*$31&-WiEZ0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WdhU[\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A
    +\uN?A\uN?A\uN?A\uN9?\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=
    +\>m'=Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc8/BWj&M5Wj&M5Wj&M5TO*K_M@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_
    +MZ*q_MZ*q_MU;b2!7IlRW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer
    +W2cerW2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dB,VuX@,VuX@,
    +VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VujK-TE,CWrX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_JI;#V!!%M[Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo&j\>m'=\>m'=\>m'=\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld+J,~>
    +128 375 32 dvxColor
    +0T$3p$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$B"fi!/ga_!/ga_
    +!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/dNY!!(HI?)Xd%Wi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2rHVuWLi$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_
    +$35s7$M"N(!!91#])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J^Z\uN?A
    +\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\>m'=\>m'=\>m'=\>m'=
    +\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5
    +\>ld5%CiFBWjo(5Wjo(5Wjn^.M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,7?$35t_
    +$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t2$31(SWa;X(W2cerW2cerW2cer
    +W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-
    +\>lL-\>lL-\>lL-\>lL-\>lL-\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE.\>Z&$$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/c=7oEkQu
    +J%DYkWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4?Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=
    +Wjo(=Wjo(=W]R_j\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yb3\-$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_UC$u$d]-TOWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWmdmHT`,8uMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_
    +MZ*q_MZ*q_MZ*q_MLc-*$31&-WiEZ0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WdhU[\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN9?\>m'=
    +\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yc>45Yc8/BWj&M5Wj&M5Wj&M5TO*K_M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MU;b2!7IlRW2cer
    +W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2dA-W2dA-W2dA-W2dA-
    +W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dB,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,
    +VuX@,VuX@,VuX@,VujK-TE,CWrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_JI;#V!!%M[Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWjo(=Wjo(=Wjo(=Wjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo&j\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld+0T$3p$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/dNY!!(HI
    +?)Xd%Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2rHVuWLi$35t_$35t_$35t_$35t_$35t_
    +$35t_$35t_$35t_$35t_$35t_$35s7$M"N(!!91#])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J^Z\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A
    +\uN?A\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5%CiFBWjo(5Wjo(5Wjn^.M@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,7?$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t2
    +$31(SWa;X(W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer\>lL-
    +\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE,\GiE,\GiE,\GiE,\GiE,\GiE.\>Z&$$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_
    +!/ga_!/ga_!/ga_!/c=7oEkQuJ%DYkWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4?Wjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=W]R_j\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yb3\-$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$Ao1J$Ao1J$Ao1JrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_UC$u$d]-TOWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWmdmHT`,8uMZ*q_MZ*q_
    +MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MLc-*$31&-WiEZ0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WdhU[\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A
    +\uN?A\uN?A\uN?A\uN9?\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=
    +\>m'=Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc8/BWj&M5Wj&M5Wj&M5TO*K_M@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_
    +MZ*q_MZ*q_MU;b2!7IlRW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer
    +W2cerW2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dB,VuX@,VuX@,
    +VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VujK-TE,CWrX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_JI;#V!!%M[Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo&j\>m'=\>m'=\>m'=\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld+J,~>
    +160 375 32 dvxColor
    +0T$3p$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$B"fi!/ga_!/ga_
    +!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/dNY!!(HI?)Xd%Wi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2rHVuWLi$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_
    +$35s7$M"N(!!91#])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J]0])J^Z\uN?A
    +\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\>m'=\>m'=\>m'=\>m'=
    +\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5
    +\>ld5%CiFBWjo(5Wjo(5Wjn^.M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,7?$35t_
    +$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t2$31(SWa;X(W2cerW2cerW2cer
    +W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-
    +\>lL-\>lL-\>lL-\>lL-\>lL-\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE.\>Z&$$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/c=7oEkQu
    +J%DYkWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4?Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=
    +Wjo(=Wjo(=W]R_j\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yb3\-$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_UC$u$d]-TOWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWmdmHT`,8uMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_
    +MZ*q_MZ*q_MZ*q_MLc-*$31&-WiEZ0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WdhU[\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN9?\>m'=
    +\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yc>45Yc8/BWj&M5Wj&M5Wj&M5TO*K_M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JMZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MU;b2!7IlRW2cer
    +W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2dA-W2dA-W2dA-W2dA-
    +W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dA-W2dB,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,
    +VuX@,VuX@,VuX@,VujK-TE,CWrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_JI;#V!!%M[Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWjo(=Wjo(=Wjo(=Wjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo&j\>m'=\>m'=\>m'=\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld+0T$3p$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/dNY!!(HI
    +?)Xd%Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2rHVuWLi$35t_$35t_$35t_$35t_$35t_
    +$35t_$35t_$35t_$35t_$35t_$35s7$M"N(!!91#])J]0])J]0])J]0])J]0])J]0])J]0])J]0
    +])J]0])J]0])J]0])J^Z\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A
    +\uN?A\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>ld5\>ld5
    +\>ld5\>ld5\>ld5\>ld5\>ld5\>ld5%CiFBWjo(5Wjo(5Wjn^.M@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4JM@,4J
    +M@,4JM@,4JM@,4JM@,7?$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t_$35t2
    +$31(SWa;X(W2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cerW2cer\>lL-
    +\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\>lL-\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE,\GiE,\GiE,\GiE,\GiE,\GiE.\>Z&$$B"fi!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_!/ga_
    +!/ga_!/ga_!/ga_!/c=7oEkQuJ%DYkWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4A
    +Wk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4AWk,4?Wjo(=
    +Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=Wjo(=W]R_j\>m'=\>m'=\>m'=Yc>45Yc>45Yc>45Yc>45
    +Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yb3\-$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J$Ao1J
    +$Ao1J$Ao1J$Ao1J$Ao1J$Ao1JrX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_rX&X_
    +rX&X_UC$u$d]-TOWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qr
    +Wi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWi2qrWmdmHT`,8uMZ*q_MZ*q_
    +MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MZ*q_MLc-*$31&-WiEZ0WW9X0WW9X0WW9X0WW9X0
    +WW9X0WW9X0WW9X0WW9X0WW9X0WW9X0WdhU[\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A\uN?A
    +\uN?A\uN?A\uN?A\uN9?\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=\>m'=
    +\>m'=Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc>45Yc8/BWj&M5Wj&M5Wj&M5T[F0Q#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d!!!%u\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiE,\GiE,\GiE,\GiFV\>Zp9Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1
    +Yc,(1Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)%^;n9W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE!.V5OYc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb4(^W3Dr!W3Dr!W3Dr!TWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)>~>
    +192 375 32 dvxColor
    +dL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!mLcC$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$W]Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)40YZfW3E5)W3E5)W3Dr!TWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#R]#d!!!%u\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiFV\>Zp9Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)%^;n9W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE!.V5OYc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yb85!Yb85!Yb85!Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb4(^W3Dr!W3Dr!W3Dr!TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pRrhoh,
    +rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rht=NW1^)nW1^)nW1^)nW1^)n
    +W1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E3/Yc+e)Yc+e)
    +Yc+e)YbMP2#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#QT@YW3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E3^Yc+e)Yc+e)
    +Yc+e)Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84ndL'7c$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!!0%.rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,
    +rhoh,rhoh,rhoh,rhoh,rht=VW48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1
    +W48e1W48e1W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E31Yc+e)Yc+e)Yc+e)Yc+e$$=*oE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUh;J$>lUW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)TWkB!
    +TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TKBBVYb85!Yb85!Yb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4%!f#QOl(VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,W.2%M
    +TW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nYc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc%u7W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE!.V5O
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc'XfW3E5)W3E5)W3E5)TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR!NZX1\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1
    +\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc&&9W3E5)W3E5)W3E5)W3E$(
    +>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$W]Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!40YZfTWkB!TWkB!TWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d!!*"s!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,
    +!4`$,!4`$,!4`$,J$>lEW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4n
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W!oo/Yc+e)Yc+e)Yc+e$$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUh;J$>lUW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W&q5^Yc+e)Yc+e)Yc+e!Yb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4%!f
    +#QOi*W4;i*!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,J%2GeW3E51W3E51
    +W3E51W3E51W3E51W3E51W3E51W3E51W3E51W3E51W3E51W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W"-&1
    +Yc+e)Yc+e)Yc+e)YbMP2#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#QT@YW3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3DpV
    +Yc+e!Yc+e!Yc+e!Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +J,~>
    +224 375 32 dvxColor
    +dL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!mLcC$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$W]Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)40YZfW3E5)W3E5)W3Dr!TWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#R]#d!!!%u\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiFV\>Zp9Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)%^;n9W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE!.V5OYc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yb85!Yb85!Yb85!Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb4(^W3Dr!W3Dr!W3Dr!TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pRrhoh,
    +rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rht=NW1^)nW1^)nW1^)nW1^)n
    +W1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E3/Yc+e)Yc+e)
    +Yc+e)YbMP2#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#QT@YW3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E3^Yc+e)Yc+e)
    +Yc+e)Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84ndL'7c$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!!0%.rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,
    +rhoh,rhoh,rhoh,rhoh,rht=VW48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1
    +W48e1W48e1W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E31Yc+e)Yc+e)Yc+e)Yc+e$$=*oE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUh;J$>lUW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)TWkB!
    +TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TKBBVYb85!Yb85!Yb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4%!f#QOl(VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,W.2%M
    +TW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nYc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc%u7W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE!.V5O
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc'XfW3E5)W3E5)W3E5)TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR!NZX1\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1
    +\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc&&9W3E5)W3E5)W3E5)W3E$(
    +>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$W]Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!40YZfTWkB!TWkB!TWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d!!*"s!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,
    +!4`$,!4`$,!4`$,J$>lEW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4n
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W!oo/Yc+e)Yc+e)Yc+e$$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUh;J$>lUW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W&q5^Yc+e)Yc+e)Yc+e!Yb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4%!f
    +#QOi*W4;i*!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,J%2GeW3E51W3E51
    +W3E51W3E51W3E51W3E51W3E51W3E51W3E51W3E51W3E51W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W"-&1
    +Yc+e)Yc+e)Yc+e)YbMP2#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#QT@YW3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3DpV
    +Yc+e!Yc+e!Yc+e!Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +J,~>
    +256 375 32 dvxColor
    +dL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!mLcC$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$W]Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)40YZfW3E5)W3E5)W3Dr!TWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#R]#d!!!%u\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,\GiE,
    +\GiFV\>Zp9Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc,(1Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)%^;n9W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE!.V5OYc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yb85!Yb85!Yb85!Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb4(^W3Dr!W3Dr!W3Dr!TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pRrhoh,
    +rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,rht=NW1^)nW1^)nW1^)nW1^)n
    +W1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW1^)nW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E3/Yc+e)Yc+e)
    +Yc+e)YbMP2#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#QT@YW3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E3^Yc+e)Yc+e)
    +Yc+e)Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84ndL'7c$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!!0%.rhoh,rhoh,rhoh,rhoh,rhoh,rhoh,
    +rhoh,rhoh,rhoh,rhoh,rht=VW48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1W48e1
    +W48e1W48e1W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E31Yc+e)Yc+e)Yc+e)Yc+e$$=*oE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUh;J$>lUW3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)TWkB!
    +TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TKBBVYb85!Yb85!Yb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4%!f#QOl(VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,VuX@,W.2%M
    +TW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nTW#)nYc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc%u7W3E5)W3E5)W3E5)UC(94$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE!.V5O
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc'XfW3E5)W3E5)W3E5)TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR!NZX1\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1
    +\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1\>ZX1Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc&&9W3E5)W3E5)W3E5)W3E$(
    +>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$W]Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)Yc+e)
    +Yc+e)Yc+e)Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!40YZfTWkB!TWkB!TWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d!!*"s!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,!4`$,
    +!4`$,!4`$,!4`$,J$>lEW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4nW3E4n
    +W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)W3E5)
    +W3E5)W3E5)W3E5)W3E5)W3E5)W!oo/Yc+e)Yc+e)Yc+e"#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#VQ/`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!"UTTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT`2.q
    +T`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`20%TTHC>TTHC>TTHC>TTHC>
    +TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>
    +TTHC>TTHC>TTHC>TTHC>TTHC/#kSB&!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6*!!iRSTWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkAV
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.4.q\.OJ%].OJ%].OJ%\cOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOO"14!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*70!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!*#&!20=q!20=q!20=q!20=q
    +!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q!20=q@%aa.OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKa6~>
    +288 375 32 dvxColor
    +JI(fP!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!%MIYb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb7)NOJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%[s
    +TTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +Ta1VurWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWk.W#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QOiVYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb;!"!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q?D*CaOJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>
    +OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>JI(lR!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!WWK+J"X$EYb85!Yb85!Yb85!Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].O?9+sTTGh.TTGh.TTGh.
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<8(_!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(>lY-1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QOl(YQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?q
    +YQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qY[F-.Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)?#kA6$!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!.UrOTWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTD7.OJ%].OJ%].OJ%].OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFd#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#VQ/`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!"UTTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT`2.qT`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`2.qT`2.qT`2.qT`2.qT`20%TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>
    +TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC/
    +#kSB&!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6*!!iRSTWkB!TWkB!
    +TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkAVTTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +4.q\.OJ%].OJ%].OJ%\cOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OO"14!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*70!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!*#&!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q@%aa.OKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKa9And#3qrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(J#KTEYc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!
    +Yc+e!Yc+e!OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].O?9+sTTGh.TTGh.TTGh.J<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<6H1!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(0)u7X#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1!%kFJYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nricBqricBq
    +ricBqricBqricBqricBqricBqricBqricBqricBqricBqrif_%OKah>OKah>OKah>OKah>OKah>
    +OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>
    +OKah>OKah>OKah>OKa9AoEYEsrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiQ*#QT@IYc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc*YVOJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%[sTTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CcNjLNrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWld0#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1!<02&T`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`20'TWkY^Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY^m)N#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOjSW3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!
    +W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3DqVTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.4.q\.OJ%].OJ%].OJ%\c
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOJ)p[!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6W!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)/[1nPTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk*gYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qY[3uaY`PN>
    +Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>
    +Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y^m)P#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#Qau3!.UZGW3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!
    +W3Dr!W3Dr!W0![6TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTD7.OJ%].OJ%].OJ%].OFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWG=#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#[IE9!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)ricBqricBq
    +ricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBq
    +ricBqricBqricBqricBqricBqrife'Ycs4nOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +J,~>
    +320 375 32 dvxColor
    +JI(fP!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!%MIYb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb7)NOJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%[s
    +TTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +Ta1VurWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWk.W#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QOiVYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb;!"!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q?D*CaOJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>
    +OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>JI(lR!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!WWK+J"X$EYb85!Yb85!Yb85!Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].O?9+sTTGh.TTGh.TTGh.
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<8(_!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(>lY-1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QOl(YQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?q
    +YQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qY[F-.Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)?#kA6$!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!.UrOTWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTD7.OJ%].OJ%].OJ%].OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFd#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#VQ/`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!"UTTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT`2.qT`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`2.qT`2.qT`2.qT`2.qT`20%TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>
    +TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC/
    +#kSB&!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6*!!iRSTWkB!TWkB!
    +TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkAVTTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +4.q\.OJ%].OJ%].OJ%\cOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OO"14!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*70!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!*#&!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q@%aa.OKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKa9And#3qrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(J#KTEYc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!
    +Yc+e!Yc+e!OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].O?9+sTTGh.TTGh.TTGh.J<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<6H1!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(0)u7X#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1!%kFJYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nricBqricBq
    +ricBqricBqricBqricBqricBqricBqricBqricBqricBqrif_%OKah>OKah>OKah>OKah>OKah>
    +OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>
    +OKah>OKah>OKah>OKa9AoEYEsrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiQ*#QT@IYc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc*YVOJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%[sTTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CcNjLNrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWld0#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1!<02&T`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`20'TWkY^Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY^m)N#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOjSW3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!
    +W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3DqVTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.4.q\.OJ%].OJ%].OJ%\c
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOJ)p[!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6W!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)/[1nPTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk*gYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qY[3uaY`PN>
    +Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>
    +Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y`PN>Y^m)P#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#Qau3!.UZGW3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!W3Dr!
    +W3Dr!W3Dr!W0![6TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTD7.OJ%].OJ%].OJ%].OFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWG=#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#[IE9!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)ricBqricBq
    +ricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBqricBq
    +ricBqricBqricBqricBqricBqrife'Ycs4nOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +J,~>
    +352 375 32 dvxColor
    +JI(fP!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!%MIYb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!Yb7)NOJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%[s
    +TTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +Ta1VurWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWk.W#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QOiVYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb;!"!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q?D*CaOJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>
    +OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>OJ%]>JI(lR!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!WWK+J"X$EYb85!Yb85!Yb85!Yb85!Yb85!
    +Yb85!Yb85!Yb85!Yb85!Yb85!Yb85!OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].O?9+sTTGh.TTGh.TTGh.
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<8(_!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(>lY-1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QOl(YQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?q
    +YQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qYQ1?qY[F-.Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)?#kA6$!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!.UrOTWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTD7.OJ%].OJ%].OJ%].OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFd#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#VQ/`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!"UTTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT`2.qT`2.qT`2.qT`2.qT`2.qT`2.q
    +T`2.qT`2.qT`2.qT`2.qT`2.qT`20%TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>
    +TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC>TTHC/
    +#kSB&!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6*!!iRSTWkB!TWkB!
    +TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkB!TWkAVTTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +4.q\.OJ%].OJ%].OJ%\cOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OO"14!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*70!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!*#&!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q
    +!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q!20=q@%aa.OKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKa9And#3qrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(J#KTEYc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!
    +Yc+e!Yc+e!OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].O?9+sTTGh.TTGh.TTGh.J<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<6H1!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(0)u7X#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1!%kFJYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nricBqricBq
    +ricBqricBqricBqricBqricBqricBqricBqricBqricBqrif_%OKah>OKah>OKah>OKah>OKah>
    +OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>OKah>
    +OKah>OKah>OKah>OKa9AoEYEsrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiQ*#QT@IYc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc+e!Yc*YVOJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%[sTTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CcNjLNrWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWld0#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1!7Hr/JGtW1JGtW1JGtW1JGtW1JGtW1
    +JGtW1JGtW1JGtW1JGtW1JGtW1JGtXJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^
    +J1h0.#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QXi0!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!(FQ!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFY].d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js3/JA?]NJA?]NJA?]NJC;5_!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +$igP5#QP,1#QP,1#QXi0!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!rrT4
    +!7HsYJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^%"Q-Dd"&o^d"&o^d"!\R!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!2Kl#!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^JA;SDd"&o^d"&o^d"&o^d_L>4#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,l!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)dZ=HZrf@,1rf@,1rf@,1rf@,1
    +rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rfCNd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^
    +d"&o^J,~>
    +384 375 32 dvxColor
    +0*hO`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!7D,Q!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA;SD^js4N^js4N^js4Nd_L>4#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,=
    +!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6+!!ii1
    +dZAs/OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY].d"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsc?JA?]^JA?]^JA?]^J1V$,#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +U^-r#It3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsd^^^Pb?JA?]^JA?]^JA?]`^jc=i!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!kXd#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QOkQO8sh1O8sh1O8sh1O8sh1O8sh1
    +O8sh1O8sh1O8sh1O8sh1O8sh1O8sh1OC3U#OO/O)JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^
    +JA?[g#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(dKfgQIt3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jn^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^^Pb/JA?]NJA?]NJA?]`^jc=i!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iu5#QP,1#QP,1#QP,1rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiT+
    +#RCFYOFMeBJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^JC&h^JC"^T^jsd^^jsd^^jsd^/I2=^!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iT(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOk##QOjSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^JC&h^JC&h^%"Q]T^jsd^^jsd^^jskKIfK`S#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#Wi"l!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!(H/!.b'1!.b'1!.b'1!.b'1
    +!.b'1!.b'1!.b'1!.b'1!.b'1!.b'1!.b'1@"=?#d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jn'D!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iT(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOkQ#QOjSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&h^JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]N%"Q-D^js4N^js4N^jskKIfK`S
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#Rg\=!!iQ1!!iQ1!!iT(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#Qk&4#QW;7It3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^cjYH?JC&h^JC&h^JC&fu#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QXi0!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!&f#!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY].
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^d"&n?JC&h^JC&h^JC&h^JC;5_!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ13rfNd#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1!7Hr/JGtW1JGtW1JGtW1
    +JGtW1JGtW1JGtW1JGtW1JGtW1JGtW1JGtW1JGtXJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^J1h0.#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QXi0
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!(FQ!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFY].d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js3/JA?]NJA?]NJA?]NJC;5_
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1$igP5#QP,1#QP,1#QXi0!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!rrT4!7HsYJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5<.
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^JA?]^JA?]^%"Q-Dd"&o^d"&o^d"!\R!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!2Kl#!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^JA?]^JA?]^JA;SDd"&o^d"&o^d"&o^d_L>4#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,l!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)dZ=HZrf@,1rf@,1
    +rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rfCNd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^J,~>
    +416 375 32 dvxColor
    +0*hO`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!7D,Q!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA;SD^js4N^js4N^js4Nd_L>4#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,=
    +!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6+!!ii1
    +dZAs/OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY].d"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsc?JA?]^JA?]^JA?]^J1V$,#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +U^-r#It3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsd^^^Pb?JA?]^JA?]^JA?]`^jc=i!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!kXd#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QOkQO8sh1O8sh1O8sh1O8sh1O8sh1
    +O8sh1O8sh1O8sh1O8sh1O8sh1O8sh1OC3U#OO/O)JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^
    +JA?[g#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(dKfgQIt3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jn^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^^Pb/JA?]NJA?]NJA?]`^jc=i!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iu5#QP,1#QP,1#QP,1rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiT+
    +#RCFYOFMeBJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^JC&h^JC"^T^jsd^^jsd^^jsd^/I2=^!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iT(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOk##QOjSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^JC&h^JC&h^%"Q]T^jsd^^jsd^^jskKIfK`S#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#Wi"l!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!(H/!.b'1!.b'1!.b'1!.b'1
    +!.b'1!.b'1!.b'1!.b'1!.b'1!.b'1!.b'1@"=?#d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jn'D!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iT(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOkQ#QOjSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&h^JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]N%"Q-D^js4N^js4N^jskKIfK`S
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#Rg\=!!iQ1!!iQ1!!iT(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#Qk&4#QW;7It3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^cjYH?JC&h^JC&h^JC&fu#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QXi0!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!&f#!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY].
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^d"&n?JC&h^JC&h^JC&h^JC;5_!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ13rfNd#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1!7Hr/JGtW1JGtW1JGtW1
    +JGtW1JGtW1JGtW1JGtW1JGtW1JGtW1JGtW1JGtXJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^J1h0.#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QXi0
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!(FQ!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFY].d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js3/JA?]NJA?]NJA?]NJC;5_
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1$igP5#QP,1#QP,1#QXi0!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!rrT4!7HsYJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5<.
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^JA?]^JA?]^%"Q-Dd"&o^d"&o^d"!\R!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!2Kl#!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^JA?]^JA?]^JA;SDd"&o^d"&o^d"&o^d_L>4#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,l!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ)dZ=HZrf@,1rf@,1
    +rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rf@,1rfCNd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^J,~>
    +448 375 32 dvxColor
    +0*hO`!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*6(!7D,Q!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA;SD^js4N^js4N^js4Nd_L>4#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,=
    +!!iQ1!!iQ1!!iQ1!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6+!!ii1
    +dZAs/OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY].d"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsc?JA?]^JA?]^JA?]^J1V$,#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +U^-r#It3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsd^^^Pb?JA?]^JA?]^JA?]`^jc=i!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!kXd#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QOkQO8sh1O8sh1O8sh1O8sh1O8sh1
    +O8sh1O8sh1O8sh1O8sh1O8sh1O8sh1OC3U#OO/O)JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^
    +JA?[g#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(dKfgQIt3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jn^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^^Pb/JA?]NJA?]NJA?]`^jc=i!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iu5#QP,1#QP,1#QP,1rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiT+
    +#RCFYOFMeBJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJC&hnJC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^JC&h^JC"^T^jsd^^jsd^^jsd^/I2=^!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iT(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOk##QOjSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hn
    +JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&h^JC&h^JC&h^JC&h^JC&h^
    +JC&h^JC&h^JC&h^JC&h^%"Q]T^jsd^^jsd^^jskKIfK`S#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#Wi"l!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!(H/!.b'1!.b'1!.b'1!.b'1
    +!.b'1!.b'1!.b'1!.b'1!.b'1!.b'1!.b'1@"=?#d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jn^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jn'D!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iT(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOkQ#QOjSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5<.JC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hnJC&hn
    +JC&hnJC&h^JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]N%"Q-D^js4N^js4N^jskKIfK`S
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#Rg\=!!iQ1!!iQ1!!iT(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(#QOl(
    +#Qk&4#QW;7It3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCd"'Jn
    +d"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"'Jnd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^cjYH?JC&h^JC&h^JC&fu#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QXi0!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!&f#!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;b
    +Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z7rRf0JY>f0JY>dJHEgm!!(HQ
    +J$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVg
    +ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-
    +Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@
    +Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z7rR
    +480 375 32 dvxColor
    +JHEgm!!!"nriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNurj%$JZEc0iYd,sg
    +Yd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sg0Xf0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVg
    +ZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*78
    +0X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ
    +!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@
    +Z8/^@Z8/^@Z8/^@Z7rRf0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVg
    +ZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ
    +!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@
    +Z7rRf
    +0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ
    +Ikn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z7rRf0JY>f0JY>dJHEgm
    +!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVg
    +ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+
    +!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@
    +Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z7rR
    +512 375 32 dvxColor
    +JHEgm!!!"nriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNurj%$JZEc0iYd,sg
    +Yd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sg0Xf0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVg
    +ZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*78
    +0X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ
    +!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@
    +Z8/^@Z8/^@Z8/^@Z7rRf0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVg
    +ZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ
    +!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@
    +Z7rRf
    +0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ
    +Ikn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z7rRf0JY>f0JY>dJHEgm
    +!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVg
    +ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+
    +!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@
    +Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z7rR
    +544 375 32 dvxColor
    +JHEgm!!!"nriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNuriuNurj%$JZEc0iYd,sg
    +Yd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sgYd,sg0Xf0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVg
    +ZEUVgZEUVgZEUVgZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*78
    +0X*7)!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ
    +!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@
    +Z8/^@Z8/^@Z8/^@Z7rRf0JY>f0JY>dJHEgm!!(HQJ$?5_ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVgZEUVg
    +ZEUVgYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YRE4i0X*780X*780X*7)!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WrN+!WW;-Z82Eb!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ!&FPZ
    +!&FPZ!&FPZ!&FPZIkn;bZ8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@Z8/^@
    +Z7rR!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!e1:R!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+
    +!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&
    +!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!3N/!WW9&!WW9&!WW4O
    +0Z>`o!e11O!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3Q/!WW9%!WW9%!WW9%0!I%60X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*78d4bQ>r\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=k
    +r\-$F0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!WiE*rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW6@-!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(
    +!WW9(!WW9(!WW9(!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!.ciC0[^#P!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%$ig>/!WW9%!WW9%!WXjW0X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X1O1!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV
    +!3jd'/oWL`0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!!E3%!!E3%
    +!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%Iff`S!WrK+!WrK+!WrK+!WrK+
    +!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK(!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&
    +!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!Xf#1!!30&!!30&
    +!!%R(`@uf~>
    +576 375 32 dvxColor
    +dK4mu!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW91!!3-%!!3-%!!3-%!%tK0YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YgG-)E;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hOF
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bDup(B"9&?%"9&?%
    +"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%
    +"9&?%"9&?%"9&?%"9&?%"9&?%"9&@-!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(
    +!s/H(!s/H(!s/H(!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!%R(
    +`A#^AIfKNM!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!Xo&1!!3-%!!3-%!!3-QYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<<\0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?'
    +0J7;uE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bDu^">!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!e1:R!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+
    +!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&
    +!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!3N/!WW9&!WW9&!WW4O
    +0Z>`o!e11O!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3Q/!WW9%!WW9%!WW9%0!I%60X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*78d4bQ>r\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=k
    +r\-$F0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!WiE*rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW6@-!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(
    +!WW9(!WW9(!WW9(!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!.ciC0[^#P!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%$ig>/!WW9%!WW9%!WXjW0X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X1O1!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV
    +!3jd'/oWL`0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!!E3%!!E3%
    +!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%Iff`S!WrK+!WrK+!WrK+!WrK+
    +!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK(!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&
    +!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!Xf#1!!30&!!30&
    +!!%R(`A#^AIfKNM!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!Xo&1!!3-%!!3-%!!3-QYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<%sdK4mu!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW91!!3-%!!3-%!!3-%!%tK0YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<:8Yl6/!WW9%!WW9%!WXjW0X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X*780X/qY!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok
    +!-%ok!-%ok0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q7'p
    +!X&E'!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%
    +!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%>lt-.!WrK(!WrK(!WrK(!WrK(!WrK(!WrK(
    +!WrK(!WrK(!WrK(!WrK(!WrK(!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW4O0Z>`o!e11O!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3Q/!WW9%!WW9%!WW9%0!I%60X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X*780X*78r\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>V
    +r\+>Vr\+>VcS-tj0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q7!o
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW7QO!WrK+!WrK+!WrK+
    +!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&
    +!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW90!%sJ,~>
    +608 375 32 dvxColor
    +dK4mu!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW91!!3-%!!3-%!!3-%!%tK0YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YgG-)E;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hOF
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bDup(B"9&?%"9&?%
    +"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%
    +"9&?%"9&?%"9&?%"9&?%"9&?%"9&@-!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(
    +!s/H(!s/H(!s/H(!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!%R(
    +`A#^AIfKNM!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!Xo&1!!3-%!!3-%!!3-QYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<<\0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?'
    +0J7;uE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bDu^">!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!e1:R!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+
    +!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&
    +!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!3N/!WW9&!WW9&!WW4O
    +0Z>`o!e11O!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3Q/!WW9%!WW9%!WW9%0!I%60X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*78d4bQ>r\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=k
    +r\-$F0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!WiE*rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW6@-!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(
    +!WW9(!WW9(!WW9(!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!.ciC0[^#P!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%$ig>/!WW9%!WW9%!WXjW0X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X1O1!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV
    +!3jd'/oWL`0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!!E3%!!E3%
    +!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%Iff`S!WrK+!WrK+!WrK+!WrK+
    +!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK(!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&
    +!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!Xf#1!!30&!!30&
    +!!%R(`A#^AIfKNM!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!Xo&1!!3-%!!3-%!!3-QYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<%sdK4mu!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW91!!3-%!!3-%!!3-%!%tK0YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<:8YV<:8Yl6/!WW9%!WW9%!WXjW0X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X*780X/qY!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok
    +!-%ok!-%ok0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q7'p
    +!X&E'!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%
    +!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%>lt-.!WrK(!WrK(!WrK(!WrK(!WrK(!WrK(
    +!WrK(!WrK(!WrK(!WrK(!WrK(!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW4O0Z>`o!e11O!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3Q/!WW9%!WW9%!WW9%0!I%60X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X*780X*78r\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>Vr\+>V
    +r\+>Vr\+>VcS-tj0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q7!o
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW7QO!WrK+!WrK+!WrK+
    +!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&
    +!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW9&!WW90!%sJ,~>
    +640 375 32 dvxColor
    +dK4mu!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW91!!3-%!!3-%!!3-%!%tK0YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YgG-)E;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hOF
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bDup(B"9&?%"9&?%
    +"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%"9&?%
    +"9&?%"9&?%"9&?%"9&?%"9&?%"9&@-!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(!s/H(
    +!s/H(!s/H(!s/H(!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!%R(
    +`A#^AIfKNM!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!Xo&1!!3-%!!3-%!!3-QYV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8YV<:8
    +YV<:8YV<:8YV<:8YV<<\0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?V0EA?'
    +0J7;uE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bDu^">!WW?%!WW?%
    +!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!WW?%!e1:R!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+
    +!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!s/Q+!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&
    +!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!30&!!3N/!WW9&!WW9&!WW4O
    +0Z>`o!e11O!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3Q/!WW9%!WW9%!WW9%0!I%60X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*78d4bQ>r\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=kr\+=k
    +r\-$F0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!WiE*rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%
    +rW3'%rW3'%rW3'%rW3'%rW3'%rW3'%rW6@-!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(!WW9(
    +!WW9(!WW9(!WW9(!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!.ciC0[^#P!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%$ig>/!WW9%!WW9%!WXjW0X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X*780X1O1!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV!3lGV
    +!3jd'/oWL`0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b!!E3%!!E3%
    +!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%!!E3%Iff`S!WrK+!WrK+!WrK+!WrK+
    +!WrK+!WrK+!WrK+!WrK+!WrK+!WrK+!WrK(!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&
    +!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!W`?&!Xf#1!!30&!!30&
    +!!%R(`A#^AIfKNM!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!Xo&1!!3-%!!3-%!!3.M+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R
    ++E0#R+E0#R+E0#R+E0#R+E0#R?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-
    +?uR6-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm4(*#8E+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE.7ih!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3T0!WW9%!WW9%
    +!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0$!WW3%0JIJF0E?(k
    +0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0Rn$qE%l5bE%l5bE%l5bE%l5bE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*R
    +E$0*RE$0*RE$0*RE+!W=E+!W=E+!W=E+!W=E+!W=E+!W=E+!W=E+!W=E*s,o0Q:E=0Q:E=0Q:E=
    +0E;1R!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!(O!:g,>0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R
    +0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q:E=+E1_-+E1_-+E1_-+E1_-+E1_-+E1_-+E1_-+E1_-
    +4!8KME$0+-E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E*iSH!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<*!!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%okIrI)\
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=
    +?uRf=?jgp-E+!W=E+!W=E*r_I!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!8enIir\aE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W-E+!W-E+!W-E+!W-E+!W-
    +E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E'%HH?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uUSq!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW92!!3-%!!3-%!!3-%!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!Wi?%!\c
    +672 375 32 dvxColor
    +!WW3%rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"
    +rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"TE'HaE%l5RE%l5RE%l5RE%l5RE%l5RE%l5R
    +E%l5RE%l5RE%l5RE%l5RE%l5RE%l6=E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-
    +E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WH?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmIfKNM!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!Y#,2!!3-%
    +!!3-%!!3-%rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rW!!$!!!'S0Q@"F
    +!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%okIrI)\0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R
    ++E0#R+E0#R+E0#R+E0#R?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?k%'/E+!W=E+!W=
    +E+!W=!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!7]Onc4.nE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*RE$0*RE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-
    +E+!WH+E1_-+E1_-+E1_-+E1_-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?N:--!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!rW*!E;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hP@
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l6=E%l6=E%l6=E%l6=E%l6=E%l6=E%l6=
    +E%l6=E%l6=$ZIe=?uRf=?uRf=?ig<.!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!h]OC+E0#R
    ++E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R?uR6-?uR6-?uR6-?uR6-
    +?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm4(*#8E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE.7ih!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3T0!WW9%!WW9%!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0$!WW3%0JIJF0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k
    +0Rn$qE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W=E+!W=E+!W=E+!W=E+!W=E+!W=
    +E+!W=E+!W=E*s,o0Q:E=0Q:E=0Q:E=0E;1R!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"
    +!!!*"!!!*"!!!(O!:g,>0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q:E=+E1_-+E1_-
    ++E1_-+E1_-+E1_-+E1_-+E1_-+E1_-4!8KME$0+-E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*iSH!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<*!!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok
    +!-%ok!-%ok!-%ok!-%ok!-%okIrI)\0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b?uRf=
    +?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?jgp-E+!W=E+!W=E*r_I!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!8enIir\aE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*R
    +E$0*RE+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE'%HH?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uUSq!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW92!!3-%!!3-%!!3-%!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!Wi?%!\c
    +704 375 32 dvxColor
    +!WW3%rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"
    +rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"TE'HaE%l5RE%l5RE%l5RE%l5RE%l5RE%l5R
    +E%l5RE%l5RE%l5RE%l5RE%l5RE%l6=E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-
    +E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WH?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmIfKNM!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!Y#,2!!3-%
    +!!3-%!!3-%rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rW!!$!!!'S0Q@"F
    +!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%okIrI)\0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R
    ++E0#R+E0#R+E0#R+E0#R?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?k%'/E+!W=E+!W=
    +E+!W=!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!7]Onc4.nE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*RE$0*RE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-
    +E+!WH+E1_-+E1_-+E1_-+E1_-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?N:--!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!rW*!E;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hP@
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l6=E%l6=E%l6=E%l6=E%l6=E%l6=E%l6=
    +E%l6=E%l6=$ZIe=?uRf=?uRf=?ig<.!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!h]OC+E0#R
    ++E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R?uR6-?uR6-?uR6-?uR6-
    +?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm4(*#8E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE.7ih!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3T0!WW9%!WW9%!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0$!WW3%0JIJF0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k
    +0Rn$qE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W=E+!W=E+!W=E+!W=E+!W=E+!W=
    +E+!W=E+!W=E*s,o0Q:E=0Q:E=0Q:E=0E;1R!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"
    +!!!*"!!!*"!!!(O!:g,>0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q:E=+E1_-+E1_-
    ++E1_-+E1_-+E1_-+E1_-+E1_-+E1_-4!8KME$0+-E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*iSH!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<*!!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok
    +!-%ok!-%ok!-%ok!-%ok!-%okIrI)\0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b?uRf=
    +?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?jgp-E+!W=E+!W=E*r_I!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!8enIir\aE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*R
    +E$0*RE+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE'%HH?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uUSq!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW92!!3-%!!3-%!!3-%!!<-"!!<-"!!<-"
    +!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!Wi?%!\c
    +736 375 32 dvxColor
    +!WW3%rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"
    +rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"TE'HaE%l5RE%l5RE%l5RE%l5RE%l5RE%l5R
    +E%l5RE%l5RE%l5RE%l5RE%l5RE%l6=E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-
    +E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WH?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmIfKNM!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!Y#,2!!3-%
    +!!3-%!!3-%rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rVup"rW!!$!!!'S0Q@"F
    +!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%ok!-%okIrI)\0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R
    ++E0#R+E0#R+E0#R+E0#R?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?uRf=?k%'/E+!W=E+!W=
    +E+!W=!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!<-"!!7]Onc4.nE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*RE$0*RE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-E+!W-
    +E+!WH+E1_-+E1_-+E1_-+E1_-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?N:--!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%
    +!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!rW*!E;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hNkE;hP@
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5b
    +E%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l6=E%l6=E%l6=E%l6=E%l6=E%l6=E%l6=
    +E%l6=E%l6=$ZIe=?uRf=?uRf=?ig<.!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!h]OC+E0#R
    ++E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R+E0#R?uR6-?uR6-?uR6-?uR6-
    +?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uR6-?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm4(*#8E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE.7ih!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3T0!WW9%!WW9%!WW9%!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"!r`0"
    +!r`0"!r`0"!r`0$!WW3%0JIJF0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k0E?(k
    +0Rn$qE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE%l5bE$0*RE$0*R
    +E$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE$0*RE+!W=E+!W=E+!W=E+!W=E+!W=E+!W=
    +E+!W=E+!W=E*s,o0Q:E=0Q:E=0Q:E=0E;1R!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"!!!*"
    +!!!*"!!!*"!!!(O!:g,>0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b0Q8^b
    +0Q8^b0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q8.R0Q:E=+E1_-+E1_-
    ++E1_-+E1_-+E1_-+E1_-+E1_-+E1_-4!8KME$0+-E$0+-E$0+-E+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE1m73"TAB$"TAB$"TAB$"TAB$"TAB$"TAB$"TAB$"TAB$
    +"TAB$"TAB$"TABS"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +IrJe7?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?or=8E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+%)oIfo`S!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!"8u1"98Q)"98Q)"98ET?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_F
    +ra5_Fra5_Fra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra7EF?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*bJH59A/Hc%V!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E:OE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm%WGfp?uTLm?uTLm?uTLm
    +ra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5bG@*elR"TAB$"TAB$"TAB$
    +"TAB$"TAB$"TAB$"TAB$"TAB$"TAB$"TAB$"TAC-"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98ER?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uZ*Q!)WZ&
    +!)WZ&!)WZ&!)WZ&!)WZ&!)WZ&!)WZ&!)WZ&!)WZ&!)WZ&!)WZ&?;P#p?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*bT`>&qrVup$rVup$rVup$rVup$rVup$rVup$rVup$rVup$rVup$rVup$
    +rW"SS!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E:OE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +4)eSH?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +@+#%)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)$j6P5!!E9)!!E9)!!"Z[E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!Ya?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F
    +?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?naiR5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zj.!:p1B"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"9
    +768 375 32 dvxColor
    +dK"b"!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E91"98Q)"98Q)"98Q)!&%`!?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uZ*Q!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF
    +!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk0L0#2?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pK8Co)L?F!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!.SrqE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*s/p?uTLm?uTLm?uTLm?uZ*Q!-%pF!-%pF
    +!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!H;Gn!!!0$!!!0$!!!0$!!!0$!!!0$!!!0$
    +!!!0$!!!0$!!!0$!!!0$!!!0$!+#d."98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)!%hSt
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmra5_&ra5_&ra5_&ra5_&
    +ra5_&ra5_&ra5_&ra5_&ra5_&ra5_&ra5_&ra9&/?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pLCc!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$0*D7X!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!.SrqE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*tbH?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTN%!.P+O"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98i5!!E9)!!E9)!!E9%048a,E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5F5lP-k5lP-k
    +5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP.F5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5_/q,!%e&qrVup$rVup$
    +rVup$rVup$rVup$rVup$rVup$rVup$rVup$rVup$rW"SS!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E:OE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm4)eSH?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm@+#%)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)$j6P5!!E9)!!E9)!!"Z[
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Ya?iY0F?iY0F?iY0F
    +?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk
    +?iWIk?iWIk?iWIk?iWIk?naiR5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zj.!:p1B"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"9
    +800 375 32 dvxColor
    +dK"b"!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E91"98Q)"98Q)"98Q)!&%`!?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uZ*Q!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF
    +!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk0L0#2?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pK8Co)L?F!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!.SrqE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*s/p?uTLm?uTLm?uTLm?uZ*Q!-%pF!-%pF
    +!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!H;Gn!!!0$!!!0$!!!0$!!!0$!!!0$!!!0$
    +!!!0$!!!0$!!!0$!!!0$!!!0$!+#d."98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)!%hSt
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmra5_&ra5_&ra5_&ra5_&
    +ra5_&ra5_&ra5_&ra5_&ra5_&ra5_&ra5_&ra9&/?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pLCc!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$0*D7X!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!.SrqE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*tbH?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTN%!.P+O"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98i5!!E9)!!E9)!!E9%048a,E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5F5lP-k5lP-k
    +5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP.F5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5_/q,!%e&qrVup$rVup$
    +rVup$rVup$rVup$rVup$rVup$rVup$rVup$rVup$rW"SS!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E:OE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm4)eSH?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm@+#%)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)$j6P5!!E9)!!E9)!!"Z[
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Ya?iY0F?iY0F?iY0F
    +?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iY0F?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk
    +?iWIk?iWIk?iWIk?iWIk?naiR5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zj.!:p1B"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"9
    +832 375 32 dvxColor
    +dK"b"!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E91"98Q)"98Q)"98Q)!&%`!?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uZ*Q!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF
    +!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk0L0#2?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pK8Co)L?F!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!.SrqE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*s/p?uTLm?uTLm?uTLm?uZ*Q!-%pF!-%pF
    +!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!-%pF!H;Gn!!!0$!!!0$!!!0$!!!0$!!!0$!!!0$
    +!!!0$!!!0$!!!0$!!!0$!!!0$!+#d."98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)!%hSt
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLmra5_&ra5_&ra5_&ra5_&
    +ra5_&ra5_&ra5_&ra5_&ra5_&ra5_&ra5_&ra9&/?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pLCc!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$!!N9$0*D7X!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!.SrqE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE*tbH?uTLm?uTLm
    +?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTLm?uTN%!.P+O"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98i5!!E9)!!E9)!!E9%048a,E+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!WmE+!Wm
    +E+!WmE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5F5lP-k5lP-k
    +5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP.F5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5_/q,!%e=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju%#Bjudu49W?!op*5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[ZibfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^@a8j#*"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98R\#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB#`&nF%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J3t-oRL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JJ:DNG5[\P=5[\P=5[\P=5[\P=
    +5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5fhU!fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ4KRaOnSdaOnSd
    +aOnSdrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>2oaN2-u"9\`("98H$
    +"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"CV9=L^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JLl@9$repgcrepgcrepgcrepgcrepgcrepgcrepgcrepgcrepgcrepgcrepgW
    +?!op*5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[ZibfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^@a8j#*"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98R\#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB#`&bB#`&bB#`&nF%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J3t-oRL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JJ,hk6?iWIk?iWIk
    +?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?sl6-@*%!AfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^oa8j!U!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<*+/
    +!!EE5L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^])uL^])uL^])u
    +L^])uL^])uL^])uL^])uL^])uL^])uL^])uL^])uL^])uLsq:_J6cPJ?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b@+`QQaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdUrWPOIfo`S!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eFL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JLcZ$$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%"NhH?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=
    +?pJ+=?pJ+=?pJ+=?pJ+=?pMfcaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnQBfZ;^dfZ;^dfZ;^df`&qlf`&ql
    +f`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qofZ;.L!X&W+rWE3$rWE3$rWE3$rWE3$
    +rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWHX0%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#Bk$%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c!*rtt?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b@+`QQaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdUrWPOIfo`S!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eB
    +L^8eFL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLcZ$$%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%"J=Cra5^kra5^kra5^kra5^kra5^k
    +ra5^kra5^kra5^kra5^kra5^kra9,!?sq(.aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +e&VO)!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$?iU<4%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=JN/.muN/.muN/.muN/.muN/.mu
    +N/.muN/.muN/.muN/.muN/.muN/.muN/5rA!.\?_5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfVHRg^jcIe"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"Gd>>
    +#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#?tRL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^\f`E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=
    +E*tq=E*trPfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d%+rYRaOnSdaOnSdaOpL5!8.:l!8.:l!8.:l!8.:l
    +!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l"5(]9^jl~>
    +864 375 32 dvxColor
    +!X&W+rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWHX0%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#Bk$%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c
    +%/u1c%/u1c!*rtt?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b@+`QQ
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdUrWPOIfo`S!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)L^8eBL^8eBL^8eBL^8eBL^8eBL^8eB
    +L^8eBL^8eBL^8eBL^8eBL^8eBL^8eFL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLcZ$$
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%"J=C
    +ra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra9,!?sq(.aOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSde&VO)!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$
    +!!*!$!!*!$?iU<4%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +N/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/5rA!.\?_5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfVHRg^jcIe"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"Gd>>#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#?tRL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^\f`E*tq=E*tq=E*tq=E*tq=E*tq=
    +E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*trPfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d%+rYRaOnSdaOnSd
    +aOpL5!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l"5(]9^]FWA!<**$!<**$
    +!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<*+0!"=YlL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^])uN"#]E!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!!$;4
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfVHRg^jcIe"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"Gd>>#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB#`&bB#`&bB%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#?tRL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^\e65lP-k5lP-k5lP-k
    +5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP/!5[\!@fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^df[InA^]4B6"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"CM34
    +":U(pL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLl@,uLl@,uLl@,u
    +Ll@,uLl@,uLl@,uLl@,uLl@,uLl@,uLl@,uLl@,uLl@/=Mu\B6?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ,PaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOm<0!5O'e!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!J)`L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^])$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=BIrJd\?uTL=?uTL=?uTL=?uTL=?uTL=?uTL=?uTL=
    +?uTL=?uTL=?uTL=?uTL=aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSda:-_BfZ;^dfZ;^dfZ;_Ea8jlla8jll
    +a8jlla8jlla8jlla8jlla8jlla8jlla8jlla8jlla91)9aM7i["9AE(!!*!$!!*!$!!*!$!!*!$
    +!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$@/p^l%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +N/S2EMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuWi`?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ,PaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOm<0!5O'e!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!J)`L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eB
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^])$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=B!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk
    +!'pNk!'pNk!'pNk!'pNk!'pNk?pJ+-aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnt_
    +!5JR7rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWHU/"9]-h%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju
    +%#Bju%#Bju%#Bju%#Bju%#Bju%#Bjudu49W?!op*5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[ZibfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^@a8j#*"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98R\#`&bB
    +#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&nF%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J3t-oRL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JJ:DNG5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=
    +5[\P=5fhU!fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ4KRaOnSdaOnSdaOnSdrl>)lrl>)lrl>)lrl>)lrl>)l
    +rl>)lrl>)lrl>)lrl>)lrl>)lrl>2oaN2-u"9\`("98H$"98H$"98H$"98H$"98H$"98H$"98H$
    +"98H$"98H$"98H$"CV9=L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLl@9$repgcrepgc
    +repgcrepgcrepgcrepgcrepgcrepgcrepgcrepgcrepgW?!op*5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[ZibfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^@a8j#*"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98R\#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&nF%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J3t-oRL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JJ,hk6?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk
    +?iWIk?iWIk?sl6-@*%!AfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^oa8j!U!<**$!<**$
    +!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<*+/!!EE5L^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^])uL^])uL^])uL^])uL^])uL^])uL^])uL^])uL^])u
    +L^])uL^])uL^])uLsq:_J6cPJ?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b@+`QQaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdUrWPOIfo`S
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)L^8eBL^8eBL^8eBL^8eB
    +L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eFL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JLcZ$$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%"NhH?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pMfcaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnQBfZ;^dfZ;^dfZ;^df`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&ql
    +f`&qlf`&qlf`&qofZ;.LJ,~>
    +896 375 32 dvxColor
    +!X&W+rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWHX0%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#Bk$%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c
    +%/u1c%/u1c!*rtt?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b@+`QQ
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdUrWPOIfo`S!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)L^8eBL^8eBL^8eBL^8eBL^8eBL^8eB
    +L^8eBL^8eBL^8eBL^8eBL^8eBL^8eFL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLcZ$$
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%"J=C
    +ra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra9,!?sq(.aOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSde&VO)!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$
    +!!*!$!!*!$?iU<4%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +N/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/5rA!.\?_5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfVHRg^jcIe"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"Gd>>#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#?tRL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^\f`E*tq=E*tq=E*tq=E*tq=E*tq=
    +E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*trPfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d%+rYRaOnSdaOnSd
    +aOpL5!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l"5(]9^]FWA!<**$!<**$
    +!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<*+0!"=YlL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^])uN"#]E!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!!$;4
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfVHRg^jcIe"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"Gd>>#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB#`&bB#`&bB%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#?tRL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^\e65lP-k5lP-k5lP-k
    +5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP-k5lP/!5[\!@fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^df[InA^]4B6"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"98H$"CM34
    +":U(pL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLl@,uLl@,uLl@,u
    +Ll@,uLl@,uLl@,uLl@,uLl@,uLl@,uLl@,uLl@,uLl@/=Mu\B6?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ,PaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOm<0!5O'e!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!J)`L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^])$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=BIrJd\?uTL=?uTL=?uTL=?uTL=?uTL=?uTL=?uTL=
    +?uTL=?uTL=?uTL=?uTL=aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSda:-_BfZ;^dfZ;^dfZ;_Ea8jlla8jll
    +a8jlla8jlla8jlla8jlla8jlla8jlla8jlla8jlla91)9aM7i["9AE(!!*!$!!*!$!!*!$!!*!$
    +!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$@/p^l%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +N/S2EMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuX:cMuWi`?pJ*b?pJ*b?pJ*b
    +?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ,PaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOm<0!5O'e!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!J)`L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eB
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^])$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=B!'pNk!'pNk!'pNk!'pNk!'pNk!'pNk
    +!'pNk!'pNk!'pNk!'pNk!'pNk?pJ+-aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnt_
    +!5JR7rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWHU/"9]-h%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#Bju%#Bju%#Bju%#Bju%#Bju%#Bju
    +%#Bju%#Bju%#Bju%#Bju%#Bju%#Bjudu49W?!op*5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[ZibfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^@a8j#*"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98R\#`&bB
    +#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&nF%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J3t-oRL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JJ:DNG5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=5[\P=
    +5[\P=5fhU!fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ4KRaOnSdaOnSdaOnSdrl>)lrl>)lrl>)lrl>)lrl>)l
    +rl>)lrl>)lrl>)lrl>)lrl>)lrl>2oaN2-u"9\`("98H$"98H$"98H$"98H$"98H$"98H$"98H$
    +"98H$"98H$"98H$"CV9=L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLl@9$repgcrepgc
    +repgcrepgcrepgcrepgcrepgcrepgcrepgcrepgcrepgW?!op*5[Zib5[Zib5[Zib5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[ZibfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^@a8j#*"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98R\#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&nF%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J3t-oRL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JJ,hk6?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk?iWIk
    +?iWIk?iWIk?sl6-@*%!AfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^oa8j!U!<**$!<**$
    +!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<*+/!!EE5L^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^])uL^])uL^])uL^])uL^])uL^])uL^])uL^])uL^])u
    +L^])uL^])uL^])uLsq:_J6cPJ?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b
    +?pJ*b@+`QQaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdUrWPOIfo`S
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)L^8eBL^8eBL^8eBL^8eB
    +L^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eBL^8eFL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JLcZ$$%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%"NhH?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pJ+=?pMfcaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnQBfZ;^dfZ;^dfZ;^df`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&ql
    +f`&qlf`&qlf`&qofZ;.LJ,~>
    +928 375 32 dvxColor
    +!X&W+rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWE3$rWHX0%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#Bk$%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c%/u1c
    +%/u1c%/u1c!*rtt?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b?pJ*b@+`QQ
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdUrWPOIfo`S!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)L^8eBL^8eBL^8eBL^8eBL^8eBL^8eB
    +L^8eBL^8eBL^8eBL^8eBL^8eBL^8eFL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JLcZ$$
    +%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%"J=C
    +ra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra5^kra9,!?sq(.aOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSde&VO)!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$!!*!$
    +!!*!$!!*!$?iU<4%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J
    +N/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/.muN/5rA!.\?_5[Zib5[Zib
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfVHRg^jcIe"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"Gd>>#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB#`&bB
    +#`&bB%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#>=J%#?tRL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^\f`E*tq=E*tq=E*tq=E*tq=E*tq=
    +E*tq=E*tq=E*tq=E*tq=E*tq=E*tq=E*trPfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^d%+rYRaOnSdaOnSd
    +aOpL5!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l"5(]9^]FWA!<**$!<**$
    +!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<**$!<*+0!"=YlL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](JL^](J
    +L^](JL^](JL^])uN"#]E!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!"Ajc!!$;4
    +5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[Zib5[`6cfZ;^dfZ;^dfZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfVHRg^jcFo!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Lsi$NUV9$NUV9$NUV9
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&CQYf3gJ$O[=@$NLP5
    +$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$\&Ki$NUVA$NUVA$NUVA$NUVA$NUVA
    +$NUVA$NUVA$NUVA$NUVA$NUVA$NUVA$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUP7#lt>5#lt>5#lt>5#lt>5#lt>5#lt>5#lt>5#ltJA!=8c5!=8c5!=8c5
    +!.hAnY^Z`_!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!1$NU>1$NU>1!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CQYf3gH$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5
    +$N:E_$4@4B$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUP7#lt>5#lt>5#lt>5#lt>5
    +#lt>5#lt>5#lt>5%g`7C!=8c5!=8c5!=8c5!.hAnY^Z`_!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Lsi$NUV9$NUV9$NUV9!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&CQYf3gJ$O[=@$NLP5$NLP5
    +$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$\&Ki$NUVA$NUVA$NUVA$NUVA$NUVA$NUVA
    +$NUVA$NUVA$NUVA$NUVA$NUVA$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUP7#lt>5#lt>5#lt>5#lt>5#lt>5#lt>5#lt>5#ltJA!=8c5!=8c5!=8c5!.hAn
    +Y^Z`_!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!1$NU>1$NU>1!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&CQYf3gH$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:D5$N:E_
    +$4@4B$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$3:M8$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUP7#lt>5#lt>5#lt>5#lt>5#lt>5
    +#lt>5#lt>5%g`7C!=8c5!=8c5!=8c5!.hAnY^Z`_!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Lsi$NUV9$NUV9$NUV9!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&CQYf3gJ$O[=@$NLP5$NLP5$NLP5
    +$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$NLP5$\&Ki$NUVA$NUVA$NUVA$NUVA$NUVA$NUVA$NUVA
    +$NUVA$NUVA$NUVA$NUVA$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUP7#lt>5#lt>5#lt>5#lt>5#lt>5#lt>5#lt>5#ltJA!=8c5!=8c5!=8c5!.hAnY^Z`_
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=
    +!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!t,2=!1$NU>1$NU>1!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&CQYf3hs~>
    +960 375 32 dvxColor
    +!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5IgQPk!=Ai8!=Ai8!=Ai8
    +!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9
    +!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo7!=8c5!=8c5!=8c5!=8c5!=8c5!=8c5!=8c5!=oJ;#lt>5
    +#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1
    +!=Jo1!5#lt>5
    +#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1!=Jo1
    +!5#lt>5#lt>5
    +#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1!=Jo1!5#lt>5#lt>5#loO\
    +`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1!=Jo1!
    +992 375 32 dvxColor
    +!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5IgQPk!=Ai8!=Ai8!=Ai8
    +!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9
    +!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo7!=8c5!=8c5!=8c5!=8c5!=8c5!=8c5!=8c5!=oJ;#lt>5
    +#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1
    +!=Jo1!5#lt>5
    +#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1!=Jo1
    +!5#lt>5#lt>5
    +#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1!=Jo1!5#lt>5#lt>5#loO\
    +`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1!=Jo1!
    +1024 375 32 dvxColor
    +!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5!"/]5IgQPk!=Ai8!=Ai8!=Ai8
    +!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Ai8!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9
    +!=Jo9!=Jo9!=Jo9!=Jo9!=Jo9!=Jo7!=8c5!=8c5!=8c5!=8c5!=8c5!=8c5!=8c5!=oJ;#lt>5
    +#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$NUV9$NUV9$NUV9
    +$NUV9$NUV9$NUV9$NUV9$NUV9$NUVs!=Jo9!=Jo9!=Jo9!5#lt>5#lt>5#loO\`NXgo$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=$Ngb=
    +$Ngb=$Ngb=$Ngb=$Ngb=$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>1$NU>k!=Jo1!=Jo1
    +!=Jo1!EE!!(HO?BDCQY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q(#rf@*krf@*krf@*krf@*krf@*krf@*krf@*krf@*k
    +rf@*krf@*krf@0mOH>"o`W6&>!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-
    +!3uO-!3uO-J$H43Yd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DEYS")O
    +YS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")O
    +YS")OYS")OYS")OYS")OYR;&I&[2kO&[2kO&[26ArW*!*rW*!*rW*!*rW*!*rW*!*rW*!*rW*!*
    +rW*!*rW*!*rW*!*rW*!*JH>?C!!(HO@$%USY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +O:_DQ!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"a\^`W6&>c`dDSricC-
    +ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-rif[jYRn%$YRn%$YRn%$YRn%$YRn%$
    +YRn%$YRn%$YRn%$YRn%$YRn%$YRn%$YRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#N
    +YRn#NYRn#NYRn#NYQ=h+$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*
    +$%W-M!<<,JY[3EqOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN&H7ak&H7ak&H7ak&H7ak
    +&H7ak&H7ak&H7ak&H7ak&H7ak&H7ak&H7am&
    +1056 375 32 dvxColor
    +!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!""2Wo)Sgkd]d,@Y`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`OqXrf@*krf@*krf@*krf@*krf@*krf@*krf@*k
    +rf@*krf@*krf@*krf@*kJHEE!!(HO?BDCQ
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q(#rf@*krf@*krf@*krf@*krf@*krf@*krf@*k
    +rf@*krf@*krf@*krf@0mOH>"o`W6&>!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-
    +!3uO-!3uO-!3uO-J$H43Yd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DE
    +YS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")O
    +YS")OYS")OYS")OYS")OYS")OYR;&I&[2kO&[2kO&[26ArW*!*rW*!*rW*!*rW*!*rW*!*rW*!*
    +rW*!*rW*!*rW*!*rW*!*rW*!*JH>?C!!(HO@$%USY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NO:_DQ!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"a\^`W6&>c`dDS
    +ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-rif[jYRn%$YRn%$YRn%$YRn%$
    +YRn%$YRn%$YRn%$YRn%$YRn%$YRn%$YRn%$YRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#N
    +YRn#NYRn#NYRn#NYRn#NYQ=h+$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*
    +$2sr*$%W-M!<<,JY[3EqOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN&H7ak&H7ak&H7ak
    +&H7ak&H7ak&H7ak&H7ak&H7ak&H7ak&H7ak&H7am&$2sr*$2sr*
    +$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$%W-K!<<,JY[EQsOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOH:%QO8ojkO8ojkO8ojkO8ojkO8ojkO8ojkO8ojkO8ojkO8ojk
    +O8ojkO8oiC!QG-?!7.kLZ2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Ut5
    +Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$&[2hN&[2hN&[2hN
    +&[2hN&[2hN&[2hN&[2hN&[2hN&[2hN&[2hN&[2hN&[2<;!=A`+!"&W*!"&W*!"&W*!"&W*!"&W*
    +!"&W*!"&W*!"&W*!"&W*!"&W*!""2Wo`5$md]d&>Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NYRpeq!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!YCHTJH
    +1088 375 32 dvxColor
    +!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!""2Wo)Sgkd]d,@Y`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`OqXrf@*krf@*krf@*krf@*krf@*krf@*krf@*k
    +rf@*krf@*krf@*krf@*kJHEE!!(HO?BDCQ
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q(#rf@*krf@*krf@*krf@*krf@*krf@*krf@*k
    +rf@*krf@*krf@*krf@0mOH>"o`W6&>!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-!3uO-
    +!3uO-!3uO-!3uO-J$H43Yd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DdYd(DE
    +YS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")OYS")O
    +YS")OYS")OYS")OYS")OYS")OYR;&I&[2kO&[2kO&[26ArW*!*rW*!*rW*!*rW*!*rW*!*rW*!*
    +rW*!*rW*!*rW*!*rW*!*rW*!*JH>?C!!(HO@$%USY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NO:_DQ!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"a\^`W6&>c`dDS
    +ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-ricC-rif[jYRn%$YRn%$YRn%$YRn%$
    +YRn%$YRn%$YRn%$YRn%$YRn%$YRn%$YRn%$YRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#NYRn#N
    +YRn#NYRn#NYRn#NYRn#NYQ=h+$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*
    +$2sr*$%W-M!<<,JY[3EqOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN&H7ak&H7ak&H7ak
    +&H7ak&H7ak&H7ak&H7ak&H7ak&H7ak&H7ak&H7am&$2sr*$2sr*
    +$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$2sr*$%W-K!<<,JY[EQsOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahNOKahN
    +OKahNOKahNOKahNOKahNOKahNOH:%QO8ojkO8ojkO8ojkO8ojkO8ojkO8ojkO8ojkO8ojkO8ojk
    +O8ojkO8oiC!QG-?!7.kLZ2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Us-Z2Ut5
    +Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$Og#D$&[2hN&[2hN&[2hN
    +&[2hN&[2hN&[2hN&[2hN&[2hN&[2hN&[2hN&[2hN&[2<;!=A`+!"&W*!"&W*!"&W*!"&W*!"&W*
    +!"&W*!"&W*!"&W*!"&W*!"&W*!""2Wo`5$md]d&>Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NYRpeq!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!"f-k!YCHTJH
    +1120 375 32 dvxColor
    +!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!"&W*!""2Wo)Sgkd]d,@Y`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)N
    +Y`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`Q)NY`OqXrf@*krf@*krf@*krf@*krf@*krf@*krf@*k
    +rf@*krf@*krf@*krf@*kJHlXm*!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c%$8ht:ls%c:ls%c
    +:ls,PJH>BD!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!WW6$Ih@bS
    +O:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$8O?6:#:lnM8:lnM8:lnM8d#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(c
    +d#_(cd#b"'!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!/H>eO
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[N#OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9%$8htd#cV9
    +d#cV9d#c]&JH>prW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!
    +rW+SN!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO+Dt
    +d#cV9d#cV9d#cV9da3OGnc8^jrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!
    +rW*!!Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQcO:dQc
    +O:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dRNOAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$8OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8
    +:lnM8:lnM8:lnM8:lqFQ>lXm*!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +%$8ht:ls%c:ls%c:ls,PJH>BD!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +!!*!!!WW6$Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOAM$8OAM$8OAM$8OAM$8
    +OAM$8OAM$8OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8d#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(c
    +d#_(cd#_(cd#_(cd#_(cd#b"'!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +!!*!!!!*!!/H>eO!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[N#OAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9
    +OO0*9%$8htd#cV9d#cV9d#c]&JH>
    +1152 375 32 dvxColor
    +UB+X#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[KbO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAH@Id#_(cd#_(cd#_(c
    +da3OGoDnplrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*'#!<@T]O:dQd
    +O:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQcO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$#:lnM8:lnM8:lnM8:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c
    +:ls%cUB(>prW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW+SN!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO+Dtd#cV9d#cV9
    +d#cV9da3OGnc8^jrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!Ih@bS
    +O:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQcO:dQcO:dQcO:dQc
    +O:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dRNOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$8O?6:#:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8
    +:lnM8:lqFQ>lXm*!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c%$8ht:ls%c
    +:ls%c:ls,PJH>BD!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!WW6$
    +Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8d#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(c
    +d#_(cd#_(cd#b"'!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +/H>eO!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[N#OAM%cOAM%cOAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9%$8ht
    +d#cV9d#cV9d#c]&JH>prW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!
    +rW*!!rW+SN!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9
    +OO+Dtd#cV9d#cV9d#cV9da3OGnc8^jrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!
    +rW*!!rW*!!Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQc
    +O:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dRNOAM$8OAM$8OAM$8OAM$8
    +OAM$8OAM$8OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8
    +:lnM8:lnM8:lnM8:lnM8:lqFQ>lXm*!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(c%$8ht:ls%c:ls%c:ls,PJH>BD!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +!!*!!!!*!!!WW6$Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQd
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOAM$8OAM$8OAM$8
    +OAM$8OAM$8OAM$8OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8d#_(cd#_(cd#_(cd#_(cd#_(cd#_(c
    +d#_(cd#_(cd#_(cd#_(cd#_(cd#b"'!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +!!*!!!!*!!!!*!!/H>eO!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+L
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[N#OAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9
    +OO0*9OO0*9%$8htd#cV9d#cV9d#c]&JH>
    +1184 375 32 dvxColor
    +UB+X#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[KbO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAH@Id#_(cd#_(cd#_(c
    +da3OGoDnplrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*'#!<@T]O:dQd
    +O:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQcO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$#:lnM8:lnM8:lnM8:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c:ls%c
    +:ls%cUB(>prW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW+SN!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO+Dtd#cV9d#cV9
    +d#cV9da3OGnc8^jrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!Ih@bS
    +O:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQcO:dQcO:dQcO:dQc
    +O:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dRNOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$8O?6:#:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8
    +:lnM8:lqFQ>lXm*!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c%$8ht:ls%c
    +:ls%c:ls,PJH>BD!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!WW6$
    +Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOAM$8OAM$8OAM$8OAM$8OAM$8OAM$8
    +OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8d#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(cd#_(c
    +d#_(cd#_(cd#b"'!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +/H>eO!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[N#OAM%cOAM%cOAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9%$8ht
    +d#cV9d#cV9d#c]&JH>prW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!
    +rW*!!rW+SN!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9OO0*9
    +OO+Dtd#cV9d#cV9d#cV9da3OGnc8^jrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!
    +rW*!!rW*!!Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQc
    +O:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dQcO:dRNOAM$8OAM$8OAM$8OAM$8
    +OAM$8OAM$8OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8:lnM8
    +:lnM8:lnM8:lnM8:lnM8:lqFQ>lXm*!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+LO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOO0(cOO0(cOO0(cOO0(c
    +OO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(cOO0(c
    +OO0(c%$8ht:ls%c:ls%c:ls,PJH>BD!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +!!*!!!!*!!!WW6$Ih@bSO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQdO:dQd
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[LMOAM$8OAM$8OAM$8
    +OAM$8OAM$8OAM$8OAM$8OAM$8O?6:#:lnM8:lnM8:lnM8d#_(cd#_(cd#_(cd#_(cd#_(cd#_(c
    +d#_(cd#_(cd#_(cd#_(cd#_(cd#b"'!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!!!*!!
    +!!*!!!!*!!!!*!!/H>eO!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<+L
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[KbO:[N#OAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOO0*9OO0*9OO0*9OO0*9OO0*9OO0*9
    +OO0*9OO0*9%$8htd#cV9d#cV9d#c]&JH>
    +1216 375 32 dvxColor
    +UB+X#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!.U(AO:[KbO:[KbO:[KbO:[KbO:[Kb
    +O:[KbO:[KbO:[KbO:[KbO:[KbO:[KbOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%c
    +OAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAM%cOAH@Id#_(cd#_(cd#_(c
    +da3OGoDnplrW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*!!rW*'#!<<*rrm1XA
    +rm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XAJ^Eu7IfKKL!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"?QSd#Qt,1
    +#Qt,1#Qt,1$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$ikk*o?[KQ
    +rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1\]d!l&r!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"9:Ld!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9'd*PVg
    +ci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?mnd.G]a!!!'!!!!'!!!!'!
    +!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!*o^-"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE<3"9\^Ud.>U9
    +^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sIQ3!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9`"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)!)W[A
    +!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)S6no$Djd!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)":,,1":,,1":,,1":,,1":,,1":,,1":,,1":,,1":,,l!!EQ1
    +!!EQ1!!EQ1!!E]5$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6t=J^F#8
    +!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!Pm'3!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)3s5Nd
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!:No
    +;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA:k@!cd/F"F!WE'!!WE'!
    +!WE'!!WE'!!WE'!!WE'!!WE'!!WE'!!WE'!!WE'!!WE()"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"9AE(!"Ai0!"Ai0!"Ai0!"Ai0!"Ai0!"Ai0!"Ai0!"Ai0!"Ai0!"Ai0!t5;8!.iMg
    +cn3&[^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmDIfTNM!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!':;`
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Eu
    +rm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XAJ^Eu7IfKKL!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"98Q1"?QSd
    +#Qt,1#Qt,1#Qt,1$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$ikk*
    +o?[KQrm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1\]d!l&r!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"9:Ld
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9'
    +d*PVgci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?mnd.G]a!!!'!!!!'!
    +!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!!!'!!*o^-"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE30rWE<3"9\^U
    +d.>U9^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sIQ3!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9`
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)S6no$Djd!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)":,,1":,,1":,,1":,,1":,,1":,,1":,,1":,,1":,,l
    +!!EQ1!!EQ1!!EQ1!!E]5$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6t=
    +J^F#8!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!5ST\!Pm'3!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +3s5Nd!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!:No;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA:k@!cd!u~>
    +1248 375 32 dvxColor
    +rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rW$4,!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!<**$%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA3$j6\5J^F#8/\o`Kd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n"X
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)3s5Nd!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!#jpci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?mnd.5P5
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1
    +!!EQ1!!EQ13rfBh"98Q1"98Q1"98Q5":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA!.iMgciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciLr/
    +IfTNM!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!':;`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98MMcqs_g!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)S6n
    +o[*R;!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!>m(-1!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E<$"98l0"98l0"98l0"98l0"98l0"98l0"98l0"98l0"98l0
    +"98l0"9T&8"9=#"o?]))d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d!l&r!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"9:Ld!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9%;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA:k@!a
    +d!l#r!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)#Qt,1#Qt,1#Qt,1#Qt,1#Qt,1#Qt,1
    +#Qt,1#Qt,1#R!3h":,,1":,,1":,,1":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":P9]d.>Ta_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[]
    +^sIQ3!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)!mo:jrm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XA
    +J^F&9rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rW$4,!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!<**$%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA3$j6\5J^F#8/\o`Kd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n"X!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)3s5Nd!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!#jpci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?mn
    +d.5P5!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1
    +!!EQ1!!EQ1!!EQ13rfBh"98Q1"98Q1"98Q5":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA!.iMgciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\
    +ciLr/IfTNM!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!':;`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98MMcqs_g!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A
    +!)S6no[*R;!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!>m(-1!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E<$"98l0"98l0"98l0"98l0"98l0"98l0"98l0"98l0
    +"98l0"98l0"9T&8"9=#"o?]))d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n#Dd!l&r!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"9:Ld!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9%;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA
    +:k@!ad!l#r!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)#Qt,1#Qt,1#Qt,1#Qt,1#Qt,1
    +#Qt,1#Qt,1#Qt,1#R!3h":,,1":,,1":,,1":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":P9]d.>Ta_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\
    +_#D[]^sIQ3!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)!mo:jrm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XA
    +rm1XAJ^F&9J,~>
    +1280 375 32 dvxColor
    +rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rW$4,!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!<**$%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA3$j6\5J^F#8/\o`Kd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n"X
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)3s5Nd!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!#jpci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?mnd.5P5
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1
    +!!EQ1!!EQ13rfBh"98Q1"98Q1"98Q5":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA!.iMgciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciLr/
    +IfTNM!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!':;`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98MMcqs_g!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)S6n
    +o[*R;!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!>m(-1!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E<$"98l0"98l0"98l0"98l0"98l0"98l0"98l0"98l0"98l0
    +"98l0"9T&8"9=#"o?]))d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d!l&r!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"9:Ld!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9%;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA:k@!a
    +d!l#r!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)#Qt,1#Qt,1#Qt,1#Qt,1#Qt,1#Qt,1
    +#Qt,1#Qt,1#R!3h":,,1":,,1":,,1":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":P9]d.>Ta_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[]
    +^sIQ3!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)!mo:jrm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XA
    +J^F&9rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rW$4,!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!<**$%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA3$j6\5J^F#8/\o`Kd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n"X!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<6&"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)3s5Nd!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!#jpci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?oAci?mn
    +d.5P5!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1!!EQ1
    +!!EQ1!!EQ1!!EQ13rfBh"98Q1"98Q1"98Q5":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA!.iMgciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\
    +ciLr/IfTNM!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'&!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!':;`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98MMcqs_g!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A!)W[A
    +!)S6no[*R;!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!!!3'!>m(-1!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E<$"98l0"98l0"98l0"98l0"98l0"98l0"98l0"98l0
    +"98l0"98l0"9T&8"9=#"o?]))d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n#Dd!l&r!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#
    +!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"9:Ld!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9%;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA;#\fA
    +:k@!ad!l#r!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)#Qt,1#Qt,1#Qt,1#Qt,1#Qt,1
    +#Qt,1#Qt,1#Qt,1#R!3h":,,1":,,1":,,1":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":P9]d.>Ta_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\
    +_#D[]^sIQ3!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9`"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)"98Q)
    +"98Q)"98Q)"98Q)"98Q)!mo:jrm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XArm1XA
    +rm1XAJ^F&9J,~>
    +1312 375 32 dvxColor
    +rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rVup!rW$4,!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)!!E9)
    +!!E9)!!E9)!!E9)!!E9)!!E9)!<**$%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA3$j6\5J&'FHd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#DeA0#<
    +eA0#RU`D":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA"98JL_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\
    +_#D]1^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sp0<^sp0<
    +^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^r4%,^r4%,^r4%,^r4%,^r4%,
    +^r4%,^r4%,^r4%,%`$#u`5'=,`5'=,`5'=,IgcGg":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":YbB$j6tA$j6tA$j6tArX8c0rX8c0
    +rX8c0rX8c0rX8c0rX8c0rX8c0rX8c0rX8c0rX8c0rX8i2$ikdmd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n/HeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SL
    +eA0SLeA0SLeA0SLeA0#<`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5';"^r4%,^r4%,^r4%,
    +^sg4%^sE2a%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA^$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6S0ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\
    +ciCl\ciCl\ciCl\d!rjX^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD
    +^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^r4%,^r4%,^r4%,
    +^r4%,^r4%,^r4%,^r4%,^r4%,^r-Ys`5'=,`5'=,`5'=,IgcGg":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA"?R"p$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$ikdmd(n#Dd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n/H`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<
    +`5'm<`5'm<`5'm<`5'm<`5'm<`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5';"^r4%,
    +^r4%,^r4%,^sg4%^sW>c%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pB7$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA!!1H^rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\
    +rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm6/1d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#DeA0#1)c$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA!<1=F_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D]1^sKmD^sKmD
    +^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmH^sp0L^sp0L^sp0L^sp0L^sp0L
    +^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0<^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,
    +%)Bfs`5'=,`5'=,`5$m~>
    +1344 375 32 dvxColor
    +IgcGg":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA"?R"p$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$ikdmd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n/H
    +`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'=,`5'=,`5'=,`5'=,
    +`5'=,`5'=,`5'=,`5'=,`5';"^r4%,^r4%,^r4%,^sg4%^sW>c%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pB7$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +!!1H^rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm6/1d(n#Dd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#DeA0#1)c$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA!<1=F_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\
    +_#D[\_#D[\_#D[\_#D]1^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD
    +^sKmH^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0<^r4%,^r4%,
    +^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,%)Bfs`5'=,`5'=,`5$j2":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDp$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6t=J&'FHd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#DeA0#RU`D":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA"98JL
    +_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D]1^sKmD^sKmD^sKmD^sKmD
    +^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<
    +^sp0<^sp0<^sp0<^sp0<^sp0<^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,%`$#u`5'=,
    +`5'=,`5'=,IgcGg":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":YbB$j6tA$j6tA$j6tArX8c0rX8c0rX8c0rX8c0rX8c0rX8c0rX8c0
    +rX8c0rX8c0rX8c0rX8i2$ikdmd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n#Dd(n/HeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0#<`5'=,
    +`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5';"^r4%,^r4%,^r4%,^sg4%^sE2a%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA^$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6S0ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\ciCl\d!rjX^sKmD
    +^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sp0L^sp0L^sp0L^sp0L^sp0L
    +^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,
    +^r-Ys`5'=,`5'=,`5'=,J,~>
    +1376 375 32 dvxColor
    +IgcGg":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA"?R"p$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$ikdmd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n/H
    +`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'm<`5'=,`5'=,`5'=,`5'=,
    +`5'=,`5'=,`5'=,`5'=,`5';"^r4%,^r4%,^r4%,^sg4%^sW>c%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pB7$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +!!1H^rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm1Y\rm6/1d(n#Dd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#DeA0#1)c$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA!<1=F_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\
    +_#D[\_#D[\_#D[\_#D]1^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD
    +^sKmH^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0L^sp0<^r4%,^r4%,
    +^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,%)Bfs`5'=,`5'=,`5$j2":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDp$j6tA$j6tA$j6tA
    +$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6t=J&'FHd(n#Dd(n#D
    +d(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#DeA0#RU`D":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA"98JL
    +_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D[\_#D]1^sKmD^sKmD^sKmD^sKmD
    +^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sKmD^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<^sp0<
    +^sp0<^sp0<^sp0<^sp0<^sp0<^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,^r4%,%`$#u`5'=,
    +`5'=,`5'=,IgcGg":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA":PDA
    +":PDA":PDA":PDA":PDA":YbB$j6tA$j6tA$j6tArX8c0rX8c0rX8c0rX8c0rX8c0rX8c0rX8c0
    +rX8c0rX8c0rX8c0rX8i2$ikdmd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#Dd(n#D
    +d(n#Dd(n/HeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0SLeA0#<`5'=,
    +`5'=,`5'=,`5'=,`5'=,`5'=,`5'=,`5';"^r4%,^r4%,^r4%,^sg4%^sE2a%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0%/pA0
    +%/pA0%/pA0%/pA0%/pA0%/pA^$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA$j6tA
    +$j6tA$j6Q;E,oo
    +1408 375 32 dvxColor
    +/cl4Y#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2rWW?(rWW?(rWW?(
    +rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(T`tJs!cW+!rbqjXrbqjXrbqjXrbqjXrbqjX
    +rbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjX
    +rbqjXrbqjXTQ#A_!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc2!XAc2!XAc2!XAc2
    +!XAc2!XAc2!XAc2!XAc2!XAcg"p,#2"p,#2"p,#2"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6
    +"pP;6"pP;6"pP;6"pP;6"pP;4IrKFIEc?&E,tqs!-8'X!-8'X!-8'X
    +!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X
    +!-8'X!-8'X!-8'X!-4`OIff`W!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/
    +!WrK/!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!X8]2!X8]2
    +!X8]2!X8]2!X8]2!X8]2!X8]2!X8]2!]pSf#6G)2#6G)2#6G)6#6G)6#6G)6#6G)6#6G)6#6G)6
    +#6G)6#6G)6#6G)6#6G)6#6G)6#6G)6#696mEc?&rbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXJT11!Ig6#W!XAc/
    +!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc3!XAc3!XAc3!XAc3!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3
    +!]pVg#6G,3#6G,3#6G,3"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6
    +"or"LEc?&u*!XAc3!XAc3!XAc3!XAc3!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2
    +!XAc2!XAc2!XAc2!XAo6!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(
    +!n.>R!!%'=DuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuap0E:Rst#6Fu/
    +#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6G,3#6G,3#6G,3#6G,3
    +#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3
    +#6G,33sPfl!XAc3!XAc3!XAc2"pY26"pY26"pY26"pY26"pY26"pY26"pY26"pY26"pY26"pY26
    +"pY26!+0\cE,oo
    +1440 375 32 dvxColor
    +/cl4Y#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2rWW?(rWW?(rWW?(
    +rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(T`tJs!cW+!rbqjXrbqjXrbqjXrbqjXrbqjX
    +rbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjX
    +rbqjXrbqjXTQ#A_!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc2!XAc2!XAc2!XAc2
    +!XAc2!XAc2!XAc2!XAc2!XAcg"p,#2"p,#2"p,#2"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6
    +"pP;6"pP;6"pP;6"pP;6"pP;4IrKFIEc?&E,tqs!-8'X!-8'X!-8'X
    +!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X
    +!-8'X!-8'X!-8'X!-4`OIff`W!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/
    +!WrK/!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!X8]2!X8]2
    +!X8]2!X8]2!X8]2!X8]2!X8]2!X8]2!]pSf#6G)2#6G)2#6G)6#6G)6#6G)6#6G)6#6G)6#6G)6
    +#6G)6#6G)6#6G)6#6G)6#6G)6#6G)6#696mEc?&rbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXJT11!Ig6#W!XAc/
    +!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc3!XAc3!XAc3!XAc3!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3
    +!]pVg#6G,3#6G,3#6G,3"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6
    +"or"LEc?&u*!XAc3!XAc3!XAc3!XAc3!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2!XAc2
    +!XAc2!XAc2!XAc2!XAo6!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(!r`B(
    +!n.>R!!%'=DuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuaqXDuap0E:Rst#6Fu/
    +#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6Fu/#6G,3#6G,3#6G,3#6G,3
    +#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3#6G,3
    +#6G,33sPfl!XAc3!XAc3!XAc2"pY26"pY26"pY26"pY26"pY26"pY26"pY26"pY26"pY26"pY26
    +"pY26!+0\cE,oo
    +1472 375 32 dvxColor
    +/cl4Y#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2#6G)2rWW?(rWW?(rWW?(
    +rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(rWW?(T`tJs!cW+!rbqjXrbqjXrbqjXrbqjXrbqjX
    +rbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjX
    +rbqjXrbqjXTQ#A_!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc2!XAc2!XAc2!XAc2
    +!XAc2!XAc2!XAc2!XAc2!XAcg"p,#2"p,#2"p,#2"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6
    +"pP;6"pP;6"pP;6"pP;6"pP;4IrKFIEc?&E,tqs!-8'X!-8'X!-8'X
    +!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X!-8'X
    +!-8'X!-8'X!-8'X!-4`OIff`W!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/!WrK/
    +!WrK/!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!X8]2!X8]2
    +!X8]2!X8]2!X8]2!X8]2!X8]2!X8]2!]pSf#6G)2#6G)2#6G)6#6G)6#6G)6#6G)6#6G)6#6G)6
    +#6G)6#6G)6#6G)6#6G)6#6G)6#6G)6#696mEc?&rbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXrbqjXJT11!Ig6#W!XAc/
    +!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc/!XAc3!XAc3!XAc3!XAc3!XAc3
    +!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3!XAc3
    +!]pVg#6G,3#6G,3#6G,3"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6"pP;6
    +"or"LEc?&
    +1504 375 32 dvxColor
    +U':T#rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWY%Z"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,1dVs\X@!$@0@!$@0@!$@0@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#cbFC92uFC92uFC92u
    +E.J55"onXSE:e*%@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$A7
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*#$qE:"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5!75\7FC9',FC9',FC9',FC9',FC9',FC9',FC9',FC9',FC9',
    +FC9',FC9',FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u$?TZt@!#du@!#dqJHj*u!!&^3!-J3Z!-J3Z
    +!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z06D/TE--&@E--&@E--&@E--&@E--&@
    +E--&@E--&@E--&@E--&@E--&@E--&@E2!O:"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TATZ"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +!7Gh9FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930FC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92u%!5m!@!#du@!#du?uUZ$`WcDCJT17#/mrd?FC930FC930FC930FC930FC930
    +FC930FC930FC930FC930FC930FC930cisIQrWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWZ[3"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p!"hIro(;E-,K,E-,K,
    +E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K0@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du?j_!`
    +FC92uFC92uE.J55"onXsDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"Z
    +E%c@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FE!rY!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*0E_R_"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p!(jIro(?E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0
    +E-,K0E-,K0E-,K0E-,K0@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du?jq-bFC92uFC92uFC9'=#0$iI!.f+]
    +E%YO?E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E6ndh"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAU3"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nkQE.;t;E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC5Gt@!#du@!#du?uUZ$`WcDCTl:!RrbqjZrbqjZrbqjZrbqjZ
    +rbqjZrbqjZrbqjZrbqjZrbqjZrbqjZrbsN4E--&@E--&@E--&@E--&@E--&@E--&@E--&@E--&@
    +E--&@E--&@E--&@E--&n"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"u$/a"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nkSE.;t;FDu>0
    +FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC5N!@!#du@!#du@!#dqJHj*u!!%RhoPcEjFDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0
    +FDu>0FDu>0FDu>0FE#S2!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*
    +?3CH8"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,1cu=JV?uU(,?uU(,?uU(,?uU(,?uU(,
    +?uU(,?uU(,?uU(,?uU(,?uU(,?uU(,@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#c`FC92uFC92uFC9'=
    +#0$iI!24?3FT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-54FDu>@FDu>@
    +FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@J,~>
    +1536 375 32 dvxColor
    +U':T#rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWY%Z"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,1dVs\X@!$@0@!$@0@!$@0@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#cbFC92uFC92uFC92u
    +E.J55"onXSE:e*%@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$A7
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*#$qE:"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5!75\7FC9',FC9',FC9',FC9',FC9',FC9',FC9',FC9',FC9',
    +FC9',FC9',FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u$?TZt@!#du@!#dqJHj*u!!&^3!-J3Z!-J3Z
    +!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z06D/TE--&@E--&@E--&@E--&@E--&@
    +E--&@E--&@E--&@E--&@E--&@E--&@E2!O:"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TATZ"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +!7Gh9FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930FC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92u%!5m!@!#du@!#du?uUZ$`WcDCJT17#/mrd?FC930FC930FC930FC930FC930
    +FC930FC930FC930FC930FC930FC930cisIQrWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWZ[3"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p!"hIro(;E-,K,E-,K,
    +E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K0@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du?j_!`
    +FC92uFC92uE.J55"onXsDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"Z
    +E%c@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FE!rY!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*0E_R_"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p!(jIro(?E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0
    +E-,K0E-,K0E-,K0E-,K0@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du?jq-bFC92uFC92uFC9'=#0$iI!.f+]
    +E%YO?E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E6ndh"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAU3"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nkQE.;t;E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC5Gt@!#du@!#du?uUZ$`WcDCTl:!RrbqjZrbqjZrbqjZrbqjZ
    +rbqjZrbqjZrbqjZrbqjZrbqjZrbqjZrbsN4E--&@E--&@E--&@E--&@E--&@E--&@E--&@E--&@
    +E--&@E--&@E--&@E--&n"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"u$/a"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nkSE.;t;FDu>0
    +FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC5N!@!#du@!#du@!#dqJHj*u!!%RhoPcEjFDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0
    +FDu>0FDu>0FDu>0FE#S2!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*
    +?3CH8"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,1cu=JV?uU(,?uU(,?uU(,?uU(,?uU(,
    +?uU(,?uU(,?uU(,?uU(,?uU(,?uU(,@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#c`FC92uFC92uFC9'=
    +#0$iI!24?3FT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-4ZFT-54FDu>@FDu>@
    +FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@J,~>
    +1568 375 32 dvxColor
    +U':T#rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWY%Z"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,1dVs\X@!$@0@!$@0@!$@0@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#cbFC92uFC92uFC92u
    +E.J55"onXSE:e*%@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$A7
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*#$qE:"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5!75\7FC9',FC9',FC9',FC9',FC9',FC9',FC9',FC9',FC9',
    +FC9',FC9',FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u$?TZt@!#du@!#dqJHj*u!!&^3!-J3Z!-J3Z
    +!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z!-J3Z06D/TE--&@E--&@E--&@E--&@E--&@
    +E--&@E--&@E--&@E--&@E--&@E--&@E2!O:"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TATZ"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +!7Gh9FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930FC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92u%!5m!@!#du@!#du?uUZ$`WcDCJT17#/mrd?FC930FC930FC930FC930FC930
    +FC930FC930FC930FC930FC930FC930cisIQrWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWZ[3"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p!"hIro(;E-,K,E-,K,
    +E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K,E-,K0@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du?j_!`
    +FC92uFC92uE.J55"onXsDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"ZDub"Z
    +E%c@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FDu>@FE!rY!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*0E_R_"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p!(jIro(?E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0
    +E-,K0E-,K0E-,K0E-,K0@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du?jq-bFC92uFC92uFC9'=#0$iI!.f+]
    +E%YO?E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E-,K0E6ndh"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAU3"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nkQE.;t;E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,E,]o,
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC5Gt@!#du@!#du?uUZ$`WcDCTl:!RrbqjZrbqjZrbqjZrbqjZ
    +rbqjZrbqjZrbqjZrbqjZrbqjZrbqjZrbsN4E--&@E--&@E--&@E--&@E--&@E--&@E--&@E--&@
    +E--&@E--&@E--&@E--&n"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"u$/a"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nkSE.;t;FDu>0
    +FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FDu>0FC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC5N!@!#du@!#du@!#dq!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*?N^Q9"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5!.T)uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92uFC92u
    +FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC71PA9;4$A9;4$A9;4$A9;@(A9;@(A9;@(
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;3a"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5#6"`*#6"`*#6"`*#6"`*#6"`*#6"`*#6"`*#6"`*#6"`*#6"`*#6"`*#6"`Y#"/Rs
    +#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Ro?!qbbFC93$FC93$FC93$
    +FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC]K(FC]K(FC]K(FC]K(FC]LmA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`N
    +A,p_uA,p!="pP50"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*#(Ha["9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9o;<"p>,5"p>,5"p>,5!.T*0FC930FC930FC930FC930FC930FC930FC930FC930FC930FC930
    +FC930FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K(FC]K(
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC[ITA9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;40~>
    +1600 375 32 dvxColor
    +Ig-#["9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"?Qej"p>,5"p>,5"p>,5#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs
    +#"/Rs#"/Rs#!ahPFC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC94i
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_GA,p!;"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAU4"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu/Iro(?
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +@!#q$?orI@FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC]K(FC8>X"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5rWE3*rWE3*rWE3*rWE3*rWE3*
    +rWE3*rWE3*rWE3*rWE3*rWE3*rWE3*rWFkY6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs
    +6j*Vs6j*Vs6j*Vs6j*Kq@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +@!#q$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9@f]!-J3N!-J3N
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu!+5j4"pFo0!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*IforY"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"q:\>"9nu5"9nu5"9nu/IroXO@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A34mD
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC9S&
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nuj"p>,5"p>,5"p>,5"p>,u"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os
    +"9\os"9\oo04]04FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFJ<:G?iU?/"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*#%%K;"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9Qt<8"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5":"i.!!`E*!!`E*!!`E*!!`E*!!`E*
    +!!`E*!!`E*!!`E*!!`E*!!`E*!!`E*0*XfI"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s
    +"9q6s"9q6s"9q6s!*ruSA9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(raZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct!,a!X8i3rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWW?*rWW?*rWW?*rW[iT"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,>"9nu5"9nu5"9nu5"9,5"p>,5"p>,5#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs
    +#"/Rs#!ahPFC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC94iA,p`N
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_GA,p!;"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAU4"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu/Iro(?@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +?orI@FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC8>X"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5rWE3*rWE3*rWE3*rWE3*rWE3*rWE3*
    +rWE3*rWE3*rWE3*rWE3*rWE3*rWFkY6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs
    +6j*Vs6j*Vs6j*Kq@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9@f]!-J3N!-J3N!-J3N
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu!+5j4"pFo0!!N9*!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*IforY"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"q:\>"9nu5"9nu5"9nu/IroXO@!$@0@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A34mDFC]K(
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC9S&"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nuj"p>,5"p>,5"p>,5"p>,u"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os
    +"9\oo04]04FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FT,eNFT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFJ<:G?iU?/"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*#%%K;"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9Qt<8"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5":"i.!!`E*!!`E*!!`E*!!`E*!!`E*!!`E*
    +!!`E*!!`E*!!`E*!!`E*!!`E*0*XfI"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s
    +"9q6s"9q6s!*ruSA9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(raZ"NraZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct!,a!X8i3rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWW?*rWW?*rW[iT"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,>"9nu5"9nu5"9nu5"9
    +1632 375 32 dvxColor
    +Ig-#["9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"?Qej"p>,5"p>,5"p>,5#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs
    +#"/Rs#"/Rs#!ahPFC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC94i
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_GA,p!;"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAU4"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu/Iro(?
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +@!#q$?orI@FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC]K(FC8>X"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5rWE3*rWE3*rWE3*rWE3*rWE3*
    +rWE3*rWE3*rWE3*rWE3*rWE3*rWE3*rWFkY6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs
    +6j*Vs6j*Vs6j*Vs6j*Kq@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +@!#q$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9@f]!-J3N!-J3N
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu!+5j4"pFo0!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*IforY"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"q:\>"9nu5"9nu5"9nu/IroXO@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A34mD
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC9S&
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nuj"p>,5"p>,5"p>,5"p>,u"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os
    +"9\os"9\oo04]04FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFJ<:G?iU?/"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*#%%K;"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9Qt<8"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5":"i.!!`E*!!`E*!!`E*!!`E*!!`E*
    +!!`E*!!`E*!!`E*!!`E*!!`E*!!`E*0*XfI"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s
    +"9q6s"9q6s"9q6s!*ruSA9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(raZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct!,a!X8i3rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWW?*rWW?*rWW?*rW[iT"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,>"9nu5"9nu5"9nu5"9,5"p>,5"p>,5#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs
    +#"/Rs#!ahPFC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC94iA,p`N
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_GA,p!;"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAU4"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu/Iro(?@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +?orI@FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC8>X"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5rWE3*rWE3*rWE3*rWE3*rWE3*rWE3*
    +rWE3*rWE3*rWE3*rWE3*rWE3*rWFkY6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs
    +6j*Vs6j*Vs6j*Kq@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9@f]!-J3N!-J3N!-J3N
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu!+5j4"pFo0!!N9*!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*IforY"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"q:\>"9nu5"9nu5"9nu/IroXO@!$@0@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A34mDFC]K(
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC9S&"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nuj"p>,5"p>,5"p>,5"p>,u"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os
    +"9\oo04]04FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FT,eNFT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFJ<:G?iU?/"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*#%%K;"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9Qt<8"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5":"i.!!`E*!!`E*!!`E*!!`E*!!`E*!!`E*
    +!!`E*!!`E*!!`E*!!`E*!!`E*0*XfI"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s
    +"9q6s"9q6s!*ruSA9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(raZ"NraZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct!,a!X8i3rWW?*rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWW?*rWW?*rW[iT"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,>"9nu5"9nu5"9nu5"9
    +1664 375 32 dvxColor
    +Ig-#["9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"?Qej"p>,5"p>,5"p>,5#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs
    +#"/Rs#"/Rs#!ahPFC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC94i
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_GA,p!;"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAU4"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu/Iro(?
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +@!#q$?orI@FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC]K(FC8>X"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5rWE3*rWE3*rWE3*rWE3*rWE3*
    +rWE3*rWE3*rWE3*rWE3*rWE3*rWE3*rWFkY6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs
    +6j*Vs6j*Vs6j*Vs6j*Kq@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +@!#q$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9@f]!-J3N!-J3N
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu!+5j4"pFo0!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*!!N9*IforY"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"q:\>"9nu5"9nu5"9nu/IroXO@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A34mD
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC9S&
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nuj"p>,5"p>,5"p>,5"p>,u"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os"9\os
    +"9\os"9\oo04]04FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FC]K$FT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFJ<:G?iU?/"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*"onf*
    +"onf*"onf*"onf*"onf*#%%K;"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9Qt<8"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5":"i.!!`E*!!`E*!!`E*!!`E*!!`E*
    +!!`E*!!`E*!!`E*!!`E*!!`E*!!`E*0*XfI"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s"9q6s
    +"9q6s"9q6s"9q6s!*ruSA9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(raZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct!,a!X8i3rWW?*rWW?*rWW?*rWW?*rWW?*
    +rWW?*rWW?*rWW?*rWW?*rWW?*rW[iT"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,>"9nu5"9nu5"9nu5"9,5"p>,5"p>,5#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs#"/Rs
    +#"/Rs#!ahPFC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC93$FC94iA,p`N
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_GA,p!;"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*"TAT*
    +"TAT*"TAT*"TAT*"TAU4"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu/Iro(?@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#du
    +@!#du@!#du@!#du@!#du@!#du@!#du@!#du@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +?orI@FC93$FC93$FC93$FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(
    +FC8>X"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5rWE3*rWE3*rWE3*rWE3*rWE3*rWE3*
    +rWE3*rWE3*rWE3*rWE3*rWE3*rWFkY6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs6j*Vs
    +6j*Vs6j*Vs6j*Kq@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$@!#q$
    +A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9@f]!-J3N!-J3N!-J3N
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu!+5j4"pFo0!!N9*!!N9*!!N9*!!N9*!!N9*
    +!!N9*!!N9*!!N9*!!N9*!!N9*IforY"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"q:\>"9nu5"9nu5"9nu/IroXO@!$@0@!$@0@!$@0
    +@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0@!$@0A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$A9;4$
    +A9;4$A9;4$A9;4$A9;4$A9;4$A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A9;@(A34mDFC]K(
    +FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC]K(FC8AW6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\7&14k!2F(1f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'
    +f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbg9Vu"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#,DY-!!6\AFT,eNFT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFOF]*gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gsB=06j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\7*uDCN;a4cN;a4c
    +N;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4dMu]V`?.drbgt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs",N"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu56j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j0:o!!!"@raZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct$G3f\G-/f\G-/f\G-/f\G-/
    +f\G-/f\G-/f\G-/f\G-/f\G-/f\G-/f\G-/f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7d!u~>
    +1696 375 32 dvxColor
    +>m*Ou6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,oR$ig:&A6mjCaP>G'aP>G'aP>G'aP>G'
    +aP>G'aP>G'aP>G'aP>G'aP>G'aP>G'aP>G'aP>"pbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFh>m(?7"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>.+$ig8/A7Tf]
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`"A6n!Cgt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E/>m*Ou6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,p*$il7c
    +$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$iu:eUL[Z_gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E+gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtcs6bY"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9q6s6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^U^R5'
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu?.@N^f[SR/f[SR/
    +f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\FhZ"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@T$o
    +!!&d)?.drRgt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs",N"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u
    +"9q6u"9q6u"9q6u"9tY1!!!(2A9@f]!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N
    +!-J3N!-HY"?/4)ff\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\FhZ"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@UWG!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc
    +!0$mc!K7#RA6mjCf\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\F^+
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFh>m(?7"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5#"/S^#"/S^#"/S^#"/S^#"/S^#"/S^#"/S^
    +#"/S^#"/S^#"/S^#"/S^#"/Ti$ig8-FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eN
    +FT,eNFT,eNFO4Q(eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gsB=06j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\7&14k!2F(1f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'
    +f\FR'f\FR'f\FR'f\FR'bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bg9Vu"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5#"/Xu#"/Xu
    +#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#,DY-!!6\AFT,eNFT,eNFT,eNFT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFOF]*gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gsB=06j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\7*uDCN;a4cN;a4cN;a4cN;a4c
    +N;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4dMu]V`?.drbgt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs",N"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu56j,m^
    +6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j0:o!!!"@raZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct$G3f\G-/f\G-/f\G-/f\G-/f\G-/f\G-/
    +f\G-/f\G-/f\G-/f\G-/f\G-/f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7cs6cB"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\U^R5'UL[Z_grS:'grS:'
    +grS:'grS:'grS:'grS:'grS:'grS:'grS:'grS:'grS:'grS9pgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtcs6bY"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu56jh`raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NdUZY=f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7cs6cB"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\d0p#6rX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8gd!2F(1f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7bhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbg9Vu"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,u"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^
    +"@NG^"JcG+!!%-?A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_uA6m^;
    +gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E/J,~>
    +1728 375 32 dvxColor
    +>m*Ou6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,oR$ig:&A6mjCaP>G'aP>G'aP>G'aP>G'
    +aP>G'aP>G'aP>G'aP>G'aP>G'aP>G'aP>G'aP>"pbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFh>m(?7"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>.+$ig8/A7Tf]
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`"A6n!Cgt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E/>m*Ou6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,p*$il7c
    +$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$iu:eUL[Z_gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E+gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtcs6bY"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9q6s6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^U^R5'
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu?.@N^f[SR/f[SR/
    +f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\FhZ"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@T$o
    +!!&d)?.drRgt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs",N"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u
    +"9q6u"9q6u"9q6u"9tY1!!!(2A9@f]!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N
    +!-J3N!-HY"?/4)ff\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\FhZ"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@UWG!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc
    +!0$mc!K7#RA6mjCf\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\F^+
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFh>m(?7"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5#"/S^#"/S^#"/S^#"/S^#"/S^#"/S^#"/S^
    +#"/S^#"/S^#"/S^#"/S^#"/Ti$ig8-FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eN
    +FT,eNFT,eNFO4Q(eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gsB=06j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\7&14k!2F(1f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'
    +f\FR'f\FR'f\FR'f\FR'bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bg9Vu"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5#"/Xu#"/Xu
    +#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#,DY-!!6\AFT,eNFT,eNFT,eNFT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFOF]*gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gsB=06j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\7*uDCN;a4cN;a4cN;a4cN;a4c
    +N;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4dMu]V`?.drbgt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs",N"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu56j,m^
    +6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j,m^6j0:o!!!"@raZ"NraZ"NraZ"N
    +raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"Nct$G3f\G-/f\G-/f\G-/f\G-/f\G-/f\G-/
    +f\G-/f\G-/f\G-/f\G-/f\G-/f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7cs6cB"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\U^R5'UL[Z_grS:'grS:'
    +grS:'grS:'grS:'grS:'grS:'grS:'grS:'grS:'grS:'grS9pgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtcs6bY"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu56jh`raZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NraZ"NdUZY=f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7cs6cB"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\d0p#6rX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8gd!2F(1f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7bhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbg9Vu"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,u"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^"@NG^
    +"@NG^"JcG+!!%-?A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p_uA6m^;
    +gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:-/gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E/J,~>
    +1760 375 32 dvxColor
    +>m*Ou6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,oR$ig:&A6mjCaP>G'aP>G'aP>G'aP>G'
    +aP>G'aP>G'aP>G'aP>G'aP>G'aP>G'aP>G'aP>"pbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFh>m(?7"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5
    +"p>,5"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>,u"p>.+$ig8/A7Tf]
    +A,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`NA,p`"A6n!Cgt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E/>m*Ou6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,p*$il7c
    +$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$iu:eUL[Z_gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E+gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtcs6bY"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9q6s6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^6j?$^U^R5'
    +!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-HRu?.@N^f[SR/f[SR/
    +f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f[SR/f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\FhZ"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@T$o
    +!!&d)?.drRgt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gt:E'gs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs",N"9nu5"9nu5"9nu5"9nu5"9nu5
    +"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9nu5"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u"9q6u
    +"9q6u"9q6u"9q6u"9tY1!!!(2A9@f]!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N!-J3N
    +!-J3N!-HY"?/4)ff\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7
    +f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\FhZ"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\"@NA\
    +"@NA\"@NA\"@NA\"@NA\"@UWG!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc
    +!0$mc!K7#RA6mjCf\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\G-7f\F^+
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFh>m(?7"p>,5"p>,5"p>,5
    +"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5#"/S^#"/S^#"/S^#"/S^#"/S^#"/S^#"/S^
    +#"/S^#"/S^#"/S^#"/S^#"/Ti$ig8-FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eN
    +FT,eNFT,eNFO4Q(eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/eC`R/
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gsB=06j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\7&14k!2F(1f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'f\FR'
    +f\FR'f\FR'f\FR'f\FR'bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bg9Vu"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5"p>,5#"/Xu#"/Xu
    +#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#"/Xu#,DY-!!6\AFT,eNFT,eNFT,eNFT,eN
    +FT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFT,eNFOF]*gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gsB=06j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\
    +6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\6j,m\7*uDCN;a4cN;a4cN;a4cN;a4c
    +N;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4dMu]V`?.drbgt:E7gt:E7gt:E7gt:E7gt:E7gt:E7
    +gt:E7gt:E7gt:E7gt:E7gt:E7gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"-?$scO89FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9E>_+
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIDrnILtrnILtrnILtrnILt
    +rnILtrnILtrnILtrnILtrnILtrnILtrnILtrnK*Lgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lt
    +h#>MNgbBBqEA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%Z!0$mc!0$mc
    +!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc@uLg)9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T/uQ&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&228_bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhW3A!8RRt!8RRt
    +!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8PrF/l=?#0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%N(aXRN(aXRN(aXRN(aXRN(aXRN(aXRN(aXRN(aXR
    +N(aXRN(aXRN(aXRN(a6a!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!++I)gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>MPgs!tsEA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[
    +EA8%[EA8%[EA.G&%#k/o!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc?]7[;
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe4&HMk`bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8Q#H/l=?#
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%$qq('$qq('$qq('
    +$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qp[6!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!<1aRbl6Atbl6Atbl6Atbl6Atbl6Atbl6Atbl6Atbl6Atbl6At
    +bl6Atbl6BLbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhW3A!8RRt
    +!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt0AJ8o0l\o[0l\o[0l\o[0l\o[
    +0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0Rs~>
    +1792 375 32 dvxColor
    +!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc@uLg)9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T/uQ&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&228_bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhW3A
    +!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8PrF/l=?#0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%N(aXRN(aXRN(aXRN(aXRN(aXRN(aXR
    +N(aXRN(aXRN(aXRN(aXRN(aXRN(a6a!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!++I)gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>MPgs!tsEA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[
    +EA8%[EA8%[EA8%[EA8%[EA.G&%#k/o!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc
    +!0$mc?]7[;9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe2&cMe2&cMe4&HMk`bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt
    +!8Q#H/l=?#0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%$qq('
    +$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qp[6!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!<1aRbl6Atbl6Atbl6Atbl6Atbl6Atbl6Atbl6At
    +bl6Atbl6Atbl6Atbl6BLbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt0AJ8o0l\o[0l\o[
    +0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0E@(2$il7c$il7c$il7c$il7c$il7c
    +$il7c$il7c$il7c$il7c$il7c$tG5)$quURN(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f02
    +!>5;3!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"^g-gs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GFbUuaI;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&:nnG)9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XG
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HH.mbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt
    +!8RRt1#1!s0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\lK$k/*o
    +$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$t"reN(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N"Q$i!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22
    +!YGG5/^3"ogs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GHbUuaI;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&:nnG)9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'
    +9FOT'9FOT'9FOT'9FOT'9FOSq&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HMgc!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt/^3"ogs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-GtbQ-GtbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbV.a+&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[
    +&M\8[&M\8[&M\8[&MO3DrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX<:q
    +$k,gg9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(!
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i4VR>tg2N$quUR$quUR
    +$quUR$quUR$quUR$quUR$quUR$quUR$quUR$quUR$quUR!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,<tbZ#$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>2g]5Rtg]5Rtg]5Rtg]5Rt
    +g]5Rtg]5Rtg]5Rtg]5Rtg]5Rtg]5Rtg]5Rtgb$b*bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAt
    +rld%N0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH)>N;a4cN;a4c
    +N;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a5qN!p+gN(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(Mu`qkrW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!1
    +0?i4qgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>LtgsF7t;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(nB$scPc9FOUR9FOUR9FOUR9FOUR9FOUR9FOUR9FOUR9FOUR
    +9FOUR9FOUR9FOUR9E>_+&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNID
    +?JNc[bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAt
    +rlbAtrlbAtrlbAtrlbAtrld+Pbfhe]0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[
    +0bH-[0bH-[0EMXkN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a5mN(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f0BrW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2
    +rW*!2rW*!2rW*!2rW*'4!<=^qgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>LtgsXD!;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(nB$scO89FOT'9FOT'9FOT'
    +9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9E>_+&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIDrnILtrnILtrnILtrnILtrnILtrnILtrnILtrnILtrnILtrnILt
    +rnILtrnK*Lgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>MNgbBBqEA8%[EA8%[EA8%[EA8%[
    +EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%ZJ,~>
    +1824 375 32 dvxColor
    +!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc@uLg)9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T/uQ&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&228_bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhW3A
    +!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8PrF/l=?#0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%N(aXRN(aXRN(aXRN(aXRN(aXRN(aXR
    +N(aXRN(aXRN(aXRN(aXRN(aXRN(a6a!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!++I)gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>MPgs!tsEA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[
    +EA8%[EA8%[EA8%[EA8%[EA.G&%#k/o!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc
    +!0$mc?]7[;9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe2&cMe2&cMe4&HMk`bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt
    +!8Q#H/l=?#0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%$qq('
    +$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qp[6!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!<1aRbl6Atbl6Atbl6Atbl6Atbl6Atbl6Atbl6At
    +bl6Atbl6Atbl6Atbl6BLbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt0AJ8o0l\o[0l\o[
    +0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0E@(2$il7c$il7c$il7c$il7c$il7c
    +$il7c$il7c$il7c$il7c$il7c$tG5)$quURN(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f02
    +!>5;3!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"^g-gs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GFbUuaI;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&:nnG)9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XG
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HH.mbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt
    +!8RRt1#1!s0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\lK$k/*o
    +$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$t"reN(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N"Q$i!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22
    +!YGG5/^3"ogs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GHbUuaI;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&:nnG)9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'
    +9FOT'9FOT'9FOT'9FOT'9FOSq&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HMgc!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt/^3"ogs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-GtbQ-GtbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbV.a+&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[
    +&M\8[&M\8[&M\8[&MO3DrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX<:q
    +$k,gg9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(!
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i4VR>tg2N$quUR$quUR
    +$quUR$quUR$quUR$quUR$quUR$quUR$quUR$quUR$quUR!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,<tbZ#$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>2g]5Rtg]5Rtg]5Rtg]5Rt
    +g]5Rtg]5Rtg]5Rtg]5Rtg]5Rtg]5Rtg]5Rtgb$b*bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAt
    +rld%N0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH)>N;a4cN;a4c
    +N;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a5qN!p+gN(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(Mu`qkrW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!1
    +0?i4qgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>LtgsF7t;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(nB$scPc9FOUR9FOUR9FOUR9FOUR9FOUR9FOUR9FOUR9FOUR
    +9FOUR9FOUR9FOUR9E>_+&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNID
    +?JNc[bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtrlbAtrlbAtrlbAtrlbAtrlbAtrlbAt
    +rlbAtrlbAtrlbAtrlbAtrld+Pbfhe]0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[0bH-[
    +0bH-[0bH-[0EMXkN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a4cN;a5mN(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f0BrW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2
    +rW*!2rW*!2rW*!2rW*'4!<=^qgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>LtgsXD!;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(nB$scO89FOT'9FOT'9FOT'
    +9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9E>_+&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIDrnILtrnILtrnILtrnILtrnILtrnILtrnILtrnILtrnILtrnILt
    +rnILtrnK*Lgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>MNgbBBqEA8%[EA8%[EA8%[EA8%[
    +EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%ZJ,~>
    +1856 375 32 dvxColor
    +!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc@uLg)9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T/uQ&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&228_bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhW3A
    +!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8PrF/l=?#0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%N(aXRN(aXRN(aXRN(aXRN(aXRN(aXR
    +N(aXRN(aXRN(aXRN(aXRN(aXRN(a6a!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!++I)gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qth#>Lth#>Lth#>Lth#>Lt
    +h#>Lth#>Lth#>Lth#>Lth#>Lth#>Lth#>MPgs!tsEA8%[EA8%[EA8%[EA8%[EA8%[EA8%[EA8%[
    +EA8%[EA8%[EA8%[EA8%[EA.G&%#k/o!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc!0$mc
    +!0$mc?]7[;9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe2&cMe2&cMe4&HMk`bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt
    +!8Q#H/l=?#0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z%UC:F%$qq('
    +$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qq('$qp[6!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!<1aRbl6Atbl6Atbl6Atbl6Atbl6Atbl6Atbl6At
    +bl6Atbl6Atbl6Atbl6BLbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt0AJ8o0l\o[0l\o[
    +0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0E@(2$il7c$il7c$il7c$il7c$il7c
    +$il7c$il7c$il7c$il7c$il7c$tG5)$quURN(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f02
    +!>5;3!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"^g-gs"Qtgs"Qtgs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GFbUuaI;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&:nnG)9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XR9T2XG
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HH.mbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFtbhUFt
    +bhUFtbhUFtbhUFtbhUFtbhUFtbhW3A!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt!8RRt
    +!8RRt1#1!s0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\o[0l\lK$k/*o
    +$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$il7c$t"reN(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(N(f1(
    +N(f1(N(f1(N(f1(N(f1(N"Q$i!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22
    +!YGG5/^3"ogs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GHbUuaI;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&:nnG)9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'9FOT'
    +9FOT'9FOT'9FOT'9FOT'9FOSq&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HMgc!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt!6kGt/^3"ogs"Qt
    +gs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"Qtgs"RQbQ-GtbQ-GtbQ-GtbQ-GtbQ-Gt
    +bQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbQ-GtbV.a+&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[&M\8[
    +&M\8[&M\8[&M\8[&MO3DrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX8dcrX<:q
    +$k,gg9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(
    +9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(9T2Z(!
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i4VR>tg2N$quUR$quUR
    +$quUR$quUR$quUR$quUR$quUR$quUR$quUR$quUR$quUR!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,<];)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&4&I_00i9Z&0i9Z&0i;pfED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QE.J&0!<<*R0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M
    +1&b#M1&b#M1&b#M0r%SG:bWI^&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HNLC!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<_,!<<+L;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf
    +;)(pf4&I_0ED\GfED\GfED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +ED^^QE.J&0!<<++0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^`T;#kV8;#kV8;#kV8
    +;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV6;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl
    +;#jGl;#jGl;#jGl;$0Wl;,AkW!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>5;3!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"kn*!!%LG0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Ye;)(p&;)(p&;)(p&;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHPJH,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>5;3!"o22!"o22!"o22!"o22!"o22
    +!"o22!"o22!"o22!"o22!"o22!"kt,!!%LG0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pP;)(pf;)(pf;)(pf;/oHQ;/oHQ;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHPJ,~>
    +1888 375 32 dvxColor
    +JHG;#jEH&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe2&Yf@,!.Rj#;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;/oGf
    +;/oGf;/oGf;/oGf;/oGf;/oGf;/oGf;/oGf;/nZp0i;pf0i;pf0i;pfED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^\+!QG-?!+%BW0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i;pfED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QEW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EVrZ6
    +;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;o:f1(d/eJ*r!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>ErW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2
    +rW*!2rW*!2rW*!2rW*!2T`G,nIo.pt0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0fr5e;)(p&;)(p&;)(pf;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/h'e`W6&>0JQEZ;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)/&I!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM
    +!&=IM!&=IM!&:6G!)P?]!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>ErW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2UB(>pIo.pt0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&ED\GfED\GfED\GfED\GfED\GfED\GfED\GfED\Gf
    +EB@#P;/oGf;/oGf;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ
    +;/h'e`W6&>?85;3;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQr_WZ8r_WZ8r_WZ8r_WZ8
    +r_WZ8r_WZ8r_WZ8r_WZ8r_WZ8r_WZ8r_WZ8r)!Gjr_WYlr_WYlr_WYlr_WYlr_WYlr_WYlr_WYl
    +r_WYlr_WYlr_Wbo;,L.B&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNLC!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<_*!<<+L;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +4&I_00i9Z&0i9Z&0i;pfED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +E.J&0!<<*R0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&1&b#M
    +1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M0r%SG:bWI^&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNLC!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2
    +!<<`2!<<`2!<<`2!<<`2!<<_,!<<+L;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(pf
    +;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf4&I_0ED\GfED\GfED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QE.J&0!<<++0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^`T;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV6
    +;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;$0Wl;,AkW!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>5;3!"o22!"o22!"o22!"o22!"o22!"o22
    +!"o22!"o22!"o22!"o22!"kn*!!%LG0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Ye;)(p&;)(p&;)(p&;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHPJH,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>5;3!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"kt,!!%LG0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf
    +0i;pP;)(pf;)(pf;)(pf;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHPJ,~>
    +1920 375 32 dvxColor
    +JHG;#jEH&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe2&Yf@,!.Rj#;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;/oGf
    +;/oGf;/oGf;/oGf;/oGf;/oGf;/oGf;/oGf;/nZp0i;pf0i;pf0i;pfED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^\+!QG-?!+%BW0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i;pfED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QEW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EVrZ6
    +;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;o:f1(d/eJ*r!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>ErW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2
    +rW*!2rW*!2rW*!2rW*!2T`G,nIo.pt0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0fr5e;)(p&;)(p&;)(pf;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/h'e`W6&>0JQEZ;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)/&I!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM
    +!&=IM!&=IM!&:6G!)P?]!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>ErW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2UB(>pIo.pt0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&ED\GfED\GfED\GfED\GfED\GfED\GfED\GfED\Gf
    +EB@#P;/oGf;/oGf;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ
    +;/h'e`W6&>?85;3;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQr_WZ8r_WZ8r_WZ8r_WZ8
    +r_WZ8r_WZ8r_WZ8r_WZ8r_WZ8r_WZ8r_WZ8r)!Gjr_WYlr_WYlr_WYlr_WYlr_WYlr_WYlr_WYl
    +r_WYlr_WYlr_Wbo;,L.B&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE
    +&HNLC!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2!<<_*!<<+L;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +4&I_00i9Z&0i9Z&0i;pfED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +E.J&0!<<*R0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&1&b#M
    +1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M1&b#M0r%SG:bWI^&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNLC!<<`2!<<`2!<<`2!<<`2!<<`2!<<`2
    +!<<`2!<<`2!<<`2!<<`2!<<_,!<<+L;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(pf
    +;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf;)(pf4&I_0ED\GfED\GfED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^QE.J&0!<<++0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^`T;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV6
    +;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;#jGl;$0Wl;,AkW!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>5;3!"o22!"o22!"o22!"o22!"o22!"o22
    +!"o22!"o22!"o22!"o22!"kn*!!%LG0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Ye;)(p&;)(p&;)(p&;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHPJH,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>E!>5;3!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"o22!"kt,!!%LG0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf0i;pf
    +0i;pP;)(pf;)(pf;)(pf;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHPJ,~>
    +1952 375 32 dvxColor
    +JHG;#jEH&HNIE&HNIE&HNIE&HNIE
    +&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&HNIE&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2&cMe2
    +&cMe2&cMe2&cMe2&cMe2&Yf@,!.Rj#;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;/oGf
    +;/oGf;/oGf;/oGf;/oGf;/oGf;/oGf;/oGf;/nZp0i;pf0i;pf0i;pfED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^\+!QG-?!+%BW0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i;pfED^^QED^^QED^^QED^^QED^^QED^^QED^^QED^^Q
    +ED^^QED^^QED^^QEW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EW/f8EVrZ6
    +;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;l;#X;o:f1(d/eJ*r!>,>E!>,>E!>,>E
    +!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>ErW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2
    +rW*!2rW*!2rW*!2rW*!2T`G,nIo.pt0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0fr5e;)(p&;)(p&;)(pf;/oHQ;/oHQ;/oHQ;/oHQ
    +;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/h'e`W6&>0JQEZ;)(p&;)(p&;)(p&;)(p&;)(p&
    +;)(p&;)(p&;)(p&;)(p&;)(p&;)(p&;)/&I!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM!&=IM
    +!&=IM!&=IM!&:6G!)P?]!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E!>,>E
    +!>,>ErW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2rW*!2UB(>pIo.pt0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&
    +0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&0i9Z&ED\GfED\GfED\GfED\GfED\GfED\GfED\GfED\Gf
    +EB@#P;/oGf;/oGf;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ;/oHQ
    +;/h'e`W6&>Io'!=;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d
    +;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C+d;,C,iTW4ZbTW4ZbTW4ZbTW4Zb
    +TW4ZbTW4ZbTW4ZbTW4ZbTKB0HWh?AbWh?AbWh?AbW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W1^)^W1^)^W1]mdU^@)%!E!k9;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8;#kV8
    +;#kV8;#kV8;#kV8;#kS7;#kS7;#kS7;#kS7;#kS7;#kS7;#kS7;#kS7;#kS7;#kS7;#kS7;-n47
    +Wh?AbWh?AbWh?AbWh?AbWh?AbWh?AbWh?AbWh?AbWh?AbWh?AbWh?AbWh?)ZWh?)ZWh?)ZWh?)Z
    +Wh?)ZWh?)ZWh?)ZWh?)ZWh?)ZWh?)ZWh?)ZWh?)ZTE,&srW*!&rW*!&rW*!&rW*!&rW*!&rW*!&
    +rW*!&rW*!&rW*!&rW*!&rW+\V!
    +1984 375 32 dvxColor
    +rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW+VT!
    +2016 375 32 dvxColor
    +rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW+VT!
    +2048 375 32 dvxColor
    +rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW*!&rW+VT!mLcC$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5
    +$31D5$31D5$31&-TV/OgTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTO"dpYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84ndL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\
    +!%t4CW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W;`FiW;`FiW;`Fi
    +W;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW1]fY$=*W=#RUP=#RUP=#RUP=#RUP=#RUP=
    +#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5
    +!"&]5!"&]5!"&]+!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTF7usYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4%!f
    +#QOiWW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)?T_+nP$=*oE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUP=!"&]5!"&]5!"&]5
    +!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]+!hc3jrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh'7qrh*Q$TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#R]#d!!"XMTW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^
    +TW"N^TW"N^TW%j_!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!2uQ`UC(9,
    +$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$31D5$31D5$31D5
    +$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5!!'8&TE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTRX2=Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb2B.TWk)nTWk)nTWk)>~>
    +2080 375 32 dvxColor
    +dL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!%t4CW1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W.CW6TVDj"#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5
    +!"&]5!"&]5!!!(nTWnEg!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq?*L!+TWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR
    +/uYGATW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^rh'7irh'7irh'7i
    +rh'7irh'7irh'7irh'7irh'7irh'7irh'7irh'7iTV/%e>lY3;$31D=$31D=$31D=$31D=$31D=
    +$31D=$31D=$31D=$31D=$31D=$31D=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5
    +$31D5$31D5$31&+Yl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:;F
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84n$a?;.TWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d
    +!!"XMTW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW!F7oUqrd>mLcC
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$31D5$31D5$31D5$31D5
    +$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31&-TV/OgTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTO"dpYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84ndL'7c$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$I]7\!%t4CW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW1]fY$=*W=
    +#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=!"&]5!"&]5!"&]5
    +!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]+!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHqJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTF7usYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4%!f#QOiWW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W1^)?T_+nP$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUP=!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]+!hc3jrh'7q
    +rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh*Q$TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +T[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d!!"XMTW"N^TW"N^TW"N^TW"N^
    +TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW%j_!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi
    +!3#mi!3#mi!3#mi!2uQ`UC(9,$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=
    +$4$t=$4$t=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5!!'8&TE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTRX2=Yb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb2B.TWk)nTWk)n
    +TWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR/uYGATW"N^TW"N^TW"N^
    +TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^JY;Y.UC(94$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$t=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5
    +$31D5$31D5$31D5!!8gdYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9q
    +Yl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:;$Yb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4%!f
    +#QOiWW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^*gTE(qiTE(qi
    +TE(qiTE(qiTE(qiTE(qiTE(qiTE(qiTE(qiTE(qiTE(p`TVDj"!"&u=!"&u=!"&u=!"&u=!"&u=
    +!"&u=!"&u=!"&u=!"&u=!"&u=!"&u=!"&u=!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5
    +!"&]5!"&]5!"&]5!!!#'rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh+bFTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk'sYb84nYb84nYb84nJ,~>
    +2112 375 32 dvxColor
    +dL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!%t4CW1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W.CW6TVDj"#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5
    +!"&]5!"&]5!!!(nTWnEg!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq?*L!+TWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR
    +/uYGATW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^rh'7irh'7irh'7i
    +rh'7irh'7irh'7irh'7irh'7irh'7irh'7irh'7iTV/%e>lY3;$31D=$31D=$31D=$31D=$31D=
    +$31D=$31D=$31D=$31D=$31D=$31D=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5
    +$31D5$31D5$31&+Yl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:;F
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84n$a?;.TWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d
    +!!"XMTW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW!F7oUqrd>mLcC
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$31D5$31D5$31D5$31D5
    +$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31&-TV/OgTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTO"dpYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84ndL'7c$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$I]7\!%t4CW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW;`FiW1]fY$=*W=
    +#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=#RUP=!"&]5!"&]5!"&]5
    +!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]+!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHqJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTF7usYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4%!f#QOiWW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W1^)?T_+nP$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUP=!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]+!hc3jrh'7q
    +rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh*Q$TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +T[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d!!"XMTW"N^TW"N^TW"N^TW"N^
    +TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW%j_!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi!3#mi
    +!3#mi!3#mi!3#mi!2uQ`UC(9,$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=$4$t=
    +$4$t=$4$t=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5!!'8&TE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTRX2=Yb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb2B.TWk)nTWk)n
    +TWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR/uYGATW"N^TW"N^TW"N^
    +TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^JY;Y.UC(94$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$t=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5
    +$31D5$31D5$31D5!!8gdYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9q
    +Yl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:;$Yb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb9DO>mLcC$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4%!f
    +#QOiWW1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^*gTE(qiTE(qi
    +TE(qiTE(qiTE(qiTE(qiTE(qiTE(qiTE(qiTE(qiTE(p`TVDj"!"&u=!"&u=!"&u=!"&u=!"&u=
    +!"&u=!"&u=!"&u=!"&u=!"&u=!"&u=!"&u=!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5
    +!"&]5!"&]5!"&]5!!!#'rh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh+bFTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk'sYb84nYb84nYb84nJ,~>
    +2144 375 32 dvxColor
    +dL'7c$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$I]7\!%t4CW1^)^W1^)^W1^)^W1^)^
    +W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W1^)^W.CW6TVDj"#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5!"&]5
    +!"&]5!"&]5!!!(nTWnEg!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq?*L!+TWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk*;$=*oE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhEdg,pR
    +/uYGATW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^rh'7irh'7irh'7i
    +rh'7irh'7irh'7irh'7irh'7irh'7irh'7irh'7iTV/%e>lY3;$31D=$31D=$31D=$31D=$31D=
    +$31D=$31D=$31D=$31D=$31D=$31D=$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31D5
    +$31D5$31D5$31&+Yl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:9qYl:;F
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84n$a?;.TWk)nTWk)nT[F0Q#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE
    +#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#RUhE#R]#d
    +!!"XMTW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW"N^TW!F7oUqrd>mLcC
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$31D5$31D5$31D5$31D5
    +$31D5$31D5$31D5$31D5$31D5$31D5$31D5$31&-TV/OgTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4qTE)4q
    +TE)4qTO"dpYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84n
    +Yb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84nYb84ndL'7c$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE$4$tE
    +$4$tE$4$tE$4$tE$4$tE$I]7\!!/asrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7qrh'7q
    +rh'7qrh+bFTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTR`\sTTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.J<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<59kOFWFCOFWFCOFWFCOFWEE!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!o>"J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ)RY!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(>lY-1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP/0!!!<(!!!<(!!!<(!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!7INJOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,SOT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OC%tn!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!rQ(!!psPJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OIuU[!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*74!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!=/Z+TWnEg!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHqJ$>SrTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgs
    +TTGgsTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTF\cJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ.@ZkOFWFCOFWFCOFWFCO=^G"J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)NTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%+~>
    +2176 375 32 dvxColor
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWld0#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(
    +#lXf(#lXhPTO"
    +J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ)RY!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(>lY-1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP/0!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!7INJOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,SOT'b1
    +OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OC%tn!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!psPJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OIuU[!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*74
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!=/Z+TWnEg!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHqJ$>SrTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ.@ZkOFWFCOFWFCOFWFCO=^G"J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ)RY!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(>lY-1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP/0!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!7INJOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,SOT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1
    +OC%tn!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!psPJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)N
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OIuU[!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*74!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!=/Z+TWnEg!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHqJ$>SrTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgs
    +TTGgsTTGgsTTGgsTTGgsTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ.@ZkOFWFCOFWFCOFWFCO=^G
    +2208 375 32 dvxColor
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWld0#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(
    +#lXf(#lXhPTO"
    +J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ)RY!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(>lY-1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP/0!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!7INJOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,SOT'b1
    +OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OC%tn!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!psPJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OIuU[!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(!<*74
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!=/Z+TWnEg!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq
    +!3lHq!3lHqJ$>SrTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTF\cJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ.@ZkOFWFCOFWFCOFWFCO=^G"J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ)RY!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(
    +!!*!(!!*!(!!*!(!!*!(>lY-1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP/0!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!7INJOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,SOT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1
    +OC%tn!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!psPJ$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)N
    +TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OIuU[!<*6(!<*6(!<*6(!<*6(!<*6(!<*6(
    +!<*6(!<*6(!<*6(!<*6(!<*74!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!rQ(
    +!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!!rQ(!=/Z+TWnEg!3lHq!3lHq!3lHq
    +!3lHq!3lHq!3lHq!3lHq!3lHq!3lHq!3lHqJ$>SrTTGgsTTGgsTTGgsTTGgsTTGgsTTGgsTTGgs
    +TTGgsTTGgsTTGgsTTGgsTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTF\cJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ.@ZkOFWFCOFWFCOFWFCO=^G
    +2240 375 32 dvxColor
    +rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWiK(rWld0#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(
    +#lXf(#lXhPTO"
    +J$>TMTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)n
    +TWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)nTWk)NTTGh.TTGh.TTGh.TTGh.TTGh.TTGh.
    +TTGh.TTGh.TKA7.OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].OJ%].
    +OJ%].OJ%].OJ)RY!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(!!*!(>lY-1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP/0!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!7INJOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,s
    +OJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,sOJ%,SOT'b1
    +OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OT'b1OF[E(!%oGs+p&qi+p&qi+p&qi
    ++p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+FfK'OFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFC^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^jnM^JA?]NJA?]NJA?]NU,'#0
    +U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,&t!!!!<(!!!<(!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!%nNW#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1
    +#QP,1#QP,1#QP,1#QP,1#h&hT!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]N
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA=6s^js4N^js4N^js4N186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186IR!;$7D+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi
    ++p&qi+p&qi+p&qi+p&qi+p&qi+p&m:OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY,s
    +d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^44&MnJC&h^JC&h^JC(SWU,'#0U,'#0U,'#0
    +U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0T[<`>#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(
    +#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(
    +#lXf(#lXf(#h&hT!.TN\J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]N
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA=6s^js4N^js4N^js4N186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou186Ou186IR!;$7D+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi
    ++p&qi+p&qi+p&qi+p&m:OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY,s^js4N^js4N
    +^js4N^js4N^js4N^js4N^js4N^js4N42?B^JA?]NJA?]NJA@m7Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0
    +Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0Yg3:L#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(#lXf(
    +#lXf(#lXf(#lXfV#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP.Z
    +!WW4MJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;sJC&h^JC&h^
    +JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JC&h^JA?]NJA?]NJA?]NJA?]NJA?]N
    +JA?]NJA?]NJA?]N4+P+s^js4N^js4N^jn27186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou0S'5r!%oGs+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi
    ++p&qi+FfK'OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^jsd^^jsd^^jsd^^jsd^^jsd^
    +^jsd^^jsd^^jsd^^jnMnJA?]^JA?]^JA?]^Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0Z8/.0
    +Z8/.0Z8/.0Z8/.0Z8/*#!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(
    +!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!;R!WW4MJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;sJA?]NJA?]NJA?]NJA?]N
    +JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]N
    +JA?]N4+P+s^js4N^js4N^jn27186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou0S'5r!%oGs+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+FfK'
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js4N^jnM^JA?]NJA?]NJA?]NU,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0
    +U,'#0U,&t!!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!!!<(!%nNW#QP,1
    +#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#QP,1#h&hT!.TN\J<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJA?]^JA?]^JA?]^JA?]^JA?]^JA?]^JA?]^
    +JA?]^JA?]^JA?]^JA?]^JA?]^JA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA=6s^js4N
    +^js4N^js4N186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186IR!;$7D
    ++p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&qi+p&m:OFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFY,sd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^44&Mn
    +JC&h^JC&h^JC(SWU,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0U,'#0TR[~>
    +
    +2272 375 32 dvxColor
    +d/X.PrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(dfKLLIt3W2OFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^cR(^JA?]NJA?]N
    +JA?[jU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU+h,uoDgKf![7[i
    +![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![)uSJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?[s^js4N^js4N
    +^js4N^nNTi186P0186P0186P0186P0186P0186P0186P0186P0186P0186P0186P.cN!qNrVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rW"PV!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!q$T!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFY,sd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js3^JA?]NJA?]NJA?]NJ23iSU,&Gu
    +U,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GsJH5F$19r[019r[019r[019r[019r[0
    +19r[019r[019r[019r[019r[019r[.d/X.PrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +dfKLLIt3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^js4N^js4N
    +^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js4N^js4N^js4N^cR(^JA?]NJA?]NJA?[jU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&Gu
    +U,&GuU,&GuU,&GuU+h,uoDgKf![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i
    +![7[i![)uSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJA?]NJA?]NJA?]NJA?]NJA?]N
    +JA?]NJA?]NJA?]NJA?[s^js4N^js4N^js4N^nNTi186P0186P0186P0186P0186P0186P0186P0
    +186P0186P0186P0186P.cN!qNrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rW"PV!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!q$T!!%M!OFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY,sd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js3^JA?]NJA?]NJA?]NJ23iSU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&Gu
    +U,&GsJH5F$19r[019r[019r[019r[019r[019r[019r[019r[019r[019r[019r[.J,~>
    +2304 375 32 dvxColor
    +d/X.PrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(dfKLLIt3W2OFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^cR(^JA?]NJA?]N
    +JA?[jU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU+h,uoDgKf![7[i
    +![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![)uSJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?[s^js4N^js4N
    +^js4N^nNTi186P0186P0186P0186P0186P0186P0186P0186P0186P0186P0186P.cN!qNrVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rW"PV!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!q$T!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFY,sd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js3^JA?]NJA?]NJA?]NJ23iSU,&Gu
    +U,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GsJH5F$19r[019r[019r[019r[019r[0
    +19r[019r[019r[019r[019r[019r[.d/X.PrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +dfKLLIt3W2OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^js4N^js4N
    +^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js4N^js4N^js4N^cR(^JA?]NJA?]NJA?[jU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&Gu
    +U,&GuU,&GuU,&GuU+h,uoDgKf![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i
    +![7[i![)uSJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJA?]NJA?]NJA?]NJA?]NJA?]N
    +JA?]NJA?]NJA?]NJA?[s^js4N^js4N^js4N^nNTi186P0186P0186P0186P0186P0186P0186P0
    +186P0186P0186P0186P.cN!qNrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rW"PV!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!q$T!!%M!OFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFY,sd"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js3^JA?]NJA?]NJA?]NJ23iSU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&Gu
    +U,&GsJH5F$19r[019r[019r[019r[019r[019r[019r[019r[019r[019r[019r[.J,~>
    +2336 375 32 dvxColor
    +d/X.PrVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(dfKLLIt3W2OFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N
    +^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js4N^cR(^JA?]NJA?]N
    +JA?[jU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU+h,uoDgKf![7[i
    +![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![7[i![)uSJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;CJ<5;C
    +J<5;CJ<5;CJ<5;CJ<5;CJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?]NJA?[s^js4N^js4N
    +^js4N^nNTi186P0186P0186P0186P0186P0186P0186P0186P0186P0186P0186P.cN!qNrVup(
    +rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rVup(rW"PV!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1
    +!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!iQ1!!q$T!!%M!OFWFCOFWFCOFWFCOFWFCOFWFCOFWFCOFWFC
    +OFWFCOFWFCOFWFCOFWFCOFY,sd"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^d"&o^
    +d"&o^d"&o^^js4N^js4N^js4N^js4N^js4N^js4N^js4N^js3^JA?]NJA?]NJA?]NJ23iSU,&Gu
    +U,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GuU,&GsJH5
    +2368 375 32 dvxColor
    +JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p
    +0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ
    ++u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q
    +0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V
    +0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-10lRh
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=9JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p
    ++tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ
    ++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q
    +0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ1'"WZ
    +1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-10lRh0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=9JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p
    +0Hr1p0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ
    ++u6GZ+u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q
    +0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V
    +0JY>V0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-
    +10lRh0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=9JHEgm!!%MC186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p
    ++tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T+
    ++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q
    +0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ
    +1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-10lRh0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=9J,~>
    +2400 375 32 dvxColor
    +JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p
    +0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ
    ++u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q
    +0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V
    +0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-10lRh
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=9JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p
    ++tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ
    ++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q
    +0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ1'"WZ
    +1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-10lRh0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=9JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p
    +0Hr1p0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ
    ++u6GZ+u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q
    +0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V
    +0JY>V0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-
    +10lRh0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=9JHEgm!!%MC186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p
    ++tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T+
    ++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou186Ou186Ou186Ou186Ou186Ou186Ou
    +186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q
    +0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>A?J`W?,?!A^t[1'"WZ1'"WZ1'"WZ
    +1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"WZ1'"W-10lRh0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=9J,~>
    +2432 375 32 dvxColor
    +JHEgm!!%MC186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Is0Hr3F
    +0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr3F0Hr1p0Hr1p0Hr1p0Hr1p0Hr1p
    +0Hr1p0Hr1p0Hr1p0Hr24+tt2p+tt2p+tt2p+tt3[+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>>!Y+>>!Y+>>!Y+>>!YJHEgm!!"T++u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ+u6GZ
    ++u6GZ+u6GZ,1//O0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;
    +0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JY=;0JJ%Z`W?,?J"e]m186Ou
    +186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou186Ou0VU7q0VU7q0VU7q0VU7q0VU7q
    +0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0VU7q0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V0JY>V
    +0KV$DU+e>VU+e>VU+e?A+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y+>>!Y
    ++>A?J`W?,?IkWW3+ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc+,&eZD
    +,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD+E/HB+E/HB+E/HB+E/HB
    ++E/HB+E/HB+E/HB+E/HB+?ONbE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0]5`W?,?Iir\QE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE!p&]+E1^r+E1^r+E5_9JHEgm!!%L&+ttc+
    ++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++tt2p,&e_[,&e_[,&e_[,&e_[
    +,&e_[,&e_[,&e_[,&e_[,&e_[,&e_[,&e_[,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD
    +,&eYdE$B6DE$B6DE$B6DE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BJHEgm!!%KkE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$/O2E$/O2E$/O2E$/O2
    +E$/O2E$/O2E$/O2E$/O2E$/Nt+E/H2+E/H2+E/H2+E7
    +2464 375 32 dvxColor
    +JHEgm!!%KkE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0+-?m'Dr?m'Dr?m'Dr?m'Dr
    +?m'Dr?m'Dr?m'Dr?m'Cr?sk*r?sk*r?sk*rd9qM,`W?,?IkW'#1,'n+1,'n+1,'n+1,'n+1,'n+
    +1,'n+1,'n+1,'n+1,'n+1,'n+1,'n++tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[
    ++tt3[+tt3[+tt3[+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+?ONdE$0*DE$0*DE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0]5`W?,?Iir\QE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B?m'D2?m'D2?m'D2?m'D2?m'D2?m'D2?m'D2?m'D2
    +?k"5t+CH=2+CH=2+CH=2ra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5eH
    +?snNe`W?,?IkWW3+ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc+,&eZD
    +,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD+E/HB+E/HB+E/HB+E/HB
    ++E/HB+E/HB+E/HB+E/HB+?ONbE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0]5`W?,?Iir\QE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE!p&]+E1^r+E1^r+E5_9JHEgm!!%L&+ttc+
    ++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++tt2p,&e_[,&e_[,&e_[,&e_[
    +,&e_[,&e_[,&e_[,&e_[,&e_[,&e_[,&e_[,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD
    +,&eYdE$B6DE$B6DE$B6DE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BJHEgm!!%KkE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$/O2E$/O2E$/O2E$/O2
    +E$/O2E$/O2E$/O2E$/O2E$/Nt+E/H2+E/H2+E/H2+E7
    +2496 375 32 dvxColor
    +JHEgm!!%KkE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0+-?m'Dr?m'Dr?m'Dr?m'Dr
    +?m'Dr?m'Dr?m'Dr?m'Cr?sk*r?sk*r?sk*rd9qM,`W?,?IkW'#1,'n+1,'n+1,'n+1,'n+1,'n+
    +1,'n+1,'n+1,'n+1,'n+1,'n+1,'n++tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[
    ++tt3[+tt3[+tt3[+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+?ONdE$0*DE$0*DE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0]5`W?,?Iir\QE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B?m'D2?m'D2?m'D2?m'D2?m'D2?m'D2?m'D2?m'D2
    +?k"5t+CH=2+CH=2+CH=2ra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5_Fra5eH
    +?snNe`W?,?IkWW3+ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc+,&eZD
    +,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD+E/HB+E/HB+E/HB+E/HB
    ++E/HB+E/HB+E/HB+E/HB+?ONbE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0]5`W?,?Iir\QE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE+!&rE!p&]+E1^r+E1^r+E5_9JHEgm!!%L&+ttc+
    ++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++ttc++tt2p,&e_[,&e_[,&e_[,&e_[
    +,&e_[,&e_[,&e_[,&e_[,&e_[,&e_[,&e_[,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD,&eZD
    +,&eYdE$B6DE$B6DE$B6DE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BJHEgm!!%KkE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$/O2E$/O2E$/O2E$/O2
    +E$/O2E$/O2E$/O2E$/O2E$/Nt+E/H2+E/H2+E/H2+E7
    +2528 375 32 dvxColor
    +JHEgm!!%KkE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0+-?m'Dr?m'Dr?m'Dr?m'Dr
    +?m'Dr?m'Dr?m'Dr?m'Cr?sk*r?sk*r?sk*rd9qM,`W?,?IkW'#1,'n+1,'n+1,'n+1,'n+1,'n+
    +1,'n+1,'n+1,'n+1,'n+1,'n+1,'n++tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[+tt3[
    ++tt3[+tt3[+tt3[+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+E/ND+?ONdE$0*DE$0*DE$0*B
    +E$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$0*BE$,/_!<*$"!<*$"!<*$"
    +!<*$"!<*$"!<*$"!<*$"!<*$"!<*$"!<*$"!<*$R!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%!!3-%
    +!!3-%!!3-%!!3-%!!3-%!!3-%!2Kl#!2EpT\mUcC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLJbE$0+-E$0+-E$0+-E$0+-E$0+-
    +E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5F
    +E;j5FE;j5FE;j6PE+"d;OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^
    +OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUCVrW3'"rW3'"rW3'"
    +rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&
    +rW3'&rW3'&rW3'&rW3'&rW3-(!Wo/'!!&d%0t
    +2560 375 32 dvxColor
    +rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'&rW3'&rW3'&rW3'&
    +rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3-(!Wo/'!!&d%0tmUcC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLJbE$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E;j5F
    +E;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j6PE+"d;OLUC^OLUC^OLUC^OLUC^
    +OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^
    +OLUC^OLUC^OLUC^OLUCVJ,~>
    +2592 375 32 dvxColor
    +rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'&rW3'&rW3'&rW3'&
    +rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3-(!Wo/'!!&d%0tmUcC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLJbE$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E$0+-E;j5F
    +E;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j5FE;j6PE+"d;OLUC^OLUC^OLUC^OLUC^
    +OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^OLUC^
    +OLUC^OLUC^OLUC^OLUCVJ,~>
    +2624 375 32 dvxColor
    +rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'"rW3'&rW3'&rW3'&rW3'&
    +rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3'&rW3-(!Wo/'!!%M9OI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$Q
    +fU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1
    +LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1I
    +d$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQ
    +d$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1I
    +fSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9
    +!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1I
    +d$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W11JI'7$
    +!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$Q
    +fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1
    +fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +JI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>A
    +fSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1
    +!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19d$W19
    +d$W19d$W19d$W19d$W19d$W11JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$Q
    +fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5I
    +R+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1
    +!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19
    +d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W11JI'7$!!%M9OI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$Q
    +fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9
    +LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\
    +/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>A
    +d"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[f
    +d$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1I
    +fSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!%M1
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19
    +d$W19d$W11J,~>
    +2656 375 32 dvxColor
    +JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$Q
    +fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1
    +fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1
    +!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19
    +d$W19d$W19d$W19d$W19d$W19d$W11JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$Q
    +fU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0A
    +R+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\
    +0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1
    +!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1I
    +d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W11JI'7$!!%M9OI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$Q
    +fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1
    +fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1I
    +fSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+
    +!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19
    +d$W19d$W19d$W11JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1
    +fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5I
    +R+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1
    +!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19
    +d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W11J,~>
    +2688 375 32 dvxColor
    +JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$Q
    +fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1
    +fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1
    +!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19
    +d$W19d$W19d$W19d$W19d$W19d$W11JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$Q
    +fU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0A
    +R+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\
    +0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1
    +!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!%M1R#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1I
    +d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W11JI'7$!!%M9OI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4C^fU1$QfU1$QfU1$QfU1$Q
    +fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1
    +fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1I
    +fSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5IR+TL4aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+
    +!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5IR*_5IR*_5IR*_5IR*_5IR*_5I
    +R*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19d$W19
    +d$W19d$W19d$W11JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI4C^fU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfU1$QfSIn1fSIn1
    +fSIn1fSIn1fSIn1fSIn1fSIn1fSIn1fSIm9LtIO1LtIO1LtIO1LtKf$aOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\/dVdi#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbCJI(oS!!%M1R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\s
    +R#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR#m\sR*_5I
    +R*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_5IR*_3^d$W1Id$W1Id$W1Id"p>Ad"p>Ad"p>Ad"p>A
    +d"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>Ad"p>9JI'7$!!%M9OI2,sOI2,sOI2,sOI2,sOI2,s
    +OI2,sOI2,sOI2,sOI2,sOI2,sOI2,sOI4[fd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQd$WIQ
    +d$WIQd$WIQd$WIQd"p>AfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ1IfSJ0AR+R5IR+R5IR+R5I
    +R+TL4aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnS\0F8!k#RLbC#RLbC
    +#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RLbC#RUJ9!"&W1!"&W1!"&W1!"&W1!"&W1
    +!"&W1!"&W1!"&W1!"&W1!"&W1!"#D+!!$9VaO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\
    +aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +dE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=YcrW~>
    +2720 375 32 dvxColor
    +dKk=2#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#QkD9$3LV;$3LV;$3LV;$3LV;$3LV;
    +$3LV;$3LV;$3LVD!t#,;!t#,;!t#,;!<1%>f`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&ql
    +f`&qlf`&qlfV$=6YbMJ0$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7C!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!ruTQaOnS\aOnS\
    +aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSddE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L=Yd1L=Yd1L=YcuO]IgcSk#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtIL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOk$3LV;$3LV;
    +$3LV;$3LV;$3LV;$3LV;$3LV;$3LV;$4I"D!t#,;!t#,;!t##[aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOm6.d]fJP>mpuI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4
    +$NgG4$NgB6fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^df[7bmZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUR]#_*6g$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#G=$3LV;$3LV;$3LV8rl>)l
    +rl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lTZGQcUBk-6#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#Rgb:$3L>3$3L>3$3L>3$3L>3$3L>3$3L>3
    +$3L>3$3L>3$3L>3$3L>3$3L>3!*mmlfZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\
    +fZ;F\fZ;F\fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^ma=ad&
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=YgNf,$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[8t$j[8t$j[8t$j[8t$j[8t$j[8t$j[8t
    +$j[8t$j[8t$j[8t$j[8t$j[8t$jZqB!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;$j-h=
    +$3LV;$3LV;$32[!fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfV6I8
    +YbMJ0$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7L!s/Q4!s/Q4
    +!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!ruTYaO&#\aO&#\aO&#\aO&#\aO&#\
    +aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSddE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +YcuO]IgcSk#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#Rgt@$3LV;$3LV;$3LV;$3LV;$3LV;
    +$3LV;$3LV;$3LV;$4I"D!t#,;!t#,;!t#&-a8jlla8jlla8jlla8jlla8jlla8jlla8jlla8jll
    +a8jlla8jlla8jkcaO7;f#[IiE$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j$MgUREaO&#\
    +aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnn]0!_^eYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L=Yd1L=Yd1L=Yd1L;dKk=2#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#QkD9$3LV;
    +$3LV;$3LV;$3LV;$3LV;$3LV;$3LV;$3LVD!t#,;!t#,;!t#,;!%l:EaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdU<(ceUBk-6#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#Rh(C!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4
    +!Wrf4!Wrf4!*n0td)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^ma=ad&ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=YgNf,$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$jZqB!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;$j-h=$3LV;$3LV;$3:(H
    +!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8*scd]fJP>mpuI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3
    +!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3!WrF-d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\
    +d)ak\d)ak\d)ak\fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^df[7bm
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUR]#_*6g$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I%#>;t%#>;t%#>;t%#>;t%#>;t%#>;t
    +%#>;t%#>;t%#>;t%#>;t%#>;t%#>;t!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#G=
    +$3LV;$3LV;$3LV8/]cScfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^>
    +aO7;f#[IiE$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$k!.=!t,24
    +!t,24!t,24!t,24!t,24!t,24!t,24!t,24!t,24!t,24!t,22>hI-EaOnS\aOnS\aOnS\aOnS\
    +aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnn]0!_^eYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L;J,~>
    +2752 375 32 dvxColor
    +dKk=2#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#QkD9$3LV;$3LV;$3LV;$3LV;$3LV;
    +$3LV;$3LV;$3LVD!t#,;!t#,;!t#,;!<1%>f`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&ql
    +f`&qlf`&qlfV$=6YbMJ0$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7C!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!ruTQaOnS\aOnS\
    +aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSddE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L=Yd1L=Yd1L=YcuO]IgcSk#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtIL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOk$3LV;$3LV;
    +$3LV;$3LV;$3LV;$3LV;$3LV;$3LV;$4I"D!t#,;!t#,;!t##[aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOm6.d]fJP>mpuI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4
    +$NgG4$NgB6fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^df[7bmZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUR]#_*6g$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#G=$3LV;$3LV;$3LV8rl>)l
    +rl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lTZGQcUBk-6#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#Rgb:$3L>3$3L>3$3L>3$3L>3$3L>3$3L>3
    +$3L>3$3L>3$3L>3$3L>3$3L>3!*mmlfZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\
    +fZ;F\fZ;F\fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^ma=ad&
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=YgNf,$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[8t$j[8t$j[8t$j[8t$j[8t$j[8t$j[8t
    +$j[8t$j[8t$j[8t$j[8t$j[8t$jZqB!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;$j-h=
    +$3LV;$3LV;$32[!fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfV6I8
    +YbMJ0$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7L!s/Q4!s/Q4
    +!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!ruTYaO&#\aO&#\aO&#\aO&#\aO&#\
    +aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSddE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +YcuO]IgcSk#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#Rgt@$3LV;$3LV;$3LV;$3LV;$3LV;
    +$3LV;$3LV;$3LV;$4I"D!t#,;!t#,;!t#&-a8jlla8jlla8jlla8jlla8jlla8jlla8jlla8jll
    +a8jlla8jlla8jkcaO7;f#[IiE$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j$MgUREaO&#\
    +aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnn]0!_^eYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L=Yd1L=Yd1L=Yd1L;dKk=2#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#QkD9$3LV;
    +$3LV;$3LV;$3LV;$3LV;$3LV;$3LV;$3LVD!t#,;!t#,;!t#,;!%l:EaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdU<(ceUBk-6#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#Rh(C!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4
    +!Wrf4!Wrf4!*n0td)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^ma=ad&ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=YgNf,$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$jZqB!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;$j-h=$3LV;$3LV;$3:(H
    +!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8*scd]fJP>mpuI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3
    +!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3!WrF-d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\
    +d)ak\d)ak\d)ak\fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^df[7bm
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUR]#_*6g$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I%#>;t%#>;t%#>;t%#>;t%#>;t%#>;t
    +%#>;t%#>;t%#>;t%#>;t%#>;t%#>;t!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#G=
    +$3LV;$3LV;$3LV8/]cScfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^>
    +aO7;f#[IiE$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$k!.=!t,24
    +!t,24!t,24!t,24!t,24!t,24!t,24!t,24!t,24!t,24!t,22>hI-EaOnS\aOnS\aOnS\aOnS\
    +aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnn]0!_^eYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L;J,~>
    +2784 375 32 dvxColor
    +dKk=2#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#QkD9$3LV;$3LV;$3LV;$3LV;$3LV;
    +$3LV;$3LV;$3LVD!t#,;!t#,;!t#,;!<1%>f`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&qlf`&ql
    +f`&qlf`&qlfV$=6YbMJ0$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7C!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!t#,3!ruTQaOnS\aOnS\
    +aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnS\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSddE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L=Yd1L=Yd1L=YcuO]IgcSk#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtIL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOtL^XOk$3LV;$3LV;
    +$3LV;$3LV;$3LV;$3LV;$3LV;$3LV;$4I"D!t#,;!t#,;!t##[aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOm6.d]fJP>mpuI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4$NgG4
    +$NgG4$NgB6fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;^d
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^df[7bmZEUX=ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUR]#_*6g$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#G=$3LV;$3LV;$3LV8rl>)l
    +rl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lrl>)lTZGQcUBk-6#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#Rgb:$3L>3$3L>3$3L>3$3L>3$3L>3$3L>3
    +$3L>3$3L>3$3L>3$3L>3$3L>3!*mmlfZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\fZ;F\
    +fZ;F\fZ;F\fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^ma=ad&
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=YgNf,$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[8t$j[8t$j[8t$j[8t$j[8t$j[8t$j[8t
    +$j[8t$j[8t$j[8t$j[8t$j[8t$jZqB!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;$j-h=
    +$3LV;$3LV;$32[!fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfV6I8
    +YbMJ0$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7L!s/Q4!s/Q4
    +!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!s/Q4!ruTYaO&#\aO&#\aO&#\aO&#\aO&#\
    +aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSddE!rGYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +YcuO]IgcSk#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#Rgt@$3LV;$3LV;$3LV;$3LV;$3LV;
    +$3LV;$3LV;$3LV;$4I"D!t#,;!t#,;!t#&-a8jlla8jlla8jlla8jlla8jlla8jlla8jlla8jll
    +a8jlla8jlla8jkcaO7;f#[IiE$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j$MgUREaO&#\
    +aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aO&#\aOnSdaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnn]0!_^eYd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=Yd1L=
    +Yd1L=Yd1L=Yd1L=Yd1L;dKk=2#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#RlLt#QkD9$3LV;
    +$3LV;$3LV;$3LV;$3LV;$3LV;$3LV;$3LVD!t#,;!t#,;!t#,;!%l:EaOnSdaOnSdaOnSdaOnSd
    +aOnSdaOnSdaOnSdaOnSdaOnSdaOnSdaOnSdU<(ceUBk-6#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#Rh(C!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4!Wrf4
    +!Wrf4!Wrf4!*n0td)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\
    +fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^ma=ad&ZEUX=ZEUX=ZEUX=
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=YgNf,$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I
    +$j[7I$j[7I$jZqB!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;$j-h=$3LV;$3LV;$3:(H
    +!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8.:l!8*scd]fJP>mpuI#RgtI#RgtI
    +#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI#RgtI!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3
    +!Wrc3!Wrc3!Wrc3!Wrc3!Wrc3!WrF-d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\d)ak\
    +d)ak\d)ak\d)ak\fZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^dfZ;^df[7bm
    +ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUX=ZEUR]#_*6g$j[7I$j[7I
    +$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I$j[7I%#>;t%#>;t%#>;t%#>;t%#>;t%#>;t
    +%#>;t%#>;t%#>;t%#>;t%#>;t%#>;t!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#G=
    +$3LV;$3LV;$3LV8J[!+trW<-%rW<-%rW<-%rW<-%rW<-%rW<-%rW<-%rW<-%rW<-%rW<-%rW<-%
    +cN@GR!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!2Hau0`J9V0`J9V0`J9V0`J9V0`J9V
    +0`J9V0`J9V0`J9V0`J9V0`J9V0`J9V0`J:.0X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X-'~>
    +2816 375 32 dvxColor
    +IgQ8b!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!`!W`?'!W`?'!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!%SS`NT@F!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%
    +!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`:.!W`B(!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&CQYk,(N0X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X-$6!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;
    +!t#&1!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!`!W`?'!W`?'
    +!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!%SS`N]=D!!<-%
    +!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!:IK>lb$.!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CsYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BV
    +YQ-BVYUqQ50X*780X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78IgQ8b!t#,;
    +!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!`!W`?'!W`?'!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!%SS`NT@F!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%
    +!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`:.!W`B(!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CQYk,(N0X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X-$6!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#&1!=Ai/
    +!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!`!W`?'!W`?'!W`?'!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!%SS`N]=D!!<-%!!<-%!!<-%
    +!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!:IK>lb$.!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&CsYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYUqQ5
    +0X*780X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78J,~>
    +2848 375 32 dvxColor
    +IgQ8b!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!`!W`?'!W`?'!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!%SS`NT@F!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%
    +!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`:.!W`B(!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&CQYk,(N0X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X-$6!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;
    +!t#&1!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!`!W`?'!W`?'
    +!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!%SS`N]=D!!<-%
    +!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!:IK>lb$.!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CsYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BV
    +YQ-BVYUqQ50X*780X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78IgQ8b!t#,;
    +!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!`!W`?'!W`?'!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!%SS`NT@F!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%
    +!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`:.!W`B(!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CQYk,(N0X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X-$6!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#&1!=Ai/
    +!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!`!W`?'!W`?'!W`?'!!*'#
    +!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!%SS`N]=D!!<-%!!<-%!!<-%
    +!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!:IK>lb$.!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&CsYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYUqQ5
    +0X*780X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78J,~>
    +2880 375 32 dvxColor
    +IgQ8b!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!`!W`?'!W`?'!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!*'#!!*'#!!%SS`NT@F!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%
    +!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`:.!W`B(!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&CQYk,(N0X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X*780X*780X-$6!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;
    +!t#&1!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!`!W`?'!W`?'
    +!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!%SS`N]=D!!<-%
    +!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!<-%!!:IK>lb$.!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CsYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BVYQ-BV
    +YQ-BVYUqQ50X*780X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78IgQ8b!t#,;
    +!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!`!W`?'!W`?'!W`?'!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#!!*'#
    +!!%SS`NT@F!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%
    +!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`9%!r`:.!W`B(!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)!s&K)
    +!s&K)!s&K)!s&K)!s&CQYk,(N0X*780X*780X*780X*780X*780X*780X*780X*780X*780X*78
    +0X*780X-$6!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#,;!t#&1!=Ai/
    +!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!=Ai/!!
    +2912 375 32 dvxColor
    +0E;+R!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!
    +2944 375 32 dvxColor
    +0E;+R!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!
    +2976 375 24 dvxColor
    +0E;+R!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!<<-#!
    +grestore showpage
    +%%EOF
    diff --git a/src/test/print/printTest.c b/src/test/print/printTest.c
    index df420f97..68e97d8e 100644
    --- a/src/test/print/printTest.c
    +++ b/src/test/print/printTest.c
    @@ -38,13 +38,20 @@
     //              program that includes print.bas and prefs.bas
     //   dialogs    the Print and Page Setup dialogs, the Retry / Cancel box
     //              and the Printing window's Cancel, driven in the test GUI
    +//   vector     PostScript pages drawn by the printer (printVec*, the
    +//              printer's faces, the Printer object on a PostScript
    +//              printer), rendered back by Ghostscript
     //
     // UPDATE_GOLDENS=1 in the environment rewrites the golden streams.
     
     #include "dvxtest.h"
     #include "dvxPlat.h"
     #include "dvxPrefs.h"
    +#include "dvxDraw.h"
    +#include "dvxFont.h"
    +#include "dvxText.h"
     #include "print/print.h"
    +#include "print/printInt.h"
     #include "compiler/compiler.h"
     #include "runtime/vm.h"
     #include "runtime/basErrors.h"
    @@ -55,6 +62,8 @@
     #include "spinner/spinner.h"
     #include "dvxBas.h"
     
    +#include 
    +#include 
     #include 
     #include 
     #include 
    @@ -85,9 +94,109 @@
     #define PAGES_FILE        "pages.prn"
     #define PAGES_SRC_MAX     65536
     #define FORM_FEED         0x0C
    +#define FORM_FEED_TEXT    "\f"
     #define ESC_CHAR          0x1B
     #define DEVICE_COLUMNS    100
     #define BAD_HANDLE        99
    +#define PCL300_DPI        300
    +#define PCL300_ROW_BYTES  300               // 8 inches of 300 dpi dots
    +#define PCL300_ROWS       3000              // 10 inches
    +#define PCL300_DOTS       (PCL300_ROW_BYTES * BITS_PER_BYTE)
    +#define BITS_PER_BYTE     8
    +#define TOP_BIT           0x80
    +#define INK_LETTERS       "KCMY"            // a decoded page's planes, in order
    +#define INK_COUNT         4
    +#define RIBBON_LETTERS    "KMC-Y"           // the ink of ESC r 0 .. 4
    +#define ORDER_MAX         8
    +#define ESCP_MODE_24PIN   39                // ESC * 39: three bytes a column
    +#define ESCP_PIN_BYTES_24 3
    +#define ESCP_HEADER_LEN   5                 // ESC * m nL nH
    +#define ESCP_UNITS_9PIN   3                 // ESC J units in a 72 dpi dot row
    +#define ESCP_UNITS_24PIN  1                 // ... in a 180 dpi one
    +#define EPSON9_DOTS       960               // 8 inches across at 120 dpi
    +#define EPSON9_ROWS       720               // 10 inches down at 72 dpi
    +#define EPSON24_DOTS      1440
    +#define EPSON24_ROWS      1800
    +#define PATCH_COUNT       8
    +#define SQUARE_DOTS       300
    +#define PRINTER_FILE      "printer.pcl"     // the Printer object's output
    +#define PRINTER_BMP       "printer.bmp"
    +#define PRINTER_ERROR_TITLE "BASIC Runtime Error"
    +#define PRINTER_INCH      300               // dots an inch on the 300 dpi printers
    +#define PRINTER_EDGE      5                 // dots in from a shape's edge
    +#define PRINTER_BOX       (PRINTER_INCH - 2 * PRINTER_EDGE)
    +#define PRINTER_HALF      (PRINTER_INCH / 2 - 2 * PRINTER_EDGE)
    +#define INK_K             0                 // a decoded page's planes (INK_LETTERS)
    +#define INK_C             1
    +#define INK_M             2
    +#define INK_Y             3
    +#define BMP_FILE_BYTES    62                // writeBmp's file: the headers, then one row
    +#define BMP_DATA_AT       54
    +#define BMP_INFO_BYTES    40
    +#define BMP_ROW_BYTES     8                 // 2 pixels of 3 bytes, padded to 4
    +#define IW_DOTS           1152              // 8 inches at 144 dpi
    +#define IW_ROWS           1440              // 10 inches
    +#define IW_ROW_BYTES      (IW_DOTS / BITS_PER_BYTE)
    +#define IW_BAND           16                // rows a band: two passes of 8 wires
    +#define IW_WIRE_ROWS      2                 // the wires are 1/72 inch apart: 2 rows
    +#define IW_PASSES         2                 // passes a band (its even rows, its odd rows)
    +#define IW_RIBBON_LETTERS "KYMC"            // the ink of ESC K 0 .. 3
    +#define IW_TEXT_BEGIN     "\x1B" "c" "\x1B" "Z\x80\x20" "\x1B" "D\x40\x04"
    +#define IW_SETUP_TEXT     IW_TEXT_BEGIN "\x1B" "p" "\x1B" ">"
    +#define SERIAL_SLOWEST    300
    +#define SERIAL_BAUD       4800
    +#define SERIAL_ODD_BAUD   1234
    +#define SERIAL_HELD       3                 // bytes the fake port holds at the close
    +#define PS_LEFT           75                // the printable area on a letter page at 300 dpi: 1/4 inch in
    +#define PS_TOP            150               // ... and 1/2 inch down
    +#define PS_EDGE           5                 // dots in from an edge, clear of rounding
    +#define PS_SHOWPAGE       "showpage"
    +#define VEC_FILE          "vector.ps"
    +#define VEC_MONO_FILE     "vectorbw.ps"
    +#define VEC_OPEN_FILE     "vecopen.ps"
    +#define VEC_TEXT_FILE     "vector.txt"
    +#define VEC_PRINTER_FILE  "printer.ps"
    +#define VEC_HELLO_DOTS    114               // Helvetica "Hello" at 12 points, 300 dpi: 2278 thousandths of 50 dots
    +#define VEC_HELLO_ASCENT  43                // (718 + (1200 - 925) / 2) thousandths of 50 dots
    +#define VEC_LINE_DOTS     60                // 1200 thousandths of 50 dots
    +#define VEC_UNDER_DOTS    272               // Times-Bold "Under" at 24 points: 2722 thousandths of 100 dots
    +#define VEC_TIMES_HELLO   111               // Times-Roman "Hello" at 12 points: 2222 thousandths of 50 dots
    +#define PPM_MAX           255
    +#define PPM_MAGIC         "P6"
    +#define PPM_HEADER_NUMS   3                 // width, height, levels
    +#define RGB_RED_SHIFT     16
    +#define RGB_GREEN_SHIFT   8
    +#define RGB_BYTE          0xFF
    +#define SPAN_ROWS         128               // a span page's size in dots
    +#define SPAN_COLS         512
    +#define SPAN_BAND         7                 // rows a band (not a divisor of anything)
    +#define TWELVE_POINTS     120               // in tenths of a point
    +#define DECIPOINTS_PER_POINT 10
    +#define TWELVE_POINT_DOTS 50                // at 300 dpi
    +#define NINE_PIN_DOTS_X   20                // 12 points at 120 dpi
    +#define NINE_PIN_DOTS_Y   12                // ... at 72 dpi
    +#define FONT_H_LAST_ROW   11                // the system font's 'H' ends on this row
    +#define PRINT_FONT_FAMILY "DejaVu Sans"
    +#define PRINT_FONT_BASE_MAX 24              // its largest base size
    +#define SQUARE_LEFT       100
    +#define SQUARE_TOP        200
    +#define CMY_PLANES        3                 // ESC * r -3 U
    +#define EPSON9_DPI_X      120
    +#define EPSON9_DPI_Y      72
    +#define PCL_PARAM_FIRST   '!'               // ESC and one of these starts a
    +#define PCL_PARAM_LAST    '/'               // parameterised PCL command
    +#define PCL_MODE_PACKBITS 2
    +#define PACK_RUN_BASE     257
    +#define PACK_LITERAL_MAX  127
    +#define STRIPE_DOTS       1000              // a 1x3 picture fits 1000 x 3000
    +#define STRIPE_BYTES      (STRIPE_DOTS / BITS_PER_BYTE)
    +#define NOISE_W           1200              // fits 2400 dots: two per pixel
    +#define NOISE_H           4
    +#define NOISE_SCALE       2
    +#define NOISE_PERIOD      12                // pixels: bytes CC 00 00 again and again
    +#define LCG_MUL           1103515245u
    +#define LCG_ADD           12345u
    +#define LCG_SHIFT         16
     
     // A scripted printer port
     typedef struct {
    @@ -102,6 +211,15 @@ typedef struct {
         bool    missing[PRINT_PORT_COUNT];
         char    out[FAKE_OUT_MAX];
         int32_t outLen;
    +    int32_t opens;                      // open calls, and what the last one was given
    +    int32_t openPort;
    +    int32_t openBaud;
    +    int32_t openHandshake;
    +    bool    refuseOpen;
    +    int32_t pending;                    // bytes "held"; each pending call lets one go
    +    int32_t pendingCalls;
    +    int32_t closes;
    +    int32_t closePort;
     } FakePortT;
     
     // Scripted job hooks
    @@ -135,6 +253,89 @@ typedef struct {
         char        warningTitle[PRINT_MSG_MAX];
     } DlgScriptT;
     
    +// A page decoded from a printed stream, as the printer would put it on
    +// paper: a plane of rows for each ink of INK_LETTERS, plane-major
    +typedef struct {
    +    uint8_t *bits;
    +    int32_t  rowBytes;
    +    int32_t  maxRows;
    +    int32_t  planes;                    // PCL ESC * r -n U; 1 when not set
    +    int32_t  rows;                      // PCL raster cursor / ESC/P rows fed
    +    int32_t  transfers;                 // PCL ESC * b V and W commands
    +    int32_t  dpi;                       // PCL ESC * t n R
    +    int32_t  pages;                     // PCL ESC * r 1 A commands
    +    bool     packBits;                  // PCL ESC * b 2 M
    +    char     order[ORDER_MAX];          // ESC/P ribbon inks of the first band
    +} PageDotsT;
    +
    +// A page the document tests draw: a square of SQUARE_DOTS in one color,
    +// with every band call checked
    +typedef struct {
    +    int32_t left;
    +    int32_t top;
    +    uint8_t rgb[3];
    +    int32_t width;                      // the page's, which every call must get
    +    int32_t nextY;                      // where the next band must start
    +    int32_t calls;
    +    int32_t failAt;                     // > 0: that call returns false
    +    bool    inOrder;
    +} SquarePageT;
    +
    +// Dots a print font's spans set on a page of SPAN_COLS x SPAN_ROWS; the
    +// spans must keep to rows top .. bottom (a band)
    +typedef struct {
    +    uint8_t dots[SPAN_ROWS][SPAN_COLS];
    +    int32_t top;
    +    int32_t bottom;
    +    int32_t outside;                    // spans off the page or outside the band
    +    int32_t lowest;                     // the lowest row set (-1: none)
    +} SpanPageT;
    +
    +// A point of a rendered vector page (the page's dots) and its colour
    +typedef struct {
    +    int32_t  x;
    +    int32_t  y;
    +    uint32_t rgb;
    +} VecCheckT;
    +
    +// A Printer object program's run: the runtime errors it raised
    +typedef struct {
    +    int32_t errors;
    +} PrinterRunT;
    +
    +// A picture Ghostscript rendered (R, G, B bytes)
    +typedef struct {
    +    uint8_t *rgb;
    +    int32_t  w;
    +    int32_t  h;
    +} PpmT;
    +
    +// A color model's picture: its stream decoded and checked patch by patch
    +typedef struct {
    +    PrintModelE        model;
    +    const char        *file;
    +    const char        *golden;
    +    int32_t            unitsPerRow;     // ESC/P: ESC J units a dot row; 0: PCL
    +    int32_t            widthDots;
    +    int32_t            maxRows;
    +    int32_t            patchRows;
    +    const char *const *want;            // inks of each patch
    +    const char        *order;           // ESC/P: the band's ink order
    +    int32_t            planes;          // PCL: ESC * r -n U
    +} ColorCaseT;
    +
    +// fontLinesBand's lines: a size in tenths of a point and a style each
    +static const int32_t  sLineSizes[]  = { 80, 100, 120, 140, 180, 240, 120, 120, 120, 120 };
    +static const uint32_t sLineStyles[] = { 0, 0, 0, 0, 0, 0, FONT_STYLE_BOLD, FONT_STYLE_ITALIC, FONT_STYLE_BOLD | FONT_STYLE_ITALIC, FONT_STYLE_UNDERLINE };
    +
    +// Printer object programs that each end in a runtime error
    +static const char *const sPrinterErrors[] = {
    +    "Printer.Width = 5\n",
    +    "Printer.Orientation = vbPRORLandscape\n",
    +    "Printer.FontSize = 0\n",
    +    "Printer.Frobnicate 1\n"
    +};
    +
     static FakePortT sFake;
     static char      sCapture[CAPTURE_MAX];
     
    @@ -148,44 +349,84 @@ static void           captureOut(void *ctx, const char *text, bool newline);
     static void           caseBasic(void);
     static void           caseBasicErrors(void);
     static void           caseBasicPages(void);
    +static void           caseColor(void);
     static void           caseDevice(void);
     static void           caseDialogs(void);
    +static void           caseDocuments(void);
     static void           caseFileErrors(void);
    +static void           caseFonts(void);
     static void           caseHeaderCodes(void);
    +static void           caseImageWriter(void);
    +static void           casePcl300(void);
     static void           casePictures(void);
     static void           casePorts(void);
     static void           casePortTimeout(void);
    +static void           casePostScript(void);
    +static void           casePrinter(void);
    +static void           caseSerial(void);
     static void           caseSettings(void);
     static void           caseText(void);
     static void           caseTextOptions(void);
    +static void           caseVector(void);
     static bool           checkGolden(const char *name, const char *path);
     static BasModuleT    *compile(const char *src);
    -static int32_t        countFormFeeds(const char *path);
    +static int32_t        countText(const char *path, const char *text);
    +static bool           escpDecode(const char *path, int32_t unitsPerRow, int32_t rowBytes, int32_t maxRows, PageDotsT *pg);
    +static void           fakeClose(void *ctx, int32_t port);
    +static bool           fakeOpen(void *ctx, int32_t port, int32_t baud, PrintHandshakeE handshake);
    +static int32_t        fakePending(void *ctx, int32_t port);
     static bool           fakePresent(void *ctx, int32_t port);
     static uint8_t        fakeStatus(void *ctx, int32_t port);
     static uint8_t        fakeWrite(void *ctx, int32_t port, uint8_t byte);
     static bool           fileEquals(const char *path, const char *expect, int32_t len);
     static PrintJobT     *fileJob(const char *path, HookT *h);
    +static bool           fontLinesBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +static bool           gsRender(const char *path, PpmT *img);
    +static bool           gsText(const char *path, const char *out);
     static PrintAnswerE   hookAsk(void *ctx, const char *message);
     static void           hookFinished(void *ctx, bool closing);
     static bool           hookIdle(void *ctx);
     static void           hookInit(HookT *h);
     static PrintHooksT    hooksOf(HookT *h);
    +static int32_t        inkIn(const PageDotsT *pg, int32_t ink, int32_t x0, int32_t y0, int32_t w, int32_t h);
    +static bool           iwDecode(const char *path, int32_t rowBytes, int32_t maxRows, PageDotsT *pg);
    +static bool           pageDot(const PageDotsT *pg, int32_t ink, int32_t x, int32_t y);
    +static bool           pageDotSet(PageDotsT *pg, int32_t ink, int32_t x, int32_t y);
    +static bool           pageDotsInit(PageDotsT *pg, int32_t rowBytes, int32_t maxRows);
     static void           pageSetup(PrintPageSetupT *ps);
    +static int32_t        patchErrors(const PageDotsT *pg, int32_t widthDots, int32_t rows, const char *const *want);
    +static bool           pclDecode(const char *path, int32_t page, int32_t rowBytes, int32_t maxRows, PageDotsT *pg);
    +static int32_t        pclPageCount(const char *path);
    +static bool           pclRow(PageDotsT *pg, int32_t plane, int32_t mode, const uint8_t *in, int32_t n);
    +static bool           ppmIs(const PpmT *img, int32_t x, int32_t y, uint32_t rgb);
    +static bool           ppmRead(const char *path, PpmT *img);
     static void           printDlgScript(AppContextT *ctx, WindowT *modal, void *userCtx);
    +static void           printerErrorScript(AppContextT *ctx, WindowT *modal, void *userCtx);
    +static void           printerRun(PrinterRunT *run, const char *src);
     static void           progressScript(AppContextT *ctx, WindowT *modal, void *userCtx);
     static void           resetFake(void);
     static void           retryScript(AppContextT *ctx, WindowT *modal, void *userCtx);
     static BasVmResultE   runProgram(const char *src, VmPrinterT *vp, bool withPrinter);
     static void           setupDlgScript(AppContextT *ctx, WindowT *modal, void *userCtx);
    +static int32_t        spanCount(const SpanPageT *sp);
    +static void           spanReset(SpanPageT *sp, int32_t top, int32_t bottom);
    +static void           spanSet(void *ctx, int32_t x0, int32_t x1, int32_t y);
    +static bool           squareBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows);
    +static int32_t        squareErrors(const PageDotsT *pg, const SquarePageT *sq, const char *want);
    +static void           squareInit(SquarePageT *sq, int32_t left, int32_t top, uint8_t r, uint8_t g, uint8_t b, int32_t width);
    +static bool           unpackBits(const uint8_t *in, int32_t n, uint8_t *out, int32_t outMax);
     static bool           vmClose(void *ctx, void *job);
     static void          *vmOpen(void *ctx, int32_t port);
     static bool           vmWrite(void *ctx, void *job, const char *data, int32_t len);
    -static void           writePicture(const PrintDriverT *drv, const uint8_t *rgb, int32_t w, int32_t h, const char *path);
    +static bool           writeBmp(const char *path);
    +static void           writePicture(PrintModelE model, const uint8_t *rgb, int32_t w, int32_t h, const char *path);
     
     // print.bas / prefs.bas entry points (printBas.c, dvxPrefsBas.c)
     void                  PrefsClose(int32_t handle);
     int32_t               PrefsCreate(void);
    +int32_t               PrintBaudRate(int32_t index);
    +int32_t               PrintCanPrintColor(void);
    +int32_t               PrintCanPrintPictures(void);
     int32_t               PrintChoiceCopies(void);
     int32_t               PrintChoiceDest(void);
     const char           *PrintChoiceFile(void);
    @@ -194,7 +435,10 @@ int32_t               PrintChoiceLast(void);
     int32_t               PrintChoose(const char *title, int32_t pageCount);
     int32_t               PrintDestCount(void);
     int32_t               PrintDestIsFile(int32_t dest);
    +int32_t               PrintDestIsSerial(int32_t dest);
     const char           *PrintDestLabel(int32_t dest);
    +int32_t               PrintHandshakeCount(void);
    +const char           *PrintHandshakeLabel(int32_t handshake);
     int32_t               PrintModelCount(void);
     const char           *PrintModelLabel(int32_t model);
     int32_t               PrintPageCount(const char *text);
    @@ -205,15 +449,18 @@ const char           *PrintPageSetupProblem(int32_t lines, int32_t columns, int3
     const char           *PrintPageText(int32_t prefs, const char *field);
     int32_t               PrintPortPresent(int32_t port);
     int32_t               PrintPortStatus(int32_t port);
    +int32_t               PrintSerialStore(int32_t prefs, int32_t baud, int32_t handshake);
    +int32_t               PrintSettingBaud(int32_t prefs);
     int32_t               PrintSettingDest(int32_t prefs);
     const char           *PrintSettingFile(int32_t prefs);
    +int32_t               PrintSettingHandshake(int32_t prefs);
     int32_t               PrintSettingModel(int32_t prefs);
     const char           *PrintSettingsProblem(int32_t dest, const char *filePath);
     int32_t               PrintSettingsStore(int32_t prefs, int32_t model, int32_t dest, const char *filePath);
     const char           *PrintStatusProblem(int32_t status, int32_t afterWrite, const char *where);
     int32_t               PrintTextPages(const char *text, const char *title, int32_t firstPage, int32_t lastPage, int32_t copies);
     
    -static const PrintPortOpsT sFakeOps = { fakePresent, fakeStatus, fakeWrite, NULL };
    +static const PrintPortOpsT sFakeOps = { fakePresent, fakeStatus, fakeWrite, NULL, fakeOpen, fakePending, fakeClose };
     
     
     static void addExpectLine(char *buf, int32_t *len, const char *text) {
    @@ -413,9 +660,9 @@ static void caseBasicPages(void) {
         TEST_EQ(PrintPageCount(text), pages);
         TEST_EQ(PrintPageCount(""), 1);
         TEST_EQ(PrintTextPages(text, "doc", 2, 2, 1), BAS_TRUE);
    -    TEST_EQ(countFormFeeds(PAGES_FILE), 1);
    +    TEST_EQ(countText(PAGES_FILE, FORM_FEED_TEXT), 1);
         TEST_EQ(PrintTextPages(text, "doc", 0, 0, 2), BAS_TRUE);
    -    TEST_EQ(countFormFeeds(PAGES_FILE), pages * 2);
    +    TEST_EQ(countText(PAGES_FILE, FORM_FEED_TEXT), pages * 2);
     
         // PrintChoose: the dialog script picks a file, 2 copies, pages 2-3
         memset(&ds, 0, sizeof(ds));
    @@ -484,6 +731,98 @@ static void caseBasicPages(void) {
     }
     
     
    +// The color models: a picture of color patches decoded back, ink by ink,
    +// from each model's stream; a picture with every shade against goldens
    +static void caseColor(void) {
    +    tCase("color");
    +
    +    static const uint8_t patches[PATCH_COUNT * 3] = {
    +        0x00, 0xFF, 0xFF,               // cyan
    +        0xFF, 0x00, 0xFF,               // magenta
    +        0xFF, 0xFF, 0x00,               // yellow
    +        0x00, 0x00, 0x00,               // black
    +        0xFF, 0x00, 0x00,               // red
    +        0x00, 0xFF, 0x00,               // green
    +        0x00, 0x00, 0xFF,               // blue
    +        0xFF, 0xFF, 0xFF                // white
    +    };
    +    static const char *const fourInks[PATCH_COUNT]  = { "C", "M", "Y", "K", "MY", "CY", "CM", "" };
    +    static const char *const threeInks[PATCH_COUNT] = { "C", "M", "Y", "CMY", "MY", "CY", "CM", "" };
    +    static const ColorCaseT  cases[] = {
    +        { PrintModelEpson9ColorE,  "patch9.prn",  "epson9color.prn",  ESCP_UNITS_9PIN,  EPSON9_DOTS,  EPSON9_ROWS,  EPSON9_DOTS / PATCH_COUNT * EPSON9_DPI_Y / EPSON9_DPI_X, fourInks,  "YMCK", 0 },
    +        { PrintModelEpson24ColorE, "patch24.prn", "epson24color.prn", ESCP_UNITS_24PIN, EPSON24_DOTS, EPSON24_ROWS, EPSON24_DOTS / PATCH_COUNT,          fourInks,  "YMCK", 0 },
    +        { PrintModelDeskJet500CE,  "patch500.pcl", "deskjet500c.prn", 0,                PCL300_DOTS,  PCL300_ROWS,  PCL300_DOTS / PATCH_COUNT,           threeInks, "",     CMY_PLANES },
    +        { PrintModelDeskJetColorE, "patch550.pcl", "deskjetcolor.prn", 0,               PCL300_DOTS,  PCL300_ROWS,  PCL300_DOTS / PATCH_COUNT,           fourInks,  "",     INK_COUNT }
    +    };
    +    uint8_t shades[PIC_W * PIC_H * 3];
    +
    +    // Which models print in color
    +    for (int32_t m = 0; m < PrintModelCountE; m++) {
    +        bool color = m == PrintModelEpson9ColorE || m == PrintModelEpson24ColorE || m == PrintModelDeskJet500CE || m == PrintModelDeskJetColorE || m == PrintModelPostScriptColorE || m == PrintModelImageWriterColorE;
    +
    +        PrintPageInfoT info;
    +
    +        TEST_EQ(printModelPage((PrintModelE)m, &info) && info.color, color);
    +    }
    +
    +    // Hue across, light to dark down
    +    for (int32_t y = 0; y < PIC_H; y++) {
    +        for (int32_t x = 0; x < PIC_W; x++) {
    +            uint8_t *p = &shades[(y * PIC_W + x) * 3];
    +
    +            p[0] = (uint8_t)(x * 255 / (PIC_W - 1));
    +            p[1] = (uint8_t)(255 - y * 255 / (PIC_H - 1));
    +            p[2] = (uint8_t)((x + y) * 255 / (PIC_W + PIC_H - 2));
    +        }
    +    }
    +
    +    for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
    +        const ColorCaseT *c = &cases[i];
    +        PageDotsT         pg;
    +        bool              ok;
    +
    +        writePicture(c->model, patches, PATCH_COUNT, 1, c->file);
    +
    +        if (c->unitsPerRow) {
    +            ok = escpDecode(c->file, c->unitsPerRow, c->widthDots / BITS_PER_BYTE, c->maxRows, &pg);
    +            TEST_STR_EQ(pg.order, c->order);
    +        } else {
    +            ok = pclDecode(c->file, 0, c->widthDots / BITS_PER_BYTE, c->maxRows, &pg);
    +            TEST_EQ(pg.planes, c->planes);
    +            TEST_TRUE(pg.packBits);
    +        }
    +
    +        TEST_TRUE(ok);
    +        TEST_EQ(pg.rows, c->patchRows);
    +        TEST_EQ(patchErrors(&pg, c->widthDots, c->patchRows, c->want), 0);
    +        free(pg.bits);
    +
    +        writePicture(c->model, shades, PIC_W, PIC_H, c->golden);
    +        TEST_TRUE(checkGolden(c->golden, c->golden));
    +    }
    +
    +    // The settings' model, for BASIC
    +    PrintSettingsT s;
    +    PrintSettingsT keep;
    +
    +    printSettingsLoad(&keep);
    +    s       = keep;
    +    s.model = PrintModelDeskJetColorE;
    +    TEST_TRUE(printSettingsSave(&s));
    +    TEST_EQ(PrintCanPrintColor(), BAS_TRUE);
    +    TEST_EQ(PrintCanPrintPictures(), BAS_TRUE);
    +    s.model = PrintModelLaserJet300E;
    +    TEST_TRUE(printSettingsSave(&s));
    +    TEST_EQ(PrintCanPrintColor(), BAS_FALSE);
    +    TEST_EQ(PrintCanPrintPictures(), BAS_TRUE);
    +    s.model = PrintModelTextE;
    +    TEST_TRUE(printSettingsSave(&s));
    +    TEST_EQ(PrintCanPrintColor(), BAS_FALSE);
    +    TEST_EQ(PrintCanPrintPictures(), BAS_FALSE);
    +    TEST_TRUE(printSettingsSave(&keep));
    +}
    +
    +
     static void caseDevice(void) {
         tCase("device settings");
     
    @@ -561,11 +900,11 @@ static void caseDevice(void) {
                 "FOR i = 0 TO PrintModelCount() - 1\n"
                 "    IF PrintModelLabel$(i) = \"\" THEN PRINT \"empty\"\n"
                 "NEXT i\n"
    -            "PRINT PrintDestCount(); PrintDestLabel$(0); PrintDestIsFile(3)\n"
    -            "PRINT \"[\" & PrintSettingsProblem$(3, \"\") & \"]\"\n"
    -            "PRINT PrintSettingsStore(p, 1, 3, \"B.PRN\")\n"
    +            "PRINT PrintDestCount(); PrintDestLabel$(0); PrintDestIsFile(%d)\n"
    +            "PRINT \"[\" & PrintSettingsProblem$(%d, \"\") & \"]\"\n"
    +            "PRINT PrintSettingsStore(p, 1, %d, \"B.PRN\")\n"
                 "PRINT PrintSettingModel(p); PrintSettingDest(p); PrintSettingFile$(p)\n"
    -            "PrefsClose p\n", prefsBas, printBas);
    +            "PrefsClose p\n", prefsBas, printBas, PrintDestFileE, PrintDestFileE, PrintDestFileE);
     
             basNativeHostSetLive(true);
             BasVmResultE rr = runProgram(src, NULL, false);
    @@ -575,7 +914,7 @@ static void caseDevice(void) {
     
             char expect[CAPTURE_MAX];
     
    -        snprintf(expect, sizeof(expect), "4 LPT1-1 \n[%s]\n-1 \n1 3 B.PRN\n", printDeviceProblem(PrintDestFileE, ""));
    +        snprintf(expect, sizeof(expect), "%d LPT1-1 \n[%s]\n-1 \n1 %d B.PRN\n", PrintDestCountE, printDeviceProblem(PrintDestFileE, ""), PrintDestFileE);
             TEST_STR_EQ(sCapture, expect);
         }
     
    @@ -643,8 +982,8 @@ static void caseDialogs(void) {
     
         TEST_TRUE(stat("uitext.prn", &st) == 0 && st.st_size > 0);
     
    -    // A picture that cannot be read, a window on a text-only printer:
    -    // both are told in a message box
    +    // A picture that cannot be read, a window and a document on a
    +    // text-only printer: each is told in a message box
         WindowT *win = dvxCreateWindowCentered(tCtx(), "Pic", SCREEN_W / 4, SCREEN_H / 4, false);
     
         wgtInitWindow(tCtx(), win);
    @@ -653,8 +992,9 @@ static void caseDialogs(void) {
         tDialogScript(printDlgScript, &ds);
         TEST_TRUE(!printUiImageFile(tCtx(), "missing.bmp", "pic", false));
         TEST_TRUE(!printUiWindow(tCtx(), win, false));
    +    TEST_TRUE(printUiDocOpen(tCtx(), "drawn", NULL) == NULL);
         tDialogScript(NULL, NULL);
    -    TEST_EQ(ds.warnings, 2);
    +    TEST_EQ(ds.warnings, 3);
     
         // The window on a 9-pin printer, into a file
         s.model = PrintModelEpson9E;
    @@ -665,6 +1005,26 @@ static void caseDialogs(void) {
         TEST_TRUE(!printUiWindow(tCtx(), NULL, false));
         dvxDestroyWindow(tCtx(), win);
     
    +    // A document the program draws, there too: two pages
    +    PrintDocT *doc = printUiDocOpen(tCtx(), "drawn", NULL);
    +
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        PrintPageInfoT info;
    +        SquarePageT    sq;
    +
    +        printDocInfo(doc, &info);
    +        squareInit(&sq, SQUARE_LEFT, SQUARE_TOP, 0, 0, 0, info.widthDots);
    +        TEST_TRUE(printDocPage(doc, squareBand, &sq));
    +        squareInit(&sq, SQUARE_LEFT, SQUARE_TOP, 0, 0, 0, info.widthDots);
    +        TEST_TRUE(printDocPage(doc, squareBand, &sq));
    +        TEST_TRUE(printUiDocClose(tCtx(), doc));
    +    }
    +
    +    TEST_EQ(countText("uiwin.prn", FORM_FEED_TEXT), 2);
    +    TEST_TRUE(!printUiDocClose(tCtx(), NULL));
    +
         // Retry / Cancel box: off line once, Retry, then the byte goes out
         s.dest = PrintDestLpt1E;
         TEST_TRUE(printSettingsSave(&s));
    @@ -712,6 +1072,133 @@ static void caseDialogs(void) {
     }
     
     
    +// Documents: each model's page, pages a program draws (decoded back),
    +// failures, and a cancel
    +static void caseDocuments(void) {
    +    tCase("documents");
    +
    +    PrintPageInfoT info;
    +    PageDotsT      pg;
    +    SquarePageT    red;
    +    SquarePageT    blue;
    +    char           err[PRINT_MSG_MAX];
    +
    +    // Each model's page
    +    TEST_TRUE(!printModelPage(PrintModelTextE, &info));
    +    TEST_TRUE(!printModelPage(PrintModelCountE, NULL));
    +    TEST_TRUE(printModelPage(PrintModelEpson9E, NULL));
    +    TEST_TRUE(printModelPage(PrintModelEpson9E, &info));
    +    TEST_EQ(info.dpiX, EPSON9_DPI_X);
    +    TEST_EQ(info.dpiY, EPSON9_DPI_Y);
    +    TEST_EQ(info.widthDots, EPSON9_DOTS);
    +    TEST_EQ(info.heightDots, EPSON9_ROWS);
    +    TEST_TRUE(!info.color);
    +    TEST_TRUE(printModelPage(PrintModelDeskJetColorE, &info));
    +    TEST_EQ(info.dpiX, PCL300_DPI);
    +    TEST_EQ(info.dpiY, PCL300_DPI);
    +    TEST_EQ(info.widthDots, PCL300_DOTS);
    +    TEST_EQ(info.heightDots, PCL300_ROWS);
    +    TEST_TRUE(info.color);
    +    TEST_TRUE(!info.vector);
    +
    +    TEST_TRUE(printModelPage(PrintModelPostScriptE, &info));
    +    TEST_TRUE(info.vector);
    +
    +    // No document for text only (its job is closed) or without a job
    +    TEST_TRUE(printDocOpen(fileJob("textdoc.prn", NULL), PrintModelTextE) == NULL);
    +    TEST_TRUE(printDocOpen(NULL, PrintModelLaserJetE) == NULL);
    +
    +    // Two pages: a red square on the first, a blue one lower down on the
    +    // second, every band asked for in order across the whole page
    +    PrintDocT *doc = printDocOpen(fileJob("twopage.pcl", NULL), PrintModelDeskJetColorE);
    +
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        printDocInfo(doc, &info);
    +        TEST_EQ(info.widthDots, PCL300_DOTS);
    +        TEST_TRUE(info.color);
    +        squareInit(&red, SQUARE_LEFT, SQUARE_TOP, 0xFF, 0x00, 0x00, info.widthDots);
    +        squareInit(&blue, SQUARE_LEFT * 2, SQUARE_TOP * 2, 0x00, 0x00, 0xFF, info.widthDots);
    +        TEST_TRUE(printDocPage(doc, squareBand, &red));
    +        TEST_TRUE(red.inOrder);
    +        TEST_EQ(red.nextY, PCL300_ROWS);
    +        TEST_TRUE(printDocPage(doc, squareBand, &blue));
    +        TEST_TRUE(blue.inOrder);
    +        TEST_TRUE(!printDocCancelled(doc));
    +        TEST_TRUE(printDocClose(doc));
    +    }
    +
    +    TEST_EQ(countText("twopage.pcl", FORM_FEED_TEXT), 2);
    +    TEST_TRUE(pclDecode("twopage.pcl", 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_EQ(pg.pages, 2);
    +    TEST_EQ(squareErrors(&pg, &red, "MY"), 0);
    +    free(pg.bits);
    +    TEST_TRUE(pclDecode("twopage.pcl", 1, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_EQ(squareErrors(&pg, &blue, "CM"), 0);
    +    free(pg.bits);
    +
    +    // A band that fails ends the page, and every page after it
    +    doc = printDocOpen(fileJob("faildoc.prn", NULL), PrintModelEpson24E);
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        printDocInfo(doc, &info);
    +        squareInit(&red, SQUARE_LEFT, SQUARE_TOP, 0, 0, 0, info.widthDots);
    +        squareInit(&blue, SQUARE_LEFT, SQUARE_TOP, 0, 0, 0, info.widthDots);
    +        red.failAt = 2;
    +        TEST_TRUE(!printDocPage(doc, squareBand, &red));
    +        TEST_EQ(red.calls, 2);
    +        TEST_TRUE(!printDocPage(doc, squareBand, &blue));
    +        TEST_EQ(blue.calls, 0);
    +        TEST_TRUE(!printDocClose(doc));
    +    }
    +
    +    // A picture with no pixels fails the document
    +    uint8_t px[3] = { 0 };
    +
    +    doc = printDocOpen(fileJob("badpic.prn", NULL), PrintModelEpson9E);
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        TEST_TRUE(!printDocPicture(doc, NULL, 1, 1, 3));
    +        TEST_TRUE(!printDocPicture(doc, px, 1, 1, 3));
    +        TEST_TRUE(!printDocClose(doc));
    +    }
    +
    +    doc = printDocOpen(fileJob("badpic.prn", NULL), PrintModelEpson9E);
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        TEST_TRUE(!printDocPicture(doc, px, 0, 1, 3));
    +        TEST_TRUE(!printDocClose(doc));
    +    }
    +
    +    TEST_TRUE(!printDocClose(NULL));
    +
    +    // Cancelled from the job's idle hook while a page goes out
    +    HookT       hook;
    +    PrintHooksT hooks;
    +
    +    resetFake();
    +    printSetPortOps(&sFakeOps);
    +    hookInit(&hook);
    +    hook.cancelAfterIdles = 1;
    +    hooks                 = hooksOf(&hook);
    +    doc                   = printDocOpen(printJobOpen(PrintDestLpt1E, NULL, &hooks, err, sizeof(err)), PrintModelLaserJet300E);
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        squareInit(&red, SQUARE_LEFT, SQUARE_TOP, 0, 0, 0, PCL300_DOTS);
    +        TEST_TRUE(!printDocPage(doc, squareBand, &red));
    +        TEST_TRUE(printDocCancelled(doc));
    +        TEST_TRUE(!printDocClose(doc));
    +    }
    +
    +    printSetPortOps(NULL);
    +}
    +
    +
     static void caseFileErrors(void) {
         tCase("file destination errors");
     
    @@ -747,6 +1234,185 @@ static void caseFileErrors(void) {
     }
     
     
    +// Text at printer sizes: the system font scaled (12 points at 300 dpi
    +// is 50 dots, its 16 pixels 3 1/8 times over), a 9-pin page's unequal
    +// dots, an underline, band clipping, then DejaVu Sans scaled up from its
    +// base sizes and drawn as it is from the add-on's 50-pixel size
    +static void caseFonts(void) {
    +    tCase("fonts");
    +
    +    static SpanPageT sp;
    +    PrintPageInfoT   laser;
    +    PrintPageInfoT   pin9;
    +    int32_t          bad = 0;
    +
    +    TEST_TRUE(printModelPage(PrintModelLaserJet300E, &laser));
    +    TEST_TRUE(printModelPage(PrintModelEpson9E, &pin9));
    +    TEST_TRUE(printFontOpen(&laser, NULL, 0, 0) == NULL);
    +    TEST_TRUE(printFontOpen(NULL, NULL, TWELVE_POINTS, 0) == NULL);
    +    printFontClose(NULL);
    +
    +    PrintFontT *sys = printFontOpen(&laser, NULL, TWELVE_POINTS, 0);
    +
    +    TEST_TRUE(sys != NULL);
    +
    +    if (sys) {
    +        TEST_EQ(printFontHeight(sys), TWELVE_POINT_DOTS);
    +        TEST_EQ(printFontAscent(sys), FONT_SYS_ASCENT * TWELVE_POINT_DOTS / FONT_CHAR_HEIGHT);
    +        TEST_EQ(printFontWidth(sys, "AB", 2), 2 * FONT_CHAR_WIDTH * TWELVE_POINT_DOTS / FONT_CHAR_HEIGHT);
    +        TEST_EQ(printFontWidth(sys, "AB", 0), 0);
    +
    +        // 'H': each glyph pixel p becomes dots p * 50 / 16 up to (p + 1)
    +        // * 50 / 16 (rounded down), so dot d is pixel ((d + 1) * 16 - 1)
    +        // / 50
    +        spanReset(&sp, 0, SPAN_ROWS - 1);
    +        printFontSpans(sys, 0, 0, "H", 1, 0, SPAN_ROWS - 1, spanSet, &sp);
    +
    +        for (int32_t y = 0; y < SPAN_ROWS; y++) {
    +            for (int32_t x = 0; x < SPAN_COLS; x++) {
    +                int32_t r    = ((y + 1) * FONT_CHAR_HEIGHT - 1) / TWELVE_POINT_DOTS;
    +                int32_t c    = ((x + 1) * FONT_CHAR_HEIGHT - 1) / TWELVE_POINT_DOTS;
    +                bool    want = y < TWELVE_POINT_DOTS && c < FONT_CHAR_WIDTH && (font8x16['H' * FONT_CHAR_HEIGHT + r] & (TOP_BIT >> c));
    +
    +                bad += sp.dots[y][x] != want;
    +            }
    +        }
    +
    +        TEST_EQ(bad, 0);
    +        TEST_EQ(sp.outside, 0);
    +
    +        // Band by band: every span inside its band, together the same
    +        static SpanPageT whole;
    +
    +        whole = sp;
    +        spanReset(&sp, 0, SPAN_ROWS - 1);
    +
    +        for (int32_t y0 = 0; y0 < TWELVE_POINT_DOTS; y0 += SPAN_BAND) {
    +            sp.top    = y0;
    +            sp.bottom = y0 + SPAN_BAND - 1;
    +            printFontSpans(sys, 0, 0, "H", 1, sp.top, sp.bottom, spanSet, &sp);
    +        }
    +
    +        TEST_EQ(sp.outside, 0);
    +        TEST_TRUE(memcmp(sp.dots, whole.dots, sizeof(sp.dots)) == 0);
    +
    +        // A line that misses the band draws nothing
    +        spanReset(&sp, 0, SPAN_ROWS - 1);
    +        printFontSpans(sys, 0, SPAN_ROWS, "H", 1, 0, SPAN_ROWS - 1, spanSet, &sp);
    +        TEST_EQ(spanCount(&sp), 0);
    +
    +        // Into an RGB band, in red
    +        static uint8_t band[SPAN_ROWS * SPAN_COLS * 3];
    +        int32_t        red = 0;
    +
    +        memset(band, 0xFF, sizeof(band));
    +        printFontDraw(sys, 0, 0, "H", 1, 0xFF0000, band, SPAN_COLS, 0, SPAN_ROWS);
    +
    +        for (int32_t i = 0; i < SPAN_ROWS * SPAN_COLS; i++) {
    +            red += band[i * 3] == 0xFF && band[i * 3 + 1] == 0 && band[i * 3 + 2] == 0;
    +        }
    +
    +        TEST_EQ(red, spanCount(&whole));
    +        printFontClose(sys);
    +    }
    +
    +    // Underlined: the same letter plus rows across its width
    +    PrintFontT *under = printFontOpen(&laser, NULL, TWELVE_POINTS, FONT_STYLE_UNDERLINE);
    +
    +    TEST_TRUE(under != NULL);
    +
    +    if (under) {
    +        int32_t y    = FONT_SYS_UNDERLINE_Y * TWELVE_POINT_DOTS / FONT_CHAR_HEIGHT;
    +        int32_t rows = (FONT_SYS_UNDERLINE_Y + FONT_SYS_LINE_THICK) * TWELVE_POINT_DOTS / FONT_CHAR_HEIGHT - y;
    +
    +        spanReset(&sp, 0, SPAN_ROWS - 1);
    +        printFontSpans(under, 0, 0, " ", 1, 0, SPAN_ROWS - 1, spanSet, &sp);
    +        TEST_EQ(spanCount(&sp), printFontWidth(under, " ", 1) * rows);
    +        TEST_EQ(sp.dots[y][0], 1);
    +        printFontClose(under);
    +    }
    +
    +    // A 9-pin page: 12 points is 20 dots across and 12 down
    +    PrintFontT *p9 = printFontOpen(&pin9, NULL, TWELVE_POINTS, 0);
    +
    +    TEST_TRUE(p9 != NULL);
    +
    +    if (p9) {
    +        TEST_EQ(printFontHeight(p9), FONT_CHAR_HEIGHT * NINE_PIN_DOTS_Y / FONT_CHAR_HEIGHT);
    +        TEST_EQ(printFontWidth(p9, "A", 1), FONT_CHAR_WIDTH * NINE_PIN_DOTS_X / FONT_CHAR_HEIGHT);
    +        spanReset(&sp, 0, SPAN_ROWS - 1);
    +        printFontSpans(p9, 0, 0, "H", 1, 0, SPAN_ROWS - 1, spanSet, &sp);
    +        TEST_TRUE(spanCount(&sp) > 0);
    +        TEST_EQ(sp.lowest, NINE_PIN_DOTS_Y - 1 - (FONT_CHAR_HEIGHT - FONT_H_LAST_ROW - 1) * NINE_PIN_DOTS_Y / FONT_CHAR_HEIGHT);
    +        printFontClose(p9);
    +    }
    +
    +    // DejaVu Sans with its base sizes: the largest, 24 pixels, scaled up
    +    TEST_TRUE(dvxFontAddFile(0, PRINT_FONT_BASE));
    +
    +    BitmapFontT *f24  = dvxFontOpen(PRINT_FONT_FAMILY, PRINT_FONT_BASE_MAX, 0);
    +    PrintFontT  *sans = printFontOpen(&laser, PRINT_FONT_FAMILY, TWELVE_POINTS, 0);
    +
    +    TEST_TRUE(sans != NULL);
    +
    +    if (sans) {
    +        TEST_EQ(printFontWidth(sans, "Hello", 5), textWidth(f24, "Hello") * TWELVE_POINT_DOTS / PRINT_FONT_BASE_MAX);
    +        printFontClose(sans);
    +    }
    +
    +    dvxFontClose(f24);
    +
    +    // With the printer-fonts add-on's sizes: its 50 pixels as they are
    +    TEST_TRUE(dvxFontAddFile(0, PRINT_FONT_LARGE));
    +    sans = printFontOpen(&laser, PRINT_FONT_FAMILY, TWELVE_POINTS, 0);
    +
    +    BitmapFontT *f50 = dvxFontOpen(PRINT_FONT_FAMILY, TWELVE_POINT_DOTS, 0);
    +
    +    TEST_EQ(f50->pixelSize, TWELVE_POINT_DOTS);
    +    TEST_TRUE(sans != NULL);
    +
    +    if (sans) {
    +        const FontGlyphT *g    = &f50->glyphs['H'];
    +        const uint8_t    *bits = f50->glyphData + f50->glyphAt['H'];
    +
    +        TEST_EQ(printFontWidth(sans, "Hello", 5), textWidth(f50, "Hello"));
    +        TEST_EQ(printFontHeight(sans), f50->charHeight);
    +        spanReset(&sp, 0, SPAN_ROWS - 1);
    +        printFontSpans(sans, 0, 0, "H", 1, 0, SPAN_ROWS - 1, spanSet, &sp);
    +        bad = 0;
    +
    +        for (int32_t y = 0; y < SPAN_ROWS; y++) {
    +            for (int32_t x = 0; x < SPAN_COLS; x++) {
    +                int32_t r    = y - (f50->ascent - g->top);
    +                int32_t c    = x - g->left;
    +                bool    want = r >= 0 && r < g->height && c >= 0 && c < g->width && (bits[r * FONT_ROW_BYTES(g->width) + FONT_PIXEL_BYTE(c)] & FONT_PIXEL_BIT(c));
    +
    +                bad += sp.dots[y][x] != want;
    +            }
    +        }
    +
    +        TEST_EQ(bad, 0);
    +        TEST_TRUE(spanCount(&sp) > 0);
    +        printFontClose(sans);
    +    }
    +
    +    dvxFontClose(f50);
    +
    +    // A page of lines at several sizes, each style, into fonts300.pcl (to
    +    // look at; ~/claude/scratch/dvxgui/printing/pclToPng.py shows it)
    +    PrintDocT *doc = printDocOpen(fileJob("fonts300.pcl", NULL), PrintModelLaserJet300E);
    +
    +    TEST_TRUE(doc != NULL);
    +
    +    if (doc) {
    +        TEST_TRUE(printDocPage(doc, fontLinesBand, &laser));
    +        TEST_TRUE(printDocClose(doc));
    +    }
    +
    +    dvxFontRemoveOwner(0);
    +}
    +
    +
     static void caseHeaderCodes(void) {
         tCase("header and footer codes");
     
    @@ -781,6 +1447,181 @@ static void caseHeaderCodes(void) {
     }
     
     
    +// The ImageWriter II: its setup, a page decoded back (two passes a band,
    +// placed with ESC F), the color patches ink by ink, and golden streams
    +static void caseImageWriter(void) {
    +    tCase("imagewriter");
    +
    +    static const uint8_t patches[PATCH_COUNT * 3] = { 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF };
    +    static const char *const fourInks[PATCH_COUNT]  = { "C", "M", "Y", "K", "MY", "CY", "CM", "" };
    +    uint8_t                  halves[2 * 3]          = { 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00 };
    +    PageDotsT                pg;
    +    int32_t                  len  = 0;
    +    int32_t                  bad  = 0;
    +
    +    // White then black across: the black right half from dot 576, its
    +    // lines placed with ESC F 0576; every band two passes
    +    writePicture(PrintModelImageWriterE, halves, 2, 1, "halves.iw");
    +    TEST_TRUE(iwDecode("halves.iw", IW_ROW_BYTES, IW_ROWS, &pg));
    +    TEST_EQ(pg.rows, IW_DOTS / 2);
    +
    +    for (int32_t y = 0; y < pg.rows; y++) {
    +        for (int32_t x = 0; x < IW_DOTS; x++) {
    +            bad += pageDot(&pg, 0, x, y) != (x >= IW_DOTS / 2);
    +        }
    +    }
    +
    +    TEST_EQ(bad, 0);
    +    free(pg.bits);
    +
    +    char *data = platformReadFile("halves.iw", &len);
    +
    +    TEST_TRUE(data != NULL);
    +
    +    if (data) {
    +        TEST_TRUE(len > (int32_t)strlen(IW_SETUP_TEXT) && memcmp(data, IW_SETUP_TEXT, strlen(IW_SETUP_TEXT)) == 0);
    +        free(data);
    +    }
    +
    +    TEST_EQ(countText("halves.iw", "\x1B" "F0576"), IW_DOTS / 2 / IW_BAND * IW_PASSES);
    +    TEST_EQ(countText("halves.iw", "\x1B" "T01\n"), IW_DOTS / 2 / IW_BAND);
    +    TEST_EQ(countText("halves.iw", "\x1B" "T15\n"), IW_DOTS / 2 / IW_BAND);
    +    TEST_EQ(countText("halves.iw", "\x1B" "K"), 0);
    +
    +    // Color patches: each ink's dots where they belong, yellow first
    +    writePicture(PrintModelImageWriterColorE, patches, PATCH_COUNT, 1, "patches.iw");
    +    TEST_TRUE(iwDecode("patches.iw", IW_ROW_BYTES, IW_ROWS, &pg));
    +    TEST_EQ(pg.rows, IW_DOTS / PATCH_COUNT);
    +    TEST_STR_EQ(pg.order, "YMCK");
    +    TEST_EQ(patchErrors(&pg, IW_DOTS, IW_DOTS / PATCH_COUNT, fourInks), 0);
    +    free(pg.bits);
    +
    +    // Golden streams of a picture with every shade
    +    uint8_t shades[PIC_W * PIC_H * 3];
    +
    +    for (int32_t i = 0; i < PIC_W * PIC_H; i++) {
    +        shades[i * 3]     = (uint8_t)(i * 7);
    +        shades[i * 3 + 1] = (uint8_t)(255 - i * 3);
    +        shades[i * 3 + 2] = (uint8_t)(i * 13);
    +    }
    +
    +    writePicture(PrintModelImageWriterE, shades, PIC_W, PIC_H, "imagewriter.prn");
    +    writePicture(PrintModelImageWriterColorE, shades, PIC_W, PIC_H, "imagewritercolor.prn");
    +    TEST_TRUE(checkGolden("imagewriter.prn", "imagewriter.prn"));
    +    TEST_TRUE(checkGolden("imagewritercolor.prn", "imagewritercolor.prn"));
    +
    +    // Text: the printer reset and its switches set before the lines
    +    PrintPageSetupT ps;
    +    PrintJobT      *job = fileJob("text.iw", NULL);
    +
    +    pageSetup(&ps);
    +    TEST_TRUE(printTextJob(job, PrintModelImageWriterE, "line\n", 5, &ps, NULL));
    +    TEST_TRUE(printJobClose(job));
    +    data = platformReadFile("text.iw", &len);
    +    TEST_TRUE(data != NULL);
    +
    +    if (data) {
    +        TEST_TRUE(memcmp(data, IW_TEXT_BEGIN, strlen(IW_TEXT_BEGIN)) == 0);
    +        TEST_TRUE(len > 2 && memcmp(data + len - 2, "\x1B" "c", 2) == 0);
    +        free(data);
    +    }
    +}
    +
    +
    +// The 300 dpi LaserJet: rows packed (PackBits), blank rows skipped, the
    +// stream decoded back to dots
    +static void casePcl300(void) {
    +    tCase("pcl300");
    +
    +    PageDotsT pg;
    +    uint8_t  black[2 * 3] = { 0 };
    +    int32_t  len          = 0;
    +    int32_t  bad          = 0;
    +
    +    // A black 2x1 picture: 2400 x 1200 dots, every row a few packed bytes
    +    writePicture(PrintModelLaserJet300E, black, 2, 1, "black300.pcl");
    +    TEST_TRUE(pclDecode("black300.pcl", 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_EQ(pg.dpi, PCL300_DPI);
    +    TEST_TRUE(pg.packBits);
    +    TEST_EQ(pg.rows, PCL300_DOTS / 2);
    +    TEST_EQ(pg.transfers, pg.rows);
    +
    +    for (int32_t i = 0; i < pg.rows * PCL300_ROW_BYTES; i++) {
    +        bad += pg.bits[i] != 0xFF;
    +    }
    +
    +    TEST_EQ(bad, 0);
    +    free(pg.bits);
    +
    +    char *data = platformReadFile("black300.pcl", &len);
    +
    +    TEST_TRUE(data != NULL && len < PCL300_ROWS * PCL300_ROW_BYTES / 10);
    +
    +    if (data) {
    +        TEST_TRUE(memcmp(data, "\x1B" "E" "\x1B*t300R", 8) == 0);
    +        free(data);
    +    }
    +
    +    // Black, white, and black thirds down the page: the white third is
    +    // skipped (ESC * b n Y), not sent
    +    uint8_t stripes[3 * 3] = { 0, 0, 0, 0xFF, 0xFF, 0xFF, 0, 0, 0 };
    +
    +    writePicture(PrintModelLaserJet300E, stripes, 1, 3, "stripes300.pcl");
    +    TEST_TRUE(pclDecode("stripes300.pcl", 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_EQ(pg.rows, PCL300_ROWS);
    +    TEST_EQ(pg.transfers, PCL300_ROWS * 2 / 3);
    +    bad = 0;
    +
    +    for (int32_t y = 0; y < PCL300_ROWS; y++) {
    +        bool ink = y / (PCL300_ROWS / 3) != 1;
    +
    +        for (int32_t b = 0; b < PCL300_ROW_BYTES; b++) {
    +            uint8_t want = ink && b < STRIPE_BYTES ? 0xFF : 0;
    +
    +            bad += pg.bits[y * PCL300_ROW_BYTES + b] != want;
    +        }
    +    }
    +
    +    TEST_EQ(bad, 0);
    +    free(pg.bits);
    +
    +    // Black and white noise, two dots a pixel: long literal stretches
    +    // between runs
    +    static uint8_t noise[NOISE_W * NOISE_H * 3];
    +    uint32_t       seed = 1;
    +
    +    for (int32_t i = 0; i < NOISE_W * NOISE_H; i++) {
    +        seed = seed * LCG_MUL + LCG_ADD;
    +        memset(&noise[i * 3], (seed >> LCG_SHIFT) & 1 ? 0xFF : 0, 3);
    +    }
    +
    +    // Some runs too, so both kinds meet, and short runs between single
    +    // bytes (the most a row grows when packed)
    +    memset(noise, 0, NOISE_W * 3);
    +
    +    for (int32_t x = 0; x < NOISE_W; x++) {
    +        memset(&noise[(NOISE_W + x) * 3], x % NOISE_PERIOD == 0 || x % NOISE_PERIOD == 2 ? 0 : 0xFF, 3);
    +    }
    +
    +    writePicture(PrintModelLaserJet300E, noise, NOISE_W, NOISE_H, "noise300.pcl");
    +    TEST_TRUE(pclDecode("noise300.pcl", 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_EQ(pg.rows, NOISE_H * NOISE_SCALE);
    +    bad = 0;
    +
    +    for (int32_t y = 0; y < pg.rows; y++) {
    +        for (int32_t x = 0; x < NOISE_W * NOISE_SCALE; x++) {
    +            bool want = noise[((y / NOISE_SCALE) * NOISE_W + x / NOISE_SCALE) * 3] == 0;
    +            bool got  = (pg.bits[y * PCL300_ROW_BYTES + x / BITS_PER_BYTE] & (TOP_BIT >> (x % BITS_PER_BYTE))) != 0;
    +
    +            bad += want != got;
    +        }
    +    }
    +
    +    TEST_EQ(bad, 0);
    +    free(pg.bits);
    +}
    +
    +
     static void casePictures(void) {
         tCase("pictures");
     
    @@ -798,19 +1639,20 @@ static void casePictures(void) {
             }
         }
     
    -    writePicture(printDriver(PrintModelEpson9E), pic, PIC_W, PIC_H, "epson9.prn");
    -    writePicture(printDriver(PrintModelEpson24E), pic, PIC_W, PIC_H, "epson24.prn");
    -    writePicture(printDriver(PrintModelLaserJetE), pic, PIC_W, PIC_H, "laserjet.prn");
    +    writePicture(PrintModelEpson9E, pic, PIC_W, PIC_H, "epson9.prn");
    +    writePicture(PrintModelEpson24E, pic, PIC_W, PIC_H, "epson24.prn");
    +    writePicture(PrintModelLaserJetE, pic, PIC_W, PIC_H, "laserjet.prn");
    +    writePicture(PrintModelLaserJet300E, pic, PIC_W, PIC_H, "laserjet300.prn");
         TEST_TRUE(checkGolden("epson9.prn", "epson9.prn"));
         TEST_TRUE(checkGolden("epson24.prn", "epson24.prn"));
         TEST_TRUE(checkGolden("laserjet.prn", "laserjet.prn"));
    -    TEST_TRUE(printDriver(PrintModelTextE) == NULL);
    +    TEST_TRUE(checkGolden("laserjet300.prn", "laserjet300.prn"));
     
         // Decoded: a black 2x1 picture fills the 9-pin page width (960 dots)
         uint8_t black[2 * 3] = { 0 };
         int32_t len          = 0;
     
    -    writePicture(printDriver(PrintModelEpson9E), black, 2, 1, "black9.prn");
    +    writePicture(PrintModelEpson9E, black, 2, 1, "black9.prn");
     
         char *data = platformReadFile("black9.prn", &len);
     
    @@ -832,7 +1674,7 @@ static void casePictures(void) {
         uint8_t white[2 * 2 * 3];
     
         memset(white, 0xFF, sizeof(white));
    -    writePicture(printDriver(PrintModelEpson24E), white, 2, 2, "white24.prn");
    +    writePicture(PrintModelEpson24E, white, 2, 2, "white24.prn");
         data = platformReadFile("white24.prn", &len);
         TEST_TRUE(data != NULL);
     
    @@ -849,7 +1691,7 @@ static void casePictures(void) {
         }
     
         // PCL: one row transfer per dot row (1200 x 600 dots for 2x1)
    -    writePicture(printDriver(PrintModelLaserJetE), black, 2, 1, "black.pcl");
    +    writePicture(PrintModelLaserJetE, black, 2, 1, "black.pcl");
         data = platformReadFile("black.pcl", &len);
         TEST_TRUE(data != NULL);
     
    @@ -866,12 +1708,6 @@ static void casePictures(void) {
             TEST_TRUE(memcmp(data, "\x1B" "E" "\x1B*t150R", 8) == 0);
             free(data);
         }
    -
    -    PrintJobT *job = fileJob("none.prn", NULL);
    -
    -    TEST_TRUE(!printRasterJob(job, NULL, pic, PIC_W, PIC_H, PIC_W * 3));
    -    TEST_TRUE(!printRasterJob(job, printDriver(PrintModelEpson9E), pic, 0, PIC_H, 0));
    -    printJobClose(job);
     }
     
     
    @@ -1027,6 +1863,322 @@ static void casePortTimeout(void) {
     }
     
     
    +// PostScript: pictures and text rendered back by Ghostscript (the
    +// printable area half an inch from the top of a letter page, centred
    +// across), and golden streams
    +static void casePostScript(void) {
    +    tCase("postscript");
    +
    +    uint8_t black[2 * 3] = { 0 };
    +    PpmT    img;
    +
    +    // A black 2x1 picture: 8 inches by 4, black
    +    writePicture(PrintModelPostScriptE, black, 2, 1, "black.ps");
    +    TEST_TRUE(gsRender("black.ps", &img));
    +
    +    if (img.rgb) {
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + PS_EDGE, PS_TOP + PS_EDGE, 0x000000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + PCL300_DOTS - PS_EDGE, PS_TOP + PCL300_DOTS / 2 - PS_EDGE, 0x000000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT - PS_EDGE, PS_TOP + PS_EDGE, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + PS_EDGE, PS_TOP - PS_EDGE, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + PS_EDGE, PS_TOP + PCL300_DOTS / 2 + PS_EDGE, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + PCL300_DOTS + PS_EDGE, PS_TOP + PS_EDGE, 0xFFFFFF));
    +        free(img.rgb);
    +    }
    +
    +    // Color patches: each patch's middle in its color
    +    static const uint8_t  patches[PATCH_COUNT * 3] = { 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF };
    +    static const uint32_t want[PATCH_COUNT]         = { 0x00FFFF, 0xFF00FF, 0xFFFF00, 0x000000, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFFFF };
    +
    +    writePicture(PrintModelPostScriptColorE, patches, PATCH_COUNT, 1, "patches.ps");
    +    TEST_TRUE(gsRender("patches.ps", &img));
    +
    +    if (img.rgb) {
    +        int32_t patchW = PCL300_DOTS / PATCH_COUNT;
    +
    +        for (int32_t i = 0; i < PATCH_COUNT; i++) {
    +            TEST_TRUE(ppmIs(&img, PS_LEFT + i * patchW + patchW / 2, PS_TOP + patchW / 2, want[i]));
    +        }
    +
    +        free(img.rgb);
    +    }
    +
    +    // Text: a PostScript job, its characters escaped (CP437 82 is e-acute)
    +    const char     *psText = "(x) \\ caf\x82!\n\fPage two\n";
    +    PrintPageSetupT ps;
    +    PrintJobT      *job    = fileJob("text.ps", NULL);
    +    char           *got;
    +    int32_t         len    = 0;
    +
    +    pageSetup(&ps);
    +    TEST_TRUE(printTextJob(job, PrintModelPostScriptE, psText, (int32_t)strlen(psText), &ps, NULL));
    +    TEST_TRUE(printJobClose(job));
    +    TEST_TRUE(gsText("text.ps", "text.txt"));
    +    got = platformReadFile("text.txt", &len);
    +    TEST_TRUE(got != NULL);
    +
    +    if (got) {
    +        TEST_TRUE(strstr(got, "(x) \\ caf\xC3\xA9!") != NULL);
    +        TEST_TRUE(strstr(got, "Page two") != NULL);
    +        free(got);
    +    }
    +
    +    TEST_EQ(countText("text.ps", PS_SHOWPAGE), 2);
    +
    +    // Golden streams of a picture with every shade
    +    uint8_t shades[PIC_W * PIC_H * 3];
    +
    +    for (int32_t i = 0; i < PIC_W * PIC_H; i++) {
    +        shades[i * 3]     = (uint8_t)(i * 7);
    +        shades[i * 3 + 1] = (uint8_t)(255 - i * 3);
    +        shades[i * 3 + 2] = (uint8_t)(i * 13);
    +    }
    +
    +    writePicture(PrintModelPostScriptE, shades, PIC_W, PIC_H, "postscript.prn");
    +    writePicture(PrintModelPostScriptColorE, shades, PIC_W, PIC_H, "postscriptcolor.prn");
    +    TEST_TRUE(checkGolden("postscript.prn", "postscript.prn"));
    +    TEST_TRUE(checkGolden("postscriptcolor.prn", "postscriptcolor.prn"));
    +}
    +
    +
    +// The BASIC Printer object: programs run with the form runtime print
    +// into a file on the 300 dpi LaserJet (and a color DeskJet), decoded
    +// back: shapes where the scale puts them, text, pages, KillDoc, the end
    +// of a program, PaintPicture, the properties, and errors
    +static void casePrinter(void) {
    +    tCase("printer object");
    +
    +    PrintSettingsT s;
    +    PageDotsT      pg;
    +    PrinterRunT    run;
    +    char           expect[CAPTURE_MAX];
    +    int32_t        bad = 0;
    +
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    platformMkdirRecursive(DVX_USER_DIR);
    +    printSettingsDefaults(&s);
    +    s.model = PrintModelLaserJet300E;
    +    s.dest  = PrintDestFileE;
    +    snprintf(s.filePath, sizeof(s.filePath), "%s", PRINTER_FILE);
    +    TEST_TRUE(printSettingsSave(&s));
    +
    +    // A 1-inch black box 1 inch in, text below it, EndDoc
    +    unlink(PRINTER_FILE);
    +    printerRun(&run,
    +        "Printer.ScaleMode = vbInches\n"
    +        "Printer.Line (1, 1)-(2, 2), , BF\n"
    +        "Printer.CurrentX = 1\n"
    +        "Printer.CurrentY = 3\n"
    +        "Printer.Print \"Hi\";\n"
    +        "PRINT Printer.CurrentX > 1; Printer.Page\n"
    +        "Printer.EndDoc\n"
    +        "PRINT Printer.Page\n");
    +    TEST_EQ(run.errors, 0);
    +    TEST_STR_EQ(sCapture, "True 1 \n1 \n");
    +    TEST_EQ(pclPageCount(PRINTER_FILE), 1);
    +    TEST_TRUE(pclDecode(PRINTER_FILE, 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +
    +    for (int32_t y = 0; y < PRINTER_INCH * 2 + PRINTER_EDGE; y++) {
    +        for (int32_t x = 0; x < PRINTER_INCH * 3; x++) {
    +            bool in = x >= PRINTER_INCH && x <= PRINTER_INCH * 2 && y >= PRINTER_INCH && y <= PRINTER_INCH * 2;
    +
    +            bad += pageDot(&pg, 0, x, y) != in;
    +        }
    +    }
    +
    +    TEST_EQ(bad, 0);
    +    TEST_TRUE(inkIn(&pg, 0, PRINTER_INCH, PRINTER_INCH * 3, PRINTER_INCH, PRINTER_INCH / 2) > 0);
    +    free(pg.bits);
    +
    +    // Two pages; KillDoc throws a page away; a page the program leaves is
    +    // printed when it ends
    +    unlink(PRINTER_FILE);
    +    printerRun(&run, "Printer.Print \"One\"\nPrinter.NewPage\nPRINT Printer.Page\nPrinter.Print \"Two\"\nPrinter.EndDoc\n");
    +    TEST_STR_EQ(sCapture, "2 \n");
    +    TEST_EQ(pclPageCount(PRINTER_FILE), 2);
    +    unlink(PRINTER_FILE);
    +    printerRun(&run, "Printer.Print \"Gone\"\nPrinter.KillDoc\n");
    +    TEST_EQ(pclPageCount(PRINTER_FILE), -1);
    +    printerRun(&run, "Printer.Circle (1440, 1440), 720\n");
    +    TEST_EQ(pclPageCount(PRINTER_FILE), 1);
    +
    +    // CurrentX / CurrentY set before anything is drawn hold for the first
    +    // Print: text 2 inches in and down, nothing above it
    +    unlink(PRINTER_FILE);
    +    printerRun(&run, "Printer.ScaleMode = vbInches\nPrinter.CurrentX = 2\nPrinter.CurrentY = 2\nPrinter.Print \"Here\"\nPrinter.EndDoc\n");
    +    TEST_EQ(run.errors, 0);
    +    TEST_TRUE(pclDecode(PRINTER_FILE, 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_TRUE(inkIn(&pg, 0, PRINTER_INCH * 2, PRINTER_INCH * 2, PRINTER_INCH, PRINTER_INCH / 2) > 0);
    +    TEST_EQ(inkIn(&pg, 0, 0, 0, PCL300_DOTS, PRINTER_INCH * 2), 0);
    +    free(pg.bits);
    +
    +    // The page and the font
    +    printerRun(&run,
    +        "PRINT Printer.Width; Printer.Height; Printer.ScaleWidth; Printer.ScaleMode\n"
    +        "PRINT Printer.TwipsPerPixelX; Printer.ColorMode; Printer.Orientation; Printer.Copies\n"
    +        "PRINT Printer.FontSize; Printer.FontName\n"
    +        "Printer.FontSize = 24\n"
    +        "Printer.FontBold = True\n"
    +        "PRINT Printer.FontSize; Printer.FontBold; Printer.TextHeight(\"X\") > 400; Printer.TextWidth(\"WW\") > Printer.TextWidth(\"W\")\n"
    +        "PRINT Printer.Port\n"
    +        "Printer.KillDoc\n");
    +    TEST_EQ(run.errors, 0);
    +    snprintf(expect, sizeof(expect), "11520 14400 11520 1 \n4.8 1 1 1 \n12 DejaVu Sans\n24 True True True \nFile\n");
    +    TEST_STR_EQ(sCapture, expect);
    +
    +    // Errors (each ends its program): a read-only property, landscape, a
    +    // size of 0, no such member
    +    for (int32_t i = 0; i < DVX_ARRAY_LEN(sPrinterErrors); i++) {
    +        printerRun(&run, sPrinterErrors[i]);
    +        TEST_EQ(run.errors, 1);
    +    }
    +
    +    // Color: a red box is magenta and yellow on the DeskJet; a picture of
    +    // a red and a blue pixel stretched over 1 x 1/2 inch
    +    s.model = PrintModelDeskJetColorE;
    +    TEST_TRUE(printSettingsSave(&s));
    +    TEST_TRUE(writeBmp(PRINTER_BMP));
    +    unlink(PRINTER_FILE);
    +    printerRun(&run,
    +        "Printer.ScaleMode = vbInches\n"
    +        "Printer.Line (0, 0)-(1, 1), vbRed, BF\n"
    +        "Printer.PaintPicture LoadPicture(\"" PRINTER_BMP "\"), 2, 0, 1, 0.5\n"
    +        "Printer.EndDoc\n");
    +    TEST_EQ(run.errors, 0);
    +    TEST_TRUE(pclDecode(PRINTER_FILE, 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg));
    +    TEST_EQ(pg.planes, INK_COUNT);
    +    TEST_EQ(inkIn(&pg, INK_M, PRINTER_EDGE, PRINTER_EDGE, PRINTER_BOX, PRINTER_BOX), PRINTER_BOX * PRINTER_BOX);
    +    TEST_EQ(inkIn(&pg, INK_Y, PRINTER_EDGE, PRINTER_EDGE, PRINTER_BOX, PRINTER_BOX), PRINTER_BOX * PRINTER_BOX);
    +    TEST_EQ(inkIn(&pg, INK_C, PRINTER_EDGE, PRINTER_EDGE, PRINTER_BOX, PRINTER_BOX), 0);
    +    TEST_EQ(inkIn(&pg, INK_M, PRINTER_INCH * 2 + PRINTER_EDGE, PRINTER_EDGE, PRINTER_HALF, PRINTER_HALF), PRINTER_HALF * PRINTER_HALF);
    +    TEST_EQ(inkIn(&pg, INK_C, PRINTER_INCH * 2 + PRINTER_EDGE, PRINTER_EDGE, PRINTER_HALF, PRINTER_HALF), 0);
    +    TEST_EQ(inkIn(&pg, INK_C, PRINTER_INCH * 2 + PRINTER_INCH / 2 + PRINTER_EDGE, PRINTER_EDGE, PRINTER_HALF, PRINTER_HALF), PRINTER_HALF * PRINTER_HALF);
    +    TEST_EQ(inkIn(&pg, INK_Y, PRINTER_INCH * 2 + PRINTER_INCH / 2 + PRINTER_EDGE, PRINTER_EDGE, PRINTER_HALF, PRINTER_HALF), 0);
    +    free(pg.bits);
    +
    +    // A text-only printer cannot draw (its page still has a size)
    +    s.model = PrintModelTextE;
    +    TEST_TRUE(printSettingsSave(&s));
    +    printerRun(&run, "PRINT Printer.Width\n");
    +    TEST_EQ(run.errors, 0);
    +    TEST_STR_EQ(sCapture, "11520 \n");
    +    printerRun(&run, "Printer.Print \"x\"\n");
    +    TEST_EQ(run.errors, 1);
    +
    +    unlink(platformDvxIniPath());
    +    tShutdown();
    +}
    +
    +
    +// Serial ports: COM1 .. COM4 as destinations, their speed and handshake
    +// in the settings, a job opening the port with them and closing it once
    +// what it holds has gone, and the BASIC entry points
    +static void caseSerial(void) {
    +    tCase("serial ports");
    +
    +    PrintSettingsT s;
    +    PrintSettingsT r;
    +    char           err[PRINT_MSG_MAX];
    +    PrefsHandleT  *h;
    +
    +    TEST_TRUE(printDestIsSerial(PrintDestCom1E));
    +    TEST_TRUE(printDestIsSerial(PrintDestCom4E));
    +    TEST_TRUE(!printDestIsSerial(PrintDestLpt3E));
    +    TEST_TRUE(!printDestIsSerial(PrintDestFileE));
    +    TEST_STR_EQ(printDestLabel(PrintDestCom2E), "COM2");
    +    TEST_EQ(printBaudRate(0), SERIAL_SLOWEST);
    +    TEST_EQ(printBaudRate(PRINT_BAUD_COUNT), 0);
    +    TEST_EQ(printBaudRate(-1), 0);
    +    TEST_STR_EQ(printHandshakeLabel(PrintHandshakeXonXoffE), "XON / XOFF");
    +
    +    // Defaults: 9600, the printer's DTR on DSR; what is stored reads back
    +    printSettingsDefaults(&s);
    +    TEST_EQ(s.baud, PRINT_BAUD_DEFAULT);
    +    TEST_EQ(s.handshake, PrintHandshakeDtrDsrE);
    +    h = prefsCreate();
    +    TEST_TRUE(printSerialStore(h, SERIAL_BAUD, PrintHandshakeXonXoffE));
    +    printSettingsRead(h, &r);
    +    TEST_EQ(r.baud, SERIAL_BAUD);
    +    TEST_EQ(r.handshake, PrintHandshakeXonXoffE);
    +    TEST_STR_EQ(prefsGetString(h, "printer", "handshake", ""), "xonxoff");
    +    TEST_TRUE(!printSerialStore(h, SERIAL_ODD_BAUD, PrintHandshakeNoneE));
    +    TEST_TRUE(!printSerialStore(h, SERIAL_BAUD, PrintHandshakeCountE));
    +    prefsSetInt(h, "printer", "baud", SERIAL_ODD_BAUD);
    +    printSettingsRead(h, &r);
    +    TEST_EQ(r.baud, PRINT_BAUD_DEFAULT);
    +    prefsClose(h);
    +
    +    // A job on COM2: opened at the saved speed and handshake, closed once
    +    // the bytes the port held have gone
    +    platformMkdirRecursive(DVX_USER_DIR);
    +    s.baud      = SERIAL_BAUD;
    +    s.handshake = PrintHandshakeRtsCtsE;
    +    TEST_TRUE(printSettingsSave(&s));
    +    printSetPortOps(&sFakeOps);
    +    resetFake();
    +
    +    PrintJobT *job = printJobOpen(PrintDestCom2E, NULL, NULL, err, sizeof(err));
    +
    +    TEST_TRUE(job != NULL);
    +    TEST_EQ(sFake.opens, 1);
    +    TEST_EQ(sFake.openPort, PRINT_PORT_COM1 + 1);
    +    TEST_EQ(sFake.openBaud, SERIAL_BAUD);
    +    TEST_EQ(sFake.openHandshake, PrintHandshakeRtsCtsE);
    +    TEST_TRUE(printJobWrite(job, "AB", 2));
    +    sFake.pending = SERIAL_HELD;
    +    TEST_TRUE(printJobClose(job));
    +    TEST_TRUE(sFake.pendingCalls > SERIAL_HELD);
    +    TEST_EQ(sFake.pending, 0);
    +    TEST_EQ(sFake.closes, 1);
    +    TEST_EQ(sFake.closePort, PRINT_PORT_COM1 + 1);
    +    TEST_TRUE(memcmp(sFake.out, "AB", 2) == 0);
    +
    +    // A port another program holds, and one the computer lacks
    +    resetFake();
    +    sFake.refuseOpen = true;
    +    TEST_TRUE(printJobOpen(PrintDestCom1E, NULL, NULL, err, sizeof(err)) == NULL);
    +    TEST_TRUE(strstr(err, "COM1") != NULL);
    +    resetFake();
    +    sFake.missing[PRINT_PORT_COM1 + 2] = true;
    +    TEST_TRUE(printJobOpen(PrintDestCom3E, NULL, NULL, err, sizeof(err)) == NULL);
    +    TEST_TRUE(strstr(err, "COM3") != NULL);
    +    TEST_EQ(sFake.opens, 0);
    +
    +    // A parallel port's job closes it too (nothing held)
    +    resetFake();
    +    job = printJobOpen(PrintDestLpt1E, NULL, NULL, err, sizeof(err));
    +    TEST_TRUE(printJobClose(job));
    +    TEST_EQ(sFake.closes, 1);
    +    TEST_EQ(sFake.closePort, 0);
    +
    +    // The real host ports: COM4 appends to com4.prn
    +    printSetPortOps(NULL);
    +    unlink("com4.prn");
    +    job = printJobOpen(PrintDestCom4E, NULL, NULL, err, sizeof(err));
    +    TEST_TRUE(printJobPuts(job, "serial"));
    +    TEST_TRUE(printJobClose(job));
    +    TEST_TRUE(fileEquals("com4.prn", "serial", 6));
    +
    +    // BASIC
    +    int32_t prefs = PrefsCreate();
    +
    +    TEST_EQ(PrintDestIsSerial(PrintDestCom1E), BAS_TRUE);
    +    TEST_EQ(PrintDestIsSerial(PrintDestFileE), BAS_FALSE);
    +    TEST_EQ(PrintDestIsSerial(PrintDestCountE), BAS_FALSE);
    +    TEST_EQ(PrintBaudRate(0), SERIAL_SLOWEST);
    +    TEST_EQ(PrintHandshakeCount(), PrintHandshakeCountE);
    +    TEST_STR_EQ(PrintHandshakeLabel(PrintHandshakeNoneE), "None");
    +    TEST_STR_EQ(PrintHandshakeLabel(PrintHandshakeCountE), "");
    +    TEST_EQ(PrintSerialStore(prefs, SERIAL_BAUD, PrintHandshakeDtrDsrE), BAS_TRUE);
    +    TEST_EQ(PrintSettingBaud(prefs), SERIAL_BAUD);
    +    TEST_EQ(PrintSettingHandshake(prefs), PrintHandshakeDtrDsrE);
    +    TEST_EQ(PrintSerialStore(prefs, SERIAL_ODD_BAUD, PrintHandshakeDtrDsrE), BAS_FALSE);
    +    TEST_EQ(PrintSerialStore(BAD_HANDLE, SERIAL_BAUD, PrintHandshakeDtrDsrE), BAS_FALSE);
    +    PrefsClose(prefs);
    +    unlink(platformDvxIniPath());
    +}
    +
    +
     static void caseSettings(void) {
         tCase("settings");
     
    @@ -1054,6 +2206,14 @@ static void caseSettings(void) {
         printSettingsRead(h, &r);
         TEST_TRUE(memcmp(&r, &s, sizeof(r)) == 0);
     
    +    // Every model reads back by its INI name
    +    for (int32_t m = 0; m < PrintModelCountE; m++) {
    +        s.model = (PrintModelE)m;
    +        printSettingsWrite(h, &s);
    +        printSettingsRead(h, &r);
    +        TEST_EQ(r.model, m);
    +    }
    +
         // Unknown names fall back; a page setup that cannot work is replaced
         prefsSetString(h, "printer", "model", "daisywheel");
         prefsSetInt(h, "printer", "columns", 5);
    @@ -1096,6 +2256,7 @@ static void caseSettings(void) {
         TEST_EQ(printPageLines(&p, PrintModelTextE), PRINT_LINES_STANDARD);
         TEST_EQ(printPageLines(&p, PrintModelEpson9E), PRINT_LINES_STANDARD);
         TEST_EQ(printPageLines(&p, PrintModelLaserJetE), PRINT_LINES_LASER);
    +    TEST_EQ(printPageLines(&p, PrintModelLaserJet300E), PRINT_LINES_LASER);
     
         // DVX.INI through the working directory
         platformMkdirRecursive(DVX_USER_DIR);
    @@ -1216,6 +2377,230 @@ static void caseTextOptions(void) {
     }
     
     
    +// Vector PostScript: each drawing call rendered back by Ghostscript (the
    +// page's dots at PS_LEFT, PS_TOP), the printer's faces measured by their
    +// widths, grey on the black-and-white model, raster pages refused on a
    +// vector page, a page left open ended by the close, a printer with no
    +// vector pages, and the Printer object on a PostScript printer
    +static void caseVector(void) {
    +    tCase("vector postscript");
    +
    +    // A 2 x 2 picture: red, green / blue, white
    +    static const uint8_t   pic[2 * 2 * 3] = { 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF };
    +    static const VecCheckT checks[]       = {
    +        { 150, 150, 0xFF0000 }, { 104, 104, 0xFF0000 }, { 195, 150, 0xFF0000 }, { 205, 150, 0xFFFFFF },     // the red box
    +        { 350, 104, 0x0000FF }, { 350, 108, 0xFFFFFF }, { 350, 96, 0xFFFFFF },                               // a hatch line of the blue box, between two, outside it
    +        { 550, 150, 0x00FF00 }, { 595, 150, 0x00FF00 }, { 550, 175, 0x00FF00 }, { 550, 185, 0xFFFFFF },     // the green ellipse
    +        { 800, 150, 0x000000 }, { 750, 150, 0xFFFFFF },                                                      // the circle's outline
    +        { 1000, 150, 0x000000 }, { 950, 100, 0x000000 }, { 900, 150, 0xFFFFFF }, { 950, 200, 0xFFFFFF },    // the arc: right to top, counterclockwise
    +        { 300, 300, 0x000000 }, { 300, 304, 0xFFFFFF },                                                      // the solid line
    +        { 103, 400, 0x000000 }, { 111, 400, 0xFFFFFF }, { 119, 400, 0x000000 },                              // dashes, 8 on and 8 off
    +        { 101, 450, 0xFFFFFF }, { 107, 450, 0x000000 }, { 115, 450, 0xFFFFFF },                              // ... from the middle of the pattern
    +        { 600, 300, 0xFF0000 }, { 603, 300, 0xFF0000 }, { 609, 300, 0xFFFFFF },                              // the dot
    +        { 105, 693, 0x000000 },                                                                              // the underline
    +        { 150, 850, 0xFF0000 }, { 250, 850, 0x00FF00 }, { 150, 950, 0x0000FF }, { 250, 950, 0xFFFFFF },     // the picture
    +        { 450, 850, 0x00FF00 }                                                                               // a part of it
    +    };
    +
    +    PrintPageInfoT info;
    +    SquarePageT    sq;
    +    SpanPageT      sp;
    +    PpmT           img;
    +    PrintDocT     *doc = printDocOpen(fileJob(VEC_FILE, NULL), PrintModelPostScriptColorE);
    +
    +    TEST_TRUE(doc != NULL);
    +
    +    if (!doc) {
    +        return;
    +    }
    +
    +    printDocInfo(doc, &info);
    +    TEST_TRUE(info.vector);
    +
    +    // The printer's faces: measured by the AFM widths, drawn only as
    +    // PostScript
    +    PrintFontT *hello = printFontOpen(&info, "DejaVu Sans", TWELVE_POINTS, 0);
    +    PrintFontT *under = printFontOpen(&info, "DejaVu Serif", TWELVE_POINTS * 2, FONT_STYLE_BOLD | FONT_STYLE_UNDERLINE);
    +
    +    TEST_TRUE(hello != NULL && under != NULL);
    +
    +    if (!hello || !under) {
    +        printFontClose(hello);
    +        printFontClose(under);
    +        printDocClose(doc);
    +        return;
    +    }
    +
    +    TEST_EQ(printFontWidth(hello, "Hello", 5), VEC_HELLO_DOTS);
    +    TEST_EQ(printFontAscent(hello), VEC_HELLO_ASCENT);
    +    TEST_EQ(printFontHeight(hello), VEC_LINE_DOTS);
    +    TEST_EQ(printFontWidth(under, "Under", 5), VEC_UNDER_DOTS);
    +    spanReset(&sp, 0, SPAN_ROWS - 1);
    +    printFontSpans(hello, 0, 0, "Hello", 5, 0, SPAN_ROWS - 1, spanSet, &sp);
    +    TEST_EQ(spanCount(&sp), 0);
    +
    +    TEST_EQ(printPsFaceOf("DejaVu Sans", 0), PRINT_PS_SANS);
    +    TEST_EQ(printPsFaceOf("DejaVu Serif", FONT_STYLE_BOLD), PRINT_PS_SERIF + PRINT_PS_BOLD);
    +    TEST_EQ(printPsFaceOf("DejaVu Sans Mono", 0), PRINT_PS_MONO);
    +    TEST_EQ(printPsFaceOf("Courier New", FONT_STYLE_ITALIC), PRINT_PS_MONO + PRINT_PS_SLANT);
    +    TEST_EQ(printPsFaceOf("Times New Roman", FONT_STYLE_BOLD | FONT_STYLE_ITALIC), PRINT_PS_SERIF + PRINT_PS_BOLD + PRINT_PS_SLANT);
    +    TEST_EQ(printPsFaceOf("Microsoft Sans Serif", 0), PRINT_PS_SANS);
    +    TEST_EQ(printPsFaceOf("Tinos", 0), PRINT_PS_SERIF);
    +    TEST_EQ(printPsFaceOf(NULL, 0), PRINT_PS_MONO);
    +    TEST_EQ(printPsLatin1(0), '?');
    +    TEST_EQ(printPsLatin1(0x82), 0xE9);
    +    TEST_EQ(printPsLatin1(0xC4), '-');
    +    TEST_EQ(printPsLatin1(0xDB), '#');
    +
    +    // A page of every drawing call; raster pages are refused on it
    +    TEST_TRUE(printVecBegin(doc));
    +    TEST_TRUE(!printVecBegin(doc));
    +    squareInit(&sq, 0, 0, 0, 0, 0, info.widthDots);
    +    TEST_TRUE(!printDocPage(doc, squareBand, &sq));
    +    TEST_TRUE(!printDocPicture(doc, pic, 2, 2, 2 * 3));
    +    printVecRect(doc, 199, 199, 100, 100, 0xFF0000, PrintFillSolidE);
    +    printVecRect(doc, 300, 100, 399, 199, 0x0000FF, PrintFillHorizontalE);
    +    printVecEllipseFill(doc, 550, 150, 50, 30, 0x00FF00, PrintFillSolidE);
    +    printVecEllipse(doc, 750, 150, 50, 50, 0x000000, 3, 0xFFFF, false, 0.0, 0.0);
    +    printVecEllipse(doc, 950, 150, 50, 50, 0x000000, 3, 0xFFFF, true, 0.0, M_PI / 2);
    +    printVecLine(doc, 100, 300, 499, 300, 0x000000, 1, 0xFFFF);
    +    printVecLine(doc, 100, 400, 499, 400, 0x000000, 1, 0xFF00);
    +    printVecLine(doc, 100, 450, 499, 450, 0x000000, 1, 0x0FF0);
    +    printVecDot(doc, 600, 300, 0xFF0000, 9);
    +    printVecText(doc, hello, 100, 500, "Hello", 5, 0x000000);
    +    printVecText(doc, under, 100, 600, "Under", 5, 0x000000);
    +    printVecImage(doc, pic, 2, 2, 0, 0, 2, 2, 100, 800, 200, 200);
    +    printVecImage(doc, pic, 2, 2, 1, 0, 1, 1, 400, 800, 100, 100);
    +    TEST_TRUE(printVecEnd(doc));
    +    TEST_TRUE(!printVecEnd(doc));
    +    TEST_TRUE(printDocClose(doc));
    +    printFontClose(hello);
    +    printFontClose(under);
    +    TEST_EQ(countText(VEC_FILE, PS_SHOWPAGE), 1);
    +    TEST_EQ(countText(VEC_FILE, "/Helvetica dvxRe"), 1);
    +    TEST_EQ(countText(VEC_FILE, "/Times-Bold dvxRe"), 1);
    +    TEST_TRUE(gsRender(VEC_FILE, &img));
    +
    +    if (img.rgb) {
    +        for (int32_t i = 0; i < DVX_ARRAY_LEN(checks); i++) {
    +            if (!ppmIs(&img, PS_LEFT + checks[i].x, PS_TOP + checks[i].y, checks[i].rgb)) {
    +                TEST_FAIL("vector page point");
    +                printf("  point %" PRId32 ", %" PRId32 " is not %06" PRIX32 "\n", checks[i].x, checks[i].y, checks[i].rgb);
    +            }
    +        }
    +
    +        free(img.rgb);
    +    }
    +
    +    TEST_TRUE(gsText(VEC_FILE, VEC_TEXT_FILE));
    +    TEST_TRUE(countText(VEC_TEXT_FILE, "Hello") > 0);
    +    TEST_TRUE(countText(VEC_TEXT_FILE, "Under") > 0);
    +
    +    // The black-and-white model draws in grey, pictures grey too; a page
    +    // left open is ended by the close
    +    doc = printDocOpen(fileJob(VEC_MONO_FILE, NULL), PrintModelPostScriptE);
    +    TEST_TRUE(doc && printVecBegin(doc));
    +    printVecRect(doc, 100, 100, 199, 199, 0xFF0000, PrintFillSolidE);
    +    printVecImage(doc, pic, 2, 2, 0, 0, 2, 2, 100, 800, 200, 200);
    +    TEST_TRUE(printDocClose(doc));
    +    TEST_EQ(countText(VEC_MONO_FILE, PS_SHOWPAGE), 1);
    +    TEST_EQ(countText(VEC_MONO_FILE, " 1 dvxImg"), 1);
    +    TEST_EQ(countText(VEC_MONO_FILE, "setrgbcolor"), 0);
    +    TEST_TRUE(gsRender(VEC_MONO_FILE, &img));
    +
    +    if (img.rgb) {
    +        const uint8_t *box = img.rgb + ((int64_t)(PS_TOP + 150) * img.w + PS_LEFT + 150) * 3;
    +        const uint8_t *red = img.rgb + ((int64_t)(PS_TOP + 850) * img.w + PS_LEFT + 150) * 3;
    +
    +        TEST_TRUE(box[0] == box[1] && box[1] == box[2] && box[0] > 0 && box[0] < PPM_MAX);
    +        TEST_TRUE(red[0] == red[1] && red[1] == red[2] && red[0] > 0 && red[0] < PPM_MAX);
    +        free(img.rgb);
    +    }
    +
    +    // A printer that draws its pages in dots has no vector pages
    +    doc = printDocOpen(fileJob(VEC_OPEN_FILE, NULL), PrintModelLaserJet300E);
    +    printDocInfo(doc, &info);
    +    TEST_TRUE(!info.vector);
    +    TEST_TRUE(!printVecBegin(doc));
    +    printVecRect(doc, 100, 100, 199, 199, 0xFF0000, PrintFillSolidE);
    +    TEST_TRUE(printDocClose(doc));
    +    TEST_EQ(countText(VEC_OPEN_FILE, "dvxBox"), 0);
    +
    +    // The Printer object on a PostScript printer: its shapes, hatches,
    +    // and text the printer's own, measured as Times-Roman; two copies
    +    // name the face once
    +    PrintSettingsT s;
    +    PrinterRunT    run;
    +    char           expect[CAPTURE_MAX];
    +
    +    tInit(SCREEN_W, SCREEN_H, SCREEN_BPP);
    +    platformMkdirRecursive(DVX_USER_DIR);
    +    printSettingsDefaults(&s);
    +    s.model = PrintModelPostScriptColorE;
    +    s.dest  = PrintDestFileE;
    +    snprintf(s.filePath, sizeof(s.filePath), "%s", VEC_PRINTER_FILE);
    +    TEST_TRUE(printSettingsSave(&s));
    +    unlink(VEC_PRINTER_FILE);
    +    printerRun(&run,
    +        "Printer.ScaleMode = vbInches\n"
    +        "Printer.Copies = 2\n"
    +        "Printer.FillStyle = 0\n"
    +        "Printer.FillColor = RGB(255, 0, 0)\n"
    +        "Printer.Line (1, 1)-(2, 2), RGB(0, 0, 255), B\n"
    +        "Printer.FillStyle = 6\n"
    +        "Printer.FillColor = RGB(0, 0, 0)\n"
    +        "Printer.Line (3, 1)-(3.5, 1.5), , B\n"
    +        "Printer.FillStyle = 1\n"
    +        "Printer.Circle (4, 1.5), 0.5, RGB(0, 255, 0)\n"
    +        "Printer.FillStyle = 0\n"
    +        "Printer.FillColor = RGB(0, 0, 255)\n"
    +        "Printer.Circle (6, 1.5), 0.25\n"
    +        "Printer.DrawWidth = 3\n"
    +        "Printer.PSet (5, 1)\n"
    +        "Printer.DrawWidth = 1\n"
    +        "Printer.DrawStyle = vbDash\n"
    +        "Printer.Line (1, 4)-(3, 4)\n"
    +        "Printer.Circle (5, 4), 0.5\n"
    +        "Printer.DrawStyle = vbInvisible\n"
    +        "Printer.Line (1, 4.5)-(3, 4.5)\n"
    +        "Printer.DrawStyle = vbSolid\n"
    +        "Printer.FontName = \"Times New Roman\"\n"
    +        "Printer.CurrentX = 1\n"
    +        "Printer.CurrentY = 3\n"
    +        "Printer.Print \"Hello\"\n"
    +        "PRINT CInt(Printer.TextWidth(\"Hello\") * 300)\n"
    +        "Printer.EndDoc\n");
    +    TEST_EQ(run.errors, 0);
    +    snprintf(expect, sizeof(expect), "%d \n", VEC_TIMES_HELLO);
    +    TEST_STR_EQ(sCapture, expect);
    +    TEST_EQ(countText(VEC_PRINTER_FILE, PS_SHOWPAGE), 2);
    +    TEST_EQ(countText(VEC_PRINTER_FILE, "/Times-Roman dvxRe"), 1);
    +    TEST_EQ(countText(VEC_PRINTER_FILE, "setdash"), 4);
    +    TEST_TRUE(gsRender(VEC_PRINTER_FILE, &img));
    +
    +    if (img.rgb) {
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 450, PS_TOP + 450, 0xFF0000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 300, PS_TOP + 450, 0x0000FF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 904, PS_TOP + 350, 0x000000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 906, PS_TOP + 350, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1350, PS_TOP + 450, 0x00FF00));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1200, PS_TOP + 450, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1800, PS_TOP + 450, 0x0000FF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1500, PS_TOP + 300, 0x000000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1504, PS_TOP + 300, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 305, PS_TOP + 1200, 0x000000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 313, PS_TOP + 1200, 0xFFFFFF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1650, PS_TOP + 1194, 0x000000));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 1649, PS_TOP + 1186, 0x0000FF));
    +        TEST_TRUE(ppmIs(&img, PS_LEFT + 500, PS_TOP + 1350, 0xFFFFFF));
    +        free(img.rgb);
    +    }
    +
    +    TEST_TRUE(gsText(VEC_PRINTER_FILE, VEC_TEXT_FILE));
    +    TEST_TRUE(countText(VEC_TEXT_FILE, "Hello") > 0);
    +}
    +
    +
     // Compare path with golden/; UPDATE_GOLDENS=1 rewrites the golden.
     static bool checkGolden(const char *name, const char *path) {
         char    golden[PATH_BUF];
    @@ -1271,26 +2656,137 @@ static BasModuleT *compile(const char *src) {
     }
     
     
    -// How many form feeds (page ends) a printed file holds; -1 when it
    -// cannot be read.
    -static int32_t countFormFeeds(const char *path) {
    -    FILE   *f     = fopen(path, "rb");
    +// How many times text is in a printed file (binary safe); -1 when the
    +// file cannot be read.
    +static int32_t countText(const char *path, const char *text) {
    +    int32_t len   = 0;
    +    int32_t n     = (int32_t)strlen(text);
         int32_t count = 0;
    -    int     ch;
    +    char   *data  = platformReadFile(path, &len);
     
    -    if (!f) {
    +    if (!data) {
             return -1;
         }
     
    -    while ((ch = fgetc(f)) != EOF) {
    -        count += (ch == FORM_FEED) ? 1 : 0;
    +    for (int32_t i = 0; i + n <= len; i++) {
    +        count += memcmp(data + i, text, (size_t)n) == 0;
         }
     
    -    fclose(f);
    +    free(data);
         return count;
     }
     
     
    +// Decode a printed ESC/P stream: ESC @, ESC r n (the ribbon), ESC * m nL
    +// nH and its columns (a pass of the head at the current row), CR, ESC J
    +// n (paper fed n units, unitsPerRow to a dot row), and form feeds.  The
    +// inks of the first band go in pg->order.  The caller frees pg->bits.
    +// False on anything else, or dots off the page.
    +static bool escpDecode(const char *path, int32_t unitsPerRow, int32_t rowBytes, int32_t maxRows, PageDotsT *pg) {
    +    int32_t        len      = 0;
    +    char          *data     = platformReadFile(path, &len);
    +    const uint8_t *d        = (const uint8_t *)data;
    +    int32_t        ink      = 0;
    +    int32_t        units    = 0;
    +    int32_t        orderLen = 0;
    +    bool           ok       = pageDotsInit(pg, rowBytes, maxRows) && data != NULL;
    +
    +    for (int32_t i = 0; ok && i < len;) {
    +        if (d[i] == '\r' || d[i] == FORM_FEED) {
    +            i++;
    +            continue;
    +        }
    +
    +        ok = d[i] == ESC_CHAR && i + 1 < len;
    +
    +        if (ok && d[i + 1] == '@') {
    +            ink  = 0;
    +            i   += 2;
    +        } else if (ok && d[i + 1] == 'r') {
    +            ok = i + 2 < len && d[i + 2] < strlen(RIBBON_LETTERS) && RIBBON_LETTERS[d[i + 2]] != '-';
    +
    +            if (ok) {
    +                ink = (int32_t)(strchr(INK_LETTERS, RIBBON_LETTERS[d[i + 2]]) - INK_LETTERS);
    +
    +                if (units == 0 && orderLen < ORDER_MAX - 1) {
    +                    pg->order[orderLen++] = INK_LETTERS[ink];
    +                }
    +            }
    +
    +            i += 3;
    +        } else if (ok && d[i + 1] == 'J') {
    +            ok     = i + 2 < len;
    +            units += ok ? d[i + 2] : 0;
    +            i     += 3;
    +        } else if (ok && d[i + 1] == '*') {
    +            ok = i + ESCP_HEADER_LEN <= len && units % unitsPerRow == 0;
    +
    +            if (!ok) {
    +                break;
    +            }
    +
    +            int32_t bytesPerCol = d[i + 2] == ESCP_MODE_24PIN ? ESCP_PIN_BYTES_24 : 1;
    +            int32_t cols        = d[i + 3] | d[i + 4] << BITS_PER_BYTE;
    +            int32_t top         = units / unitsPerRow;
    +
    +            i  += ESCP_HEADER_LEN;
    +            ok  = i + cols * bytesPerCol <= len;
    +
    +            for (int32_t x = 0; ok && x < cols; x++) {
    +                for (int32_t b = 0; ok && b < bytesPerCol; b++) {
    +                    uint8_t v = d[i + x * bytesPerCol + b];
    +
    +                    for (int32_t bit = 0; ok && bit < BITS_PER_BYTE; bit++) {
    +                        if (v & (TOP_BIT >> bit)) {
    +                            ok = pageDotSet(pg, ink, x, top + b * BITS_PER_BYTE + bit);
    +                        }
    +                    }
    +                }
    +            }
    +
    +            i += cols * bytesPerCol;
    +        } else {
    +            ok = false;
    +        }
    +    }
    +
    +    pg->rows = units / unitsPerRow;
    +    free(data);
    +    return ok;
    +}
    +
    +
    +static void fakeClose(void *ctx, int32_t port) {
    +    (void)ctx;
    +    sFake.closes++;
    +    sFake.closePort = port;
    +}
    +
    +
    +static bool fakeOpen(void *ctx, int32_t port, int32_t baud, PrintHandshakeE handshake) {
    +    (void)ctx;
    +    sFake.opens++;
    +    sFake.openPort      = port;
    +    sFake.openBaud      = baud;
    +    sFake.openHandshake = (int32_t)handshake;
    +    return !sFake.refuseOpen;
    +}
    +
    +
    +// The bytes a port holds: one goes each time it is asked.
    +static int32_t fakePending(void *ctx, int32_t port) {
    +    (void)ctx;
    +    (void)port;
    +    sFake.pendingCalls++;
    +
    +    if (sFake.pending > 0) {
    +        return sFake.pending--;
    +    }
    +
    +    return 0;
    +}
    +
    +
     static bool fakePresent(void *ctx, int32_t port) {
         (void)ctx;
         return !sFake.missing[port];
    @@ -1359,6 +2855,58 @@ static PrintJobT *fileJob(const char *path, HookT *h) {
     }
     
     
    +// A page of text lines in DejaVu Sans (sLineSizes / sLineStyles), each
    +// band drawn from the fonts (opened per band: a page is drawn this way
    +// by a program that keeps nothing between bands).
    +static bool fontLinesBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    const PrintPageInfoT *page = (const PrintPageInfoT *)ctx;
    +    int32_t               y    = 0;
    +
    +    for (size_t i = 0; i < sizeof(sLineSizes) / sizeof(sLineSizes[0]); i++) {
    +        char        line[PATH_BUF];
    +        PrintFontT *pf = printFontOpen(page, PRINT_FONT_FAMILY, sLineSizes[i], sLineStyles[i]);
    +
    +        if (!pf) {
    +            return false;
    +        }
    +
    +        snprintf(line, sizeof(line), "%" PRId32 " point: The quick brown fox jumps over the lazy dog.", sLineSizes[i] / DECIPOINTS_PER_POINT);
    +        printFontDraw(pf, 0, y, line, (int32_t)strlen(line), i % 2 ? 0x000000 : 0x0000C0, rgb, width, y0, rows);
    +        y += printFontHeight(pf);
    +        printFontClose(pf);
    +    }
    +
    +    return true;
    +}
    +
    +
    +// Ghostscript renders a PostScript file at 300 dpi on a letter page into
    +// img, colors converted plainly (no press profile: CMYK 1 0 0 0 is RGB 0
    +// 255 255); the caller frees img->rgb.  False when it fails or its
    +// picture cannot be read.
    +static bool gsRender(const char *path, PpmT *img) {
    +    char cmd[PATH_BUF * 2];
    +
    +    memset(img, 0, sizeof(*img));
    +    snprintf(cmd, sizeof(cmd), "%s -q -dSAFER -dNOPAUSE -dBATCH -dUseFastColor -sPAPERSIZE=letter -r%d -sDEVICE=ppmraw -sOutputFile=gs.ppm %s 2>>gs.log", PRINT_GS, PCL300_DPI, path);
    +
    +    if (system(cmd) != 0) {
    +        return false;
    +    }
    +
    +    return ppmRead("gs.ppm", img);
    +}
    +
    +
    +// Ghostscript's text of a PostScript file, UTF-8, into out.
    +static bool gsText(const char *path, const char *out) {
    +    char cmd[PATH_BUF * 2];
    +
    +    snprintf(cmd, sizeof(cmd), "%s -q -dSAFER -dNOPAUSE -dBATCH -sDEVICE=txtwrite -sOutputFile=%s %s 2>>gs.log", PRINT_GS, out, path);
    +    return system(cmd) == 0;
    +}
    +
    +
     static PrintAnswerE hookAsk(void *ctx, const char *message) {
         HookT        *h      = (HookT *)ctx;
         PrintAnswerE  answer = h->asks < h->answerCount ? h->answers[h->asks] : PrintAnswerCancelE;
    @@ -1409,6 +2957,155 @@ static PrintHooksT hooksOf(HookT *h) {
     }
     
     
    +// Dots of ink in a rectangle of a decoded page.
    +static int32_t inkIn(const PageDotsT *pg, int32_t ink, int32_t x0, int32_t y0, int32_t w, int32_t h) {
    +    int32_t n = 0;
    +
    +    for (int32_t y = y0; y < y0 + h; y++) {
    +        for (int32_t x = x0; x < x0 + w; x++) {
    +            n += pageDot(pg, ink, x, y);
    +        }
    +    }
    +
    +    return n;
    +}
    +
    +
    +// Decode a printed ImageWriter II stream: ESC c, ESC Z / D (two switch
    +// bytes), ESC p, ESC >, ESC K n (the ribbon), ESC F nnnn (the head
    +// placed), ESC G nnnn and its columns (bit 0 the top wire, wires 2 rows
    +// apart at 144 dpi), ESC T nn (the line feed, nn/144 inch: rows), CR,
    +// LF, and form feeds.  The inks of the first band go in pg->order.  The
    +// caller frees pg->bits.  False on anything else, or dots off the page.
    +static bool iwDecode(const char *path, int32_t rowBytes, int32_t maxRows, PageDotsT *pg) {
    +    int32_t        len      = 0;
    +    char          *data     = platformReadFile(path, &len);
    +    const uint8_t *d        = (const uint8_t *)data;
    +    int32_t        ink      = 0;
    +    int32_t        x0       = 0;
    +    int32_t        feed     = 0;
    +    int32_t        y        = 0;
    +    int32_t        orderLen = 0;
    +    bool           ok       = pageDotsInit(pg, rowBytes, maxRows) && data != NULL;
    +
    +    for (int32_t i = 0; ok && i < len;) {
    +        if (d[i] == '\r') {
    +            x0 = 0;
    +            i++;
    +            continue;
    +        }
    +
    +        if (d[i] == '\n') {
    +            y += feed;
    +            x0 = 0;
    +            i++;
    +            continue;
    +        }
    +
    +        if (d[i] == FORM_FEED) {
    +            i++;
    +            continue;
    +        }
    +
    +        ok = d[i] == ESC_CHAR && i + 1 < len;
    +
    +        if (!ok) {
    +            break;
    +        }
    +
    +        char cmd = (char)d[i + 1];
    +
    +        i += 2;
    +
    +        if (cmd == 'c' || cmd == 'p' || cmd == '>') {
    +            continue;
    +        }
    +
    +        if (cmd == 'Z' || cmd == 'D') {
    +            i += 2;
    +        } else if (cmd == 'K') {
    +            ok = i < len && d[i] >= '0' && d[i] <= '3';
    +
    +            if (ok) {
    +                ink = (int32_t)(strchr(INK_LETTERS, IW_RIBBON_LETTERS[d[i] - '0']) - INK_LETTERS);
    +
    +                if (y == 0 && orderLen < ORDER_MAX - 1) {
    +                    pg->order[orderLen++] = INK_LETTERS[ink];
    +                }
    +            }
    +
    +            i++;
    +        } else if (cmd == 'T') {
    +            ok   = i + 2 <= len;
    +            feed = ok ? (d[i] - '0') * 10 + (d[i + 1] - '0') : 0;
    +            i   += 2;
    +        } else if (cmd == 'F' || cmd == 'G') {
    +            ok = i + 4 <= len;
    +
    +            int32_t n = 0;
    +
    +            for (int32_t k = 0; ok && k < 4; k++) {
    +                n = n * 10 + (d[i + k] - '0');
    +            }
    +
    +            i += 4;
    +
    +            if (cmd == 'F') {
    +                x0 = n;
    +                continue;
    +            }
    +
    +            ok = ok && i + n <= len;
    +
    +            for (int32_t c = 0; ok && c < n; c++) {
    +                for (int32_t bit = 0; ok && bit < BITS_PER_BYTE; bit++) {
    +                    if (d[i + c] & (1u << bit)) {
    +                        ok = pageDotSet(pg, ink, x0 + c, y + bit * IW_WIRE_ROWS);
    +                    }
    +                }
    +            }
    +
    +            i += n;
    +        } else {
    +            ok = false;
    +        }
    +    }
    +
    +    pg->rows = y;
    +    free(data);
    +    return ok;
    +}
    +
    +
    +// True when a decoded page has a dot of ink at (x, y).
    +static bool pageDot(const PageDotsT *pg, int32_t ink, int32_t x, int32_t y) {
    +    return (pg->bits[((int64_t)ink * pg->maxRows + y) * pg->rowBytes + x / BITS_PER_BYTE] & (TOP_BIT >> (x % BITS_PER_BYTE))) != 0;
    +}
    +
    +
    +// Put a dot of ink at (x, y); false when it is off the page.
    +static bool pageDotSet(PageDotsT *pg, int32_t ink, int32_t x, int32_t y) {
    +    if (x >= pg->rowBytes * BITS_PER_BYTE || y >= pg->maxRows) {
    +        return false;
    +    }
    +
    +    pg->bits[((int64_t)ink * pg->maxRows + y) * pg->rowBytes + x / BITS_PER_BYTE] |= (uint8_t)(TOP_BIT >> (x % BITS_PER_BYTE));
    +    return true;
    +}
    +
    +
    +// An empty decoded page of maxRows rows for each ink; false when out of
    +// memory.
    +static bool pageDotsInit(PageDotsT *pg, int32_t rowBytes, int32_t maxRows) {
    +    memset(pg, 0, sizeof(*pg));
    +    pg->rowBytes = rowBytes;
    +    pg->maxRows  = maxRows;
    +    pg->planes   = 1;
    +    pg->bits     = (uint8_t *)calloc((size_t)INK_COUNT * maxRows * rowBytes, 1);
    +    return pg->bits != NULL;
    +}
    +
    +
     // 10 lines of 24 columns, 2-line / 2-column margins: 6 body lines of 20
     // characters, header in line 2, footer in line 10.
     static void pageSetup(PrintPageSetupT *ps) {
    @@ -1428,6 +3125,210 @@ static void pageSetup(PrintPageSetupT *ps) {
     }
     
     
    +// Dots of a decoded page that are wrong for PATCH_COUNT patches across
    +// widthDots and rows down: patch i has every dot of the inks named in
    +// want[i] (letters of INK_LETTERS) and none of the others.
    +static int32_t patchErrors(const PageDotsT *pg, int32_t widthDots, int32_t rows, const char *const *want) {
    +    int32_t patchW = widthDots / PATCH_COUNT;
    +    int32_t bad    = 0;
    +
    +    for (int32_t ink = 0; ink < INK_COUNT; ink++) {
    +        for (int32_t y = 0; y < rows; y++) {
    +            for (int32_t x = 0; x < widthDots; x++) {
    +                bool expect = strchr(want[x / patchW], INK_LETTERS[ink]) != NULL;
    +
    +                bad += pageDot(pg, ink, x, y) != expect;
    +            }
    +        }
    +    }
    +
    +    return bad;
    +}
    +
    +
    +// Decode page (0: the first) of a printed PCL raster stream: ESC * r 1 A
    +// (a page's graphic starts), ESC * b n M (compression), ESC * b n V / W
    +// (a plane's row; W ends the row), ESC * b n Y (rows skipped), ESC * r
    +// -n U (planes), ESC * t n R (dpi); other commands are passed over, and
    +// so are the other pages, but pg->pages counts them all.  The caller
    +// frees pg->bits.  False when the stream is malformed.
    +static bool pclDecode(const char *path, int32_t page, int32_t rowBytes, int32_t maxRows, PageDotsT *pg) {
    +    int32_t len   = 0;
    +    char   *data  = platformReadFile(path, &len);
    +    int32_t plane = 0;
    +    int32_t mode  = 0;
    +    bool    mine  = false;
    +    bool    ok    = pageDotsInit(pg, rowBytes, maxRows) && data != NULL;
    +
    +    for (int32_t i = 0; ok && i < len;) {
    +        // Form feeds, and ESC with one character (ESC E)
    +        if ((uint8_t)data[i] != ESC_CHAR || i + 2 >= len || data[i + 1] < PCL_PARAM_FIRST || data[i + 1] > PCL_PARAM_LAST) {
    +            i += (uint8_t)data[i] == ESC_CHAR ? 2 : 1;
    +            continue;
    +        }
    +
    +        char param = data[i + 1];
    +        char group = data[i + 2];
    +        bool more  = true;
    +
    +        i += 3;
    +
    +        // Commands chain while their letters are lower case
    +        while (ok && more) {
    +            int32_t sign  = 1;
    +            int32_t value = 0;
    +
    +            if (i < len && (data[i] == '-' || data[i] == '+')) {
    +                sign = data[i] == '-' ? -1 : 1;
    +                i++;
    +            }
    +
    +            while (i < len && data[i] >= '0' && data[i] <= '9') {
    +                value = value * 10 + data[i] - '0';
    +                i++;
    +            }
    +
    +            ok = i < len;
    +
    +            if (!ok) {
    +                break;
    +            }
    +
    +            char cmd = data[i++];
    +
    +            more   = cmd >= 'a' && cmd <= 'z';
    +            cmd    = more ? (char)(cmd - 'a' + 'A') : cmd;
    +            value *= sign;
    +
    +            if (param == '*' && group == 'r' && cmd == 'A') {
    +                mine = pg->pages++ == page;
    +            } else if (param == '*' && group == 't' && cmd == 'R') {
    +                pg->dpi = value;
    +            } else if (param == '*' && group == 'r' && cmd == 'U') {
    +                pg->planes = value < 0 ? -value : value;
    +            } else if (param == '*' && group == 'b' && cmd == 'M') {
    +                mode         = value;
    +                pg->packBits = pg->packBits || value == PCL_MODE_PACKBITS;
    +            } else if (param == '*' && group == 'b' && cmd == 'Y' && mine) {
    +                pg->rows += value;
    +                plane     = 0;
    +            } else if (param == '*' && group == 'b' && (cmd == 'V' || cmd == 'W')) {
    +                ok = value >= 0 && i + value <= len && (!mine || pclRow(pg, plane, mode, (const uint8_t *)data + i, value));
    +                i += value;
    +
    +                if (mine) {
    +                    pg->transfers++;
    +                    plane++;
    +                }
    +
    +                if (mine && cmd == 'W') {
    +                    pg->rows++;
    +                    plane = 0;
    +                }
    +            }
    +        }
    +    }
    +
    +    free(data);
    +    return ok;
    +}
    +
    +
    +// Pages (raster graphics started) in a printed PCL file; -1 when it
    +// cannot be read or decoded.
    +static int32_t pclPageCount(const char *path) {
    +    PageDotsT pg;
    +    bool      ok    = pclDecode(path, 0, PCL300_ROW_BYTES, PCL300_ROWS, &pg);
    +    int32_t   pages = pg.pages;
    +
    +    free(pg.bits);
    +    return ok ? pages : -1;
    +}
    +
    +
    +// One plane's row of a decoded page at the raster cursor; the planes are
    +// black alone, or ESC * r -3 U's cyan, magenta, and yellow, or ESC * r -4
    +// U's black and the three.
    +static bool pclRow(PageDotsT *pg, int32_t plane, int32_t mode, const uint8_t *in, int32_t n) {
    +    const char *inks = pg->planes == CMY_PLANES ? "CMY" : pg->planes == INK_COUNT ? INK_LETTERS : "K";
    +
    +    if (pg->rows >= pg->maxRows || plane >= (int32_t)strlen(inks)) {
    +        return false;
    +    }
    +
    +    int32_t  ink = (int32_t)(strchr(INK_LETTERS, inks[plane]) - INK_LETTERS);
    +    uint8_t *row = pg->bits + ((int64_t)ink * pg->maxRows + pg->rows) * pg->rowBytes;
    +
    +    if (mode == PCL_MODE_PACKBITS) {
    +        return unpackBits(in, n, row, pg->rowBytes);
    +    }
    +
    +    if (n > pg->rowBytes) {
    +        return false;
    +    }
    +
    +    memcpy(row, in, (size_t)n);
    +    return true;
    +}
    +
    +
    +// Whether a rendered picture's pixel at (x, y) is rgb (0xRRGGBB).
    +static bool ppmIs(const PpmT *img, int32_t x, int32_t y, uint32_t rgb) {
    +    if (x < 0 || y < 0 || x >= img->w || y >= img->h) {
    +        return false;
    +    }
    +
    +    const uint8_t *p = img->rgb + ((int64_t)y * img->w + x) * 3;
    +
    +    return p[0] == ((rgb >> RGB_RED_SHIFT) & RGB_BYTE) && p[1] == ((rgb >> RGB_GREEN_SHIFT) & RGB_BYTE) && p[2] == (rgb & RGB_BYTE);
    +}
    +
    +
    +// A binary PPM (P6, 255 levels; # comment lines in its header) the
    +// first page of which goes into img.
    +static bool ppmRead(const char *path, PpmT *img) {
    +    FILE   *fp = fopen(path, "rb");
    +    int32_t v[PPM_HEADER_NUMS];
    +    char    line[PATH_BUF];
    +    int32_t got = 0;
    +
    +    if (!fp || !fgets(line, sizeof(line), fp) || strncmp(line, PPM_MAGIC, strlen(PPM_MAGIC)) != 0) {
    +        if (fp) {
    +            fclose(fp);
    +        }
    +
    +        return false;
    +    }
    +
    +    while (got < PPM_HEADER_NUMS && fgets(line, sizeof(line), fp)) {
    +        char *p = line;
    +        char *end;
    +
    +        while (line[0] != '#' && got < PPM_HEADER_NUMS && (v[got] = (int32_t)strtol(p, &end, 10), end != p)) {
    +            got++;
    +            p = end;
    +        }
    +    }
    +
    +    if (got < PPM_HEADER_NUMS || v[2] != PPM_MAX) {
    +        fclose(fp);
    +        return false;
    +    }
    +
    +    int32_t w = v[0];
    +    int32_t h = v[1];
    +
    +    img->w   = w;
    +    img->h   = h;
    +    img->rgb = (uint8_t *)malloc((size_t)w * (size_t)h * 3);
    +
    +    bool ok = img->rgb && fread(img->rgb, 3, (size_t)w * (size_t)h, fp) == (size_t)w * (size_t)h;
    +
    +    fclose(fp);
    +    return ok;
    +}
    +
    +
     // The Print dialog: warnings (message boxes) are recorded and closed;
     // the dialog itself is filled in and printed.
     static void printDlgScript(AppContextT *ctx, WindowT *modal, void *userCtx) {
    @@ -1475,6 +3376,59 @@ static void printDlgScript(AppContextT *ctx, WindowT *modal, void *userCtx) {
     }
     
     
    +// The runtime error boxes a Printer program raises: counted and closed
    +// (the Printing window, up while a page goes out, is left to the job).
    +static void printerErrorScript(AppContextT *ctx, WindowT *modal, void *userCtx) {
    +    PrinterRunT *run = (PrinterRunT *)userCtx;
    +
    +    (void)ctx;
    +
    +    if (strcmp(modal->title, PRINTER_ERROR_TITLE) == 0) {
    +        run->errors++;
    +        tDialogClickButton(modal, "OK");
    +    }
    +}
    +
    +
    +// A program run with the form runtime on the test GUI (its PRINT output
    +// in sCapture), the runtime then destroyed (a page left is printed).
    +static void printerRun(PrinterRunT *run, const char *src) {
    +    BasModuleT *mod = compile(src);
    +    BasVmT     *vm  = basVmCreate();
    +
    +    memset(run, 0, sizeof(*run));
    +    sCapture[0] = '\0';
    +    TEST_TRUE(mod != NULL && vm != NULL);
    +
    +    if (!mod || !vm) {
    +        basVmDestroy(vm);
    +        basModuleFree(mod);
    +        return;
    +    }
    +
    +    basVmLoadModule(vm, mod);
    +    basVmSetPrintCallback(vm, captureOut, NULL);
    +    basVmSetStepLimit(vm, BAS_VM_DEFAULT_STEP_SLICE);
    +    tDialogScript(printerErrorScript, run);
    +
    +    BasFormRtT  *rt = basFormRtCreate(tCtx(), vm, mod);
    +    BasVmResultE r;
    +
    +    do {
    +        r = basVmRun(vm);
    +
    +        if (r == BAS_VM_STEP_LIMIT) {
    +            dvxUpdate(tCtx());
    +        }
    +    } while (r == BAS_VM_STEP_LIMIT);
    +
    +    basFormRtDestroy(rt);
    +    tDialogScript(NULL, NULL);
    +    basVmDestroy(vm);
    +    basModuleFree(mod);
    +}
    +
    +
     static void progressScript(AppContextT *ctx, WindowT *modal, void *userCtx) {
         DlgScriptT *ds     = (DlgScriptT *)userCtx;
         WidgetT    *cancel = tFind(modal, "printCancel");
    @@ -1566,6 +3520,150 @@ static void setupDlgScript(AppContextT *ctx, WindowT *modal, void *userCtx) {
     }
     
     
    +// Dots set on a span page.
    +static int32_t spanCount(const SpanPageT *sp) {
    +    int32_t n = 0;
    +
    +    for (int32_t y = 0; y < SPAN_ROWS; y++) {
    +        for (int32_t x = 0; x < SPAN_COLS; x++) {
    +            n += sp->dots[y][x];
    +        }
    +    }
    +
    +    return n;
    +}
    +
    +
    +// A clear span page whose spans must keep to rows top .. bottom.
    +static void spanReset(SpanPageT *sp, int32_t top, int32_t bottom) {
    +    memset(sp, 0, sizeof(*sp));
    +    sp->top    = top;
    +    sp->bottom = bottom;
    +    sp->lowest = -1;
    +}
    +
    +
    +// A print font's span: its dots set, and counted when off the page or
    +// outside the band.
    +static void spanSet(void *ctx, int32_t x0, int32_t x1, int32_t y) {
    +    SpanPageT *sp = (SpanPageT *)ctx;
    +
    +    if (y < sp->top || y > sp->bottom || y < 0 || y >= SPAN_ROWS || x0 < 0 || x1 >= SPAN_COLS || x1 < x0) {
    +        sp->outside++;
    +        return;
    +    }
    +
    +    sp->lowest = y > sp->lowest ? y : sp->lowest;
    +
    +    for (int32_t x = x0; x <= x1; x++) {
    +        sp->dots[y][x] = 1;
    +    }
    +}
    +
    +
    +// A square page's band: the square's dots of it in its color, and the
    +// call checked against the page (width, bands in order).  False on the
    +// failAt'th call.
    +static bool squareBand(void *ctx, uint8_t *rgb, int32_t width, int32_t y0, int32_t rows) {
    +    SquarePageT *sq = (SquarePageT *)ctx;
    +
    +    sq->calls++;
    +    sq->inOrder = sq->inOrder && width == sq->width && y0 == sq->nextY;
    +    sq->nextY   = y0 + rows;
    +
    +    if (sq->calls == sq->failAt) {
    +        return false;
    +    }
    +
    +    for (int32_t r = 0; r < rows; r++) {
    +        int32_t y = y0 + r;
    +
    +        if (y < sq->top || y >= sq->top + SQUARE_DOTS) {
    +            continue;
    +        }
    +
    +        for (int32_t x = sq->left; x < sq->left + SQUARE_DOTS; x++) {
    +            memcpy(rgb + ((int64_t)r * width + x) * 3, sq->rgb, 3);
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
    +// Dots of a decoded page that are wrong for sq: inside it every dot of
    +// the inks in want (letters of INK_LETTERS), and no other dot anywhere.
    +static int32_t squareErrors(const PageDotsT *pg, const SquarePageT *sq, const char *want) {
    +    int32_t bad = 0;
    +
    +    for (int32_t ink = 0; ink < INK_COUNT; ink++) {
    +        bool    inks   = strchr(want, INK_LETTERS[ink]) != NULL;
    +        int32_t total  = 0;
    +        int32_t inside = 0;
    +
    +        for (int64_t i = 0; i < (int64_t)pg->maxRows * pg->rowBytes; i++) {
    +            total += __builtin_popcount(pg->bits[(int64_t)ink * pg->maxRows * pg->rowBytes + i]);
    +        }
    +
    +        for (int32_t y = sq->top; y < sq->top + SQUARE_DOTS; y++) {
    +            for (int32_t x = sq->left; x < sq->left + SQUARE_DOTS; x++) {
    +                inside += pageDot(pg, ink, x, y);
    +            }
    +        }
    +
    +        bad += (inks ? SQUARE_DOTS * SQUARE_DOTS - inside : inside) + (total - inside);
    +    }
    +
    +    return bad;
    +}
    +
    +
    +// A page with a square of SQUARE_DOTS at (left, top) in r, g, b, on a
    +// page width dots across.
    +static void squareInit(SquarePageT *sq, int32_t left, int32_t top, uint8_t r, uint8_t g, uint8_t b, int32_t width) {
    +    memset(sq, 0, sizeof(*sq));
    +    sq->left    = left;
    +    sq->top     = top;
    +    sq->rgb[0]  = r;
    +    sq->rgb[1]  = g;
    +    sq->rgb[2]  = b;
    +    sq->width   = width;
    +    sq->inOrder = true;
    +}
    +
    +
    +// TIFF PackBits in[0..n) into out (at most outMax bytes); false when it
    +// runs over or is cut short
    +static bool unpackBits(const uint8_t *in, int32_t n, uint8_t *out, int32_t outMax) {
    +    int32_t o = 0;
    +
    +    for (int32_t i = 0; i < n;) {
    +        int32_t count = in[i++];
    +
    +        if (count <= PACK_LITERAL_MAX) {
    +            if (i + count + 1 > n || o + count + 1 > outMax) {
    +                return false;
    +            }
    +
    +            memcpy(out + o, in + i, (size_t)(count + 1));
    +            i += count + 1;
    +            o += count + 1;
    +        } else {
    +            int32_t run = PACK_RUN_BASE - count;
    +
    +            if (i >= n || o + run > outMax) {
    +                return false;
    +            }
    +
    +            memset(out + o, in[i++], (size_t)run);
    +            o += run;
    +        }
    +    }
    +
    +    return true;
    +}
    +
    +
     static bool vmClose(void *ctx, void *job) {
         (void)ctx;
         return printJobClose((PrintJobT *)job);
    @@ -1591,12 +3689,31 @@ static bool vmWrite(void *ctx, void *job, const char *data, int32_t len) {
     }
     
     
    -static void writePicture(const PrintDriverT *drv, const uint8_t *rgb, int32_t w, int32_t h, const char *path) {
    -    PrintJobT *job = fileJob(path, NULL);
    +// A 2 x 1 24-bit BMP: a red pixel, then a blue one.
    +static bool writeBmp(const char *path) {
    +    static const uint8_t bmp[BMP_FILE_BYTES] = {
    +        'B', 'M', BMP_FILE_BYTES, 0, 0, 0, 0, 0, 0, 0, BMP_DATA_AT, 0, 0, 0,
    +        BMP_INFO_BYTES, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 1, 0, 24, 0, 0, 0, 0, 0, BMP_ROW_BYTES, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    +        0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00
    +    };
    +    FILE *fp = fopen(path, "wb");
    +    bool  ok = fp && fwrite(bmp, 1, sizeof(bmp), fp) == sizeof(bmp);
     
    -    TEST_TRUE(job != NULL);
    -    TEST_TRUE(printRasterJob(job, drv, rgb, w, h, w * 3));
    -    TEST_TRUE(printJobClose(job));
    +    if (fp) {
    +        fclose(fp);
    +    }
    +
    +    return ok;
    +}
    +
    +
    +// A picture as a one-page document on model, into the file path.
    +static void writePicture(PrintModelE model, const uint8_t *rgb, int32_t w, int32_t h, const char *path) {
    +    PrintDocT *doc = printDocOpen(fileJob(path, NULL), model);
    +
    +    TEST_TRUE(doc != NULL);
    +    TEST_TRUE(printDocPicture(doc, rgb, w, h, w * 3));
    +    TEST_TRUE(printDocClose(doc));
     }
     
     
    @@ -1613,14 +3730,23 @@ int main(void) {
         caseHeaderCodes();
         caseSettings();
         casePorts();
    +    caseSerial();
         casePortTimeout();
         caseFileErrors();
    +    caseFonts();
         casePictures();
    +    casePcl300();
    +    caseImageWriter();
    +    casePostScript();
         caseBasic();
         caseBasicErrors();
         caseBasicPages();
    +    caseColor();
         caseDevice();
         caseDialogs();
    +    casePrinter();
    +    caseVector();
    +    caseDocuments();
     
         return tSuiteEnd();
     }
    diff --git a/src/test/samples/golden/basdemo-debug.app b/src/test/samples/golden/basdemo-debug.app
    index 7b5e5413171d805dd7000ffdfc1a35f7b2eb21c8..f3bce4453e3f1d3b62018a1ae66c812d0fa6473d 100644
    GIT binary patch
    delta 12583
    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
    
    delta 9274
    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
    ZmOIOZc$*`O&v}D}l&qeN+qqyW{tKowQaJzs
    
    diff --git a/src/test/samples/golden/basdemo-release.app b/src/test/samples/golden/basdemo-release.app
    index c9e5410c2bc71efa07b18bfabc081d09f97affcf..a58178ef84fbee040b1fcc23f957b87075a946f5 100644
    GIT binary patch
    delta 9471
    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
    
    delta 6702
    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
    
    diff --git a/src/test/samples/golden/clock-debug.app b/src/test/samples/golden/clock-debug.app
    index db1df6cff4761476e2f76c7e0223de5c92224d21..a38987c7cafc1a8aec45f840a8d451035de10f0e 100644
    GIT binary patch
    delta 1377
    zcmaJ=QA}G^82-SmpEfgt5A4E!4
    zVw!8?;z_0^ONI%F@xjD}2Q@Ppkp*x8O>_@?Fq)`KF4>ZsB|Mm-tKU5r*q*$}*L%))
    zzW@8bbI$G6p5J@Es*zaRqDQi`{VQ`zmD%GGkruF@yHk;$Uenraihh!}mxLNu0=Jlw
    z4}7D1rk4`+EBf|7#p1bZuH33wwark~X471AKUZdJ__Y#m&(i&Mtv6Gdtrf-#E4DIH
    zwD5wKf45jH1|xm?#}MXP>huq-g3j4{1h>iN{@8PP$6X;k&Hc80Y=C#!8Xf)}bv>W9
    z)u>KUQ;`oWC|V#JQuM<1UaItEYyzw{ol*2Zz++pv*1wQ`HS>v`$WKJM%tefjzZR#6
    zeiafk$Jdrcb}1c&K}7$Y-?OzGi(717-FkZI^}(Tm<@5Q;L*_}XX69kx5@rh+-RAiz
    zijz2|*iF!}Qfx!f8+cYeG$X+#s)e&b{qKTEdV3>ITJL=
    zFP_DQ(kaFQe7?->7%d^X0ldw>FMCV=$VPO6KX3Q2b>443z*L^LKPF$oy2L-V`xxU_
    z>|LIVxK(VAhE5HqI+GKl!O38HEOd5sJfTvUdma7*zrd#ecx9pxAk-7;a3YaBO)Y%P
    z;r71|)4PCJ8ku@0sHR>WSI1}!#IAX7awMHlJ110?>iHGNk=izR!Ip-sg7!o*rBXE*
    z8y21c&Tv|%#5P1`G6ti2Siks@nh_hHe`xgndu2elKEBCIuXU#FF_!mpbz8$%9z7JYv_~e
    zq47cJb28}Mk{~emR!L+$D!G_>1jk&G`4Uw_G?Ve}x(!jLy*tq&b+WVq`Q*H=QJ(rt2o%Bqk%6
    zV5M0Ysc=3+8SLc6AS7h?AE3d`8qW&ijU64^)g69-HiLzY7w!i+D#H|a<7NR4h&A#O@e@K@aLJ>;ZNOid*4
    z9d{&CeBJ%6v&v$+Qyt00>FSrAY?H4Ye2OW2>tJ)uPd=h?5rwFuKN>t*M}#XE&wr`u
    BohkqT
    
    delta 1329
    zcmaJ6h80HJa{uhWQh1P4Gc34t;K4gYEu^_!M0PaDfV?HGmzpy`FRNA@FOs!
    zn>s@n(%_SHapS^nqz1#vR6|T_T2r(ph$(J#fkxw^F0MiIA~hO+$M4=5Xji`Ed+*-w
    zeCIpop8LK(apS~$HB2*?YMG%0XU-N1g_-5bd_N=yUz@1{7-n_aPe
    z=3L~e{gKp}mqZy+nYrVanJ$^x;JipH!2n;Dr#av`zC9*(xOgoZ~nIg_x+>f2yQ__ZtPSgLkOf;5siGgN~
    zDCLg{*S19|ck{39zB2FQD0XY!K9@TszTwy0HFX`GDQXi}ppji%E^713nKMwfiMRM~
    z?nBS_fNi(mkf)7`cu_KE{kng-qq8e{69Zbw%^O61^gEkdz-q&uo1_$}$qcN69wu}eRp>APiYs=M}@!&BimR@k)526*$3n(l{O)6DV4ekQ`}ISNZu45lu|CDKT|AX
    zhujBbo~aU}f-6OJn|KY2jqA{z64_$bk9Ynl6qXvwCN-^<&zA>085hyFz&ZX&`78QG
    zH_;e>+NjmO;t35c&BL!5&(*$-tHo)ktEYSX*vQyWbRwD=jtvft
    zrY%bHkf$kl8$Mp0s0kPZlzPnSPNzqD=_nue1fIG8(?0+e>YF?pwZ>15TEjF90{+Up
    zb)+wowvLTi7A?ZDnP2f7494MyJnAYY9Z8RjTeK75Y6PC;es7Hy;g5R{9{&M08^9*O
    zkZ~UP1=tJi0iYlF82A!c2fhOefEzx$fJcEO(7W`m_f|#rWz>`p7&2GUrFj!CM`LR`
    zC5q>uM>JycC)nwOy~7v;`e}My@1UGqzF1s_*&b-Z5QcpkbS|a{>_b(I9G&?!IhMnI
    zCO;}GN!eUz%PmQ{BgdnS@Nmlhzl5?cFWO6Muae6)ItfLl9N#w4MJPArRIu1>7!$>v
    z`SSM$fs2pydgyO>F*v}Ng7*F7lMYQ(@~*Myj?t6E#HO|xh^W87eK
    zMtWt0yRfnY`vZkK5Vk$6wD_pnka2Qoho+)hX8BNj4h;`pJzt#KYu(gZegPIaSTngaYNlZtH35<#*B#gyCf%lu#qCOZ=v68vhG!Cqv8&7h1XleZM=uf
    z86z&P@2D?gBu)H^U5e~av19*(6zqzN1q2BkTN7W}HCstsG|KwLe90?&Zaw<9Ts|+?
    zQd{*jVor(72IC7o==M%1m
    z;HNNs1jy9r^oJ2W`}SFVf+j#z%==}cCqMlcQ}
    zUIWIs*X?5+{DAv#_%6o04?F})z%06L0WU*;02l(U09S!^U<3FIcnrJ%uSTF3=v(^H
    zeWUj1IQm)(3~6&1QTqsw4V4o1!1M|u#rh=z9?XH1dYnE}2PtobLsrO_cki3EqG8JW
    zD+0t_;Yb_mD$KJbly6pAk!$ua8k14IB88UC3Gy1#z!za&qD4NvMY5Z8-nJhONe(}`X
    mOgP(}Svu5s(9MqU(=Gda-}{N)kZp;&2NIFH0V20G1=fGSID8WT
    
    delta 1212
    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
    S?&C>v&b-u*wh9q|ivI&MUw1VC
    
    delta 10978
    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
    
    diff --git a/src/test/samples/golden/fileMgr-release.app b/src/test/samples/golden/fileMgr-release.app
    index 9501eb0ec08437d8d2c6f3fa10c74afac3e4ddd7..592060dca3b5fd2dc21e286c93329e5d60b5036d 100644
    GIT binary patch
    delta 6774
    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
    MxEF8POpM?E1DMr9vj6}9
    
    delta 6707
    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
    
    diff --git a/src/test/samples/golden/helpedit-debug.app b/src/test/samples/golden/helpedit-debug.app
    index 36248001f28e6f0041abe60c475502c1acf74a21..b8a02171eea57dca1585adc94a77e1a68036c73f 100644
    GIT binary patch
    delta 2822
    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
    X=Yob1OElGWKX31_g46TW-7l|jXJ5xwA
    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
    EUuz{fzyJUM
    
    diff --git a/src/test/samples/golden/iconed-release.app b/src/test/samples/golden/iconed-release.app
    index 66b2f3c7609f7ce0e3c59eb98c12299ea3d594a8..88d8944417611d92ff378cdbd50ef2ec086e2956 100644
    GIT binary patch
    delta 2230
    zcmaJ>eNa?Y6hCj-hr2+se#^4UJ^@V(Mp7A^lm>)lAfcYSvw)V;Do+<$cWv3V{*YX7
    zox&9sy(%IWXp~uL(pG9(k|59rO;a{gGmd{aIz7{w#AhE^20`zhMPIKy)QAaN(B%!Yqs4?XlWP@=Bq{dc~Tf-%^WmeDy^lh9)B5rgJ{Rg_2Xj92CB#U{`iZ19Wn6|`ep&3RX``XT>*4L~*kH2V*twrXV^
    zRLbWNLhCrGPz3sXRY*`NA0hYDOPAFt))wjZ<9*Mrsb1aR*5aK*CpF~nMhYDx>p|@X
    zY1s^Qz{ceG3C)gJY(tbYNPwTSRfRNFL0iBg|24`gy)jY;!Se1EK_i_j!&>D_=xbh-
    zE?>m=nqWhqQvND}?!uBp`7EC5K)H$t{ZR&(S}Av$90MC)d8YA}&snDcw67gwFd>8sv
    zq(d`{`Haq19I7_-a=unn$0g;r21zzNiJ}qAxd8EuJJjgVvi)jTyjR6o0^YYNDI|U
    zK%vuJFM%E{YIH0>Sx#xk#=+(a2g*^1e&qog4|3uO&TFG@ekFvrXiOer39rsd@Sxl0yj+RXXJ3L;iEJ3Dn0}|`8G0~WaM|DwwwH#q@PZx2C6b9Ao$S;vXWD*pNna4rQj-@mt%Fo2Co$~+X-LjxsXEE&
    zcd0jg7J&WSoB{m(HVFJLovjsX!Hn$=M@>XMpKk6R(NFJBY)OmPXE+j^XI}06A_jJlzVDFwGkfQbF$$f$st#6hk$%hP1%9LV;x>Cc`9rH~O@|JQ}dz
    z2Lb#c_?8ZVrap|6Z<3kD9jvQ%a>Qtg^!&9kGosxc*RoO;L~J#v(Vja=XUe0l9@OY~
    zDdSs|5%{;EQM4%~sL%}g785=x;K;+^cMv=`0>*7Xus#Al`Y`xil5H|--vbpz@~x!8
    zw2*5hJ50t5B{iK%bRq)(J_wCAs~Y!4$xe$tM!qxUVa!A!1)o`hL3@0htxK4;I2p0l
    zA8FNel$~0zF2`L%TMm2K&^U>M5RNmALOysPSikiI@VziXlc`wJcn;R${bNkNg@%jq
    z_|S8pwmP*G#TJHE^&!2kCJxgUSY}e*Ff|DLM~KXTin$4|hI
    zX$lr0HCVfGHXQdsEIA_@y=P5Ec>j{&xU8jNnls1Bk
    zt26@G2rYz|Qd1Jo{JS_(mTELduKp}@HL@|W!f{w5lR;4a9{~GTcp?M295sAKQH#iN
    zWcpmGi?+NFJBaQy9Ubb(C*q$o@aL$CHDTi;>tH>wO#=;DuGsj)M3?quQlzFn(BTIn
    z@LzzSQ&Z(U`8{pf|9H#O=g0Qb_9v01^r7VgRJ8*~cMd;tx&fHKAGkQk6W9aKbUGLC
    zwjQ+cgUZK-J{1b^LuAivbG#OcoEwVW&Kk)R{(1J<*r}62^whe%g?5+nc01i3vNv;;
    zxk#$_meyM(Pp#eghyq$>_tjYJUTKPQEU~+4>K*nMq(ZmP<^8X*%)VA~5;@Z`-zs^%
    ywXO{`dYTd$v5XVwqGp;bT$J^TjvFAEPd;Wy&jrY#BgbD+MU`b`9sq7H`~3s?9&0=R
    
    delta 2073
    zcmaJ>ZERCz6h61>9k*q&F7nlGZ7&-rLnc$05Mjxt6y;-bxlK0$8DoQzvFrmkO86nL
    zjn=VsqreO5KxN=`qVi=yzJ}oEAUb2BCPdL6%8#ql9}dN+Xno$d-GGpIlXH9CbDr~@
    zkN4bmZodA^u?&->MV%%^a)ma=VzJQ0)ZiKuQP`tw{W+y0a4B41Zv8_uma+UK?}J*0
    z=XCfy$7u(QWTFzyxPCJh+xnrcwU0+8@yG&mSP5ND4Q8|6BsJ~yEq}PEF*T^NUy{aK
    zBCxj3nP{}IQD(cnMqpApJl%bsZo;F5JwdN#_*j8?>f*@>tQu$9`ftJdX$f3$WxhmI
    zW%C+I!xV|w!M}&=3LHl(;i6!8Q{xijYj)0@;a3UqYcVHFK|nYE%B%yum2N^MYEGPNKz5&8ez8tl8CNCHnI0d
    zJT31v6Vic|WM6)P^Oh6NP2+ylRrbt)4i3p=Mg={$(5MK@GdQQk1GuD(WJuLo+q
    zYirh3`>B+vmR#2l;IYa?xsbIGfv)$LS6A1qqPeWrVxJKK>7NkcTD@VT?r&JU&R7uBr}RQu=E`~B1d#(Z|lGSlS)AL6Ji9`ty1U4x%8;T{dg6U=7KkUT8MI&=Ot
    z&*(HfXKvC%a>88`mhgNlQU>=t2$)m&1`Gw?pe-iyX6sww<**778uG
    zt~v@6T4wqzq3vL0>2{&*WSi1wid4JUne^;?dD5NKOp1@QGr{?L`ur(e+fFu#Y$h^U
    zkzBkYI1`PPnq_4OzJpa}WMy&qEt=h&h9b$-ED0Px?_@_ZGM)-i8q(~ch>4ERR^{hN
    z#hpQeHDVz-M1}e&BtsJUdH5_OJPPU9f0AFI&-f5{>P}GZW#gS~qO0w!$LUV={E2gF
    zLc2Mv<+vVB*j7X1J?~=sT=&=RgT}|p8DHd#B)>#WnlE0031%wL-FS1r%XfqK13Wna
    z<_lJ!I}+foyTJ!ouG?ujNG6VyL#)_6T?(-_w=+AQn$KitBC7lfsZBnw8M_nD&WrA5
    zpS$xBGfH*goug)0UL9uJua23O4y^YpUNs-(J_~slV%K2H6F)u}(ZHjgcc7EXax2O@^fk&g(r
    z5UZZx6P!oc#}giP$G7Vk$;WvkSloCcghqHF%$?~%vk4DB=@9dz(%_)jJI9j>A#G~Qo9eiEBT
    z3M}9Ibc$^FKvelOsWvlL&alC($NtA#G;vz;S>C>lHBanYc#f+pN#Z+)
    zkDPA+@}DKiO8KgI;moJgi`%;0CZCUgZ19;NB)`jcPx3^sP3lc%C#EiA%W}%2H*&tQ
    p-Q(il2cCJ0^`cx>I3iue{~>LD1b>LUS3Glzm+H|uL|7Z~{09!hFa`hs
    
    diff --git a/src/test/samples/golden/imgview-debug.app b/src/test/samples/golden/imgview-debug.app
    index 385ff21471031a54b48b5f20443b87df33dd0205..0ea9543d6af1c546067ba863be16a3e0f4a7bf7c 100644
    GIT binary patch
    delta 1479
    zcmZ8fZA@EL7=BObg%(gcWy~^|b&xsYCd_E&4@mfGEfd_OO##t?MXoEgwAc^y$B-f=
    zlU$oEp2Uy11Wbr?F(ximw>XobEI^DkW-f8M#UIASOSa^?WIqh(>T_?)N4&{%&$-Y0
    zyw7>x_uN07$wQx27+7Pf(qLi_EKbdrBzg@*vfVU!t2jBfEH@fO`8bem2QMOE7E=g4IOL?~T`OUc85id#X){W;DjRhpi
    zo;f*rER)H&x;vG3A)KzSR!(FS920j5ZqeE<-xFB<7Pk`Pb;cd+2zMIynd@@uy?o4A
    zArC>
    zauE`(j&oUy#C)s=2HnbOe%Dyv?9Z}E>CS=q=lc4OESyY6_h^$^iqFErqMOZOcatYa
    z$xr&2d{;sH^3jH<)bNC`XWRgr9@&#{DSxl(mEKJIN$!~KlqRPvQ9kJ=htCcHQ
    z`{c1ixAI+1J%f@&kQOWr^r^di;s
    zpxIh?9HzGcJv2D_noEit9+83+1hH=35gLpIBu7}1XfMBDKDfIPUa+P9Y(h&Q6p^SL
    zj1hsyfS0(Xu!1%4{e=hHenXizfLp*SFpkq!fGX(s1AV|*U;$VL(trxw0k*@d9B=|1
    zsZR@kEM}ej^&Ms7GmJ61E;Lb->s}P1&g$Rp5$qXx)Q=cK+rXb@LNf_1l-76y9EpmE
    z2Goxk{u*c+UHENCJ;OxZ<`Y(qCN^wRID~{&dP}ft;tBQh95JpjaF|fP*dmF8X>!$S
    zhF|Km1DQ{e;DqiR6WX*4n{bG1nrjoiX;RNK(W=S6+9KBqdm1_?*(N;d#jXB&!DUDo
    zSLd0~j+Syh-!SoE&1Ye&dSJ8o_&~ahtYiQ~Wb&xXOmNU-!lR~{yGG+Mv&&5A(B@Q1
    z47a4qeqJBrRfcj{AvM_a*R}&V>lb~5&!C%49`vtFI7~lirK#66g#jQzntqeI$m5g3%UlFuR5Vp)gOE_u5;fNVF~Dmcm2cz&%+}vo|*A@|z_Lzjhoz3~jF{Lp
    j{#Vr_28WGTRzF(thK=Y0eI!J!T|KVKDxyMdLU{fIosHDt
    
    delta 1306
    zcmYjPZA@EL7=BON%Wf+f5VyI`0(HR;oU$#E43)aFQO1^#-QA|JY+*>ZPDaXyKyiO&
    zODM8>ZINZA3;u9lceYj(uVH9jQMxag6)y!5S?c
    zm^D6@I_-)mB5Kpb?Z0!m(a%d$3({$qPQSxY$G$8{R`ct2Z`thHy3vv-NqYIO_N~qf
    zU{m!~Vw`VuxZORLrZ^p#S_n)LKFtUugPI6&zvHpPj})}teA4K@{-K=(U2B(*5p|aa
    zg?%-m;9a@9T375n9K~o!8s`#6#P@v8;i)`+qMO>p3^cNeck{GH_)JkL`EelMZKxS6h_NNdYHP&>P**7&|O8SP7`iE
    zq&yT~xg+C3NnHqx(6Z&lZZg_4L8Df-($cnyuPl{y6`J_Ongo)X_@H2!M17i=#th!g
    z(x+KQU2sdWsZG3$!SZ$Jj)-{P^~>#la=C1E(J*5zJhQ>=jM<5nfOGt_4bLl+4x#~G
    zru+#&Q&SlDNmQD?y)gyz4`y;*73j+C8cuU`@
    zSTxc!5Q)$<5`BEexu+t8Jou<1Z`vR28;sC)EN{ZX0bc6zumFGDwddeZh*<_!02>_R
    zz;D0~XlsGfz^A~sz)j!>AO|>*^C0jj&<>o;UUPj@%$oU&_ic&C8Dq4h)KP=J5k;5@
    zYaZ;!m{G`liD9V>{5VsdB;_eul)Me8ME4Md&GQV81RB4NS3+irX;zHEwqfJd7>-Ry
    zd755P>ZN$Ze6>KtC4+|Y1nPUI|
    
    diff --git a/src/test/samples/golden/imgview-release.app b/src/test/samples/golden/imgview-release.app
    index b88fb1af9c1734a663a7f44fca1f41a081bc2108..a4888cbe47e0d2ba680a049c5856bd08dcdab480 100644
    GIT binary patch
    delta 1366
    zcmZ8fUuau(6h1f2ZIiazsNK58bj!L@Hr!m4$rM^h+TNIIv)v}7shw?2%ynr@lbJu&
    z2V+v2qTw1G{=h#lScQTp2#!1`Ot84p4p~L!9())+h=W@C
    zeCPXp=iIw0{OR-7;p$%1L&R%h)RWXI8D3|qoDFF
    zD5{UIbcy0hb{qmht?sSFWR0I@Y6H!*fc`
    z6@$w6b@q8!5|wWd#Yle@b@|UULVHkI!eJa^i^>Oj&t?>hM%$>eShZ+(Zr=W+HLx*ZB91L@%tz&#+Oa_XJ*gDB?>UR*|GO0=u`naPvi$Qc1jAx07d=G84-h{{8*0V?1tQSG_(XUYyV7CH57E24
    zfPm&P9j`~I?uZHqUA|sTlXy4y&l2yUd45>^((L?mWLH~V^o~i~{U4Tk%kyA($UYyq
    zFJA!H=|hqDkvweGdwc4=x`#q|M?-jHH+7N$86r~v9u|rL{{c0^lwNhXcH*!~t*TG$
    zA+DMF?b;uinhC`p&`ATKY{VDJNRQ~DCzOb!qoG%&!DKd(`G0aG
    z6qe%bsx^AlFJ&?_iPLKLF1@p!xhG(`n^rHYJpSm3Chkv`>we5OxewohHayYL*tqZW
    L{X}MU7x?`PG|8s-
    
    delta 1209
    zcmY*XU1$_n6h5;%lbu~t6Y(!=l67pYsI@kN;%c+aMt4?Qx@4SX6EVb?)sYxBiA}P>
    z7tMxDu<3O1;nIit&_)U5skCJ&5wtXIg|uKwK_5gE3iaV?>Vr{Q`n!I2cF}aHyk1B=6&lHQr@!2Z-q=(4S)v@0y^VuJqs66q9!H=+rH@V%&
    z>sOqw1<$y|D~Q?*{^XxxaqN2a#GFuig)%BT>iCaU_7--{tF5^5qV6PeRkp?+d29R^
    zkez7U$j`IoaxmEIaw_xs)SNy=_!*9FCk>urQF&X-<}z0+IgR}n9eSh8YweCmL_O6>
    z?tN}i)-L}2yszw+7{y80&e_}{eurI^>(;j)?V&b41CHq8^Agvd%^m}@jep91mmA+n
    zBYW8SNK9+;REd(ep6-}!Kib*xJr*>Vn^cLa(eFfV3a14pKTJL3Zl?P-(%lv4&fq~d
    zq-+{l{7cxSY?E`Gu(E~u9&$P+lg=)&N|)QpZ@4V?DGdIN8|b#dzbZ>EqCbOABZ79d
    z&>61KXT3ysfwSy8-+RhMnP`B8
    z)q3d`YgI#1FUzXym1)!i>|=F<Z3j+;P09z`cGst
    zR(!y+Xc~?YHsjwNO2H30>MV)&XZi;%dKu+aC>&tbfjUWNuLpL&eIGUpz#`y*;ymye
    zcn#cLz)9d+;3jYzxCayg89uK9TY(PXc;U0a&y~_ac5dyuondM=m*I9vqPxjqd__ch*oqox-_T6>8Iu5eiS`!
    zI|ta)8odw;#wGU2x+5%B+g13bcAxL1cYVT}DBfZw8d$ThR>OPIOo%Aq-bxf}J#5Mw
    Ji2N>(=o#hQZYux)
    
    diff --git a/src/test/samples/golden/inc-comm-debug.app b/src/test/samples/golden/inc-comm-debug.app
    index c49b765ecf31391e0c4a91b0cfb67963b0301616..de7e85229d298184525c236affee516ac11ca776 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-sznQ+e1;xW@aJ^N-GR|P#DR?pwV?hXO^1fjWya2%`3$Mhu
    zjHCno0l@`>=pjgaC@^TGIEj3)eCQ#1h=d~wA@~rpT;JVUUHEX${r$eb@A>`CS#4Nt
    zcxg2Vjyan_5{@iQrp#)$fk<;o6I&(m$g<`TWqyUV6nN^Eoa=%dcfOXN@h6YEU&eRU0sh*sr*UQ-fxCS}d3dSss2
    z{nu4^7DFJaMWQnQhB(HGHGfC<>F8sL$WBDD=rnfS7yabrZz0L`@hWejrovq?@bYJD
    zPi#8t&qw3R&dJoJ-oBoNsrYDh?xxbw1S~ANSrWTV79S%&>DT1@jIgf|W5~RgsmAJY
    z18lmq4%N+nX7o-sSNx>8678BTcdxqE!@nRGYpCLDSbMdI>gDhE)iY>G=5O%lr~Fgw
    z+dtO@2faLjO&7+N`NMq8h7T8wvUWb1vFK-RZ2!(?r}S^Gws1@koGejPY8oscS_8M(
    zyP})M2O`mBc1E%ZA6TbUE+{N49W~A%Z(vWPV}iiur8e6%UKQg}-_`z+*3j^vd(<5c
    zdTtC31r+LF6()Q6C-`JQg^|buboH>(9|(l5Q4p`ejgU_o6-T}aebUA(cnNTcZU^gn0A4*cMKpXdMo^Ssa5@Nf9<
    zc`Tf?P-77|@6?#C>r<;vwckRBCJK|Es`AP+Ex_l#OYAajwuVngd2va5Xy~L{tb&lJ
    z#I}Fy`s7nrZryNN4QGJYgsJCFwT`Y>eY=t6c|s=1~EY?|)k-R`8R
    zsmhCmb+JJ3Nt&pJB^IFp{#fhbves8hn*19WIa$`V4>WUx#9d)#-H9lBw|?!+rQF9;
    zw4kbuXWQ5ty3BiOJ38Ye$`+w9rnp_PRCcHMYwh($`0y!`dl
    zYDZ^R>{Z`70#$$h?TE#2%C_vzSj}^SHsg$M8v}F~q2;sE)F0I22c)
    zYhx27*T?NYy1r0XG0Jg4I#=noD^^0@f^qt|@~Ul?CnQ5p3pLzp+AesxBvpi3+dTRX
    zdPCU9+3B)yzUDT5EnY*qdb)>O(wSsvB&4LmgUO+UOk&h$ulK%3j>ASsJs1S0IwN-{
    z66s!YmZt1({|rKZ0JGIMIu??LTZiNnNuhvzGd!5?QxbAZMwZDu5}WCwz0n&%9(<&$
    zWIB^b56fgf+SO>Bqb`Ss6X|hBm>Hq)$
    
    diff --git a/src/test/samples/golden/inc-comm-release.app b/src/test/samples/golden/inc-comm-release.app
    index 1b7a1d961f694cae24253f88d9f5dd1b310240bd..7969e2f491dabf4d8349bfea980408a916a8784b 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-sznQ+e1;xW@aJ^N-GR|P#DR?pwV?hXO^1fjWya2%`3$Mhu
    zjHCno0mTJ_=pjhyJ1S~ANSrWTV79S%&>DT1@jIgf|W5~RgsmAJY
    z18lmq4%N+nX7o-sSN){9678BTcdxqE!@nRGYpCLDSbMdI>gDhE)w5_x=5O%lr~Fgw
    z+dtO@2faLjO&7+N`NMq8h7T8wvUWb1vFK-RZ2!(?r}QsXTR0{NPL?PtH4PRJt$|zY
    zUC~YB1Ci)5J0sbI53Exv7Zetjjv8l?H?SwtF+pJSQk!iWuZr=g?`r=@YiM}TJ?ahz
    zJvRo20t$7o3X{G36MQnD!bs!+x_VgY4+KKjsEGwlrS^L;-3EGUVC=SA8M!c|1SyEX
    zUvpPzARJIyhZTit*u1H+(g82nQeVEHB@h}>s0#O^cvziv-C+@G2@K6g1KbEU2rxkf^J=cw=>uM$zZ#f6j&-_`&-=&;S4Dd7m@eFxzmi
    z#==R99*e+vCr5Q%pImdQ0~SIwQJDBtRZyO3em?(QVwY))HFQEMh|AhTLnqy06@)}2
    zw*6bzC!V_U8-~+rIQ_gPOg?w2b#&G0+r9k1uLW{W)ki;D_u8jX%{L!lQ*;;ab|*|t
    zRY9EJ5a$U#NfXtO#KP3iA8R{Y*7{0G6My~LlVx4UU<*e`%oSqRortn`>(|a)%6&XV
    z3#i&yuARN1D|}6DXIG3w*b+3x6n9HnbxpYfWrW?N-}vT>y{KN&t{blnvT#XKkiVW<
    z>+I@|KEZ-Ea&rP9F3g+H&Er(m3L_*&%*_mLqC8lE=_KZ+S=*uUt)E6*SPe3U6INDy
    z86#SBE~GUYt2DJ8?1iZ^e}%*znHN!&*n_fV2J=a50XKN}7(U4~1{pOK)e&|LhvF)9
    z?QFc{`ndf^*B9$5MmR1&=PKQH#Y)IqFh(C&UbW5egkpppy?CDL)3M5)hS?|qLPhmDYWFa%6>M(&Bn
    zQ+?ztP1@ZJ(+K?m%vS%%Xiy$*OUp@;L;?9`cp%lU#O2nEERzK!w$LSelQ)b!_(*rj
    zbS9n}mdSp!tI;?|U5*+~q{kgi7d{}S2)2L)jw$d39EG+K41jg;3T%RRKnFZ>4uT^f
    L3i=lBIMU94Vx~Aw
    
    diff --git a/src/test/samples/golden/inc-commdlg-debug.app b/src/test/samples/golden/inc-commdlg-debug.app
    index e7636fe174b9ab0d12152adb73ba7bb2d0aee2a2..5f530207b64d9e19482ab845befeef8c56bdf6f4 100644
    GIT binary patch
    delta 898
    zcmYk3ZAep57{{MG@7$dlt|)wIvt=R*N;3-kps>_Uqej<_y=1;*dTWij*>u*2h6}I6
    zxQwI&y@1eyLG&R=_MyO_k)jg$rTox`=tCqNQ3ydIX1Si-YcB_WoO7O+|9PJ0tT%jb
    zc&=A5=9EFjF{KNW>0+@<1t3|tiH)LoWJxl!JiSO-^Bi>xmQ{w2TVC>yX+D69r`tcX
    z*@<~wY%!O$&0v<9m2{Kui$y(|&oiD)|MN?>Xt7u+3nv#?4e*jPDaCJQvst^llitU`
    zSW^|fqa;{w?EtEc9(8$7;O^5oXqcGT5~hcAuytC~ZgdTauzI0T&MAsplDuRQ9X!o!
    z|E(%KgH51SibQ$(1?|{YtobWezZiYM0hj1zzh)$u}+5Yu4~3b^SL
    zvcopD_!Mhg*lI{$=-H&>RG`+5wnl%Ejd%Dg*N{cYtTO^
    ngi_rl?aZF4T(P*?EG^D*Lkf=^*FLLnpj0~XX%JxJ73J)BrQq+#^;_Puw*I`DyWe!uVc%kP}CQ=hB9
    zV>fVOw#p!Ij+qHnRcF>qid&feRFX+PQ(S!do5(KGW~1+fm~pQv4>X;4lNAvX
    z6xsGKRh@onO>byUqvmw+iZJuMRIZ_GMrYaTzrNy0mC8=~*;r|wLp9xWkj>IPyv-KX
    zH6nM`%CH}pPlukPv&lOS7xMw?l)6
    z>B*bUMB<|oIe>OK8g0~Sv2$*E+|qdN17dPu3mD*-1z$idv<+YoJOZ!4CU^%_z$51n
    sI0{0bKYQEKU&58qvWg4Y%N1=LXQRuNwf2fiXfcSkS^}NkegB>R0!=?Y*#H0l
    
    diff --git a/src/test/samples/golden/inc-commdlg-release.app b/src/test/samples/golden/inc-commdlg-release.app
    index be12d5d5674badda949037cec0cb5ac43db71a14..6c108576fa44fbd55ce6d17984e6e34d207f0137 100644
    GIT binary patch
    delta 872
    zcmYL`Ur19?9LIlW-sznRt|@4HsUC
    zaT!Sm`U64>2GK*1?7_gGp`sG`QaArTvpVqMp8NZKf8X=_oio?8
    z(Dd4*Bi58z$CI+Zz=+
    zozS6;SLu?S>}N*nbZgBIiX+jfn6vk)Xzg2ZwTdDf|aYAe~QBZCe&I4FS
    zZqtQ=Tl$9_Ko333o5=_2<|~Ov!~8M*GtBGgQ~o$1be``p&)}=#c+h`sVALKA4Lir2
    z;ehMr@Q5Tr7p*c_DnFr52C32mxDZVp5(gwHI0!8?U?{iTN7EfdOAU?Raf+jtM#KOF
    zF!0yh5gZCjqCF&vP)Fwt&DB=)LR;w16|_mgQ4uOI!eQbRa-A9rO{A4J7B-*%ifwKn
    z+eikPz|%I76R2-Q`jA;<5m`mnkRQk{aumHPkPgI=dQ~`BOb*jSr5B0}&@SDu2c;n~
    ZlzwbAW3v!a2Zn*6PRPBH
    zNW7o4(wNn8W)7i0fYBNlpOECy_7OQoVkjWr2=~SZk`cKrA6KqcS;&b~zg7sNGh@dFV-7JD>p`Ifub9
    l5CZ+#ySDxku8fvdT*_XpXyGay`p{UdfljHSGCz)f{s(RBJgfi!
    
    diff --git a/src/test/samples/golden/inc-compiler-debug.app b/src/test/samples/golden/inc-compiler-debug.app
    index 271e47d977ddbb7de1b5ea97f5e5982a9ab27d0b..1f2fd7802cfc456101d15dc9e3dce5fa325dafdd 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-sznQ-iM-;&CDbelvb4XpfHk&L8I%2&MY;{8*9wX)L9P=7hZ{R
    z8A*r!f#8Bc^bjOI6c{vAR3cx>haRGbNI0Spf)6pv_1&G-g%9W4-|zeTp5O1Bxw^T!
    zS5}kYn6a58;n3oEs#xnZ5$R58Y_ljHS=Jq*!mqHVJWtJ{b4^g<&Ns?4o=?=S@SR_o
    z%-Dh)Tgq1L2&i(1x;*}|ShKQ)yz=v@|9RaNE!HXw3UvHE2X4>P-XQ0QVUVvWVVr>qjBE0JM%b5+F%(|SH1n}h
    z6Kp#4HqFg{rj1TFR{W&960N!|d#{Gp!@nRGJ5|Y7v3Bbb&CB2Ksi)AA!r$W0Pr0Yq
    zvwvX-_Ir5(n+}Yv@JG3tbssJoWvzTXEgNU9Z~e|>CX8>UI)6kEoGejLV(HH#S_QY*
    zT)|EA1Ci)5J0sbI53F4(6I2$K4x6WtH?gPE5kX+HQj2X8uZr=g=W6dzb7-*NJ?st#
    zJvaIX0xGq!a*Ms}6MWL3+)U&FhI&x#4Fp2hsDTA7CH8wT-2z6cZ{)UH9l9`}1}TWZ
    zUvpQeFC0*t2UV4-*sP_#!T~SXQctd+DG(Y`sT2_riId|8q9%zz^Q{dH(-D&-rj4e1
    zwH8iVc3T9_Gd-^B`gFcp9kdXliNe&Us+{sn^YPj561zfMt-%vgPF&R<8an9~s~{vS
    zvF+cwKK0a@-87t5!|CHSVfuNsT2I%l-rcMJ`t6dTs@awUY=-XQU9PyP
    zsmh6qo8ltDCuyP@lvs%R_+#yd%UW+KY3i>peX^|U9BSnVi8_PKx)V|M7Ju!`rQF9;
    zG{34%WIEUzy2jVmb#+Hcn5{r#OmVlQRrAVKD8uX~{l>Rk>O=LiHfOvxz(OTSPX2l-
    z-__j{d4dIPW)=iOoR~M8S;VQJ<;F;qn41~eLV2hH(@D%l)3!sC#h*r8NDVNC6INDu
    z86{d|A*eMPt2DKp?1iZ^e}%*znHN!&*n_fV9`i|T2{(B67(U4~1{gIJHDPughr&8^
    z9c;4X`ndf^*O%)n#yHMT7b;zL#Y)IqFhMseui55#LQ?dM;O1V_PQk;)sUpm6-Nrs9EgKuH9L<0CPdM5x!^;CYW6hmDX1Fbqs}O74xt
    zlKtcyP1s$hXA$}Xn5}`a@qj$qJ|ZVb0tMuo;r`@+5|i6fvP_nc*h*LI&7KhQ;3GXH
    z)7e;ZR3`h;u0i8GbvkM}ksfz6U;KcW0w@9t95dhxI0|he7zB^OE3gIL0UhwjIS7t`
    M2hi>gV$I4H^UBYq{^xa9v{BZ_J#{sUh*ou<7Li5ONBdO;N?%*
    zZ?W-=KNpRwJI7NOdwRPUrs5-2*_&F9CSV~OW=ZTeS$vfIWL%T)6T-fHjG^!vrkSh8
    zOt9(H+ch`;kv2NrT=kRgO0?;=?7bRV5C4o@>|`Zh!`h=qG%tU*r=CGe3V)41Kjofc
    z&;FSq*ze^DY&tQv!XM;nHhj2fl(q4Rv}~NYvHdHPnKHh)n*32gaI!=}iDe*H7*MI5m0Rp(AK{Y*hSqNHAq1O
    z{+hc&{o#PxGNh_h&E_o)6%Kg8mU?pq&4JLcN~MU1NSpxIm|SQTnpj<7!`Uxra~*7f
    lG#JBa8{jDPb)W~#f(5VwR>60$0}jBe6tn`@-1EXd`49E=Iza#c
    
    delta 802
    zcmYL`T}V@57{}kUo$1+-Twu^Z=ZcE#(;&4Ag+U$rfZ=gtE|O_ekLcKkO^Mc!l~coh{A3L4o5te~#yLZYtf;*HfsGm1V>|8q9%zz^Q{dH(-D&-yFka=FMHqdpeXXonwzUE6;s~-Brn41~eM0uzJ(@D%l$7~0tia(9Gpz3D~C#OQut}i!KjB}iiE>ybgij|PJV3IzrykeW@2}#jYLLK*-b_s4SP8FfvwuHWe
    zUKjRocDg2O^|ihYxyb9Cu*1Z&aX^n
    zY{42|%2w?NsB(&$b^K$oYGVs|31`kjK|l?A43>#
    zIL;sB3Yzcj65NJQwENFs4OqQA!W`lup^LSMH5SL7x{5_bo3hWSDbig@S#zmgnWuOD
    zbrqSx5Qu8gn9RQ+jR0QvY^NV0vr%2}wXNCymj{2AL7
    z8_orCQC-&8m5-;b#+mC|zcZN$OB9q?`tyiZ!EN@g
    z;FkHJNOXmrmF&U?)-II^3X4cb%+tu5*c0ifAh0>9#Xf~s#dy?nt#_z7JlO9U_C!M7
    zoBabph1yuT#ZmSNK50;HCh`J9J*e~sgW>Dcz(ST1$9t_#T^vwC6hh#y
    zxjWn!2`bHlib7Rv&Qf3Dgcoe7Cs)uE3=b((iin8BDR6^X3vEIpt1YZQ{}pYnflZJG
    kqd09H9D}|Vbb}c%50=3S_yM-TL3ovd7T}(JQMj@H4<80%-
    z8Ve^a)mjA3JvFB5`c%G39k39hiNfTkik$LH^YYp661z-Wt%2iGPF&F*8an9~Dd`mK_Is@azPY?@Z`E?3;t
    zROG~k4RL|slQdBcNGwRb{L%J9C9S8JH2K#%a-yUQ4YqQGM4bU<-H9l9w|?!+rQF9;
    zG@q)CXFAv$y2{tob#_Hbm@PwNOmVlURrAUfD8uX~{l>Rk>_zpGcHMZbp9PDOoc#4<
    zzO$=4@&pUo$jl3bI5BTFvw%}U%Z-vKF*h@~iSl3>rjwY9j@S-PZ2dIif~ub}oUpRO
    z%P7$z^8u~NSf!~Au@|Pw{1p;gH!q?pu?Ho~9OjeQB5v^RHhhw4^fPKIs>AFW4uv)7
    zI@mO=;t}oS>jdGli&X>FFij|PJV4OZKziOM~2}#k@LM`{2h6Fbkr;1Q#TSVVM
    zZwPxiJ6#pd*WSjj#cN1+Z%?`{nTq>I{7ND)6d#VsBtkv*2KRg9IBbM8fFWS2Q*uu%
    zmh2;EX~OP0HG|L}z-;x8j``(u`>>oK2^5fThWnEJN=$A`$ue0)Vk=#?H@kz#gO79<
    zO=n`sv`qG)U5&;$>U7j_B0c74zVHDt1+WDya7=?Q;0Ux$U;sPSj-ycd4?Yn%yZ`_I
    
    diff --git a/src/test/samples/golden/inc-conn-release.app b/src/test/samples/golden/inc-conn-release.app
    index bc055ef7babbca9d36b27b59364c67455a9bd9b6..6d9e8d706be5824db65157497424d312c8e515c6 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5?x84UGqVr{r6q+uD2!xc(CE6MGfU0##u{@ob=E_}g;!!+
    zM$&=)fZ&2b^bjOI7#K8CR3cx>haRGbP&lFxf)6pv+jn0-6hM5MW-v8|#+bXjwXGQY-}^1L;Ru6039xZcRmc|K9Q%y)n3
    z`q+XszLc%n6;S0AHS74NV%5eL^2#r!|K~M#tXQouj8ltZ0m)ihN=w|+b=~7@lk(WUFS+-m9VY@~_Cn&z$6|Si7~T>f;~w)l+Cm=5O&ApxjgJ
    z+rKmfhkQJVO$Wx7`Qu#8h94J=vQ|Exu^MM?Z2#8v3FDio&L0s37fTkDSo-sbR>2+i
    zzTmd`kw|ovos;asN7gQt2?~oy$IMg6o7gkyxFE1Osl`5tSH*bLbG>({IXu|!8TLd%
    z-dp_xL512_xy4cT89o_MZYJ^qLp`YU27}=n)WAZP62}9WZUZCLH*(jb3|$&fLKH&a
    zuem$i7YQoOgNi~`Y|c_&;e;1#sV7&^6buh3REmg*L>;)vtc5nAk<}K~U-*VL*TELZ
    lfKi;b0Zu?)3%bEHm%)?p`ej{fCY6`7ZP=q7jLXC(kS{o{mL&7
    z)mb=c(QOep&(xT%>r-nr>Y#-XO%x_SRTY%ynvc(am)K?6W(^*f3gWW%(9lV@SOp;w
    ziEaPZ^~q<>{JP<^8crXt2~#g>)CRh0_3m2!-`D)P8r4fbTld&!P|dgQXVbKrcexU#
    zrm7&$uZ!~ppQMRuP+}qK!^q{+X&%!#rtJk-V!5_1Nbbtj_i-TJjNmvSFZ
    z(fq15p6g_9=?Y(0-_;!>5w--4F~!}IR$WuBKp9~-={LUhVjrrPwCl!e11waM6y&cb
    z*SflUqEE4)_1v65h!gYXbMrVAwL+G}h`E`e4U~r}FrCC)G-EqBvGvo43#kFdaKg%p
    zuVO@t&IPq*W0j^h%wC!*^H)ghk$DkSi9IM=W-*_{7I1@ix8ajaV}MapQ5#{`a44=q
    z*U2VIu8-S)bbYa*BFk}pI#=nkD^^0@fpPkz@~Ul?CnQZz3vTWW4GSJFK^39iwt&8a
    z-VpY3cDf>*ci+aZ#cN1UU+-vpDxC;q0!lJCoEVAABuc&ZM$ZT2IBbM8f?;5)({gV-
    zp6VxOY0~aGHG|L}z-$d<#{%+b$B3LHNfeN8hWk?kN?dMF%Q9I&VjEquw|GLxgOBu-
    zOlRV$QJL&RyB3Xe)aj_>M0(88a$yrOMX&`da7=?Q;0UzMU=Tb8ufYa*4|KpI=Kwei
    MqM(2Aj$^6%9}^!r9smFU
    
    diff --git a/src/test/samples/golden/inc-debug-debug.app b/src/test/samples/golden/inc-debug-debug.app
    index 80e7f0665a0a8f7506cef080e4005ac796b3e824..dc7202fb4cfc9016d48cc0bc380d3735891223aa 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5?xCn;Gcyqdr6q+uD2!xc(CE6MGfU0##u{@o^;!=N7hZ{R
    z8A%8F1Agu{#Dsba0uM5Mcwa7`%3Aq&+G?D+zV$nknJ~WD>iiKwaIr+e5lep_(JHvb
    z-WA+5KM;v7von%i_`uqwQbA#=bksbByoo)LjtK&rlUnSPcvXx?Jy&~&n!|(ro?(v~
    z^4{nl2rAUZ$}Eo3Pw+{DGBc4E80tZ#Hy8|GqXrhT9C6%(=@u|jeIvI$%Fu-YB}5?v
    z{+hePeQHo?9#j;nVsn=IawoiCOFg-QreJtTp%O$yB$}dx!xjh?#!gF>zP80I&2|C6NQ;iRXOFE=H;{BC3cy%TLZ_XoVcnzFm%!_RzXNe
    zV%xuUedeh%yJ0x3hSSSyLgsmm+DO-}o?WZ|`mK_Ys@b;vY?fB@E?3;t
    zROQ6Q4RMj+lQdBcNGwRb{L#)sWv!=_H1pRxexj`FjRl96joc#4<
    zp{KVm`~(ZyNaqDYoR~M8Uc{-WjLvbCt
    zE;e0qecb+|>&uN56CCHG`AU~vu@dqYOwq@cS8ek=At`!VsOMhOZo$pPsUkGkme6<5
    z8^T`BPS=F<^$Ym5cn#?r=pXAyrsDo_zmf=y#7AQ?2~&@~$^9NV4jUm&U<8=zl-wVS
    zB?rk_ny|Z0%^~y$Fk3?tlYV)ub5u@{1PaJE!+psiB_?;IWSJ}>v7N5iTirqA!AJT^
    zrZchRm`wJeU5my!>U7j`B0c74z3>4sMX&`daLj@);0UxWU>H0CufQgF2Xw$A=Kwei
    M!eDUuwnM4@2X%QkSO5S3
    
    diff --git a/src/test/samples/golden/inc-debug-release.app b/src/test/samples/golden/inc-debug-release.app
    index c46d95def8703a795df40cbbf98a3b79624e9d27..177c975350fb013d488609741c8d219bc36d0257 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5?x84UGcyqdr5S}iD6C{+(CE6MGfU0##u{@ob=E_}g;!!+
    zM$&=)fZ&2b^bjO_C@^TGs6@V$4?RQ=k#IyY1Rr9S>$^Lv3m?w8zu))wJ-^>MoAsOZ
    zFD(Yap0pYy;qbyls#xtZ5NQr+e4{89UefHM%&)NKJa_GaV^xr2j@R;2o=?;v^POLr
    z%=o-Hx|prn5m03pHS@&BV%5Ut^UBYs{^vDkq*$%cjS~xE0m)i>LW|wWWHK&K8-E00
    zw6T)k&lR-X-6gn<9PjX+!s;`-d5GD>Lqa!e6>CklJ#{q;ixy>{UQ-lvBxKE@x@Df;
    z`PWrw216jKg(EWmhB(HGHho9;i;>3?k&TF=ktyuDF8avB-$Ih@<5kW=O@%sP;Nj2M
    zuGo0imy5=f?fTTEp5E@c$=FC$_NEpiaafpjvjleQEH+9$(yz(;8DVce#*ldpQ;k()
    z2H13J?W&9aOzWMlFZ)Py#@jS&_Fi?Zn}0zrda{zQVC~Vus)xVdQ%|EMnZLoGk8)43
    zXa8Im9PscsHk}w-<_~i*W`A&4p;AOdBu;?q%v@*@npj<7!?~|$a}{iW
    mG#JBaYv364b)W~#fH|-Pmcb9O4GzMq6tn_o@BPBCkBte0=V^#4ghoYw);~7nih$hEBT0$_R-_
    zZ2PyaPds(z)(xlCaQb*nn0#KP*3lKKch}PYzUI$Xsb2coy2n0^YOZ-do1&Gx%at@W
    z6?t)PU7REMBu!L<5(`luf3)pTN$V{pP5ku@pD5|Vu@;VyxHHJCI}s)C)~}tpl>2y!
    z=2x|`Y&&~Hmw9(>M`xTw*djE>6nBeSbyc|nWrW?N-}vT>J*ZyNt{blnuuxHwm%lcw
    zc64?{pI|}j*;#=QC+5v%=Wr@$`4JK)=4N6WD96e$oy1&p*miJy>!%SHQUi?Pgq0Ou
    z#)%f44Qh?XDot&ey)aeguaMXy^CGGedr-2>U_Ob>;|A|;!zY=>0Hda&I>N5uP*{Pk
    zosAb=AGiPL`a)gV2*>&9Y`M#>SP6Lx#^~entF{@QkPJN~)Nrq9SnzO3stC2VdGsCh
    zhOn2j(`Dg&&29WzyoPl3bPu(rGs(bkKuHA$l7k7EM5)(a?|F|LhmDYWFaS(-M($1|
    z(!Jy?P1#*1rxE%Cn618%(SSVEHYle^3I*hw;r?`=l8{?7vP|ZY*g_ZWO`Z_);3Hi{
    z)0sqiNGAKxu14b=bvoRfNRK(1E_^^t0c-&a98=&6I09`W=m%@y71#jpfDU-%8~}$w
    M6!b3Kah$IF2TF%Hx&QzG
    
    diff --git a/src/test/samples/golden/inc-designer-debug.app b/src/test/samples/golden/inc-designer-debug.app
    index 7a2620eea24e80353d71f9ded22b3c03bf518899..b6c0bebdc967f2e70a511927914ffc63ff76eadc 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-sznQ-iM-;&CEm;lvX76pfHk&L8I%2&MY;{8*9wX)L9P=7hZ{R
    z8A%8F1A+?%(L<2l
    zUs_FqW7cMpgd>X+sba0mM5H^V@vWkGWLbBJ3ctdd^E|bS&UHbFJ6|hLc|K8x!gqgV
    zGUE$!Y$;o{E1=3D>hi?LV$I4H^2*Pr{^xa9v{?Nkw;R9)+iO
    z|8*6f!4Qb*k*LDIA&#+PP2bV|QuMJzWGA9nbPBt!i+=L*w~%D}c$KrzQsGV*c=7A)zfH6;cxKgr`%KQ
    z+dnr12faLjO((`y_`_Vyh7T8wvNk@EmW?wvwtr_blg2k&lRqj5PL?Pru?*x9t%6(Z
    zUBONB1Ci)5J0sbI53EBf6I2$Kj+&>DH?b$uF+pH+QmbtWuZr=g_iEp8OK51oJ>m`r
    zJvRmh11hz%a*Ms}6MWL3+)U&FhI&Zt3j{*fsF4LNCH8wT-3CUgfAqFn9lkKA1}TWZ
    zUvpQeKO9h7hE$cR*_@@J!T~SXQg5!HIS?9FsT2_riBsS@lMAgv6RRt1IQJE8u7fR*
    l24gsF1009G4)lN-Fb|f&3ittbz#({*f>z*~eNkwa{{ZVZI;;Qy
    
    delta 802
    zcmYL`T}V@57{}kUoz1f$xxk>ooGU7_PlJYCC=BY@2MmuJbCJyD#}OU-u+1ahhy#zv
    zIt(vfy2zU>1h1k(LO~;~z=FD}3yHj{i#JvmX%u~){^xAifgil@^ZfsRp7%MM{!RaV
    zkA;&K>MR21ogCG5eR8!%?Y9u3iNeIE>Z0;o3-E>S61zfMtl<+5&
    zXhBsQ%eS+)beZ?mcXlO6lr2JIOmVNQRacd(P)6A;`i*bC)Pw3}?S}E%5Q~&0MfvNg
    z)y}SX>?szso}U#6abey*MwxU0-Ohk8oU&&Q`e{ij|OeV2nPgx@Mc<3CYqkLLK*pb_iZBMHQjmHjloO
    z-W2w64!SH{sJnw-i`S5NPj{|0lTC$&LrOY4m>NpTBu0IXM(=y%IBkS9fXmk$}*WpVhde#Ga2%`3$Mhu
    zjHCno0l@`>@F7TiC@^TGs6@V$4?RQ=k#L4W2tLF#*LQbT7e1VGf4}eVdw#!jW*cT3
    zURe!-W8P+vgk#H-DYM#bAkv)D#8yc>vZ^^mnO|cq1)jQP=Y}B1op0plypX6<=DT~@
    z?8K5Kwvwyb6;S06HOu5Dvub5a1r--l|MQwFYE~P-XROn%CVx7r;psryN(W)HMYf9qIq^vnrkIXZ>
    z|GEm#VF*OENL1$E5yx1u<{#*OCHhn%vJ+7(I*ncTWj}fOJ4kYUyv|#wsc;tzy!-{*
    z7n{!e^U=7nb2@dUx36b$Dn44ByQ!6E0u~nCEQ#GFi;t0?^lS2cLD*M_F=SrLRAcqH
    z0XAJ)hwA3PGJ2<*YktyPiFVDFyH{Q7;a`!9ovq^QSbMdI>g68})H7&F=5O)mr~FeK
    z*w5;M!(N`irVC@s{Bgb}?ZZW*tesD0Ec%)0?cdq#l>W`v7LEymlO>8uO@jqQ>);N1
    zUv%5}NF=(-&Pg`mBkPpP1%-vB;=w}GA-7`y9MMlKC0K?)-9
    z*W48v2nUqbVMU=DwqR;taUSEX7u#nbj9JUigMKH^3Ii
    lfN`9b1}CAf2fbhpEP_?A27ZDaa0Fgupbfa@Ul#9~{{Wt6I}ZQ=
    
    delta 802
    zcmYL`T}V@57{}kUo$1+-w!omloGU7_PlJYCC=BY@2MmuJbCFE*t6dbs=3AkY>MvU-R`8R
    zsmhCU>*5^2CuyP@mRN)a_+u@H%UWM4Y2t5S_+(kv*5AYt5_g4}btj_i-Tb{Xm+}Bl
    z(SoWrmThHk=?d?uZSRPaC|iQYnBrbZs}_{2P)6A;`kimQ)Q#$8?S}E%5Q~%~dHLI^
    zLVHJN>?szso}Cp4abezEb`GbamLDN;Vs55?1Lgh-OeZln9kv}B-~467Mbr>uIALYQ
    zS8<}nX2Y7_Sf#0LV=qmW`70#$*u03U#2%I{Gnh|e^SHsg$M8v}F~q2;sE)GhI22c*
    zYh~jl*Qc#Ny1rOfF~V^{I$P;LEnY)9ySs*()0t#wIHaV)1IfXJOk&h$ulIgHj>ASsJs1F{IwN-_
    z66qdtj;8GH)6)q33CvdS$Y@9&Y8jMMB!vR<&G2BlS4qgt8CfRtNNl1@_6BbRdGL|W
    zlId(BJtUL;Xjh|gp1K?!PNc^j4HrKmrU*8H1&%536&!`u5Bk6wcnvndd!PdzIS0WJ
    M5Cc7ncOBc#f3+4k^Z)<=
    
    diff --git a/src/test/samples/golden/inc-fileops-debug.app b/src/test/samples/golden/inc-fileops-debug.app
    index 9fd18193e6a2215279de5e6df77b043a43470f7f..b3f3d848347a0268e4ae91fc7180b65cfb3b5ae7 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-sznQjwnjm%uGZe6!bm0tjjkIyv(zkatT8uJXFW7r>`IKw
    zNIK9T5L_^b9)iS&1cOG3O5{sP^bkEn!V!fKe27`D@9wNFd^qR+e&65s{C?*|8=?&_
    zEe63pV>L*^;l=S(vD#%I(i~EBvnU>3*6gCpudwDkckQBMO_1Y`S@|i?C+d*-&aX@+
    zx?qkiWvg}sRM|z%JpQ3rwXlV}^7E}KM3U0Bt
    z1viZkM54>=jARwwvks|DP*_MhVw^(Wz@A7)1%b^;t=37rD#oMUt9`>Q!Jz@yh$|Fu
    z-xwJ5E7Z=)O}4U+@JWMmBas{E>LI1i?+;$1MiwxY*zUn}3+So-(c3O%_`;wPpa24Y
    z&7HyikY8yTQWUCYbEf(VJG@{^y}5#Be{fi#Qba@~PJ-*qTxb!RSY2WLxvyw*4Qzrm
    l7{h7n;289Epa)EYd9Vytzz?tu4#KMxv;ybMi^5O){{YZKI;;Qy
    
    delta 802
    zcmYL`T}V@57{}kUo$1+-Tw%~a=ZcE#(;zWWVNl0DV0he^i)5M~N9NdvZ65JP9PEg!
    z!|>vzi@eD~@G2@K6g09Au%NDrkf^J=cw=?ZjH1ue|C|jw@Pqe#p8x;P^FAlrl5M$D
    zYvH8DI*Y)$r^a<%pIWO{hb)9>qLBSml~UOx9-Vi#$fHE>kQi_6-5Lnqx}6@-K&
    zw)tDvvrnA4b;D^joL*iNrk+)+4RqD&*}nY0ulX|7s)v5I?y}FInrq$5rs+1`<%*k{
    zs=PSAF3uBtk|wGFi3O>bKiqMkto4+VvVXm!$I7~{NE=5;)EQvbt%$O>_-kt}gq(>ah=RP2&0E)l@$29l?4nf-lhQLGc5^R8XKnFZ>_JM;S
    M3d(!bm0tjjkIyv(zkatT8uJXFW7rcqPVV
    zBpv7v2(B1J4?*ICfk7ihB@&^0=plNDgd++e_z<&P-`!bV_;Aks{l35N`TfpWsavUg
    zWibf$X{$jJ4lj%)3)Kz-k>-#_Hw$9nCCx6%{0eK#aaS)m)&x1`cr8EWxkPO;-}#kJ
    zkItK;ihb@JA3v
    z>&yB5Y(dlAU4q-t@iy;itUj}whnP(~By_T7vD#$YQ&+OEXi@g*H3cz8Le?CrTjr^q
    ze_e&9Fa)AnI3n|Jh-0j1!*_JQ7f
    z#rm_pY&529pGaQn>h7Eyj}291ZfY?ShlN=;OJKLbV#DMk{hGX=5%%U{44GFk)mSlN
    zfK9vBs=D~kl-}vavX3-pyhXES?p4>i`4{A(r^@*%)-ElqdieW2^(0!7`5XNCDEkz9
    z_Ahk70S}L3(~hxa{xDm!?!`r;tc8!I%=($@Tffukas8XF${iL22aD$wnfh{wR>3Xy
    zF7KxCfkDLByQ8ghjK
    z?i+pmeuY|DsmWII2|g)MY9w+4T|J=m`2E3aRL=sYBHKNfZUH^jJAB)v3|{D00u(^t
    zuemeW8}chn1BybGY}Qm;W``GSsXJTH=noDmRE&s-#7S_Sne#0|1FOleJ@*xDu7ORE
    l0wXwW9UOzc26TZbFb9^vGWY?u!9jQxgJ$5IewqJl{~xk7I}QK<
    
    delta 802
    zcmYL`T}V@57{}kUo$1+-Tw%~a=ZcE#(;zWKVNl0DV0hfv7RfX}j_BBjZ9C$PIPi$9
    z!|>vzi@eE1@G2@K6g09Au%PgwE+p!zF5XyOq*3&F`k%952Y&Fr&-4HPdEV!&H?23_
    zt+sH|LXAb>+!G_Zu1~C1s{Iy1G*KA;RFPMnXVr7Jc
    zCARfj*Tq+K^&>u15DBrktG
    zx!Td$6?uXMtz~BfLY$a4mz~3@pyh{2l$e_t*g$!p4AV)>MKiX8W1ByXxS;B13@5Ct
    z@G?rY$V@a?|=??aLxDjfMJ4j3eCQ!UBH@Ta2tLFt*LQbT7e1VGf4}eVdw#!jR_j;m
    zUs?=;J!v&a!r_I9RI%D+AkrMt_-0WoyrkJhnO|YedG6W;$C@C=9Ixf4JfEmT<~zSK
    znelmZbTM1CBcRGIYUYWL#j1tP=arvN{m*O8NU>U>8z&aT0+O}%gciG#$z)ufHvS00
    zXk#V6pDSp&yGw8zIo{ztjn!v%^ANL%hlFm{D%P58d+KTy7A?v?y{0JUNXVK)b;~@x
    z^RKJW42D2d3rA%B4RMSWZTgPx7bA})A{!A!BU9LQUG$NMzl9{*$E%!$nhJHoz{8)h
    zU9s`3FBgp|+x4kSJ-yv?ld+Mi>`g63;;=C5W(n*zSZtJhq+gTwGs50{j3M(HrW&ip
    z46y0c+Eo|-nbteqSoV?TjJIjl?7iw*H~)fM^i(BZ!P=vRRS$o^r=CVjGJk_VALX86
    z&;GeCIN;%NY&tQv%pc}z*1foBl(q4Rv{^rMed~87GpT>cn*32gaIknmiD@8@Xa(G2
    z?+R`jABaSk*%`?yd|(|?nV_(cbi_D~yn#KDjtT;sm0GP+cvXx?y;u8&TY^Iat`S!#
    z;Jz_1=vSznm78p3pWu@Qp%~f0drspEQ23l8ytjJDQE@Gvzi@eD~@G2@sC}^Y=SWs7WAyFXI#T%=OW)yv%{^xAifgil@^ZfsRp7%Lhja!ZP
    zYAl?TtF;K6dwN{g_35=Lb-+T1CJIxZDzeHm&C6%LOYAajwFZt$S#eo=Xy~L{tc;Mb
    z#I}Fy`qWcrX2Wn=4X2magz4v1YCT=GdUh@U?`ytvmFl6Nt$XaVsAgLBvl&{+yIgTo
    zQ;`)HHpB&jPtrs+Ah96z@<-bbm9(B>($rt?=!udpG}y`!5_JZcbtj_aE&SS+Fh>Fk6DgnBs0xtF9?mpbWE{^c&xDu@}`#+I8c#eikfBvhvr)
    zwa%{Y$P+ARBRww=;>5g}^a4(KEjvb{#N5o_Cdz|lm`-9YI%+#OS@>zh1yw&|IALY^
    zmrtK^b*T?NYx}K{q8{;@1oiBIU6)Pcc!32F=e$_U|6Oy8*g<9@44GC^8P8Fffwurui
    z-VpY3cDf>*uf2_5i`S6u-ky=RWGe0-^(%?MP<%KhlL+u#m+SO>BqfSQ+C(>h%<_jMXlLrN0fnx@I0Y{*10s~+jyaJox9nb-foCDx6
    Mh=9J_9mmPafAYXMkN^Mx
    
    diff --git a/src/test/samples/golden/inc-font-release.app b/src/test/samples/golden/inc-font-release.app
    index 054ba971945d000644ec81d55b426414af80936a..885392cc77a077f88c0289fe32984e75ffa55d59 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng?xCn;Gcyqdr5S}iD2!xc(CE6MGfU0##u{@ob=E_}g;!!+
    zM$&=)fZ&2b1O|x@1qO{2mB^R!p@--p5{@W@;6u#v_T8P;g%9W4-|zeTp5O1B>ALB<
    zmo}5&oVA-I;qc;kx>)Tr5os=IY_lj4UDlkU%&)MfJa5gSYfX?7uGjKYo=?;+^POM1
    zKDJbIfhRmy(YOWkL
    z!KOoNQ$74=#^`ioB|w@x*{a#I_iAXp{0nmNQx$v_Yqu6vef<5NdI~Mc{0;sBlzWOj
    z`{#z>fR87!>A=`Bf0(OT_v4~b*2>2-R^!a|t>3ynVSKaI`6GhhV#$Idmi|1VRd9>F
    zE4XQXAQD|>XC%AufwfDeg2E!wQS%h?CiX-+CJ1a!YOzn^RWTm*TpNnTfo>P!B4-!C?3rHL#H7h~pkiw}6rA8@cUKhAs>!AqpYz
    z*W4ZMiv*SCK}De|HfO0Xcft#{)RQY{3WkRiDnUd<;v~4vtc5nAk<}K~pZkh7*T5#o
    lfKi;b4vs@#3%bD!m$Y&xVy#8sJk#U4u1~L3tAiFoG*OuPRFzYnX+A#tU1FDLt2KCB%8AR`14Ad>WEF&j
    zCARfj*QcI3v+IV_YB+toCQLuCR_p1i)w^r?e_!)ws#P!jY~5p@MK#;9pUu#nyvr3g
    zHB~t=zb@tpK1mbRpu|Gd#~*D!RMvV+NmGA)=@VsL=TIw0NYoi**6oP0ck|cwT*^H>
    zMf0oLM5cqip)0(*uB$sr!fXi|V~RT^t-7XMhBC}<&~JRpg+5d-YS)a{23V*h$;n?E
    z*SflUB2TcO^~}6Lh!gW>GkKhfT5gO)iMg4f4U~r}FrCC)G;KRLx%tzG3#kFdaKg%p
    zFQY_@%m=k5W0j`1lf5ui=C6?0L-Qi461!iv%wax>6>x)hx8ajaV}MapQ4?lYaVV}r
    z*TE)Bu8&)PbbYbDVvOVbbiUGMSFD7*1rzjf=>o+BImLrA~*N6X`KW^Z5^mDS}O4fnx@I0Y{*10)yZYcm+1VJD>v|IS0UD
    M5CQ#*w;lS9f3Dy-^8f$<
    
    diff --git a/src/test/samples/golden/inc-game-debug.app b/src/test/samples/golden/inc-game-debug.app
    index ff4de30ab67965360a76544cbcf4dfa62f846b85..d631d97698ac57afee9618261bf4ed544ca37b70 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng?x84UGcyqdr4@xeD2!xc(CE6MGfU0##u{@ob=E_}g;!!+
    zM$&=)fZ&2b^bjOI6c{v8{1f?7KJ*YhM8Xk;5PXPP-oCrDy71wg`}=)=-}C#Ov+P)Q
    zytJ7F=ZxJX35OTP)5U71iAZxvW1B^Z=#u6XWqyS<<#}rtTx)`yaJ`nF@_eFpneY74
    z^|5(td@)$=C+${#@(
    zZ>Z$=a|O+JcL{F8$J_mj@B`$YOucTd;cL}IuqdsBbbw4f|WvzTXV>QlP-}f(o^9q0zrU=A#S74QRWgM;uY1ueim^P+Ha{~t?OI^zHU
    
    delta 802
    zcmYL`T}V@57{}kUo$1-ITwu^Z=ZcE#b3tMk3WGZK0mI|Qwn(P=ab%8t*z|}u;=m)a
    z4#SI=E|MS%!KmK_ws=4OMvWU9P05
    zsmhCUYvLTiCuyP@lvs%R_@ixy%34n;Y2vSM_(WM3j
    zG{35iW!u>sy3E(scXY-{ge^j2OmVNIRSU`$C?o6^{l+(6>_PRCcHMYwfQ3quy!`cK
    zp`)`a`V|xn5gZU&jj~l$Z4WDEh1B{xAnh3jwLvaPV
    zb~aveecbw^>kADPBOK?avz0EpVkP7)7^6=rui9pKLNfHUP{+NdVZqHMsUp>Ih&LjiF0VNe2NDd}s5~Uuy*Zm$j4jUm}FaS(-M($1|
    z(!Jy?P1#+irV;uBn618%(SSVEHYle^3I*hw;r?`=l8{?7vP|ZY*g_ZWP3{o#;3Hin
    z)0sqiNGAKxu0i7*bvkM}ksfn2UHE{QBG?2LIHtfCa0J>$&<`GiS6~CY13KW5a{wF$
    MQP8_^*Ku>lKV4%u$N&HU
    
    diff --git a/src/test/samples/golden/inc-game-release.app b/src/test/samples/golden/inc-game-release.app
    index 3846ae0999fe2b979c18490e5f33e95fddd4a5f2..21b62104aba2d005612c051a6b75f79781c85682 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng?x84UGcyqdrCSvCpfHk&L8I%2&MY;{8*9wX)L9P=7hZ{R
    z8A%8G1A+?%(L<23UvHGnZ9%iz5Na$iMVy#8qQ&+QyXjAtYHAQh}Qqi57N8#z6
    ze_e%VFa)A{B&zUlh-0i+<9Bqw6n!ia$wU;3PGQ%1(NA9f7LsfquW}YzD%=4BFMq~%
    z#fG!~Tr{q3pGaNm?&+GJh!0m~Z)z!;fQ8jCOJcXd;v?iIqE)wN@72(H_!s12rz-g>)^0tbdHMT2^)y;i_#6EBDfbk6
    z_RkH$0WVKr(}A%S{xDaw?!!f+td)3Xy
    zuHdHmfkd6%}1wunAl_DY{aS~i-)E+p!zF5XyOq*3&F`k%952Y&Fr&-4HPdEV#DcxSwK
    z>n)tL*kBPj_vEOq>yvA>>VSn1O%x_R)fAOynvXAhm)K?6W(^*fisG{Nz|cuISrs7>
    ziEaJX^@*p>!n)zK8crXt36sxj)h4=X_3T>y-`D*4TGc~8Tld(fQ7yFYXH#@1?{cL~
    zO-)gpUl->IK1mbRpu|Gd#~vlxNyZLK-F6ADc
    zqWM*AEZ@oA&=tPEv8y{lB5Vm7V~RUvt-7XMfil8w&~JR}#a>h|X*0%a11waQ6y>ic
    z*Sfl6(I;5YdVWqI#EE$e`FWg5S}{iw#N14L1Lb%XrjwY9j@S;4Z~ipmLTZ39oUpRe
    z%LLJ)b3v`ySf!~Avlphy{1p;=XkJ8BV)rYSSE4>ZTKY97+};?)J51e97?Ou
    zb+YlY>*LlRU0-af%5j{Z&Q-hYij|PJV2nPlzG|E03CYsaLId}jh6Oj5qKeRHTR`7I
    zuM2xQJ6#dZH{8On#cN2cw`aILlT8Ii0!lhKm>NpTBuYJYulqf695zC{U=Wzj&l9b!CvP>3`*hZJ^E$$HV;3KiJ
    z=}a;+ER%g`*P(HaIvw?#NRK&ME_^^t32Xuj98=&6I09`m7yysJE3g6H0UhwjIRFlW
    MDCk?f?TGLA2N#4mEC2ui
    
    diff --git a/src/test/samples/golden/inc-help-debug.app b/src/test/samples/golden/inc-help-debug.app
    index 3bc105ea8f248dc7051ae22047d6bf1700dfd0e5..46a9412a0de5bfd260696986cc907be4c9ba1b63 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng?x84UGcyqdrCSvCpfHk&L8I%2&MY;{8*9wX)L9P=7hZ{R
    z8A%8F1A+?%(L<2A=_uf1In?@Zq9S*2>4zR^!Z#?cbTqgz?SPFKp=F38d%U$B0qrXHZW3sBX`~E(4_%2NI?Yt
    zn!7@M;egsasH#-WW-av<4tT+qdU6F#fzXgjrHF_~oCY_Uwa_Lsvbw_h3t!RZI@khf
    lFpASQzzOK|8q9%zz^Q{dH(-D&-nsB2o*dP6eR8c@?XwV~iNeIEs=V@C^YOXw61z%Utih8~UR>5589M1Us~{vI
    zvF+cwKJm<%+c2C~!|CHSVe&<_+CW#Wo;}O|`puH5s=4NaY>MvYU9P05
    zsmhD<8{#~{CuyP@lvs%R_~UIy%34n;Y2vSM_*7XJ?r-4;i93VLx)V|M7Ju!`r98k>
    zG{35iW!u>sy297icXY-{ge^g1OmVNIRo9ejP)67+{l+(6?nd>BcGGxmfQ3quy!`d_
    zT1RIr`VrudrjwY94%-fo7k?UYAvM4lPFPvt
    zWt?cyxuDi)tkTqm*`}#7e}%*zn-@`)*u%1A2J=a50XKN}8a~N11{gIJH4%0Lhr%j!
    z?QFc{`ndf^*B2WqMmWw-=PF%x#Y)IqFh-wLUboHggk+ay!;x#1J-8Iyj&LjiF0VNe2NDd}s5~Uuy*Zm$j4jUm}FaS(-M(#=^
    z(mmunP1#*%rV;uBn62KC(SSVEHYle^3I*hw;r?{5l8{?7vP>3`*g}`=P3{o#;3Kh;
    z>0BZ`B$ESZ*PwBMIvur~NKZJLE`2~u0Th7+jw$d39D}wI^nrEo3T%OQKnFZ>4uPW}
    M3VIgrI%aqM18^@m(EtDd
    
    diff --git a/src/test/samples/golden/inc-help-release.app b/src/test/samples/golden/inc-help-release.app
    index 37496a5957efffba536441ea94cc5066e2ca09ba..eddeaa08af99b8b353c7e0b563b3e72059bd8ce8 100644
    GIT binary patch
    delta 824
    zcmYL`QAkr!7{|Xm@AS?L_fV9wnVE=!(u%?!6h<;JXms7snWbiVW39QFI_sg~!YeT@
    zm!tz_Ah=)T7Js&iP~kp^DC1X
    zTd>BLvQ;|*s+^)`9sgLY+So!~`T5lUyylJ-s}+WEa#1WGS!+vb`rS+>Ot0jrlsm_s}ybg>q(*5cSxSF@;SQ}!7(MY<~~YcAC*^YqTY
    zt|BuS0#Pj*lleEqF;=|sJGx(rO-e)#B8tbRu41TcKV!RM
    z!`VPCsw>+kQkS}Wy5=YJ;i~LSEyWVBuo`Ab>^7J_LIELc@0y|Rih@@
    zbZBj=hyP3)oo=iINOLDzHGB464Xu}dK`wr(lCNUz)}pG9zu!|&qa~TY!C!!KPqAnJ
    z+z=e_@dP#<7+dC#ay9FITr|pB`FPrDoVmXBJCm6(zS)}m5kYXVL_vwAKaXe?+-C0z
    zZkZp7M3>na$u4|g?NXVbu!wZTJdM1GJ&}$I0-KXs>{EDEj7L3Jdxx6CgZ-XiPbB2M
    z(LWGWsEw6d9A%&2lLqBxA}=u1gGz5O7`{dgEMzHh+=uBFFj9RZcRb3_g#jf*Aq4)K
    zyTg5vpwc|3C{)enEcF#mc)^x>as^Gn@Q^~Kh=@p>1lO6h&?Yppy2AQ%U(x0o*aT@X
    kiqqD?G3e_+H<$tQU>U4{A7C3CgjXqO0q)rsg*W&A0T0F;=m)a
    z4#SI=z`V&q@G2@K6f{~NU_o8gg+yJ|#T%=OG>Se?|8q9%zz^Q{dH(-D&-nsB2o*dP6eX>xk4p<1$L}B7nRbF|f`S{#-iCv^E*5FYoFD`2j44rh7RS*)E
    z*ye9tpLpubtrvEF!{V%ZJ?`G&yMB)ea)Y(Rz38yb(eh_)m-ylHbuAdE?3;t
    zROQ9_HF2KclQdBcN-RWu{Nc6(Wv!=_H1XG$Iabzn47PBDM4drq-HIrCH-2r+rQE|)
    zG{35iW!u>sy297icXmZdm@PqLOmU~ARSU``D8uXq{l+(+??v^3cFlNgfQ3quy!`ce
    zp|h(y@&pT7%gzaeI5BT7JC9RQ%a4#KF*h@~j`CmyrjwY9W^DV%H+~v%AvM4lPFPv-
    zWt3=E4>Y4{}57+};?)P&hp9Ez*Z
    zwX^Y(>*MAhU0-ad7~wcSovU=&6)Pcc!5Dp9dBrx%6Oy7Qg*xsv?GW5toGL=SZ2^4;
    zy)NwL>~uvqTXze;7Ox@Qy*=sHWGWuW1e8Q@C_WsMNrZarUiW+CIBbM?!4NRjDY+*W
    zOZJh|G+}q0m`3OiV7B^4MgwxXZCFl_1PaJE!~MyAB__9~WSJ}=v4t+#o7^Gf!AH7F
    zrc<$GS|)qYu0i7rbvkM}ksfh0o%?{8BG>>HIHtfCa0uE)FaREbS705y13KW5vkx2u
    M5zx1I+i`i@KPpf-H2?qr
    
    diff --git a/src/test/samples/golden/inc-hlpfile-debug.app b/src/test/samples/golden/inc-hlpfile-debug.app
    index b524f4a5b6e409f8ad1b5c05e3dd428ef92fe4dc..8038da4cd831ad357bbfeab9b7093be2e7fc2254 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5?x84UGcyqdr6q+uD2!xc(CE6MGfU0##u{@ob=E_}g;!!+
    zM$&=)fZ&2b^bjO_s4!@xs6@V$4?RQ=k#a;K1Rr9S>$^Lv3m?w8zu))wJ-^>MYxQgO
    zFDwSZp0pYy;qbyls#xtZ5NQr+e6uJPUefHM%rCR%Ja_GaV^xr2j#u&%o=?;v^POLr
    z%=o-Hx|prn5m03pHS@&BV%5Ut^UBYq{^vDkq*$%cjS~xE0m)i>LW|wbWHK&K8-EC4
    zw6T)k%N4ZT*(JD*9PjX+!s;`-d5GD>Lqa!e6>CklJ#{q;ixy>{UQ-lvBxKE@x@Df;
    z`PWrw216jKg(EWmhB(HGHho9;i;+hXk&TF=ktyuDF8Ij9-$Ih@<7Li5O@%sP;Nef%
    zuGo0Smy5=f?GvesJ-yv?ld+Mi>`g63;;=C5W(n*zSZtJhq+gTwGs50{j3M(HrW&ip
    z46y0c+Eo|-nbteqSoV?TjJIjl?7iw*H~)fM^kgMp!P=vRRS$o^r=CVjGJk_VALX86
    z&;FS%IN;%NY&tQv%pc@x*1foBl(q4Rv{^rMed~87GpT>cn*32gaIknmiD@8@Xa(G4
    z?+R`h?~6p2*lEcsd|(|?nV_(cbi_D~yn#KIjtT;sm0GP+cvXx?y;u5%TY^Iat`S!#
    z;J!XE=vSznm78p3pWu@Q);slb)W~#fH|-Pmcb9O4GzMq6tn_o@_FIw{eJ)w@H;;M
    
    delta 802
    zcmYL`T}V@57{}kUo$1*SU0~3_%@q~dr%7TL3WGZK0mI|QTqM){IHF@8Ha)`|ao`bI
    zhvCIb7kQI~;8j#eXlP^~U_o8gg+yHy^2X|-8AYF`|2Z3W;0N#fJpccn=Y7s*!)C*s
    z8Ve^ax-A0dnHtk|eQK>r9k39hiNfTkik$LH^YPj561zxStihvFPF&U=7&_?&Dn{5Ys@dkfY?@Z`E?3;t
    zROH0@b#b2HlQdBcN-RWu{Nc6(C9SuZH2K#zdaR`D7;NDPi8_PKx*bvS7JhBdrQF3+
    zG{35iXWH2ty297gc6LQcm@PqLOmVxYRo9eDP=?uc`i*Zs-;3%6?W*zG01Fi*Ir;1H
    zwa%{Y$P+ARJu@c|;>5h!%sftcEte)yVs2(|1LeUoOeZlH9kuPBDEu_yLTZ39oUpR|
    z%P7$zb3v`qSf#1$U@uIS`70#$(7cGM#O{?WvzSj}3%J3%)9^{AF~F#)s1CC$IOJEM
    zYiAQh*T=0ty1rOfmgYD=ohx_Q6)Pcc!8m*b>GCV#cN1+Z_h|;G8GSu29!i_C_WsMNrZas^`7_0ao7l{2SdPAr{tbk
    zEZIj+(}dl1Vg{iD&jz=pjgbNHA!ms6@V$4?RQ=k#IyI1Rr9S>$^Lv3m?w8zu))wJ-^>MGxam|
    zuPg?^K5I2d!qKH@s#xtZ5NQr+e5)uPUeWBL%&)TMJa_GqV_lHrjyd@m&nN1T`R=bw
    zW_-~cTh3PP3aGM+nmPKhShcXlyz&dF|9Qny9%44}h|tYi#afeXUtP_@qD48N*A&GaNm+BKZkeZd
    z|8*6b#t?{V;fTz?A&#+PP2bV|a^#6bWFw+jWD>isOFr`Ow~%D}c%8FQQ=v{6c=&U+
    zCpMn*<)U$A=Tz!)PjC0aM0}(wdsEAi1T4(DSrWTV79S-a>DT1_jIcK!W5~RQsm7`?
    z18h3AcGbmyru9xYSAC>86K$F`d#}3I&A%WQJ6*}wu=Z$S)x+QKtEbSC%-`V8N4cli
    zw|}7v4taP2n@)@^^GCUw4KFSlWo@CUKQg}@3p?+mf+BUYs3`_
    zxNi;&`W0$ttI9q0kmU;(UvRqzAsfWz=A1+Bn2`?4^4@DIciI->vp
    
    delta 802
    zcmYL`T}V@57{}kUo$1*SU0~2a=ZcE#(mK_ws=1c^Y>HO$E?2_T
    zROH3E4RMa(lQdBcNGwRb{L%J9C9S8JH2K#%a-yUQMO!&SV$J}w?nIQlTfcVZQtsm^
    znorfnGac*=UEyo$I=f;d%$A@rrnpRk>_zpGcHMZbp9PDOy!`d#
    zdS_R6MovsMyYj5M%;x(kZw`aI5l}`9a{7Nz~m>7!7Btkv*2KRg9IBbM8fI(oY({fKd
    zp6VlKY0~aGHI2|8z-;x8j``)`_8~b*k|-eG4ELq_mAKrNmSr-J#8$dwZ*~Wf2OsG!
    zn$E;i!!p^2b~PI3sMAryiS(GG`N9Xp6u=g+z%d2BfFsa0fdTLcyaJox9nb-foCDx6
    Mh=9JuJC1DSKiRoBhyVZp
    
    diff --git a/src/test/samples/golden/inc-http-debug.app b/src/test/samples/golden/inc-http-debug.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..1ad8ef004af5627447e8212168b03ff04755744a
    GIT binary patch
    literal 6341
    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
    hcTZsaKR;C*9Tm=f`C(Hr!J
    z7j~22ns=Ba;mGn-+NyS&h%~n}xm}{iRyCI>^M2M+;Hz79ZwRvPekDKQg+!e)-}{-(
    zPA=IJE4ivY0aY$hvrT=ls&=+iP;ox}Kd*V>R<+VFPA!W?Bx@ZhO~0MZX1)G){t&`M
    zQx(6LFKE5o
    ze`W{{`gszYE{rYn2l<-K04^G3?R+X@GtS)H`IXI18{d3w;kY2US+c0qGE_jc4sNoy
    zMK{d%MWV~>jN}mBvreg8P*_wtYMw>j#2!n>1c5C`ZH^hdD#oL}D+8mgk&z+qm^T{s
    zT^||_Db&F#EY9+e@X3G*Gm#G%>Jeoi6pCD>CKk4oI`6`C2N-tQ533S3zo)87rbCgefffxP-Il0GDJiqPJwI8R%{oVS$%Qixi4sQ18jo~
    kn80b9;5hX4pcl-6MX(Ciz<00<4#BGov;oij^WuK%AL6V!IRF3v
    
    delta 802
    zcmYL`OK1~O6o&64lXen{iBQ2nlSWaoJ}b1;MGFC)`hd`BLK?B8?@vk9DkdfYIK>2``m`=Ot4Dy;V{oJkA(KUNu_saji7RtNT0R3#=>zqZk(0qW+&|SROo3b=j
    zy11|*E)aZ@CaMvMMQM;f)^@n84V03m{swa=%es!C7LJgFC&KJI5oPbzubsJ+dw7Z#
    zQniVEJ9|S{`I_3!t^|p(WoXPP?v%9Zy3zw>jNPE$_~wg!s9w^pnXe7AXi1{WUr()f
    zc6G;}U_l%Cd4UiQ<}KtGa4Ks07)cOoGeesw4^?0~iFs+xacFYurx_Pj!;ImCl@(tm
    zh!&rZXpQD7Eo}#TVX3TNA+d+nMN}nrzigSqd=gv44c^J(8rZm9CJJ&S$bOVaj$8I;OA0Q5o#Ta=)353
    zVISwDtHOETE&N)%hIIG!j<#m9sc8>
    zesY$ko!*97g#G|lYhY|VERVL0$Z3*B0r^&VC^MiW<<_h$lSL%9&}Ca2%`3$Mhu
    zjHCno0l@`>=pjgaC{Spms6@V$4++sjBpgu)!H1aT`tHu^!iRJ2@Av(E&+m86THRXR
    zE2}|p%-9T)aAa{jWmY>4M4D3?+bW7jmNkbc^Q){W&r`GLTo>fH^NswB=M%NdeD_x-
    zGqzxfEoG~A1yngi%`*PctXkPZUfG4z|Gegkn$>dMIJqbmkgT;OwfNmkCgb+D^2ZRy
    z8Y=jMTtV}_J%ZctiFV%^tbU7!hnZbGEOfCJvBqTIS68u!XjKmAHAQh}Qr4WRN9O6>
    ze_e&AF$AJoBr5Z7h-0i+<9Bqw6n!EQ*@-9?oy4y5lApZ%EhO1KUgs>-RJa2MUjCfz
    zi4EucxoBM3IhnfL-P1Kc5g)G1-qcbw0Sk+6mc(w8#Yf0b`Zf7JBkaq^7&5PBs4PFL_%tle5f_44=o>M67&^Ev$aDfbln
    z_Ahk7K`&2W(}A&N{wP5ud
    zuHcsOp-6Ovot13D2i7i?3JMELM~zd+8`x9nm>{rOsl_&lSH*bLbFFu%IW*Ys9(IR=
    zo}2vx0fpLFnaN)I2|j61W+d_eT|KDu1_Gh$)WCwKV*7oVZUa5lH*&|V3|$;hf)qsH
    zuemGK7Y-=RgNi~`Y}Qm??tm9;sV7&^6bKC|RDy_z#3^utSqiN}BdaZ}KmQeNu7fR*
    l2BSD_1009G7IcGYFb|f&3ittbz#({*fEM7Id0ALv{sY9GI-39h
    
    delta 802
    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
    BC3XM+
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-json-release.app b/src/test/samples/golden/inc-json-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..5827c288ef8189c7363687e847b6c5b99768f51d
    GIT binary patch
    literal 6213
    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
    
    delta 802
    zcmYL`T}V@57{}kUo$1-ITwu^(&J`8e=Yqs86b5zd1BS$Y&xVy#8sJd>lku1^-K)qV>hnkY&l06%xi!OSHJpB46DFTmtMzo%>fN#Yzpn+d)vA|%w(hb|qnc~l%ckgd-sMV|
    znyS1wwnIOYU^|n
    NK@{{Z-gaEs_79<4IBoy{
    
    diff --git a/src/test/samples/golden/inc-layout-release.app b/src/test/samples/golden/inc-layout-release.app
    index 27fa883bd2d78ce8e11f3e2277dff2c0f3edd10b..74123c824c154807d6417c6fdf657c560f13a76f 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng?x84UGcyqd#TA7;D2!xc(dfFNGfU0##u{@ob=E_}g;!!+
    zM$&=)fZ&2b^bjO_C@^TGs6@V$4?RQ=k#IyI1i_f)`tHu^!iRJ2@Av(E&+m86cKvq!
    zON&9UC#?oaIJ_{CDptD;M4Cey-zbWOmo&R5^DC@5&t1FVSQX@$6l~Na$v*Vy(%xr>oygseGKx6IQ!
    z|GEmzUrRi;a?x^lS2dM%bH=F=SrDRAbec
    z0XCglyXxXU(|V`t%RbVa@ixtxy;oi9=3kJDo~-06SbMav>f!JA)YE85=5O%lquf*M
    z*+16>2RuBEO(({d`NLeznim(1vNk@EHtT1uZT`+=CiO2_lRqj54i+ycF%9Gqt$6MWL3+(_gGx_U_I^ZSF>sF4LsCANDo-2{57fAqFX8NM*61So*O
    zUvp=$Kjc?hh7^UW*{rFd!VWLkQg5!H*&iHMs1y+qi4)*DGZ$KfCRSJ2aP}+OTm>5-
    l4aRWV8aM`h9q0iwU=A#SW$*)RfrIcW1+BoDd{KCA{~v4{I_m%c
    
    delta 802
    zcmYL`T}V@57{}kUo$1-ITwu^Z=ZcE#YX*s3C=BXY3B%*YTqM){I5NjRYnsB2o|({feI{S64p|7%L}B`KRZe-X`S@&6VwY*FHF#3WiL2T}Lnqy06@-K(
    zw*6bzr=K~q8-~+rIDNb(%)F>p8|b>#vuE{xU-M_GRS*4Q-DjUeHQREK&C=bx%at%S
    zRXK5SLtG^IBu!L<5(`luf4t*JS?eh!P5(|a)%6&XV
    z^Q+oqrjxy;YkX~ecTbFj*$Onq6n9HnHLqNOGR$t$?|e&pKdP6s>&9yXEL4)@kg#UYaWNS4ixUc@b5KJt$k|F`vYiaD#WR;gd{bfKgLX6K2bN(wOK@`ustEPACG;Ki
    zhOnQr(>38j-EI6@yoU7l_l>rt(uu%WKuHFN6C-h%M5xE!=>C8lhmDX%Fbqs}TJDR-
    zQv>82P1;?j=Mee>n61I_iGV!XF(M~P5(VU&;r`U15|`W3vP_nc*h*LI&F&EL;3K^y
    z)7f}xR3-<|u0i8GbvkM}k)CigU;K!e0@wl;IA+0Da17ceFa#ch*I*O82Rh)9a|j#-
    M5iqcP$I-s)A9iXu&;S4c
    
    diff --git a/src/test/samples/golden/inc-listhelp-debug.app b/src/test/samples/golden/inc-listhelp-debug.app
    index 0afa33a89e5ccaddbe51ac40142ab3cc38601085..2f45461574edb6a68fb6093983052a8e79df648a 100644
    GIT binary patch
    delta 824
    zcmYL`QAkr!7{|Xm@AS@u?V%`TGcyqdr4@!fD2!xc(CE6MGfU0##u{@ob=E_}g;!!+
    zM$!Q*5L_^b9)iS&0)<8smB^R!p@--p5{@W@;6u!E{dZ?|;m0}m`~KhmeBU|ghIGR#
    zt3hzg+YFL$Y)T#l_VByyl9U)k@tsxhxiutaT){_`Pg4>-M(u#}LMv
    zs`$fvLF@f}g4^h+PTyIqev5~PnO!_8^sqLu&SXDO*RY6aRSxMjC2?m`)|{$G=9#^J
    zU4`c`1fp6bD)VoMW2{*7cXYoJeIgOri6|DG!LIwVpS=7nB)L9b=PlG!xC;he{+#WL
    zP3QgjXk6Jnox0N7*OQ)#k5=byY9*S0g+(_@Vz7vy3MReT+5uNG0g{QZG?7A?tq5r2NlKgEIl
    z3te#7%M;jiVQiUC=W8~7xM-BM^T~`wKXY^EcQ!kvfAh74V}jshiK0@|U;)uOxWnER
    z-8McFiLSD9l1=!)I;Cq{Cj%;sL>{24hn4<7AasM8SkP2ze*n`Rpr;1L?z)wcOG8SKf(ZOI
    zcZCMR0i|_VQK*J3m>Me`@PaM%@G2@K6g1KdEU2rxkf^J=cw=?ZjH1ue|C|jw@Pqe#p8x;P^FC*zVWZ)G
    zjfImIJr;rUPL1ihKDFjl2P}kWqA>ZXDz7}#{Cw`a#4ghoYv_cO7nik1hEBT8DhP>4
    zZ2PyaPd;_!HVvoMaQb;on0oG1>*%W0w|n`2UkhZNs*irQ?zPXLnrlA5rs*!;?M|4Q
    zs=PSADb5pok|wGliG``3Kh}1*to45=$^$$_
    z3#i(7ww=A9D|}6DXIG3w*b+3x6!%J6bxpYfWrW?L-}vT>y{KN&t{blnvT#X~m%pA`
    z>+I@|ZeT&1**Sp_7v{}n=W!}%`B4%h=4J-BP#&znbP{vZjP1}w@uv|NR)dV;gq0Ou
    z#)uZ33u%qUDot$%dts`~Um>x_=0#K`_ONW3#e5Q5zzyC#hEFn$K}JnQb%b5Rp|A>F
    zJDVuEK5qZf^~Ji1QH~4Hxk|TPu@dqYjMFETS8cOAA!&MA@Nlnbhv4NBR1s=z3+OxO
    zO<^Brrz^sF&mH_)yoPl5_KdWq(urUus3b!}iQ%|RqSR-v_r6Dt!$wFw7y_m`E%(IZ
    zsXlU+ChhKq8HD}-W~+a6EGUn(4a-TAL;?9`cp%lU#O2nsERzK!w$LSelQ)b!_(*rj
    zbS9n}k;#6vtI;?|U5*+~q{kgi7d{}S0E)l@$29l?jzZfA2EaOa1-8IDpaUK`2f+~#
    M1$~Qm9XFi+08q_1zyJUM
    
    diff --git a/src/test/samples/golden/inc-listhelp-release.app b/src/test/samples/golden/inc-listhelp-release.app
    index 96127cafea9afd8a9ec4b9ca34f58cc9fb5cb6c1..9cdacc7e97b44495b1fe28353dbc61fcdd7934b0 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-sznQ+e1;xW@aJ^N-GR|P#DR?pwV?hw=6Zy8*9wX)L9P=7hZ{R
    z8A=EG1A+?%(L<2~5X4T4;3Mww9|K~MV%&b=G#;Ikoh-9rJr6ulWvst&doj-yw
    z-c-e>@&&E;_6csIr#k&-u?8$29$|LzsL;*Y#5$AxKwZP4qE$Jh*OVljDOq!>9+_wM
    z{&f|Z!w`sS(U{D?A&#-)&EL`eO6;*jWGA9{YzDio%K`H8w~*xec$K$M(-9vGy!;v4
    z7n{xp^3jB{dpdokr?-1?Ix$+EyQ!5}5*8NSEQQ?`ON>!~^lS2eM%Z77F=SrLRAcqH
    z0X9CZLv{0?8NJi3wE$_ZWV>d|-K(zk@Gr>48>;v^)*daYdinbU^(}VJ}Z&BvO@jqQ>)oi7>PVUR}U+F!C?40HL;MX)P5hPJ3vnjjNNf7BbSDh5QPx<
    zYwijUM1o4|u%b{6TQD_NI^YFc>dhCl1j8c=l_4S`aR%IAmSU^W%<799FMLIt8((|a)%6&XV
    z3#i&eu9Ll?D|}sjcTb!|*b+3x6nD#7bxpYfWrW?N-}u&xF;p*U*NxW(S-31I$X`#b
    zb@%i}pI|{7xjBIl7v|09=5Z=%g)E5^b2CGmC=XR(I*GaInC;Ny)=wiYtOgmw2`ekT
    zj1w(77t)%IRhrr^_QF(|zd~Y<%!{Z>>_Npci}@tBfE&Df44-5ggN&Mr+6cRbLunPd
    zPBvL~ecb+|>x&IlS&j?PxoWpvu@dqYOwh;GS8cOAAsKpF@Nlnbm*C}+R1xZJ3+OxO
    z4PhT=rz^sF&u#o#yoU6~`bOK+nPhM*sH8%}$&rLiqSR+^^u9-q!$wFW7zU;~Bljf|
    z>3(vSrtI#fB0_%vvo(+%56Yt*BXWwQP(Z#J9!L);3AsHZ%VYtGZFI@r;teAYKGIt@
    zok^reWwIabS~Sj4m!pmo>2XKPg%5}+fh}NxV+MQyN1<&7gJ2!J0-N9+&;gH}gWw2=
    Lg8s!jj+pZw3hp>0
    
    diff --git a/src/test/samples/golden/inc-music-debug.app b/src/test/samples/golden/inc-music-debug.app
    index a07ceff8a0b2def3ed5367282baca534083ffed1..f5ebb4bc0cd506581564f6f72cea808ada39ab19 100644
    GIT binary patch
    delta 1148
    zcmYjOOK1~O6n&FU+DWY@3#A{@T0cmu7Nb-YtWd2fEp&96u}%H8j+v)POlE45i7u>-
    zn4)0_iVysN&M2A;X+)9!Yc|QxX@G^Z)Rf6z+vv^-1F||)t2ez
    z`%VkfI#Fxkn5yZKNqh331tHzdUHDp&9-7r#S%DrUZ6)HyY4>MFNV^{jw`nOty#k&8
    zoy%RAa-?SRRr3r~wX(WnNXcj*t#^qs>)dH;^ILiI*5P6>0aFpu3Zz
    zK(q6UU1{Sm1Vr_rgg`$+9L7rR`~=<4ByMmBxe!9B#4WH69ta{YeFBtx9}i547HMk;u54lwHTKX3L{rXVSOP?3mqtKE-
    zAA=V}<|zvDcMZaFFU^3_2V)ELnprdFhebnKHyz13j5p`L|HmrDL2N)?ie#V2N
    z`v$Rs6bSqi=T%t?;r0l|sDV7PZCl+6S|E!C%!0O%8pWsvA}l1f0FDz!xs%yRn##9z
    zetxX9RPmfUb$4kY#{c^x*f|9
    zuSEmCE>)6w$rn*8Okc#Y-I8*0sp$z)%;$$tA?Z=W`6Owo{54!)@Buj>VnqfQED!dn
    z5qU2@UF`0XWqVP!P1Tf`qbSgYl@l>P^s^`+N^xB5=ND&1k|qc8Z30bD$=0eyzT9v+
    zmHfV!6l`Oa=JTBjkE2oP1pdd{9|$knU1~H|RO35Ic9qqWb5)ltaSP$9*E=KC=851+
    MXismSCtl?L1wq%I1^@s6
    
    delta 859
    zcmYk3ZAep57{|}u?(}Yqw!qNIoGU8Q-YgXTps>hmFEG5`u}zX`zFe7OFE+hH0(IaO
    zTbChp=tF*K#lTNdA)%m=z0e9Oi25K=pX$Sj)rT|+d%HdNZdeC?aL)7m|9_ry&g1$Q
    zM{n766t_^NV<^Y;n4&1ts}^Zchmg!NQ=iOf@rmrBQ{Opq2Dj)vhqyGmB;Qqa+%-~y
    zke?$PzZ7Naku{Z5oqE;jqGe|KsYR;6D|+YlrGI_dowP_!{8?XNoP}ztc{iEC+i06D
    zqG`-&Hk)I!2o@)^l7}N+?4l2}?<;DZg`}xJuF=CqoiErzAr!KDh<+=g=w1K0HJ5lB
    zt|Gf7c|6%c-r!~0UftClLVmIc8g+`B1+BCyo&}|!T*cq$=F@#p?UXO8x8;eqAV~{f
    zkF0ie_XHlo1Lcw#1|ciVn@VP3mzUEC6hhj|1lOP(EP?4bV#A|`y_4%d)Htui69PMU
    zvi$Q9k^>o!+@L@M<1`~ru%;;UlZ&v)
    zuYj(DOcq=pH-0P1LQP46qTD!BYBP#@gx&(<_(AD8!yJuJ95*pl)NAZx98?60OtoPi
    z`X+pZ*-07kGIO%(I(#kM2KDsyj@?On-b0SbfKVMU1Ze8G
    z&>IfN`cW&68g2En5V{Fyt%1ZCFO0Mg3sDq>0_1Dq?%04B7TV&1faW2w1uq&K9bU)-
    zAL=QXj)!9-0@?-bGH9H@R+F7#@j+ALsSgm72i5@{IA(w^z<$s+0E57N;1#e2yaN<~
    zhMYY>Ef4_u7jBq(%~U0}lsj;+d=tN{Xu`)zoj75;pqrpBRo2?iSAY#>Qdg_5lb`tS
    F{1>*FL(u>L
    
    diff --git a/src/test/samples/golden/inc-music-release.app b/src/test/samples/golden/inc-music-release.app
    index 0426f640cac6144289cd28ac6eb7916b7e5b7a35..03cbdadd9f628fffa814de23a7d16d1348cb0034 100644
    GIT binary patch
    delta 1137
    zcmYjOU1$?o6h4zq+N5?(9+b9{cDpKK(PAu$f)%RWlomR>)6BN1f30KY+9W13nq=a#
    zSQ;@!!w^=yu&aw;g@X7XD1F!k7c45Rh`y)~K8O#ZaEpQneb7`J&&5%|4z6>$$>HJ}W+!Gmx&00RBj7souC6fl!06;fc9?cmNt
    zTOv2351~MaU875U$KIG7Es%w+GY5u84&6InNN+GVHJ6=439ltKh1OeI$UqFNHAS9d
    zI8u#ch^&?7oEyd+C^KXX;>lX+t#yZcn=$6xe$9;
    zW?#XUMD{;=F|fa)EPvf1tO~J7G=^|&k^N=Y%tvt1Sk})bieBr@`43-8rSsOAZmrI6
    zT!2p2G`bU20M8I7>D`(W&I>%iLAuk`#66;eu4ax<-SwmM3g#X3lItgqqqki>O_%Xk
    zaXc6~BB#2wWFnXj>Z%k?9FG$ipe=5H^HbC*B3hgP62f99i5!n>Vd$W$yU~9ZML!^{
    zl#=;9NK(HZCn~5I_$DrBiXJE3NkU*Fz3twywiC5b7DnuXuDF&Wum&SMCfX54skhe0
    z?WS$DJ9=MYn{N>B5Jki|zBZ3ohx|6gFya>C9^wJwG2%U90kImj)*yNi!t{+=xsKaJ
    zJ@w7IR)V0XqTxPGRs=bm)aqX;ifosrt7&gVppU4d=?M0-EFj4lQt6wEbAqfZQL{~?i5lJB
    z&??kgPOob2dqvr{R%t)qs|qBQl1Irm-fz*wvfZbp(iJt~V{}iAkN(kct|5afHqXz4
    NL&0Pt0B+hkUjd;foE`uG
    
    delta 849
    zcmYL`T}V@57{}kUo$1+-w!mr8a1bfRk+FLR=FBhjPC7A|NDwJZI#{d#b`HAV>Q#bhfcvZ*5Qch
    znzAfEx602ET!O;OK7snd!yangU(~t_NfUoP!$*s{K)8t^Bx3VX<5on`yZ&oyuGBre
    zisF@(v2+`K3(Ksts-rVPf^-oz+7x#RS~-_GkIEpu0pHoivpraCSFUNd6{)`<$x7dj
    zWr5x=T4r!QfzIOzy*o6Ypc_RBx}rQtuiz%X
    zg1R<3UT}Td_@k-|)g>bg`X!(7;tPZy>8tED$_jn
    zEpVON#h784J6(ATzZP#px_Y{YS`x{aI4q{(zJb_aR3ag8n`>MjFvnsdqy`y4bahhd
    zjz$x`q#5F7N9{C*ZX$ZCZ)8-IhFS-uIEiBc^Yw6VqAwMdT9T4P<}tAe7R~i8Kjxv2
    zbQMe|qlqDj?8bIEHco-f;$(O@Y^gu<5o7YmI$}V_6!H~0h}t@&A9;klM%Iw`h>Eb7
    yvllslgpl5a+m`M!W+zxHToA6uAEOfJ0vDWO^S68#BXj2a8HN^>6O4eMeSLT`B
    ze_cgpF$AJoG$!+Jh-0jH(|2^g9D5=WIfy78o5HT^QhS?qj^EdblQ2r_Q
    z?O*7EgFc?brW0e!{87GU!;gzbSsS0oSoJeEwtr`{llnJTQ!pwBE|x4TF%KAt*1&D{
    zuJD%Wp-6Ovot5mu2i75#2?~oyM@`en8`x9nm>{rusntG(SH*bLd#!J{B|J3X8Sz9y
    z-kSr1L512`x!Fz+3ds$?(`~ke5Iv@Z5
    
    delta 802
    zcmYL`T}TvB7>3X6%$4G}OX9?*ioE(v_Y1l2GP^>X`OpbDFD>g2O`Uw3l@St^
    z+4gV4n0V^WZJ18pbovEdoP6%mYUwKP+r9k1uLrU&%|}1;dmYoL=9&($DY{GWc#@W;
    zA}`HtNOJ_AtV>!*W?||Vj?hY}2C!*xt`n5BcdLK{G
    z1DZaTZDVificnS4-VrBJwgini#l50dTT`z>8D+QVH=*fLH>#KQ8|G_+EL@c2m9MAP
    z+B-U9Pq3hk?5s$L8}sI}b2t_B{0NB?Ycu_uDEF6PI+=Osu>H{Z)=x7otOXgv2`ekS
    zj1xUJ8`2xhRa)8zdts@pUm>$c)+rcbtvK}Ib_Wt3gVp|A>F
    z8yhdWK5qXpjK$ir5snMc*>aCVtu0-QJbvvs#i5_=0Ui^TV0@wmJIHtfCa1`1G&*QVk0Mc?e1poj5
    
    diff --git a/src/test/samples/golden/inc-net-release.app b/src/test/samples/golden/inc-net-release.app
    index c20d9e4f419535d4d42eb3d92395bc25042b9693..087a1a98e536dad3915fb2f0f26fe16ab2177e17 100644
    GIT binary patch
    delta 824
    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
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-pkg-release.app b/src/test/samples/golden/inc-pkg-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..de09fca9ffff27faa4a889f3c70980f8ede571b2
    GIT binary patch
    literal 6707
    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
    QiPNiH`a(ng*Gk&|0YeXaTmS$7
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-pkgnet-release.app b/src/test/samples/golden/inc-pkgnet-release.app
    new file mode 100644
    index 0000000000000000000000000000000000000000..bc3a7f232153fac164ec9afb7bcf574e5b26c1cd
    GIT binary patch
    literal 6463
    zcmds6acCRY8GlY~*K3{GZN08!4T!~Qy^bbv*Y%IlfMfd{mqpG=swA(i<6fj^>sZdvpLCeg%lB{qZ_PeyYJy%+Srx=@g(G*%nRz3t_x!$}uz1Er$TAJ=_A!M15
    zSPCej?Il`oz+w8`c5`iHDf{aO82%{>a5H(ME$CooV3IvXy7Noq2_=yV=tm2T0J(Khq2|%weI}L(pYZgs~mqw
    z^+eRkgdC?)&!n9P&zPE2q8?fC7%^G*AQN#pA{nx(>>+}tA(4tSNy+X#59wqw88>8E
    z(G5w7P!gl@#2ApsZv7csJ#g6X-5rmTz9$c{OZGP`^6cB}7x9NfftS{PfI702AwOdY
    zKrK7m60m-_&hsOrXYy_7p4$j12s!4K6>=dlb37eu#Vm!bF~@EhncEL%mh!-8A
    zMT`xi_0eZ`cm?5v<8#9H>9l>$a;3>)dNdq{hG|=xSj4Ondl;3b4y@r=qwR3YcIXIM
    zCtTQB2J7=>g;_C^OoyydB!Dd!K(?iy!fNM3%^p9U>1hEBcSd}*^$@G-@S-Mwd%_n50G#;Bh2@RKGU1B
    z@*A!hZ>lZ*WqSnk%Z;}qA;I}fmbv-EKI>H&nvZu{KjIDpFRimZz}Op#eI3_{_JEbN
    zHLY1;z97saOqS7fK(t1~-KmvHcFyokb>x;>y)x0+MgCRVAb|$5W#95A-9o=6<#z6jCQJ5&ICBnVLoIsc%hS
    znZ6AZHm}0pBZ56OSfX;lh(>jOi5!|3*>yM80NeorA7SWo({#SGnt=DaXj<7X{
    z$}rL?TCZ@Ql^ENx)>?hmjMMQ6mU!v*oAVy-4;~Uc8r9XL7NMRo$=<>Y)f1&U)nq9y
    ze~0cN@1!UA#43#_Q-KZkB7}A
    zEba4Rz*<44d4H!_us-DKg&d4$t{H;`;Q|B53Kx80?0p+yk(-!H!1y9o{RMvj^Uo2i
    zlU#|ZG%wrJFqT_*3vSrMyRVtsHu^VGfX5=~JrH=J&vPJ=U}T#pY}VOR7EE?wZT37I
    zP_;?F5tbA~AEHwSv~isXcm}hzH#(+KO5TU%cHG2K$Ru`@bC4;Z?8JH)fm{zXh{wW{
    zYC?{XAt2bg=&={!%v`3`lc^N-Q4K{&qfN~bSZnS&~KopQC
    z6L_Kq5ytg8cV>114oZ6vUaITTIAvM=3;M1=JcvEuCJ6z@<7$LFj*x91T}f!NVvLb9
    z0Dl_%--qTCcR)NXhZ9sG3iK{Ne_a}23!%$JF0Pt@J#)oZtUn_%*Do}Q!SXkV9{9OI
    zBlCT6Ri|VJdK#g41Tt0v?4+B$pRu&)WIqh=2Jj$6g1id(6@S-ys(vYmh6D?_r=TkX^ug7;*&iCS(b68gd5m5#&?I7ZA`6$RNZExeVEj$^8c8
    zc}U{9afKSB*ZWv2qCj~J(gpE2(m>ejDvG1$4gQ@NbFYM&Pya>!hdwepCFL
    zQq%^o2fqfzO~LELuS!u5c>VZg6m^2%5`K}Qe(+nxhrMVQRO&1@y1%Nr^T@X-eh!Q9
    zL0vyZo$(g|Iez^V_23u50n78xe@pwT<`bPa#jgPZmKs{lSR22#JI_^Zn;Q6Kj-{F5
    z^9d5)V)~0k$u(pZ)99Vt6Hh*Rf%8KtSVq-XQgi$ex*SrNbR07gfjfv
    zzMIhgf8g^`Y|(Fj@38mY;(-9C7P6z*!YA!!=X7xg5uY0$q6RyW-RacrqtkY$aUc@G
    zw_BstY2qw)jB2izsA`5=58{CRdB;kWbbU(IqIbJq(lHvKh9N8C`sbX!pcZd)s(q3n
    z#ntg7bxHZLR(As$phhI-Mi5NIacXl}y|O}G4GwiU%(<;T_Wd~SB8n7WbQHsF_9Zn9
    zO_#tvLAsEBbxL;!!r)okH5`y>JbITCc0WbZqB_&NgX)(x*R(HAC9Ndo6;=C_cSY|;
    z2TqeDk8d*0e0C
    
    literal 0
    HcmV?d00001
    
    diff --git a/src/test/samples/golden/inc-prefs-debug.app b/src/test/samples/golden/inc-prefs-debug.app
    index b9089366323aff9986f413fd54b657f0537bc039..0d2a7322cb100d46e725415911684eb740faec16 100644
    GIT binary patch
    delta 904
    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
    xanjGJ_F^lMrbv!e7g1bxHsL
    
    delta 846
    zcmYL`T}V@57{{Npo$1+-w!m-$o$HhA(?GEcg+(6wfZ_3sZK6z@dPK)QY&WNLO~<@04wUM0*Sn;i#JvmX%u~)o^v+rg&(}{^ZfsRp7(v9*Du!H
    zsWdS{uFAwQ?x`_N)27z!O1}v}<+;gEWm);D>SZ(E1=>NH%>ENXmS0xy>pI~kEddA#
    zbo;laO+ImC)^(>@cY0Zsn|fwfYRIbDvwQh}U-hN!iidnQJFPQV%`_gM(_|Oxa>Wfz
    zS(cw)=jQ>Jpz?}epaJ4#kF_2yYCVOd$-mx_lSN%{posy9I{ehU6H)YT{o0vJzKd5;
    zeTq7sZliC>3R_v-*%gHlT|$jM#bQCLtjU*98KO7HH@5L&4_4dNtNLw48YoDz($`aK
    zon77G$M`_&={XL-f%9h4^SH~a*-?ms@iGG&SRN?B=>+N`BbGxGTR-)m2osOB*8LuHY!-l8WDqgbDJc?*
    zC3~TnB&@Ew84Ue_7_Gk1F;NR~5>=k3=dPN60haRGbNI0Spk`FP<+jn)Tp5os=Ie6uJKUDlkU%rCR%Ja6ryYfX?7u2=FCo=?;v^POM1
    zKE7a$FJ-HC1XMXi%{uX+ShcZ*yz+DD|9QbIfhRkc2YOWeH
    z!KPDdS3Ue^#^`ioB|w@x*{0dE_iAXp{4;X#la+iGYmXLHef-^?dKxXs{5AdplzWOj
    z`)7vWfR87!>BQJFe~_zL_v4~b*2X6?R^!a|t>3ynX?(La`J;m1V#$IM%RnB{D!9qs
    z7Thr37l|&h(~@0y&pMZi^!Y2dD%|u>csE3rkU@&}@8d=Cv;n)tL?6wG;XLeH8_1QwLI%FY46NQ;iH96&(=Hs*9C3cCnTZ6}?oVcnzFm%#QRz*ly
    zVq3p;edeh%yJ0x3hSSGu!tC=}wUMq{y}MTb_cec}R`t@));;!lRI_dS*&N-;yIcuV
    zQvlxNTl%#%v~nPS=EU?pye^cnyj6_l7-P1;>2=Mnk?n61Hy$$&i8IVvYf5(VU&;r`U15|=yDvP_nc*iKjMt)39_;3Ltp
    z>2y3bCX;<=*P(HiIvw?#NRK&M&woHn5tM)hjydoJ9D%k441q`B71#vtfDU-%8~}$w
    M1PmhaRGbNI2pk1Rr9Sx9{$(E_^uW{(j%z_xyh6WE>gC
    zE1OAh&e~0qaCm7VU8;7Oh%}cpo+(L0S2U+6^Q)}6z+1QES{LMm>y7-37ZP>IeCL<0
    zk1tx|%ekr@0aZ>>vrc?0Rc&mspz=cce_nIPO4TaEIJG1ek*u|+w8ULq*FC;A{ush|
    z<8l5VU(j-Im*6&XqQid%YryK|5#|sN3Eiw!tg|@w)HN(B+LV1pO-aI)k~Np=m3emO
    zUssVC41uT?jmi8Q;utI5^c~$V$DT+;4kC)jrm*X}6d)g;ha}g>>%4`Uj&#Dn$DgxZ
    zvGH6WA5AFRC)1aEdb<}U6C>5Rn_7+~VPQ4QQrK;>#3%*GxF-K+g#CpWL*}(iHCKEnD2YmXLHef<5NdKxXs{4M?hlz)mn
    z`xl1bfR87!>BQJFf0VD;@Z+LU*2X8YR^!Z#t>3ynX?(M_g`csE3rkU@&~08d=Cv=C}{jEnuYjNAGx);fsSxh(ZYb
    zHFt;mBSEEQNKvSU%~=|%obZAz_2vtjgW+L?$`KKfI0bGnYq3peV)exh=f9%Ob&vsB
    lFox4Mz%l6SK@XS#3t$DTf*)WT9E4XnXa(-sm&Nn@{{SqvJ4^ro
    
    delta 802
    zcmYL`T}V@57{}kUo$1-ITwu^Z=ZcE#b3tMk3WGZK0mI|Qwn(P=ab%8t*ya&$#DPa-
    z9flV#UF1y`f>%)?p`f8wU_o8gg+yJ|#T&DWG>Se?|8q9%zz^Q{dH(-D&-nsB2o*2<}ePXp*?Y9u3iNg41RY7^K`S|>IiCv{F*5FC0ATDZ;4V`p{RS*)9
    z*!FK-AAjb|uNzLQ;q>vEF!7>VZJ^6m&z{Bqea)Y%Rz38yb)S6-)qL|oHc5B$E?3gj
    zR29V8b#a#9lQdBcN-RWu{PDIUWv!=_H2&8&bgHZi$67c-;?5wm?nIQmTfcVZQXb+d
    znqSpMbM5RcUE*u&J38Ye!WN)0rnq0ys;kO1C?o7P{l+(6?m_j6cGGxmfQ3qug8cRL
    zYDZ^R^eGm!o|_Q}abn(lZWgDaRv0F6Vs0k3fpV+@(@D%lhir$(wtgCMAvM4lPFPv-
    zRh($inV{BatkTqm*-KMp{tAh$m={r%*rT##8uLkP4mWuB8a~N11{gIJH4%0LhvG7H
    z?QE>%`n3H=*XJ85hB?kpXDVHG#Y)IKFiM|PUbjv2gk+a&$;x(kJr#su4&Ljgv0VNe2NDd}s5~Uuy*Zl!G4jUm}FaS(-M($1|
    z(!Jz7P1#*%rV#oAn619yk${|S8P
    z>0Bb6mB|6LYtXnrosL>gq$eCrmp&q<2)2L)j!EzZ9D}wI^n*3<8f<{~KnFZ>4uPW}
    M3VP@7IaYT41311pF#rGn
    
    diff --git a/src/test/samples/golden/inc-richtext-debug.app b/src/test/samples/golden/inc-richtext-debug.app
    index 6c69fa6e1e7d44766724f6a05902d654c363447b..04af92c05d66bad1a05781a1eb13cdca8fcde031 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng?x84UGcyqdrCSvCpfHk&L8I%2&MY;{8*9wX)L9P=Ctith
    z8A%8F1A+?%(L<8>P+-tVQHgvhA9{!$BH@Ta2tLHD)^~SS7e1VGf4}eVdw#!jX5<<9
    zh0P>5=Ika(IJ`8GD%LtpM7mQN-zthnR&3UvHGnZ9%iz5Na$iMVy#8qQ&+QyXjAtYHAQh}Qqi57N8#z+
    ze_e%VF$AJ|B&zVQh-0i+<2Q7_9DO7a$wU;3PGQ%1(NA9f29j(aFLM@JD%=4BFMrB@
    ziw$S}xoBM7Igz^5-P5%&86U37-qdn50Sl{Pmc(w8#Yf0b#x?mqA?(Y?7z(dpnz?G!
    z1e*@MO>^@fX`|E4RX^#jM5}Jk-m9VY@XyG_PF3S?s3@YneBQ|>AD
    z?4KEe174oMrUPRu{6Vf}!-tDTSu3ANTa7a}wtrF;=&`c
    z4#SI=F7hS|!K5hU>>N%-Ek8n{#N5o_I?96;m`-9Ynz8L4-}q_7h139JIALYQ
    zmrhaRGbNI0Spf)6pv+jn)Tp5os=Ie5)uCUD2GP%&)TMJa6riYh92Nt~c^Co=?;v^W9&%
    zKE7y;FK4TE1yngj%{uY1Shcamyz&d_|9QHy-zQcb8YryK|5#|t&2;HnzthG4y)zvI2+LQxEO;N&?k~Np=m3e0O
    zUssVC41uT?jmi8Q;utI5^c~$V$DT+;4kC)jrm*X}6d)ge3rV(**EtI{9qELDk3VO7
    zV&l0$E}BqwPNpyS^mZ>yCPu2VH?bIfhRkc2YOWeH
    z!KPDdS3Ue^#^`i&H9(p>*{0dE_iAXp{0nmN)0KP;YmXLHef<5tdKxXsd>(%R%00!t
    z{R=~I$j6h|bYg6oKg!i?_;JxFYvU6ct8wPW_HSLEG``uI{82%0v1CDsWgw4e4cun$
    z3T~MnibPk~S;;PZU>#DKps}i9MB$3j&*yTJ2MKRg6cy*ZPKA!b1a|5lz)I&;NFc`j0jVxp-aomULHZW5Cqjx;Y@WnwTL?HzJ
    zn!CgOk)YBtq$pI)<}3{rPI$qVdUFNM!SJv`rHF_~oB}tPwa_LsvAV*B^Iy^CI@kgk
    lFox4MzzOK1Nhy#zv
    zIt(vfy2zU>1h1k(LO~;~z=FD}3yHd_i#Jvm%_y>u)Bl_eJMe?|eV+gS&+|TK+qdnz
    z@3wH#Vy#8sJX2%3u1~F1s{G8CQQAkR_p1i)w^f;e_sn^t5q-kV%=w-K{eNWkWJIwyvvm^
    zHC1_WenXrm_#{nKLlO&9KYzUKNLlMGB~AYIXMAN{$6yObNX!{x)}4s5xA<#kF69B9
    zq6JiKJloFR(iPra*Vz>#5w--4F~z--R$WuBLK$JV=y$&PQZK5PwHwB3gDhN<ASs0~i9PIxY9a
    z?D?3XVbB2nN79cnvned!PdzIfuYe
    N5CwgUcO8LU{{V?LIN1OI
    
    diff --git a/src/test/samples/golden/inc-security-debug.app b/src/test/samples/golden/inc-security-debug.app
    index 049e62f2b6662dd3bd358419487595d7c53ba903..d2c113a826cb69e8f8d304ef8e146e568e1ba34f 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5?x84UGcyqdr6q+uD2!xc(CE6MGfS7{jWya2%`3$N6;
    zjHCno0l@`>=pjf%3Je-4Dv>YcLl4nIBpgu)!H1aT`tHu^!iRJ2@Av(E&+m86Z2fHg
    zON&9U&sYtTaCmV%RjhUyh%|>ZwpkR9ENgaA=9gJ>p1XF@u_nlI$7}g1&nN1T`OdFQ
    zW^BP6Tgq1L2&l4)ntA+Vv1(xpdFAI)|MQwNTC7&+#>qvofMl&bsl{(+G8vbrjX#1g
    z)>z5!=L%Zx>=N9Dk9YV^VfCBcJj`t3A)%YKinS)&p1PVvM2oUduPKTt@bG7B
    zS8P1v&qd?P_KDQR9&h*jM0~g^ds9o%1T4(DSrWSq79Sx$>DT1@jIb{sW5~RQsm7{N
    z18h3AcGbmyru9xYR{W$n6K$F`d#}3I&A%WQJ6XwBvG!;Y)x+QKsi)AA%-`V8Pr0Yq
    zvwyA&4tRJ1n@)@^^M|>bbssJoWo>*sZPw3R-};@&Oz7WCP5y`=I9Q^f#MGZhvnRN^Eywx&`!9-^eYOGIV}G2~rS&
    zzvj+RUpSz&3@Qp$vpG{kg&kh7C2y{vIS?9Bs1y+qiF$C2nF}pK6RRt1IQtcCu7ORE
    l2BSD_9UOzc4)lO&Fb|f&3ituG!9jSHf>z+1c~N+8{~y<4I;;Qy
    
    delta 802
    zcmYL`T}V@57{}kUo$1*SU0~2a=ZcE#(0!SZfhD_tcoK>r?qEb-+T1CJK|EDssv*&C6%MOYAajwFZt$IdMgMXy~Ndtc;Mb
    z#I}Fy`s7n*cEfO54X2magsJCMYCT=EdUmb+?`ytHmFl6Nt$XY(&S(7$cd6J6m8`Qi8%wzx)V|IZvEPsOL>5&
    zXg*aN&vdXibd|5E>+Fh=Fk6PknBrbhtLBv}P=?tp`i*b7*o*2V?S}DMKMNKmIr;0!
    zd}mj8M*;GLtzcN
    z4mMGAecb+|>x=beqa5d>bLB3(VkP7)7^jcRui9pLLeliKP|LlhA;HZhs3O$a7SMOl
    zo5EhsPFIEVwRiAq@fy3
    ML_pu-UB|V`f9!=gkpKVy
    
    diff --git a/src/test/samples/golden/inc-security-release.app b/src/test/samples/golden/inc-security-release.app
    index 86ce00f0f57f3e3f9f2712e4fb7adb74e818b134..4e510cfefde68ec641636c9f2a5263306fd0cb97 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5?x84UGqVr{r6q+uD2!xc(CE6QGfU0##u{@ob=E_}iC1D=
    zM$&=)fZ&2b^bjOI6c{v8R3cx>haRGbNI0Spf)6pv+jngu{yy>0-6hM5MW-@y(({bXjwXGQY~2^1L;Rt~EhUxL(Uoc|K9Q%y)k2
    z`uKu1zLc%n5m4n6HS5I3V%5eL^2*Pr|K~M#tXQouj8ltZ0m)ihN=w|;b=~7@<&PkY
    zHyr2pa|O*~y9Br46Yc)fSOZosk1&ULNa$iMVvWVIr>P
    z6&ub5a?ymceKLKyyQgb@GBI44y{V;G5*Aj&EQQ?$ON>x}jBE0LM%bT^F=SrNRCDF1
    z2{s*Co9f{|Ge)NyD*@8n$yUvty;no)bbw4f|WvzT7V>QlP-}07e
    z4#SI=F7hS|!K0!=(Y%)XKGZ}^{KTgwckRBCJK|ED)P!R&By1yOY9PDu?CMyd2v~LVCbZqtc;L|
    z#I}Cx`s7n*ZryNN4X2OSgsJCMY8_p*dUq`U?`!^SmFlIRt-I_qsOFmYvT0h$yIcuV
    zQ;`?v*Ts2)Ptrs+D6tUr@rT~uvq>%N6wi`S5@p6=n+R5}sJ1e9cOFfkODNtAl+^`7_0ao7l{2ZO*=r{(T=
    zJk?81)1=*XVg{iv|Is3pt
    N5Cy%9w;dNM{{g0^H}wDj
    
    diff --git a/src/test/samples/golden/inc-shell-debug.app b/src/test/samples/golden/inc-shell-debug.app
    index 9b24dc391617834012ce48b4e418296f42904665..1c7b74e5cec0973fd4d0ea8c2ccc7ccdd2be7bdc 100644
    GIT binary patch
    delta 916
    zcmYk3ZAep57{{MG@AS?WwxIB(&6bHMD6J?AR9Navq0!yOUb1po-dJO9=A8AR;lfFb
    z%SbxV3kWV4gdc)rA1VwQDQb~l$`5^rK19M1g%AvKmh0KQ_Hy9IIp=x#pXYhbRMVTL
    zr&(~G1n&sINgT4DIO={f(1<^wo*y8Sbo
    z9i7v}7IInJ3}zWwQ8V^l9o3S#JpJkPKfh>>sH0VqaB`k40A92w#rVx^HfwcS=zR=~
    zHCNL+aze+g9YD3A!w%1J+`Sqb4G{xd%3L6wY`w~`8(mAntX3$Ja*E@oBrlqxHlAj-
    z|5g>6#wO4zh9f-vf_7{x*76mrUx+;501N;Si%j6D`>Yq7^c9BW>UbtgMAIP`3OMOw
    zvcopFdSz=|*gTRx*WVs`t}S^%G$$;gE;m;x)}FLtb&F5})KhNDjUb~kzwOXBHEym>+X
    zie33BDPXUYCh*|Gwt0F_&RO-~pfSxt$1)n}&DD)x+3dKqQg!*m3}YgRf^t=V9>6kk
    zoxCo%ro77noF^wZ9rKPjxJpJKA?|>368#GDkUPjQWQOb1P2jI$d*Hs*8|(-S_FIRn
    zA;0Zv|A0>bJJG8Ql^;+igXon2HbjaZ6ncHWz-4GAepR{QHi~W_lB;j{hE)ij84
    zN5da+bD%He6FLS30cy#NsBKuIQ0_jA|sV9ZK#f+1vOIu5dz~&KrK5V!>
    y5G0n;7e#wyOINTxkW!Snl_gu4lUwbkR{JS(vS7a+ABFE)xpc|na9OwhyZ;7l4^6-T
    
    delta 868
    zcmYk3T}V@57{{Npo$1-Iw!qNQoGU8Qz6=rr6_$Clg5mLuZIVp$vzi@eD~kS?NPgoZ};0VdQ{1sZi#7jLXC(kS{oJ?Cs#FZ|$rpXdMo^Stl7T6?4R
    zw!^>(nKA>%I44F_Rh?M2%6$d^h3CdU7NrwU6*rsuCeU-F-sn9dr1=HqzNQnd(E@;g
    zKsSG>>i82|YDIGzHK&_ZxQSJ(TtSwMt{n^i`iduMm0jeMvD7?;)l}VHI!U&(c6&tE
    z6s7su6@C_Q2?{TJ1?nSi_E5`#yw;UV8vo-SI-1w{`|BBikj+btTM>Eh#?P&}5_j+_
    zibqyPldbeMS!5mM?HwTq(0SBoQ{2jF<>kbAR0il(@|CSS+l|#W<+65Lk@|9ywDjfJ
    za(hQ-@G(BnN^*t+u;IL^IbPiAG-KqHm-6&F`D~bd35^l0f
    zsB5KTIoF5H->RCaC>UlK51A>nn-fNWH^?Y?RCv)e%>u;939gKJN&K9XiI4s$rIZUi4^X3|-5A)Cm
    zojKE~aBNV5J=iYBMl-Ql91KqmTWZd{$Cxa#ff&#+iF`&5qP808Lmnb8kTv8jq9QEj
    z>_aM%Akve$Y3V9re8gJPKsrk9WhzSprfuuk(N-hh;A^Jzn&+%vu&y$^Y<k0
    zX2<6Z@x@%#4ue$|Rx(U{(2GVgpI3P{{XZ|+VtTPkF-|V91;9&PNlCt$&1M~LJAHt{
    zcvCgKqZYK^+68nQIqLGA#IN7rq!D6fOPF5L!Pe`{d+J&eWsO3SQd2Bjle}aVojlF#
    z{Oc+*gF|3cipF^Q72`Noy!jh;zZiSS0hj?G9-G2l_jy0K=^K>f`goyQi0Oz24cznz
    z*=3tr{AyGdwvMHH`nJlCFM@H#%ec2C^En$~TxqDT#PWl;h@e|c_1;2e#RCLpKd+KRyiKnme&kyQT
    z?Abq61P9zSfg2Bw&C~m8&6*D{8q4f-B4bd_T-*Ge%}y#SRhK`?FjkT%DANt*0jwa`
    z$=iZ!+Pf^k1#*frG4F|st6&5Y;SOo1F|Q$yxWf!XX1Na26uv5s2i{Bl;r7t*pku@l
    z2|BM14h010B9%IG#YgnXAeCAGC!(l_h5kSwbQzjRP*-NYji#H3k{TGj;Sj>-hJ+vl
    zG4R*i78-~Ig!W-UfLb!EYpk-M7uteXEocpd!UB|IgvG>h0Oj
    zHjoT5hNrC|M^N8@^dU3I9I}KgBj1rN^{lYJm~j(*Nz1+>aQcKm00(Xh
    kg^9i7RndMm(iv_Gr8K2I+R~p`lUr>rk7L^ifJZ9p5A^9uL;wH)
    
    delta 842
    zcmYL`T}V@57{{Npo$1+-w!qNAoGU8Qz6=th6h?XM1BS;lwn;K=>Jc6Lu;~$R#DPa-
    z9flV#UgS*{f>%*7LPI0_01N7>0*kt;i#JvmX&>}?dd}If7k==*&-4HPdEWP(Iq~)Q
    zO_zxga#bddaZiqFnl`y=SNco
    zKsSGD+QcJAW=(gRb*GnAxydJXrG~7SJ=>T5_f=oou6W2NbES0}tC_~#bc$?aozA$S
    zDa-QnYy3Rm5>#IC3p7By?19#OMXjfhH1XFve7L9!_BSyAQHP(Jw<3z(jbB@H$+z$-
    zs!vhJ(rxrLS!P|;9i34K(M8ngQ`{(Ml~wryDns-N`NlS$>&9xkI-}oKq=AAYD}6b#
    z+R@n+euxjWmY(AP95`<#J&(J*njL{C7%$Vmj^+LmoKB!lGHls9zVTC!3n(I`xWgyQ
    zKZ}AIp7X2q`coR(Abo16jIR*rJ>$SD0=-?d%;J0kUBDB1cj!LBFp88Iit-S>gq!>d
    z>e}dd!S!MDkEZ2nN=6vQN9IbMR@n^j1{os{N-tVwS%4Hd##J#dNRV?gaUyfomIdtF
    z$YpLPVW&PxBvWy5Sd)h`!$7TVjLk18-osuH4
    zSh5G2Ny6$pI*p+}5Tn&QGAc?#t%Fhm5?H`|Bixtlm19y%N|Im!6Pw7QwZR?0JoG_V
    z!E`E?9Fkxcw#%_`nmBAOh9?JY4QJnDOdi=lOz4B(KUMaq}}u~(cVofUU-wUvHL+NGv=1RGE}XyHRbRYejqoCSosPB1N7m!F
    zIk6u9Qv8IqBQztjt-o5W^VdVQ4LfU#gRBwW3N8QGU5(P~ZT*S0fBjan(p^312(Pa5
    z9Z0mMSFQTntyW7)O|cIlur_>%z2_#3zq5@%jU1cFJ`4YRNM8?a#JuQ1
    zy2wXyr<2oFkFSbWyef;VxwYGsc?E_5)>^EH>^IM*>1Igix(=5vC}VGNNC(W?LOk`H9k)^t^3e>R;?@7&BIE4ead_1SxMVrBLV_-fA_
    zVz=NwYb{n&?0YZz60{_;ui=zO?p1j5pE&`KrPvA>X&75%AGkT2Sy(irO|j)>$f>z`
    z@9$RYg7Y6)b
    zEIiI}^r|q?cM)zC#zS*w6=Pg46(k~=nw%>XRg9)-e;~I1C(vmE`uzyW04KVH6;;*e
    z(J<8l;n=$%dJo{ZGUc}|5j
    z026>@<8n~x;?j-oo)@`r5Xri!Wkkg^aHnfS>t+GdWbeY#xM~{NkW0%1CN4NVb|MMS
    z>!plLloAQ<9`s_svvb-)muDuhwm`Bw83|m*>P|*NH+Hinh2S0jWR4gQ+3B57V=Sv^
    z8deSZRqv^#c9bHM+BrQ4|0FJq+7}>zJzZ3FB@=L2d%+!GI$qoG+5`g=F-PWMBF7#j
    z1Z$>3a=K=V*j`1mMI@|Z#lUtondn8^IXX{rWx=69zu^nRi}Mzf%w=+u+8C^n>|qkh
    zS*$+nF3-Js!wK3;(e3b)`yTaHj0u6&dBJojGC26iUXN3%=YKewh+Os!_~};UiRgi-
    R`>fz)p_gXT(!hVt{{Whn+(7^U
    
    delta 875
    zcmYk3TSyd97{|}-%$TGMmu+}{Oc?3ghh7ZPjrcC7OKg{17rs8Vr;gs
    zt|>}!3!B^mg2gMG?BR(QyO_gmRe7y5mo)XqHF_+s^9?pp2nDSkLhnT6z1g2Tb4mB$
    zDvDcHCKB!BHC|=x6`fr{YTAWuF2!S0u
    zS>{C$DS>&9Ql~wouJw`Ux=Q~QJb9=eIGHE+^OiZ7k0*<80`DHp$LmIcU|mt{CzoN9
    zSp!`=nasI9Z2wl(balZPMY-{Oq0J=G2)zL&@T0;J;~axf3^%al)GO>`9aI=gY=v!VPRB|BA%h}a7aV}>@?Ln-a(Gph)^vs1nBCR
    z*b@px`%p8Em~1CzA@n<-xBAD%1u@<>EJjcS3XrddyQBS5NNkCTB3gvRCcJE_cX%NW
    ze5gBTIunY^)84fs#u`{hQctYrR=rO5u1}CB9u&g=zaO!w8L+>^1gq
    RDa62x+G_T72qXWU{{rbqNKgO(
    
    diff --git a/src/test/samples/golden/inc-sound-release.app b/src/test/samples/golden/inc-sound-release.app
    index dc786cf0a16588dccbc18666d1b34c60e0c586bf..e3952b7735b8a18108f42fcf86ec229d9d5dfb48 100644
    GIT binary patch
    delta 1317
    zcmZuvU1%It6uz^WB(s}f)}NAW*tD@NCBYU~QLB(j(zqd!U7XC2>{@N&G<%!fWp-xW
    zxwHCU$VxZJGAtG^_ydJT2uL3U%RZ=7Xs{(IVqdfmK8O!u;f5BZ&P$tIVb`u~#@tC&lBzm>)ev
    zm&7RU;yK;*_?qm*tE$YJ+k0KvS78WXou!J*eg_`Ls-5}+y5FpPDj^g@2-PZ=z&i7K
    z9;MiI2yy%P+LNfR+i4I;u`lS3IC47gv330Lne{j3=Fi@^SSQEaO>I_IK_bk>*1)<)
    z>t&Qjd`;P3fX;Tn7&05C)%Ih{0gy>MGgXEC)#RPtyPZc)a&_7naQDh%RrV|RYR?^I
    zci^6LmZ~ZCgCBhvT9Vm!@Rdj2Q~2^%c)$}WwhBfX#+KQ~Ud~n)77c0BY^53IHMbu8
    z(`sGhr!m}77KAum?TiEqZ3x`~yhFe5e7pT45urEfi_(DbBb}8F3Ygl`q4vw*575t~
    zCk26ClO_i)!K=b}X#SjLO_-&ELKNFjbA_Ug(F`37#t!}rI!(Y}J3=aeN0+ds>*fL)
    zp++zg`v62A0JtktepkWPYej4z1BhMmq?xgGJW;|J4bf}ClZQt^3uMu}moToI7DoNR
    zh~Rh@aGr)kQQ;Il5jr{b8?<>Ba39bFEJL*|z!8W)0hj|^1>6AK0^A1t33v!N09yTk
    zNkFpkWk~B5(v6%TF7O_^kb)P?
    zQbr|OiG=n(%wo{DbH-w~Zziy@NV2;b30%hdZbrhi_Oca?;9dT7j#!Vl>0MA`JgXT7
    z)-C#N->b_VC`G1>^JWO{Brc4(4yLWN^Pb+~LHjAX6M5>um=5<34L!Es!%Ox44@VRISNe{?^SbAi*|c&f
    KiV*xA{QL{@aM`&4
    
    delta 864
    zcmYL`T}V@57{}kUbEaoQ+5#h|bFL`SJ`FMrGzNLBgyHdwZIVp$<18Kf(9I*>hy#zv
    zIt(vfy2zWX243~W2n7x811zYkx{#==x_D!C(Tt+c({s*-z3_wgeV+gSf1dX}>!;JF
    z?%53#pRG2qlw)>MRn^%IiySl{q;Txar?Ry4OmQ)(?>xDPTWQZRKFzHt4>cWsgOniT
    z=gIbORh@ZiO>JsUT64M>g`ItFk?Zjq?cBZczpuEH7TJkE(-o$9sHR#DkU6}IvDqTJ
    zrYy~6Hn|Lf#Vefb;fWW!m?NEaMXj@tH1pRre!Qsj4YyJVg{&Sz??e>6`CmJ8N%!F@
    zid$Bul3nBtUS;exJ-s31C(EGGrnpTKsxq
    zqo=no@B|)cGr7njWQBQC$qejrN_ql?kp42mTTmV@f$4Z+!{f%<>HJSE&MOOqzz&`)
    z_cDZ(z@kTK)Sgn;`p64irT+?^tm_9(=E;MiWdY{n$r7BvyGQf!x=|olSCsq771-p~
    zK-Wd43$Bmbe^fPFUot^aZoF7(Gf6Z;Z-FWNxOB|8z#tUI&1^OG8v9rW6~Pi)V_bs1
    z8DC@fQ6{{~p0BB$p@~UBOmvQlQ51y&MNMehGPj4?T2
    za}YQL1c1TpZF7Ga)tt5LJwxGO#bu-ZP3*UI+^VmnaH6Uf->Is@v^`TXrcDN`&DSkN
    JDhq>Z=YLsoMaTdE
    
    diff --git a/src/test/samples/golden/inc-sql-debug.app b/src/test/samples/golden/inc-sql-debug.app
    index 8e818e44abb54a82b552056d383c29731830e471..2ae361c52f92bdeadc96d011e97e71686b25851d 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szo5+e1;xW@aJ^N;3+3P#DR?pwV^1%`7#`8*9wX)L9P=7hZ{R
    z8A%8F1A+?%(L<2zb8r)C@&&E;_6TkxC%yhNSOXRhk1)GbkIXZ>
    z|GJ9IU^
    z0Gm#&Lv{0?8NJiZ)c|R(WV>d|-K(zk@Gr>4Pgn6ZtUX#(_3`)n>S?qj^LhLQDE}1u
    z_Ahk7As;uN^SEX7u#nbj9Jp8twA*TELZ
    lfH9o50Zu?)4|>21SO6n)tL(qIud&+MeG>$4k9b=X3PCJHm3YVyi6&Cln)OY9Q$SwqLAytt-4Fm%#QRz*le
    zV%xuUedehvw`n-7hSSe$!t8UW+C&Rh@2<7~eJzl6s$Tlpy2n0`YOZ}ho1?Y7+nq2q
    zHFP1F;|FLcOoj@(yyJllzVuJ
    z7EraRY!`b&*ZKO!p57RVuvKV`DejcD>V|R|$_TqbzwzxC22j1IT{B)AWZ|+TFMn;>
    z=;`f?KEZ-Evx@>DF3g+DF5y(v@);5%=4M8=P#&qmbP{vZ3ERQx(oZ8UtOgmw2`ekU
    zj1eum7}8peRhrsv_QF(|zd~XU&5Ni??0&_vfcYf0j2pbW4WDEhgN&Mrx(K_9L$Lr|
    z7n?4-K5qZf^_8Zo495lNVzt|@SP6Lxrs(79E4BrmkTg9dG;ptJx8UItR1q3&%ji4k
    zbzv`Or|ZJGhFkcxcn#?r=pXM$r4zx4ppp!YCdT42iBhk<+4CMb4jUoOU=*0@wA>$$
    zrv}LxnzXx5&Li{(Fk3^J$)G&mIVLAb5(VU&;ephU5|=yDvP_nd=%cIlHcuFN@R7c<
    z>2y3bE|Yy|*P(Hix*YYKNRK(%&VN8m5tM)hjydoJ9D%kK41-7D71#prfDU-%8~}$w
    M6b!E1b~tPQ0TD1bBme*a
    
    diff --git a/src/test/samples/golden/inc-sql-release.app b/src/test/samples/golden/inc-sql-release.app
    index 1c91ea6b30b552b927f69141ba28a84e25b2e51a..fa964a78aea6171a04c1c008f4aaa02601a60f3b 100644
    GIT binary patch
    delta 824
    zcmYL`Ur19?9LIlm-szng+e1;xW@aJ^N;3+3P#DR?pwV?hXO^1fjWya2%`3$Nt3
    zjHCno0l@`>=pjgaC@^TGs6@V$4?)pGBpgu)!H1aT`tHu^!iRJ2@Av(E&+m86MBPN)
    z3#&nJ%-Rf+aCmV%WmY>4M4D3?+boJlmNkbc^UJI$&r`GLTodHD^OgLB=M%NdeCJmt
    zGqzxfEoG~A1XMXh%`*PMtXkPZUfH?S|Gegkn$>dMIJqbmkgT;OwfOB!Cgb+D@`n({
    z8Y=j`TtV}lU4q;2@pj)StbU7!hnZbGBy_PBvBqTIQ&+KwXjS&phwBG5)ik~!BqE)kH?^V}&_!s12CoA|W)^07LdilFO^)y4j7
    zw%~^GzDRV5otA9Ed)6+M3JMELM~u_R8`xv%s35R8sl_&hSH*bLbES8vIW*Ys9(IR=
    zp6mSs0fpLFnaN)I5k6^9W+d_eT|KDu1_Gh0)WCwKV*6c~ZUH^jH*(9Z44ofPf)qsH
    zuemGK7Y-=RgNi~`Y|d0)?tm9;sV7&^6bKC|RDy_z#0hYXSqiN}BdaZ}Kl>GJu7ORE
    l2BSD_9UOzc7IcFdFb|f&3ituG!9jSHfEM7IeO~x^{~xe6I-&po
    
    delta 802
    zcmYL`T}V@57{}kUo$1-ITwu^Z=ZcE#Qz5Yng+U$rfZ=gtTO`x`IHF@8wt2)Gao`bM
    zhvCIb7kQI~;8j#eC}?OOu!6d(3+dykF5XyOq*3&F`k%952Y&Fr&-4HPdEVztH%vF&
    zt+sH|VvR-M+>@iau1~I4s{Iy1G*OuNRFPAjYFWMzbe
    zCARfj*C(Dhvw6d5HJn~v6DFTks&#bL>e;pYzpwc+m8yq+w(hadpqg#o&!*^3-sOs$
    znu?q_pBLu|K1mbRfW(5-%O7n!RML8iNfUp)!zW6*&_D}ENYoi%*6oOrck|cwT*^H>
    zMf0iJSf-u5rYn4PZAWL6gxL}_#uRsoT6JBy3}u+zpx^lB3q7b_)UFw?^|N46l9RtS
    ztao&FMIK{8`OKU^h!gW>GxInVwA=`Z5_2;H8z>KyVLFMq=&61!ir%wj%?E#L<4Zo?;;Mn9vbqAJX;;!s$H
    zuAPk+T_3mp==x$^*$Bt^=v=wWu2>0q3&!Z9@+-Dko{$thCDd@QXh?8#ajFQlwgvPZ
    z^t!N@v(pvfT+J=~TD*pI^>n9Olc~6W*sml4gYlu5Od`}{uXn#kj>ASsJs1S0Iwg0<
    zV#!`|h9>N;lQRhY0nAq4$f#dVw++b&l0X6ZX1Fier^Mvelq{14B(~5cdy_keJordg
    z(R4bNOv_{++Er+rrA|jRC(>h%rt=>VQvjR50>>2i0**l22>QW8@Dgl*cR&X`at?sQ
    NAOd<9Z#&-X_y^5FIEMfL
    
    diff --git a/src/test/samples/golden/inc-system-debug.app b/src/test/samples/golden/inc-system-debug.app
    index 1737fbad2e741a664ad7f3c3b00bd5daf674baf0..4d25f006ddabd037609e30aa5d7cf49e108cb8bd 100644
    GIT binary patch
    delta 1029
    zcmZXRe@IhN6vywIZ~A7E>xZagGcyq-r4`i*6h<~taP-{6ZCPs5y_#cgwqF%ATsVpG
    z7)cjOL2$t!`Xfjd6c{v8R3iVV{G&f2q7<$ugy0`B%XRj)Ozgpjckj91^S$SsJ9_%L
    z=9yALY2zvhLzT=9CkjIy5`+XDGqhF^3Cs&xnk83oV-8n4t6Qeni0&o(h~y$r3Lx$hr*B&&*YT1CjOlZ`e0$GLNt!e(s_s#+G9fGb}E%JnA*rg
    z5Qgh3$-PWM)155@Zr)=?+bQ_l|)e}$>OJ2dw
    zjxtY?wSOWC4w^_57G`LhB@Z$=D>fK3q_vUZq+GmnW&L+5H6niTnp{6c>2Nf!RMww^
    z&?0aHzsb8Uy-y=_8J}TP)H`fs$|xTDm_yPDIG5lt<}gL!DW+L94zCLBq3#}wr^)5+
    zH+T&`C)d|M;NVd^E|;mxK7vmYD3>C{0ixQ?TO1BoFRI5*S*iLin63ljk=1|GzUk?)p$y#snCKK*rM)CLZidw;gJF&G#s1&u3~wm!->^H8byC^-+MQ#10Oi&_xpao{LVRdT~|)t
    zwi`GxS7{J9$JCgrs#B}w@_>O5#Uo69EX$^zDsDdWO=Rb2qtSm%%zBoT`Kbxl8c$+Pz
    zYs#{o`8Cfx!4egZ>=#*py7?n*hl*NfA!+iDd*pag7aVNl2#Hwz%(xX%^ltpznk#h&
    zS5dsOGM;W{ujw*xuj=fIkPushMw{YRK`XDO&O;eu*XUQi;cPFe9m-|xwmudpNV3wG
    zy4B9E?(kzg&{}#*7FsKB;A?Rk(%suL+?+_pd?UV8+++Eq2Gbt>K`5RNyBYJQk=w5K)xRCP4uUtQgc$0$N~}@>7v=?2p|tW
    z(p@l}jwXgBvJdSFG+LcsKs^WJ!0};0~p|#2A@F?jDm?l)s>sJ{*q<_gjrWpX8Jl*~o
    zkB`i0f(wbPZ3eR%SXndrK1V7db7>{5Gk?$Jwm^u2jUJ`g3
    z+up6pJB3YPRQ3mW`W55YRTaSSL-C?0~;t~vVO#{H3Qq=X(f@;
    z%ZF8HW0E^01N{+GDqUZ)f!r2um$iwxDq4Yl!n5G%GP;ca9@#G$>D!cg5=-LgE8N*2
    z`4lPpr;6aPk%n<$!nS$(Aepmf#X)0QI~|Q_lsnfpf5qct$``H7@G*>@gfk1&gJ}TE
    z$PMx)^LqMy7T_{D!)cj!q?0RVMB?R+q)+0xii~qd8HP-A&DsfkRcsG>`^@epr)$vQ
    zF?b!qz~GQwgbq@o))jw1pBPe-4j>?k+9jIpc4t4-5r?`!cNa}J5#`9@yJ-;J7luR!
    zI56
    zRb&H+A;Wmv8gdNv)kqIAh0G$0$P)4$*+O#Bs}N~M+M>_0`m&j`QB7Wu8Qu@VEw|8*
    zH8}*cPjnsJvp1RtJO`AR==n!>Xpc4`<)JpJ35|!N|pZ&n@oJ?J57c!g#f87{04X5dQAWT
    
    delta 839
    zcmYL`Ur19?9LLYy?(}X*E--STb45k=SE*qS3WK~_!SK4VO_FK;xuRo#Y&!8p9C$_6
    zW%zLDAz!i(e2R(@3M$zju%MpmL86}W;l%18jiSG|@4Xv#-~;FUe&6qx-#I7Uu;spA
    zW#Xhd|8@ki1J3YKAOur~sYU!%kwRib{U-hQT6&L+%uC&ggnrS@DX6PQ?;fNWU
    zvaGnUAubRsNfi~p!~)dApKLo;)Vc~uQ-3|9XNtPuU=v43#O`P2-H4)h``7MVsRy`<
    z>Q&Tr~4$s=wsASRD{@dZ1Stn
    zwX?~B>*LNJP0Q7mjB%Wo&X+o@DKjDOzyy6#dd)J&6OyFog=+2%4GK;!MpHtKWf6TF
    zy(t{ztaL@VSbYayi`$Uy-ky<`L^9?Z^`+weq1bR#CSmHb);T{Q$7Ug<4h#W9os@f`
    z(L^6a2%`3$Mhu
    zjHCno0l@`>@F7TiC@^TGs6@V$NDt9NBpgu)!H1aT`tHu^!iRJ2@Av(E&+m86THRXR
    zORGU}%-Rf+aCC7zWmY>4M4D3?+bW7jmNkbc^Q){W&r`GLTo>fH^R@hp=M%NdeD_x-
    zGqzxfEoG~A1yngi%`*PctXkPZUfG4z|Gegkn$>dMIJqbmkgT;OwfNmkCgb+D^2ZRy
    z8Y=jMTtV}_J%Zct$#&lvtbU7!hnZbGB6P79vBqTIS68u!XjKmAHAQh}Qr4WRN9O6>
    ze_e%VFa)AnBr5Z7h-0i+<9Bqw6n!EQ*@-9?oy4y5lApZ%EhO1KUga#*RJa2MUjCfz
    zi4EucxoBM3IhDHH-P1Kc5g)G1-qcbw0Sk+6mc(w8#Yf0b`Zf7JBkaq^7&5PBs4PFL_%tle5f_44=o>M67&^EdeOQ|>AD
    z?Wc9YAums0(}A&N{wP5ud
    zuHcsOp-6Ovot13D2i7i?3JMEL$Ba|R8`x9nxFE1Osl_&lSH*bLbFFu%IW*Ys9(IR=
    zo}2vx0fpLFnaN)I2|j61W+d_eT|KDu1_Gh$)WCwKV*7oVZUa5lH*&|V3|$;hf)qsH
    zuemGK7Y-=RgNi~`Y|d0)?tm9;sV7&^6bKC|RDy_zL>;)nEQMB~k<}K~pZ|(B*TEJ@
    lgHfEe0Zu?)3%bD!mU}c1a
    zB)0Wi*C(Gkv+IV_YB;^TCQLoAQtRoe)w5&ye_!)us#FjCY~5v_K{ea5mrc`3-sOs!
    znu?q_zb?)be3B-r0f_~vmp|NoprrK_lP3Rq)5l7>&Y@P0kcczDtlJSKZ{gSWT*^H>
    zMf0iJc&3BBp(}h%T~~L6gxC@^#uRsoT6Imi1Z9X_r{DON^L?mZ(5@P<^|N46l9Rt4
    zU+e1b2|vMt)-!VgAx_Mj&CKJJ*K(sILd?w!ZK6C>hUp~cqG{XyiNa4KE~xq$!wD

    {5FmPp3@X}=N=497;IG6_?Uy}|t+ISv~k4PY3U>ZIHo zjVAiZX&Se?PRt?9J{V^57#q zMboKhA|;bOXjh|ghB_TJoJfy2n$LYeOdb?~1&(R(1ssC52@HZq;1$>e?|=??|n NK^XKe-gexo{0F;OIP?Gj diff --git a/src/test/samples/golden/inc-xfer-release.app b/src/test/samples/golden/inc-xfer-release.app index 3ad37b874b0d05f171b052f3c8e71783a47bcbf9..eff4093cf6dbd222ab60a92f0f8190b279714570 100644 GIT binary patch delta 824 zcmYL`Ur19?9LIlm-szo5?xCn;Gcyqdr6q+uD2!xc(CE6MGfU0##u{@o^;!=N7hZ{R z8A%8F1Agu{#Dsba0uM5Mcwa7`%3Aq&+G?D+zV$nknJ~WD>iiKwaIr+e5lep_(JHvb z-WA+5KM;v7von%i_`uqwQbA#=bksbByoo)LjtK&rlUnSPcvXx?Jy&~&n!|(ro?(v~ z^4{nl2rAUZ$}Eo3Pw+{DGBc4E80tZ#Hy8|GqXrhT9C6%(=@u|jeIvI$%Fu-YB}5?v z{+hePeQHo?9#j;nVsn=IawoiCOFg-QreJtTp%O$yB$}dx!xjh?#!gF>zP80I&2|C6NQ;iRXOFE=H;{BC3cy%TLZ_XoVcnzFm%!_RzXNe zV%xuUedeh%yJ0x3hSSSyLgsmm+DO-}o?WZ|`mK_Ys@b;vY?fB@E?3;t zROQ6Q4RMj+lQdBcNGwRb{L#)sWv!=_H1pRxexj`FjRl96joc#4< zp{KVm`~(ZyNaqDYoR~M8Uc{-WjLvbCt zE;e0qecb+|>&uN56CCHG`AU~vu@dqYOwq@cS8ek=At`!VsOMhOZo$pPsUkGkme6<5 z8^T`BPS=F<^$Ym5cn#?r=pXAyrsDo_zmf=y#7AQ?2~&@~$^9NV4jUm&U<8=zl-wVS zB?rk_ny|Z0%^~y$Fk3?tlYV)ub5u@{1PaJE!+psiB_?;IWSJ}>v7N5iTirqA!AJT^ zrZchRm`wJeU5my!>U7j`B0c74z3>4sMX&`daLj@);0UxWU>H0CufQgF2Xw$A=Kwei M!eDUuwnM4@2X%QkSO5S3 diff --git a/src/test/samples/golden/notepad-debug.app b/src/test/samples/golden/notepad-debug.app index 8ef6fc72f486a73cb9ad1d40fc5073bdbf48fc35..8c27c172d6cd474f86501aa895ec2759e87b86e4 100644 GIT binary patch delta 2590 zcmaJ>e{2+05T5sXdv|R?4k#el!ilwkL}(!({z0{+Txl!CWtVNs?_TXfsmB%CyZR&I z6*wZz*+AGJ5(rkPQKLq&MlqOJDFLElBA{YIOf;a2pih(-EXq0a_O2EG@RGTG`_0Vv zzM1!CPp$f8_1n3emGtIuFT3-^w!RVZWgH+8@NT_2tZUP$M2Sna>#ed3{k{`{OHAzw z98q7g-2gSJee>KhYsyjx+Crjd<6D|*^ z38N>`^>i|s)M^*odoZwbZoa+4NhsfZ3y>{RSkp8cpUpnqj#@>oacrek;kqxgC>1@$ z+T_YLvt`b(u0W5P2*h>O9=JKEDtZ`;Ks2$bL$$w09Lwq~zKH5icD&#PC<1`aj{Ue= zwzL^)?T;{obiD6S#QUOkNKk9PV%>7ho!9K3UFP)}ealy^UfI91D^ft6I@!^U6h2AT zgR3i6S35L=Y*W*Dgqz%0hH6i>;z~h`BTZexh->zZ0jc!L>1Ie&cP~!lQD0@OZeKuO z=d67D96nbiHpOf0Pg2n@q9oP+5Z{`?If@khYce3G*6zkd9hR-y&pSDno3PQCw%Fb_ z;FEh^zV>%Axl{gnr@Gr23s~Kr@tNy00M4O3ZXNS%RCc)lR#*>s^Vnxrjdvn5t*CdB z@*?`Vwby$$W7bh`Mc#fqRV)uyHw4?tTUys^5iJ_l*R9_WGQqI?nMD)NBF_MtUjfk3 zWOS<;424=Ag}GKZb9~XWNO}!Tx*FS`(9E_+Hke@uBakMqZfT5$%<@*#gelh1%sG=w zkPB&HwUba5YH2fJ0wOMS%tTvb`Lc3Zu{AAgPUW{K^Ag%sv;njj?sggN9*m!cwhHYq zT0hz;w9{w;?K;{RZ8`i)0Zze8MfGo=*v;o@L=afo^LF;I_@ z_Z5a^DD&Vsr3~C#m{+_y2+RlO0)k#=h+ROIQtb|;6>rFQ-HQqs7~&n`9f^1^+Yv)D ziW^07@3I~E8UQ~!)l5R(V@kOUJ;HV{(1sKC0aF&xC8a2FRPJ7Zaw-YiY=}N7R(#C# zH5d)J-wDdC(l8vvN#@2<#HqA5W{59{g1F8&ad`1ToMmb~J-=j16`UqNogP3ogP{0| z`515lO0;|MUwqB<>39UVIxlrf?cY$MKX5nI!?;>VnJ(^gf#$(wOekCid${s2b;++R1pnT{ zex(}xbkj;77@6YB~c@C$_xvZRvF}NKDjpc}(uVh{# zqAR-yIyP)PUkP3@j$G~H&X^-?d`eg@iIKuYo)a;NE06rIu6$0HwiK^|yu`9E8g5;Y;3F)BZ z(xK!EsSW3)Qf@YZVI2`ne9SCsiH0Np1s4Vz z%#bx_Qd6OBMk4FNYbkq-lbJ#W34CdoHtV$}6^|I+P1^A{KQPZ&V3m6AQfSl$tiPuh X<{p>|P%p0x`fKX6Thjn&*y#5UYwx(g delta 2402 zcmaJ?Yiv|i5T0{(?{-_N-9kZ2`dAbQAW(=j6roUrEwoU$++|yY^a)E!(+8ApMI+fh zwt>1dO}L^0CKe@Dei+0C!GHn5SP+U2h^dGVBF1Z~9%+%6;5u{eZVP|hWO~n?`Q|&{ z%sF%WXYrY$_menNyOOz`rL=4bg+eVS;{xkBK+t7x{ynzMcP5x+ZM~%$tMyEad#c*z zIvV_#aOyt822h|H1NTCq=1(21-IQse%q(lr-f}iBFi}5lamF7ViU;!=;sQ?n56k1x z?dWZtGuGIqKWa@(tdf}6HrM`c*M7jC8gvERs^QVItdo~d2xFZlQ}e%Bb!lN-et9MX zsBpLq%OFKKxBs6(UA{wDN-!@F+}g0hxU3(uCZ(<^s(=FH2x3UZK@%G|=_^5Gf$^q( z(>f=&485y@yQo^t@R*P`?~Umv*Ax{Op2h}sH+0wm9H_UoVLz_=f^GFs0kSjYz348t zp*qz_)a#V-{{Fi}=Lu+rfh%lTUrz-D3p?DwbZVu<<{RfFN*;x3oRBZBfNC5Lr|dy} zs?mvWm^+g4RGFw5xORC7=APBw}JbiP}f9TJ!5%#M^s|8&jN4-ZL5AN zYLl|Z3Q((O*pt~MJ>QXIsaMf28kmv+dvVbeSv0e#y$sY-6y3(K5@i6SIr$gO1t2tU#_HL*Er- znXf&Hv|@*RciT|~1CQ8Ayz%JQS-T&}2sfN?udsG}odZq`s?p?ml_{AL`Z{Z8pglsq z-AtKFcal(IuiSke%E=;MqepZ|v0@){uSYcCe(z9ju7+?Boy>-%h<77VzegM*3gY^} z#Nj;$(Z$pfdLCxVeCVMWpc4fc#-LPu$Q%qfIkL1T@vk_-+*9!gaCKDbl-fU{s;QF4 zFhB`({_`~%J06ZXu>O4UnQVv+M7LZ-fjGmWNYPn*SV4TwZ1D<+b27?7oR7eI*sg=L z>jidtm<5G>LBbpsU=847;!8}!?5`MlUdYwf5%Oz#d2yHx%EU$HP{6OnlKT>Fq<|{d z-!K*fnHPYej$Y=lfw}yaIiet)QoF$$f+y@C*8CR*lx!y^Tl6gORnR?0-~So zT1HF*SUJbaZ^+8ItelI#aXSRvWDX9!Im}K(bY)%8uyIpw4%o#l3e}6-epA?;u&{e1 zMhX-6O+<((+5fA{%IVT(;8l=!SmEv}^D5Z5noJVy{0*Ew_iK3kjA=3CistUq_{7WU z7jZm14Gyj>0%bAXHVzzT1#cVRImL6VUv408=5Vf#$B;85HiXfeu5NnWgavKJYwx2b_q05q#aw23o{#he6?w!< zTv<-HVFZnssi-m1!6uI1C7M&G{6oXlESsd_*gWJ8aAgHeDI!`3K?aJ3W99LL6ul&Q zZr6e2Y^GaMSL+8-OS@do>2dsSoW3P(5?iUiotBn#djdd-JPd$^`Kz>5Q}Ev)3Buoh DXqJB~ diff --git a/src/test/samples/golden/notepad-release.app b/src/test/samples/golden/notepad-release.app index 55622cc0582c0c7b2442b82e18f34513890b9eb0..9dcb44664a61d4d5ad95c461cf29dfa8d95366c0 100644 GIT binary patch delta 1695 zcmZuwZA@EL7=BOb?H4FTVWXwRy1@d==3vpx7)bbPEtA6K-nC_HgB5#SsimF$K!1po zQKF=4ws_*l+>&8Jvh0V61QShWGU66kjQB(M!^LGk)Fqc}$+BdLrs(Q>?&V_`Z}Rk< z_dU=1ydUTE!J&_QU)nUJW!|nakjllWh0;vF1|aJ+Ox`cajorw$XnFZKZ7&k{EIRKJ zKIi;~|6JAq_;`8s??Penx_NquMXeHyYSCuRQ$LkvZ1j4OqibRFJnKo9W_BpTvx{0i z@LBI{Hur9!P!RmR@+U}~ZmN;br~$5t$AD_deZJ63xQ5N5oT3fd?c^ABYxfu%*4TA) zOly;hm6(#8bC%CKGa@hNS2wCk&0!Ph&5osc`8V`qThq;dVD(GsPYnPK05F}tfV=*~ zVerdWk;3ZuT9wEwr20_6FJGdMwM}hd)ti$ZH7*<(7(90EY%aNrO=>AUgA!&%Y!-L- zXl@k3po}T>3%Wx(Y=f8UX@=W1u0ff;tT!Xbf8~`*?=6QR>zV1z+Syzct|;Hev*{OW zQ$`CUsed^et8Breb_cHf2_u=gmBOp)+B|N?`GNWj)kLBx!*kq4JlD{3 zhUW>PR}60Z1-w;k4+e(<30EvWA|!=WR2&{T8IizC9ma<0pHU}|aBu)3LSe_HKqL}- z9hzv=XlZyKMIR!Rqu}T}f|TevDMcZQj&*TQESQQ&uDB#Y9lc^au%iXFP!??$=Ew56HaN|-gYFbDY?vKDw4gW$ z+{V~ep#-H&1TAqunS_@~mZ9$qDlh_jVT$2|7aVxN>zRXdsU!kIv0}f(v;t@Ja?D@tqK^p>S=XV3qV-~$7e<5QcUHP*&rwJDAVu9qISR$z)ztIN(t z)Xn;wSf8YL3EKf+W1zRJwbd#E3z;yX;t(75lN4K(#VVjC>ym&5M-PAl8DeBq=&5SN zOcPZV<06}vQq%G_s8Zh-7`z)d(+%k%v53VJBy0x_dw`6{NpQ?g)+9 zoW(}yh@5c6B0(uW|CP-}%AWx~J{(Q92cqg4i^SqIZ|`?@NQq=;LX_gefyh%TsV$HS z3W22btZKRg(O@DJcvEVRrJ~9II=ccxQiOV{L;FN2nH-LuV9^z7uLt gzgMiOe45tp-ffH50{o5-o_!s@KEYcLU{p=`7qc+d9RL6T delta 1522 zcmZuvT}&KR6h3!%XLoi%fmj-5S(dGWp{TVOn_>kEmZ3k2472XiK!8Hoh7x}2!jir; zKv`08Bz?H)L+OKPNO&@}A*M}Q1O6mvQuIL|TCKj+hcRf3)Y$mDe)kSSNu1=HnRCu} zzI)EO=R*AFSKkY{n6XggQdxNNbS9ITT*^tGa1mL$I`L!nbm~*9#xr%_uv>VwJG#r5 z)~{I~2%hn_?Imh9?0bJ@G83QVPA!W}x5%vVSnA|wIq9AJhC7mXZ8L5)jOC;w{3myT z|2(`?RVDU0{;)^W`XwiOTAy3i=LnBs>FKCp$9RqB$;PsEZp2ZV_@`$0>2+SbrND(l zSjf#6d4^0hZ7vzxn(a66vw8!(CVSM$`r+Q+nasjY?+9ZJeAcJ=Q*NR=z#0CL?=@w{ zLzLu|Y7zU2$JH?F=P9*VnMXXpFRDA3pI=uG6}^M2MK{#Z)j3o@nCv%)&D22jWdEtY z1hw&qe|PvUbOH*|Zr~&!*~vs_U*F(ys^bHG?b-7X{T+~@?vc}GVyN*{Vt@wVfW8!O z80=2S7cVl6ml*hk_u8g_1@U z?TR2^_=H2Lc1VoOENQ+gjOU_5Vwv*Hx4g zcbecO!HO)WDdiw3hxoBzK8_d())$Ep>7vi^|0WroM>{0Pz;6dj!wv(5j0d3DC5HVV zB~+*d4?|OQ$;gG#6Ja1-f{a=l3oCHacwzqN5iu`W(@H)Cot+~!wsA8$jdexcBA(bN(PP<2GU z#N~(%3=DmXQ)!~l(Ws2Nz#zrsgCTukF;vgE7H;8-;m(ED;$)U9`4Io2ygYQXgy=3d QOFVV)qvqu@BEMtOzlouo=>Px# diff --git a/src/test/samples/golden/pkgbuild-debug.app b/src/test/samples/golden/pkgbuild-debug.app new file mode 100644 index 0000000000000000000000000000000000000000..07a2ab6eb560ed4dac22ee97ec0fdede801069d6 GIT binary patch literal 27270 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 bk1hWaf9vh|U&)vTKuDZ^01HY`fn5I=f{0@g literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/pkgbuild-release.app b/src/test/samples/golden/pkgbuild-release.app new file mode 100644 index 0000000000000000000000000000000000000000..55cc013527d87579621699b5f09ee26a17331b48 GIT binary patch literal 24984 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 literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/player-debug.app b/src/test/samples/golden/player-debug.app new file mode 100644 index 0000000000000000000000000000000000000000..3db2b4e963ed9ca473e98b18bff2d82cde34d307 GIT binary patch literal 23604 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 E3y%x$&Hw-a literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/player-release.app b/src/test/samples/golden/player-release.app new file mode 100644 index 0000000000000000000000000000000000000000..21f4005b41058e6eb76a627d20164866044a653b GIT binary patch literal 19799 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 L;Twf;4nh7G6)(pd literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/recorder-debug.app b/src/test/samples/golden/recorder-debug.app new file mode 100644 index 0000000000000000000000000000000000000000..66b322cdfa5de4b000b4d82c387931dbf650b331 GIT binary patch literal 22865 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 DEH`$a literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/recorder-release.app b/src/test/samples/golden/recorder-release.app new file mode 100644 index 0000000000000000000000000000000000000000..0dfd39352d593b1b64cf3ab88e16249d16b2d029 GIT binary patch literal 20075 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 literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/resedit-debug.app b/src/test/samples/golden/resedit-debug.app index f1c90f1f7b16801a486a5b48e7f99c796ebba110..ce713ecd1ba0f626c9eeea8029075222f7d1ba06 100644 GIT binary patch delta 3615 zcmZ`*4Qv$06@D|mvClS&&m`tAHn|u>ff#HI6w;V-Hk=Dd4CA|;ZGLR9@wtv;Y-(cy zBxrqbR%;zsP`6DQB}$ycN*XDZYEdO^NmVyQfTB<}Eomy1Mk-3wrKA}lAtf~l?t8Pl z=XI){^zQck%$xUp@6FtP*7cvRUlel|I#a^^Y~K0diGt)#4v-4_PkcHnee7Z?YiD`eT zeyAW>suPc$_vL_0#l}+UC#R;SB25j}(-=5XU1puK7t|$g17JmxEo=j%1I(%~_g3MZq|n!J^%YH%Dupfz=DNLp9Xj<7~He2mp! z82YInpb`K^hMvQxojbar$@(3JP#tgDDUuU|&6uFcdeyw`tFG;~t?Bs9RTH~f_q3cl zoj$mPn7S}DiYWp**%&@uH`9lp8#GN_e?@awHo}nBGBYWb7#!0yr((&7^~I#F^!lZ4 zNY#%vq)Lca9UHZ-pl@VNnROX|TT{o9P1d_k^b1&$wBACk8|+p%>0i?UcQsj~_|S~7 zrS+7(=2{mLjmsLW;mLsB^V*HCrlwBo&zWV}hZqZ+qdD_)_h$iIMtR(PJLh}iM?Qev z=6Zh#d(Uk2FJy6Z(0`A30sY*3#(ytk=2`!ilIO6i2oLtOw++-^ZjkP_(P#(BVjYVEci1FZ80p zOyO7_sd+YcU1Hw+P@GqDJs#fO#dLj}Wq0G?%_RxwcqC;X~P{|4mB3)F6Qic%u7B3j-;>Lq}?fR+H{WAD^n^UA!tjkZjZD!?Gz z2IGo=V_QJe)}9GiOJXEXm$iokLuLx z?CzYx)~$gcf*cQuwGd3GE*4!xWI{~!W}uazuKFBkcY=-HuMSL)MZY^L1DUF9st#(s z3{Dy}4By0I7}S<^I34RDC!z4jViEVd>f>7dma87t>I1HNM61=VdQ__$T=fa9I&=4J zNW@ek17Ws77>@rBh9pA{uo1JJhIP`A53^{IZeL8D)H!LqH^LCw!r0N9D($HQ$xEmo zdMeR|Q`%q`PKKDFeoP$E%>0Cj5QrMu7ROrmAG5Zrrx-?JOelhPFQ})P)TiYcwg7Je zeOef{<#`qj>f@+JDZZUf>Wg|eWEn}O9JSQSm$cC)Iy+&gm$hy)ot;o)TD6%*^%b4$ z>rA{l&BW{OzA-hf4}UVI-XQj-D}Iy752^CsxJb`3u?fUx3NbKsHe^kseT$(b2gB*( z0N-jO|1FcJ(WoYL9&E_(b)GQg@up!&+S}eV_VRN~Hqi3(R2^p4N6mn{Axk$kQbx`T zEPBV!>SM;~KpSXK|I~G6fwGu*7)1Sy5zIn1udzUur=kC1@)64MZ!RO)f zvkaHY%h)zAm&OgROOyHeOftV0OxB37cpn7kYD?(bHu%p2Mq3+s|qf+Q|2lcG$+st-2+-mzSUkpjW(@K zNGs1A&$Zr&W`g{H3)$wjn#=bnRR{wO`y~Z$aXnHsGojXU`7%X@U8r^c2kKTXcG$J- zgrI&UL56>TL)E4p)I&v}oeTUSF1JzAZB&(E&rXRmhV~`2J=0j}#j?xW*ofD&n@~3M z%zt!?3t0C!)8bYxAETYy^;+Fx!vVf;Ch!g}Ybmm88n64Zq3@h-j&(b)UIi5R7>^#t zw|jhF!nZb1`+4*OJPd-u^+;{9hHEln4)A@lRq_c6;{w5FoCawWhH SJ#zq?8=E5|RRB~Yef|rNH5ixx delta 3597 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�WlI|}pYY}hbSdZH7wnkgk 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 I!u99>0;bAA`~Uy| literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/software-release.app b/src/test/samples/golden/software-release.app new file mode 100644 index 0000000000000000000000000000000000000000..75e0c43fd34127aac1fa922960bdfa1cb4b80b5a GIT binary patch literal 32713 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 MQbTv+WRRiwe;t|QK>z>% literal 0 HcmV?d00001 diff --git a/src/test/samples/golden/terminal-debug.app b/src/test/samples/golden/terminal-debug.app index cb6ebb041e20b622e999e199eb158a5735c2a5f0..b008e9a82422993e934187fcf874c11e01260152 100644 GIT binary patch delta 16303 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 X<$b7UX^!|erha;S@`HTZ$nF0JYMjl* delta 16024 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 Nr@u$#jfBeo{U5a1fSCXQ diff --git a/src/test/samples/golden/terminal-release.app b/src/test/samples/golden/terminal-release.app index 0a784db07d363caa60a2ad1b4d54b5221795951e..a402c61cdda616cec40cc3c3686cf3592e3a131c 100644 GIT binary patch delta 11198 zcmbVS34B!5xj*M7Hr!~HqR9Jy-#PcrB=Ps&@Avp|@7()+ z-#Op@o$s8RKc2J>zh*sFWXYzZ#g<1FJh%5m=AIRn5JL++yZ`Dtc-;#_P08Wm&#U=K z(J9X@d`pH8F8przzTp%hmW78u`uNdy&eIhVh|I}scX5e>2 zR&;nkg_85-m1=SFl(dkcu2I(|7j>rd7~jE#N5Vr3_e8_PXFhVJ8h9K^06KKtV0ics zfJ3eQbN>kGpCA0PM~ILRV*lWscv^AA2C;njNsy2n-_cX-IWf?V36>8(s6I)a)3QNB z4|bkvIPuw)S6%tc;e-7X$x_b`-is-+c(NmSdPg1HA~py%)4JCIUY7zj!o!o)9(!WU z!ZhteZF?fa|2)HzzH@Se7+P}g(xGCqE7L}Ye+}IJ>6ODj!|%$W>-H=ke#ubZ1CheR z$MLp7Xjho&zs3YP%ZKm9Lp#(C58tZCyuA*F#;~Qsd(UJs&$r({d+yv}{&#dz$`&aX zs(VudX`7OS_!+LP>gm+Y_ASXmd`_M3DVDFOWu6k*sRld~?0bN>)K@%ZQmSK~#l?5R zt58o|)v>B?QFrgANPlFYE4qGD&&E#Crb^R7B|pVHXKtPRh{>|i+>9--@^4*TxW2_!0l~Z6`-%fwG!9kxSqlF0fQop{RCWmr zDj+aKDN(N!&5{#TO7X%;r81DtyzDX=@ad;485rk2Wtb<`Uwp-CUC)2>jVl4@D0+Dq2s z{&dMSUzKdi@(38O!Jeq(go`q(WeWgVUV=@tYt-@yZ=_6;C#Q;eYEJ2&^GIvW?{?zn zPZRLUE;XyHBBNH?lO-Y-u{kLAs10Qo$SLZ9ve|^FOF;a+th}sV+Eb++lKvzSeI66p zGMi!t55P1?yHPciXUFh7P5N!I78C9j$)FR$XNyw~;dJRw8Hr4ZBWFl|>PTd2963`) zx1bUVjwz9h971{|fk6W$1LOe5L+Y9Gp`<}^y;@pP6JzcCk`OnFWL8rLE^Ftv#v&m7 zOzHO!;)W3;5|w|J7?TuZNXQD(pDdzG68%&f*E;(~GjL5a2IJagpSq;7`m!mMYlxAK zkRK^wiPJJQ`T`7)B-)W*zd&K8Xw!ba8KRP~1_O_p^8(q|`~6a{*^{+iMlqSZ3dnvjG#7ASBTElnPdN<&RWQO#7AsQzCG zFJP)9(UFkwjl5{-Kqo#@C02uz^J46WF)G3V z;K~jdgM;aTW7ChpA(4RNL`o=#ZVCa%DHO-$nEXO$0jWc4pK7QriiM?K-CZ8xB2*bF z!(^5)(`S(8`{QL!5}>*~_Hg zOR>L;`a^p;L+o~4(v9RAbp@f+Yg2-9nR;hRFt$t*8TwO4K~45KJ;RlsGQ&z2MQg~2 z$**!zw62aoecnZl;1!Ej7lRZHNV>3BNhn@r)+UcP+6;w&iXJM|zZ^|ypgN4Qp%KP` zJ0sKzc%%38xHhlfFCujWu~$p!6>~%O8X2-$0I@seK2}=YR+pFL7scwax*Y#nLv^)F zr4HBSRJ5^8*Fk~xGIf{NKc039&5;tC880VKi*z-vz94oW1PIt45ArWGf;xq+^SIT> zMjL5wkh}MI?Q8hHk?))M-i14)&J?W?D8n|v3`On?qz^Z}(Oa2SxDr&FO|6h6yriSc zTC9n*Q)icQ4;i)9)Ad)yRzZnwww@oMLLq~|z7`%po9P5j596~1%i9!>8W&D{415LV zbKx|=z(1P^Cx>hJLVL4|$P;?%UKf%N2(%%oD3|=AT39TE#{naA{MeJdHMJ_AH0fhW zv&=*U=~rc;z$`y9A<2x4w&#Pr){YWoK&EKEM81V~Eojuyn1jU5Qhgz;66%rAq#X3e ziz7KS(%y>A4<77J^_Nhi98kp#vk@{mYDL53&-+ELNQnyxIuak{$S?9-8K4yEA3*u8 zLc|g_H|jDn5D$EtHh8>)P;WI{-L_3)c*x!^M+SVD#Y5wijjfLpbt%p+4xJvDG)J(X z0rqCJdP25F=*MgCkkNYL|76AE>=a;t70+0*OSeFV&60^);=E`jc7KAT@FIi zUGKc2TpNnrV?leyg7y+9#;h@tEWoakA@aUjq70jpo6T?Tp?J zv^qMY)_;Ju{&;vwQ1(cm>c8d8Djzj z;9BFX2?pf%p_*P!e6joJc zIsk{gDQ~~W&Koz3b|qD8#w@arv_1br;duNrV-6gFSjI-}nFU~rDj$ce3WdJ_0!HiAD z{P!61-^{_Qr^SbA?(F=c(;`jkfYM%KGIA&_+ps(XuikOJIi6G^cTM>`P&Y6BValcAMhF$q+ZNE6o+X zt18jT?i*X#eWQ}^QrZtl)SNFk0g1SI&@?^|A&c7D!gUxNuEvc!SdZ7jnbNszvPLh3 zDb|P!kfL8hkgqygZ4^F5`@-5r%jrIl z+zv(F04E4UjkAtS&1H~gisUhGm-V0z-QyTq_5BMgiXTp#I%IzxE5J9LUjGqw_QKMD zV@<750xval-jvpFBJ^q?j*O7Qyjo5iwE1&%Y&uPtF$hvv3ab>P3r$^m)YPR%)Hmh@ z(;uV5X0QD?1@w>S6~;y~=M2<(!Ww85qR+6I;fY6ZFdTjmH{L~fnDQE!&a8;3**Lr* z!#)AnQOTbRv^V}u1Li>_4fvJ@(E7A1#$&#%AyZwF?~EQpfbUA{Lr~1svwUwf$-ip= z6(mhTk{pw;6MU*6#{v01L$H1?g0x9fwa`BR0-YjAb*IQUjd~I&K4c_plG@drn{fgl zI#uKW$por>3ZSRyOp&N;5$%8!mef4-JkNlFPZW1H7sj5&%MY0rCoKug$W~h9^aNvR zh|fUCSQ;|UK%SE~QH}{~HEtw;M?`&qm=@}JprNa-S9S}~E z4d@jIgjU0VPC6ho9tH%T8L6<)#vG`Qi@{R?9P1iF9T)(i5*U#7B0#9v2Bh5x5RQxu zNJsr3PIp}p)d)DOgFbv2DvtXY!SD&pz;xsRgi~4r(xC)U0fTT>7~xJBXv8`We8akL z(zeq94{96)(~2KG4|#)dNTG73W2h~>238KQ#La8RaCYMHy2b9t5EI3YKK|&CBme^0 zV_tuP*Prr-n1o>e8GuMB+tB=neoZy6|EXV7@M?qEsHmp>4dfuVYgU8k9^%^tZY9ju zDWbD~PVGOISbOZ|qFLJ|K9#Qc#MCT!hJ()o4p0f^Yl`V^#jv37T{IqQnh?o0HAm|> z$RFH@LMu7wqt9scuC*NW(Kk13Yi8RPz6%){z7038jRUF#Q@;+o9F;LEzK#%N=~_!}9$7sert0fCg&Nl&I7I#Zof?H5mvy`|axNoLvlTcrJG8Nx0Qr#r)V zND`r4;Y%PJo?xfuNTnm^*nuuRn+)viu{3W>SIV;_zDjQm%swqXc2%Kmv6L)CiiqmB zko^})250+dhier+Rx4Xeu=N`=+|X;PB%?bdnyTnHfif@IWzDl zHM{}k?T`CLZCliRqmXysaNZH2&lD!sz3$n7nVB}=6Hy-Y7XZirdJb)EpG10aKKYzV+gvGUt9sna zRCIHt6)tDw;mtnlR5=fSVsnWsRzKd{AnS3@RRz5hTYe)~;t5dAWZ8rK(;Z}QXJzM%DS1#{`?^H~!rnaeGG6;_>oe7UdGy1;o%SHAvS z>*DxpOMk9huG;$>EMFCmyr(~4P1BFB_6MyM`tg(gQfr%jjP=@T)P>4FtnY2sMdn6YXm| zdoSzh=fCayTfJ)78c++imsqFjdHQYJC*ae|m$z41Z%<|1&$kDy_ogx~bw{bpS4(#U zvS#{_Lfu#QcJ}c!2LP;*x2hdGYOIVIa9e>oz9ZABox#K2*k-Fg?I^a+pTQHB>@2Ys z#WBlw7F(C=*8@9Ctq%QoVrRAWMVcu`o!vP*|L^Eu>9egzv~+i0L(xu3R;Wv_E%;0u zP6}wFK2kHnr@|`K$9hC~l@^qsJ=k(n$2C#~!uUf+pSX7X1U2LK{8HC!-ubI~`qy`N zmCh|KpQtTaUteFY-rtvV^e1=xGR@j|wfg4$wN_0`{qp{LD`TJXK2R?|P_rJGnsUb- mV4keH9;mgxdXKvOf%>9H??caH>cWC$?UB&^LQs9BfByr!hOc-4 delta 10696 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 VqJlC9e$MPz9CIHQf-({P`~zm4RCoXY diff --git a/src/test/samples/golden/welcome-debug.app b/src/test/samples/golden/welcome-debug.app index aa22653f78032fb709259edcdb6524828f651be9..bd46ceb56a288b1c1154bd93106ba7c029fd8387 100644 GIT binary patch delta 3800 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 diff --git a/src/test/samples/golden/widshow-debug.app b/src/test/samples/golden/widshow-debug.app index 5332befc00274b67ebbe12523effb293c44bf3ce..4c8960dda5b8d3a5609651347d318c3d22d32be7 100644 GIT binary patch delta 824 zcmYL`Ur19?9LIlm-sznQ?x84UGcyqdr4@xeD2!xc(CE6MGfU0##u{^5>a2%`3$N6; zjHCno0l@`>=pjgk6c{v8R3cx>haRGbNI0Spf)6pv_1&G-g%9W4-|zeTp5O1B+0bn0 zrNtoFQ&xi{99o=6m#WOy1Q0QT8V!g?>r>L1s{3%TS#(!yvkds>2MbeJp38k z6`RiZ^3k}meLQ`!x36b@GCoq1yQ!sU0v2Z7EQ#F)i;t3z^lS2dM%Y`3F=SrHRAbGU z0XAJ)hw9=#GkT{RD?ZYkiFVDJyH{Q7=3kJDovh}oSbMdI>f!JA)YE85=5O%lqx@6s z*+16>`#n5?O&7+N`NMq8x)&FXvUWa^G3#fpZ~e|@C-pB?S2!vN4wfh?GYu9Ht%95E zUC|BW1Ci(wJ1tp-53EzF5EK@c4jZSDH?Swt5kX*cQk!)OuZr=g?@Ir0Yj9}LHR1{f z+}8&~euX+%rO8(D2|gK6X(Vz3T|K1q`~AVI)Wia>U94ZUH?tFnY_S44)4v0SX}S z*W4K#2>X@RAw{8DHfL(Avcn6u)R!-4@dt+$Dn~>_;sm(H%*7U=nKcwQp8bk8*T5#o lfH9o54vs?K0D8d;mqA>ZXDz7}#ynODv#4gh|Yv8z)7nik%hEBT8DhLTl zZ2PyaPd;_#HVmiLaC&)7n0j8VHqce8XZP~|zUIqTs~-BUEyo%yL%!e#Fn5jrnpzqs%y#>C`0TP{l>Rm>__#IcEfnBp9M>jy!`d# zT6a%x_z4!Yk)0C=abn(Fb{?mqmLDY%Vs2)56XoFwOeZlH&Dai2Z2dIif~ub}oUpRu z%LviJa{;Z{Sf#1$VlPaU`70#$$h?TE#1_kzSE4>WB4S~=x5Ya)P&e|9Ez*Z zb+U<)>*MwxT`x3LjB=ch&Q-eXij|PJV4Oa#ylR`}2}#k@LLK*-b_s4SMirsnwt&8a z-W2w6cDf>*ue*a^i`S6e{=Rg3A{Fyz{7O795=%y95~dz|llwh#95zClzz8taDY-8i zO$?B;G;VjDnnCCfV73NF$NX}-BPqv890lZ?;l9M65|!IivP>3`*hZJ^E$$%l;3K^y z)0t=@EtCCd*PwBZIvur~NE;n37d{}S2)2L)j%n}(9EG+S41sm<3T%RRKnFZ>4uT^f M3c&@YGaFe$=?YjNzWt65aEDF=+2qJ%3YYcAC*^UUtQ zt|GG-0#Pj*llfP~F;=|s8@gYPJ(7qVL==xrW7l~pKtBEkl58I@a~5hk(g6b>f6Dg6 zhI4^jG@&8l}}`>#+e)2zjb}m_~vTzM+L#fk_9D}fjpu$aErYy zxM_YM5?x_uCA;vRwM%7!!Xna9^9=GP_Em6K0fzdVwb4b8ayWD#bxb*p_6X13PQpX z+xo5R6HlDEb;D^joIYL?CZAQS4RqD&*}43`ulcjps)v5I?zT^(nrqp|rsxjdEgIb2Eb*C=XU(I*GaHi0#1m=1(Ioqy`wn2`ekU zh!HI^7u1@JRhrrk_S{sNzd~XU&5Ni??0(rYi}@tBfE&EK44-5g1B{xAnlQVHLvax&H)8IJSQxk{H^u@dqIjL}DxS8TI9A!&L_sN-JI4#CYOs3O$c7SMOl z>%tz+PFI9;b+_2rxE%Cn63WIXh0rr8_iOa2NStbie^wK4Jvpa-5_(*rj zbUL0ImdReUYtT4LosL>gq(>di=ieiy2sVKQjw$d39EP?D41hK85^R9CKnFZ>_Jczp M0{RwjJ3co21BrJzKmY&$ diff --git a/src/test/shell/Makefile b/src/test/shell/Makefile index aa3ef032..4b409d43 100644 --- a/src/test/shell/Makefile +++ b/src/test/shell/Makefile @@ -47,7 +47,7 @@ FIX_SOS = $(patsubst fixtures/%.c,$(FIXDIR)/%.so,$(FIX_SRCS)) FIX_HLP = $(FIXDIR)/fixture.hlp HLPC = $(DVXTEST_ROOT)/bin/host/dvxhlpc HELP_FIX = $(DVXTEST_ROOT)/src/test/tools/fixtures/help -SHELL_SRCS = $(SHELLDIR)/shellApp.c $(SHELLDIR)/shellBas.c $(SHELLDIR)/shellSysBas.c $(SHELLDIR)/shellMain.c $(SHELLDIR)/shellInfo.c $(SHELLDIR)/shellAssoc.c $(SHELLDIR)/shellProg.c $(SHELLDIR)/shellRun.c $(SHELLDIR)/pmModel.c $(SHELLDIR)/pmModelBas.c +SHELL_SRCS = $(SHELLDIR)/shellApp.c $(SHELLDIR)/shellBas.c $(SHELLDIR)/shellSysBas.c $(SHELLDIR)/shellMain.c $(SHELLDIR)/shellInfo.c $(SHELLDIR)/shellAssoc.c $(SHELLDIR)/shellProg.c $(SHELLDIR)/shellRun.c $(SHELLDIR)/shellSaver.c $(SHELLDIR)/pmModel.c $(SHELLDIR)/pmModelBas.c SHELL_OBJS = $(patsubst $(SHELLDIR)/%.c,$(BINDIR)/%.o,$(SHELL_SRCS)) SHELL_BINS = $(SHELL_TEST) $(APPS_TEST) $(SHMAIN_TEST) $(PM_TEST) SHFIX_OBJ = $(BINDIR)/shellFixture.o @@ -72,6 +72,11 @@ JOY_DIR = $(DVXTEST_ROOT)/src/libs/kpunch/joystick JOY_OBJS = $(patsubst $(JOY_DIR)/%.c,$(BINDIR)/joystick/%.o,$(wildcard $(JOY_DIR)/*.c)) JOYKEYS_DRV = $(BINDIR)/joykeys.drv +# The screen savers (BASIC programs) pace their frames with FRAMEWAIT, +# which looks the game library's frame pacer up by name. +GAME_DIR = $(DVXTEST_ROOT)/src/libs/kpunch/game +GAME_OBJ = $(BINDIR)/game/game.o + # ... and its Sounds page the sound library (no drivers: no sound device). AUDIO_DIR = $(DVXTEST_ROOT)/src/libs/kpunch/audio AUDIO_OBJS = $(patsubst $(AUDIO_DIR)/%.c,$(BINDIR)/audio/%.o,$(filter-out $(AUDIO_DIR)/audioShell.c,$(wildcard $(AUDIO_DIR)/*.c))) @@ -108,8 +113,8 @@ $(SHFIX_BINS): EXTRA_OBJS = $(SHFIX_OBJ) $(SHFIX_BINS): $(SHFIX_OBJ) -$(APPS_TEST): EXTRA_OBJS = $(SHFIX_OBJ) $(FILEOPS_OBJS) $(JOY_OBJS) $(AUDIO_OBJS) $(HLPC_OBJ) -Wl,--whole-archive $(DVXTEST_BASRT_A) -Wl,--no-whole-archive -$(APPS_TEST): $(FILEOPS_OBJS) $(JOY_OBJS) $(AUDIO_OBJS) $(HLPC_OBJ) $(DVXTEST_BASRT_A) $(DVXTEST_BASSTUB_SO) $(JOYKEYS_DRV) | dvxtest-bascomp +$(APPS_TEST): EXTRA_OBJS = $(SHFIX_OBJ) $(FILEOPS_OBJS) $(JOY_OBJS) $(AUDIO_OBJS) $(GAME_OBJ) $(HLPC_OBJ) -Wl,--whole-archive $(DVXTEST_BASRT_A) -Wl,--no-whole-archive +$(APPS_TEST): $(FILEOPS_OBJS) $(JOY_OBJS) $(AUDIO_OBJS) $(GAME_OBJ) $(HLPC_OBJ) $(DVXTEST_BASRT_A) $(DVXTEST_BASSTUB_SO) $(JOYKEYS_DRV) | dvxtest-bascomp $(JOYKEYS_DRV): $(JOY_DIR)/drivers/joykeys/joyKeys.c $(DVX_MAKEFILES) @mkdir -p $(BINDIR) @@ -119,6 +124,10 @@ $(BINDIR)/audio/%.o: $(AUDIO_DIR)/%.c $(DVX_MAKEFILES) @mkdir -p $(dir $@) $(HOSTCC) $(CFLAGS) -I$(DVXTEST_ROOT)/src/libs/kpunch -I$(AUDIO_DIR) -c -o $@ $< +$(GAME_OBJ): $(GAME_DIR)/game.c $(DVX_MAKEFILES) + @mkdir -p $(dir $@) + $(HOSTCC) $(CFLAGS) -I$(DVXTEST_ROOT)/src/libs/kpunch -I$(GAME_DIR) -c -o $@ $< + $(BINDIR)/joystick/%.o: $(JOY_DIR)/%.c $(DVX_MAKEFILES) @mkdir -p $(dir $@) $(HOSTCC) $(CFLAGS) -I$(DVXTEST_ROOT)/src/libs/kpunch -I$(JOY_DIR) -c -o $@ $< diff --git a/src/test/shell/appsTest.c b/src/test/shell/appsTest.c index 06a73900..165d463d 100644 --- a/src/test/shell/appsTest.c +++ b/src/test/shell/appsTest.c @@ -33,6 +33,9 @@ #include "shellFixture.h" #include "shellInf.h" #include "shellAssoc.h" +#include "shellProg.h" +#include "shellRun.h" +#include "shellSaver.h" #include "dvxDlg.h" #include "dvxMem.h" #include "dvxPlat.h" @@ -46,6 +49,7 @@ #include "listBox/listBox.h" #include "listView/listView.h" #include "slider/slider.h" +#include "spinner/spinner.h" #include "tabControl/tabCtrl.h" #include "treeView/treeView.h" #include "../../libs/kpunch/hlpfile/hlpFormat.h" @@ -54,6 +58,9 @@ #include "../../libs/kpunch/joystick/joystick.h" #include "../../libs/kpunch/audio/audio.h" #include "../../libs/kpunch/audio/audioDrv.h" +#include "../../libs/kpunch/music/music.h" +#include "../../libs/kpunch/pkg/pkgDb.h" +#include "stb_ds_wrap.h" #include "dvxBas.h" #include @@ -88,7 +95,17 @@ #define CP_SND_VOLUME 50 // a master volume other than DVX.INI's #define CP_SND_VOLUME_TRY 10 // one tried and cancelled #define CP_SND_MIDI_MPU 1 // MIDI music: the MIDI port +#define CP_SND_MIDI_IN 1 // MIDI in: the MIDI port +#define CP_FIT_W 640 // Control Panel fits the smallest screen across +#define CP_SND_MODS_SOFT 1 // Modules: mixed in software #define CP_SND_FILES 3 // (None) and the two files in the sounds folder +#define CP_BANKS_SHARED DVX_SHARED_DIR "/" DVX_BANKS_DIR +#define CP_BANKS_USER DVX_USER_DIR "/" DVX_BANKS_DIR +#define CP_BANK_ITEMS 5 // Standard, Acme's two, Solo's, the user's own +#define CP_BANK_BRIGHT 2 // the item of ACME\BRIGHT.OP2 +#define CP_BANK_SOLO 3 // the item of KPUNCH\SOLO.OP2 +#define CP_FONT_ITEMS 5 // Standard, the user's .SF2 and .SBK, the GUS patches, the AWE ROM bank +#define CP_FAKE_TEXT_MAX 256 #define SYS_COLOR_ID 2 // a system color the system.bas case changes #define SYS_GLYPH_CHAR 'A' // a character the system.bas case reads the glyph of #define SYS_GLYPH_ROW 6 // a row of it that has pixels set @@ -140,6 +157,7 @@ #define HELP_QUOTE_STR "\"" // DVX_ARG_QUOTE as a string literal #define FIX_SAVE FIXTURE("fixSave.so") #define FIX_STUCK FIXTURE("fixStuck.so") +#define APPS_LOG "appsTest.log" // the DVX log (fixProcessInit) #define EXIT_NOTES_MAX 256 // Progman menu ids (progman.c) @@ -281,6 +299,38 @@ #define DEMO_TAB_COUNT 9 #define DEMO_CANVAS_TAB 6 +// A DOS program on the host: a shell script named like a batch file +// (caseDosPrograms); it writes "ran" and its arguments into ran.txt in the +// folder it runs in +#define DOS_DIR "DOSPROGS" +#define DOS_WORK "DOSWORK" +#define DOS_PROG DOS_DIR "/RUN.BAT" +#define DOS_MISSING DOS_DIR "/NONE.BAT" +#define DOS_RAN DOS_DIR "/ran.txt" +#define DOS_RAN_WORK DOS_WORK "/ran.txt" +#define DOS_DOC DOS_DIR "/LETTER.DOC" +#define DOS_SCRIPT "#!/bin/sh\necho \"ran $*\" > ran.txt\n" +#define DOS_MODE 0755 +#define DOS_HOOKS_MAX 32 +#define DOS_TEXT_MAX 256 +#define SAVER_DBP "src/savers/kpunch/blank/blank.dbp" // the Blank Screen saver DVX ships +#define SAVER_DIR DVX_SHARED_DIR "/" DVX_SAVERS_DIR +#define SAVER_NAME "blank.app" +#define SAVER_APP SAVER_DIR "/" SAVER_NAME +#define SAVER_MISSING "none.app" +#define SAVER_WAIT_MIN 1 // minutes +#define SAVER_WAIT_MS (SAVER_WAIT_MIN * 60000) +#define SAVER_WAIT_KEPT 7 +#define SAVER_IDLE_STEP_MS 5000 +#define SAVER_MS_PER_SEC 1000 +#define SAVER_MOUSE_X 100 +#define SAVER_MOUSE_Y 100 +#define SAVER_DUMMY_W 200 +#define SAVER_DUMMY_H 100 +#define SAVER_LABEL "Blank Screen" // blank.dbp's Name (its screen window's title too) +#define SAVER_LIST_COUNT 2 // (None) and Blank Screen +#define SAVER_KILL_LOG "BASIC: '" SAVER_LABEL "' ended by force; its runtime freed" + // One scripted answer to a Program Manager dialog: fill the text boxes // that follow the given labels, click the Browse... after browse's box // (the dialog it opens takes the next step), then click the button. @@ -301,6 +351,24 @@ typedef struct { // Prototypes // ============================================================ +// The music and package libraries' functions Control Panel's Sounds page +// looks up by name: appsTest links neither, and stands in for them below +const char *musicErrorText(int32_t err); +void musicFree(MusicT *music); +int32_t musicLoadMacro(const char *macro, MusicT **out); +bool musicPatchSetPath(char *out, int32_t outSize); +int32_t musicPlay(MusicT *music, bool loop); +bool musicRomBankPath(char *out, int32_t outSize); +int32_t musicSetBank(MusicT *music, const char *bank); +void pkgDbFree(PkgDbT *db); +bool pkgDbLoad(PkgDbT *db, const char *root); +const PkgInstalledT *pkgDbOwner(const PkgDbT *db, const char *name, const char *exceptId); + +// shell.bas entry points for DOS programs (shellBas.c) +int32_t ShellIsDosProgram(const char *path); +void ShellRunDos(const char *program, const char *args, const char *folder, int32_t pause); +int32_t ShellRunProgram(const char *program, const char *args, const char *folder); + // prefs.bas entry points (dvxPrefsBas.c; BASIC finds them by name) void PrefsClose(int32_t handle); int32_t PrefsCreate(void); @@ -314,6 +382,7 @@ static void caseCpanelCancelKeepsDblClick(void); static void caseCpanelColors(void); static void caseCpanelDesktop(void); static void caseCpanelFileTypes(void); +static void caseCpanelInstruments(void); static void caseCpanelJoystick(void); static void caseCpanelMouse(void); static void caseCpanelParity(void); @@ -324,6 +393,7 @@ static void caseDemoLowDepth(void); static void caseDemoMenus(void); static void caseDemoNoAssets(void); static void caseDocumentOpensNotepad(void); +static void caseDosPrograms(void); static bool caseEnabled(const char *name); static void caseExitCleanApps(void); static void caseExitDemoPrompt(void); @@ -350,6 +420,7 @@ static void caseProgmanMenus(void); static void caseProgmanStartup(void); static void caseProgmanSysinfoEmpty(void); static void caseProgmanViews(void); +static void caseScreenSaver(void); static void caseShellBasBindings(void); static void caseShellInfo(void); static void caseShellRunningBas(void); @@ -359,6 +430,9 @@ static WidgetT *clickButton(WindowT *win, const char *caption); static WidgetT *clickButtonIn(WidgetT *root, const char *caption); static void collectLeaves(WidgetT *w, WidgetT **arr, int32_t *count); static void cpanelColorPick(AppContextT *ctx, WindowT *modal, void *userCtx); +static void dosHookA(bool suspending); +static void dosHookB(bool suspending); +static bool dosRan(const char *path, const char *want); static void f1DuringIndex(AppContextT *ctx, WindowT *modal, void *userCtx); static void fireHandlers(WidgetT *w, bool clicksToo); static WidgetT *firstFocusable(WidgetT *root); @@ -368,6 +442,7 @@ static WindowT *helpOpen(int32_t *outId); static void hotkeyIgnore(void *userCtx); static bool iniHas(const char *section, const char *key, const char *value); static int32_t loadApp(const char *path); +static int32_t logCount(const char *text); static bool menuEnabled(WindowT *win, int32_t id); static int32_t parpmItemCell(WidgetT *icons, const char *method, int32_t index); static void pmBrowse(const char *label); @@ -391,7 +466,22 @@ static void writeHlpHeaderFile(const char *path, uint32_t magic, uint32_t ve // ============================================================ static int32_t sCtrlEscCalls = 0; + +// The stand-ins for the music and package libraries (see musicFree): +// what the Instruments Test asked for, and the add-ons that installed +// the test banks +static char sFakeTune[CP_FAKE_TEXT_MAX]; +static char sFakeBank[CP_FAKE_TEXT_MAX]; +static const char *sFakePatchSet = ""; // musicPatchSetPath's answer ("": none) +static const char *sFakeRomBank = ""; // musicRomBankPath's +static int32_t sFakeMusic; // the music handed out (its address) +static int32_t sFakePlays = 0; +static int32_t sFakeFrees = 0; +static int32_t sFakePlayResult = MUSIC_OK; +static PkgInstalledT sFakeAcme; // ACME\ALT.OP2 and ACME\BRIGHT.OP2 +static PkgInstalledT sFakeSolo; // KPUNCH\SOLO.OP2 static char sExitNotes[EXIT_NOTES_MAX]; // fixSave outcomes (fixExitNote) +static char sDosHooks[DOS_HOOKS_MAX]; // the suspend hooks as they ran (caseDosPrograms) static PmScriptT sPmScript; // progman dialog answers static int32_t sF1Step = 0; // f1DuringIndex progress static int32_t sSysKeys[SYS_KEYS_MAX]; // keys caseSysInputBas's window got @@ -621,6 +711,11 @@ static void caseCpanelDesktop(void) { // Pre-set wallpaper so the snapshot holds a real path for Cancel. TEST_TRUE(dvxSetWallpaper(tCtx(), SYS_WALLPAPER)); + // A screen saver for the list; none chosen. + TEST_EQ(platformMkdirRecursive(SAVER_DIR), 0); + TEST_TRUE(fixInstallBasicApp(SHELLTEST_BASCOMP, SHELLTEST_BASSTUB_SO, SAVER_DBP, SAVER_APP)); + shellSaverSet("", SHELL_SAVER_WAIT_DEFAULT); + int32_t id = loadApp(APP_CPANEL); WindowT *win = tFindWindow("Control Panel"); @@ -676,10 +771,41 @@ static void caseCpanelDesktop(void) { clickButton(win, "Clear"); TEST_STR_EQ(tCtx()->wallpaperPath, ""); - // Cancel restores the pre-open wallpaper. + // The screen saver row: (None) and the savers by their names; the + // choice and the wait take effect at once, Test starts it. + WidgetT *savers = widgetAfterLabel(root, "Screen saver:"); + WidgetT *wait = widgetAfterLabel(root, "Wait:"); + + TEST_TRUE(savers && wait && savers->onChange && wait->onChange); + + if (savers && wait && savers->onChange && wait->onChange) { + TEST_EQ(wgtDropdownGetSelected(savers), 0); + TEST_EQ(wgtDropdownGetItemCount(savers), SAVER_LIST_COUNT); + TEST_STR_EQ(wgtDropdownGetItem(savers, 0), "(None)"); + TEST_STR_EQ(wgtDropdownGetItem(savers, 1), SAVER_LABEL); + wgtDropdownSetSelected(savers, 1); + savers->onChange(savers); + wgtSpinnerSetValue(wait, SAVER_WAIT_KEPT); + wait->onChange(wait); + TEST_STR_EQ(shellSaverName(), SAVER_NAME); + TEST_EQ(shellSaverWait(), SAVER_WAIT_KEPT); + clickButton(win, "Test"); + shellSaverStep(tCtx()); + TEST_TRUE(shellSaverRunning()); + fixPumpShell(TASK_FRAMES); + tAdvanceClock(SHELL_SAVER_GRACE_MS); + tKeySpace(); + shellSaverStep(tCtx()); + fixPumpShell(TASK_FRAMES); + TEST_TRUE(!shellSaverRunning()); + } + + // Cancel restores the pre-open wallpaper and screen saver. clickButton(win, "Cancel"); fixPumpShell(SETTLE_FRAMES); TEST_STR_EQ(tCtx()->wallpaperPath, SYS_WALLPAPER); + TEST_STR_EQ(shellSaverName(), ""); + TEST_EQ(shellSaverWait(), SHELL_SAVER_WAIT_DEFAULT); // Second pass: open with a wallpaper, clear it, close via the window // button (onClose) so the restore branch with a saved path runs too. @@ -785,6 +911,133 @@ static void caseCpanelFileTypes(void) { } +// The Sounds page's Instruments: each bank shown by the add-on that +// installed it (with its own name when the add-on has two), else by its +// name; Test plays a tune on the bank selected, in place of the one +// before, and says when it cannot; OK keeps the choice. Its SoundFont +// row lists SoundFonts 2 and 1 and the sound cards' own banks the music +// library finds. The music and package libraries are the stand-ins +// below. +static void caseCpanelInstruments(void) { + static const char *const labels[CP_BANK_ITEMS] = { "Standard", "Acme Instruments: ALT", "Acme Instruments: BRIGHT", "Solo Bank", "MINE" }; + static const char *const fontLabels[CP_FONT_ITEMS] = { "Standard", "MY", "OLD", "Gravis UltraSound patches", "Sound Blaster AWE ROM instruments" }; + PkgFileT file = { 0 }; + + tCase("cpanel sounds page: instruments"); + fixShellUp(SCREEN_BPP); + TEST_EQ(platformMkdirRecursive(CP_BANKS_SHARED "/ACME"), 0); + TEST_EQ(platformMkdirRecursive(CP_BANKS_SHARED "/KPUNCH"), 0); + TEST_EQ(platformMkdirRecursive(CP_BANKS_USER), 0); + TEST_TRUE(writeBytes(CP_BANKS_SHARED "/ACME/ALT.OP2", "#OPL_II#", 8)); + TEST_TRUE(writeBytes(CP_BANKS_SHARED "/ACME/BRIGHT.OP2", "#OPL_II#", 8)); + TEST_TRUE(writeBytes(CP_BANKS_SHARED "/ACME/README.TXT", "no", 2)); + TEST_TRUE(writeBytes(CP_BANKS_SHARED "/KPUNCH/SOLO.OP2", "#OPL_II#", 8)); + TEST_TRUE(writeBytes(CP_BANKS_USER "/MINE.OP2", "#OPL_II#", 8)); + TEST_TRUE(writeBytes(CP_BANKS_USER "/MY.SF2", "RIFF", 4)); + TEST_TRUE(writeBytes(CP_BANKS_USER "/OLD.SBK", "RIFF", 4)); + TEST_TRUE(writeBytes(platformDvxIniPath(), "[audio]\n", 8)); + sFakePatchSet = "C:/ULTRASND/ULTRASND.INI"; + sFakeRomBank = "C:/SB16/SFBANK/SYNTHGM.SBK"; + + snprintf(sFakeAcme.manifest.name, sizeof(sFakeAcme.manifest.name), "Acme Instruments"); + file.name = CP_BANKS_SHARED "/ACME/ALT.OP2"; + arrput(sFakeAcme.manifest.files, file); + file.name = CP_BANKS_SHARED "/ACME/BRIGHT.OP2"; + arrput(sFakeAcme.manifest.files, file); + file.name = CP_BANKS_SHARED "/ACME/README.TXT"; + arrput(sFakeAcme.manifest.files, file); + snprintf(sFakeSolo.manifest.name, sizeof(sFakeSolo.manifest.name), "Solo Bank"); + file.name = CP_BANKS_SHARED "/KPUNCH/SOLO.OP2"; + arrput(sFakeSolo.manifest.files, file); + + for (int32_t pass = 0; pass < 2; pass++) { + int32_t id = loadApp(APP_CPANEL); + WindowT *win = tFindWindow("Control Panel"); + + TEST_TRUE(id > 0 && win != NULL); + + if (!win) { + break; + } + + wgtTabControlSetActive(win->widgetRoot->firstChild, CP_SOUNDS_TAB); + fixPumpShell(SETTLE_FRAMES); + + WidgetT *banks = widgetAfterLabel(win->widgetRoot, "Instruments:"); + WidgetT *test = banks ? banks->nextSibling : NULL; + + TEST_TRUE(banks && test && test->onClick); + + if (!banks || !test || !test->onClick) { + break; + } + + TEST_EQ(wgtDropdownGetItemCount(banks), CP_BANK_ITEMS); + + for (int32_t i = 0; i < CP_BANK_ITEMS && i < wgtDropdownGetItemCount(banks); i++) { + TEST_STR_EQ(wgtDropdownGetItem(banks, i), labels[i]); + } + + WidgetT *fonts = widgetAfterLabel(win->widgetRoot, "SoundFont:"); + + TEST_TRUE(fonts != NULL); + TEST_EQ(fonts ? wgtDropdownGetItemCount(fonts) : 0, CP_FONT_ITEMS); + + for (int32_t i = 0; fonts && i < CP_FONT_ITEMS && i < wgtDropdownGetItemCount(fonts); i++) { + TEST_STR_EQ(wgtDropdownGetItem(fonts, i), fontLabels[i]); + } + + if (pass == 1) { + TEST_EQ(wgtDropdownGetSelected(banks), CP_BANK_SOLO); + clickButton(win, "Cancel"); + fixPumpShell(SETTLE_FRAMES); + break; + } + + TEST_EQ(wgtDropdownGetSelected(banks), 0); + + // Test: the tune on the bank selected (not stored yet) + wgtDropdownSetSelected(banks, CP_BANK_BRIGHT); + test->onClick(test); + TEST_TRUE(sFakeTune[0] != '\0'); + TEST_STR_EQ(sFakeBank, "ACME" DVX_PATH_SEP "BRIGHT.OP2"); + TEST_EQ(sFakePlays, 1); + TEST_EQ(sFakeFrees, 0); + wgtDropdownSetSelected(banks, 0); + test->onClick(test); + TEST_STR_EQ(sFakeBank, ""); + TEST_EQ(sFakePlays, 2); + TEST_EQ(sFakeFrees, 1); + + // A tune that cannot play is said, and let go + sFakePlayResult = MUSIC_ERR_NO_DEVICE; + fixDialogsOn("OK"); + test->onClick(test); + fixDialogsOff(); + TEST_EQ(sFakePlays, 3); + TEST_EQ(sFakeFrees, 3); + sFakePlayResult = MUSIC_OK; + test->onClick(test); + + wgtDropdownSetSelected(banks, CP_BANK_SOLO); + clickButton(win, "OK"); + fixPumpShell(SETTLE_FRAMES); + TEST_EQ(sFakeFrees, 4); + } + + PrefsHandleT *h = prefsLoad(platformDvxIniPath()); + + TEST_STR_EQ(prefsGetString(h, AUDIO_INI_SECTION, MUSIC_INI_BANK, ""), "KPUNCH" DVX_PATH_SEP "SOLO.OP2"); + prefsClose(h); + sFakePatchSet = ""; + sFakeRomBank = ""; + arrfree(sFakeAcme.manifest.files); + arrfree(sFakeSolo.manifest.files); + fixKillAllApps(); + fixShellDown(); +} + + // The Game Controllers page with the keyboard joystick (no game port on // the host): the controller list, the dead zone applied at once and put // back by Cancel, the keyboard setting looking again, Keys..., and OK @@ -1018,16 +1271,19 @@ static void caseCpanelSounds(void) { wgtTabControlSetActive(root->firstChild, CP_SOUNDS_TAB); fixPumpShell(SETTLE_FRAMES); + TEST_TRUE(win->w <= CP_FIT_W); WidgetT *events = widgetAfterLabel(root, "Events:"); WidgetT *file = widgetAfterLabel(root, "Sound:"); WidgetT *volume = widgetAfterLabel(root, "Volume:"); WidgetT *driver = widgetAfterLabel(root, "Sound driver:"); WidgetT *midi = widgetAfterLabel(root, "MIDI music:"); + WidgetT *midiIn = widgetAfterLabel(root, "MIDI in:"); + WidgetT *mods = widgetAfterLabel(root, "Modules:"); - TEST_TRUE(file && volume && driver && midi && events); + TEST_TRUE(file && volume && driver && midi && midiIn && mods && events); - if (!file || !volume || !driver || !midi || !events) { + if (!file || !volume || !driver || !midi || !midiIn || !mods || !events) { break; } @@ -1040,6 +1296,11 @@ static void caseCpanelSounds(void) { TEST_EQ(wgtDropdownGetItemCount(driver), 1); TEST_STR_EQ(wgtDropdownGetItem(driver, 0), "Automatic"); TEST_EQ(wgtSliderGetValue(volume), pass == 0 ? 80 : CP_SND_VOLUME); + TEST_EQ(wgtDropdownGetItemCount(mods), AUDIO_MODULES_CHOICE_COUNT); + TEST_EQ(wgtDropdownGetSelected(mods), pass == 0 ? 0 : CP_SND_MODS_SOFT); + TEST_EQ(wgtDropdownGetItemCount(midiIn), AUDIO_MIDI_IN_CHOICE_COUNT); + TEST_STR_EQ(wgtDropdownGetItem(midiIn, 0), "None"); + TEST_EQ(wgtDropdownGetSelected(midiIn), pass == 0 ? 0 : CP_SND_MIDI_IN); // The error sound: none at first (the chime once OK saved it). wgtListBoxSetSelected(events, SoundErrorE); @@ -1057,6 +1318,8 @@ static void caseCpanelSounds(void) { volume->onChange(volume); TEST_EQ(audioGetMasterVolume(), (pass == 0 ? CP_SND_VOLUME : CP_SND_VOLUME_TRY) * AUDIO_VOLUME_MAX / PERCENT_MAX); wgtDropdownSetSelected(midi, CP_SND_MIDI_MPU); + wgtDropdownSetSelected(mods, CP_SND_MODS_SOFT); + wgtDropdownSetSelected(midiIn, pass == 0 ? CP_SND_MIDI_IN : 0); clickButton(win, pass == 0 ? "OK" : "Cancel"); fixPumpShell(SETTLE_FRAMES); } @@ -1072,6 +1335,8 @@ static void caseCpanelSounds(void) { TEST_EQ(prefsGetInt(h, AUDIO_INI_SECTION, AUDIO_INI_VOLUME, -1), CP_SND_VOLUME); TEST_STR_EQ(prefsGetString(h, AUDIO_INI_SECTION, AUDIO_INI_DRIVER, ""), AUDIO_DRIVER_AUTO); TEST_STR_EQ(prefsGetString(h, AUDIO_INI_SECTION, AUDIO_INI_MIDI, ""), AUDIO_MIDI_MPU401); + TEST_STR_EQ(prefsGetString(h, AUDIO_INI_SECTION, AUDIO_INI_MODULES, ""), AUDIO_MODULES_SOFTWARE); + TEST_STR_EQ(prefsGetString(h, AUDIO_INI_SECTION, AUDIO_INI_MIDI_IN, ""), AUDIO_MIDI_MPU401); prefsClose(h); audioSetMasterVolume(AUDIO_VOLUME_MAX); fixKillAllApps(); @@ -1234,6 +1499,33 @@ static void caseCpanelSysBas(void) { TEST_EQ(SysSetWallpaper(""), BAS_TRUE); TEST_STR_EQ(SysWallpaper(), ""); + // Screen saver: set, read back, store / load, start with none, idle + int32_t withSaver = PrefsCreate(); + + SysSetScreenSaver(SAVER_MISSING, SAVER_WAIT_KEPT); + TEST_STR_EQ(SysScreenSaver(), SAVER_MISSING); + TEST_EQ(SysScreenSaverWait(), SAVER_WAIT_KEPT); + TEST_EQ(SysScreenSaverRunning(), BAS_FALSE); + SysScreenSaverStore(withSaver); + SysScreenSaverStore(0); + TEST_STR_EQ(PrefsGet(withSaver, "desktop", "saver", ""), SAVER_MISSING); + SysSetScreenSaver("", SHELL_SAVER_WAIT_MAX + 1); + TEST_EQ(SysScreenSaverWait(), SHELL_SAVER_WAIT_MAX); + TEST_EQ(SysScreenSaverStart(), BAS_FALSE); + SysScreenSaverLoad(withSaver); + SysScreenSaverLoad(0); + TEST_STR_EQ(SysScreenSaver(), SAVER_MISSING); + TEST_EQ(SysScreenSaverWait(), SAVER_WAIT_KEPT); + TEST_EQ(SysScreenSaverStart(), BAS_TRUE); + shellSaverStep(ctx); + TEST_EQ(SysScreenSaverRunning(), BAS_FALSE); + SysSetScreenSaver("", SHELL_SAVER_WAIT_DEFAULT); + PrefsClose(withSaver); + tKeySpace(); + TEST_EQ(SysIdleSeconds(), 0); + tAdvanceClock(SAVER_IDLE_STEP_MS); + TEST_EQ(SysIdleSeconds(), SAVER_IDLE_STEP_MS / SAVER_MS_PER_SEC); + // Mouse: apply, read back, store / load, defaults SysSetMouse(MOUSE_WHEEL_DIR_REVERSED, SYS_DBL_MS, MOUSE_ACCEL_HIGH, SYS_MICKEYS, SYS_WHEEL_STEP); TEST_EQ(SysMouseWheelDir(), MOUSE_WHEEL_DIR_REVERSED); @@ -1622,6 +1914,116 @@ static void caseDocumentOpensNotepad(void) { } +// A DOS program run with DVX stepped aside (a shell script stands in on +// the host): in its own folder or the one given, with the suspend hooks +// around it (newest first, then oldest first); with only the desktop +// running it runs at once; another program is named and asked to close +// first (No keeps it and runs nothing); a missing program is said; a +// document whose type opens in a DOS program runs it with the document; +// the BASIC calls. +static void caseDosPrograms(void) { + static const char store[] = "[associations]\nDOC = " DOS_PROG "\n"; + AppContextT *ctx; + + tCase("a DOS program runs with DVX stepped aside, and DVX comes back"); + fixShellUp(SCREEN_BPP); + ctx = tCtx(); + TEST_EQ(platformMkdirRecursive(DOS_DIR), 0); + TEST_EQ(platformMkdirRecursive(DOS_WORK), 0); + TEST_TRUE(writeBytes(DOS_PROG, DOS_SCRIPT, (int32_t)strlen(DOS_SCRIPT))); + TEST_TRUE(writeBytes(DOS_DOC, "x", 1)); + TEST_EQ(chmod(DOS_PROG, DOS_MODE), 0); + assocShipped(store); + TEST_TRUE(platformSuspendHookAdd(dosHookA)); + TEST_TRUE(platformSuspendHookAdd(dosHookB)); + + int32_t pm = loadApp(APP_PROGMAN); + + fixPumpShell(TASK_FRAMES); + sDosHooks[0] = '\0'; + fixDialogs = 0; + fixDialogsOn("Cancel"); + shellRunDos(DOS_PROG, "one two", NULL, false); + shellRunDosRequest(ctx, pm); + TEST_EQ(fixDialogs, 0); + TEST_TRUE(dosRan(DOS_RAN, "ran one two\n")); + TEST_STR_EQ(sDosHooks, "B+A+A-B-"); + TEST_TRUE(shellGetApp(pm) != NULL); + TEST_TRUE(tFindWindow("Program Manager") != NULL); + + // Nothing waits: nothing runs. + shellRunDosRequest(ctx, pm); + TEST_STR_EQ(sDosHooks, "B+A+A-B-"); + + // In the folder given. + shellRunDos(DOS_PROG, "w", DOS_WORK, false); + shellRunDosRequest(ctx, pm); + TEST_TRUE(dosRan(DOS_RAN_WORK, "ran w\n")); + + tCase("a DOS program asks the other programs to close first"); + int32_t clk = loadApp(APP_CLOCK); + + fixPumpShell(TASK_FRAMES); + unlink(DOS_RAN); + fixDialogsOn("No"); + fixDialogs = 0; + shellRunDos(DOS_PROG, "x", NULL, false); + shellRunDosRequest(ctx, pm); + TEST_EQ(fixDialogs, 1); + TEST_TRUE(!dosRan(DOS_RAN, NULL)); + TEST_TRUE(shellGetApp(clk) != NULL); + fixDialogsOn("Yes"); + fixDialogs = 0; + shellRunDos(DOS_PROG, "y", NULL, false); + shellRunDosRequest(ctx, pm); + fixPumpShell(SETTLE_FRAMES); + TEST_EQ(fixDialogs, 1); + TEST_TRUE(dosRan(DOS_RAN, "ran y\n")); + TEST_PTR_EQ(shellGetApp(clk), NULL); + TEST_TRUE(shellGetApp(pm) != NULL); + + tCase("a missing DOS program is said; shellRunProgram and associations"); + sDosHooks[0] = '\0'; + fixDialogsOn("OK"); + fixDialogs = 0; + shellRunDos(DOS_MISSING, "", NULL, false); + shellRunDosRequest(ctx, pm); + TEST_EQ(fixDialogs, 1); + TEST_STR_EQ(sDosHooks, ""); + + TEST_EQ(shellRunProgram(ctx, DOS_PROG, "p", NULL), SHELL_RUN_DOS); + shellRunDosRequest(ctx, pm); + TEST_TRUE(dosRan(DOS_RAN, "ran p\n")); + + // The document by its full name: the program runs in its own folder. + char want[DOS_TEXT_MAX]; + char full[DVX_MAX_PATH]; + + TEST_TRUE(platformPathAbsolute(DOS_DOC, full, sizeof(full))); + snprintf(want, sizeof(want), "ran %s\n", full); + TEST_EQ(shellRunProgram(ctx, DOS_DOC, "", NULL), SHELL_RUN_DOS); + shellRunDosRequest(ctx, pm); + TEST_TRUE(dosRan(DOS_RAN, want)); + + tCase("shell.bas: ShellIsDosProgram, ShellRunDos, ShellRunProgram"); + TEST_EQ(ShellIsDosProgram("GAME.EXE"), BAS_TRUE); + TEST_EQ(ShellIsDosProgram("A.APP"), BAS_FALSE); + TEST_EQ(ShellRunProgram(DOS_PROG, "b", ""), -1); + shellRunDosRequest(ctx, pm); + TEST_TRUE(dosRan(DOS_RAN, "ran b\n")); + ShellRunDos(DOS_PROG, "c", "", BAS_FALSE); + shellRunDosRequest(ctx, pm); + TEST_TRUE(dosRan(DOS_RAN, "ran c\n")); + + fixDialogsOff(); + platformSuspendHookRemove(dosHookA); + platformSuspendHookRemove(dosHookB); + fixKillAllApps(); + shellAssocTestReset(NULL, NULL, NULL); + fixShellDown(); +} + + static bool caseEnabled(const char *name) { const char *only = getenv("APPS_CASE"); @@ -3183,6 +3585,179 @@ static void caseProgmanViews(void) { } +// The screen saver (shellSaver.c) with the Blank Screen saver DVX ships: +// started after the wait with no input, never sooner, not while a dialog +// box is open or a program has the full screen; it takes the full screen; +// input in the grace keeps it, input after it ends it; one that does not +// close is ended; Test starts it at once; the settings round-trip. +static void caseScreenSaver(void) { + tCase("the screen saver starts after the wait, and input ends it"); + fixShellUp(SCREEN_BPP); + + AppContextT *ctx = tCtx(); + + TEST_EQ(platformMkdirRecursive(SAVER_DIR), 0); + + if (!fixInstallBasicApp(SHELLTEST_BASCOMP, SHELLTEST_BASSTUB_SO, SAVER_DBP, SAVER_APP)) { + fixShellDown(); + return; + } + + // A file in SAVERS is kept by its name; the wait stays in range. + shellSaverSet(SAVER_APP, 0); + TEST_STR_EQ(shellSaverName(), SAVER_NAME); + TEST_EQ(shellSaverWait(), SHELL_SAVER_WAIT_MIN); + shellSaverSet(SAVER_NAME, SHELL_SAVER_WAIT_MAX + 1); + TEST_EQ(shellSaverWait(), SHELL_SAVER_WAIT_MAX); + shellSaverSet(SAVER_NAME, SAVER_WAIT_MIN); + + // dvxIdleMs: input sets it to 0, the clock moves it on. + tKeySpace(); + TEST_EQ(dvxIdleMs(ctx), 0); + tAdvanceClock(SAVER_IDLE_STEP_MS); + TEST_EQ(dvxIdleMs(ctx), SAVER_IDLE_STEP_MS); + tMouseMove(SAVER_MOUSE_X, SAVER_MOUSE_Y); + TEST_EQ(dvxIdleMs(ctx), 0); + + // A second short of the wait: nothing; the wait: it starts. + tAdvanceClock(SAVER_WAIT_MS - SHELL_SAVER_GRACE_MS); + shellSaverStep(ctx); + TEST_TRUE(!shellSaverRunning()); + tAdvanceClock(SHELL_SAVER_GRACE_MS); + shellSaverStep(ctx); + TEST_TRUE(shellSaverRunning()); + fixPumpShell(TASK_FRAMES); + TEST_TRUE(ctx->exclusive.owner != NULL); + + // Input inside the grace keeps it; input after it ends it. + tKeySpace(); + shellSaverStep(ctx); + TEST_TRUE(shellSaverRunning()); + tAdvanceClock(SHELL_SAVER_GRACE_MS); + tKeySpace(); + shellSaverStep(ctx); + fixPumpShell(TASK_FRAMES); + TEST_TRUE(!shellSaverRunning()); + TEST_TRUE(ctx->exclusive.owner == NULL); + + // Not again until another wait has passed. + shellSaverStep(ctx); + TEST_TRUE(!shellSaverRunning()); + + tCase("the screen saver waits for a dialog box and a full-screen program"); + WindowT *dummy = dvxCreateWindow(ctx, "Saver Dialog", 0, 0, SAVER_DUMMY_W, SAVER_DUMMY_H, false); + + TEST_TRUE(dummy != NULL); + tAdvanceClock(SAVER_WAIT_MS); + ctx->modalWindow = dummy; + shellSaverStep(ctx); + TEST_TRUE(!shellSaverRunning()); + ctx->modalWindow = NULL; + ctx->exclusive.owner = dummy; + shellSaverStep(ctx); + TEST_TRUE(!shellSaverRunning()); + ctx->exclusive.owner = NULL; + dvxDestroyWindow(ctx, dummy); + fixPumpShell(1); + + // Fix Stuck ignores the close request (and moves the fake clock on as + // it loops): asked at input after the grace, ended SHELL_SAVER_END_MS + // later. + tCase("a screen saver that does not close is ended"); + shellSaverSet(FIX_STUCK, SAVER_WAIT_MIN); + TEST_TRUE(shellSaverStart()); + shellSaverStep(ctx); + TEST_TRUE(shellSaverRunning()); + fixPumpShell(TASK_FRAMES); + tAdvanceClock(SHELL_SAVER_GRACE_MS); + tKeySpace(); + shellSaverStep(ctx); + fixPumpShell(TASK_FRAMES); + TEST_TRUE(shellSaverRunning()); + tAdvanceClock(SHELL_SAVER_END_MS); + shellSaverStep(ctx); + fixPumpShell(TASK_FRAMES); + TEST_TRUE(!shellSaverRunning()); + TEST_TRUE(tFindWindow("Fix Stuck") == NULL); + shellSaverSet(SAVER_NAME, SAVER_WAIT_MIN); + + // A BASIC program ended by force (End Task): the BASIC runtime's kill + // hook ends it as its own end would, while its window is still there + // (LeakSanitizer finds anything it leaves: its VM, screen, and module) + tCase("a BASIC screen saver ended by force frees its runtime"); + + int32_t freed = logCount(SAVER_KILL_LOG); + + TEST_TRUE(shellSaverStart()); + shellSaverStep(ctx); + TEST_TRUE(shellSaverRunning()); + fixPumpShell(TASK_FRAMES); + + WindowT *blank = tFindWindow(SAVER_LABEL); + + TEST_TRUE(blank != NULL); + + if (blank) { + shellForceKillApp(ctx, shellGetApp(blank->appId)); + } + + fixPumpShell(TASK_FRAMES); + TEST_TRUE(!shellSaverRunning()); + TEST_TRUE(tFindWindow(SAVER_LABEL) == NULL); + TEST_EQ(logCount(SAVER_KILL_LOG), freed + 1); + + tCase("Test starts the screen saver at once; none chosen, a missing one"); + TEST_TRUE(shellSaverStart()); + shellSaverStep(ctx); + TEST_TRUE(shellSaverRunning()); + TEST_TRUE(!shellSaverStart()); + fixPumpShell(TASK_FRAMES); + tAdvanceClock(SHELL_SAVER_GRACE_MS); + tKeySpace(); + tAdvanceClock(SHELL_SAVER_GRACE_MS); + shellSaverStep(ctx); + fixPumpShell(TASK_FRAMES); + TEST_TRUE(!shellSaverRunning()); + + shellSaverSet("", SAVER_WAIT_MIN); + TEST_TRUE(!shellSaverStart()); + tAdvanceClock(SAVER_WAIT_MS); + shellSaverStep(ctx); + TEST_TRUE(!shellSaverRunning()); + + fixDialogs = 0; + shellSaverSet(SAVER_MISSING, SAVER_WAIT_MIN); + TEST_TRUE(shellSaverStart()); + shellSaverStep(ctx); + TEST_TRUE(!shellSaverRunning()); + TEST_EQ(fixDialogs, 0); + + tCase("the screen saver settings round-trip through DVX.INI"); + PrefsHandleT *prefs = prefsCreate(); + + TEST_TRUE(prefs != NULL); + shellSaverSet(SAVER_NAME, SAVER_WAIT_KEPT); + shellSaverToPrefs(prefs); + TEST_STR_EQ(prefsGetString(prefs, DVX_DESKTOP_SECTION, "saver", ""), SAVER_NAME); + TEST_EQ(prefsGetInt(prefs, DVX_DESKTOP_SECTION, "saverWait", 0), SAVER_WAIT_KEPT); + shellSaverSet("", SHELL_SAVER_WAIT_MIN); + shellSaverFromPrefs(prefs); + TEST_STR_EQ(shellSaverName(), SAVER_NAME); + TEST_EQ(shellSaverWait(), SAVER_WAIT_KEPT); + prefsClose(prefs); + + // Nothing in DVX.INI: none, the default wait. + prefs = prefsCreate(); + shellSaverFromPrefs(prefs); + TEST_STR_EQ(shellSaverName(), ""); + TEST_EQ(shellSaverWait(), SHELL_SAVER_WAIT_DEFAULT); + prefsClose(prefs); + + fixKillAllApps(); + fixShellDown(); +} + + // Every shell.bas binding called from a BASIC program (shbind.frm checks // what it can see itself and names the first failure in its title); here // the programs it started, Run Minimized, Task Manager, and the hotkey, @@ -3251,6 +3826,15 @@ static void caseShellBasBindings(void) { tKey(PM_SCAN_K, 0, KEY_MOD_CTRL | KEY_MOD_ALT); fixPumpShell(1); + // The DOS program it asked for: not there, which DVX says (before + // asking any program to close). + fixDialogs = 0; + fixDialogsOn("OK"); + shellRunDosRequest(ctx, 0); + fixDialogsOff(); + TEST_EQ(fixDialogs, 1); + TEST_TRUE(shellGetApp(id) != NULL); + // Exit DVX, asked for by the program: every program closes, then DVX. TEST_TRUE(ctx->running); shellRunExitRequest(ctx, 0); @@ -3609,6 +4193,28 @@ static void cpanelColorPick(AppContextT *ctx, WindowT *modal, void *userCtx) { } +// Suspend hooks recording when they run (caseDosPrograms). +static void dosHookA(bool suspending) { + strncat(sDosHooks, suspending ? "A+" : "A-", sizeof(sDosHooks) - strlen(sDosHooks) - 1); +} + + +static void dosHookB(bool suspending) { + strncat(sDosHooks, suspending ? "B+" : "B-", sizeof(sDosHooks) - strlen(sDosHooks) - 1); +} + + +// The stand-in DOS program wrote want into path (NULL: it wrote anything). +static bool dosRan(const char *path, const char *want) { + int32_t len = 0; + char *text = platformReadFile(path, &len); + bool ran = text && (!want || strcmp(text, want) == 0); + + free(text); + return ran; +} + + // Dialog script for caseHelpNewArgsInDialog: first frame, "press F1" (the // shell hands the viewer another file) and repaint the list; second frame, // close the dialog. @@ -3741,6 +4347,25 @@ static int32_t loadApp(const char *path) { } +// How often text is in the DVX log (APPS_LOG, written as it goes). +static int32_t logCount(const char *text) { + char path[DVX_MAX_PATH]; + int32_t len = 0; + int32_t count = 0; + + snprintf(path, sizeof(path), "%s/%s", fixStartDir, APPS_LOG); + + char *data = platformReadFile(path, &len); + + for (const char *p = data ? strstr(data, text) : NULL; p; p = strstr(p + 1, text)) { + count++; + } + + free(data); + return count; +} + + // A command's enabled state in the window's menu bar. static bool menuEnabled(WindowT *win, int32_t id) { for (int32_t m = 0; win->menuBar && m < win->menuBar->menuCount; m++) { @@ -3755,6 +4380,63 @@ static bool menuEnabled(WindowT *win, int32_t id) { } +// Stand-in for the music library (Control Panel looks it up by name): +// the error text. +const char *musicErrorText(int32_t err) { + (void)err; + return "No sound device can play this music"; +} + + +// Stand-ins for the music library: musicLoadMacro hands out sFakeMusic +// and keeps the macro, musicSetBank keeps the bank, musicPlay counts and +// answers sFakePlayResult, musicFree counts. There is no sound device +// here, so nothing would be heard anyway. +void musicFree(MusicT *music) { + if (music) { + sFakeFrees++; + } +} + + +int32_t musicLoadMacro(const char *macro, MusicT **out) { + snprintf(sFakeTune, sizeof(sFakeTune), "%s", macro); + *out = (MusicT *)&sFakeMusic; + return MUSIC_OK; +} + + +// Stand-in for the music library: the UltraSound's patches it found +// (sFakePatchSet). +bool musicPatchSetPath(char *out, int32_t outSize) { + snprintf(out, (size_t)outSize, "%s", sFakePatchSet); + return sFakePatchSet[0] != '\0'; +} + + +int32_t musicPlay(MusicT *music, bool loop) { + (void)music; + (void)loop; + sFakePlays++; + return sFakePlayResult; +} + + +// Stand-in for the music library: the AWE's ROM bank it found +// (sFakeRomBank). +bool musicRomBankPath(char *out, int32_t outSize) { + snprintf(out, (size_t)outSize, "%s", sFakeRomBank); + return sFakeRomBank[0] != '\0'; +} + + +int32_t musicSetBank(MusicT *music, const char *bank) { + (void)music; + snprintf(sFakeBank, sizeof(sFakeBank), "%s", bank ? bank : ""); + return MUSIC_OK; +} + + // parpm's IconView's ItemCol or ItemRow (method) of item index; -1 when // the call fails. static int32_t parpmItemCell(WidgetT *icons, const char *method, int32_t index) { @@ -3765,6 +4447,32 @@ static int32_t parpmItemCell(WidgetT *icons, const char *method, int32_t index) } +// Stand-ins for the package library: no records to read; the test banks' +// add-ons by the vendor folder in the file's name. +void pkgDbFree(PkgDbT *db) { + (void)db; +} + + +bool pkgDbLoad(PkgDbT *db, const char *root) { + (void)root; + memset(db, 0, sizeof(*db)); + return true; +} + + +const PkgInstalledT *pkgDbOwner(const PkgDbT *db, const char *name, const char *exceptId) { + (void)db; + (void)exceptId; + + if (strstr(name, "ACME")) { + return &sFakeAcme; + } + + return strstr(name, "KPUNCH") ? &sFakeSolo : NULL; +} + + static void pmBrowse(const char *label) { sPmScript.steps[sPmScript.count - 1].browse = label; } @@ -3985,7 +4693,7 @@ static void writeHlpHeaderFile(const char *path, uint32_t magic, uint32_t versio int main(void) { tSuiteBegin("T10 apps"); - if (!fixProcessInit("appsTest.log")) { + if (!fixProcessInit(APPS_LOG)) { return 1; } @@ -4006,6 +4714,8 @@ int main(void) { RUN_CASE(caseProgmanStartup); RUN_CASE(caseProgmanHotkeysGlobal); RUN_CASE(caseDocumentOpensNotepad); + RUN_CASE(caseDosPrograms); + RUN_CASE(caseScreenSaver); RUN_CASE(caseProgmanInstalledChange); RUN_CASE(caseCpanelMouse); RUN_CASE(caseCpanelParity); @@ -4014,6 +4724,7 @@ int main(void) { RUN_CASE(caseCpanelFileTypes); RUN_CASE(caseCpanelJoystick); RUN_CASE(caseCpanelSounds); + RUN_CASE(caseCpanelInstruments); RUN_CASE(caseCpanelSysBas); RUN_CASE(caseSysInputBas); RUN_CASE(caseCpanelVideo); diff --git a/src/test/shell/basic/shbind/shbind.frm b/src/test/shell/basic/shbind/shbind.frm index 01e66869..d6b6bf8d 100644 --- a/src/test/shell/basic/shbind/shbind.frm +++ b/src/test/shell/basic/shbind/shbind.frm @@ -136,6 +136,8 @@ SUB CheckPrograms Check ShellIsProgram("A/B.APP"), "IsProgram app" Check NOT ShellIsProgram("docs/notes.txt"), "IsProgram document" + Check ShellIsDosProgram("GAMES/PLAY.EXE") AND ShellIsDosProgram("c:/dos/go.bat") AND ShellIsDosProgram("EDIT.COM"), "IsDosProgram" + Check NOT ShellIsDosProgram("A/B.APP") AND NOT ShellIsDosProgram("docs/notes.txt"), "IsDosProgram not" Check ShellProgramName$("some/dir/widget.app") = "Widget", "ProgramName fallback" Check ShellProgramDescription$("some/dir/widget.app") = "", "ProgramDescription none" @@ -221,6 +223,10 @@ SUB CheckRunning ShellMinimizeApp minId Check ShellRunProgram("missing.txt", "", "") = 0, "RunProgram missing" + + ' Set to run once this procedure returns; the test carries it out (a + ' file that is not there: DVX says so) + ShellRunDos "dos/missing.bat", "", "", False Check ShellRun(CLOCK_APP, "x"), "Run" Check NOT ShellRun("", ""), "Run empty" diff --git a/src/test/shell/progmanTest.c b/src/test/shell/progmanTest.c index 5cd1f58e..7243bfb7 100644 --- a/src/test/shell/progmanTest.c +++ b/src/test/shell/progmanTest.c @@ -906,6 +906,10 @@ static void caseModelFields(void) { TEST_STR_EQ(text, "4"); TEST_TRUE(pmItemSetField(&item, PM_FIELD_MINIMIZED, "1")); TEST_TRUE(item.runMinimized); + TEST_TRUE(pmItemSetField(&item, PM_FIELD_PAUSE, "1")); + TEST_TRUE(item.pause); + TEST_TRUE(pmItemGetField(&item, PM_FIELD_PAUSE, text, (int32_t)sizeof(text))); + TEST_STR_EQ(text, "1"); TEST_TRUE(pmItemSetField(&item, PM_FIELD_DESCRIPTION, "Tip")); TEST_STR_EQ(item.description, "Tip"); TEST_TRUE(!pmItemSetField(&item, "nope", "x")); @@ -980,6 +984,7 @@ static void caseModelRoundTrip(void) { full.hotkeyKey = KEY_F9; full.hotkeyMods = ACCEL_CTRL | ACCEL_SHIFT; full.runMinimized = true; + full.pause = true; full.col = 4; full.row = 2; pmItemAdd(&m, tools, &full); @@ -1021,11 +1026,13 @@ static void caseModelRoundTrip(void) { TEST_EQ(e->hotkeyKey, KEY_F9); TEST_EQ(e->hotkeyMods, ACCEL_CTRL | ACCEL_SHIFT); TEST_TRUE(e->runMinimized); + TEST_TRUE(e->pause); TEST_EQ(e->col, 4); TEST_EQ(e->row, 2); TEST_EQ(r.groups[1].items[1].col, PM_CELL_NONE); TEST_EQ(r.groups[1].items[1].hotkeyKey, 0); TEST_TRUE(!r.groups[1].items[1].runMinimized); + TEST_TRUE(!r.groups[1].items[1].pause); TEST_EQ(arrlen(r.history), 2); TEST_STR_EQ(r.history[0], "new.app arg"); diff --git a/src/test/shell/shellFixture.c b/src/test/shell/shellFixture.c index ab64ad12..4f6544f3 100644 --- a/src/test/shell/shellFixture.c +++ b/src/test/shell/shellFixture.c @@ -66,12 +66,17 @@ static void dismissDialog(AppContextT *ctx, WindowT *modal, void *userCtx) { } if (sDialogTypeName) { - // The filename input sits after the "File name:" label; the input - // box's single text input and the Run dialog's command box are - // found the same way. + // The filename input sits after the "File name:" label (a folder + // dialog's after "Folder name:"); the input box's single text + // input and the Run dialog's command box are found the same way. WidgetT *label = tFindByText(modal->widgetRoot, "File name:"); WidgetT *input = label ? label->nextSibling : NULL; + if (!input) { + label = tFindByText(modal->widgetRoot, "Folder name:"); + input = label ? label->nextSibling : NULL; + } + if (!input) { label = tFindByText(modal->widgetRoot, "Search for:"); input = label ? label->nextSibling : NULL; diff --git a/src/test/shell/shellFixture.h b/src/test/shell/shellFixture.h index 53331a44..d4cdebd5 100644 --- a/src/test/shell/shellFixture.h +++ b/src/test/shell/shellFixture.h @@ -85,8 +85,8 @@ void fixDialogsOff(void); // is answered with the `answer` button, else OK, else Yes. void fixDialogsOn(const char *answer); -// Adjust the running script: type `name` into the dialog's file-name or -// search input (NULL for none) and answer with `answer`. +// Adjust the running script: type `name` into the dialog's file-name, +// folder-name, or search input (NULL for none) and answer with `answer`. void fixDialogsType(const char *name, const char *answer); // A BASIC program for the host: compile the project (dbp, relative to diff --git a/src/test/unit/testFonts.c b/src/test/unit/testFonts.c index 4956f8ef..b86e4d1a 100644 --- a/src/test/unit/testFonts.c +++ b/src/test/unit/testFonts.c @@ -93,7 +93,8 @@ static const FaceDefT sFaces[] = { { "DJVMONO.DFN", "DejaVu Sans Mono", 0, DFN_FACE_FIXED, { 10 }, 1, 0, "B" }, { "ZZDUP.DFN", "Test Sans", 0, 0, { 20 }, 1, 0, "Z" }, { "SYSTEM.DFN", "System", 0, 0, { 9 }, 1, 0, "S" }, - { "BADSTRK.DFN", "Bad Strike", 0, 0, { 10 }, 1, 0, "A" } + { "BADSTRK.DFN", "Bad Strike", 0, 0, { 10 }, 1, 0, "A" }, + { "ZZSAME.DFN", "Test Sans", 0, 0, { 12 }, 1, 0, "Q" } }; #define FACE_BAD_STRIKE 8 // sFaces index of the face whose glyph sizes are spoiled @@ -331,11 +332,12 @@ static void testAdded(void) { TEST_TRUE(small->glyphs['C'].width > 0); TEST_EQ(big->pixelSize, 40); TEST_TRUE(big->glyphs['Z'].width > 0); - TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, TEXT_SIZES_MAX), 3); + TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, TEXT_SIZES_MAX), 4); TEST_EQ(sizes[0], 8); TEST_EQ(sizes[1], 12); - TEST_EQ(sizes[2], 40); - TEST_EQ(FontNearestSize("Test Sans", 30, 0), 40); + TEST_EQ(sizes[2], 20); + TEST_EQ(sizes[3], 40); + TEST_EQ(FontNearestSize("Test Sans", 32, 0), 40); // A style it lacks: the installed face of it, not one made from its own BitmapFontT *bold = dvxFontOpen("Test Sans", 12, FONT_STYLE_BOLD); @@ -358,7 +360,7 @@ static void testAdded(void) { dvxFontSetSmooth(false); // Another program sees the installed family alone - TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, TEXT_SIZES_MAX), 2); + TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, TEXT_SIZES_MAX), 3); TEST_EQ(dvxFontFamilyCount(), 6); dvxMemAppIdPtr = &app; @@ -454,9 +456,10 @@ static void testBasic(void) { TEST_STR_EQ(FontFamilyName(99), ""); TEST_EQ(FontFamilyFixed("Test Mono"), -1); TEST_EQ(FontFamilyFixed("Test Sans"), 0); - TEST_EQ(FontSizeCount("Test Sans", 0), 2); + TEST_EQ(FontSizeCount("Test Sans", 0), 3); TEST_EQ(FontSizeAt("Test Sans", 0, 1), 12); - TEST_EQ(FontSizeAt("Test Sans", 0, 2), 0); + TEST_EQ(FontSizeAt("Test Sans", 0, 2), 20); + TEST_EQ(FontSizeAt("Test Sans", 0, 3), 0); TEST_EQ(FontSizeAt("Test Sans", 0, -1), 0); TEST_EQ(FontNearestSize("Test Sans", 11, 0), 12); TEST_EQ(FontNearestSize("Nope", 11, FONT_STYLE_BOLD), FONT_CHAR_HEIGHT); @@ -534,9 +537,10 @@ static void testDetachOldest(void) { // The family list: the system font first, the rest alphabetical; files -// that cannot be read, a family called "System" and a second face of a -// style skipped (a face whose glyphs are spoiled reads well enough to be -// listed); sizes and pitch per family +// that cannot be read and a family called "System" skipped, a second file +// of a face adding the sizes it lacks (one with none skipped; a face +// whose glyphs are spoiled reads well enough to be listed); sizes and +// pitch per family static void testFamilies(void) { int32_t sizes[TEXT_SIZES_MAX]; @@ -557,15 +561,27 @@ static void testFamilies(void) { TEST_TRUE(dvxFontFamilyFixed(NULL)); TEST_FALSE(dvxFontFamilyFixed("Nope")); - // The duplicate's 20 px never shows; the bold face has its own sizes; - // italic is drawn from the regular face - TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, TEXT_SIZES_MAX), 2); + // The second file's 20 px joins the face's sizes, from that file (its + // 'Z'); the third brings only a 12 the face has, so its 'Q' never + // shows; the bold face has its own sizes; italic is drawn from the + // regular face + TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, TEXT_SIZES_MAX), 3); TEST_EQ(sizes[0], 8); TEST_EQ(sizes[1], 12); - TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, 1), 2); + TEST_EQ(sizes[2], 20); + TEST_EQ(dvxFontSizes("Test Sans", 0, sizes, 1), 3); + + BitmapFontT *added = dvxFontOpen("Test Sans", 20, 0); + BitmapFontT *first = dvxFontOpen("Test Sans", 12, 0); + + TEST_EQ(added->pixelSize, 20); + TEST_TRUE(added->glyphs['Z'].width > 0); + TEST_EQ(first->glyphs['Q'].width, 0); + dvxFontClose(added); + dvxFontClose(first); TEST_EQ(dvxFontSizes("Test Sans", FONT_STYLE_BOLD, sizes, TEXT_SIZES_MAX), 1); TEST_EQ(sizes[0], 12); - TEST_EQ(dvxFontSizes("Test Sans", FONT_STYLE_ITALIC | FONT_STYLE_UNDERLINE, sizes, TEXT_SIZES_MAX), 2); + TEST_EQ(dvxFontSizes("Test Sans", FONT_STYLE_ITALIC | FONT_STYLE_UNDERLINE, sizes, TEXT_SIZES_MAX), 3); TEST_EQ(dvxFontSizes("Nope", 0, sizes, TEXT_SIZES_MAX), 0); TEST_EQ(dvxFontSizes(NULL, 0, sizes, TEXT_SIZES_MAX), 1); TEST_EQ(sizes[0], FONT_CHAR_HEIGHT); diff --git a/src/test/unit/testLayout.c b/src/test/unit/testLayout.c index 78a57fff..2da73bcf 100644 --- a/src/test/unit/testLayout.c +++ b/src/test/unit/testLayout.c @@ -178,11 +178,12 @@ static void testHBoxAndNested(void) { TEST_EQ(a->h, row->h - DEFAULT_PADDING * 2); TEST_EQ(b->y, row->y + DEFAULT_PADDING); - // Cross-axis preferred size centres the child. - b->prefH = wgtPixels(LEAF_MIN); + // A cross-axis cap centres the child. + b->maxH = wgtPixels(LEAF_MIN); wgtLayout(root, LAYOUT_W, LAYOUT_H, &dvxFont8x16); TEST_EQ(b->h, LEAF_MIN); TEST_EQ(b->y, row->y + DEFAULT_PADDING + (row->h - DEFAULT_PADDING * 2 - LEAF_MIN) / 2); + b->maxH = 0; // Deeper nesting: a box inside c inherits c's rect. WidgetT *inner = leaf(c, 5, 5); diff --git a/src/test/unit/testPlatform.c b/src/test/unit/testPlatform.c index a943bcc0..28f7a985 100644 --- a/src/test/unit/testPlatform.c +++ b/src/test/unit/testPlatform.c @@ -433,6 +433,7 @@ static void testFolders(void) { TEST_STR_EQ(platformKindName(DvxKindFontsE), DVX_FONTS_DIR); TEST_STR_EQ(platformKindName(DvxKindWpaperE), DVX_WPAPER_DIR); TEST_STR_EQ(platformKindName(DvxKindBanksE), DVX_BANKS_DIR); + TEST_STR_EQ(platformKindName(DvxKindSaversE), DVX_SAVERS_DIR); TEST_PTR_NULL(platformKindName(DvxKindCountE)); TEST_PTR_NULL(platformKindName((DvxKindE)-1)); TEST_TRUE(!platformKindDir(out, sizeof(out), DvxKindCountE, true)); diff --git a/src/test/widgets/testMisc.c b/src/test/widgets/testMisc.c index 8514e9d3..a2e5543b 100644 --- a/src/test/widgets/testMisc.c +++ b/src/test/widgets/testMisc.c @@ -665,18 +665,10 @@ static void caseRichLabel(void) { // The red run sits in the middle of the line TEST_EQ(rectCount(f.win, rl->x, rl->y + FONT_CHAR_HEIGHT, (rl->w - textWidth(&tCtx()->font, "ef")) / 2, FONT_CHAR_HEIGHT, red), 0); - // Too narrow for "ab cd": cd wraps - rl->prefW = wgtPixels(RICH_WRAP_COLS * FONT_CHAR_WIDTH); - wgtInvalidate(rl); - wtSettle(); - TEST_EQ(rl->calcMinW, RICH_WRAP_COLS * FONT_CHAR_WIDTH); - TEST_EQ(rl->calcMinH, 3 * FONT_CHAR_HEIGHT); - - // The same with only minW (what BASIC's Width sets): cd wraps there - // too, painted as measured although the layout stretches the widget - // wider (the red ef on the third line) - rl->prefW = 0; - rl->minW = wgtPixels(RICH_WRAP_COLS * FONT_CHAR_WIDTH); + // Too narrow for "ab cd" at its width hint (minW, what BASIC's Width + // sets): cd wraps, painted as measured although the layout stretches + // the widget wider (the red ef on the third line) + rl->minW = wgtPixels(RICH_WRAP_COLS * FONT_CHAR_WIDTH); wgtInvalidate(rl); wtSettle(); TEST_EQ(rl->calcMinW, RICH_WRAP_COLS * FONT_CHAR_WIDTH); @@ -685,6 +677,13 @@ static void caseRichLabel(void) { TEST_EQ(rectCount(f.win, rl->x, rl->y + FONT_CHAR_HEIGHT, rl->w, FONT_CHAR_HEIGHT, red), 0); TEST_TRUE(rectCount(f.win, rl->x, rl->y + 2 * FONT_CHAR_HEIGHT, rl->w, FONT_CHAR_HEIGHT, red) > 0); + // With maxW at the hint too it is no wider than that + rl->maxW = rl->minW; + wgtInvalidate(rl); + wtSettle(); + TEST_EQ(rl->w, RICH_WRAP_COLS * FONT_CHAR_WIDTH); + rl->maxW = 0; + // Empty: one line of its font tall, nothing wide, the style and // color back to the control's rl->minW = 0; diff --git a/src/tools/Makefile b/src/tools/Makefile index 2797e887..156917a4 100644 --- a/src/tools/Makefile +++ b/src/tools/Makefile @@ -31,8 +31,10 @@ include ../common.mk LIBDVX = ../libs/kpunch/libdvx +MUSIC = ../libs/kpunch/music +AUDIO = ../libs/kpunch/audio INCLUDES = -I$(LIBDVX) -I$(LIBDVX)/platform -HOST_INCS = $(INCLUDES) -I$(FREETYPE_DIR)/include/freetype2 +HOST_INCS = $(INCLUDES) -I$(FREETYPE_DIR)/include/freetype2 -I$(MUSIC) -I$(AUDIO) # Host tools use HOSTCC, never CC, so an analyze.sh / CI override of CC # (to a cross/analyzer compiler) cannot cross-compile them or contaminate @@ -66,13 +68,14 @@ MKWGTICON_SRCS = mkwgticon.c bmpDraw.c BMP2RAW_SRCS = bmp2raw.c MKSOUNDS_SRCS = mkSounds.c MKFONT_SRCS = mkfont.c bmpDraw.c $(LIBDVX)/dvxFontFmt.c $(LIBDVX)/dvxPrefs.c $(SUPPORT_SRCS) -ALL_SRCS = $(sort $(DVXRES_SRCS) $(DVXHLPC_SRCS) $(MKICON_SRCS) $(MKTBICON_SRCS) $(MKWGTICON_SRCS) $(BMP2RAW_SRCS) $(MKSOUNDS_SRCS) $(MKFONT_SRCS)) +MKSF_SRCS = mksf.c $(MUSIC)/musicSf2.c $(MUSIC)/musicCents.c $(LIBDVX)/dvxPrefs.c $(SUPPORT_SRCS) +ALL_SRCS = $(sort $(DVXRES_SRCS) $(DVXHLPC_SRCS) $(MKICON_SRCS) $(MKTBICON_SRCS) $(MKWGTICON_SRCS) $(BMP2RAW_SRCS) $(MKSOUNDS_SRCS) $(MKFONT_SRCS) $(MKSF_SRCS)) # hostObjs / dosObjs SRCS -> objects for SRCS hostObjs = $(patsubst %.c,$(HOBJDIR)/%.o,$(notdir $1)) dosObjs = $(patsubst %.c,$(DOBJDIR)/%.o,$(notdir $1)) -HOST_TOOLS = $(HOSTDIR)/dvxres $(HOSTDIR)/mkicon $(HOSTDIR)/mktbicon $(HOSTDIR)/mkwgticon $(HOSTDIR)/bmp2raw $(HOSTDIR)/dvxhlpc $(HOSTDIR)/mksounds $(HOSTDIR)/mkfont +HOST_TOOLS = $(HOSTDIR)/dvxres $(HOSTDIR)/mkicon $(HOSTDIR)/mktbicon $(HOSTDIR)/mkwgticon $(HOSTDIR)/bmp2raw $(HOSTDIR)/dvxhlpc $(HOSTDIR)/mksounds $(HOSTDIR)/mkfont $(HOSTDIR)/mksf DOS_TOOLS = $(SYSTEMDIR)/SPLASH.RAW $(SYSTEMDIR)/DVXHLPC.EXE $(SYSTEMDIR)/DVXRES.EXE # The default desktop sounds (DVX.INI [sounds] names them), made by @@ -89,10 +92,23 @@ FONTDIR = $(BINDIR)/shared/fonts FONT_LIST = ../fonts/fonts.ini FONT_STAMP = ../../obj/tools/fonts.stamp +# Add-ons with fonts (src/addons/KIND/NAME/fonts.ini): their .DFN files +# made into obj/addons/NAME/SHARED/FONTS/KPUNCH, which mkDist packs in +# place of the add-on's folder; a stamp beside each stands for its set. +ADDON_DIR = ../../obj/addons +ADDON_FONT_DIRS = $(patsubst %/fonts.ini,%,$(wildcard ../addons/*/*/fonts.ini)) +ADDON_FONT_STAMPS = $(foreach d,$(ADDON_FONT_DIRS),$(ADDON_DIR)/$(notdir $(d)).stamp) + +# Add-ons with a SoundFont (src/addons/KIND/NAME/soundfont.ini): the +# add-on's own files (its SHARED folder) and the SoundFont mksf makes from +# the pinned source, in obj/addons/NAME, packed the same way. +ADDON_SF_DIRS = $(patsubst %/soundfont.ini,%,$(wildcard ../addons/*/*/soundfont.ini)) +ADDON_SF_STAMPS = $(foreach d,$(ADDON_SF_DIRS),$(ADDON_DIR)/$(notdir $(d)).stamp) + ifeq ($(COV),1) ALL = $(HOST_TOOLS) else -ALL = $(HOST_TOOLS) $(DOS_TOOLS) $(SOUNDS) $(FONT_STAMP) +ALL = $(HOST_TOOLS) $(DOS_TOOLS) $(SOUNDS) $(FONT_STAMP) $(ADDON_FONT_STAMPS) $(ADDON_SF_STAMPS) endif .PHONY: all host clean deploy-helpsrc @@ -125,6 +141,9 @@ $(HOSTDIR)/mksounds: $(call hostObjs,$(MKSOUNDS_SRCS)) | $(HOSTDIR) $(HOSTDIR)/mkfont: $(call hostObjs,$(MKFONT_SRCS)) $(FREETYPE_DIR)/lib/libfreetype.a | $(HOSTDIR) $(HOSTCC) $(HOSTLDFLAGS) -o $@ $^ +$(HOSTDIR)/mksf: $(call hostObjs,$(MKSF_SRCS)) | $(HOSTDIR) + $(HOSTCC) $(HOSTLDFLAGS) -o $@ $^ -lm + $(SOUNDDIR)/%.wav: $(HOSTDIR)/mksounds | $(SOUNDDIR) $(HOSTDIR)/mksounds $* $@ @@ -134,6 +153,29 @@ $(FONT_STAMP): $(HOSTDIR)/mkfont $(FONT_LIST) $(FONT_SOURCES)/.installed $(HOSTDIR)/mkfont $(FONT_LIST) $(FONT_SOURCES) $(FONTDIR) touch $@ +# $(1) = an add-on folder with a fonts.ini +define ADDON_FONT_RULES +$(ADDON_DIR)/$(notdir $(1)).stamp: $(1)/fonts.ini $(HOSTDIR)/mkfont $(FONT_SOURCES)/.installed + rm -rf $(ADDON_DIR)/$(notdir $(1)) $$@ + mkdir -p $(ADDON_DIR)/$(notdir $(1))/SHARED/FONTS + $(HOSTDIR)/mkfont $(1)/fonts.ini $(FONT_SOURCES) $(ADDON_DIR)/$(notdir $(1))/SHARED/FONTS/KPUNCH + touch $$@ +endef + +$(foreach d,$(ADDON_FONT_DIRS),$(eval $(call ADDON_FONT_RULES,$(d)))) + +# $(1) = an add-on folder with a soundfont.ini +define ADDON_SF_RULES +$(ADDON_DIR)/$(notdir $(1)).stamp: $(1)/soundfont.ini $(wildcard $(1)/SHARED/BANKS/KPUNCH/*) $(HOSTDIR)/mksf $(SOUNDFONT_SOURCES)/.installed + rm -rf $(ADDON_DIR)/$(notdir $(1)) $$@ + mkdir -p $(ADDON_DIR)/$(notdir $(1)) + cp -r $(1)/SHARED $(ADDON_DIR)/$(notdir $(1))/ + $(HOSTDIR)/mksf $(1)/soundfont.ini $(SOUNDFONT_SOURCES) $(ADDON_DIR)/$(notdir $(1))/SHARED/BANKS/KPUNCH + touch $$@ +endef + +$(foreach d,$(ADDON_SF_DIRS),$(eval $(call ADDON_SF_RULES,$(d)))) + $(SYSTEMDIR)/SPLASH.RAW: $(HOSTDIR)/bmp2raw ../../assets/splash.bmp | $(SYSTEMDIR) $(HOSTDIR)/bmp2raw ../../assets/splash.bmp $@ diff --git a/src/tools/README.md b/src/tools/README.md index 7d6d1939..c0cfdd38 100644 --- a/src/tools/README.md +++ b/src/tools/README.md @@ -410,6 +410,26 @@ Setup's (`src/installer/setupFonts.ini`), and those a program carries (an app folder's `fonts.ini`, made into `obj/apps//fonts` before the program is built). The SDK ships it in `bin/`. +## afmWidths.py -- PostScript Font Widths + +Makes `src/libs/kpunch/print/printPsAfm.c`, the widths of the twelve +text faces every PostScript printer has (Helvetica, Times, and Courier, +each upright, bold, slanted, and both), from Adobe's Core 14 AFM files. +The printing library measures text on vector pages with them, so it +lines up as the printer prints it. Not part of the build: run it again +only to change what the table keeps. + +``` +python3 src/tools/afmWidths.py AFMDIR src/libs/kpunch/print/printPsAfm.c +``` + +AFMDIR holds the AFM files and Adobe's `MustRead.html` (on Ubuntu, +`/usr/share/ruby-prawn/fonts`). The output keeps each face's widths in +ISOLatin1Encoding order, its ascender, descender, x-height, and +underline, the files' copyright notices, and Adobe's licence paragraph, +which allows a modified copy when the change is noted, as the file's +header does. + ## bascomp -- DVX BASIC Compiler (host) Command-line compiler for DVX BASIC projects. Takes a `.dbp` project diff --git a/src/tools/afmWidths.py b/src/tools/afmWidths.py new file mode 100644 index 00000000..e2e61368 --- /dev/null +++ b/src/tools/afmWidths.py @@ -0,0 +1,155 @@ +# 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. + +# afmWidths.py -- make src/libs/kpunch/print/printPsAfm.c from Adobe's +# Core 14 AFM files: the character widths, in ISOLatin1Encoding order, of +# the twelve text faces every PostScript printer has (Helvetica, Times, +# Courier, each upright, bold, oblique or italic, and bold oblique or +# bold italic), with each face's ascender, descender, x-height, and +# underline position and thickness. +# +# afmWidths.py AFMDIR OUT.c +# +# AFMDIR holds Helvetica.afm ... Courier-BoldOblique.afm and MustRead.html +# (Adobe's Core 14 AFM files, e.g. /usr/share/ruby-prawn/fonts). Glyphs +# a face lacks get width 0. + +import re +import sys + +FIRST = 32 +FACES = [ + 'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique', + 'Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic', + 'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique', +] +PER_LINE = 16 +WRAP = 76 + +# ISOLatin1Encoding's glyph names (PostScript Level 2) from code FIRST: +# the codes below it are control codes, which the table leaves out. +LATIN1 = [ + 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quoteright', + 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'minus', 'period', 'slash', 'zero', + 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'colon', 'semicolon', + 'less', 'equal', 'greater', 'question', 'at', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'bracketleft', + 'backslash', 'bracketright', 'asciicircum', 'underscore', 'quoteleft', 'a', 'b', 'c', 'd', 'e', + 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', + 'y', 'z', 'braceleft', 'bar', 'braceright', 'asciitilde', '.notdef', '.notdef', '.notdef', + '.notdef', '.notdef', '.notdef', '.notdef', '.notdef', '.notdef', '.notdef', '.notdef', + '.notdef', '.notdef', '.notdef', '.notdef', '.notdef', '.notdef', 'dotlessi', 'grave', 'acute', + 'circumflex', 'tilde', 'macron', 'breve', 'dotaccent', 'dieresis', '.notdef', 'ring', + 'cedilla', '.notdef', 'hungarumlaut', 'ogonek', 'caron', 'space', 'exclamdown', 'cent', + 'sterling', 'currency', 'yen', 'brokenbar', 'section', 'dieresis', 'copyright', 'ordfeminine', + 'guillemotleft', 'logicalnot', 'hyphen', 'registered', 'macron', 'degree', 'plusminus', + 'twosuperior', 'threesuperior', 'acute', 'mu', 'paragraph', 'periodcentered', 'cedilla', + 'onesuperior', 'ordmasculine', 'guillemotright', 'onequarter', 'onehalf', 'threequarters', + 'questiondown', 'Agrave', 'Aacute', 'Acircumflex', 'Atilde', 'Adieresis', 'Aring', 'AE', + 'Ccedilla', 'Egrave', 'Eacute', 'Ecircumflex', 'Edieresis', 'Igrave', 'Iacute', 'Icircumflex', + 'Idieresis', 'Eth', 'Ntilde', 'Ograve', 'Oacute', 'Ocircumflex', 'Otilde', 'Odieresis', + 'multiply', 'Oslash', 'Ugrave', 'Uacute', 'Ucircumflex', 'Udieresis', 'Yacute', 'Thorn', + 'germandbls', 'agrave', 'aacute', 'acircumflex', 'atilde', 'adieresis', 'aring', 'ae', + 'ccedilla', 'egrave', 'eacute', 'ecircumflex', 'edieresis', 'igrave', 'iacute', 'icircumflex', + 'idieresis', 'eth', 'ntilde', 'ograve', 'oacute', 'ocircumflex', 'otilde', 'odieresis', + 'divide', 'oslash', 'ugrave', 'uacute', 'ucircumflex', 'udieresis', 'yacute', 'thorn', + 'ydieresis', +] + + +def readAfm(path): + widths = {} + keys = {} + for line in open(path, encoding='latin-1'): + m = re.match(r'C\s+-?\d+\s*;\s*WX\s+(\d+)\s*;\s*N\s+(\S+)', line) + if m: + widths[m.group(2)] = int(m.group(1)) + continue + m = re.match(r'(Ascender|Descender|XHeight|UnderlinePosition|UnderlineThickness|Notice)\s+(.*)$', line.rstrip('\n')) + if m: + keys[m.group(1)] = m.group(2) + return widths, keys + + +# Text as comment lines of at most WRAP columns, each starting with lead. +def wrap(text, lead): + lines = [] + line = '' + for w in text.split(): + if line and len(lead) + len(line) + 1 + len(w) > WRAP: + lines.append(lead + line) + line = '' + line = line + ' ' + w if line else w + lines.append(lead + line) + return lines + + +def main(): + afmDir, outPath = sys.argv[1:3] + + licence = re.sub(r'<[^>]+>', ' ', open(afmDir + '/MustRead.html', encoding='latin-1').read()) + licence = re.search(r'This file and the 14 PostScript.*?is not modified\.', ' '.join(licence.split())).group(0) + faces = [readAfm('%s/%s.afm' % (afmDir, f)) for f in FACES] + notices = [] + for widths, keys in faces: + notice = re.sub(r'\.(?=[A-Z])', '. ', keys['Notice']) + if notice not in notices: + notices.append(notice) + + out = [] + out.append('// printPsAfm.c -- Printing library: the widths of the PostScript') + out.append('// printers\' own text faces (printInt.h), made by src/tools/afmWidths.py') + out.append('// -- do not edit.') + out.append('//') + out.append('// Modified from Adobe\'s Core 14 AFM files: only the character widths (in') + out.append('// thousandths of the font size, in ISOLatin1Encoding order from code %d),' % FIRST) + out.append('// the ascender, descender, x-height, and underline of twelve faces are') + out.append('// kept, as C tables. The files\' notices:') + for notice in notices: + out.append('//') + out.extend(wrap(notice, '// ')) + out.append('//') + out.append('// The licence they come with (MustRead.html):') + out.append('//') + out.extend(wrap(licence, '// ')) + out.append('') + out.append('#include "printInt.h"') + out.append('') + out.append('const PrintPsFaceT printPsFaces[PRINT_PS_FACES] = {') + + for f, (widths, keys) in zip(FACES, faces): + row = [widths.get(name, 0) for name in LATIN1] + out.append(' {') + out.append(' "%s", %s, %s, %s, %s, %s,' % (f, keys['Ascender'], keys['Descender'], keys['XHeight'], keys['UnderlinePosition'], keys['UnderlineThickness'])) + out.append(' {') + for i in range(0, len(row), PER_LINE): + out.append(' ' + ', '.join('%4d' % w for w in row[i:i + PER_LINE]) + ',') + out[-1] = out[-1].rstrip(',') + out.append(' }') + out.append(' },') + + out[-1] = ' }' + out.append('};') + open(outPath, 'w').write('\n'.join(out) + '\n') + + +main() diff --git a/src/tools/mksf.c b/src/tools/mksf.c new file mode 100644 index 00000000..3fbcd3e9 --- /dev/null +++ b/src/tools/mksf.c @@ -0,0 +1,1047 @@ +// 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. + +// mksf.c -- make a small General MIDI SoundFont for wavetable sound +// cards, on the build host +// +// mksf MANIFEST SOURCEDIR OUTDIR +// +// A wavetable card has little sample memory (256 KB to 1 MB) and DVX +// loads only what a song plays (musicWaveLoad.c), so a bank for it needs +// small instruments. mksf makes one from a large General MIDI SoundFont: +// the 128 instruments of bank 0 and the standard drum kit (bank 128, +// program 0), each made to fit a size: +// +// One layer only the regions that play the manifest's velocity (the +// loudest layer where none does), the whole velocity range +// given to them. +// Mono of a stereo pair only the left sample, panned centre. +// One sound a key plays one region (the loudest where layers +// overlap); an instrument keeps at most a few of them, +// spread over its keys, each widened to its neighbours'. +// Short samples resampled down (a windowed-sinc low-pass, +// Lanczos 3; a looping one at a rate a little off the +// step's that keeps its loop a whole number of frames, the +// frames lined up on the loop's start); a sample that does +// not loop cut short and faded out; a long loop cut to a +// whole number of periods of its note, of the counts near +// the length wanted the one whose end joins its start +// best; the attack before a loop cut short; every loop's +// end blended over XFADE_MS into what comes before its +// start, so the sound runs on across the join; samples end +// with their loop's first frames again (for the chip's +// interpolation) and the 46 zero frames the specification +// asks for. +// +// Each instrument takes the first step (sMelodicSteps; each drum key +// sDrumSteps) whose samples fit its budget; the last step is taken +// whatever it comes to. Samples are shared where two regions make the +// same one. Every region's generators are kept as resolved by +// musicSf2.c (the preset's added to the instrument's), so the result +// plays as the source did, but for the cuts. Each preset is one +// instrument; there are no modulators (DVX plays the default ones). +// +// The manifest (src/addons/banks/dvxgm/soundfont.ini) is an INI file, +// section [soundfont]: +// source the SoundFont to make it from, a file under SOURCEDIR +// output the file to make in OUTDIR +// name the bank's name (INAM) +// copyright its copyright notice (ICOP) +// comment its comment (ICMT) +// velocity the velocity whose layer is kept (default 100) +// presetBytes each instrument's budget in bytes (16-bit samples) +// drumBytes each drum key's budget +// A summary goes to stdout; any error stops the run with a message. + +#include "musicInt.h" +#include "dvxPlat.h" +#include "dvxPrefs.h" +#include "thirdparty/stb_ds_wrap.h" + +#include +#include +#include +#include +#include + +#define SECTION "soundfont" +#define VELOCITY_DEFAULT 100 +#define PRESET_BYTES_DEF 40960 +#define DRUM_BYTES_DEF 12288 +#define KEYS 128 +#define MS_PER_SEC 1000 +#define FRAME_BYTES 2 // 16-bit samples +#define GUARD_FRAMES 8 // after a loop's end, its first frames again +#define PAD_FRAMES 46 // zero frames after each sample (the specification's least) +#define FADE_MS 20 // a cut sample fades out over its last +#define MATCH_HALF 8 // frames each side compared where a loop joins +#define LOOP_TWEAK 2 // frames a loop's end may move to join better +#define PERIOD_TRIES 3 // period counts tried each side of the nearest when a loop is cut +#define XFADE_MS 5 // a loop's end blended into what comes before its start +#define LANCZOS_A 3 +#define PI 3.14159265358979323846 +#define PICK_MAX 8 // key regions an instrument keeps, at most (the steps' largest) +#define A4_KEY 69 +#define A4_HZ 440.0 +#define SEMITONES 12.0 +#define COARSE_FRAMES 32768 // a coarse address offset's unit +#define LOOP_MODE_MASK 3 +#define GM_KIT 0 // the standard kit's program +#define NAME_BYTES 20 +#define GEN_RANGE_SHIFT 8 // keyRange: low byte, high byte +#define SF_MAJOR 2 +#define SF_MINOR 1 +#define SF_ENGINE "EMU8000" +#define SF_TOOL "mksf" +#define POINTS 4 // start, end, loop start, loop end +#define PT_START 0 +#define PT_END 1 +#define PT_LOOP_START 2 +#define PT_LOOP_END 3 + +// Hydra record sizes (the specification's) +#define MOD_BYTES 10 +#define GEN_BYTES 4 +#define SHDR_BYTES 46 + +// A step of the cuts: an instrument takes the first whose samples fit +typedef struct { + uint32_t rate; // Hz at most + int32_t regions; // key regions at most + uint32_t oneShotMs; // a sample that does not loop, at most + uint32_t attackMs; // what plays before a loop, at most + uint32_t loopMs; // a loop longer than this is cut to about this +} StepT; + +static const StepT sMelodicSteps[] = { + { 22050, 4, 1500, 500, 200 }, + { 22050, 3, 1000, 300, 120 }, + { 16000, 3, 800, 250, 100 }, + { 16000, 2, 600, 200, 80 }, + { 11025, 2, 400, 150, 60 }, + { 11025, 1, 300, 100, 40 } +}; + +static const StepT sDrumSteps[] = { + { 22050, 1, 800, 100, 100 }, + { 16000, 1, 500, 100, 100 }, + { 11025, 1, 300, 50, 50 }, + { 11025, 1, 150, 50, 50 } +}; + +#define MELODIC_STEPS ((int32_t)(sizeof(sMelodicSteps) / sizeof(sMelodicSteps[0]))) +#define DRUM_STEPS ((int32_t)(sizeof(sDrumSteps) / sizeof(sDrumSteps[0]))) + +// A sample made for the output, and what it was made from (to share it) +typedef struct { + int16_t *frames; // stb_ds, guard frames included + uint32_t loopStart; + uint32_t loopEnd; + uint32_t rate; + uint32_t src; // the source sample + uint32_t from[POINTS]; // its points as the region plays them + const StepT *step; + uint8_t rootKey; + int8_t correction; + bool loops; +} OutSampleT; + +typedef struct { + int16_t gen[SF_GEN_COUNT]; + uint32_t sample; // in sSamples + uint8_t keyLo; + uint8_t keyHi; +} OutRegionT; + +typedef struct { + char name[NAME_BYTES + 1]; + uint16_t bank; + uint16_t program; + OutRegionT *regions; // stb_ds +} OutPresetT; + +// A run of keys one source region plays +typedef struct { + const WaveRegionT *region; + uint8_t keyLo; + uint8_t keyHi; +} SlotT; + +static WaveBankT sSrc; +static OutSampleT *sSamples = NULL; // stb_ds +static OutPresetT *sPresets = NULL; // stb_ds +static int32_t sVelocity; +static int32_t sStepUse[MELODIC_STEPS]; +static int32_t sDrumStepUse[DRUM_STEPS]; + +// ============================================================ +// Prototypes +// ============================================================ + +static void addPreset(const WavePresetT *p, uint32_t budget); +static void chunkBegin(uint8_t **out, const char *id, uint32_t *at); +static void chunkEnd(uint8_t **out, uint32_t at); +static void crossfade(int16_t *x, uint32_t loopStart, uint32_t loopEnd, uint32_t frames); +static void die(const char *fmt, ...) __attribute__((format(printf, 1, 2), noreturn)); +static void fitSlots(OutPresetT *out, const SlotT *slots, int32_t count, const StepT *steps, int32_t stepCount, uint32_t budget, int32_t *use); +static uint32_t frameBytes(const OutSampleT *s); +static int32_t keyMap(const WavePresetT *p, SlotT **slots); +static double lanczos(double x); +static uint32_t loopJoin(const int16_t *x, uint32_t n, uint32_t loopStart, uint32_t want, uint32_t spread, double *err); +static uint32_t loopShorten(const int16_t *x, uint32_t n, uint32_t loopStart, uint32_t want, double period); +static uint32_t makeSample(const WaveRegionT *r, const StepT *st); +static int32_t pickSlots(const SlotT *slots, int32_t count, int32_t max, SlotT *picked); +static void putBytes(uint8_t **out, const void *p, uint32_t n); +static void putName(uint8_t **out, const char *name, uint32_t bytes); +static void putU16(uint8_t **out, uint32_t v); +static void putU32(uint8_t **out, uint32_t v); +static void regionPoints(const WaveRegionT *r, uint32_t *pts); +static int16_t *resample(const int16_t *in, uint32_t n, double ratio, double offset, uint32_t *outN); +static bool skipGen(int32_t g, int16_t value); +static void writeBank(PrefsHandleT *m, const char *path); +int main(int argc, char **argv); + + +// The preset's regions (one layer, mono, a key map), fitted to budget: +// drum keys each to drumBytes, an instrument as a whole. +static void addPreset(const WavePresetT *p, uint32_t budget) { + OutPresetT out; + SlotT *slots = NULL; + int32_t count = keyMap(p, &slots); + bool drums = p->bank == SF_DRUM_BANK; + + memset(&out, 0, sizeof(out)); + snprintf(out.name, sizeof(out.name), "%s", p->name); + out.bank = p->bank; + out.program = p->program; + + if (drums) { + for (int32_t i = 0; i < count; i++) { + fitSlots(&out, &slots[i], 1, sDrumSteps, DRUM_STEPS, budget, sDrumStepUse); + } + } else if (count > 0) { + fitSlots(&out, slots, count, sMelodicSteps, MELODIC_STEPS, budget, sStepUse); + } + + arrfree(slots); + + if (arrlen(out.regions) == 0) { + fprintf(stderr, "mksf: %s (%u:%u) has nothing to play; left out\n", p->name, (unsigned)p->bank, (unsigned)p->program); + return; + } + + arrput(sPresets, out); +} + + +// A chunk's id and a length to fill in (chunkEnd), at *at. +static void chunkBegin(uint8_t **out, const char *id, uint32_t *at) { + *at = (uint32_t)arrlen(*out); + putBytes(out, id, 4); + putU32(out, 0); +} + + +// The chunk begun at at ends here (padded to an even length). +static void chunkEnd(uint8_t **out, uint32_t at) { + uint32_t len = (uint32_t)arrlen(*out) - at - 8; + + if (len & 1) { + arrput(*out, 0); + } + + (*out)[at + 4] = (uint8_t)len; + (*out)[at + 5] = (uint8_t)(len >> 8); + (*out)[at + 6] = (uint8_t)(len >> 16); + (*out)[at + 7] = (uint8_t)(len >> 24); +} + + +// The frames before a loop's end blended into those before its start, +// all of the latter at the end, so the frame before the end is the one +// before the start and the sound runs on across the join. +static void crossfade(int16_t *x, uint32_t loopStart, uint32_t loopEnd, uint32_t frames) { + for (uint32_t i = 0; i < frames; i++) { + double w = (i + 1.0) / frames; + uint32_t a = loopEnd - frames + i; + uint32_t b = loopStart - frames + i; + + x[a] = (int16_t)lround(x[a] * (1.0 - w) + x[b] * w); + } +} + + +static void die(const char *fmt, ...) { + va_list ap; + + va_start(ap, fmt); + fprintf(stderr, "mksf: "); + vfprintf(stderr, fmt, ap); + fprintf(stderr, "\n"); + va_end(ap); + exit(1); +} + + +// The slots' samples at the first step that fits budget (the last one +// whatever it comes to), as regions of out. Samples a step made that it +// does not keep are given back. +static void fitSlots(OutPresetT *out, const SlotT *slots, int32_t count, const StepT *steps, int32_t stepCount, uint32_t budget, int32_t *use) { + SlotT *picked = (SlotT *)malloc((size_t)count * sizeof(SlotT)); + + if (!picked) { + die("out of memory"); + } + + for (int32_t s = 0; s < stepCount; s++) { + int32_t made = (int32_t)arrlen(sSamples); + int32_t kept = (int32_t)arrlen(out->regions); + int32_t n = pickSlots(slots, count, steps[s].regions, picked); + uint32_t bytes = 0; + + for (int32_t i = 0; i < n; i++) { + OutRegionT r; + bool seen = false; + + memcpy(r.gen, picked[i].region->gen, sizeof(r.gen)); + r.sample = makeSample(picked[i].region, &steps[s]); + r.keyLo = picked[i].keyLo; + r.keyHi = picked[i].keyHi; + + for (int32_t j = kept; j < (int32_t)arrlen(out->regions); j++) { + seen = seen || out->regions[j].sample == r.sample; + } + + bytes += seen ? 0 : frameBytes(&sSamples[r.sample]); + arrput(out->regions, r); + } + + if (bytes <= budget || s == stepCount - 1) { + use[s]++; + break; + } + + // Too big: this step's work undone + for (int32_t i = made; i < (int32_t)arrlen(sSamples); i++) { + arrfree(sSamples[i].frames); + } + + arrsetlen(sSamples, made); + arrsetlen(out->regions, kept); + } + + free(picked); +} + + +static uint32_t frameBytes(const OutSampleT *s) { + return (uint32_t)arrlen(s->frames) * FRAME_BYTES; +} + + +// The preset's key map: each key the loudest of the regions that play +// the velocity layer kept (only left samples where the preset has stereo +// pairs), runs of keys on one region as slots. How many. +static int32_t keyMap(const WavePresetT *p, SlotT **slots) { + const WaveRegionT *best[KEYS]; + bool left = false; + bool layer = false; + int32_t velHi = -1; + + for (uint32_t i = 0; i < p->count; i++) { + const WaveRegionT *r = &sSrc.regions[p->first + i]; + + left = left || (sSrc.samples[r->sample].type & SF_SAMPLE_LEFT); + layer = layer || (sVelocity >= r->velLo && sVelocity <= r->velHi); + velHi = r->velHi > velHi ? r->velHi : velHi; + } + + memset(best, 0, sizeof(best)); + + for (uint32_t i = 0; i < p->count; i++) { + const WaveRegionT *r = &sSrc.regions[p->first + i]; + uint16_t type = sSrc.samples[r->sample].type; + bool in = layer ? (sVelocity >= r->velLo && sVelocity <= r->velHi) : r->velHi == velHi; + + if (!in || (left && (type & SF_SAMPLE_RIGHT)) || (type & SF_SAMPLE_ROM)) { + continue; + } + + for (int32_t k = r->keyLo; k <= r->keyHi; k++) { + if (!best[k] || r->gen[SF_GEN_ATTENUATION] < best[k]->gen[SF_GEN_ATTENUATION]) { + best[k] = r; + } + } + } + + for (int32_t k = 0; k < KEYS; k++) { + SlotT *last = arrlen(*slots) ? &(*slots)[arrlen(*slots) - 1] : NULL; + + if (!best[k]) { + continue; + } + + if (last && last->region == best[k] && last->keyHi == k - 1) { + last->keyHi = (uint8_t)k; + } else { + SlotT s = { best[k], (uint8_t)k, (uint8_t)k }; + + arrput(*slots, s); + } + } + + return (int32_t)arrlen(*slots); +} + + +// The Lanczos window (a = LANCZOS_A) times sinc. +static double lanczos(double x) { + if (x == 0.0) { + return 1.0; + } + + if (fabs(x) >= LANCZOS_A) { + return 0.0; + } + + double px = PI * x; + + return LANCZOS_A * sin(px) * sin(px / LANCZOS_A) / (px * px); +} + + +// Where a loop from loopStart should end, from want - spread to want + +// spread: where the frames about it are most like those about its start +// (*err: how unlike, the sum of the squared differences; negative when +// there is no room to look). +static uint32_t loopJoin(const int16_t *x, uint32_t n, uint32_t loopStart, uint32_t want, uint32_t spread, double *err) { + uint32_t lo = want > spread ? want - spread : 0; + uint32_t hi = want + spread; + uint32_t best = want; + + *err = -1.0; + + if (n < 2 * MATCH_HALF) { + return want; + } + + lo = lo > loopStart + MATCH_HALF ? lo : loopStart + MATCH_HALF; + hi = hi + MATCH_HALF < n ? hi : n - MATCH_HALF; + + for (uint32_t e = lo; e <= hi; e++) { + double sum = 0.0; + + for (int32_t j = -MATCH_HALF; j < MATCH_HALF; j++) { + int64_t a = (int64_t)loopStart + j; + double d = (a >= 0 ? x[a] : 0) - x[(int64_t)e + j]; + + sum += d * d; + } + + if (*err < 0.0 || sum < *err) { + *err = sum; + best = e; + } + } + + return best; +} + + +// A loop from loopStart cut to a whole number of periods of its note, +// about want frames: of the counts near that, the end that joins its +// start best. +static uint32_t loopShorten(const int16_t *x, uint32_t n, uint32_t loopStart, uint32_t want, double period) { + int32_t near = (int32_t)lround(want / period); + uint32_t best = loopStart + (uint32_t)lround(period); + double bestErr = -1.0; + + for (int32_t k = near - PERIOD_TRIES; k <= near + PERIOD_TRIES; k++) { + double err; + uint32_t e = k < 1 ? 0 : loopJoin(x, n, loopStart, loopStart + (uint32_t)lround(k * period), LOOP_TWEAK, &err); + + if (k >= 1 && err >= 0.0 && (bestErr < 0.0 || err < bestErr)) { + bestErr = err; + best = e; + } + } + + return best; +} + + +// Region r's sample cut at step st (one already made the same way is +// shared); its index in sSamples. Its root key is the one the region +// plays it at (its overriding root key, if it has one). +static uint32_t makeSample(const WaveRegionT *r, const StepT *st) { + const WaveSampleT *src = &sSrc.samples[r->sample]; + uint32_t pts[POINTS]; + uint8_t mode = (uint8_t)(r->gen[SF_GEN_SAMPLE_MODES] & LOOP_MODE_MASK); + uint8_t root = (uint8_t)(r->gen[SF_GEN_ROOT_KEY] >= 0 ? r->gen[SF_GEN_ROOT_KEY] : src->rootKey); + OutSampleT s; + + regionPoints(r, pts); + + for (int32_t i = 0; i < (int32_t)arrlen(sSamples); i++) { + OutSampleT *o = &sSamples[i]; + + if (o->src == r->sample && o->step == st && o->rootKey == root && memcmp(o->from, pts, sizeof(pts)) == 0) { + return (uint32_t)i; + } + } + + memset(&s, 0, sizeof(s)); + s.src = r->sample; + s.step = st; + s.rootKey = root; + s.correction = src->correction; + s.loops = (mode == SF_LOOP_ON || mode == SF_LOOP_RELEASE) && pts[PT_LOOP_END] > pts[PT_LOOP_START]; + s.rate = src->rate; + memcpy(s.from, pts, sizeof(pts)); + + uint32_t n = pts[PT_END] - pts[PT_START]; + int16_t *raw = (int16_t *)malloc((n ? n : 1) * sizeof(int16_t)); + + if (!raw || !waveBankRead(&sSrc, pts[PT_START], n, raw)) { + die("cannot read sample %u of %s", (unsigned)r->sample, sSrc.path); + } + + uint32_t ls = pts[PT_LOOP_START] - pts[PT_START]; + uint32_t le = pts[PT_LOOP_END] - pts[PT_START]; + + // A lower rate: a loop stays a whole number of frames (the rate moved + // a little to make it so), the frames lined up on its start. + if (src->rate > st->rate) { + double ratio = (double)st->rate / src->rate; + double offset = 0.0; + + s.rate = st->rate; + + if (s.loops) { + uint32_t len = (uint32_t)lround((le - ls) * ratio); + uint32_t at; + + len = len ? len : 1; + s.rate = (uint32_t)lround((double)src->rate * len / (le - ls)); + ratio = (double)s.rate / src->rate; + at = (uint32_t)lround(ls * ratio); + offset = ls - at / ratio; + ls = at; + le = at + len; + } + + int16_t *low = resample(raw, n, ratio, offset, &n); + + free(raw); + raw = low; + le = le < n ? le : n; + } + + uint32_t keep; + + s.loops = s.loops && le > ls; + + if (s.loops) { + double period = s.rate / (A4_HZ * pow(2.0, (s.rootKey - A4_KEY) / SEMITONES)); + uint32_t attack = st->attackMs * s.rate / MS_PER_SEC; + uint32_t want = st->loopMs * s.rate / MS_PER_SEC; + uint32_t fade = XFADE_MS * s.rate / MS_PER_SEC; + double err; + + // A long attack starts its loop early; a long loop is cut + ls = ls > attack ? attack : ls; + + if (le - ls > want + (uint32_t)period) { + le = loopShorten(raw, n, ls, want, period); + } else if (le - ls > 2 * MATCH_HALF) { + le = loopJoin(raw, n, ls, le, LOOP_TWEAK, &err); + } + + fade = fade < ls ? fade : ls; + fade = fade < (le - ls) / 2 ? fade : (le - ls) / 2; + crossfade(raw, ls, le, fade); + keep = le; + } else { + uint32_t most = st->oneShotMs * s.rate / MS_PER_SEC; + + keep = n; + + // Cut: faded out over its end + if (most < n) { + uint32_t fade = FADE_MS * s.rate / MS_PER_SEC; + + keep = most; + fade = fade < keep ? fade : keep; + + for (uint32_t i = 0; i < fade; i++) { + raw[keep - fade + i] = (int16_t)(raw[keep - fade + i] * (int32_t)(fade - i) / (int32_t)fade); + } + } + } + + for (uint32_t i = 0; i < keep; i++) { + arrput(s.frames, raw[i]); + } + + // After a loop its first frames again (round again for a short one); + // after a sample silence + for (uint32_t i = 0; i < GUARD_FRAMES; i++) { + arrput(s.frames, s.loops ? raw[ls + i % (le - ls)] : 0); + } + + free(raw); + s.loopStart = s.loops ? ls : 0; + s.loopEnd = s.loops ? le : 0; + arrput(sSamples, s); + return (uint32_t)arrlen(sSamples) - 1; +} + + +// At most max of the slots, spread over them, each widened over the keys +// of the slots left out nearest it; how many. +static int32_t pickSlots(const SlotT *slots, int32_t count, int32_t max, SlotT *picked) { + int32_t n = count < max ? count : max; + int32_t mids[PICK_MAX]; + + for (int32_t i = 0; i < n; i++) { + int32_t at = n == 1 ? count / 2 : (int32_t)lround((double)i * (count - 1) / (n - 1)); + + picked[i] = slots[at]; + mids[i] = (slots[at].keyLo + slots[at].keyHi) / 2; + } + + // Every slot's keys go to the picked slot whose keys are nearest + for (int32_t s = 0; s < count; s++) { + int32_t mid = (slots[s].keyLo + slots[s].keyHi) / 2; + int32_t near = 0; + + for (int32_t i = 1; i < n; i++) { + near = abs(mids[i] - mid) < abs(mids[near] - mid) ? i : near; + } + + picked[near].keyLo = slots[s].keyLo < picked[near].keyLo ? slots[s].keyLo : picked[near].keyLo; + picked[near].keyHi = slots[s].keyHi > picked[near].keyHi ? slots[s].keyHi : picked[near].keyHi; + } + + return n; +} + + +static void putBytes(uint8_t **out, const void *p, uint32_t n) { + for (uint32_t i = 0; i < n; i++) { + arrput(*out, ((const uint8_t *)p)[i]); + } +} + + +// A name in a fixed field of bytes, zero filled. +static void putName(uint8_t **out, const char *name, uint32_t bytes) { + uint32_t len = (uint32_t)strlen(name); + + for (uint32_t i = 0; i < bytes; i++) { + arrput(*out, i < len ? (uint8_t)name[i] : 0); + } +} + + +static void putU16(uint8_t **out, uint32_t v) { + arrput(*out, (uint8_t)v); + arrput(*out, (uint8_t)(v >> 8)); +} + + +static void putU32(uint8_t **out, uint32_t v) { + putU16(out, v & 0xFFFF); + putU16(out, v >> 16); +} + + +// Region r's sample points as it plays them (its address offsets added, +// kept inside the sample), in frames of the source. +static void regionPoints(const WaveRegionT *r, uint32_t *pts) { + const WaveSampleT *s = &sSrc.samples[r->sample]; + const int16_t *g = r->gen; + int64_t len = (int64_t)s->end - s->start; + int64_t start = g[SF_GEN_START_OFFSET] + (int64_t)g[SF_GEN_START_COARSE] * COARSE_FRAMES; + int64_t end = len + g[SF_GEN_END_OFFSET] + (int64_t)g[SF_GEN_END_COARSE] * COARSE_FRAMES; + int64_t ls = (int64_t)s->loopStart - s->start + g[SF_GEN_LOOP_START_OFFSET] + (int64_t)g[SF_GEN_LOOP_START_COARSE] * COARSE_FRAMES; + int64_t le = (int64_t)s->loopEnd - s->start + g[SF_GEN_LOOP_END_OFFSET] + (int64_t)g[SF_GEN_LOOP_END_COARSE] * COARSE_FRAMES; + + start = start < 0 ? 0 : (start > len ? len : start); + end = end < start ? start : (end > len ? len : end); + ls = ls < start ? start : (ls > end ? end : ls); + le = le < ls ? ls : (le > end ? end : le); + + pts[PT_START] = s->start + (uint32_t)start; + pts[PT_END] = s->start + (uint32_t)end; + pts[PT_LOOP_START] = s->start + (uint32_t)ls; + pts[PT_LOOP_END] = s->start + (uint32_t)le; +} + + +// in (n frames) at ratio times its rate (below one), frame i taken at +// offset + i / ratio: each frame a Lanczos-windowed sinc low-pass at the +// new rate's half, its weights summing to one. +static int16_t *resample(const int16_t *in, uint32_t n, double ratio, double offset, uint32_t *outN) { + double span = (n - offset) * ratio; + uint32_t m = span > 0.0 ? (uint32_t)span : 0; + int32_t width = (int32_t)ceil(LANCZOS_A / ratio); + int16_t *out = (int16_t *)malloc((m ? m : 1) * sizeof(int16_t)); + + if (!out) { + die("out of memory"); + } + + for (uint32_t i = 0; i < m; i++) { + double t = offset + i / ratio; + int64_t c = (int64_t)floor(t); + double sum = 0.0; + double wsum = 0.0; + + for (int64_t k = c - width + 1; k <= c + width; k++) { + double w = lanczos((t - k) * ratio); + int64_t j = k < 0 ? 0 : (k >= n ? n - 1 : k); + + sum += in[j] * w; + wsum += w; + } + + double v = wsum != 0.0 ? sum / wsum : 0.0; + + out[i] = (int16_t)(v > INT16_MAX ? INT16_MAX : (v < INT16_MIN ? INT16_MIN : lround(v))); + } + + *outN = m; + return out; +} + + +// A generator the output leaves out: at its default, written apart (the +// ranges, the sample), meaningless after the cuts (address offsets), or +// not a generator. +static bool skipGen(int32_t g, int16_t value) { + switch (g) { + case SF_GEN_START_OFFSET: + case SF_GEN_END_OFFSET: + case SF_GEN_LOOP_START_OFFSET: + case SF_GEN_LOOP_END_OFFSET: + case SF_GEN_START_COARSE: + case SF_GEN_END_COARSE: + case SF_GEN_LOOP_START_COARSE: + case SF_GEN_LOOP_END_COARSE: + case SF_GEN_INSTRUMENT: + case SF_GEN_KEY_RANGE: + case SF_GEN_VEL_RANGE: + case SF_GEN_SAMPLE_ID: + return true; + + default: + return value == sf2GenDefault(g); + } +} + + +// The bank as a SoundFont 2.01 file at path. +static void writeBank(PrefsHandleT *m, const char *path) { + uint8_t *out = NULL; + uint32_t riff; + uint32_t list; + uint32_t ck; + uint32_t at = 0; + + chunkBegin(&out, "RIFF", &riff); + putBytes(&out, "sfbk", 4); + + // INFO + chunkBegin(&out, "LIST", &list); + putBytes(&out, "INFO", 4); + chunkBegin(&out, "ifil", &ck); + putU16(&out, SF_MAJOR); + putU16(&out, SF_MINOR); + chunkEnd(&out, ck); + + const char *info[][2] = { + { "isng", SF_ENGINE }, + { "INAM", prefsGetString(m, SECTION, "name", "") }, + { "ICOP", prefsGetString(m, SECTION, "copyright", "") }, + { "ICMT", prefsGetString(m, SECTION, "comment", "") }, + { "ISFT", SF_TOOL } + }; + + for (uint32_t i = 0; i < sizeof(info) / sizeof(info[0]); i++) { + chunkBegin(&out, info[i][0], &ck); + putBytes(&out, info[i][1], (uint32_t)strlen(info[i][1]) + 1); + chunkEnd(&out, ck); + } + + chunkEnd(&out, list); + + // sdta: every sample, then its padding + chunkBegin(&out, "LIST", &list); + putBytes(&out, "sdta", 4); + chunkBegin(&out, "smpl", &ck); + + for (int32_t i = 0; i < (int32_t)arrlen(sSamples); i++) { + for (int32_t f = 0; f < (int32_t)arrlen(sSamples[i].frames); f++) { + putU16(&out, (uint16_t)sSamples[i].frames[f]); + } + + for (int32_t f = 0; f < PAD_FRAMES; f++) { + putU16(&out, 0); + } + } + + chunkEnd(&out, ck); + chunkEnd(&out, list); + + // pdta: a preset per instrument, one zone each + chunkBegin(&out, "LIST", &list); + putBytes(&out, "pdta", 4); + chunkBegin(&out, "phdr", &ck); + + for (int32_t i = 0; i < (int32_t)arrlen(sPresets); i++) { + putName(&out, sPresets[i].name, NAME_BYTES); + putU16(&out, sPresets[i].program); + putU16(&out, sPresets[i].bank); + putU16(&out, (uint32_t)i); + putU32(&out, 0); + putU32(&out, 0); + putU32(&out, 0); + } + + putName(&out, "EOP", NAME_BYTES); + putU16(&out, 0); + putU16(&out, 0); + putU16(&out, (uint32_t)arrlen(sPresets)); + putU32(&out, 0); + putU32(&out, 0); + putU32(&out, 0); + chunkEnd(&out, ck); + chunkBegin(&out, "pbag", &ck); + + for (int32_t i = 0; i <= (int32_t)arrlen(sPresets); i++) { + putU16(&out, (uint32_t)i); + putU16(&out, 0); + } + + chunkEnd(&out, ck); + chunkBegin(&out, "pmod", &ck); + putName(&out, "", MOD_BYTES); + chunkEnd(&out, ck); + chunkBegin(&out, "pgen", &ck); + + for (int32_t i = 0; i < (int32_t)arrlen(sPresets); i++) { + putU16(&out, SF_GEN_INSTRUMENT); + putU16(&out, (uint32_t)i); + } + + putU32(&out, 0); + chunkEnd(&out, ck); + + // The instruments: their zones, each zone's generators + chunkBegin(&out, "inst", &ck); + + for (int32_t i = 0; i < (int32_t)arrlen(sPresets); i++) { + putName(&out, sPresets[i].name, NAME_BYTES); + putU16(&out, at); + at += (uint32_t)arrlen(sPresets[i].regions); + } + + putName(&out, "EOI", NAME_BYTES); + putU16(&out, at); + chunkEnd(&out, ck); + + uint8_t *gens = NULL; + uint32_t zone = 0; + + chunkBegin(&out, "ibag", &ck); + + for (int32_t i = 0; i < (int32_t)arrlen(sPresets); i++) { + for (int32_t z = 0; z < (int32_t)arrlen(sPresets[i].regions); z++, zone++) { + const OutRegionT *r = &sPresets[i].regions[z]; + const OutSampleT *s = &sSamples[r->sample]; + + putU16(&out, (uint32_t)arrlen(gens) / GEN_BYTES); + putU16(&out, 0); + putU16(&gens, SF_GEN_KEY_RANGE); + putU16(&gens, r->keyLo | (uint32_t)r->keyHi << GEN_RANGE_SHIFT); + + for (int32_t g = 0; g < SF_GEN_COUNT; g++) { + int16_t v = r->gen[g]; + + // Stereo halves play alone, centred; cut samples loop + // for as long as they sound + if (g == SF_GEN_PAN && (sSrc.samples[s->src].type & (SF_SAMPLE_LEFT | SF_SAMPLE_RIGHT))) { + v = 0; + } else if (g == SF_GEN_SAMPLE_MODES) { + v = s->loops ? SF_LOOP_ON : SF_LOOP_NONE; + } + + if (!skipGen(g, v)) { + putU16(&gens, (uint32_t)g); + putU16(&gens, (uint16_t)v); + } + } + + putU16(&gens, SF_GEN_SAMPLE_ID); + putU16(&gens, r->sample); + } + } + + putU16(&out, (uint32_t)arrlen(gens) / GEN_BYTES); + putU16(&out, 0); + chunkEnd(&out, ck); + chunkBegin(&out, "imod", &ck); + putName(&out, "", MOD_BYTES); + chunkEnd(&out, ck); + putU32(&gens, 0); + chunkBegin(&out, "igen", &ck); + putBytes(&out, gens, (uint32_t)arrlen(gens)); + chunkEnd(&out, ck); + arrfree(gens); + + // The samples, where they are in smpl (a sample's end after its guard + // frames: a loop has its points to read past its end) + chunkBegin(&out, "shdr", &ck); + at = 0; + + for (int32_t i = 0; i < (int32_t)arrlen(sSamples); i++) { + const OutSampleT *s = &sSamples[i]; + uint32_t n = (uint32_t)arrlen(s->frames); + char name[NAME_BYTES + 1]; + + snprintf(name, sizeof(name), "S%u", (unsigned)i); + putName(&out, name, NAME_BYTES); + putU32(&out, at); + putU32(&out, at + n); + putU32(&out, at + (s->loops ? s->loopStart : 0)); + putU32(&out, at + (s->loops ? s->loopEnd : 0)); + putU32(&out, s->rate); + arrput(out, s->rootKey); + arrput(out, (uint8_t)s->correction); + putU16(&out, 0); + putU16(&out, SF_SAMPLE_MONO); + at += (uint32_t)arrlen(s->frames) + PAD_FRAMES; + } + + putName(&out, "EOS", NAME_BYTES); + putName(&out, "", SHDR_BYTES - NAME_BYTES); + chunkEnd(&out, ck); + chunkEnd(&out, list); + chunkEnd(&out, riff); + + FILE *f = fopen(path, "wb"); + + if (!f || fwrite(out, 1, (size_t)arrlen(out), f) != (size_t)arrlen(out) || fclose(f) != 0) { + die("cannot write %s", path); + } + + arrfree(out); +} + + +int main(int argc, char **argv) { + if (argc != 4) { + fprintf(stderr, "usage: mksf MANIFEST SOURCEDIR OUTDIR\n"); + return 2; + } + + PrefsHandleT *m = prefsLoad(argv[1]); + char path[DVX_MAX_PATH]; + + if (!m || !prefsGetString(m, SECTION, "source", NULL) || !prefsGetString(m, SECTION, "output", NULL)) { + die("%s has no [%s] source and output", argv[1], SECTION); + } + + snprintf(path, sizeof(path), "%s/%s", argv[2], prefsGetString(m, SECTION, "source", "")); + + if (sf2Load(path, &sSrc) != MUSIC_OK) { + die("%s is missing or not a SoundFont 2 file", path); + } + + sVelocity = prefsGetInt(m, SECTION, "velocity", VELOCITY_DEFAULT); + + uint32_t presetBytes = (uint32_t)prefsGetInt(m, SECTION, "presetBytes", PRESET_BYTES_DEF); + uint32_t drumBytes = (uint32_t)prefsGetInt(m, SECTION, "drumBytes", DRUM_BYTES_DEF); + + // Bank 0's instruments in program order, then the standard kit + for (uint32_t prog = 0; prog < KEYS; prog++) { + for (uint32_t i = 0; i < sSrc.presetCount; i++) { + if (sSrc.presets[i].bank == 0 && sSrc.presets[i].program == prog) { + addPreset(&sSrc.presets[i], presetBytes); + } + } + } + + const WavePresetT *kit = NULL; + + for (uint32_t i = 0; i < sSrc.presetCount && !kit; i++) { + kit = sSrc.presets[i].bank == SF_DRUM_BANK && sSrc.presets[i].program == GM_KIT ? &sSrc.presets[i] : NULL; + } + + if (!kit) { + die("%s has no standard drum kit", path); + } + + addPreset(kit, drumBytes); + + if (platformMkdirRecursive(argv[3]) != 0) { + die("cannot make %s", argv[3]); + } + + snprintf(path, sizeof(path), "%s/%s", argv[3], prefsGetString(m, SECTION, "output", "")); + writeBank(m, path); + + uint64_t bytes = 0; + + for (int32_t i = 0; i < (int32_t)arrlen(sSamples); i++) { + bytes += frameBytes(&sSamples[i]); + } + + printf("mksf: %s: %d presets, %d samples, %llu KB of samples; instruments by step:", path, (int)arrlen(sPresets), (int)arrlen(sSamples), (unsigned long long)(bytes / 1024)); + + for (int32_t s = 0; s < MELODIC_STEPS; s++) { + printf(" %d", (int)sStepUse[s]); + } + + printf("; drum keys by step:"); + + for (int32_t s = 0; s < DRUM_STEPS; s++) { + printf(" %d", (int)sDrumStepUse[s]); + } + + printf("\n"); + + for (int32_t i = 0; i < (int32_t)arrlen(sSamples); i++) { + arrfree(sSamples[i].frames); + } + + for (int32_t i = 0; i < (int32_t)arrlen(sPresets); i++) { + arrfree(sPresets[i].regions); + } + + arrfree(sSamples); + arrfree(sPresets); + waveBankFree(&sSrc); + prefsClose(m); + return 0; +} diff --git a/src/widgets/kpunch/richLabel/richlbl.dhs b/src/widgets/kpunch/richLabel/richlbl.dhs index 88b9f29c..124d17d9 100644 --- a/src/widgets/kpunch/richLabel/richlbl.dhs +++ b/src/widgets/kpunch/richLabel/richlbl.dhs @@ -30,7 +30,7 @@ .h2 RichLabel -Text in many fonts, styles, and colors, wrapped and aligned paragraph by paragraph. The text is built by calls: wgtRichLabelSetFont, wgtRichLabelSetStyle, and wgtRichLabelSetColor choose what the following wgtRichLabelAddText uses; wgtRichLabelAddLineBreak starts a new line and wgtRichLabelAddParagraph a new paragraph with its own alignment. Text starts in the widget's own font (wgtSetFont, or its container's). The widget is laid out at its width hint (prefW, else minW, which BASIC's Width sets), wrapping at spaces (a word too wide for a line breaks inside it); without one each paragraph is one line. Every line is as tall as the tallest font on it, its text standing on one baseline. Not focusable; drawn the same enabled or not. It fills its rectangle as a Label does: with the nearest explicit bgColor, else the content background. Built on the text flow in libdvx (dvxFlow.h). +Text in many fonts, styles, and colors, wrapped and aligned paragraph by paragraph. The text is built by calls: wgtRichLabelSetFont, wgtRichLabelSetStyle, and wgtRichLabelSetColor choose what the following wgtRichLabelAddText uses; wgtRichLabelAddLineBreak starts a new line and wgtRichLabelAddParagraph a new paragraph with its own alignment. Text starts in the widget's own font (wgtSetFont, or its container's). The widget is laid out at its width hint (minW, which BASIC's Width sets), wrapping at spaces (a word too wide for a line breaks inside it); without one each paragraph is one line. Every line is as tall as the tallest font on it, its text standing on one baseline. Not focusable; drawn the same enabled or not. It fills its rectangle as a Label does: with the nearest explicit bgColor, else the content background. Built on the text flow in libdvx (dvxFlow.h). Header: widgets/richLbl.h diff --git a/src/widgets/kpunch/richLabel/widgetRichLabel.c b/src/widgets/kpunch/richLabel/widgetRichLabel.c index 6c356ce3..4ab0edfa 100644 --- a/src/widgets/kpunch/richLabel/widgetRichLabel.c +++ b/src/widgets/kpunch/richLabel/widgetRichLabel.c @@ -29,8 +29,8 @@ // a line and AddParagraph a paragraph with its alignment; Clear empties // it. Text starts in the control's own font (FontName and the rest, or // its container's); SetFont "" goes back to it, a size of 0 keeps the -// control's size. It wraps at its width hint (prefW, else minW: BASIC's -// Width); without one each paragraph is one line. Not focusable, not +// control's size. It wraps at its width hint (minW, which BASIC's Width +// sets); without one each paragraph is one line. Not focusable, not // interactive, drawn the same enabled or not (its runs keep their // colors). Paint fills the background as a Label does (the nearest // explicit BackColor, else the content background). @@ -238,11 +238,10 @@ void widgetRichLabelPaint(WidgetT *w, DisplayT *d, const BlitOpsT *ops, const Bi } -// The width the text wraps at: its width hint (prefW, else minW: BASIC's -// Width sets minW), else FLOW_WIDTH_NATURAL +// The width the text wraps at: its width hint (minW, which BASIC's Width +// sets), else FLOW_WIDTH_NATURAL static int32_t wrapWidth(const WidgetT *w, const BitmapFontT *font) { - int32_t hint = w->prefW ? w->prefW : w->minW; - int32_t wrapW = hint ? wgtResolveSize(hint, 0, font->charWidth) : 0; + int32_t wrapW = w->minW ? wgtResolveSize(w->minW, 0, font->charWidth) : 0; return (wrapW > 0) ? wrapW : FLOW_WIDTH_NATURAL; } diff --git a/src/widgets/kpunch/wgtsys.dhs b/src/widgets/kpunch/wgtsys.dhs index c4f43816..0d4a9a33 100644 --- a/src/widgets/kpunch/wgtsys.dhs +++ b/src/widgets/kpunch/wgtsys.dhs @@ -47,9 +47,8 @@ Every widget inherits from the WidgetT structure defined in libdvx/dvxWgt.h. The ----- ---- ----------- name char[32] Widget name for lookup via wgtFind(). x, y, w, h int32_t Computed geometry relative to the window content area (set by layout). - minW, minH int32_t (tagged) Minimum size hints. Use wgtPixels(), wgtChars(), or wgtPercent(). 0 = auto. - maxW, maxH int32_t (tagged) Maximum size constraints. 0 = no limit. - prefW, prefH int32_t (tagged) Preferred size. 0 = auto. + minW, minH int32_t (tagged) Minimum size hints: the widget is never laid out smaller. Use wgtPixels(), wgtChars(), or wgtPercent(). 0 = auto. A fixed width in a row (a button's, a column of labels) is minW with weight 0. + maxW, maxH int32_t (tagged) Maximum size constraints: the widget never grows larger; across its box a capped widget is centred. 0 = no limit. weight int32_t Extra-space distribution weight. 0 = fixed, 100 = normal. A widget with weight=200 gets twice the extra space of one with weight=100. align WidgetAlignE Main-axis alignment for children: AlignStartE, AlignCenterE, AlignEndE. spacing int32_t (tagged) Spacing between children (containers only). WGT_GAP_DEFAULT = the class's default; 0 = none.