Interestingly, the compiler gives me this warning:

I'll answer this first because the rest can't be done properly with this in the way. The compiler can generate prolog/epilog code for a function:

SwitchContext:
               push ebp         ; save the previous frame pointer
               mov  ebp, esp    ; setup the frame pointer
               sub  esp, 8      ; space for local variables
               ...              ; function code
               mov  esp, ebp    ; restore the stack pointer
               pop  ebp         ; restore the frame pointer
               ret


Now it should be obvious what's the warning about. The compiler uses ebp for its own purposes and warns you if you change it in inline asm. This compiler generated code can be a problem when you want to implement context switching because it affects the stack layout. You can implement SwitchContext even if such code is present but creating the stack for a new thread will be a problem because you don't know what stack layout SwitchContext expects.

A possible solution is to use __declspec(naked) (which prevents such code from being generated) and __fastcall (which causes the first 2 arguments of the function to be passed in registers ecx and edx).

I'm a bit rusty on my x86 assembly, but what I don't understand is that the code is moving oldStack into eax, then a few lines later, esp is moved into eax. Is my translation of the original code even correct?

Nope. In the original version there were some parathesis which you ignored. Something like mov %esp, (%eax) converts to mov [eax], esp.

Here's an example that uses __declspec(naked) and __fastcall):

__declspec(naked) void __fastcall SwitchContext(VirtualThread* pOld, VirtualThread* pNew)
{
    // N.B. the following code assumes that NativeStack is at offset 4 in VirtualThread. If that's not true
    // then the appropiate offset needs to be used when storing/loading the stack.
    __asm
    {        
        // Save registers
        push ebp
        push ebx
        push esi
        push edi
        // Save old stack
        mov [ecx+4], esp    // store to pOld->NativeStack
        // Load new stack
        mov esp, [edx+4]    // load from pNew->NativeStack
        // Restore registers
        pop edi
        pop esi
        pop ebx
        pop ebp
        ret
    }
}

So what I'm missing right now is a way to set up these stacks for the initial run

To create a new thread you have to allocate memory for its native stack and setup the stack exactly the same as in SwitchContext does. Once the thread stack is properly initialized you can simply call SwitchContext to start the new thread.

In addition, my assumption here is that my VirtualThread class will not only hold the "virtual stack", but also this native stack that will be used to do context switching for each thread.

Yes, if you create thread you'll need to also allocate the native stacks yourself.

void InitializeContext(VirtualThread* pNew, void *startFunction)
{    
    int stackSize = 4096;
    char *stack = reinterpret_cast<char *>(malloc(stackSize)); // allocate some space, malloc is just an example
    stack += stackSize; // go to the top of the stack because the stack grows down
    
    __asm
    {        
        mov ecx, esp // save current stack pointer
        mov esp, stack

        mov eax, startFunction
        push eax    // push the start function address as the return address (of SwitchContext)

        // Save registers
        xor eax, eax // let's always start a thread with zeroed registers
        push eax
        push eax
        push eax
        push eax

        mov stack, esp
        mov esp, ecx // restore the stack pointer
    }
   
    pNew->NativeStack = stack;
}