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.
A pyxis union maps straight onto a C++ union — the one place where this
backend needs no encoding tricks, since C++ has the construct natively and
with the same semantics.
union alignas(8) Payload {
::std::int32_t as_int;
float as_float;
::std::int32_t* as_ptr;
};
static_assert(sizeof(Payload) == 0x8);
static_assert(alignof(Payload) == 8);The same size/alignment static_asserts as a struct, #pragma pack(push, 1)
for #[packed], and alignas(N) from the resolved alignment. Members that are
themselves unions or structs are ordinary members, and a union's members are
full-definition dependencies exactly as a struct's fields are — so the
dependency graph orders them ahead of the union in the header.
Forward declarations use union Name; rather than struct Name;: C++ requires
the class-key to match the definition.
Nested item declarations inside a union body are rendered in-class, the same way
a struct's are. Inline pub payload: union { ... } fields become module-scope
union {Type}{Field}Union definitions with an ordinary member referring to
them, so the parent struct is unremarkable.
A pyxis fn(A, B) -> R becomes a C++ function pointer. C declaration syntax
nests inside-out, so the backend builds declarations with a declarator walker
(render_declaration in src/backends/cpp/render/types.rs) rather than
concatenating a rendered type with a name. This is what makes the nested cases
come out right:
| pyxis | C++ |
|---|---|
f: fn(*mut Engine) |
void (*f)(Engine*); |
f: [fn(*mut Engine); 4] |
void (*f[4])(Engine*); |
f: *mut fn(*mut Engine) |
void (**f)(Engine*); |
fn install(...) -> fn(*mut Engine) |
void (*install(...))(Engine*); |
f: *const fn(*mut Engine) |
void (*const *f)(Engine*); |
Gluing render_type's type-id form onto a name would produce
void (*)(Engine*) f[4], which is not valid C++.
*const T qualifies the pointee, and for a function or array pointee there is
no base type to hang const on — const void (**f)(Engine*) would qualify the
return type instead. The walker therefore carries const-ness down and spends
it on the * that the qualified level itself contributes.
Parameter types are rendered with render_parameter_type, which keeps an
array's extent. A declaration and the using fn_t = ... alias its body calls
through must agree on every parameter; rendering one as uint32_t x[4] and the
other as uint32_t produces C++ that doesn't compile. Note that C++ decays an
array parameter to a pointer where Rust passes it by value, so a by-value array
parameter has a different ABI in the two backends — a pre-existing property of
array parameters, not of function pointers.
Parameter names on a pyxis function-pointer type are dropped in the C++ output — only the parameter types are emitted, as they are for vftable slots.
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.
Doc comments are emitted as /// comments, which doxygen picks up natively. Rustdoc-style intra-doc links are rewritten from their resolved semantic targets into doxygen-markdown references:
/// Link to a field as [`Self::field`](@ref doc_self_links::Container::field).Rendering follows the C++ emission rules rather than the written path: module segments become namespaces, nested types stay genuinely nested (ns::Outer::Inner), extern values map to their get_<name>() accessor, and nested constants/extern values under an enum or bitflags parent — which the emitter flattens to module scope, since those have no struct body — become ns::Parent_NAME / ns::Parent_get_name. Links to predefined primitives and out-of-tree extern types have no documented entity to reference and are flattened to their bare label instead of emitting a dead link.
test.py verifies the emitted corpus with doxygen (any warning, including an unresolvable @ref, fails the suite). The check is skipped with a warning when doxygen isn't installed; shell.nix provides it.
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 union member access helpers — a union's members are emitted as plain members. Which one is live is a property of the surrounding data, and pyxis deliberately does not model that relationship, so there is nothing for the backend to generate an accessor from.
- 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.