Getting a VB6 application to build again, reproducibly

Most VB6 systems we are asked to modernize arrive with the same problem. The application runs in production, and nobody can build it. The developer who could left, the build machine was decommissioned, and the last compile was five years ago on a laptop that has since been reimaged.

That has to be fixed first, and it is not optional. Without a repeatable build you cannot make a control group. You cannot change one line to insert a seam, you cannot compare a modified binary against the shipped one, and you cannot honestly estimate a migration, because you cannot yet demonstrate that the source you were handed is the source that is running. We have twice found source trees that were missing a module the production executable clearly contained.

This tutorial is the sequence we use. Expect two to five days for a mid-sized application, longer if the dependency list includes controls whose vendor no longer exists. Nothing here changes the running system.

Step 1: read the project files before you install anything

The .vbp file is a text manifest, and it tells you most of what the build needs. Open it in a text editor:

Type=Exe
Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#..\..\Windows\SysWOW64\stdole2.tlb#OLE Automation
Object={831FDD16-0C5C-11D2-A9FC-0000F8754DA1}#2.2#0; MSCOMCTL.OCX
Object={F9043C88-F6F2-101A-A3C9-08002B2F49FB}#1.2#0; COMDLG32.OCX
Reference=*\G{420B2830-E718-11CF-893D-00A0C9054228}#1.0#0#..\..\Windows\SysWOW64\scrrun.dll#Microsoft Scripting Runtime
StartMode=0
MajorVer=4
MinorVer=2
RevisionVer=118
AutoIncrementVer=1

Collect three lists from every .vbp in the tree, including ActiveX DLL and OCX projects the executable depends on:

  1. Object= lines. These are ActiveX controls, each with a CLSID and a version. They must be present and registered on the build machine or the project will not load.
  2. Reference= lines. Type libraries and COM DLLs, again by CLSID and version.
  3. Compatible* and version settings. CompatibleMode, CompatibleEXE32, MajorVer/MinorVer/RevisionVer and AutoIncrementVer all affect the interface GUIDs of the binary you produce, which matters a great deal in the next step.

Write the lists down in a file you commit. This inventory is a deliverable in its own right: it is the first honest statement of what the system actually depends on. Our Classic ASP inventory tutorial does the equivalent job for that stack.

Step 2: build the build machine, as a VM

Use a virtual machine, not a developer's desktop. You will get the dependency set wrong two or three times, and you want to roll back rather than archaeologise a machine you have polluted.

What we do:

  • A Windows Server 2019 or Windows 10/11 x64 VM, licensed, snapshotted at every stage. The VB6 runtime is shipped with and supported on current Windows versions; the VB6 IDE and compiler have been out of support since 2008, so the IDE is not going to be blessed by anyone. It does install and it does work.
  • Visual Studio 6.0 with Service Pack 6, installed from the customer's own licensed media. This is a licensing question, not a technical one, and it belongs to the customer. If the media and the license cannot be found, stop and resolve that before spending money on anything else.
  • Install nothing else. No Office, no SQL Server client tools you have not proven you need, no antivirus exclusions you cannot document. Each addition you make without justification is a dependency you may be hiding.

Snapshot the VM immediately after SP6 and label it clean-vb6. Every experiment starts from there.

Step 3: register the dependencies deliberately

On a 64-bit Windows machine, VB6 components are 32-bit, so they live in C:\Windows\SysWOW64 and register into the 32-bit registry view. Register from that directory:

copy MSCOMCTL.OCX C:\Windows\SysWOW64\
C:\Windows\SysWOW64\regsvr32.exe C:\Windows\SysWOW64\MSCOMCTL.OCX

Two rules we hold to:

  • Take the binaries from production, not from the internet. Copy each OCX and DLL off a machine that runs the application today, and record its file version and hash. Downloaded copies of common controls are a well-known way to introduce a version mismatch that shows up months later as a subtly different date picker.
  • Record every registration in a script. A register-dependencies.cmd that runs against clean-vb6 and produces a working environment is the artifact you are trying to create. If the environment only exists as steps someone once performed, you have not finished.

When a project refuses to load with a missing-control error, the CLSID in the error maps to an Object= line from step 1. Work the list; do not click through the dialogs that offer to remove the control from the project, which silently deletes properties from your forms.

Step 4: compile from the command line

Interactive builds are not reproducible. VB6 compiles from the command line:

"C:\Program Files (x86)\Microsoft Visual Studio\VB98\VB6.EXE" /make "C:\src\Orders\Orders.vbp" /out "C:\build\build.log"
if exist "C:\build\build.log" type "C:\build\build.log"

Two things to know. VB6.EXE /make returns a non-zero exit code on failure, but it writes errors to the file given by /out rather than to the console, so your script must check and surface that file. And ActiveX DLL and OCX projects must be built and registered in dependency order before the executable that binds to them; a group file (.vbg) does not do this for you from the command line. Order the calls yourself in the script.

Before this build, set AutoIncrementVer=0 in each .vbp. Otherwise every compile bumps the revision number, your binaries never match twice, and your source tree develops a stream of one-line commits nobody asked for.

Step 5: prove the build matches production

A build that compiles is not yet a build you can trust. VB6 output is not bit-for-bit reproducible — the compiler stamps in identifiers that vary between compiles — so comparing hashes against the production executable will fail and tells you nothing. Compare what is meaningful instead:

  • File version and size. Should match the production binary closely. A 30% size difference means the source tree is not what shipped.
  • The public COM surface. For ActiveX DLLs, dump the type library from both your build and the production binary with OLE/COM Object Viewer (oleview.exe) and diff the IDL. Identical interfaces and identical GUIDs mean existing callers will bind to your build. Different GUIDs mean you have broken binary compatibility, usually because CompatibleEXE32 was not set to the shipped binary with CompatibleMode=2. Fix that before going further.
  • Behavior. Install your build on a test machine beside a restored copy of the production database and run the ten transactions that matter most. If you have a characterization harness, point it at both; see characterization tests for code nobody understands.

When all three agree, you have a baseline. Tag the source, snapshot the VM as vb6-baseline, and commit the build and registration scripts next to the source.

Step 6: put it under source control and make it boring

Get the tree into Git, with .frm, .frx, .bas, .cls, .vbp and .vbg all committed. .frx files are binary form resources, and they will not merge; note that in the README and keep one person on a form at a time. Exclude .exe, .dll and .ocx build output.

Then give the VM a job: run the build script on every commit, from a snapshot, and fail loudly. A 32-bit build agent on a VB6 VM is unglamorous and it works. This is the point at which the project stops being archaeology, because you can now change a line and see the consequence the same day.

What to watch for

  • Source that is not the source. The mismatch we quoted earlier is common enough that step 5 exists for it alone. If the type library diff or the file size disagrees, look for a second source tree on a share, a decompiled module, or a hotfix applied directly to production. Resolve it before estimating; an estimate built on the wrong source is not an estimate.
  • Dead vendors. Grid, scheduler and reporting controls from vendors that no longer exist can usually still be registered from production binaries, but you will not get a new license key or a 64-bit successor. Note each one now as a migration decision, because it will drive slice sequencing later.
  • Licensed design-time controls. Some OCXs require a design-time license key in the registry (HKCR\Licenses). The control runs fine in production and refuses to load in the IDE. You need the key from the original install media, which is one more reason step 2 starts with the customer's media.
  • The temptation to fix things. Do not tidy, reformat, upgrade or refactor anything during this work. The only deliverable is the same binary from a repeatable process. Everything you improve now is a variable in the comparison you are about to run.

This is unexciting work, and we would rather sell it than skip it. A VB6 modernization that begins without a repeatable build is estimated by reading code, and estimates from reading are guesses. Once the build is repeatable, the next step is a seam: usually COM interop, which we walk through in strangling a VB6 app.