Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/generating_csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ Eventually we might add translation from the Doxygen comment annotations to thos

This should be enough to at least display those comments in IDEs, but any `@...` doxygen tags will be left as is, instead of being translated into their proper XML form.

## Exposed structs at the P/Invoke boundary

Structs exposed via `--expose-as-struct` (see [the C docs](/docs/generating_c.md#expose-simple-structs-as-structs)) are passed by value between C and C# as blittable C# structs, with one exception. If an exposed struct has exactly one field, and that field is a scalar (an arithmetic type other than `bool`, or an enum), then the `DllImport` declarations use that scalar directly, and the struct is wrapped and unwrapped on the C# side. The public C# API is unaffected, and the C side is unaffected too, because in C both spellings have the same ABI.

This is needed for Unity's IL2CPP on WebAssembly. IL2CPP wraps every C# struct into a union with padding, and on wasm32 Clang only passes and returns single-element structs as plain scalars, which that wrapper no longer is. So the IL2CPP-compiled call site would return such a struct through a hidden pointer and pass it by pointer, while the C library returns and accepts a plain scalar (`wasm-ld` reports this as `function signature mismatch`, and the calls trap or read garbage at runtime). A scalar has the same ABI everywhere. Structs with more than one field don't have this problem, since both sides agree on passing those indirectly.

## Distributing the C# bindings as a Nuget package

This is not a full explanation, but a rought outline of what you need to do.
Expand Down
94 changes: 83 additions & 11 deletions src/generators/csharp/generator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,56 @@ namespace mrbind::CSharp
return CppToCSharpIdentifier(name.parts.back());
}

std::optional<Generator::ExposedStructSingleScalarField> Generator::GetExposedStructSingleScalarField(const CInterop::TypeKinds::Class &class_desc, const cppdecl::QualifiedName &cpp_class_name)
{
assert(class_desc.kind == CInterop::ClassKind::exposed_struct);

// Find the only non-static field, if any.
const CInterop::ClassField *field = nullptr;
for (const CInterop::ClassField &elem : class_desc.fields)
{
if (elem.is_static)
continue;
if (field)
return {}; // More than one field.
field = &elem;
}
if (!field)
return {};

cppdecl::Type cpp_type = ParseTypeOrThrow(field->type);
if (!cpp_type.modifiers.empty())
return {}; // Arrays and pointers.
cpp_type.RemoveQualifiers(cppdecl::CvQualifiers::const_);
const std::string cpp_type_str = CppdeclToCode(cpp_type);

std::string csharp_type;
if (auto prim = c_desc.platform_info.FindPrimitiveType(cpp_type_str))
{
// Not `bool`, because the C# struct stores it as a `byte` behind a property, and because `bool` isn't blittable when passed by value anyway.
if (prim->kind == PrimitiveTypeInfo::Kind::boolean)
return {};

auto csharp_type_opt = CToCSharpPrimitiveTypeOpt(cpp_type_str, false);
if (!csharp_type_opt)
return {};
csharp_type = std::string(*csharp_type_opt);
}
else if (auto type_desc = c_desc.FindTypeOpt(cpp_type_str); type_desc && std::holds_alternative<CInterop::TypeKinds::Enum>(type_desc->var))
{
csharp_type = CppToCSharpEnumName(cpp_type.simple_type.name);
}
else
{
return {}; // Nested exposed structs and anything else.
}

return ExposedStructSingleScalarField{
.csharp_type = std::move(csharp_type),
.csharp_field_name = CppToCSharpFieldName(cpp_class_name, false, field->full_name),
};
}

std::string Generator::CppToCSharpByValueHelperName(cppdecl::QualifiedName name, bool is_shared)
{
// Must make this before adjusting the name.
Expand Down Expand Up @@ -1704,15 +1754,40 @@ namespace mrbind::CSharp
const std::string csharp_value_type = CppToCSharpExposedStructName(cpp_effective_type.simple_type.name);
const std::string csharp_in_opt_type = CppToCSharpInOptStructHelperName(cpp_effective_type.simple_type.name);

// Exposed structs with exactly one scalar field are passed through `DllImport` by value as that scalar, not as the struct.
// In C both spellings have the same ABI on every platform we care about, so the C side is unaffected.
// This is needed for Unity's IL2CPP on wasm32. IL2CPP wraps every C# struct into a union with padding, and Clang only
// passes/returns single-element structs as plain scalars on wasm32, which that wrapper isn't. So the IL2CPP-compiled
// call site returns such a struct through a hidden pointer and passes it by pointer, while the C library returns and
// accepts a plain scalar (`wasm-ld` warns about `function signature mismatch`, and the calls trap or read garbage).
// A scalar has the same ABI on both sides. Structs with several fields are fine, both sides pass those indirectly.
// The pass-by-pointer variant below (for default arguments) is unaffected, pointers are always the same.
const std::optional<ExposedStructSingleScalarField> scalar_field = GetExposedStructSingleScalarField(elem, cpp_effective_type.simple_type.name);
const std::string csharp_dllimport_type = scalar_field ? scalar_field->csharp_type : csharp_value_type;

TypeBinding::ReturnUsage return_usage{
.cpp_never_throws = true, // Exposed structs must be trivial, which means all their non-deleted SMFs must be trivial too, which implies non-throwing.
.dllimport_return_type = csharp_dllimport_type,
.csharp_return_type = csharp_value_type,
// Default `make_return_statements` is good enough when not unwrapping the scalar.
};
if (scalar_field)
{
return_usage.make_return_statements = [csharp_value_type, csharp_field_name = scalar_field->csharp_field_name](const std::string &target, const std::string &expr)
{
return target + " new " + csharp_value_type + " {" + csharp_field_name + " = " + expr + "};";
};
}

return CreateBinding({
.param_usage = TypeBinding::ParamUsage{
.make_strings = [csharp_value_type](const std::string &name, bool /*have_useless_defarg*/)
.make_strings = [csharp_value_type, csharp_dllimport_type, scalar_field](const std::string &name, bool /*have_useless_defarg*/)
{
return TypeBinding::ParamUsage::Strings{
.cpp_never_throws = true, // Exposed structs must be trivial, which means all their non-deleted SMFs must be trivial too, which implies non-throwing.
.dllimport_decl_params = {{.type = csharp_value_type, .name = name}},
.dllimport_decl_params = {{.type = csharp_dllimport_type, .name = name}},
.csharp_decl_params = {{.type = csharp_value_type, .name = name}},
.dllimport_args = {name},
.dllimport_args = {scalar_field ? name + "." + scalar_field->csharp_field_name : name},
};
},
},
Expand All @@ -1727,12 +1802,7 @@ namespace mrbind::CSharp
};
},
},
.return_usage = TypeBinding::ReturnUsage{
.cpp_never_throws = true, // Exposed structs must be trivial, which means all their non-deleted SMFs must be trivial too, which implies non-throwing.
.dllimport_return_type = csharp_value_type,
.csharp_return_type = csharp_value_type,
// Default `make_return_expr` is good enough!
},
.return_usage = std::move(return_usage),
});
}
break;
Expand Down Expand Up @@ -4807,7 +4877,8 @@ namespace mrbind::CSharp
{
// You can assign to `this`, and it assigns elementwise! Nice.
// See: https://stackoverflow.com/q/10038598/2752075
file.WriteString("this = " + expr + ";\n");
// This goes through the return binding because exposed structs with a single scalar field are returned from C as that scalar, see `GetTypeBindingOpt()`.
file.WriteString(ret_binding->MakeReturnStatements("this =", expr) + "\n");
}
else
{
Expand All @@ -4821,7 +4892,8 @@ namespace mrbind::CSharp

ctor_expr = "(_Underlying *)" + generator.RequestHelper("_Alloc") + "(" + class_size_str + ")";

post_ctor_statements = "*(" + generator.CppToCSharpExposedStructName(generator.ParseNameOrThrow(func_like.ret.cpp_type)) + " *)_UnderlyingPtr = " + expr + ";\n";
// This goes through the return binding because exposed structs with a single scalar field are returned from C as that scalar, see `GetTypeBindingOpt()`.
post_ctor_statements = ret_binding->MakeReturnStatements("*(" + generator.CppToCSharpExposedStructName(generator.ParseNameOrThrow(func_like.ret.cpp_type)) + " *)_UnderlyingPtr =", expr) + "\n";
}
else
{
Expand Down
13 changes: 13 additions & 0 deletions src/generators/csharp/generator.h
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,19 @@ namespace mrbind::CSharp
// since the result can be affected by the properties of the class.
[[nodiscard]] std::string CppToCSharpUnqualExposedStructName(cppdecl::QualifiedName name);

// Describes the only field of an exposed struct that has exactly one scalar field. See `GetExposedStructSingleScalarField()`.
struct ExposedStructSingleScalarField
{
// The C# type of the field, e.g. `int`.
std::string csharp_type;
// The C# name of the field in the exposed struct.
std::string csharp_field_name;
};
// If the exposed struct `class_desc` (named `cpp_class_name` in C++) has exactly one non-static field, and that field is either an arithmetic type
// other than `bool` or an enum, returns the description of that field. Otherwise returns null.
// Such structs are passed through `DllImport` by value as that field rather than as the struct itself. See `GetTypeBindingOpt()` for the explanation.
[[nodiscard]] std::optional<ExposedStructSingleScalarField> GetExposedStructSingleScalarField(const CInterop::TypeKinds::Class &class_desc, const cppdecl::QualifiedName &cpp_class_name);

// Converts a C++ qualified class name to a C# name of its helper that's used to pass it by value.
// This only makes sense for classes that use the pass-by enum.
[[nodiscard]] std::string CppToCSharpByValueHelperName(cppdecl::QualifiedName name, bool is_shared);
Expand Down
3 changes: 3 additions & 0 deletions test/input/MR/test_csharp.h
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,9 @@ namespace MR::CSharp
int x;
};

// Single-field exposed structs are passed through P/Invoke as their only field. This tests by-value parameters and return values.
inline ExposedLayoutC test_exposed_c(ExposedLayoutC a, ExposedLayoutC b = {}) {a.x += b.x; return a;}


// Test various array members.
struct ArrayMembers
Expand Down
5 changes: 5 additions & 0 deletions test/output_c/include/MR/test_csharp.h
Original file line number Diff line number Diff line change
Expand Up @@ -6312,6 +6312,11 @@ MR_C_API MR_CSharp_ExposedLayoutB MR_CSharp_ExposedLayoutB_Construct_1(const MR_
/// Parameter `_2` can not be null. It is a single object.
MR_C_API bool MR_C_equal_MR_CSharp_ExposedLayoutB(const MR_CSharp_ExposedLayoutB *_1, const MR_CSharp_ExposedLayoutB *_2);

// Single-field exposed structs are passed through P/Invoke as their only field. This tests by-value parameters and return values.
/// Generated from function `MR::CSharp::test_exposed_c`.
/// Parameter `b` has a default argument: `{}`, pass a null pointer to use it.
MR_C_API MR_CSharp_ExposedLayoutC MR_CSharp_test_exposed_c(MR_CSharp_ExposedLayoutC a, const MR_CSharp_ExposedLayoutC *b);

/// Returns a pointer to a member variable of class `MR::CSharp::ArrayMembers` named `i`.
/// Parameter `_this` can not be null. It is a single object.
/// The returned pointer will never be null. It is non-owning, do NOT destroy it.
Expand Down
12 changes: 12 additions & 0 deletions test/output_c/source/MR/test_csharp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9550,6 +9550,18 @@ bool MR_C_equal_MR_CSharp_ExposedLayoutB(const MR_CSharp_ExposedLayoutB *_1, con
) // MRBINDC_TRY
}

MR_CSharp_ExposedLayoutC MR_CSharp_test_exposed_c(MR_CSharp_ExposedLayoutC a, const MR_CSharp_ExposedLayoutC *b)
{
MRBINDC_TRY(
using namespace MR;
using namespace CSharp;
return MRBINDC_BIT_CAST((MR_CSharp_ExposedLayoutC), ::MR::CSharp::test_exposed_c(
MRBINDC_BIT_CAST((MR::CSharp::ExposedLayoutC), a),
(b ? MRBINDC_BIT_CAST((MR::CSharp::ExposedLayoutC), *b) : MR::CSharp::ExposedLayoutC(MR::CSharp::ExposedLayoutC{}))
));
) // MRBINDC_TRY
}

const int *MR_CSharp_ArrayMembers_Get_i(const MR_CSharp_ArrayMembers *_this)
{
return std::addressof(((_this ? void() : MRBINDC_THROW("Parameter `_this` can not be null.", void)), *(const MR::CSharp::ArrayMembers *)(_this)).i);
Expand Down
5 changes: 5 additions & 0 deletions test/output_c_fixed_typedefs/include/MR/test_csharp.h
Original file line number Diff line number Diff line change
Expand Up @@ -6128,6 +6128,11 @@ MR_C_API MR_CSharp_ExposedLayoutB MR_CSharp_ExposedLayoutB_Construct_1(const MR_
// Parameter `_2` can not be null. It is a single object.
MR_C_API bool MR_C_equal_MR_CSharp_ExposedLayoutB(const MR_CSharp_ExposedLayoutB *_1, const MR_CSharp_ExposedLayoutB *_2);

// Single-field exposed structs are passed through P/Invoke as their only field. This tests by-value parameters and return values.
// Generated from function `MR::CSharp::test_exposed_c`.
// Parameter `b` has a default argument: `{}`, pass a null pointer to use it.
MR_C_API MR_CSharp_ExposedLayoutC MR_CSharp_test_exposed_c(MR_CSharp_ExposedLayoutC a, const MR_CSharp_ExposedLayoutC *b);

// Returns a pointer to a member variable of class `MR::CSharp::ArrayMembers` named `i`.
// Parameter `_this` can not be null. It is a single object.
// The returned pointer will never be null. It is non-owning, do NOT destroy it.
Expand Down
8 changes: 8 additions & 0 deletions test/output_c_fixed_typedefs/source/MR/test_csharp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6882,6 +6882,14 @@ bool MR_C_equal_MR_CSharp_ExposedLayoutB(const MR_CSharp_ExposedLayoutB *_1, con
);
}

MR_CSharp_ExposedLayoutC MR_CSharp_test_exposed_c(MR_CSharp_ExposedLayoutC a, const MR_CSharp_ExposedLayoutC *b)
{
return MRBINDC_BIT_CAST((MR_CSharp_ExposedLayoutC), ::MR::CSharp::test_exposed_c(
MRBINDC_BIT_CAST((MR::CSharp::ExposedLayoutC), a),
(b ? MRBINDC_BIT_CAST((MR::CSharp::ExposedLayoutC), *b) : MR::CSharp::ExposedLayoutC(MR::CSharp::ExposedLayoutC{}))
));
}

const int32_t *MR_CSharp_ArrayMembers_Get_i(const MR_CSharp_ArrayMembers *_this)
{
return std::addressof(((_this ? void() : MRBINDC_THROW("Parameter `_this` can not be null.", void)), *(const MR::CSharp::ArrayMembers *)(_this)).i);
Expand Down
5 changes: 5 additions & 0 deletions test/output_c_fixed_typedefs_64_only/include/MR/test_csharp.h
Original file line number Diff line number Diff line change
Expand Up @@ -6311,6 +6311,11 @@ MR_C_API MR_CSharp_ExposedLayoutB MR_CSharp_ExposedLayoutB_Construct_1(const MR_
/// Parameter `_2` can not be null. It is a single object.
MR_C_API bool MR_C_equal_MR_CSharp_ExposedLayoutB(const MR_CSharp_ExposedLayoutB *_1, const MR_CSharp_ExposedLayoutB *_2);

// Single-field exposed structs are passed through P/Invoke as their only field. This tests by-value parameters and return values.
/// Generated from function `MR::CSharp::test_exposed_c`.
/// Parameter `b` has a default argument: `{}`, pass a null pointer to use it.
MR_C_API MR_CSharp_ExposedLayoutC MR_CSharp_test_exposed_c(MR_CSharp_ExposedLayoutC a, const MR_CSharp_ExposedLayoutC *b);

/// Returns a pointer to a member variable of class `MR::CSharp::ArrayMembers` named `i`.
/// Parameter `_this` can not be null. It is a single object.
/// The returned pointer will never be null. It is non-owning, do NOT destroy it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7138,6 +7138,14 @@ bool MR_C_equal_MR_CSharp_ExposedLayoutB(const MR_CSharp_ExposedLayoutB *_1, con
);
}

MR_CSharp_ExposedLayoutC MR_CSharp_test_exposed_c(MR_CSharp_ExposedLayoutC a, const MR_CSharp_ExposedLayoutC *b)
{
return MRBINDC_BIT_CAST((MR_CSharp_ExposedLayoutC), ::MR::CSharp::test_exposed_c(
MRBINDC_BIT_CAST((MR::CSharp::ExposedLayoutC), a),
(b ? MRBINDC_BIT_CAST((MR::CSharp::ExposedLayoutC), *b) : MR::CSharp::ExposedLayoutC(MR::CSharp::ExposedLayoutC{}))
));
}

const int *MR_CSharp_ArrayMembers_Get_i(const MR_CSharp_ArrayMembers *_this)
{
return std::addressof(((_this ? void() : MRBINDC_THROW("Parameter `_this` can not be null.", void)), *(const MR::CSharp::ArrayMembers *)(_this)).i);
Expand Down
Loading