Skip to content

About

libTooling frontend and C++/C# emitters for binding shim.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Hosihikari.Bindings.Generator

English | 简体中文

The generator turns the versioned IR into the C ABI binding shim and the native-style C# Interop. It does not produce the hand-written .NET Idiomatic layer.

Boundary

  • Input: IR shards produced by the libTooling frontend, plus the IR library contract.
  • Output: generated C++ (binding shim) and C# (Interop) sources. Both are committed in the binding repositories and verified by a CI diff.
  • Not here: the hand-written Idiomatic wrappers, the HostingShim, or any CLR hosting.
  • The IR library is a sibling checkout (../Ir); in CI it is pulled with actions/checkout using a pinned repository/path.

Projects

Path Purpose
src/Hosihikari.Bindings.Generator .NET CLI: version, IR validation, coverage validation
frontend/ libTooling CMake target (LLVM 23.1)
tests/fixtures/ Minimal IR fixtures used by the CLI contract and real-shim jobs
tests/BindingShimSmoke End-to-end Windows smoke for the LLAPI slice
tests/RealShimSmoke CI smoke for the link-carrier fixture

Commands

dotnet run --project src/Hosihikari.Bindings.Generator/Hosihikari.Bindings.Generator.csproj -c Release -- version
dotnet run --project src/Hosihikari.Bindings.Generator/Hosihikari.Bindings.Generator.csproj -c Release -- validate-ir tests/fixtures/basic/ir-manifest.json tests/fixtures/basic
dotnet run --project src/Hosihikari.Bindings.Generator/Hosihikari.Bindings.Generator.csproj -c Release -- emit tests/fixtures/basic/ir-manifest.json tests/fixtures/basic artifacts/emitted --abi-header ../Binding.Abi/native/hosihikari_abi.h

validate-ir reads the manifest and every *.jsonl under the shard directory, then runs the IR validator. validate-coverage does the same for coverage/summary.json + coverage/manifest.jsonl.

emit writes interop/ (C#), shim/ (C++ binding shim), and coverage/ (manifest + summary). Pass --native-root <path> to emit the real binding shim for the IR's source files:

dotnet run --project src/Hosihikari.Bindings.Generator/Hosihikari.Bindings.Generator.csproj -c Release -- emit \
  artifacts/llapi-full-ir/ir-manifest.json artifacts/llapi-full-ir artifacts/llapi-real-shim \
  --native-root C:/path/to/LeviLamina --abi-header ../Binding.Abi/native/hosihikari_abi.h

--abi-header points at HosihikariProjection/BindingAbi's native/hosihikari_abi.h; the emitter copies it into every shim directory so the shim build stays self-contained. Generated C# references Hosihikari.Bindings.Abi for StringView, ArrayView, SharedHandle, and the version-checking ShimLibrary loader.

Without --native-root, emit writes the contract stubs used by the fixture CI, unless --symbol-dispatch is set.

Symbol dispatch

--symbol-dispatch emits the same C# Interop and binding table from symbol-level IR without game headers:

dotnet run --project src/Hosihikari.Bindings.Generator/Hosihikari.Bindings.Generator.csproj -c Release -- emit \
  artifacts/symbol-ir-1.21.3/merged-levi-final/ir-manifest.json artifacts/symbol-ir-1.21.3/merged-levi-final \
  artifacts/symbol-dispatch --abi-header ../Binding.Abi/native/hosihikari_abi.h \
  --symbol-dispatch --target-module bedrock_server

Direct members resolve module base + rva on Windows or module base + address on Linux; if no address is recorded, the shim falls back to GetProcAddress/dlsym with the symbol name. Virtual members use the per-configuration vtableIndex, scoped to the member's declaring class; a class whose symbol stream has no vtable falls back to direct symbol/RVA dispatch. The index is relative to the vtable address point on both platforms. Instance methods that return a non-trivial std::string follow the MSVC member ABI (this in RCX, hidden result buffer in RDX); static methods follow the free-function convention. Non-trivial results are destroyed through the target module's own std::basic_string destructor, recorded in the IR manifest as supportSymbols.stdStringDestructor, so strings allocated by a game-private allocator are not released by the shim's allocator; fixtures without that support symbol fall back to local destruction. Core::PathBuffer<std::string> projects as string because the wrapper's layout and non-trivial return ABI match its single std::string member. --target-module names the hosting module. The mode accepts scalars, enums, std::string/std::string_view, single-level pointers/references, and records passed or returned by value. Records keep their generated wrapper type in C#; the shim handles the ABI: small triviallyCopyable records travel in a register, larger records are copied to a stack temporary and passed by pointer (or returned through the hidden buffer), and a record that is not trivially copyable is built through its copy constructor, which is resolved by name when no direct symbol is recorded. A record that is only trivially destructible — AABB, Vec2, Brightness — still uses the hidden-reference convention, even when small, because a user-provided copy constructor makes it non-trivial for calls; its copy constructor runs instead of a byte copy. A trivially destructible record without a recorded copy constructor is copied as bytes. A signature that omits defaulted template arguments — Bedrock::Result<T> for Bedrock::Result<T, std::error_code> — binds to the recorded specialization, so the return reaches that record's layout and destructor. Top-level const on a by-value record return (for example Tick const) resolves through the same record table; pointer and reference returns keep the borrowed path. const static fields are read from their data symbols: scalars are dereferenced, const char[] and std::string constants become strings, and record or pointer constants surface as borrowed wrappers with static lifetime. Windows collects @@2 static data symbols; constants without an independent data address remain runtime stubs on that configuration. Non-trivial records are accepted when the IR manifest's recordSupport supplies destructor and copy-constructor symbols; a synthetic support member feeds both the by-value copy/destroy path and the owned wrapper's Dispose. std::unique_ptr<T, std::default_delete<T>> results release the pointee into an owned handle through an 8-byte no-op-deleter unique_ptr view over the hidden return buffer; allocations with align <= 16 use non-aligned operator new/delete so game-allocated pointees are freed with the matching form. Nullable wrapper annotations do not distinguish C# overloads, so const T& and const T* parameter overloads receive stable hash suffixes. Public byte-aligned fields, including fields inherited through non-virtual bases, are exposed as offset-based properties. std::vector<T> parameters and by-value results are supported when the element is a scalar or enum, std::string/std::string_view, a single-level pointer (surfacing as nint[]), or a record with a recorded layout and trivial destructor (IR 1.5.1 declaration.triviallyDestructible); element records travel through an aligned byte blob, so a user-provided copy constructor such as AABB's is accepted, returned record elements become owned copies backed by a generated destroy entry, and reference or pointer vector results remain borrowed handles. gsl::span<T,-1> results project as a borrowed 16-byte view: pointer elements (including gsl::not_null<T*>) surface as nint[] without copying or ownership, while record-element spans remain stubs because the element layout is unavailable; std::pair remains unsupported. std::optional<T> parameters and results support scalars, strings, and trivially copyable records (records use a layout-compatible blob and become owned wrappers on return). std::shared_ptr<T> parameters and results keep one shared reference alive in a shim holder; std::weak_ptr<T> results are locked once into a strong snapshot, and expired references produce a null handle. OwnerPtr<T> and StackRefResult<T> use the same shared ABI, WeakRef<T> uses the weak ABI, and Bedrock::NonOwnerPointer<T> keeps the returned control block alive while exposing the object stored in its first field; gsl::not_null<...> is unwrapped around these wrappers. Managed binding-table bool results — including variant alternatives — are declared as byte and normalized with != 0, because Windows x64 only guarantees the low byte of the return register. optional_ref<T> parameters and results use the optional presence flag with borrowed references. The header supplement contributes enums, dependency record declarations (including sequence element types), layouts, and the triviallyCopyable/triviallyDestructible facts. The 1.21.3 supplement also includes CompoundTag, ListTag, SecureStorage, LevelStorageManager, PlatformRuntimeInfo, and pathfinder::Path so unique_ptr pointees have declarations and layouts. Maps, callbacks, and variant values remain stubs; a map requires named key/value types and matching hash/equality functors, which the symbol stream does not carry. Custom owning wrappers outside the supported shared/weak family remain stubs. Constructors require a recorded layout so the shim can allocate; a class without a layout can still expose borrowed handles from static factories.

tests/symbol-dispatch/run.ps1 builds a synthetic PE/PDB fixture, runs the symbol frontend, emits the dispatch shim, and runs the C# smoke test. tests/fixtures/symbol-dispatch plus tests/symbol-dispatch/linux-fixture.cpp cover the Linux resolver and vtable offset in the cli CI job. tests/bds-smoke runs the same shim inside a real 1.21.3 server: a LeviLamina preload-native mod hosts the CLR through nethost/hostfxr, calls the managed BlockPos/ChunkPos/Vec3 projection through RVAs, invokes scalar, container, and Core::PathBuffer<std::string> getters on the live AppPlatform service object, and checks the returned storage, userdata, and current-storage paths.

Real binding shim v1

  • The C ABI is one versioned HosihikariBindingTableV1 per carrier surface. Handles are void*; owned handles come from constructor factories and are released by the generated disposer. Table fields are named from the stable function identity (for example hs_method_1a2b3c4d), not from the slot ordinal; the ordinal remains only the physical array position, so adding a function does not rename existing call sites. Field strings are returned as borrowed HosihikariStringView values that the managed side copies immediately; string parameters are (const char*, size_t) UTF-8 pairs.
  • Constructors, destructors, public fields, and supported methods are projected. Members that the generator deliberately refuses are emitted as NotSupportedException stubs marked with [Obsolete] carrying the same reason; calling one produces compiler warning CS0618 and an IDE strikethrough, and projects can escalate it with <WarningsAsErrors>CS0618</WarningsAsErrors>. Constructors and methods accept multiple parameters. Constructors may take mutable and rvalue references to public declarations that have generated wrappers; the shim passes those parameters by dereferenced address. A method may return void, a fundamental, an enum, an opaque pointer, a string, or an eligible by-value record; a single-level T*/T& return to a public declaration surfaces as a typed borrowed wrapper (T?/T) whose Dispose only invalidates the wrapper and whose ReleaseHandle refuses borrowed handles, leaving ownership with the native side, and optional_ref<T> returns use the same wrapper path with the hidden presence flag; the public (nint handle) constructor remains the owning path for raw handles; single-level pointer parameters to public declarations are nullable (T?) and map null to a null pointer, while rvalue-reference parameters stay non-null wrappers and deeper indirection stays nint; accessible public bases, including transitive bases, receive As<TBase>() upcasts and polymorphic bases receive TryAs<TDerived>() RTTI downcasts for every transitive derived class, both returning borrowed wrappers; string results are copied into a thread-local shim buffer and the managed wrapper copies them immediately. Multiple string parameters are pinned independently per call; UTF-8 encodings of at most 256 bytes use a stack buffer instead of a per-call byte array. Enums use their underlying integer ABI. By-value record arguments are dereferenced from caller handles; by-value record results are copied into shim-owned objects that the managed wrapper can dispose. Record and container fields are exposed through borrowed address views; array fields are getter-only. std::vector<T> results use a borrowed HosihikariArrayView and surface as T[]/string[]/nint[], including pointer elements, with empty results returning Array.Empty<T>(); vector parameters expand to a hidden (element pointer, count) pair and surface as T[]/string[]/Variant[], including record and variant elements, with multiple vector parameters per call; each vector gets its own pinned element view; std::initializer_list<T> parameters stay unsupported because C++ cannot construct a runtime-length initializer list, but the affected classes expose equivalent vector overloads; std::vector<std::pair<string, uint64>> parameters surface as KeyValuePair<string, ulong>[], with string keys and values sharing one UTF-8 arena per call; std::optional<T> parameters and results use a hidden presence flag; managed values are nullable scalars, string?, or owned T?; std::unique_ptr<T> results release ownership as an owned T handle; unique_ptr parameters transfer ownership from the wrapper, which releases its handle before the call and throws if reused; abstract interfaces with a public virtual destructor receive a disposer when returned this way. std::shared_ptr<T> results return a SharedHandle that keeps one shared reference alive and releases it on dispose; shared_ptr/weak_ptr parameters accept a SharedHandle, and the shim recovers the typed reference through a type-checked holder. optional_ref<T> parameters and results use the optional presence flag with borrowed references; managed values are T?/nint?. nonstd::expected_lite::expected<T, E> results return T and surface failures as InvalidOperationException carrying the native error message; E may be ll::Error or a string. std::variant<T...> values with string, scalar/enum, pointer, and copyable-record alternatives receive a generated owned wrapper with per-alternative Create/As/Is, Index, and Dispose; by-value parameters copy, const& parameters borrow, and by-value results copy into an owned handle. A public copyable class whose members are inherited (no recorded fields, constructors, or destructor) is accepted as a record alternative; the generator emits the destroy function needed by its Dispose. endstone::NotNull<T>/Nullable<T> returns use SharedHandle; sequence returns distinguish borrowed pointer elements (nint[]), owned elements (T[]/T?[] for records and unique_ptr), and shared elements (SharedHandle[] for shared_ptr/NotNull/Nullable); unordered_set<T*> returns borrowed address arrays. std::chrono::time_point<std::chrono::system_clock> maps to Unix milliseconds; other clocks map to their native tick count; std::chrono::duration maps to its native count; optional time values use long?. Returned std::function<R(Args...)> values become generated callable wrappers with Invoke and Dispose. Reference returns of variant, callable, chrono, typeid_t, strong_ordering, and filesystem-path values are copied through the same projection instead of surfacing as opaque handles; pointers to those types remain borrowed handles. Bedrock::typeid_t<C> maps to ushort, std::strong_ordering to sbyte (-1/0/1), and std::filesystem::path to a UTF-8 string; optional forms follow the scalar/string rules. std::unordered_map<K, V> parameters and results use a managed Dictionary<K, V>; keys support string/scalar/pointer and values support string/scalar/pointer, record copies, and variant copies. Map parameters reuse the vector arena strategy: string keys and values share one UTF-8 arena per call, and maps of at most 16 entries use a stackalloc MapEntry buffer. Reference and pointer results keep a borrowed handle when the entry types cannot be projected.
  • Enum declarations are projected as C# enum types. The native underlyingType picks the base type and widens only when a value does not fit; every enumerator carries its evaluated value (IR 1.6.0 member.value), so implicit and expression-valued enumerators both arrive intact. Nested enums follow the nested-class rule and land in a _Nested namespace segment; sanitized name collisions get a stable hash suffix and keywords are escaped. Signatures keep their underlying-integer projection, so a value crosses a call as (int)Foo.Bar.
  • A type with no usable member — no planned constructor, destructor, field access, method, or cast, or an enum with no enumerator — is still emitted for its type identity and carries [Obsolete(message, false)]. Generated files disable CS0618 internally, so the marker only fires in user code.
  • The default link-safe mode projects public non-template classes that are complete in the IR source root and have only inline methods and link-safe bases. --link-carrier also admits public concrete template instances and links the carrier import library, fmt, and prelinked Bedrock imports. Template arguments must be accessible; incomplete arguments are admitted only for standard or supported third-party types. Member templates, rvalue-qualified members, and variable templates are not projected, and a template destructor must have a visible definition before a disposer is emitted. Assignment operators are projected for non-template classes; assignments on template specializations are skipped because the underlying operation may require complete template arguments or may be deleted. Static methods and static fields are projected without self. Abstract classes do not receive factories. Classes that store string_view or std::string reference fields receive holder-backed factories: the shim copies each string argument into a holder and constructs the object against those copies, so the views and references stay valid until the wrapper is disposed. An abstract class with exactly one pure virtual callback (pointer/reference/scalar parameters, void/scalar/enum return), a public virtual destructor, and an accessible scalar/enum/shared or implicit default constructor receives a director factory: the shim subclasses the interface, forwards the virtual call to (invoke, userData, release), and returns a shared_ptr handle. Every generated wrapper also exposes Borrow(nint) for a non-owning view of a raw handle. Public namespace-level functions are projected into one Functions static class per native namespace. An inline function is compiled into the shim; under --link-carrier a carrier-exported out-of-line function is called through the import library instead. Results and parameters use the same mapping as members (scalars, strings, pointers, by-value records, optional/optional_ref, unique_ptr, expected, vectors, maps, shared handles, callbacks). Namespace-level comparison operators (==, !=, <, <=, >, >=) become static operator_equal-style methods addressed through ADL; other operators, function templates, dependent friend patterns, lambda parameters, and multi-level pointers stay unprojected.
  • Every table function is a C++ function-try-block. Native exceptions are captured into a thread-local message exposed by hosihikari_get_last_error; no C++ exception crosses into managed code. Factory failures return a null handle and the generated Create methods throw InvalidOperationException with that message.
  • std::function<R(Args...)>, brstd::move_only_function<R(Args...)>, and brstd::function_ref<R(Args...)> parameters are exposed as (invoke, userData, release) triples. The shim wraps a shared callback state in the requested callable type (std::function, brstd::move_only_function, or brstd::function_ref); the last copy invokes release(userData). v1 supports scalar and enum results with scalar, enum, string, pointer, reference, and by-value record arguments; const scalar, enum, and string references cross as values.
  • --ll-allocator routes the shim's global operator new/delete through ll::memory::getDefaultAllocator(). Use it with carriers such as LeviLamina that replace the global allocator; mock carrier fixtures omit it.
  • NativeMethods.Load(path) validates the exported table version and sizeof(Table) before use. The table's version and size are the first two fields.

frontend/build-shim.ps1 compiles the generated shim with clang-cl and /MD against the LeviLamina include environment. Pass -AdditionalLinkLibrary for carrier/package libs and -DelayLoadLibrary LeviLamina.dll for a standalone smoke build. frontend/build-carrier-shim.ps1 compiles the object, runs prelink to generate the Bedrock runtime import library, and links the carrier shim. frontend/smoke-shim.ps1 emits, builds, and runs the end-to-end smoke test in tests/BindingShimSmoke; add -LinkCarrier to use the carrier build with delay-loading.

Local LLAPI slice

frontend/run-local.ps1 parses ll/api/event/Event.h, writes IR under artifacts/llapi-ir, and validates it. frontend/run-umbrella.ps1 -Module ll/api parses the full src/ll/api plus src-server/ll/api umbrella, reuses the LeviLamina compile_commands.json, and currently yields 1,532 declarations / 6,515 members at IR 1.6.0. Public record types referenced by value from filtered signatures are added with their constructors, destructor, and fields even when their headers sit outside the module filter. The IR records each function's and method's inline/defined/consteval facts, member templates, ref qualifiers, enum underlying types, and class traits (abstract, copyConstructible), which the binding shim planner uses for eligibility and ownership.

powershell -File frontend/run-umbrella.ps1 -Module ll/api -Output artifacts/llapi-full-ir
powershell -File frontend/smoke-shim.ps1 -IrDirectory artifacts/llapi-full-ir
powershell -File frontend/smoke-shim.ps1 -IrDirectory artifacts/llapi-full-ir -LinkCarrier

The complete LLAPI umbrella currently selects 496 link-safe functions, or 3,819 functions when built with --link-carrier against LeviLamina.lib. The slice covers ll::event::EventId, ll::event::EventIdView, ll::hash_utils::HashedIdBase, ll::hash_utils::HashCombiner, ll::data::Version, and the other eligible classes. The smoke test creates a native EventId from a managed string, reads its name and hash, compares two HashedIdBase views, calls HashCombiner::hash(), and round-trips ll::data::Version through string construction, to_string(), field getters, valid(string), and isIdenticalTo(Version). The remaining members keep their Interop stubs and are recorded in the coverage manifest with the interop emitter only.

Endstone interop surface

frontend/run-endstone.ps1 parses the Endstone public umbrella (include/endstone/endstone.hpp) with expected-lite and nlohmann_json headers. --virtual-dispatch emits a link-free shim for the header-only virtual interfaces: inline members and virtual methods are called through the host-provided vtables, while non-virtual out-of-line members remain stubs. frontend/build-endstone-shim.ps1 compiles the generated table against the Endstone headers. The Endstone 0.11.14 interop surface currently selects 2,364 functions, including eligible template instances, inline/constexpr static fields, assignment operators on non-template classes, and inheritance casts.

frontend/run-endstone.sh generates the linux-x64-server configuration on Linux. The endstone-linux-ir CI job builds the frontend against LLVM 23.1, generates and validates the IR, emits the Interop and shim, compares the emitted Interop byte-for-byte with the committed Windows-IR output, and compiles the shim. Fixed-width spellings, standard-library iterator types, and system_clock::time_point durations are normalized to the Windows form so declaration ids, table field hashes, and coverage stay identical across platforms (ADR 0018).

Carrier updates

frontend/new-carrier.ps1 runs a carrier bump end to end: it resolves a Third/ checkout (adding a git worktree with -Fetch), validates the Bedrock header list and can prune paths the carrier dropped (-PruneHeaderList, which repoints a uniquely named file and never overwrites the input list), builds the carrier with xmake when the llapi surface needs LeviLamina.lib, runs the frontend and emitters, checks the committed provenance against the checkout, and optionally runs the runtime smoke.

# preview: resolve the carrier and validate the header list only
powershell -File frontend/new-carrier.ps1 -Carrier levilamina -Version v26.61.0 -Fetch -DryRun

# Bedrock and LLAPI from a rebuilt carrier
powershell -File frontend/new-carrier.ps1 -Carrier levilamina -Version v26.61.0 -Fetch -BuildCarrier

# Endstone only
powershell -File frontend/new-carrier.ps1 -Carrier endstone -Version v0.11.16 -Fetch -Surface endstone

frontend/carriers.json records the carrier versions the surfaces are generated from. The carrier-canary workflow compares those pins with the newest upstream vX.Y.Z tag every Monday and opens or updates a carrier-update issue when one is newer; the issue body carries the matching new-carrier.ps1 command.

The script stops before the product-level follow-ups: package <Version> values, README and profile coverage numbers, coverage baselines, and frontend/carriers.json itself.

Related repositories

  • HosihikariProjection/BindingIr: schema, models, reader/writer, validator.
  • HosihikariProjection/BindingAbi: shared ABI value types, loader, and native header.
  • HosihikariProjection/EndstoneBinding: the Endstone interop surface.
  • HosihikariProjection/LeviLaminaBinding: the LeviLamina interop surface.

Licence

GPL-3.0-or-later, see LICENSE.md.

About

libTooling frontend and C++/C# emitters for binding shim.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages