16 KiB
VB6 → Modern Migration Playbook
A reusable guide for modernizing legacy Visual Basic 6 applications, distilled from the DA-12 Service Tool rebuild (VB6 → Python 3 + PySide6). Copy this file into each new migration project and adapt the app-specific parts.
The DA-12 tool is used throughout as the worked example, but the process, the VB6 quirk reference, and the packaging/testing patterns are general and apply to any VB6 desktop app — especially ones that talk to hardware, instruments, or a serial/ USB/TCP device.
1. Why these apps break (and what "done" means)
VB6 apps stop running on modern Windows not because the logic is bad but because the runtime and UI components are gone:
- VB6 runtime (
MSVBVM60.dll) — not present by default on modern Windows. - 32-bit ActiveX/OCX controls that must be
regsvr32-registered with admin rights. Common culprits:VSFLEX7L.OCX/VSFLEX8(grids),VSOCX6.OCX(layout/tabs),MSCOMM32.OCX(serial),RICHTX32.OCX(rich text),COMDLG32.OCX,MSWINSCK.OCX(sockets),TABCTL32.OCX. - Proprietary/vendor DLLs or type libraries (
.tlb/.dll) the app references.
"Done" therefore means: a self-contained app that installs and launches with no runtime/OCX registration, reproducing the workflows users know. Treat clean deployment as a first-class deliverable, not a final-day step — it's usually the entire reason for the rebuild.
Key early question for every app: does the vendor DLL/tlb do real work, or just helper functions? On the DA-12,
cimscan.tlbwas referenced but only provided time/format/hex helpers — none of the device I/O depended on it. Confirming this early (grep every reference, check call sites) determines whether you have a clean-room rebuild or a hard COM-interop problem.
2. Choosing the target stack
Two stacks cover almost every VB6 desktop app. Pick per project; document the call.
| Python + PySide6 (Qt) | C# / .NET (WPF or WinUI) | |
|---|---|---|
| Best when | The owner wants to read/tweak it; team is Python-ish; no dedicated devs | Deployment bulletproof-ness is paramount; .NET skills available |
| Serial/HW | pyserial (excellent) |
System.IO.Ports (excellent) |
| Grids/tabs | QTableWidget/QTabWidget map ~1:1 onto VSFlexGrid/vsElastic |
DataGrid/TabControl |
| Deployment | PyInstaller one-folder + Inno Setup; watch AV false-positives, larger bundles | dotnet publish self-contained single-file — gold standard, minimal AV friction |
| Maintainability for a non-dev owner | High (readable) | Lower (can't read C#) |
Capability is not the deciding factor — both do everything a VB6 line-of-business or instrument tool needs. The real trade-off is maintainability by whoever owns it vs deployment robustness. For CIMTechniques (PM owner, Python-literate, no in-house devs) we chose Python + PySide6 and made packaging a tracked deliverable to neutralize its one weakness. If a future app is safety-critical or distributed to many external customers with zero support tolerance, reconsider .NET for that one.
3. The migration process (phased)
This order front-loads risk (the protocol/business logic) and keeps the app runnable without the real device the whole way through.
- Reverse-engineer & spec. Read the
.frm/.bas/.clssource; document how it talks to its device/DB/peers and what every screen shows. Write a design spec. Explicitly list what you're dropping with source locations (see §7). - Pure core first (no I/O, no UI). Encode the wire/file/DB format as small, fully unit-tested functions. This is where reverse-engineering errors surface.
- Swappable transport + a simulator. Put the real I/O behind one interface and write a fake that speaks the same protocol. Now the whole app runs with no device.
- Domain layer. Models + a controller that wires transport → core → models and exposes high-level operations + change signals.
- UI. Thin views that render models and call controller methods on edit.
- Packaging + docs + external verification. Build the installer; write a hardware/DB verification checklist for the things only the real environment can confirm.
Commit per step, keep tests green, and keep a REBUILD-STATUS.md so anyone (or a
future session) can pick up.
4. Reading VB6 source
File types:
.vbp— project file. ListsObject=(OCX deps with GUIDs),Reference=(DLL/tlb deps), forms/modules, and version/metadata. Start here — it tells you the whole dependency surface..frm— a form: a UI-definition header (controls, properties, geometry in twips) followed by the event-handler code. The code is after theAttribute VB_Name = "..."line..frx— binary blob for a form (icons, images). Not human-readable..bas— standard module (shared procedures,DeclareWin32 APIs,Typedefs, globals)..cls— class module..vbw— window layout (ignore)..tmp/backup copies of forms may linger.
Where the logic lives: event handlers (Sub Control_Event()), plus Public/
Private Subs/Functions. For an instrument app, find the comms event handler (e.g.
MSComm1_OnComm) and the send routines — that's the protocol.
Practical tips: grep for (Private|Public) (Sub|Function) to map the procedures;
grep vendor-DLL function names across all files to see if they're load-bearing; the
UI-definition portion of large .frm files is verbose noise — skip to the code.
5. VB6 quirks → modern equivalents ⭐ (the reusable gold)
These are the gotchas that cause silent wrong-data bugs if mishandled. Verified on the DA-12 job.
Numeric & hex parsing
| VB6 | Behavior | Python equivalent / trap |
|---|---|---|
CInt("&H" & s) |
Parse hex as signed 16-bit. &HFFFF = −1, &H8000 = −32768 |
v=int(s,16)&0xFFFF; v-0x10000 if v&0x8000 else v. Don't use plain int(s,16) |
CLng("&H" & s) |
Parse hex as signed 32-bit | signed-32 helper; &HFFFFFFFF = −1 |
Int(x) |
Floors toward −∞ (−1.5 → −2) | Python int() truncates toward zero! Use math.floor — matters for negative values |
Fix(x) |
Truncates toward zero | Python int() |
Val(s) |
Lenient leading-numeric parse, never errors | write a tolerant parser; float() raises |
Hex$(n) |
Uppercase hex, no padding | format(n & MASK, "0NX") for fixed width (VB code often hand-pads via Hex2/Hex4/Hex8) |
Round(x, n) |
Banker's rounding (round-half-to-even) | Python round() is also banker's — matches. Format$ may differ; verify |
Strings (all 1-based!)
| VB6 | Note | Python |
|---|---|---|
Mid$(s, start, len) |
1-based start | s[start-1:start-1+len] |
Left$(s,n) / Right$(s,n) |
s[:n] / s[-n:] |
|
InStr(start, s, sub) |
1-based, returns 0 if not found | s.find(sub, start-1)+1 semantics — be careful |
Trim$/LTrim$/RTrim$ |
.strip()/.lstrip()/.rstrip() |
|
s$, n%, l&, x!, d# |
type-suffix: String, Integer(16), Long(32), Single, Double | dataclass fields / annotations |
Fixed-point & sentinels (instrument protocols)
- VB6 instrument code typically stores reals as scaled integers:
stored = real × 10^n, transmitted as hex. Decode =signed(hex) / 10^n. Each field can have a different scale — map them individually from the parse routine. - Watch for NaN/"no value" sentinels like
&H8000(−32768) or a literal"FFFF8000". CAUTION: the same byte pattern may be a sentinel in one routine and a real value in another. On the DA-12, the value/limit fields treated8000as "no value", but the scale/offset fields (AddFloat) did not —8000there is a legitimate −3.2768. Check every parse helper's sentinel logic separately; don't globalize it.
Control flow & errors
| VB6 | Meaning | Python |
|---|---|---|
On Error Resume Next |
Swallow errors, keep going | targeted try/except; don't blanket-swallow |
On Error GoTo Label |
Jump to handler | try/except block |
DoEvents in a Do…Loop |
Busy-wait pumping the UI | use real async / a reader thread / QTimer |
Type … End Type |
record struct | @dataclass |
Declare Function … Lib "user32" |
Win32 API call | ctypes, or a stdlib/Qt equivalent (often unnecessary) |
UI & components
| VB6 / OCX | Replacement (Qt) |
|---|---|
VSFlexGrid (grid) |
QTableWidget / QTableView + model |
vsElastic / SSTab / TabCtl (layout/tabs) |
Qt layouts + QTabWidget |
MSComm (serial OCX, _OnComm events) |
pyserial in a reader thread → signal |
RichTextBox (RICHTX32) |
QTextBrowser / QTextEdit |
MSWinsock (TCP) |
socket / QTcpSocket |
Control arrays (txt(0), txt(1)) |
a Python list/dict of widgets |
Predeclared form instances (frmMain global, PredeclaredId) |
explicit object passed in, not a global |
| Geometry in twips (1/1440") | ignore; use Qt layouts. (1 twip ≈ 1/15 px at 96 DPI) |
App.Path |
bundle-aware path; user data via QStandardPaths |
Binary settings files (.tab via Put/Get) |
JSON config |
Serial specifics (very common in these apps)
- Port settings string
"19200,n,8,1"→ pyserialSerial(port, 19200, bytesize=8, parity='N', stopbits=1, timeout=0.1). Mirror DTR/RTS if the VB set them. - Decode bytes as latin-1 if the protocol is ASCII-hex + control chars (lossless byte↔char) rather than utf-8.
_OnCommfires on received data; replicate with a daemon reader thread that appends to a buffer and pushes complete frames out.
6. Architecture pattern that worked
Four layers; the protocol/business core is pure and the device is swappable.
UI (Qt views) ──signals──> Controller (orchestration, QObject)
│ uses
Pure core (framing/parse/encode/codecs — NO I/O, 100% tested)
│ bytes
Transport interface ──> RealTransport (pyserial/socket/db)
└─> Simulator (same interface, fake device)
Why it pays off:
- Pure core = the reverse-engineering is testable with vectors derived from the VB6 source, before any hardware exists.
- Simulator = the whole UI runs with no device (demo, CI, headless tests). Make it deterministic (no randomness/realtime in test paths).
- One swappable interface means the real transport and the simulator are interchangeable, and the GUI never knows which it's talking to.
Qt threading tip: route the reader thread's data into the controller via a Qt signal connected with AutoConnection (the default): it delivers synchronously when emitted from the GUI thread (so the simulator and tests need no event loop) and queues onto the GUI thread when emitted from the reader thread (thread-safe). One line, both cases handled.
Edit-loop tip: when a table view repaints from the model, block its
itemChanged signal during repopulation so programmatic fills don't fire phantom
"user edited" events.
7. Document what you DROP (don't just delete)
Legacy apps accumulate obsolete features. When the owner confirms something is dead (on the DA-12: wireless sensors, an annunciator panel, a debug tab, factory-only serial-number writing), don't silently omit it — write a "Dropped / Deferred Features" section in the spec with, for each item:
- what it did,
- the exact legacy source locations (
file:linefor the handler, the UI controls, the inbound parse branch, the outbound command), - a one-line "how to restore" note.
This makes scope cuts auditable and cheap to reverse, and it protects you when "we don't use that anymore" turns out to be wrong six months later.
8. Testing strategy
- Unit-test the pure core hard. Hand-compute expected values from the VB6 logic and assert them. The signed-hex / scaling / sentinel rules (§5) are exactly where bugs hide — cover them explicitly.
- Integration-test through the simulator. Drive the controller with the fake device and assert round-trips (set a value → read it back).
- Headless UI smoke tests with
pytest-qtunderQT_QPA_PLATFORM=offscreen: instantiate each tab/dialog, feed model data, assert it renders and that edits call the controller. - Run a code review on the protocol layer before trusting it — a second pass
caught two silent wrong-data bugs on the DA-12 (the sentinel and
Int()-floor issues above) that all looked plausible. - You usually won't have ground-truth captures (the DA-12 repo's
Exceptions.txtwas just a crash log). The first real frames you capture from the device become your most valuable regression fixtures — save them.
9. Packaging & deployment (the actual point)
- PyInstaller one-folder build (not
--onefile): faster start and fewer AV false-positives than one-file. Exclude unused heavy modules (e.g. PySide6's QtQml/QtQuick/QtWebEngine/Multimedia) to shrink the bundle. - Inno Setup installer: per-machine install to Program Files, Start-menu
shortcut, no runtime/OCX registration. (Compiler
ISCC.exemust be installed.) - Antivirus / SmartScreen: unsigned PyInstaller exes get flagged — this is the #1 "customer can't run it" risk and exactly what you're trying to escape. Plan for an Authenticode code-signing certificate for both the exe and the installer.
- Test on a clean Windows VM with no Python/dev tools — that's the only way to catch missing-dependency failures before the customer does.
- Data/config locations: use
QStandardPaths(AppData/AppConfig) for config (JSON, replacing binary.tab/.ini), logs, and history files — never write next to the exe in Program Files.
10. External-dependency verification
Some things genuinely can't be confirmed from source — device clock epoch/format,
firmware enums, exact register/message maps, DB quirks. Isolate each unknown to a
small, clearly-commented spot (a single codec/helper) and write a
HARDWARE-VERIFICATION.md (or ENV-VERIFICATION.md) checklist: what to do, expected
result, and the exact fix-up location if it's wrong. This lets the owner finish
verification without spelunking the codebase.
10b. Consolidating multiple apps into one suite
If you're modernizing several VB6 apps for the same users on the same machines, strongly consider a unified app with each tool as a module on a shared core, rather than N separate apps. Customers then install/sign/update one thing — which is usually the whole point of the modernization.
Decision guide:
- Share a
corepackage regardless (transport base, codec primitives, UI table base, config, logging, packaging/signing). This is pure upside. - One unified shell when tools share users/machines, have no access boundaries, and can share a release cadence. Separate shells on the shared core when audiences, access levels, or update rhythms differ. (Same code reuse either way; the difference is runtime coupling.)
- Don't over-build the framework from one app. Extract only the obviously-generic
pieces into
core; let shared abstractions emerge with the 2nd/3rd module (rule of three). A thin module contract (id,title,create_widget(),shutdown(), each module owning its own device connection) is enough to start.
The DA-12 project is taking this path — see docs/SUITE-ARCHITECTURE.md for the
concrete target structure, module contract, and sequencing.
11. Per-app reuse checklist
- Read
.vbp; inventory OCX (Object=) and DLL/tlb (Reference=) deps. - Determine whether any vendor DLL/tlb does load-bearing work (grep call sites).
- Map procedures; locate the comms/DB handler and send/parse routines.
- Choose stack (Python+PySide6 default; .NET if deployment-criticality demands).
- Write spec incl. Dropped/Deferred Features with source locations.
- Build pure core + tests (mind §5 signed-hex/scale/sentinel/
Inttraps). - Build transport interface + simulator.
- Build domain (models + controller w/ AutoConnection signals).
- Build thin UI; block edit signals on repopulate.
- PyInstaller one-folder + Inno Setup; exclude unused modules.
- Code-signing cert; clean-VM install test.
*-VERIFICATION.mdchecklist for environment-only unknowns.REBUILD-STATUS.mdhandoff.
Source project: DA-12 Service Tool rebuild — see its docs/superpowers/specs/,
docs/superpowers/plans/, docs/HARDWARE-VERIFICATION.md, and the cim_suite/modules/da12/
package for concrete, working examples of every pattern above.