Years ago I remember reading references that you could link in C object files
into your vb6 projects. This allows you to code some portions in C, yet only
distribute one file and not have to include a separate dll. I also remember
hearing about people working to be able to write standard dlls (not activex)
to use from other languages that did not require COM stuff or having to be
registered on the system before use.

If i remember correctly these were features offered by commercial vb6 addins.
Eventually I got curious and wanted to explore this area of VB6. 

As i scoured the net, I eventually found an article and tool released by Jim White
on how to create a standard dll in vb6 for use in other languages. 

This tool works by renaming the original link.exe and putting a custom one in its
place to control the link process. He also worked out what kind of dllmain was
required to initialize the vb6 runtime and COM. It was a great piece of research
and I am so glad he released it open source! Thank you Jim! (A copy of the article
is also included in the repo)

From here, I wanted to experiment and figure out how to compile in the C object code.
There are several techniques you can use. The first one I used built directly on his
examples. I added the C object modules to the vb compile, and then added each of the
new functions to the executables export table. They were now accessible through VBs
api declare mechanism. When running in the IDE, you would use the IDE version loaded
from the external dll, when running as an EXE you would use the api declare that aimed
at the exe itself.

Private Declare Function to64 Lib "project1.exe" ( _
ByVal hi As Long, ByVal lo As Long) As Currency

Private Declare Function dll_to64 Lib "c_obj.dll" Alias "to64" (_
ByVal hi As Long, ByVal lo As Long) As Currency

Function doto64(hi As Long, lo As Long) As Currency
    If IDE Then
        doto64 = dll_to64(hi, lo)
    Else
        doto64 = to64(hi, lo)
    End If
End Function
This worked, but its bulky. Re reading some of the other documentation on what other tools did, they seemed to replace an entire module (.bas) with their own c obj file. Ok..how did they do that? In VB, everything is a C++ class, even your modules. They are actually global multiuse classes. If you look at them in a disassembler you see the following:
void __stdcall Module1::test(Module1 *this) ?test@Module1@@AAGXXZ

Function test(x As String)
    MsgBox x
End Function
If we want our C object module to successfully replace the vb6 object module and link into an executable, we need our C functions to have the exact same function prototypes. This includes the class structure to get the proper name mangling. This also means that you have to replace the entire module and all of its function since they form a single class. After a ton of searching and experimentation I found that the following works:
//this is the name of the module were replacing..
#define MODNAME Module1  

//this structure is required to match the vb6 name mangling of AAGXXZ
class MODNAME
{
private:
   void __stdcall MODNAME::init();  
   void __stdcall MODNAME::to64();
   void __stdcall MODNAME::sub64();
   void __stdcall MODNAME::add64();
   void __stdcall MODNAME::hex64();
};

//since our private class functions actually do have arguments, 
//the implementation of the private functions has to redirect 
//to the correct implementation. The real implementations are 
//also exported from the dll so that they can be used when 
//in dll form.
#define JMPFUNC(Name)   __declspec(naked) void __stdcall  MODNAME::Name () { _asm jmp _##Name }

JMPFUNC(init)
JMPFUNC(to64)
JMPFUNC(sub64)
JMPFUNC(add64)
JMPFUNC(hex64)
Ok, so now we have our vb6 functions being replaced at runtime with the C code. what about when we are running in the IDE and developing our application? Since we already have to compile in a VB module with the same interface as a place holder, we just make it functional by utilizing the exact same functions from the C code, compiled as a dll.
Private Declare Function dll_init Lib "c_module.dll" Alias "init" (ByVal lpfnGetProc As Long, ByVal lpfnLoadLib As Long) As Long
Private Declare Function dll_to64 Lib "c_module.dll" Alias "to64" (ByVal hi As Long, ByVal lo As Long) As Currency
Private Declare Function dll_add64 Lib "c_module.dll" Alias "add64" (ByVal a As Currency, ByVal b As Currency) As Currency
Private Declare Function dll_sub64 Lib "c_module.dll" Alias "sub64" (ByVal a As Currency, ByVal b As Currency) As Currency
Private Declare Function dll_hex64 Lib "c_module.dll" Alias "hex64" (ByVal a As Currency) As String


Function init(ByVal lpfnGetProc As Long, ByVal lpfnLoadLib As Long) As Long
    init = dll_init(lpfnGetProc, lpfnLoadLib)
End Function

Function to64(ByVal hi As Long, ByVal lo As Long) As Currency
    to64 = dll_to64(hi, lo)
End Function

Function add64(ByVal a As Currency, ByVal b As Currency) As Currency
    add64 = dll_add64(a, b)
End Function

Function sub64(ByVal a As Currency, ByVal b As Currency) As Currency
    sub64 = dll_sub64(a, b)
End Function

'not sure why but when going through an API declare, our BSTR return is expanded again on return?
'but when run from the internal obj file is functions as expected...work around for now..
Function hex64(ByVal a As Currency) As String
    hex64 = dll_hex64(a)
    hex64 = StrConv(hex64, vbFromUnicode)
End Function
Now there are some differences between executing a function through an api declare and an internal function. Remember from the previous section how the api mechanism converts strings. You will see an example of having to deal with that in the hex64 function. A real world project that is already using this technique is Vladimir Vissoultchev's vbsqlite project. His solution makes allot of sense. He converts all strings manually to C style strings, and then passing in the string address to the api. This is a good route, especially when integrating with a gigantic preexisting code base that expects C style strings.
'SQLITE_API int SQLITE_STDCALL sqlite3_open_v2(
'  const char *filename,   /* Database filename (UTF-8) */
'  sqlite3 **ppDb,         /* OUT: SQLite db handle */
'  int flags,              /* Flags */
'  const char *zVfs        /* Name of VFS module to use */
');

Public Function OpenDb(FileName As String) As Boolean
    Dim baName()        As Byte
    
    If m_hDb <> 0 Then
        Call vbsqlite3_close_v2(m_hDb)
        m_hDb = 0
    End If
    flags = SQLITE_OPEN_READWRITE
    baName = pvToUtf8(FileName)
    pvResult = vbsqlite3_open_v2(VarPtr(baName(0)),VarPtr(m_hDb), flags, 0)
     
    OpenDb = True
End Function

Private Function pvToUtf8(sText As String) As Byte()
    Dim baBuffer()      As Byte
    Dim lSize           As Long
    
    ReDim baBuffer(0 To 4 * Len(sText)) As Byte
    lSize = WideCharToMultiByte(CP_UTF8, 0, StrPtr(sText), _
              Len(sText), baBuffer(0), UBound(baBuffer), 0, 0)
    ReDim Preserve baBuffer(0 To lSize) As Byte
    pvToUtf8 = baBuffer
End Function
If you are coding from scratch you could also use Variants instead of strings or compile your C code to accept LPWSTR pointers and pass in the vb6 strptr (if you arent modifying them). One of the problems I was having when I was experimenting is that if my C code made use of WinApi I was getting a corrupted executable. I had to resort to loading the api myself manually in an init() method and call them all through function pointers that I loaded by passing in references to LoadLibrary and GetProcAddress.
typedef FARPROC  (__stdcall *GetProc)(HMODULE a0,LPCSTR a1);
typedef HMODULE  (__stdcall *LoadLib)(LPCSTR a0);
typedef BSTR (__stdcall *SysAllocLen)(void* str, int sz);
typedef int (__cdecl *Sprnf)(char *, const char *, ...);
typedef int (__cdecl *Strlen)(char *);

int __stdcall _init(int lpfnGetProc, int lpfnLoadLib){
#pragma EXPORT 	 
	 HMODULE h = 0;
	 int failed = 0;

	 //_asm int 3
	 getproc = (GetProc)lpfnGetProc;
     loadlib = (LoadLib)lpfnLoadLib;

	 h = loadlib("kernel32.dll");
	 mb2wc = (Mb2wc)getproc(h,"MultiByteToWideChar");

	 h = loadlib("msvcrt.dll");
	 sprnf = (Sprnf)getproc(h,"sprintf");
	 strln = (Strlen)getproc(h,"strlen");
	 
	 h = loadlib("oleaut32");
	 sysAlloc = (SysAllocLen)getproc(h,"SysAllocStringLen");
	 
	 if( (int)mb2wc == 0) failed++;
	 if( (int)sprnf == 0) failed++;
	 if( (int)strln == 0) failed++;
	 if( (int)sysAlloc == 0) failed++;

	 return failed;
}
While this works, its bulky and allot of typing. Thankfully Vladimir was able to come up with a solution to this problem by adding in the following to the linker command line:
KERNEL32.LIB /OPT:NOREF

"These are required as build.bat in lib compiles sqlite sources with /MD option 
(link with MSVCRT.LIB) and then linker needs KERNEL32.LIB to find all used winapi 
functions. Option /OPT:NOREF is needed for the final VbSqlite.dll to import 
VB6 run-time functions from MSVBVM60.DLL, otherwise the linker will skip 
VbSqlite.obj as containing only unreferenced symbols and produce invalid executable."
So there we have it a decent ability to link in C object files directly into your VB6 executables! Another bit worth mentioning, Jim's original link tool works off of commands in a custom .vbc file. I extended this for my experiments. Vladimir wrote his own link replacement that operates automatically behind the scenes. If it locates cobj files with the same name as an object file in your project, it will automatically swap them out. This is handy so you dont have to remember to generate a vbc file or remember the command file syntax everytime. One last thing i wanted to experiment with, was the ability to only replace a specific function in a module. Adding an entirely new module file for just a couple functions would annoy me. Could this be done? The answer again was yes. Since the linker knows of a functions existence from the obj file, we can wipe any reference of the vb6 implementation from the vb produced object file, and then just add in our own new object file that contains that one function. The linker will now pull from the C implementation and compile that into the final executable. Its kind of a dirty hack but it works! :) A couple more notes that Vladimir compiled while researching .obj swapping: 1. In your JMPFUNC macro you are forwarding to _##Name. In sqlite codebase all functions are prefixed by `sqlite3_` and it made sense to keep the same names in the .bas file. Unfortunately `__asm jmp Name` is not working as Name resolves to current function name -- the class MODULE method currently compiled. I tried '__asm jmp ::Name' explicitly specifiying global scope but apparently VC6 has troubles with scopes in assembly and errors with "illegal number of operands" for the jmp instruction. That's why I had to resort 'vbsqlite_' prefix in .bas file, just to be able to distinguish from original codebase 'sqlite3_' prefix in the JMPFUNC stubs. 2. There is a /FORCE linker option that forces it to produce executable even there are duplicate symbols found in .obj files. This allows to selectively replace single .bas module function (w/ surrogate .cobj files for instances), provided that the original .obj files comes *after* the replacement .cobj in linker params. The hack comes kind of ugly, but patching .obj files is way beyond any assessment of beauty :-)) I didn't actually try this method for selective function replacement but speculate that it could fly with enough persistence.