The cpp backend emits one .hpp per pyxis module (with a matching .cpp
when there are out-of-line definitions to host), plus a shared
include/pyxis_runtime.hpp and a normative top-level CMakeLists.txt.
This document records the non-obvious design decisions and their
trade-offs — things you'd notice when binding the output to a real
binary that you wouldn't pick up by reading the code in isolation.
Output layout looks like this:
out/
├── CMakeLists.txt
├── include/
│ ├── pyxis_runtime.hpp
│ ├── <module>.hpp
│ └── <nested>/<module>.hpp
└── src/
├── <module>.cpp (only when needed)
└── <nested>/<module>.cpp
The .hpp/.cpp split is opinionated: pyxis hoists every non-template
out-of-line method definition into the .cpp so the header stays a
declaration surface. Template members stay header-visible (the compiler
needs them at every instantiation site). The CMakeLists.txt uses
GLOB_RECURSE so adding or removing modules is transparent.
#[base] regions become composition, not C++ inheritance. A type
Derived with #[base] base: Base lays out as:
struct Derived {
Base base; // by-value, occupies the same offsets as `Base`
// ...derived-only fields below...
};Plus an implicit conversion operator on the derived type:
struct Derived {
operator Base&() { return this->base; }
operator const Base&() const { return this->base; }
};Pyxis's job is to describe layouts that already exist in a binary. Real C++ inheritance brings layout decisions that aren't ours to make: the compiler chooses base-class offsets, may insert padding for alignment, picks a vtable layout, and on multiple inheritance generally inserts adjustor thunks. We can't reliably match a specific binary's layout while letting the compiler "help."
Composition + an explicit Base field keeps the IR's layout
authoritative. Derived is guaranteed identical to Base { ... extra fields } because that's literally what the struct is.
Derived and Base are not implicitly convertible at the pointer
level. Derived* does not coerce to Base*; only Derived& coerces
to Base& (via the operator Base&() we emit).
Any binary-bound API typed as Base* requires &derived->base at the
call site. Example:
void some_engine_api(Base* b);
Derived* d = ...;
some_engine_api(d); // ❌ no implicit Derived*→Base* conversion
some_engine_api(&d->base); // ✅ explicit upcast through the fieldIn Rust the equivalent is d.as_ref() (which pyxis generates via
AsRef/AsMut impls). The C++ side picks ergonomics over magic.
Each type with a vftable { ... } block in pyxis gets:
- A separate
<Type>Vftablestruct describing the function-pointer layout. - A
vftable: const <Type>Vftable*field (added implicitly if the type doesn't already inherit one through a base region). - An
_vftable_ptr()accessor on the type. - Per-vfunc wrapper methods that fetch the pointer through
_vftable_ptr(), call through it, and return the result.
The function-pointer signatures in the generated <Type>Vftable
struct take void* (or const void*) for the receiver, not
Type*:
struct BaseVftable {
void (*destructor)(void*); // not BaseA*, BaseB*, or Base*
::std::int32_t (*foo)(const void*, int);
};That lets a derived type's this flow into the slot without explicit
casts through the base chain. We can't express "any pointer to this
type or one of its bases" in C++'s type system without inheritance
(which we've already given up — see above), so void* is the
ABI-compatible escape hatch.
Inside the generated wrappers this means you're trusting the slot
index rather than getting a type-check on the call site. Wrappers are
mechanical and the layout is checked by static_assert(sizeof), so
the type-system gap is bounded; just don't expect the compiler to
catch a mistake if you write a wrapper by hand.
For a Derived whose vftable lives in a Base field, the accessor
walks the chain via reinterpret_cast:
const DerivedVftable* Derived::_vftable_ptr() const {
return reinterpret_cast<const DerivedVftable*>(this->base._vftable_ptr());
}This relies on the derived vftable's layout sharing its prefix with
the base vftable's — exactly how MSVC lays out vtables for genuine
inheritance — so the cast is safe in practice on the MSVC ABI we
target. Strict-aliasing pedants will note this is a reinterpret_cast
between unrelated pointer types; in our setting that's fine because
the underlying bytes are guaranteed to be the same vftable layout
extended at the end.
We always target MSVC ABI. The PYXIS_* macro shims defined in
pyxis_runtime.hpp (driven by src/backends/cpp/runtime.rs) expand
to __cdecl/__stdcall/etc. on MSVC, to __attribute__((cdecl))-
style attributes on clang/GCC i386, and to nothing on every other
host. The non-MSVC branches exist only so generated headers
compile-check during the dev loop — anything actually calling through
to a __stdcall binary entrypoint needs an MSVC-compatible build.
PYXIS_VECTORCALL is empty on the clang/GCC i386 branch (there's no
real vectorcall on that target). Binaries that actually depend on
vectorcall semantics must build against the MSVC arm.
Standalone prologue / epilogue statements, gated with
#[cfg(backend = "cpp")], splice user-written C++ into the emitted
module. The cpp backend uniquely supports a definition modifier:
#[cfg(backend = "cpp")]
prologue r#" ... "#; // lands in the .hpp above the namespace
#[cfg(backend = "cpp")]
epilogue r#" ... "#; // lands in the .hpp at the bottom of the namespace
#[cfg(backend = "cpp")]
prologue definition r#" ... "#; // lands in the .cpp above the namespace
#[cfg(backend = "cpp")]
epilogue definition r#" ... "#; // lands in the .cpp at the bottom of the namespace
The definition modifier requires a cfg that resolves cpp-only (an
ungated definition, or one active for a non-cpp backend, is rejected).
Common use cases:
prologuefor#include <foo>directives, template specializations, orusingaliases that need to be visible to downstream consumers.prologue definitionfor source-private#includes (e.g.<windows.h>,<d3d10.h>) that would otherwise leak into every consumer of the header.epiloguefor header-side inline method bodies (paired with pyxis's#[external_body]attribute, which declares the signature in the IR and leaves the body to the splice).epilogue definitionfor method bodies that don't need to be header-visible.
The semantic layer rejects definition for non-cpp backends — only
cpp distinguishes header from source, so the modifier is meaningless
elsewhere.
extern type Foo; declarations can carry #[cpp_header = "<atomic>"]
and #[cpp_name = "std::atomic<int32_t>"] attributes. The cpp backend
walks them once up front and:
- Adds the
cpp_headervalue to the using module's#includelist. - Substitutes the
cpp_nameverbatim at every use site (we don't emit ausing Foo = std::atomic<int32_t>;alias because the pyxis leaf name may contain generic syntax —Foo<Bar<u32>>— that's invalid on the LHS ofusing).
A pub use other::Item; re-export is emitted as a using Item = ::other::Item;
alias inside the re-exporting module's namespace, so the item is reachable as
<module>::Item from C++ consumers — mirroring the pyxis-level re-export. The
target is canonicalized through any re-export chain and rendered fully qualified
(routing through the normal type renderer, so predefined / #[cpp_name] externs
resolve correctly). The alias participates in dependency analysis like any other
cross-module reference, so the defining module's header is #included; a
same-module target needs no extra include and is still declared before the alias.
A plain use is module-private and emits nothing.
Two distinct kinds of edges:
- FullDef: by-value field, base, array element, FullDef-typed
template arg. Needs an
#includeof the defining module. - FwdOnly: pointer field, function param/return. A forward declaration is enough.
src/backends/cpp/deps.rs walks the graph for every module and
classifies edges. The header includes the FullDef deps and forward-
declares the FwdOnly ones.
Inside the namespace, every non-alias, non-extern type also gets an intra-module forward decl up front so peer items can refer to each other regardless of declaration order.
A same-module type reference normally emits the bare leaf name
(Viewport); cross-module references are fully qualified
(::ui::scaleform::Viewport). The one exception: a bare leaf is
resolved in class scope first, so a struct with a member whose name
matches a type name — legal in pyxis and Rust, where fields and types
occupy separate namespaces — poisons that name for the rest of the
class:
struct MovieImpl {
Viewport Viewport; // hides the type name
void set_viewport(const Viewport* desc); // C2327: not a type name
};To avoid this, render_struct (src/backends/cpp/render.rs) collects
the class's member names (fields + methods) up front and threads them
through RenderCtx::shadowed_members. render_path qualifies a
same-module reference only when its leaf is in that set — the qualified
name is looked up in namespace scope and bypasses the shadowing member.
Every other reference keeps its bare name, so the fix is local to the
genuine collision. The scope set covers the out-of-class method
definitions too (void MovieImpl::set_viewport(...)), whose parameter
types are still looked up in class scope. Predefined primitives and
#[cpp_name] externs are substituted before this point and are
unaffected. The field_type_name_collision corpus input pins the
behavior — the corpus build compiles the emitted C++ with the host
compiler, which also rejects the shadowed form.
A strongly-connected component on FullDef edges is a real value-cycle
that no amount of forward-decling can resolve (e.g. A { B b; } and
B { A a; }). The backend runs Tarjan's SCC over both the intra-
module item graph and the cross-module aggregate graph and errors out
with CppBackendError::LayoutCycle if it finds one. Break the cycle
by introducing a pointer indirection or moving a type into a separate
module.
The emitted CMakeLists.txt is normative — no toolchain assumptions.
On Linux:
cargo install xwinandxwin --arch x86,x86_64 --accept-license splat --output ~/.xwin.- Install LLVM ≥ 16 (
clang-cl,lld-link,llvm-lib,llvm-rc). cmake -S out -B build -DCMAKE_TOOLCHAIN_FILE=tools/cmake-toolchains/xwin-x86.cmake -DXWIN_ROOT=$HOME/.xwin.
The toolchain pins MultiThreadedDLL (release CRT) for every config
because xwin doesn't ship msvcrtd.lib. Treat Debug and Release
as differing only in optimization / debug-info, not CRT selection.
On native Windows no toolchain file is needed; point CMake at MSVC or
clang-cl and build normally.
- No virtual inheritance — composition only. Diamond inheritance shows up as two base fields.
- No RTTI — there's no
typeid,dynamic_cast, or exception hierarchy. Binary-bound APIs typically don't use these and adding them would require us to commit to a vtable layout. - No copy/move semantics — copy/move constructors/assignment are
implicit defaults.
#[copyable]/#[cloneable]only inform the Rust backend; the cpp side is always trivially-copyable when the fields are. The exception is#[pinned]: a pinned type gets deleted copy/move constructors and assignment operators, since pinned types must not be relocated in memory (the target C++ code passes pointers tothisor its fields around). - No member access control — pyxis's
pub/private distinction is rust-only. In cpp every method and field is emitted at struct scope with default visibility (public forstruct). Backend epilogues need to call into rust-private methods by name.