Inside VB.Collection

How Visual Basic 6’s built‑in Collection really works — and what that buys you.
Generated by Claude Code · 2026

Every VB6 programmer has used Collection. Almost nobody knows what’s inside it. The folklore says “hash table.” The folklore is wrong. This is a tour of the real thing, decompiled from Microsoft’s own debug symbols for MSVBVM60.DLL — the data structures, the exact memory offsets, the tricks those offsets unlock, and even how to drive the Collection from plain C.

The one‑sentence truth: a VB6 Collection is a C++ object called VBACollection that keeps its items in two structures at once — an insertion‑ordered doubly‑linked list (that’s your 1‑based index and For Each order) and a red‑black tree keyed on the item’s key string (that’s your O(log n) Collection("name") lookup). No hash anywhere.

1. Two structures, one set of items

When you Add an item, the runtime allocates one node and threads it into both structures. The list remembers order; the tree remembers keys. They share the same nodes, so there is never a copy — just two sets of pointers over the same memory.

VBACollection (the Collection object) +------------------------------------------------+ | pHead --------+ RBroot ------+ | | pTail ----+ | | | +-----------|---|-----------------------|--------+ | | | insertion | v (doubly-linked list, = index / For Each order) order: | [N1] <-> [N2] <-> [N3] <-> [N4] <-> [N5] +----------------------------^ (pTail) | key order: (red-black tree over the SAME nodes, keyed by key string) [N3] / \ [N1] [N4] \ \ [N2] [N5]

Keyed access walks the tree (balanced, so O(log n)). Indexed access walks the list from the head (O(n)). For Each walks the list too — which is why enumeration is always in insertion order, never key order.

2. The memory layout

Everything below is read straight from the runtime’s code (MSVBVM60.DLL 6.00.8268). Offsets are from ObjPtr(collection) for the object, and from the node pointer for a node. VB6 is 32‑bit, so every pointer is 4 bytes.

The Collection object (VBACollection)

OffsetFieldNotes
+0x00vtable → IVBACollectionthe dual/IDispatch VB talks to
+0x04vtable → IProvideClassInfo
+0x08vtable → IExternalConnectiondrives the live‑enumerator bookkeeping (§4)
+0x0Creference count
+0x10Countitem count; overflow guarded at 0x7FFFFFFF
+0x18pHead — first list node
+0x1CpTail — last list node
+0x20pEnumHead — list of active enumerators
+0x24RB tree rootpoints at the sentinel when empty
+0x28RB NIL‑sentinel pointer
+0x30embedded NIL sentinel nodethe tree’s shared “leaf”

A node (VARLIST / RBVARLIST)

There are two node shapes. An item added without a key is smaller and lives only in the list; an item added with a key is bigger and also lives in the tree.

OffsetFieldkeyless (32 B)keyed (48 B)
+0x00VARIANT value (16 bytes)✓✓
+0x10BSTR key0BstrFromVar(key)
+0x14pPrev (list)✓✓
+0x18pNext (list)✓✓
+0x20RB parent—✓
+0x24RB left—✓
+0x28RB right—✓
+0x2CRB color—0=RED, 1=BLACK

Two facts that explain a lot of VB behaviour:

3. What the operations actually do

Add(item, key, before, after) — allocate a node (32 bytes if keyless, 48 if keyed), store the key with BstrFromVar and the value with VariantCopyInd, splice it into the linked list at the position implied by before/after, and — if it has a key — insert it into the red‑black tree, then Count++. A duplicate key is rejected with the familiar error 0x800A01C9.

Item(indexOrKey) is just look it up, then copy the value out. The lookup (LookUp) decides what you passed: a string is treated as a key and found through the tree (FindRBNode); anything numeric is treated as a 1‑based index and found by walking pNext from pHead.

Keys compare case‑insensitively. The tree orders nodes with VarBstrCmp(a, b, …, 0x30001) — those flags include NORM_IGNORECASE. So coll("Name") and coll("name") are the same key, and the duplicate‑key rule is case‑insensitive too.

Remove clears the value, deletes the node from the tree (if keyed) and frees its key string, decrements Count, unlinks it from the list — and, crucially, advances any live enumerator that was parked on that node. That last step is why removing the current item in the middle of a For Each doesn’t corrupt the loop: each active For Each registers a little cursor record on the Collection’s enumerator list, and Remove nudges it forward before the node disappears.

4. Consequences you can feel

5. What else is possible

Once you know it’s a list + tree rather than a hash, and that pHead/pTail and the item’s VARIANT are right there in the node, a whole shelf of operations the public API never exposed becomes trivial — all with CopyMemory against the offsets in §2. (These extend the well‑known CollectionEx.cls by elroy[1], whose key‑recovery trick uses exactly these offsets.) The capabilities below are implemented and verified in a clean, optimized edition of that class[4] built and compile‑tested alongside this paper.

The tree is secretly a sorted map

The list gives you a deque

The node’s VARIANT is addressable

And the free performance win

Any bulk operation that today calls a “get key for index i” helper inside a loop re‑walks the list from the head every time — O(n²). One linked‑list walk that returns every key (and node pointer) at once makes all of them O(n). It’s the single highest‑value retrofit.

A note on the key: the Collection’s key is the tree’s sort position, so you can’t change a key without re‑positioning the node in the tree. You can avoid every other cost of remove‑and‑add (re‑allocating the node, re‑copying the value, re‑splicing the list) — the minimal in‑place rekey is “swap the BSTR, then tree‑delete + tree‑insert the same node”, leaving its value, order, and refcounts alone.

6. Driving a Collection from C

You don’t need VB at all to use one — the runtime is just a COM DLL. There are two levels of this, both demonstrated in the references below[2][3].

Level 1 — receive a Collection from VB and fill it

The easy, safe pattern: let the VB caller create the Collection (with New) and pass it to your DLL by reference; your C code just adds to it. This sidesteps SafeArrays and hand‑rolled COM entirely. Generate a thin wrapper with #import and trim it to the Collection object:

#import "msvbvm60.dll" no_namespace   // produces .tlh/.tli; keep the Collection parts

// exported, called from VB as:  Private Declare Sub addItems Lib "col_dll" (ByRef col As Collection)
void __stdcall addItems(_CollectionPtr* pCol){
    addStr(pCol, "one"); addStr(pCol, "two"); addStr(pCol, "three");
}
void addStr(_CollectionPtr* pCol, const char* s){
    _variant_t v; v.SetString(s);        // VT_BSTR
    (*pCol)->Add(&v);                     // Collection::Add(item [,key,before,after])
}

On the VB side it’s just Dim c As New Collection : addItems c, then read it back with For Each. To read from C, loop 1..Count (remember: 1‑based), call Item(&index) with a _variant_t index, and convert the result — guarding the conversion, because a non‑string item (VT_UNKNOWN, etc.) can throw.

Level 2 — create one from scratch in C

You can also mint a Collection with no VB caller at all, using the runtime’s own object‑creation export. The one non‑obvious step — and a lovely confirmation of the internals — is that you must first initialise the runtime’s per‑thread state, because the VBACollection constructor reads it (TlsGetValue(g_itlsEbthread)). That is exactly what CreateIExprSrvObj sets up. Sketch:

// resolved from msvbvm60.dll with GetProcAddress:
//   CreateIExprSrvObj, __vbaNew2   (and __vbaNew)
struct vbComDef { DWORD flags; const CLSID* pClsid; const IID* pIid; DWORD reserved /*=0*/; };

CoInitialize(0);
CreateIExprSrvObj(/*...*/);           // init the EB-thread TLS the ctor needs (no VB object loaded yet)

vbComDef def = { 0, &CLSID_Collection, &IID__Collection, 0 };
IUnknown* pUnk = 0;
__vbaNew2(&def, &pUnk);               // runtime allocates + constructs the VBACollection
_CollectionPtr pCol = pUnk;           // QI to the dual interface

_variant_t v; v.SetString("hello");
pCol->Add(&v);                        // populate exactly as before

The GUIDs are the standard VB.Collection coclass (CLSID {A4C4671C-499F-101B-BB78-00AA00383CBB}) and its _Collection dual interface, both obtainable from the msvbvm60.dll type library; the exact strings are in the referenced col.cpp. That reference notes the demo was checked for leaks, dangling pointers, corruption, and correct object ownership — and passed. Every object you then Add is stored by the same VariantCopyInd path described in §2, so ownership behaves identically to the VB case.

7. Reference — symbols & offsets

From MSVBVM60.DLL 6.00.8268, imagebase 0x66000000 (RVA = VA − 0x66000000).

FunctionRVA
VBACollection::Create (factory)0x14044
VBACollection::VBACollection (ctor)0x14068
VBACollection::Add0x1415F
VBACollection::Item0x612AA
VBACollection::LookUp0x612D0
VBACollection::Remove0xEC350
VBACollection::NewEnum0xEC3EC
VBACollection::TreeInsert0x142DE
VBACollection::InsertRBNode (RB fixup)0x142AA
VBACollection::FindRBNode0x6139B
VBACollection::DeleteRBNode0xEC5CC
VBACollection::LeftRotate / RightRotate0xEC42C / 0xEC46F
VBAEnum::Create (the For Each enumerator)0xEC1FE

Helpers used by the above: VariantCopyInd, VariantCopy, VariantClear, SysFreeString, VarBstrCmp, BstrFromVar, ProfMemAlloc/Free.


Notes & credits

This paper was generated by Claude Code. The structure was recovered by static reverse engineering — IDA / Hex‑Rays over Microsoft’s shipped debug symbols for MSVBVM60.DLL 6.00.8268 — cross‑checked across Add, LookUp, Remove, TreeInsert, InsertRBNode and the constructor, and matching the independently‑derived offsets in the community tooling below.

Validation. The findings were then confirmed empirically. A VB6 implementation[4] that drives these exact offsets — CopyMemory walks of the linked list and the red‑black tree — was compiled and run against the live runtime. A reverse in‑order tree walk returned the keyed items in exactly the same order as an independent key‑sort, keyless items were absent from the tree, and an in‑place value write at node+0x00 preserved position, key and refcounts. A **94‑assertion test harness**[4] over every public method plus these structural claims passes green (94 / 0). That directly verifies the dual list‑plus‑tree structure, the node/header offsets, and the reversed‑tree ordering described above.

[1] elroy, “Wrapper for VB6 Collections” (CollectionEx.cls) — vbforums.com/showthread.php?834579

[2] D. Zimmer, “Using a VB6 Collection from C” — sandsprite.com/blogs (uid=11, pid=388)

[3] D. Zimmer, create_vb6_collection_from_c/col.cpp — github.com/dzzie/tests

[4] CollectionEx.cls (clean / optimized edition) + tests/ (a 94‑assertion VB6 test harness) — the validating implementation, generated by Claude Code alongside this paper; drives the offsets herein and was compile‑tested against the live runtime (adds single‑pass O(n) bulk ops, in‑place value edit, SortedByKey and a red‑black TreeKeysInOrder walk). The harness — CollectionExTests.vbp/.bas, runnable with /q for CI — passes 94 / 0.