VB6Runtime / Documentation

Variant Value System and Type Coercion Architecture

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
Empty0Dim xEmpty
Null1N/AN/A
Byte17Dim x As Byte0
Integer2Dim x As Integer0
Long3Dim x As Long0
Single4Dim x As Single0.0
Double5Dim x As Double0.0
Currency6Dim x As Currency0
String8Dim x As String""
Boolean11Dim x As BooleanFalse
Date7Dim x As Date0.0
Error10CVErr(n)CVErr(0)
Object9Dim x As ObjectNothing
Array8192 + TDim 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
VbStringStringTryFromCStr / string conversion
VbByteu8TryFromCByte
VbLongi32TryFromCLng
VbIntegeri16TryFromCInt
VbBooleanboolTryFromCBool
VbDatef64TryFromCDate
VbSinglef32TryFromCSng
VbDoublef64TryFromCDbl
VbCurrencyi64 (scaled)TryFromCCur

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
EmptyFalseUninitialized = zero
NullError 94Invalid use of Null
Integer(0)FalseZero
Integer(1)TrueNon-zero
Boolean(True)TrueIdentity
String("True")TrueCase-insensitive match
String("False")FalseCase-insensitive match
String("42")TrueParseable number, non-zero
String("abc")Error 13Type mismatch

String-to-Numeric Parsing

The parse_vb_number() function handles VB6's numeric string conventions:

// 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.

Equality Resolution Order:
1. Same-type identity: Empty, Null, Nothing, Error, String, Boolean, Date, Array → direct comparison
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 = NulltrueNull equals only itself
Null + 5Error 94Null propagates through arithmetic
CStr(Null)Error 94Cannot convert Null to string
CInt(Null)Error 94Cannot convert Null to integer
CBool(Null)Error 94Cannot convert Null to boolean
CDbl(Null)Error 94Cannot 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.012/30/189900:00:00
1.012/31/189900:00:00
2.01/1/190000:00:00
0.512/30/189912:00:00
-693594.012/30/189900: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:

  1. Presence check — absent arguments raise error 450 (Wrong number of arguments) before any conversion
  2. Left-to-right conversion — arguments are converted in declaration order, so the leftmost offending argument's error wins
  3. 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++ VARIANT type 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:

  1. 14-variant enum representing every VB6 value kind, including Empty, Null, Nothing, and CVErr as distinct concepts
  2. Typed wrapper types (VbLong, VbInteger, etc.) that encode VB6 coercion semantics at the type level via TryFrom
  3. Banker's rounding for all integer conversions, matching VB6's CInt and CLng exactly
  4. Two-stage numeric equality — exact i64 comparison first, then f64 coercion fallback — that handles cross-type equality correctly
  5. Null as error 94Null is distinct from Empty and raises on typed conversion, but propagates through Nullable<T>
  6. OLE Automation dates — 1899-12-30 epoch serial with fractional day for time
  7. Scaled integer currency — fixed-point i64 with CURRENCY_SCALE of 10,000
  8. Flat-buffer arrays — row-major storage with arbitrary inclusive bounds and dynamic/static distinction
  9. 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