Overview
At the heart of VB6Runtime lies VARIANT — the dynamic value type that serves as the
universal carrier for all data in the Visual Basic 6.0 type system. Every function call, every
variable, every property access flows through VARIANT. In the Rust implementation, this
type is VBVariant, a single enum with 14 variants that
faithfully replicates VB6's variant semantics: banker's rounding on integer conversion, Null
propagation as error 94, overflow as error 6, CVErr passthrough, and cross-type numeric
equality that differs from both Rust and the C++ VARIANT spec.
The value system is implemented in three files totaling roughly 2,100 lines:
value.rs (~1,600 lines) for the core VBVariant enum and its wrapper types,
array.rs (~268 lines) for array storage, and boundary.rs (~218 lines) for the
typed-argument boundary layer between untyped variant slices and library functions.
Core principle: VB6 has no compile-time type checking for most values. A variable
declared as Dim x can hold an integer today and a string tomorrow. The runtime value system
must therefore carry both the data and its type, and perform coercion at every conversion boundary.
VBVariant Enum
The VBVariant enum is the dynamic counterpart of the static VbType enum and
represents a VB6 variant at runtime:
pub enum VBVariant {
Empty, // Uninitialized variant (VB6: Dim x)
Null, // Null value (unknown data)
Nothing, // Object reference set to Nothing
Byte(u8), // 8-bit unsigned integer
Integer(i16), // 16-bit signed integer
Long(i32), // 32-bit signed integer
Single(f32), // 32-bit floating point
Double(f64), // 64-bit floating point
Currency(i64), // Scaled integer (raw / 10,000)
String(String), // Unicode string
Boolean(bool), // Boolean
Date(f64), // OLE automation date serial
Error(VbError), // Error value from CVErr
Object(Box<dyn VbObject>), // Object reference
Array(ArrayValue), // Array value
}
Each variant maps to a VBA VarType code used by functions like VarType()
and TypeName(). Arrays are encoded as the bitwise OR of vbArray (8192) and
the element type code. The type_of() method returns the corresponding VbType,
while var_type() returns the integer code:
| Variant | VarType Code | VB6 Declaration | Default Value |
|---|---|---|---|
Empty | 0 | Dim x | Empty |
Null | 1 | N/A | N/A |
Byte | 17 | Dim x As Byte | 0 |
Integer | 2 | Dim x As Integer | 0 |
Long | 3 | Dim x As Long | 0 |
Single | 4 | Dim x As Single | 0.0 |
Double | 5 | Dim x As Double | 0.0 |
Currency | 6 | Dim x As Currency | 0 |
String | 8 | Dim x As String | "" |
Boolean | 11 | Dim x As Boolean | False |
Date | 7 | Dim x As Date | 0.0 |
Error | 10 | CVErr(n) | CVErr(0) |
Object | 9 | Dim x As Object | Nothing |
Array | 8192 + T | Dim x() | Uninitialized |
Typed Wrapper Types
Rather than exposing the VBVariant variants directly to library functions, the value
system uses a set of typed wrapper structs — one per numeric/fixed type — that encode
VB6 coercion semantics at the type level:
| Wrapper | Inner Type | Conversion | VB6 Function |
|---|---|---|---|
VbString | String | TryFrom | CStr / string conversion |
VbByte | u8 | TryFrom | CByte |
VbLong | i32 | TryFrom | CLng |
VbInteger | i16 | TryFrom | CInt |
VbBoolean | bool | TryFrom | CBool |
VbDate | f64 | TryFrom | CDate |
VbSingle | f32 | TryFrom | CSng |
VbDouble | f64 | TryFrom | CDbl |
VbCurrency | i64 (scaled) | TryFrom | CCur |
Each wrapper implements both From<wrapper> for VBVariant (boxing the value) and
TryFrom<&VBVariant> for wrapper (unboxing with coercion). This means every
conversion — whether from a typed wrapper or from a raw variant — follows the same VB6-specific rules:
banker's rounding for integer types, overflow as error 6, type mismatch as error 13, and
Null as error 94.
// Creating a variant from a typed wrapper
let v = VbLong::from(42);
// → VBVariant::Long(42)
// Converting a variant to a typed wrapper (with coercion)
let v = VBVariant::from_string("2.5");
let n: VbLong = v.try_into().unwrap(); // CLng("2.5") → 2 (banker's rounding)
// → VbLong::from(2)
// The round-trip is lossy when coercion changes the value
let v = VBVariant::from_double(2.5);
let n: VbInteger = v.try_into().unwrap();
assert_eq!(n.as_i16(), 2); // CInt(2.5) = 2 (banker's rounding: round to even)
Why wrappers instead of direct enum matching? The wrapper approach ensures that
coercion semantics cannot drift between call sites. Every VbLong::try_from() call
follows the CLng path through VBVariant::as_i32(), which implements
banker's rounding uniformly. If library functions matched the enum directly, each conversion path
could diverge.
Type Coercion System
The coercion system is the core of the value type — it defines how a variant of one kind is
converted to another, following VB6's sometimes surprising rules. Every conversion is implemented as
a method on VBVariant that returns a VbResult<T>:
pub fn as_i64(&self) -> VbResult<i64> // CLng: rounds to 64-bit int
pub fn as_i32(&self) -> VbResult<i32> // CLng: rounds to 32-bit long
pub fn as_i16(&self) -> VbResult<i16> // CInt: rounds to 16-bit integer
pub fn as_byte(&self) -> VbResult<u8> // CByte: rounds to 8-bit unsigned
pub fn as_f64(&self) -> VbResult<f64> // CDbl: converts to double
pub fn as_f32(&self) -> VbResult<f32> // CSng: converts to single
pub fn as_currency_scaled(&self) -> VbResult<i64> // CCur: scaled
pub fn as_bool(&self) -> VbResult<bool> // CBool: non-zero = true
pub fn as_date_serial(&self) -> VbResult<f64> // CDate: serial number
pub fn as_string(&self) -> VbResult<String> // CStr: to string
The as_i64() method is the canonical integer conversion path. Every typed integer
conversion (as_i16, as_i32, as_byte) routes through it,
ensuring consistent behavior:
pub fn as_i64(&self) -> VbResult<i64> {
match self {
VBVariant::Empty => Ok(0),
VBVariant::Null => Err(VbError::invalid_use_of_null()), // error 94
VBVariant::Nothing | Object(_) | Array(_) => Err(VbError::type_mismatch()), // error 13
VBVariant::Error(e) => Err(e.clone()), // re-raise CVErr
VBVariant::Boolean(b) => Ok(if *b { -1 } else { 0 }), // VB6: True = -1
VBVariant::Currency(raw) => {
round_half_even(*raw as f64 / CURRENCY_SCALE).ok_or_overflow()
}
VBVariant::Single(v) => round_half_even(*v as f64).ok_or_overflow(),
VBVariant::Double(v) => round_half_even(*v).ok_or_overflow(),
VBVariant::Date(v) => round_half_even(*v).ok_or_overflow(),
VBVariant::String(s) => {
let n = parse_vb_number(s).ok_or_type_mismatch()?;
round_half_even(n).ok_or_overflow()
}
// Byte, Integer, Long: direct extraction
_ => Ok(self.numeric_i64_exact()?.into()),
}
}
Numeric Coercion Details
Banker's Rounding
VB6's CInt, CLng, and CCur functions use
banker's rounding (round half to even) rather than the familiar "round half up"
taught in most schools. This means CInt(2.5) = 2 (rounds to the nearest even number)
while CInt(3.5) = 4:
' VB6 behavior:
? CInt(2.5) ' → 2 (2 is even)
? CInt(3.5) ' → 4 (4 is even)
? CInt(-2.5) ' → -2 (−2 is even)
? CInt(-3.5) ' → -4 (−4 is even)
The Rust implementation matches this exactly:
fn round_half_even(x: f64) -> Option<i64> {
if !x.is_finite() { return None; }
let fl = x.floor();
let diff = x - fl;
let rounded = if diff < 0.5 {
fl
} else if diff > 0.5 {
fl + 1.0
} else if (fl as i64) % 2 == 0 {
fl // tie: round to even
} else {
fl + 1.0
};
if rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
None // overflow → error 6
} else {
Some(rounded as i64)
}
}
Boolean Coercion
Boolean conversion follows CBool semantics: a value is True if and only if
it is non-zero. The special cases include:
| Input | CBool Result |
Notes |
|---|---|---|
Empty | False | Uninitialized = zero |
Null | Error 94 | Invalid use of Null |
Integer(0) | False | Zero |
Integer(1) | True | Non-zero |
Boolean(True) | True | Identity |
String("True") | True | Case-insensitive match |
String("False") | False | Case-insensitive match |
String("42") | True | Parseable number, non-zero |
String("abc") | Error 13 | Type mismatch |
String-to-Numeric Parsing
The parse_vb_number() function handles VB6's numeric string conventions:
&Hprefix for hexadecimal:&H1F→ 31&Oprefix for octal:&O10→ 8- Type suffixes stripped before parsing:
%(Integer),&(Long),!(Single),#(Double),@(Currency),$(String) - Standard decimal/floating-point parsing for all other strings
// parse_vb_number examples:
parse_vb_number("&H1F") → Some(31.0)
parse_vb_number("&O10") → Some(8.0)
parse_vb_number("5%") → Some(5.0) // % suffix stripped
parse_vb_number("-1.5") → Some(-1.5)
parse_vb_number("abc") → None // unparseable
parse_vb_number("") → None // empty
Equality and Comparison
The PartialEq implementation for VBVariant is one of the most subtle parts
of the value system, because VB6 equality involves implicit type coercion that differs from both
Rust's strict equality and the C++ VARIANT spec.
2. Object identity: Object → pointer equality (not structural)
3. Currency equality: Currency ↔ Currency → scaled integer comparison
4. Exact i64: Both convert to i64 without loss? → compare i64
5. Coerced f64: Both convert to f64? → compare f64
6. Fallback: false
The two-stage coercion approach is key: first, try comparing via exact i64 conversion
(preserving integer semantics), then fall back to f64 coercion for floating-point:
// Equality follows VB6 coercion rules:
assert_eq!(VBVariant::Integer(1), VBVariant::Double(1.0)); // ✓ numeric coercion
assert_eq!(VBVariant::Integer(1), VBVariant::from_string("1")); // ✓ string → number
assert_eq!(VBVariant::from_currency_scaled(10000), VBVariant::Double(1.0)); // ✓ currency
// But VB6's boolean coercion is specific:
assert_eq!(VBVariant::Boolean(true), VBVariant::Integer(-1)); // ✓ VB6: True = -1
assert_ne!(VBVariant::Boolean(true), VBVariant::Integer(1)); // ✗ True ≠ 1 in VB6
assert_eq!(VBVariant::Boolean(false), VBVariant::Integer(0)); // ✓ False = 0
// Empty acts as zero:
assert_eq!(VBVariant::empty(), VBVariant::Long(0)); // ✓ Empty → 0
// Null equals only itself:
assert_ne!(VBVariant::null(), VBVariant::Long(0)); // ✗
assert_ne!(VBVariant::null(), VBVariant::empty()); // ✗
⚠️ VB6 Boolean Equality Quirk: In VB6, the Boolean True coerces to
-1 (not 1) in numeric contexts. This means True = -1 but
True <> 1 in VB6. This is a well-known VB6 gotcha that the Rust implementation faithfully
reproduces via numeric_i64_exact() returning Some(-1) for
Boolean(true).
Object equality uses pointer identity, not structural equality. Two objects of the same type with the same properties are not equal unless they are the same reference:
let obj = TestObject("Coll");
let a = VBVariant::from_object(Box::new(obj));
let b = VBVariant::from_object(Box::new(TestObject("Coll")));
assert_ne!(a, b); // different pointers, even if types match
let a2 = a.clone();
assert_ne!(a, a2); // even a clone is a different reference!
Null Propagation
VB6 Null represents unknown or invalid data. It is distinct from
Empty (uninitialized) and cannot be converted to any concrete type
without raising error 94 (Invalid use of Null):
| Operation | Result | VB6 Behavior |
|---|---|---|
Null = Null | true | Null equals only itself |
Null + 5 | Error 94 | Null propagates through arithmetic |
CStr(Null) | Error 94 | Cannot convert Null to string |
CInt(Null) | Error 94 | Cannot convert Null to integer |
CBool(Null) | Error 94 | Cannot convert Null to boolean |
CDbl(Null) | Error 94 | Cannot convert Null to double |
However, some operations treat Null specially. The Nullable<T> type in
the boundary layer allows certain function parameters to accept Null without raising an
error, propagating it as a result value instead:
// Regular typed parameter: Null → error 94
let args = [VBVariant::Null];
arg::(&args, 0).unwrap_err(); // error 94: invalid use of Null
// Nullable parameter: Null → None (no error)
let converted: Nullable = arg(&null, 0).unwrap();
assert!(converted.is_null()); // propagates, doesn't raise
Date Serialization
VB6 dates are stored as f64 serial numbers representing the number of days since
December 30, 1899 — the OLE Automation date epoch. This is a fractional value where
the integer part is the date and the fractional part represents the time of day:
| Serial | Date | Time | |
|---|---|---|---|
0.0 | 12/30/1899 | 00:00:00 | |
1.0 | 12/31/1899 | 00:00:00 | |
2.0 | 1/1/1900 | 00:00:00 | |
0.5 | 12/30/1899 | 12:00:00 | |
-693594.0 | 12/30/1899 | 00:00:00 | (same as 0 via leap year bug) |
Date serialization converts from civil date/time to serial:
// Parse date string → serial
parse_vb_date("1/1/2026") → Some(46023.0)
// Parse date + time → serial
parse_vb_date("1/1/2026 14:30:00") → Some(46023.5625)
// Reverse: serial → civil
date_serial_to_datetime(46023.0) → DateTime(2026, 1, 1, 0, 0, 0)
date_serial_to_string(0.0) → "12/30/1899"
Date string parsing handles multiple formats including M/D/YYYY, YYYY-M-D,
YYYYMMDD, and 12/24-hour mixed time formats with AM/PM. Two-digit years follow VB6's
split: 0–29 map to 2000–2029, and 30–99 map to 1930–1999.
Currency Representation
VB6's Currency type is a fixed-point 64-bit type stored as a scaled integer. The
CURRENCY_SCALE constant is 10,000, meaning that the value 1.25 is stored internally as
12500:
pub const CURRENCY_SCALE: i64 = 10_000;
// Internal representation:
VbCurrency::from(12500) // → represents 1.25
VbCurrency::from(10000) // → represents 1.00
VbCurrency::from(0) // → represents 0.00
// Conversion to/from decimal:
VBVariant::from_currency(1.25) // f64 → i64: 1.25 * 10000 = 12500
VBVariant::from_currency_scaled(12500).as_string() // "1.25"
Currency conversion uses banker's rounding (the same round_half_even() function) to
handle the f64 → scaled integer transition without introducing floating-point artifacts. String
formatting strips trailing zeros from the fractional part:
| Scaled Value | String Output |
|---|---|
12500 | "1.25" |
10000 | "1" |
12345 | "1.2345" |
-12500 | "-1.25" |
Array Support
VB6 arrays are represented by the ArrayValue struct, which stores elements in a
flat buffer (Vec<VBVariant>) with one ArrayDimension
per rank. Unlike Rust slices (which are 0-based with upper-exclusive bounds), VB6 arrays use
inclusive bounds with a default lower bound of 1 — though any bounds are legal
(Dim x(-2 To 5) is valid):
pub struct ArrayValue {
element_type: VbType,
dimensions: Vec<ArrayDimension>, // one per rank, inclusive bounds
data: Vec<VBVariant>, // flat buffer, row-major order
}
pub struct ArrayDimension {
pub lower: i32, // inclusive lower bound (default: 1)
pub upper: i32, // inclusive upper bound
}
impl ArrayDimension {
pub fn len(&self) -> usize {
(self.upper as i64 - self.lower as i64 + 1).max(0) as usize
}
}
Multi-dimensional arrays use row-major ordering (last dimension varies fastest), and the offset calculation handles arbitrary bounds — negative, zero-based, or mixed:
// Arbitrary bounds are supported:
let mut arr = ArrayValue::new_fixed(
VbType::String,
&[ArrayDimension::new(-2, 0)] // -2, -1, 0 → 3 elements
).unwrap();
arr.set(&[-2], VBVariant::from_string("a")).unwrap();
arr.set(&[0], VBVariant::from_string("c")).unwrap();
// Multi-dimensional (row-major, 2×3):
let dims = [
ArrayDimension::new(1, 2), // first dimension: 1, 2
ArrayDimension::new(1, 3), // second dimension: 1, 2, 3
];
let mut arr = ArrayValue::new_fixed(VbType::Integer, &dims).unwrap();
arr.set(&[2, 3], VBVariant::Long(23)).unwrap(); // row 2, col 3
// Flat buffer: [11, 12, 13, 21, 22, 23]
Dynamic arrays (created with Dim x() or New) require ReDim
before access. The is_initialized() method tracks whether ReDim has been
called:
let arr = ArrayValue::new_dynamic(VbType::Integer);
assert!(!arr.is_initialized());
arr.get(&[1]).unwrap_err(); // error 9: subscript out of range
Argument Boundary Layer
The boundary layer in boundary.rs is the single conversion point between the untyped
[VBVariant] argument slices that host environments pass to library functions and the
typed wrappers that library functions declare. It provides three families of helper functions:
// Convert argument at index, raising error 450 if absent
pub fn arg<'a, T>(args: &'a [VBVariant], index: usize) -> VbResult<T>
where T: TryFrom<&'a VBVariant, Error = VbError>
// Like arg, but absent → None; present still converts eagerly
pub fn opt_arg<'a, T>(args: &'a [VBVariant], index: usize) -> VbResult<Option<T>>
where T: TryFrom<&'a VBVariant, Error = VbError>
// Borrow raw variant without coercion (for predicates, Null-inspection)
pub fn variant_arg(args: &[VBVariant], index: usize) -> VbResult<&VBVariant>
// Null-propagating: Null → Nullable::Null instead of error 94
pub enum Nullable<T> {
Value(T),
Null,
}
The evaluation order is canonical and deterministic:
- Presence check — absent arguments raise error 450 (Wrong number of arguments) before any conversion
- Left-to-right conversion — arguments are converted in declaration order, so the leftmost offending argument's error wins
- Conversion — routes through the wrapper's
TryFrom, which encodes all VB6 coercion rules (error 6, 13, 94, or CVErr passthrough)
Why the boundary layer matters: Without it, each library function could implement
its own argument checking and coercion, leading to inconsistent error numbers, wrong evaluation order,
and CVErr values being silently converted instead of re-raised. The boundary layer
centralizes all of this into a single, well-tested set of helpers.
Object References
Object values in VBVariant are stored as Box<dyn VbObject>, where
VbObject is a trait that requires Debug, Send + Sync, and a
clone_box method for type-erased cloning:
pub trait VbObject: fmt::Debug + Send + Sync {
fn type_name(&self) -> &str; // e.g., "Collection", "StdPicture"
fn as_any(&self) -> &dyn std::any::Any;
fn clone_box(&self) -> Box<dyn VbObject>;
}
// Implementation:
impl Clone for VBVariant {
fn clone(&self) -> Self {
match self {
VBVariant::Object(o) -> VBVariant::Object(o.clone_box()),
_ => /* ... */,
}
}
}
Object equality is pointer-based: two objects are equal if and only if they are the same reference
(same memory address). This matches VB6's Set a = b semantics where object variables
hold references, not values.
Design Decisions
| Decision | Options Considered | Chosen |
|---|---|---|
| Flat buffer vs. recursive arrays | Flat Vec<VBVariant> vs. nested Vec<Self> |
Flat — simpler bounds checking, matches C-style array layout, easier to serialize |
| Currency as scaled i64 vs. f64 | Scaled integer (fixed-point) vs. IEEE 754 double | Scaled i64 — avoids floating-point artifacts in financial calculations, matches VB6's actual 64-bit scaled representation |
| Booleans as true/-1 vs. true/1 | C convention (1/0) vs. VB6 convention (-1/0) | VB6 convention — True = -1 is required for correct equality and arithmetic |
| Object equality | Structural equality vs. pointer identity | Pointer identity — matches VB6's reference semantics; structural equality would require deep comparison of unknown COM objects |
| Array bounds | Rust-style 0-based exclusive vs. VB6-style inclusive | Inclusive bounds with arbitrary lower — matches VB6's Dim x(1 To 5) and Option Base behavior |
| Null handling | Null propagates as error vs. Null as a distinct value | Distinct value that propagates through typed conversion (error 94) but is caught by Nullable<T> for Null-tolerant parameters |
| Coercion path | Per-function coercion vs. centralized TryFrom |
Centralized — every coercion goes through wrapper TryFrom<&VBVariant> for uniform behavior |
Future Considerations
1. Arithmetic Operators on VBVariant
Currently, arithmetic is performed by converting variants to their native types, applying the
operator, then wrapping the result back. Implementing ops::Add, ops::Mul,
etc. directly on VBVariant would provide:
- Type-promotion rules (e.g., Integer + Double → Double)
- Overflow detection at the operator level
- Null propagation at the operator level
Trade-off: Adding trait implementations to a large enum in Rust requires a match arm per variant, increasing code size. But it would centralize arithmetic coercion logic.
2. Performance: Tagged Union vs. Arena
Currently, VBVariant stores data inline (except objects and arrays which are heap-allocated
via Box). For hot paths with many small numeric variants, an arena-based approach could
reduce allocations:
- Store small values (f64 + tag) in a tight struct
- Heap-allocate only Strings, Objects, and Arrays
- Match the layout of the C++
VARIANTtype more closely
Challenge: Rust's enum layout is already quite efficient, and the wrapper type system adds indirection regardless. Benchmarks would be needed.
3. Type-Safe Variant Views
The current as_* methods consume ownership. A borrow-only variant view system could
enable read-only access without consuming the variant:
v.as_number() -> Option<&Numberv>returning a borrowed number view- Avoids cloning for read-heavy paths
Benefit: Cleaner API for library functions that inspect but don't modify arguments.
Summary
The VBVariant value system is a carefully designed type coercion engine that faithfully replicates VB6's dynamic typing in Rust. Its key characteristics:
- 14-variant enum representing every VB6 value kind, including Empty, Null, Nothing, and CVErr as distinct concepts
- Typed wrapper types (VbLong, VbInteger, etc.) that encode VB6 coercion semantics at
the type level via
TryFrom - Banker's rounding for all integer conversions, matching VB6's
CIntandCLngexactly - Two-stage numeric equality — exact i64 comparison first, then f64 coercion fallback — that handles cross-type equality correctly
- Null as error 94 —
Nullis distinct fromEmptyand raises on typed conversion, but propagates throughNullable<T> - OLE Automation dates — 1899-12-30 epoch serial with fractional day for time
- Scaled integer currency — fixed-point i64 with CURRENCY_SCALE of 10,000
- Flat-buffer arrays — row-major storage with arbitrary inclusive bounds and dynamic/static distinction
- Centralized boundary layer — single argument coercion path ensuring uniform error handling and evaluation order
The system was designed because:
- ✅ VB6's type system is fundamentally dynamic — every value carries its type
- ✅ Coercion semantics differ from both Rust and C++ VARIANT specifications
- ✅ Uniform conversion paths prevent drift between library function implementations
- ✅ Null and Empty are distinct concepts with different conversion rules
- ✅ Boolean -1 (not 1) is required for correct VB6 arithmetic and equality
- ✅ Currency needs fixed-point arithmetic, not floating-point