Reference version 1.0

VB6 file format — the structures a VB5/6 image carries

When you compile a Visual Basic 5 or 6 project, the IDE lays a graph of fixed structures into the PE, rooted at the VBHeader, that the MSVBVM runtime walks at load. This page documents those on-disk structures field by field: offset, size, type, meaning, and the gotchas that bite. It covers both P-code and native builds — the structures below are identical in both; only the method-dispatch layer and a few flags differ.

Offsets, sizes and the example values are captured from vbdec itself (its dump() and structIDC output over a loaded sample, PDFStreamDumper); field types and notes are drawn from the vbdec struct sources. Companion references: the P-code opcode reference (and its VB5 vs VB6 section), UDT_DESCRIPTORS.md, VB6_events and RTMI_FIELDS.md for the deeper layers.

Nesting map

How the structures reference each other (blue = the pointer/offset field that reaches the child):

PE image — OEP stub: push <VBHeader>; call ThunRTMain
VBHeader EXEPROJECTINFO, ~0x68 bytes
aProjectInfo → ProjectInfo
aObjectTable → ObjectTable
aProjectInfo2 → ProjectInfo2
lpObjectArray → tObject[ ObjectCount1 ] stride 0x30, one per form/class/module
tObject+0x00 aObjectInfo → ObjectInfo
lpMethods → ProcDscInfo[ NumberOfProcs ] VB6: flat array  |  VB5: mov-edx thunks
(+0x80 type) OptionalObjectInfo designer objects only
lpControls → Controls[ ControlCount ]
aMethodLinkTable → method-link thunks / events
aGuiTable → GuiTable[ ] forms & UserControls
aFormPointer → GUIObjectInfo + control property blob
aExternalComponentTable → ExternalComponent[ ExternalComponentCount ] OCX / referenced typelibs
aComRegisterData → ComRegData → ComRegInfo[ ] COM-exposed projects

How the loader walks it

  1. The PE entry point is a tiny stub that pushes the VBHeader VA and calls the runtime's ThunRTMain. That pushed pointer is how you find the VBHeader without guessing.
  2. From VBHeader the runtime reaches ProjectInfo (aProjectInfo), then the ObjectTable (ProjectInfo.aObjectTable).
  3. The ObjectTable's lpObjectArray is an array of tObject descriptors (stride 0x30), one per compiled object — not the ObjectInfo records themselves. Each tObject+0x00 (aObjectInfo) points to that object's ObjectInfo. Each ObjectInfo's lpMethods gives its procedures, and designer objects carry an OptionalObjectInfo right after the ObjectInfo with their controls and events.
  4. Visual objects are also listed in the GuiTable (VBHeader.aGuiTable), whose aFormPointer reaches the GUIObjectInfo and the serialized control-property blob.
  5. External OCX/typelib references live in the external-component table (aExternalComponentTable), and COM-exposed projects carry ComRegData/ComRegInfo (aComRegisterData).

VBHeader (EXEPROJECTINFO)

Reached via PE entry point: the OEP stub is push <VBHeader> ; call ThunRTMain — the pushed pointer IS the VBHeader VA.

The root of every VB5/6 image. Fixed-size header that anchors the whole VB object graph: it points at the ProjectInfo, the GUI table, the external-component table and the COM-registration data, and carries the offsets (relative to its own VA) of the project's name/title/help/exe strings.

Gotcha. The signature is VB5! in both VB5 and VB6 — there is no VB6!. aSubMain == 0 means the startup is a form load, not a Sub Main. The o* fields are offsets relative to the VBHeader VA, not absolute pointers like the a* fields.
OffSizeTypeFieldNotese.g.
0x004StringsignatureVB5! identifier "VB5!"VB5!
0x042IntegerRuntimeBuild1FF0
0x0614StringLanguageDLLLanguage DLL name 0x2A meaning default or null terminated string.*
0x1414StringBackupLanguageDLLBackup Language DLL name. 0x7F meaning default or null terminated string. Changing values do not effect working status of an exe.~
0x222IntegerRuntimeDLLVersionRun-time DLL versionA
0x244LongLanguageID409
0x284LongBackupLanguageIDBackup Language ID (only when Language DLL exists)0
0x2C4LongaSubMainAddress to Sub Main() code (If 0000 0000 then it's a load form call)0
0x304LongaProjectInfo40540C
0x344LongfMDLIntObjs38FFFF
0x384LongfMDLIntObjs2FFFFFF00
0x3C4LongThreadFlags8
0x404LongThreadCount1
0x442IntegerFormCount10
0x462IntegerExternalComponentCountNumber of external components (eg. winsock) referencedA
0x484LongThunkCountE9
0x4C4LongaGuiTable4078D4
0x504LongaExternalComponentTable40E390
0x544LongaComRegisterData401344
0x584LongoProjectExename78
0x5C4LongoProjectTitle8A
0x604LongoHelpFile9A
0x644LongoProjectName9B

ProjectInfo

Reached via VBHeader.aProjectInfo

The compile-time project record: where code starts/ends, the VBA exception handler, the native-code pointer, and the project path string. Most of its address fields are only meaningful at runtime.

Gotcha. OriginalPathName is a fixed 521-byte reserved path buffer (vbdec reads MAX_PATH*2+1 bytes at +0x2A, up to the NullSpacer at +0x233) — vbdec's own source carries a TODO that this field's exact element width differs in the vbparser reference, so treat the WCHAR[261] reading as approximate. It is empty before SP6 and, when present, is a classic compiler-leak of the build machine's project path. aNativeCode is the hinge between P-code and native builds.
OffSizeTypeFieldNotese.g.
0x004Longsignature1F4
0x044LongaObjectTable41AA5C
0x084LongNull10
0x0C4LongaStartOfCode47B530
0x104LongaEndOfCode47B540
0x144LongFlag191A4
0x184LongThreadSpace47C000
0x1C4LongaVBAExceptionhandler40129A
0x204LongaNativeCode0
0x242IntegeroProjectLocation2A
0x262IntegerFlag25C
0x282IntegerFlag341
0x2A521StringOriginalPathNameD:\_code\pdf_stream_dumper\_Project1.vbp
0x2331ByteNullSpacer0
0x2344LongaExternalTable404FF8
0x2384LongExternalCount3E

ObjectTable

Reached via ProjectInfo.aObjectTable

The catalog of every compiled object (form/class/module/UserControl/UserDocument). lpObjectArray points at an array of tObject descriptors (stride 0x30), not at the ObjectInfo records directly — each tObject+0x00 (aObjectInfo) is the VA of this object's ObjectInfo; ObjectCount1 says how many.

Gotcha. uuidObj is a 16-byte GUID that vbdec historically parsed as four separate flag longs (per vb.idc). iObjectsInUse drifts up/down in the IDE as modules load/unload but equals iCompiledObjects in a compiled exe.
OffSizeTypeFieldNotese.g.
0x004LonglNull10
0x044LongaExecProjPointer to VB Project Exec COM Object.47C2E8
0x084LongaProjectInfo2Secondary Project Information475854
0x0C4LongConst1Always set to -1 after compiling. UnusedFFFFFFFF
0x104LongNull20
0x144LonglpProjectObjectPointer to in-memory Project Data.47C2D8
0x1816StringuuidObjGUID of the Object Table.{98529654-E4C9-4A84-9C03-1D803895DFC7}
0x282IntegerfCompileTypeInternal flag used during compilation.A
0x2A2IntegerObjectCount1Total objects present in Project.23
0x2C2IntegeriCompiledObjectsEqual to above after compiling.23
0x2E2IntegeriObjectsInUse23
0x304LonglpObjectArrayPointer to Object Descriptors41AAB0
0x344LongfIdeFlagFlag/Pointer used in IDE only.0
0x384LonglpIdeDataFlag/Pointer used in IDE only.0
0x3C4LonglpIdeData2Flag/Pointer used in IDE only.0
0x404LongaProjectNamePointer to Project Name41BBAC
0x444LongLangID1409
0x484LongLangID2409
0x4C4LonglpIdeData3Flag/Pointer used in IDE only.0
0x504LongdwIdentifierTemplate Version of Structure.2

ProjectInfo2

Reached via ObjectTable.aProjectInfo2

Secondary project info: a back-pointer to the ObjectTable, a pointer to the object-descriptor list, and the project description / help-file / help-context.

Gotcha. Largely inert in a compiled exe — vbdec's author notes “I do not see any useful data in this table.” Several members are always -1 or never read after compilation.
OffSizeTypeFieldNotese.g.
0x004LonglNull10
0x044LonglpObjectTableBack-Pointer to the Object Table.41AA5C
0x084LonglConst1Always set to -1 after compiling. UnusedFFFFFFFF
0x0C4LonglNull2Not written or read in any case.0
0x104LonglpObjectListPointer to Object Descriptor Pointers.441580
0x144LongdwUnused2Not written or read in any case.0
0x184LongszProjectDescriptionPointer to Project Description0
0x1C4LongszProjectHelpFilePointer to Project Help File0
0x204LongdwReserved2Always set to -1 after compiling. UnusedFFFFFFFF
0x244LonglHelpContextIDProject Help Context ID0

ObjectInfo (epiModule)

Reached via ObjectTable.lpObjectArray → tObject[i] → +0x00 aObjectInfo

The per-object record (vbdec/runtime name epiModule). It carries the method count, the pointer to this object's methods, the constant pool, and — if the object has a designer/controls — is immediately followed by the OptionalObjectInfo.

Gotcha. The single most important load difference. lpMethods (+0x24) is, in VB6, a static flat array of NumberOfProcs ProcDscInfo VAs; in VB5 it is a runtime-only heap pointer (garbage on disk) — see the VB5/VB6 section. Also: NumberOfProcs counts user routines + API declares for P-code, but API declares only for native. lpConstantPool can be immediately followed by the OptionalObjectInfo.
OffSizeTypeFieldNotese.g.
0x002IntegerwRefCountAlways 1 after compilation.1
0x022IntegerObjectIndex0
0x044LongaObjectTablePointer to the Object Table41AA5C
0x084LonglpIdeDataZero after compilation. Used in IDE only.0
0x0C4LonglpPrivateObjectPointer to Private Object Descriptor.43D964
0x104LongdwReservedFFFFFFFF
0x144LongNull20
0x184LongaObject41AAB0
0x1C4LonglpProjectData47C000
0x202IntegerNumberOfProcsnative = api declares only, pcode = user func + api (in this code unit)77
0x222IntegerwMethodCount2Zeroed out after compilation. IDE only.0
0x244LonglpMethods4175BC
0x282IntegeriConstantsCountNumber of Constants1D4
0x2A2IntegeriMaxConstantsMaximum Constants to allocate.200
0x2C4LonglpIdeData21
0x304LonglpIdeData33BCEF74
0x344LonglpConstantPool416E6C

OptionalObjectInfo

Reached via Immediately follows ObjectInfo when tObject.ObjectType AND 0x80

Present only for objects with a designer surface (forms, UserControls, classes with events). Holds the object's GUIDs, the controls array, the P-code count, the init/terminate event offsets, and the aMethodLinkTable.

Gotcha. This is where control and event wiring lives. On VB5, aMethodLinkTable (+0x30, i.e. ObjectInfo+0x68) is the static array of x86 method-link thunks used to recover procedure descriptor VAs (standard modules have no OptionalObjectInfo, so their thunks are found by scanning .text).
OffSizeTypeFieldNotese.g.
0x004LongdwObjectGuidsHow many GUIDs to Register. 2 = Designer1
0x044LonglpObjectGuidUnique GUID of the Object41BCA0
0x084LongNull10
0x0C4LonglpuuidObjectTypesPointer to Array of Object Interface GUIDs417798
0x104LongdwObjectTypeGuidsHow many GUIDs in the Array above.1
0x144LonglpControls2Usually the same as lpControls.4177A0
0x184LongdwNull20
0x1C4LonglpObjectGuid2Pointer to Array of Object GUIDs.41779C
0x204LongControlCountNumber of Controls in array below.72
0x244LonglpControlsPointer to Controls Array.4177A0
0x282IntegeriMethodLinkCountNumber of Events in Event Array.86
0x2A2IntegeriPCodeCountNumber of P-Codes used by this Object.1B7
0x2C2IntegeroInitializeEventOffset to Initialize Event from aMethodLinkTable68
0x2E2IntegeroTerminateEventOffset to Terminate Event from aMethodLinkTable6C
0x304LongaMethodLinkTablePointer to pointers of MethodLink418970
0x344LongaBasicClassObjectPointer to the in-memory class object.483E7C
0x384LongNull30
0x3C4LonglpIdeDatausually null31A9BD8

GuiTable

Reached via VBHeader.aGuiTable → one per form / UserControl

Designer record for a visual object: its ObjectGUI UUID, OLEMisc flags, object CLSID, and the pointer (aFormPointer) to the GUIObjectInfo + control property blob.

Gotcha. Forms and UserControls only — plain classes and standard modules have no GuiTable (confirm with cco.GUIObj Is Nothing). dataSize measures from aFormPointer to the end of this object's property blob. A fuller field map (control flags, the two extra GUIDs) is in the old sandsprite decompiler forum writeups.
OffSizeTypeFieldNotese.g.
0x004LonglStructSizeTotal size of this structure50
0x0416StringuuidObjectGUIUUID of Object GUI{6F5B5966-8CF6-479F-AA43-1083598DFB27}
0x144LongUnknown10
0x184LongUnknown20
0x1C4LongUnknown30
0x204LongUnknown40
0x244LonglObjectIDCurrent Object ID in the Project0
0x284LongUnknown53810
0x2C4LongfOLEMiscOLEMisc Flags0
0x3016StringuuidObjectUUID of Object{00000000-0000-0000-0000-000000000000}
0x404LongdataSizeend offset is FormPointer + dataSize336C
0x444LongUnknown70
0x484LongaFormPointerPointer to GUI Object Info401404
0x4C4LongUnknown84C

GUIObjectInfo

Reached via GuiTable.aFormPointer

Header of the control/property blob for a visual object: the control count and the three defining GUIDs (ObjectGUI, DefaultIID, COMEventsIID), followed by the total length of the serialized properties.

Gotcha. bControlsCount is a single byte and is the cheapest reliable “did the form's controls change?” signal in a binary diff. The GUIDs regenerate on every recompile — expected churn, not a real change.
OffSizeTypeFieldNotese.g.
0x004LonglSignature31CCFF
0x041BytebControlsCountNumber of controls of this Object70
0x0516StringguidObjectGUIGUID of this ObjectGUI{6F5B5966-8CF6-479F-AA43-1083598DFB27}
0x1516StringguidDefaultIIDGUID of this object DefaultIID{CAE46972-3F84-4FA7-898E-E6D0BC241199}
0x2516StringguidCOMEventsIIDGUID of this object EventsIID{33AD4F3A-6699-11CF-B70C-00AA0060D393}
0x354LonglUnknown10
0x394LonglUnknown20
0x3D4LonglUnknown30
0x414LonglUnknown40
0x454LonglUnknown50
0x494LonglUnknown60
0x4D4LonglUnknown70
0x514LonglUnknown80
0x554LonglUnknown90
0x594LonglPropertiesLengthTotal Length of Properties3313

ComRegData

Reached via VBHeader.aComRegisterData

Root of the COM-registration block (only present when the project exposes COM objects). Points at the COM-interface info and carries the project/typelib CLSID, LCID and typelib version.

Gotcha. Can sit at the same VA as the first ComRegInfo (baseOffset = VBHeader.aComRegisterData + oRegInfo). uuidProjectClsId is regenerated each compile.
OffSizeTypeFieldNotese.g.
0x004LongoRegInfoOffset to COM Interfaces Info48
0x044LonglpszProjectNameOffset to Project/Typelib Name30
0x084LonglpszHelpDirectoryOffset to Help Directory40
0x0C4LonglpszProjectDescriptionOffset to Project Description0
0x1016StringuuidProjectClsIdCLSID of Project/Typelib{9F5E4DAC-AC08-4449-8314-54B2DFD363A7}
0x204LonglTlbLcidLCID of Type Library0
0x242IntegeriPadding10
0x262IntegeriTlbVerMajorTypelib Major Version1
0x282IntegeriTlbVerMinorTypelib Minor Version0
0x2A2IntegeriPadding20
0x2C4LonglPadding30

ComRegInfo

Reached via ComRegData.oRegInfo → linked list via oNextObject

One record per COM-exposed object: its name/description offsets, instancing mode, object & control CLSIDs, OLEMisc status, and the Object-Type flag.

Gotcha. fObjectType identifies the kind of object: 0x02 Designer, 0x10 Class Module, 0x20 User Control (OCX), 0x80 User Document. Records chain via oNextObject (offset, not pointer; 0 = end).
OffSizeTypeFieldNotese.g.
0x004LongoNextObjectOffset to COM Interfaces Info0
0x044LongoObjectNameOffset to Object Name90
0x084LongoObjectDescriptionOffset to Object Description0
0x0C4LonglInstancingInstancing Mode2
0x104LonglObjectIDCurrent Object ID in the Project17
0x1416StringuuidObjectClsIDCLSID of Object{B628C6ED-013E-45F2-83B3-796074881630}
0x244LongfIsInterfaceSpecifies if the next CLSID is valid1
0x284LongoObjectClsIDOffset to CLSID of Object InterfaceA0
0x2C4LongoControlClsIDOffset to CLSID of Control InterfaceB0
0x304LongfIsControlSpecifies if the CLSID above is valid1
0x344LonglMiscStatusOLEMISC Flags (see MSDN docs)0
0x381BytefClassTypeClass Type1
0x391BytefObjectTypeFlag identifying the Object Type20
0x3A2IntegeriToolboxBitmap32Control Bitmap ID in Toolbox0
0x3C2IntegeriDefaultIconMinimized Icon of Control Window0
0x3E2IntegerfIsDesignerSpecifies whether this is a Designer0
0x404LongoDesignerDataOffset to Designer Data0

Form controls — the serialized property blob

The visual designer packs every control on a form / UserControl into a serialized blob. GuiTable.aFormPointer points at it, it is GuiTable.dataSize bytes long, and parsing starts at aFormPointer + 0x5E (after the GUIObjectInfo header). Inside is a sequence of per-control records, each followed by a dynamic run of 1-byte “control codes” that describe the parent/child control tree. Fair warning from the source: this is the hairiest corner of the whole format — the control-code state machine, not the fixed header, is what makes it hard. (From David Zimmer's Parsing VB6 Form Controls.)

The control header

Each control begins with a fixed header, then its (variable-size) property data to length:

FieldTypeNotes
lengthLongoffset to the end of this control's struct. Bit 31 (0x80000000) is the isArray flag; the real length is length & 0x7FFFFFFF
cIdBytecontrol id
(arrayIndex)Integerpresent only for control-array elements (when the isArray bit is set)
cnameStringlength-prefixed, e.g. 00 05 "Text1"
nullBytestring null terminator
cTypeBytethe control type (table below)

To tell a normal header from an array header, read the high byte of length (the 4th byte): if (b & 0x80) it's an array element. (The “aha” from the paper: it's not a weird 3-byte length + flag byte — it's a 32-bit length that steals the sign bit, which would otherwise be unused, as the isArray flag.)

cType — control type byte

#Control#Control#Control
0PictureBox8ListBox19Menu
1Label9HScrollBar20MDIForm
2TextBox10VScrollBar22Shape
3Frame11Timer23Line
4CommandButton13Form24Image
5CheckBox16DriveListBox37Data
6OptionButton17DirListBox38OLE
7ComboBox18FileListBox40UserControl
  41PropertyPage42 / 0xFFUserDocument / External

The numbering has gaps (12, 14–15, 21, 25–36, 39) — the runtime's own table has them too.

Control codes (the hairy part)

After a control's fixed struct comes a dynamic, variable-length run of 1-byte codes describing the control tree:

CodeNameMeaning
1BeginNewChilda new child control inside the current parent (p-code form: FF01)
2EndCurrentControlParentend the parent and all its children (FF02)
3ContinueCurrentControlParentanother child follows in the same parent (FF03)
4FinalEndConfigBlockend of the whole blob
5StartOfMenusthe menu section follows

Valid sequences differ between ordinary controls and menus. Hard-won rules from bulk analysis: 03 01 is never valid; 03 03 is valid for a menu but not other controls; 03 04 is valid only when bytesLeft == 0.

Gotcha. The killer: a control's length/property bytes can contain values that look like valid control codes, so a naive parser gobbles into the next control's header and every control after it is garbage. vbdec's defence is a rewind heuristic — detect an implausible header length, seek back one byte, and retry — plus per-control tracking of control-codes, rewindCount, lateRewinds and bytesNotProcessed. A clean parse ends with bytesNotProcessed == 0; anything else flags a control whose extraction drifted. (vbdec: Explore → Form Object Properties; the CControlProperties accumulator exposes all of it, and gui_scan.js bulk-exports it to SQLite for finding sequences that break the parser.)

Beyond locating the blob: the individual properties

Finding each control's blob is only half the battle; pulling the actual property values (.Caption, .Text, .Height…) is a separate per-control-type slog: the embedded property opcode is memberID − 0x10000, values are variable-size and must be walked in order (you can't jump to the one you want), and the layout differs for every control. vbdec deliberately does not do full form reconstruction; it surfaces the parsed blobs plus an image extractor, an Extract Strings option, and the OLB Viewer (which decodes memberID − 0x10000 to help read the hex by hand).

3rd-party (OCX / ActiveX) controls

For a 3rd-party control the VB header's cType is 0xFF (External) and the real type is a length-prefixed ProgID string at the start of the property data (e.g. "MSComctlLib.ListView"):

[ VB control header: length, cId, cname, null, cType=0xFF ]
[ u16-length ProgID string  e.g. "MSComctlLib.ListView" ]
[ property opcode stream ... to control.length ]

Reversing MSVBVM60 (symboled build) shows the runtime loads every control, intrinsic and OCX alike, through one routine, PropLoadProp(CTL*, FILEREF*) (header via LoadHctlHeader). For intrinsic controls that opcode stream carries all the property values. For an OCX it carries only the VB wrapper (the extender/stock props) plus one object-data opcode whose value is the control's own serialized blob — so the control's own properties are not in this opcode format (see “Full typed decode” below, proven by compile-diff). The mechanism below is therefore exact for intrinsic controls and for the OCX wrapper:

How the runtime parses it — PropLoadProp

type&0x3Fvalue in the stream
1length-prefixed string (FrefFastReadSz)
22-byte Integer
3–71- or 4-byte scalar (Byte / Boolean / Enum / OLE_COLOR via OleTranslateColor)
8–114-byte coordinate, DPI-scaled from twips (PosX / SizeX / PosY / SizeY)
13 (0x0D)BSTR (length + chars)
17 (0x11)packed bitfield (shift/mask from tagPROP+10)
21 (0x15)Picture — an inline StdPicture / IPicture
33 (0x21)string
otheran inline IPersistStream OLE object (e.g. StdFont, StdDataFormat)

Then PropSetControlProperty(CTL*, opcode, value, tagPROP*) applies it: it stores straight into the control's instance-data slot at tagPROP+0x10 (with the right coercion — BSTR alloc, bitfield pack, AddRef for an object, PictSetPicture for a picture) or, when the flag bits say so, calls a control method through its vtable (offset +0x20 / +0x24). That is exactly the "vtable offset to call + property type" model — the type tells the loader how to read the bytes, the descriptor says where/how to apply them.

So the ListView landmarks fit perfectly: the embedded StdFont {0BE35203-8F91-11CE-9DE3-00AA004BB851} is just the Font property's value — an inline IPersistStream-serialized StdFont (its sub-stream holds "Courier New"); a column caption is a BSTR value; GridLines is a one-byte scalar. The whole stream is walkable given the control's property-index→type map. vbdec has that map for intrinsic controls (from vb6.olb); for an OCX you rebuild it from the control's own typelib (TLBINF32 / the OLB Viewer — see tlb_dump.vbs: ListView → IListView, View=dispid 0x12, GridLines=0x1B, stock Font=−512).

Validation (vs source). Decoding the compiled lvDebug blob in PDFStreamDumper reproduces its Form1.frm source exactly: _ExtentX=14949, _ExtentY=2566, _Version=393217, the Font (inline StdFont: "Courier New", 8.25pt, Weight 400), and ColumnHeader(1).Text="Message" all round-trip byte-for-byte — confirming the PropLoadProp type model on a real 3rd-party control.

Is it a VB6 PropertyBag dump? No. A PropertyBag.Contents blob begins with the magic 93 B2 00 00 (0xB293) and stores every property name as a UTF-16LE string (gridlines, view, caption… plainly readable). The control stream has no property-name strings — it is index-keyed via tagPROP, not name-keyed. (A control implementing only IPersistPropertyBag would persist as a name-keyed bag; most intrinsic controls and MSComctlLib use the PropLoadProp stream.)

Full typed decode — the mechanism (how to name every property)

The opcode is an index into the control's property table — an array of tagPROP / tagPROPDEF / tagPROPSHORT pointers in opcode order (each descriptor carries the +4 type/flags, the +0x10 member offset, etc.). Two sources:

Proven by compile-diff (the definitive answer). Compiling a minimal ListView form and flipping one property at a time shows the control's props are bit-packed with a checksum, not stored as (opcode,value) pairs: View 3→1 changed exactly one data byte (+0x5B: 0x23→0x21, i.e. bits 0–1) plus a 3-byte checksum near the end; LabelEdit 1→0 cleared bit 5 of the same byte (0x23→0x03) and moved the checksum again. So +0x5B holds View (bits 0–1) and LabelEdit (bit 5), MSComctlLib-style. There is therefore no universal OCX property namer — each control family (MSComctlLib, RichTextBox, …) defines its own IPersistStream packing (bitfields, extents, embedded StdFont/items, a checksum), and naming its values means reversing that control's format (readily done by compile-diff, one property at a time). Intrinsic controls remain fully decodable via vb6.olb; for OCX, the universal, format-agnostic answer is the coverage-complete dump (ProgID/Font/Picture/strings + every other byte shown as (unparsed) with its code and raw hex).

So a full OCX property namer needs that per-control ordered table. Build it statically (per the malware rule, never load the sample's control): take VB's fixed stock/extender set + the control's dispinterface property order from a system-registered typelib (or the sample's embedded typelib bytes read statically; see tlb_dump.vbs), map each property's VARTYPE to the PropLoadProp type codes above, then walk the opcode stream. Cache the result like vb6.olb. Until then, vbdec's OCX dumper is coverage-complete — it decodes ProgID / Font / Picture / strings and shows every remaining byte as an (unparsed) entry with its code byte and raw hex, so nothing is hidden.

Runtime reference (MSVBVM60 symbols): PropLoadProp, LoadHctlHeader, PropSetControlProperty, _PropLoadPicture, _PropSetDefaults, FrefFastReadSz, _ErrLoadBstr; structs CTL, FILEREF, tagPROP, tagCTLHDR. The opcode is the property's index in the control's table, not a raw dispid — vbdec's opcode = memberID − 0x10000 is just how it rebuilds that index from the vb6.olb member list for intrinsic controls. Full OCX value decode is tractable, but only once you rebuild the exact tagPROP order and types from the control's type info.

Prototype & type descriptors (function-signature recovery)

This is one of VB6's most useful — and least known — properties: every public COM object (form, class, UserControl) carries complete type information for its public members, baked in at compile time, with no type library required and no way to disable it. That means the full prototype of every public Sub / Function / Property / Event — argument count, per-argument type, ByRef/ByVal/Optional/Array, return type — plus every public variable's type and in-object offset, can be recovered straight from the binary. (Based on David Zimmer's paper, Recovery of Function Prototypes in Visual Basic 6 Executables.)

The chain: ObjectInfo → lpPrivateObject → PrivateObj, whose three pointers reach the arrays of FuncTypeDesc, PubVarDesc and EventDesc records. A function/event record is a fixed header followed by a packed buffer of type bytes, one per argument (plus one for the return value on a Function / Property Get).

The type byte

Each argument's type is a single byte: a base type from an internal enumeration (not VARENUM), OR-ed with modifier bits. COM/internal types are followed by a 32-bit offset to a reference struct (library GUID, CLSID, name, DLL path).

Base type (hex)VB type
0x03Boolean
0x05Byte
0x06Integer
0x08Long
0x0ASingle
0x0BDouble
0x0CDate
0x0DCurrency
0x0FVariant
0x10String
0x13internal VB object (+32-bit ref offset)
0x1BObject
0x1CCOM interface (+32-bit ref offset)
0x1DCOM object (+32-bit ref offset)
0x1EHRESULT (internal)
Modifier bitMeaning
0x20ByRef
0x40Array
0x80Optional

argSize packs the argument count in its low bits (argCount × 4) and the property kind (Get/Let/Set) in its top 3 bits. flags bit 1 flags a return value. optionalVals set means Optional defaults follow (parsed into the COptParamDefVal records; note an Optional typed as an enum is saved only as its underlying Long). Everything is 32-bit aligned, and null padding can appear between a type byte and its offset and between type bytes — a parser must tolerate it.

Payoff. This is how vbdec reconstructs public method/property/event signatures, and how PubVarDesc.dataOffset turns an anonymous [pr+0xNN] field access back into Me.<field>. The records sit in .text; user code lies between ProjectInfo.aStartOfCode and aEndOfCode.

PrivateObj

Reached via ObjectInfo.lpPrivateObject

The hub for type/prototype recovery. Carries the public-variable and event counts and the pointers to the three descriptor arrays — functions (lpFuncTypeInfo), public variables (lpPublicVars) and events (lpEventsTypeInfo) — plus a back-link to the owning ObjectInfo.

Gotcha. The public-function count is NOT here — it comes from the object's ProcNamesArray / ObjectInfo.NumberOfProcs (a null name = a private function with no descriptor). Only cntPublicVars and cntEvents live in PrivateObj. Standard .bas modules have no PrivateObj (not COM objects internally).
OffSizeTypeFieldNotese.g.
0x004Longnul10
0x044LonglpParentLink416DF4
0x084Longunk1FFFFFFFF
0x0C4Longnul20
0x102IntegercntPublicVars1A
0x122IntegercntEvents0
0x144Longnul30
0x184LonglpFuncTypeInfo454CE4
0x1C4Longnul40
0x204LonglpPublicVars43F904
0x244LonglpEventsTypeInfo43A7D0
0x284LonglpNull43A7D0
0x2C4Longnul50
0x304Longnul60
0x344Longnul70
0x384Longunk3268
0x3C4Longunk44

FuncTypeDesc (public function/property)

Reached via PrivateObj.lpFuncTypeInfo → array, indexed in ProcNamesArray order

One per public Sub/Function/Property. A fixed header followed by a variable-length buffer of per-argument type bytes. argSize encodes the argument count and the property kind; vOff is the vtable offset; lpAryArgNames points at the argument-name strings.

Gotcha. argSize: the low bits are argCount×4 (so an Event with 1 arg shows 4); the top 3 bits are the property kind (111 Set, 010 Let, 001 Get). flags bit 1 set = the last type entry is a return value (Function / Property Get). vOff's low bit is a runtime flag — clear it. lpAryArgNames is NOT null-terminated: compute the count first, then walk it. Up to 59 user args.
OffSizeTypeFieldNotese.g.
0x001ByteargSize0 (0 args)
0x011flags0
0x022IntegervOff0
0x042IntegerconstFFFF0
0x062Integernul10
0x084LongoptionalVals0
0x0C4LongmemberID0
0x104LonglpAryArgNameslast array entry null0
0x144LonglpFuncDesc0
0x184Longnul30
0x1C4LonghelpID0
0x201Byteunk10

PubVarDesc (public variable)

Reached via PrivateObj.lpPublicVars → array of cntPublicVars

One per public (module-level) variable. VB auto-generates Get/Let (and Set for object types) property accessors for these — vOff gives all three vtable slots. dataOffset is the variable's location relative to the object pointer (ObjPtr()+dataOffset).

Gotcha. dataOffset is the key to member-field name recovery: it maps a disassembly [pr+0xNN] reference back to Me.<field> (vbdec's field annotator uses exactly this). A type byte of 0x1D (COM object) is followed by a 32-bit offset to a reference struct with the library GUID / CLSID / name / DLL path (see the live example: Public selli As IListItem resolving to MSComctlLib).
OffSizeTypeFieldNotese.g.
0x004LonglpName47593C -> Public selli As IListItem
0x044Longnul10
0x084Longnul20
0x0C2Integerindex1
0x0E1Byteunk13
0x0F1Byteunk240
0x102Integerunk32
0x122IntegervOff6F8 [Get 6F8, Let 6FC, Set 700]
0x144LongdataOffsetfrom objPtr34

EventDesc

Reached via PrivateObj.lpEventsTypeInfo → array of cntEvents

One per declared event. Mirrors the FuncTypeDesc layout (events are prototypes that are only ever raised, never implemented in the defining object). The captured example recovers Event event_1(stream As CPDFStream).

Gotcha. Event names are not linked from any structure — they are recovered heuristically from the unreferenced strings that follow the method-name array, and only when unambiguous; until then an event prints as event_<dispid> (the memberID). Events never take a ParamArray.
OffSizeTypeFieldNotese.g.
0x001ByteargSize4 (1 args)
0x011ByteisFuncnote: Event defs do not support ParamArray so this is ok...0
0x024LongconstFFFFFFFFFFFF
0x064Longnul10
0x0A2Integernul20
0x0C4LongmemberID1
0x104LonglpAryArgNameslast array entry not null, use argSize /443A80C
0x144LonglpFuncDesc0
0x184Longnul30
0x1C4LonghelpID0
0x201Byteunk10

ProcDscInfo / RTMI — the per-procedure frame record

Every procedure carries a small fixed record the runtime calls RTMI (vbdec: ProcDscInfo), placed immediately after its p-code body — so code start = RTMI − cbCode. The runtime keeps the RTMI pointer in ebx through the prolog and uses it for stdcall cleanup, frame allocation, Erl line lookup, Resume, and destroying the frame's locals on exit. (Full field reference: RTMI_FIELDS.md — derived from the 1998 MSVBVM60 symbols plus compile-diff probes.)

OffSizeFieldMeaning
+0x00u32pObjectInfoowning ObjectInfo
+0x04u16cbArgsstdcall bytes to pop at return: 4 + 4/arg (class methods add this; a Function adds 4 for the hidden return-value pointer; Optional/ParamArray each count as one arg)
+0x06u16cbFramelocal frame size (bytes below EBP), checked against the thread stack limit
+0x08u16cbCodep-code length; the code starts at RTMI − cbCode
+0x0Au16cbHead0x18 + frame-RESDESCTBL size = offset to the first byte past the table
+0x0Cu16flags0x10 = Friend (prolog checks the caller's this belongs to this project); 0x20 = Class_Initialize (AddRef of this skipped); 0 otherwise
+0x0Eu16offErlTable0, or offset to the line-number table (u16 count, then count × {u16 pcodeOff, u16 Erl}), binary-searched to answer Erl
+0x10u16offLongStmtTable0, or offset to the long-statement length table; a BoS marker 02 nn ("statement too long for the inline byte") indexes entry nn here. Used by Resume / Resume Next
+0x12u16(compiler/IDE)usually 0x19; 0 exactly when the proc has an Optional argument with a default or a For Each loop. No runtime reader found
+0x14u16?0 in all probes
+0x16u16?0 in all probes
+0x18—frame RESDESCTBLdestructible locals (see Record descriptors): cbDesc ≥ 13 → DestructInstExceptSpecial on exit; the table's construct count → ConstructInst in the prolog
Gotcha. The frame table at +0x18 is a RESDESCTBL whose cbRecord is 0 and whose flags bit 0x01 marks the function return value as the first entry — it is skipped by teardown so the return value survives. vbdec's older ProcDscInfo dump mislabeled some fields: field_A is cbHead, field_C is flags, field_10 is offLongStmtTable, and the test [ecx+0Bh],1 the runtime does is the RESDESCTBL flags byte (RTMI+0x23), not RTMI+0x0B.

Binary reuse. To relocate and re-run a single p-code function outside its binary (David Zimmer's Binary reuse of VB6 P-code functions), you patch RTMI.pObjectInfo (+0x00) to your own object-info, rebuild the function's constant pool, and enter through the runtime's ProcCallEngine with the ProcDscInfo address in EDX — after initializing the runtime via CreateIExprSrvObj (ThunRTMain works but misbehaves on teardown). A missing return type defaults to a COM Variant the caller must push; simple numeric returns come back in EAX; module globals need codeObj.aModulePublic pointed at writable memory.

Record (UDT) descriptors & RESDESCTBL — one grammar, four uses

VB6 uses a single binary format for every list of “things that need construction, destruction or marshalling at known offsets.” A UDT's record descriptor, a module's data table, a class/form instance table, and a procedure's frame table are all the same structure — the runtime's ConstructItem / DestructItem walk the same entry list. (Full spec incl. the SAFEARRAY template, the ANSI translation layout, the Put/Get file-serialization program and external-typelib record blocks: UDT_DESCRIPTORS.md.)

UseLocated atOffsets relative to
Record descriptor (one per UDT type)const-pool udtPool:N operand; pointers inside other descriptorsstart of the record
Module data RESDESCTBLObjectInfo.aPublicBytes (Dim/Public), aStaticBytes (Static)module data block
Class / Form instance RESDESCTBLObjectInfo.aPublicBytesthe object; member data starts at +0x34
Procedure frame tableProcDscInfo (RTMI) +0x18signed 16-bit EBP offset

Header (12 bytes)

OffSizeFieldMeaning
+0x00u16cbDesctotal descriptor size incl. header
+0x02u16cbRecordLenB of the UDT (data size for module/class tables); 0 in frame tables
+0x04u16cConstructentries constructed at procedure entry (local fixed arrays); 0 elsewhere
+0x06u16cEntriesentry count
+0x08u16cbAnsisize of the ANSI layout for Declare marshalling; 0 in RESDESCTBLs
+0x0Au8alignmax member alignment (capped at 4 for VB types); 0 in RESDESCTBLs
+0x0Bu8flagssee below

flags: 0x01 (frame tables) first entry is the function return value, skipped by teardown; 0x04 always set on record descriptors; 0x08 has members needing destruction (String/Variant/ Object/dynamic array/nested-record-needing-it); 0x10 has an ANSI translation table; 0x20 contains string data. Examples: 0x04 plain UDT, 0x0C Variant/Object, 0x2C String, 0x34 fixed-length strings.

Entries

Packed with no padding, in declaration order (frame tables unsorted). Every entry starts u16 off, u16 type, then type-dependent fields. Type codes (low 5 bits = base type, higher bits = modifiers):

CodeMeaning
0x0001 / 0x0002 / 0x0003String / Variant / Object (user classes, IUnknown, Collection, StdFont all look the same here)
0x0005Array — +0x0080 fixed/embedded, +0x0100 dynamic
0x0009Record (nested UDT) — +0x20 inner needs destruction, +0x40 inner has string data
0x000Afixed-length string
0x000Bplain bytes (memcpy block in the ANSI translation table)
0x0004fixed-length-String local > 64 chars (a BSTR alloc'd at entry, freed at exit)
0x0006array lock (released with SafeArrayUnlock)
+0x0200 / +0x1000 / +0x2000constructed-at-entry / ANSI-form record / carries an explicit ANSI offset (translated)
Gotcha. Which members get entries: an untranslated record lists only members that need work (String/Variant/Object, dynamic arrays, fixed arrays of ref/record, nested records with refs or strings) — plain numerics get no entry. A fixed-length string, or a nested record containing strings, triggers an ANSI translation table: from the first triggering member onward every member gets a 0x2000-flagged entry with an ANSI offset (0xFFFF in RESDESCTBLs). So the entry list is not a complete field map unless you account for both rules.

What consumes it

OpcodeUses
AssignRecord (FE F5) / DestructRecord (FF 2A) / EraseDestruct (FE 60)deep copy / destruct / Erase of a record (array)
CRec2Ansi / CRec2Uni (1F / 20)Declare marshalling via the translation table
Get/PutRecOwn3/4 (FF 16–19)not the descriptor — a separate Put/Get file-serialization program (see the md)
StAryRecMove/Copy (FF 03/04)assignment of arrays of records
AryInRecLdPr/LdRf (FF 06/07)a standalone SAFEARRAY template (fixed array inside a UDT)

Plain UDTs (no refs, no strings) are copied with CopyBytes — the descriptor is emitted but nothing in p-code references it. Real-world check: all 647 descriptor tables in a 516 KB production binary (PDFStreamDumper) decode to exactly their declared size, independent VB and Python decoders agreeing on every one.

The constant pool

This one trips people up, so it's worth being precise. Each compilation unit (module, class, form) has one constant pool, shared by all of that unit's procedures and built as the compiler walks the file. It is a flat array of DWORD slots at ObjectInfo.lpConstantPool (+0x34), iConstantsCount (+0x28) entries long (iMaxConstants +0x2A reserved). P-code operands index it by slot number: a string load points at a BSTR slot, an ImpAdCall* calls through a native-stub slot, a record op points at a descriptor slot, and so on.

The catch: a slot is just a DWORD. Its meaning comes entirely from what it points at and which opcode uses it — there is no type tag. vbdec classifies each slot by probing its target (Explore → Constant Pool):

vbdec tagWhat the slot isUsed by
UniStra real BSTR string literal (SysAllocString'd Unicode)string loads; late-bound member names
ExtApi.an API-import native-call stub (a Declare)ImpAdCall* to an API
(VB method stub)a native-call stub for a VB method in another unitImpAdCall* to a VB proc
CodeObja CodeObject base offsetobject / member access
GUIDa raw 16-byte GUIDCreateObject, etc.
IID/CLSID Structa COMDEF {clsid, iid} pairearly-bound COM (late binding uses a UniStr member name instead)
ControlIndexa control indexcontrol access
VarAddra variable addressdata loads/stores
ResDescTbl Addra record/UDT descriptor addressAssignRecord / DestructRecord / …
(internal runtime COM)a pointer to an internal runtime COM object— (not generally recoverable)
Gotcha. Because the pool is an untyped DWORD array, you cannot read it in isolation — you classify each slot by what it references (a readable Unicode string → UniStr; a 16-byte GUID shape → GUID; a GUID preceded by a second GUID → a COMDEF IID/CLSID; an import/.text thunk → ExtApi…). The same slot index means different things in different units, since every unit has its own pool.

Why it matters — binary reuse. To re-run a p-code function outside its binary you must rebuild its unit's pool: real BSTRs via SysAllocString, &guid byte arrays, COMDEF structs, API stubs, and codeobject bases — then point the function's RTMI unit at it. vbdec's Constant Pool viewer will even emit the C or VB rebuild code (constPool[N] = (int)SysAllocString(L"…"), &guid_N, the COMDEF setup) to bootstrap exactly that.

Native thunks (method-link stubs)

Even a pure P-code VB6 exe is full of tiny hand-rolled x86 stubs in .text. They are the glue that lets native call sites — the vtable, AddressOf callbacks, event sinks, the PE import table, Declares — jump into the P-code engine (or an API). vbdec lists them under Explore → Native Stubs (frmNativeStubs); each is a CNativeStub with a VA, owning code object, type, and the target it resolves to. (These are the general case of the VB5 method-link thunks.)

Canonical shapes

The operands are the per-stub target VA and the runtime engine entry (e.g. MethCallEngine / ProcCallEngine):

P-code method (form/class):  33 C0            xor eax, eax
                             BA <ProcDscInfo> mov edx, <target>
                             68 <engine>      push <MethCallEngine>
                             C3               ret
P-code method (module):      BA <ProcDscInfo> mov edx, <target>
                             B9 <engine>      mov ecx, <engine>
                             FF E1            jmp ecx
Event thunk (multiplexed):   B8 <idx>         mov eax, <event index>     ; see gotcha
                             66 3D C0 33      cmp ax, 0C033h
                             BA <target>      mov edx, <target>
                             68 <engine>      push <engine>
                             C3               ret
API Declare / import jump:   FF 25 / jmp dword ptr [import]  (or a push+jmp stub into the API)

Types — and where vbdec recovers each

#stubTypeWhat it isRecovered from
0st_unknownunclassified—
1st_callbackan AddressOf code pointer, discovered as the disassembler sees it pushedmodPCode2.MakeAddrToVB
2st_jmpImportApia jump into the PE import tablePE imports
3st_propertya property accessor thunk (auto or explicit Get/Let/Set)CUserProp / method-link table
4st_pcodeMethoda P-code procedure entry thunk (incl. Sub Main)modPeSkeleton, CUserProp, method links
5st_apiDeclarea Declared-API thunkCVBProject.AddDeclare
6st_controlEventa control event-handler thunkCOptObjInfo (optional object info)
7st_propNewObja Public X As New … object-property thunkCUserProp / method links
8st_setEventan event-sink (WithEvents) thunkCUserProp / method links

CNativeStub also carries memOffset (ObjPtr()+memOffset for setEvent/propNewObj/property), the owner code object, and the CMethodLink it came from.

Gotcha — multiplexed stubs. Microsoft overlaps stubs: two logical thunks can share bytes at adjacent VAs, using a filler instruction so one stub's bytes double as valid operands of another. The event thunk's mov eax, 3Ch / cmp ax, 0C033h deliberately embeds 33 C0 (xor eax, eax) so the same bytes are also the start of an overlapping P-code-method stub. A linear disassembler cannot render both correctly — it's an inherent, unfixable disasm artifact (and it breaks naive IDC struct scripting), not a vbdec bug. Treat overlapping stub VAs as expected.

Live memory: reading a running VB6 process

Every structure above also exists in the running process — and reading them live (via ReadProcessMemory, or in-process over the ROT) unlocks what the file can't give you: the instances that actually exist, their data member values, reference counts, and (on VB5) the runtime-filled lpMethods. This is what vbdec's Live Classes / Live Memory viewers do. Three moves cover most of it. (Background: David Zimmer's VB6 Variable Initialization and Teardown; the ROT plumbing is CRemotelyScriptable.cls.)

1 — From a live instance back to the whole object model

A VB6 object reference is a pointer; the ObjectInfo pointer sits one DWORD before the vtable, so from any live ObjPtr you can climb all the way back to the ObjectTable:

p        = ObjPtr(obj)            ' the instance address
pVtbl    = [p]                    ' +0x00 : the vtable pointer
pObjInfo = [pVtbl - 4]            ' the ObjectInfo for this class (stored just before the vtable)
pTObject = [pObjInfo + 0x18]      ' ObjectInfo.aObject  -> this class's tObject (Object descriptor)
pObjTable= [pObjInfo + 0x04]      ' ObjectInfo.aObjectTable -> the project ObjectTable

The tObject descriptor (the lpObjectArray entries, stride 0x30) carries the runtime names and flags: +0x18 aObjectName, +0x1C ProcCount, +0x20 aProcNamesArray (the public method-name strings; a null entry = a private proc), +0x28 ObjectType flags.

2 — Enumerate live instances and walk their data members

Each class's run-time instance manager (RUN_INSTMGR) is reachable from its (static) ObjectInfo. This is the runtime's own list — the same linked list RcmResetModulesPrepass/ExecuteInitTerm walk at shutdown to tear every instance down — so enumerating it finds every live object:

pRunMgr  = [ ObjectInfo.lpProjectData ]   ' lpProjectData = ObjectInfo + 0x1C
count    = [ pRunMgr + 0x0C ]             ' how many live instances
objPtr   = [ pRunMgr + 0x00 ]             ' first instance
'   per instance:  refCount = [objPtr + 0x20] ;  nextInstance = [objPtr + 0x04]

Then each public variable's value lives at objPtr + PubVarDesc.dataOffset, read according to the descriptor's base type:

Base typeRead
StringDWORD at objPtr+dataOffset = BSTR ptr; length = [BSTR-4] & 0xFFFF; then read the bytes
Date / Double / Currency8 bytes
Single4 bytes
Integer / Boolean2 bytes
Byte1 byte
Variant16 bytes
Object / other4-byte pointer

Module-level / static variables work the same but their dataOffset is an absolute address, not objPtr-relative. (This is the same PubVarDesc.dataOffset the field annotator uses — static type info + a live pointer = typed, named member values.)

3 — Private variables & resource teardown (RESDESCTBL)

The IDispatch descriptors above cover only public members. Private module/class variables that require cleanup are described by a second structure the runtime uses for init and tear-down: RESDESCTBL, pointed to by Object.aPublicBytes. It exists so a terminating object can free its strings/objects/arrays (RESDESCTBL::DestructItem) and so pre-dimensioned arrays are ready on first use (RESDESCTBL::ConstructItem).

Header (tResDescTbl): size, memDataSz (end-marker of the class data block, 0xABABABAB), reqAlloc, entries, unk1 — followed by per-variable entries of { dataOffset, typeId } (e.g. typeId 1 = String, 3 = Object; it also tracks Variants, Arrays and UDTs whose subtype needs cleanup). For instanced objects the member is at ObjPtr + dataOffset; for BAS modules the data lives at Object.aModulePublic (absolute). Pre-dimensioned array (SafeArray) definitions live here too.

Gotcha. RESDESCTBL lists only variables that need cleanup — a Private b As Long never appears (nothing to free), so it is NOT a complete private-variable list. It is coarser than the IDispatch type info, but it is the one source that reveals private variable offsets/types. vbdec surfaces it under Explore → Resource Descriptors; the full type grammar is in the UDT / record-descriptor companion write-up.

4 — Make a class public and reach it over the ROT

Normally only forms/public objects are externally reachable. You can flip a private class in memory by setting bit 0x800 in its tObject.objectType (+0x28), then register the instance in the Running Object Table so a script can GetObject it:

' per class: locate tObject (via the chain above, or walk ObjectTable.lpObjectArray [+0x30], stride 0x30,
' count at +0x2A), then:
VirtualProtect va+0x28, 4, PAGE_EXECUTE_READWRITE, old
PutMem4 va+0x28, objectType Xor 0x800          ' set the "public" bit
VirtualProtect ... restore
' then publish it:
GetRunningObjectTable 0, pROT
CreateFileMoniker StrPtr("myapp.obj"), pMoniker
pROT.Register ROTFLAGS_REGISTRATIONKEEPSALIVE, ObjPtr(obj), ObjPtr(pMoniker), VarPtr(cookie)
'   -> a client does:  Set o = GetObject("myapp.obj")

Reads/writes use the undocumented MSVBVM60 helpers GetMem4/PutMem4 (and CopyMemory); verify the target really is a VB6 object first (__vbaCheckType with the VB6 object IID). This is exactly how vbdec exposes vbdec.frmMain/vbdec.vbp for the ROT automation used throughout these tools.

Gotcha. This is all process-only — nothing on disk changes and the patch dies with the process. The offsets are undocumented VB6 internals tied to specific runtime/compile builds, so patching live structures can silently fail or crash the target. Over the ROT, TypeName() of a VB6 class returns "Object" (use TypeOf, or ask the target process itself via an in-process query).

Gotchas & version notes (quick reference)

Generated from structs_sample.txt + the vbdec struct sources. This documents the structures as vbdec parses them; field names follow vbdec's naming, which in turn builds on the VB-reversing community's decades of work on the format.

Runtime symbol reference (MSVBVM60)

Jump-off points for your own reversing. Addresses are in the symboled MSVBVM60.DLL v6.00.8268 (linked 1998-09-26, imagebase 0x66000000) — the same build vbdec ships as its target.runtime; names are the runtime's own (C++-mangled in the DLL, shown demangled). Offsets drift between builds, so match by name.

Reference runtime & symbols (reproduce these addresses). The exact DLL + its COFF debug symbols (msvbvm60.dbg) are archived at sandsprite.com/vb-reversing/files/msvbvm60.zip. SHA-256:
  • MSVBVM60.DLL (1,409,024 bytes, v6.00.8268)   e86cbb17c8fcd97bb04d9e0b7fb3ef7a33c6896e681705fc95405f14008737cd
  • msvbvm60.dbg (2,826,996 bytes)   ee600ce25f6f3331c55a7f8e41088b11fd85f4ecc6d61a7679b33edd150058d0
Load the .dbg alongside the DLL (IDA/WinDbg) to get the named symbols below verbatim.

Control & property load

AddrSymbolRole
0x66065D92RbyLoadHctlload one hosted control (the dispatcher: header → create → props)
0x66065E5FLoadHctlHeaderread the control header (length, cId, name, cType / ProgID)
0x66065EFDPropLoadPropwalk the 1-byte-opcode property stream
0x66006391PropSetControlPropertyapply one property (store at tagPROP+0x10, or vtable call +0x20/+0x24)
0x66067262_PropLoadPictureload a Picture property
0x660661DF_PropSetDefaultsdefault the properties an ascending opcode skipped
0x66065EDCFrefFastReadSzread a length-prefixed string from the stream
0x66041780_ErrLoadBstrload a BSTR value
0x66064EF0CCreErrCreateCtlStructcreate the control struct (builds its tagPROP table from type info)
0x6604236DCioErrLoadFormNtableload a form's control table

Records / RESDESCTBL / UDT

AddrSymbol
0x66061D6BRESDESC::CbResdesc (entry size)
0x66061E42RESDESCTBL::DestructItem (teardown)
0x660E9A07RESDESCTBL::ConstructInst (init, incl. pre-dim arrays)
0x66012479RESDESCTBL::DestructInstExceptSpecial

Procedure / RTMI, instance teardown, scaling

AddrSymbol
0x66102CEDErlOfOtex (Erl line lookup)
0x66102D06PtexBosNext (BoS / long-statement)
0x6610B10CLongResume (Resume / Resume Next)
—RcmResetModulesPrepass / ExecuteInitTerm (walk the RUN_INSTMGR instance list at shutdown)
0x6606428A…GrafR4ScaleTwipsPosX/SizeX/PosY/SizeY (twips↔pixel for coordinate properties)

Structures named by these routines: CTL, FILEREF, tagPROP, tagCTLHDR, Projitem / Project, RTMI, RESDESCTBL / RESDESC, RUN_INSTMGR. Pair with a stock-symbol MSVBVM60.DLL (or vbdec's target.runtime) in IDA to follow them live.

Credits

This reference was created by David Zimmer (sandsprite.com), working with Claude (Anthropic).

On the shoulders of others

None of this starts from zero. This reference stands on the shoulders of several generations of VB reversers, who mapped the runtime, the file format and the P-code engine long before AI tools existed, and who shared what they found.

Credits include the VB Decompiling community, Sarge, Napalm, VbGamer45, Mr. Unleaded, Moogman, _aLfa_, Alex Ionescu, Warning, josephCo, Darker, Trick, Firehacker, dodi, sysenter-eip and others.

Special thanks go out to: