VB.CollectionEvery 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.
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.
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.
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.
VBACollection)| Offset | Field | Notes |
|---|---|---|
+0x00 | vtable → IVBACollection | the dual/IDispatch VB talks to |
+0x04 | vtable → IProvideClassInfo | |
+0x08 | vtable → IExternalConnection | drives the live‑enumerator bookkeeping (§4) |
+0x0C | reference count | |
+0x10 | Count | item count; overflow guarded at 0x7FFFFFFF |
+0x18 | pHead — first list node | |
+0x1C | pTail — last list node | |
+0x20 | pEnumHead — list of active enumerators | |
+0x24 | RB tree root | points at the sentinel when empty |
+0x28 | RB NIL‑sentinel pointer | |
+0x30 | embedded NIL sentinel node | the tree’s shared “leaf” |
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.
| Offset | Field | keyless (32 B) | keyed (48 B) |
|---|---|---|---|
+0x00 | VARIANT value (16 bytes) | ✓ | ✓ |
+0x10 | BSTR key | 0 | BstrFromVar(key) |
+0x14 | pPrev (list) | ✓ | ✓ |
+0x18 | pNext (list) | ✓ | ✓ |
+0x20 | RB parent | — | ✓ |
+0x24 | RB left | — | ✓ |
+0x28 | RB right | — | ✓ |
+0x2C | RB color | — | 0=RED, 1=BLACK |
Two facts that explain a lot of VB behaviour:
VariantCopyInd, which dereferences a ByRef
argument and keeps a copy of the resolved value. An object is AddRef’d; it is Released on removal.BstrFromVar. That’s why a
numeric key is awkward: LookUp routes string arguments to the tree but numeric ones to the
index walk, so a number can never reach the key side.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.
O(log n); indexed lookup is O(n). Looping
For i = 1 To coll.Count: … coll(i) … over a large collection is quietly quadratic; use
For Each (one list walk) instead.For Each is insertion order, never sorted/key order.For Each is safe — by design.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.
O(n) with no comparisons. One verified subtlety: TreeInsert routes a smaller key to the
right child (VarBstrCmp → VARCMP_LT → right), so VB builds the tree
reversed — a plain in‑order walk comes back descending; walk right‑subtree‑first for
ascending. (And because keys are stored zero‑padded, that order is the real key order.)O(1). With pHead and pTail you get
push/pop at both ends for free (RemoveLast is normally an O(n) index walk; here it’s a
pointer read).pTail back along pPrev.pPrev/pNext pointers — reorder without
remove‑and‑re‑add.node+0x00 (via
VariantClear + VariantCopy, so refcounts stay honest) to change an item’s value
without losing its position or key.vt word at node+0x00 to see precisely what was
stored — where IsObject(coll(i)) would coerce and can mislead.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.
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].
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.
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.
From MSVBVM60.DLL 6.00.8268, imagebase 0x66000000 (RVA = VA − 0x66000000).
| Function | RVA |
|---|---|
VBACollection::Create (factory) | 0x14044 |
VBACollection::VBACollection (ctor) | 0x14068 |
VBACollection::Add | 0x1415F |
VBACollection::Item | 0x612AA |
VBACollection::LookUp | 0x612D0 |
VBACollection::Remove | 0xEC350 |
VBACollection::NewEnum | 0xEC3EC |
VBACollection::TreeInsert | 0x142DE |
VBACollection::InsertRBNode (RB fixup) | 0x142AA |
VBACollection::FindRBNode | 0x6139B |
VBACollection::DeleteRBNode | 0xEC5CC |
VBACollection::LeftRotate / RightRotate | 0xEC42C / 0xEC46F |
VBAEnum::Create (the For Each enumerator) | 0xEC1FE |
Helpers used by the above: VariantCopyInd, VariantCopy, VariantClear,
SysFreeString, VarBstrCmp, BstrFromVar, ProfMemAlloc/Free.
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.