Wrapper Maintenance¶
This page is for Kanama maintainers working on generated wrappers, KDoc, or API refreshes. Game projects do not need these commands.
Generator Role¶
The wrapper generator turns Godot's extension_api.json into conservative
Kotlin wrapper drafts. Its job is to scale API coverage without hand-writing
thousands of MethodBind calls, while refusing shapes where Kanama has not
defined ABI, ownership, or marshalling policy yet.
Generated drafts are not automatically promoted. A class only becomes public source after review, smoke coverage where practical, and reproducibility checks against the generator output. This keeps broad wrapper growth auditable instead of relying on one-off manual wrappers.
Refreshing generated Panama bindings from a new Godot API/header set requires
jextract 25+. Normal game projects do not run jextract; it is only part of
Kanama's API/header refresh workflow.
Wrapper Guardrails¶
Kanama's conservative wrapper generator is intentionally fail-loud. CI checks the generated method shapes against the runtime FFM helpers, including int32/int64 width, float/double layout, packed-array storage, generic Array/Dictionary/Variant policy, and Callable blocking.
Generated public APIs must keep concrete Godot object types concrete. For
example, a Godot Node3D return should render as Node3D?, not
GodotObject, Object, or MemorySegment. Exact Godot Object APIs remain
dynamic because the engine itself does not promise a more specific type.
Ownership-sensitive namespace-style types use explicit policy before default
generation. Callable stays blocked unless a helper has a bounded ownership
shape; DirAccess, FileAccess, and SceneTree use dedicated handle aliases
where factory methods return nullable object handles.
Generated wrappers are held to a full per-platform drift-gate. The generator
can emit selected classes with --emit-class, and check_full_drift_gate in
scripts/check_wrapper_generator.py regenerates the entire committed wrapper
set — every in-API class under src/main/kotlin/.../api (desktop/Android) and
ios-runtime/.../api (iOS) — and fails if any committed file diverges from a
fresh regen (behavior-comparable; sync_kdoc_from_godot_docs.py owns the prose).
A hand-edit to a generated class, an un-adopted generator improvement, or a
platform copy drifting from another all fail the gate, so the three wrapper trees
cannot silently diverge. The only exemptions are the deliberately hand-shaped
classes listed in DESKTOP_HANDSHAPED/IOS_HANDSHAPED (hand-authored facades,
ergonomic helpers, aliases, and custom defaults the generator does not emit); a
class joins that list only when the generator genuinely cannot reproduce it.
Non-API files (GodotObject, GD, DirAccessHandle, …) are auto-excluded.
Android has no separate committed tree — prepareAndroidKanamaSources copies the
desktop sources through the PanamaPort remap, so the desktop gate covers it
transitively. Adopted classes with skipped methods are only accepted when every
skip is a Godot virtual callback that belongs to the override-registration design
rather than the public ptrcall wrapper surface.
RefCounted Return Ownership¶
This section is the ABI truth, for contributors. The rule a game developer needs — and the owned / borrowed / engine-owned / node categories it produces — lives in Godot API → Resource Ownership. Keep the two in step: this page explains why the
+1exists, that page explains what to do about it.
Engine methods whose return type derives from RefCounted hand the ptrcall
caller a +1 reference through the return slot. This holds for every such
method: Godot's PtrToArg<Ref<T>>::encode copy-assigns into the slot and
PtrToArg<RequiredResult<T>>::encode nets the same +1 — meta: "required"
signals never-null-on-success, not a different ownership convention
(runtime-measured on Godot 4.7-stable; see the Tweener ownership probe in
scripts/runtime_smoke.sh). Non-RefCounted object returns (nodes, servers)
are raw pointers with no reference transfer.
The wrapper convention on desktop/Android:
- A generated wrapper returned from a RefCounted-typed method owns that
reference;
close()releases it (unreference()+ destroy at zero). This matches the script-property retention path (ScriptBridge.retainScriptResource), which takes its own reference. - Self-returning fluent methods collapse: when the returned address equals
the receiver's handle, the generated method releases the duplicate reference
and returns
thisinstead of minting a second owning wrapper (chained calls such astweenAwait(...)?.setTimeout(...)stay reference-neutral). The generator emits this pattern whenever the receiver class conforms to the method's return class; it is the same policy the hand-shaped Tween/Tweener classes use (wrapOrThis). - Wrappers minted from Variant-path returns or
fromHandlecasts borrow; a release there underflows. Hand-shaped self-collapse helpers must therefore sit on a ptrcall object-return helper, never oncallWithVariantArgs(PropertyTweener.fromregressed exactly this way once).
The iOS island mirrors the same convention (task 30): the C-shim exposes
object_destroy (kanama_ios_godot_object_destroy), ObjectCalls.destroyObject
wraps it, and the generated iOS RefCounted carries close() (unreference +
destroy at zero) plus the internal releaseHandle primitive via generator
custom sections — so the collapse pattern above is emitted identically under
--ios-emit-class. The base iOS GodotObject.close() stays a no-op (node/server
returns are raw pointers with no reference transfer). Both the custom sections
and the collapse emission are locked by check_ios_policies, and the on-device
self-test matrix carries a refcounted-ret-owns-plus1 refcount probe
(duplicate() → refcount 1 → close()).
Freshly created resources own their reference — close what you create (tasks 61/62)¶
X.create() factories return an owning wrapper. All backends construct via
classdb_construct_object3 (Godot 4.7), which returns a RefCounted subtype
already owned (refcount 1) — Godot's own contract: "the caller … is
responsible for decrementing it again when the object is no longer needed." No
hand-rolled init_ref claim: the wrapper owns its +1 straight out of
construction, balanced by close() (or use { }).
This is what makes handing a created resource to the engine safe. When you pass
it to a Ref<>-taking sink — a surface-override material, a node property,
ResourceSaver.save — the engine takes its own reference on top of the
wrapper's. A later close()/use { } then drops only the wrapper's +1, so the
resource survives for the engine (issue #91 — previously, on the deprecated
construct_object2 path, create() was non-owning and close() dropped the
engine's only reference, so the material/mesh vanished from the saved scene).
Two Ref<Material> sinks (surface override + material override) plus a
create_refcount=1 assertion are guarded by the MaterialHandoffSmoke row in
scripts/runtime_smoke.sh; iOS guards the same on-device in its
ObjectCalls self-test.
The contract is the ordinary AutoCloseable one: close what you create. A
created resource that is never closed leaks its +1 (Godot prints
Leaked instance: <Class> at exit in debug/editor runs — the same signal the
smoke gates on). Prefer X.create().use { … }, or assign it and close() after.
A getMesh()/getMaterial()-style read-back is also an owned return (see
RefCounted Return Ownership above) and must be
closed too — forgetting one leaks the resource it points at.
Issue #81 is subsumed. Before the
construct_object3migration (task 62),create()was non-owning, soResourceSaver.save's transientRefcould drop a fresh resource's only reference to zero and free it mid-call (issue #81, a JVM SIGSEGV), which a protective-reference guard in the save ptrcall helper worked around. Withconstruct_object3the wrapper's own+1outlives the transientRef, so the guard was removed —saveneeds no special-casing. Theissue81 packed_scene_saverow (ref_after_save=1,alive_after_save=true) proves the fresh resource survives save with no guard.
Two dynamic paths share this ownership problem: ClassDB.instantiate and
ClassDB.class_call_static both return a Variant that holds the fresh
instance's only reference, so the borrowed Variant-scalar decode would hand
back a handle that dies with the Variant (use-after-free for RefCounted classes
— GitHub PR #42; instantiate is also the canonical construction path for
third-party GDExtension classes, and a static factory such as
RegEx.create_from_string or Image.create has the same sole-reference
return). Both route through the same mechanism — a METHOD_CALL_SHAPE_OVERRIDES
entry in generate_api_wrapper.py that names the owned dispatch helper the
generated wrapper calls — and differ only in that helper:
ClassDB.instantiateis a generated ptrcall →ptrcallWithStringNameArgRetVariantScalarOwned.ClassDB.class_call_staticis a varargsObject.call, so its override names the vararg helpercallWithVariantArgsOwned, which delegates tocallWithVariantArgs(owned = true)(render_vararg_methodreads the override to pick the helper, defaulting tocallWithVariantArgs).
In both cases RefCounted results are retained before the return Variant is
destroyed and come back as the owning RefCounted wrapper (close() releases).
On iOS the retain must happen inside the C shim, before it destroys the return
Variant: instantiate uses the dedicated entry
kanama_ios_classdb_instantiate_owned, while class_call_static uses the
shared kanama_ios_godot_object_call with its out_is_refcounted out-param
(non-NULL turns on the retain). Non-RefCounted and non-object results stay
borrowed. Do not blanket-retain in variantToScalar itself — every other
dynamic object read (property gets, ordinary call() returns) is a borrow, and
a retain there leaks one reference per read. Other fresh-sole-reference dynamic
calls (obj.call("duplicate")) remain unfixed by design for now; add a
METHOD_CALL_SHAPE_OVERRIDES entry naming an owned helper if one bites.
Validated by the ClassDB instantiate ownership and ClassDB class_call_static
ownership probes in scripts/runtime_smoke.sh.
Engine-wide MethodName, PropertyName, and SignalName constants are
generated from extension_api.json separately from the class wrapper drafts:
python3 scripts/generate_name_constants.py
scripts/check_wrapper_generator.py runs the same generator in --check mode,
so a Godot API refresh fails loudly if the committed name constants are stale.
iOS Generator Policy¶
The iOS island is emitted under IOS_AUDIT_ONLY and has extra policy so a regen stays
honest instead of silently dropping or clashing. These are locked by check_ios_policies
in scripts/check_wrapper_generator.py:
-
Bare-
Objectreturns.get_collider()-style methods that return the root GodotObjectwrap toGodotObject?. Desktop/Android findGodotObject.wrap()via aGodotObject.ktfile; on iOSGodotObjectlives insideIosGodotApi.kt, sowrapper_has_wrapspecial-casesGodotObjectas always-present. Without this the iOS regen drops every bare-Object-return method (a silent coverage loss), even though theptrcallNoArgsRetObjecthelper is fully wired (Node returns use it). -
Subclass-override openness. Where a hand-written iOS subclass overrides a generated method, the base method must be generated
open— otherwise a regen drops the keyword and the override stops compiling.Node.createTween()is emittedopen(via the Node custom member section) so the hand-writtenSceneTreesubclass can override it with the correctSceneTree.create_tweenbind (the FPS F2 fix). Add such cases to the class'sIOS_CUSTOM_MEMBER_SECTIONSentry, not by hand-editing the generated file. -
Explicit class collisions. Real Godot classes that are deliberately hand-written on iOS (inside
IosGodotApi.ktor a bespoke single-class file) are listed inIOS_HANDWRITTEN_COLLISION_CLASSESwith a reason.--ios-emit-class <that class>logs acollision:line and skips it, instead of writing a<Class>.ktthat duplicate-declares the class and breaks the compile. When a class graduates to a real generated wrapper (asTime/InputMap/PhysicsServer3Ddid), delete its entry so generation is allowed.FileAccesslives here: iOS hosts it as a hand-written static facade plus its ownFileAccessHandleinios-runtime/.../api/FileAccess.kt. -
Explicit uncompilable classes.
IOS_UNSUPPORTED_CLASSESlists the classes whose generated draft cannot compile on iOS, each with its reason:DirAccess(its draft references the hand-authoredDirAccessHandledesktop policy class iOS does not carry) andMethodTweener(its generated fluent methods clash with the hand-written iOSTweenerglue).--ios-emit-class <that class>logs anunsupported:line and skips it. Together with the collision registry these are the only by-design exceptions to iOS class-set parity with desktop (task 30); retire an entry by porting the desktop policy surface it depends on. -
RefCounted ownership sections. The iOS
RefCountedwrapper'sclose()/releaseHandlecustom sections and the fluent self-return collapse emission (see "RefCounted Return Ownership" above) are locked bycheck_ios_policiesso a generator refactor cannot silently reintroduce the per-call RefCounted return leak.
Two cross-platform load-bearing wrapper shapes are reproduced by explicit policy (so a regen
does not drop them), locked by check_ios_policies:
- Composite default-value overrides.
KOTLIN_DEFAULT_EXPRESSION_OVERRIDESinjects a final Kotlin default expression by exact(class, method, arg).Node3D.look_at(up = Vector3.UP)is the current entry — demos call the 1-arglookAt(target)form and rely on it. Kept surgical (per exact arg) so no other method silently gains a default. - Non-null factory.
NON_NULL_FROM_HANDLE_CLASSES(currently{Resource}) emitsfromHandle(handle): Resource(non-null) so a@ScriptClass(attachTo = "Resource")script's(MemorySegment) -> ResourceselfFactory type-checks. The nullablewraphelper stays.
The broad pre-existing regen drift (a fresh regen once changed the majority of the committed desktop generated files — accumulated generator improvements that were never re-adopted) has been closed by the task 21 convergence pass: all in-API wrappers were regenerated and re-adopted from the honest generator, and the full drift-gate above now keeps committed == fresh regen on every platform, so that class of drift cannot silently return.
Coverage Triage¶
For v0.3.0 wrapper work, use the coverage reports as the baseline instead of waiting for another demo to expose a gap:
- Refresh
docs/contributing/api-coverage.mdanddocs/contributing/wrapper-generator-report.md. - Start from the generator skip categories and missing helper shapes.
- Rank gaps by common Godot workflows: scene tree, resources, physics queries, animation, UI, input, materials, signals, RPC, and export-facing APIs.
- Prefer removing raw escape hatches (
Object.call,Object.get,Object.set, string method names, manual handle casts) from normal user workflows over broad class-count increases. - Use public demos as regression coverage after choosing a wrapper slice, not as the only source of wrapper priorities.
The current coverage pages already separate promoted source coverage from generator capability. Treat low-coverage areas such as variant/dictionary helper shapes, typed arrays, and multiplayer/editor methods as API design work: add helper policy and audits before promoting wider wrappers.
Long-tail triage (task 22)¶
Once the class surface is broadly promoted, the remaining skips are a long tail of data-type / shape gaps, not missing classes. A full triage of the non-virtual skips and the skipped properties found:
- Non-virtual method skips (65 as of task 28's start): the large majority are intentional, not gaps —
- ~49 root
Objectmethods (call/get/set/connect/emit_signal/…) are deliberately routed through the hand-shapedGodotObjectpolicy, not regenerated per subclass. These are the escape hatches; keep them onGodotObject. - 2
RefCountedlifetime methods (reference/init_ref) are ownership-sensitive and hand-shaped. - One clean low-ABI slice landed — a new call shape that recombines
already-audited primitives and whose signature contains a type not in
IOS_ARG_KINDS/IOS_RET_KOTLIN, so it generates on desktop+Android and is cleanly iOS-skipped (no C-shim helper, no self-test row, no device gate — the same guardrail Dictionary uses):(Rect2i, Object, Color, int32, Object)→void(DrawableTexture2D.blit_rect) viaptrcallWithRect2iObjectColorIntObjectArgs— reuses the Rect2i/Object/Color/int cells.Rect2iis not an iOS arg kind.
Dictionary→Dictionary(GDScriptTextDocument.resolve/.rename) is a by-design skip, not a gap. Task 22 briefly landed it viaptrcallWithDictionaryArgRetDictionary, but the shape was reverted for stability: a Dictionary-in/Dictionary-out passthrough through the genericMap<String, Any?>helper silently drops non-String keys, soaudit_generator_shape_policykeeps the('Dictionary',) -> 'Dictionary'shape out ofCALL_SHAPES(it must stay hand-audited if ever needed). The two methods are editor-LSP plumbing with no game-runtime workflow.-
The formerly deferred ~12 landed in task 28 as audited desktop/Android families (all iOS-cleanly-skipped — none of the new kinds are iOS arg/return kinds; task 30 brought iOS to full class-set parity, and these per-method shape mirrors remain documented skip-report deferrals):
- Typed-array argument family: RenderingDevice
blas_create/tlas_build/raytracing_pipeline_create,ImporterMesh.merge_importer_meshes(TypedObjectArray+TypedTransform3DArray),DrawableTexture2D.blit_rect_multi,RenderingServer.texture_drawable_blit_rect(TypedRIDArray+Rect2i), and OpenXRdo_entity_update— newObjectCallshelpers recombining the auditedinitArrayOfObjects/initArrayOfRids/initArrayprimitives, registered in the shape-policy audit's dynamic helper sets. Signalscalar arg (Tween.tween_await): admitted per-method like Callable (never in the genericCALL_SHAPEStable).BuiltinTypes.initSignalbuilds Signal(Object, StringName) for the call and destroys it after; the engine keeps its own ObjectID-based copy, so no Kotlin state is retained. Runtime-validated inruntime_smoke(await resolves on real emission).- Hand-shaped-class landings:
EditorExportPlatform.export_project,OpenXRSpatialAnchorCapability.create_new_anchor/do_entity_update, andFont.find_variationare hand-hosted on theirDESKTOP_HANDSHAPEDwrappers in the exact generated form (draft-verified) — the committed copies had drifted to pre-4.7 signatures on compatibility hashes and now target the current 4.7 binds.
After task 28, the generator report's non-virtual skips are only the documented by-design entries above (root-Object policy, RefCounted lifetime, the
GDScriptTextDocumentDictionary revert) — each carries its rationale verbatim in the report viaBY_DESIGN_METHOD_SKIPS/ the policy messages. - ~505 skipped properties are NOT a marshalling gap. Triaged: - ~415 are indexed / parameterized accessors whose getter takes an argument (e.g.get_list_stream(i),get_param(param)). Godot lists them asfoo/0,foo/1, … pseudo-properties; they are not Kotlinval/vars by design, and the underlying getter/setter methods are already generated and callable. Not a gap. - ~90 have no simple own getter method — the getter is inherited (the property surfaces on the parent wrapper and the getter method is inherited), an engine virtual_get_*(task 13 territory), or an indexed inherited getter. None are unlocked by a new marshalling shape. - Typed-array argument family: RenderingDevice
Net: no skipped property is a "quick win behind a shape." Property coverage advances only when its getter method becomes generatable (a new no-arg return shape) or via ergonomic hand-shaping, never by widening property emission.
Virtual-return coverage (task 29)¶
@OverrideVirtual return marshalling covers every Variant-expressible return
family in the 4.7 virtual signature table on desktop/Android. Tasks 13 + 29
landed, per family (Kotlin return type in parentheses): the audited scalar/POD
set, String, PackedStringArray (List<String>), all remaining fixed-element
Packed*Arrays (ByteArray/IntArray/LongArray/FloatArray/DoubleArray/
List<Vector2>/List<Vector3>/List<Color>), Dictionary
(Map<String, Any?>, String keys — the audited container policy), generic and
typed Arrays (List<Any?> — also the shape for the engine's typedarray::*
returns), RID, Rect2, AABB, Transform2D, Transform3D, Projection,
and Variant (Any?). Two families additionally ride existing ones by
Variant conversion: StringName returns are declared as String and
enum/bitfield returns as Long (the engine casts the returned Variant at the
call site, exactly as it does for GDScript).
By-design excluded returns (6 virtuals): void* (3) and const Glyph*
(3, TextServerExtension glyph buffers). Raw-pointer returns are not
Variant-expressible — GDScript cannot override these virtuals either — so they
stay outside the @OverrideVirtual surface permanently.
iOS mirror bounds: iOS value-returns cover Bool/Int/Float/Vector2/Vector2i/
Vector3/String/RID, all fixed-element Packed*Array families (a
KanamaIosPackedArgDesc + flat element buffer through the PT return scratch),
PackedStringArray, Dictionary and generic Array (tagged-entry blobs), and
Variant returns over that audited inner-type set (an unaudited inner value
serializes as nil — a valid Variant). The Rect2/AABB/Transform2D/
Transform3D/Projection returns are desktop/Android-only for now, by
design: Transform3D/Projection exceed the 32-byte PT return scratch (they
need the pointer-to-scratch route), the C shim has no variant_from boxing for
the four yet, and no 4.7 virtual returning them has arguments that are
declarable in Kotlin today — the iOS emitter warn-skips them fail-loud (the
same guardrail the task-28 desktop families use), and the mirror belongs to the
iOS wrapper-family follow-up slice.
Width-sensitive coverage: the desktop families are runtime-validated in
runtime_smoke.sh (the vret rows — GDScript typeof() + content checks
through the script-instance dispatch, including Int.MAX_VALUE, 1.0e308, and
full-range bytes); the iOS families have Kotlin encode round-trips plus C
build/box round-trips in the on-device self-test matrix (the
virtual-packed-*-ret / virtual-dictionary-ret / virtual-array-ret rows).
iOS @ScriptProperty conversion parity: narrow scalars
(kotlin.Float/kotlin.Int widened to the 64-bit FLOAT/INT slots), Kotlin
enum class exports (INT slot carrying the ordinal), and enum-list exports
(typed int Array of ordinals) use the same conversions on iOS as on
desktop/Android. The iOS emitter narrows numeric slots, clamps scalar enum
ordinals, and maps/clamps enum-list elements. Integer arrays have a dedicated
C/runtime delivery entrypoint rather than sharing object-array cells, so stale
or malformed integers cannot be misread as object handles. Scene-loaded values
and later engine-originated Object.set updates both take this path. The iOS
descriptor now preserves each property's hint, hint string, and usage, while
the ScriptInstance getter maps the Kotlin values back to widened scalars or
ordinal arrays for engine reads.
KDoc Maintenance¶
Public Godot-backed wrappers and builtin value types carry generated KDoc
imported from Godot's doc/classes/*.xml files. The generated blocks include a
Generated from Godot docs: marker so they can be refreshed safely instead of
hand-edited.
Docs version pin: wrapper KDoc is synced from the Godot 4.7-stable
doc/classes (commit 5b4e0cb0fd), matching the shipped runtime baseline. Do
not sync from a -rc / latest tree — a mismatched docs tree reintroduces
version skew. Bump this pin when the Godot runtime baseline moves, and re-run the
full refresh (part of the Godot Upgrade Runbook).
To check whether KDoc is current against a local Godot source tree:
python3 scripts/sync_kdoc_from_godot_docs.py \
--godot-docs /path/to/godot-4.7-stable/doc/classes --check
To refresh the whole tree (the 4.7-stable baseline refresh, task 23):
python3 scripts/sync_kdoc_from_godot_docs.py \
--godot-docs /path/to/godot-4.7-stable/doc/classes --write
The script uses $GODOT_DOCS when set, otherwise it defaults to
godot/doc/classes relative to the current working directory. Use
--godot-docs /path/to/godot/doc/classes when syncing against a different
Godot checkout. Use --classes Node,Node3D,Tween for a focused refresh while
working on one wrapper slice, or --scope types --classes Vector3 when
working on builtin values. Keep the default separate --api-dir
(.../kanama/api) and --types-dir (.../kanama/types) — pointing --types-dir
at the api dir mislabels node classes as value types.
The refresh is gate-neutral: check_full_drift_gate strips
Generated from Godot docs: blocks before comparing (comparable_source), so a
KDoc refresh never affects drift-gate correctness and only touches comments. The
sync replaces an existing generated block in place (multi-line or the older
single-line /** … */ form), so re-running is idempotent.
Value-Type Audits¶
Value-type helpers that mirror Godot builtin methods should call the matching
Godot builtin unless the local implementation is deliberately proven equivalent.
Use the audit script when editing types/*.kt:
python3 scripts/audit_value_type_wrappers.py --api extension_api.json
The audit is report-only by default, and --strict is wired into local CI.
Reviewed local scalar formulas are allowlisted in the script; any newly added
Godot-named value helper that does not call the builtin should be treated as
suspicious until it is either bound to Godot's builtin or deliberately added to
the reviewed list with focused parity evidence.
For scalar Godot float method arguments, keep using the separate ABI guard:
python3 scripts/audit_scalar_float_abi.py
Godot's scalar float ptrcall slot is 64-bit (JAVA_DOUBLE) even when
single-precision real_t value components are Float.