A Visual Basic 6 executable was produced in one of two ways, and what decompilation yields depends entirely on which. The binary carries the answer: there is nothing to guess.

The fileA field in the project description structures, inside the executable, carries the compilation mode.
Field is zero → P-CodeBytecode interpreted by MSVBVM60.dll. Typed, high-level instructions.
Field is non-zero → Native Codex86 machine code. A pointer to the compiled code. Types and names to be rebuilt.

The compilation mode is written in the file

VB6 offers two targets, set in the project properties under Compile. The chosen mode is not inferred after the fact: it occupies a dedicated field, and a single test reads it.

Native Code is the compiler's default setting, and it is by far what one meets in applications that were actually shipped. The free edition of VBReFormer reads that field and shows the mode as soon as the file is opened — the quickest way to know what to expect before going further.

What each mode gives back

P-Code

Each bytecode instruction corresponds to a language operation. The structure of the code comes back almost in full: loops, conditions, calls, string and Variant operations are recovered in their original form.

Reconstruction is largely mechanical, because the instruction stated explicitly what it did.

Native Code

Types are no longer declared; they follow from the way values are handled. The runtime supplies most of the evidence, a VB6 program delegating to it nearly everything that is not elementary arithmetic.

The result is more uneven from one procedure to the next, and this is the mode that covers the large majority of the binaries we are sent.

These gaps can be measured. Here are the rates of translation into a named VB6 construct, each with its denominator:

P-Code instruction set1,158 / 1,164  ·  99.5%
Runtime functions — native code291 / 347  ·  83.9%
Runtime functions — P-Code65 / 91  ·  71.4%

Measured on 207 VB6 programs, 125 compiled to native code and 82 to P-Code. The name-by-name detail is published.

What the result looks like

Here is real output, taken from our case study on a VB6 CrackMe. A single excerpt shows at once what the binary kept and what it lost.

Decompiler output — P-Code binary
Private Sub Command1_Click()
    var_pv10 = Text1.Text
    var_pv13 = Date$ & " " & Time$
    For var_pv14 = 1 To Len(var_pv13) Step 1
        If IsNumeric(Mid$(var_pv13, CLng(var_pv14), 1)) Then
            var_pv15 = Asc(Mid$(var_pv13, CLng(var_pv14), 1))
            If var_pv14 <= Len(var_pv10) Then
                var_pv16 = Str(Asc(Mid$(var_pv10, CLng(var_pv14), 1)))
                var_pv16 = Right$(var_pv16, 1)
            End If
            var_pv18 = var_pv18 & Chr$(CLng(var_pv15 + 17 + var_pv16))
        End If
    Next var_pv14

In cyan, what was recovered from the binary: the control name and its event handler (Command1_Click), the control name and its property (Text1.Text), and the runtime functions rendered under their original VB6 names — Len, IsNumeric, Mid$, Asc, Right$, Chr$.

In red, what was lost: the names of local variables. The decompiler gives them an identifier derived from their location, for want of anything better.

The algorithm is entirely legible: the validation key is built from the text entered, the date and the time. No local variable name is needed to understand it — but they will have to be renamed before recompiling.

What survives, what does not

This split does not depend on the compilation mode. It is a property of the format.

Kept in the binary

  • Project, executable and help file names
  • Names of forms, modules and classes
  • Control names and their properties
  • Names of public procedures
  • Parameter names of public members
  • Names of public member variables
  • Name and library of Declare API calls
  • Implements interfaces, WithEvents variables

Absent from the binary

  • Names of local variables
  • Names of private procedures and variables
  • Declared types of locals and parameters
  • Constants and enumerations, folded onto their value
  • Member names of a Type
  • Parameter types and names of a Declare
  • Comments and original formatting

It is the left-hand column that makes the difference between a navigable project and a heap of anonymous functions. A form recovers its name, its controls recover theirs, and public procedures stay identifiable — which is usually enough to take an application back over.

The test that misleads

One method circulates for guessing the mode without reading the field meant for it: examine the method table and check whether its entries look like valid code addresses. It fails exactly where it should help.

In a native binary that table holds pointers installed by the runtime, which have every appearance of being correct addresses. Following them amounts to decoding machine code as if it were bytecode, and produces hundreds of entries that correspond to nothing.

This is why a native binary handed to a tool built for P-Code does not return a clean error but an abundant, wrong result — the most expensive kind of failure, since it takes time to notice.

In practice

Identify the mode before anything else: it determines what is reachable. With P-Code, expect the structure of the code back almost as written. With Native Code, expect reconstructed code, accurate in its calls and operations, with local variable names to reassign.

In both cases the user interface is recovered in full — forms, controls, properties, resources — because it is described by data rather than by code. That is often what is actually wanted when taking over an application whose source code has been lost.