Skip to content

Task 117 P2′: Image, PlaneMesh, StaticBody3D and the tweener family generated once — HAND_SHAPED down to the three roots - #275

Merged
falcon4ever merged 9 commits into
mainfrom
task-117/p2
Sep 23, 2026
Merged

falcon4ever merged 9 commits into
mainfrom
task-117/p2

Conversation

@falcon4ever

@falcon4ever falcon4ever commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

What & why

Task 117 P2′ (kanama-tasks 117-hand-shaped-wrappers-expect-actual.md → D18): the four classes that were generated on desktop but
hand-written on iOS are generated once — Image (iOS gains the 21 members its hand copy omitted: blit*/blend*/fillRect/
getRegion, the load*FromBuffer/save*ToBuffer family, setData, computeImageMetrics — the iOS renderer refused none of them),
PlaneMesh, StaticBody3D (iOS gets the real : PhysicsBody3D : CollisionObject3D chain — the task's D3 item — by construction),
and the tweener family (Tweener, PropertyTweener, CallbackTweener, MethodTweener; the iOS fluent glue in IosGodotApi.kt and
its three IosGodot.* declarations are deleted; iOS gains MethodTweener). Tween stays hand/collision; its iOS tweenMethod now
returns MethodTweener?. HAND_SHAPED is down to the three roots (GodotObject, RefCounted, GodotCallable); allowlist 103 → 62;
shared tree 1003 → 1010; expect object 1415 → 1431 (16 desktop actual markers).

Desktop source change (documented): the generated PropertyTweener/CallbackTweener fluent setters return the nullable self
type like every generated Object return (the hand files returned the non-null self through a private wrapOrThis); two chained calls in
Match3's Tile.kt use ?. in the paired kanama-demos PR — the eleven-demo canary fails only on those two lines. iOS-side:
PlaneMesh.fromResource(Resource) non-null (0 callers), Image.getData() without the size hint. Orphaned C entries (unreferenced
from Kotlin now, left for a later cleanup): kanama_ios_godot_property_tweener_from_color, …tweener_set_trans, …tweener_set_ease
and their static binds.

Worker parcel (Opus) with a diff-first checkpoint on the tweener family (the nullability finding was reported before code; ruled: keep
the generator's shape, pair a demos fix); coordinator re-ran drift, parity, ObjectCalls-parity and PT-tag gates on the head and read
the generator, gate-script, iOS glue and seam diffs.

Follow-up in this PR: iOS static dispatch (D19) — found by the independent review

The review's blocker: Image.createFromData moved from the iOS hand copy (ptrcallStatic…) into the shared file, where the
generator renders every Godot static as ObjectCalls.<helper>(bind, NULL_SEGMENT, …). Desktop passes the null straight to
object_method_bind_ptrcall; the iOS C entry points early-return on a null instance (the guard commit 30c949a1 kept when it added
kanama_ios_godot_ptrcall_static). Every shared-tree static — 72 sites in 36 classes (Image, JSON, Thread, RegEx, Resource, the
GLTF
factories, …) — had been a silent no-op on iOS since 7a43afc (2026-09-15).
* Two follow-up commits fix it at the iOS
ObjectCalls layer, keep every C guard, and add a gate so it cannot come back:

  • 002e2c65: ptrcallDispatch in the iOS ObjectCalls hand region routes a zero instance to kanama_ios_godot_ptrcall_static; the
    generator template emits it, so all 1172 plain ptrcall sites (hand + generated) go through it. 59 of the 72 sites fixed.

  • a30baf0e: the 22 specialised guarded C entry points that ObjectCalls calls (…_no_args_ret_*, …_ret_variant_scalar,
    …_ret_utf8, …_ret_array_blob, …_ret_callable, kanama_ios_godot_object_call, …) get the same _dispatch body / guarded
    instance entry / _static sibling split as kanama_ios_godot_ptrcall, declared in ios/include/kanama_ios.h; one private
    *Dispatch per entry in ObjectCalls.kt. The remaining 13 sites fixed → 72/72. New gate scripts/check_ios_static_dispatch.py
    (local_ci stage, gates index): parses the shim for every exported function that early-returns on a null instance and takes a
    method_bind, and fails if ObjectCalls.kt calls one outside a *Dispatch that also calls its _static sibling — red run
    recorded in the script's docstring and re-executed by the coordinator (FAIL at the raw call, restored → PASS).

  • OBJECTCALLS SELFTEST 205 → 218: 13 probes through the wrapper API (Image.createFromData/create, Thread.isMainThread,
    RegEx.createFromString, JSON round trip, ShaderIncludeDB.listBuiltInIncludeFiles/getBuiltInIncludeFile,
    Resource.generateSceneUniqueId, MultiplayerAPI.getDefaultInterface). Desktop: no code change.

  • Text fixes from the review: the CHANGELOG claim that Image's factories were "unchanged on both platforms"; the
    PropertyTweener.from mechanism sentence in wrapper-maintenance.md (a return-type special case in _candidate_for_impl, not
    METHOD_CALL_SHAPE_OVERRIDES); the null-instance convention documented on both backends. Filed in kanama-tasks as 119 items 35–36
    and tasks 124–126 (no silent paths; API exercise derived from the API description; freeze the generator conventions at 0.5.0).

  • 0fca5abe (from the independent review of the two commits above): two of the new _dispatch bodies (…_ret_raycast_dict,
    …_ret_object_handles) still called the guarded kanama_ios_godot_ptrcall, so their _static siblings were dead ends (latent: no
    shared static uses those shapes) — fixed, and the gate now scans the shim's own _dispatch bodies (red run), scopes guarded entries
    by signature instead of the name prefix (which brought kanama_ios_classdb_instantiate_owned into scope: it got the same split and
    dispatcher), matches generic Kotlin members and one-line header declarations (red runs), and defines "guarded" as an early return
    (guarded set unchanged: 26, 24 called). Docs/CHANGELOG qualifiers restored. Reviewer's recount of the earlier claims: 72/36 sites, 1172
    plain sites, 205 → 218 checks, C bodies are moves (guard split only), desktop diff empty, generator fixture matches.

  • 4e9805de (from the first phone smoke on 0fca5ab): Match3 crashed inside the Kotlin OBJECTCALLS SELFTEST — the
    RenderingServer.mesh_create_from_surfaces row looked the singleton up at scene init, where Godot has not registered it yet
    (main.cpp: initialize_extensions(SCENE) at 835, register_server_singletons() at 3864); the lookup returned 0, the old guard
    made the call a no-op returning RID 0, and the row asserted exactly RID 0 — a false pass since task 100. With D19 the call reached
    Godot with a null this. Fix: requireSingleton/requireObject helpers (every singleton row and the eight default-asserting
    object rows now assert presence and skip on absence instead of calling with zero), the RenderingServer row moved to a one-shot
    first-frame phase (OBJECTCALLS SELFTEST (frame 1): N passed, M failed, hooked in kanama_ios_frame), and getSingleton logs an
    error line on both platforms when it returns null. Expected on device: level 2 236 passed, 0 failed, frame 1 2 passed, 0 failed (superseded below).

  • 4a7146a6 (from the second phone smoke, on 4e9805d: no crash, runner PASS, but the now-honest self-test showed 4 FAILs): the
    NativeMenu rows were another false pass (same late registration as RenderingServer → moved to frame 1; NativeMenu::get_singleton()
    is non-null on iOS via the apple_embedded DisplayServer); the two ShaderIncludeDB probes could not distinguish the fix from the no-op
    on GL Compatibility (built-in includes are RD-only) → replaced by GLTFDocument.getSupportedGltfExtensions() containing
    KHR_lights_punctual and a helper-level FileAccess.get_sha256("res://project.binary") (64 lowercase hex chars) through the
    identical null-instance route; the RenderingServer row's RID(0) expectation was wrong (4.7.2 has no empty-list ERR_FAIL; a valid
    RID is allocated) → asserts a valid RID and frees it. Rule written into the self-test header and backends/ios.md: a probe must
    assert a value the no-op path cannot produce
    ; every default-valued row audited (table in the commit). Expected on device: level 2
    236 passed, 0 failed, frame 1 4 passed, 0 failed. iOS FileAccess.fileExists added (desktop parity).

  • 09506cf7: independent review of the two self-test commits (no blocker; every Godot fact re-verified at the cited lines; counts
    reconciled 218 → 236 / 4). Applied its findings: the follow-up-4 CHANGELOG totals marked as superseded, the get_frustum row now
    asserts the documented off-world empty list instead of a vacuous all {}, the null-singleton log line names the legitimate
    editor/web-only case, one citation range corrected. Note recorded: check_wrapper_parity.py covers only the three roots, so the
    new iOS FileAccess.fileExists is hand-verified against desktop, not gate-covered.

ABI / ownership call-out: ios/bootstrap/kanama_ios_shim.c gains 22 _dispatch + 22 _static functions (body moves, no guard
removed, no signature changed); ObjectCalls.kt iOS gains 23 dispatchers. Both CI ios and local-ci (linux) jobs passed on a30baf0; re-run on 0fca5ab.

Checklist

  • Ran scripts/local_ci.sh <godot> to green — the full gate, not just the in-editor smoke (see AGENTS.md → Validation). — PASS on the head 850c543 (machine B, 2026-09-21, 58 stages; runtime_smoke PASS incl. the Tweener ownership rows).
  • Up to date with the latest main (branch protection also enforces this at merge time). — cut from 34f9c63, main unchanged since.
  • If this touches ABI / memory-ownership code (src/jvmMain/kotlin/binding/, processor/, retain/release, marshalling), I've called it out below so it gets a careful review. — binding/runtime/ObjectCalls.kt: 16 existing helpers gain actual on desktop, none on iOS beyond the generated region; no body change. The tweener self-return collapse (releaseHandle on a same-address return) is the shared tree's standard form on both platforms; runtime_smoke checks Tweener ownership … method_chained_same=true property_from_delta=0.
  • Updated docs / CHANGELOG if behavior changed, and bumped any paired constants I touched (see AGENTS.md → synchronized invariants). — CHANGELOG block per group; wrapper-maintenance.md, demo-porting-rules.md; generated pages incl. the stale ios-backend-handwritten.md.

Testing

Drift PASS (shared=1010, hand desktop=23 → the roots), parity PASS (3 classes, 62), ObjectCalls parity PASS (1431), PT-tag PASS;
compileKotlinJvm / compileKotlinIosArm64 / :processor:test / :jvmTest / ktfmtCheck PASS per group; example_project and
templates compile unchanged; demos canary 10/11 PASS, the failure being exactly the two Tile.kt chains. Falsifications: Image back
in HAND_SHAPED → parity FAIL; Tweener back in PER_PLATFORM_WRAPPERS → drift gate FAIL.

scripts/local_ci.sh PASS on 850c543; PASS on a30baf0; re-run on 0fca5ab in progress. Pending before merge: independent review of the two follow-up commits (one Opus agent); iPhone 12 smokes (expected self-test lines: 236 passed, 0 failed at level 2 and (frame 1): 4 passed, 0 failed) (Match3 +
third-person through the demos runner, from the paired demos branch — third-person exercises StaticBody3D and the tween paths).

AI assistance

Written with Claude Code (Opus worker, Fable 5.1 coordinator), reviewed by the maintainer.

🤖 Generated with Claude Code

falcon4ever and others added 9 commits September 21, 2026 15:49
…Image members

Both group-B "iOS hand copy is a strict subset of the shared draft" classes are generated
once into src/sharedApi; all four per-platform copies are deleted and PER_PLATFORM_WRAPPERS
loses both entries (32 -> 30). The shared draft is signature-identical to the deleted
desktop file (only internal wrap's parameter goes MemorySegment -> the RawSegment alias),
so nothing changes on desktop: iOS gains Image's 21 desktop-only members (blit*/blend*/
fillRect/getRegion, the load*/save*Buffer PackedByteArray family, setData,
computeImageMetrics) and PlaneMesh.fromResource tightens to a non-null Resource.

No Image shape was refused by the iOS renderer: the shape gap stays at 3 desktop-only
members waiting across 2 classes and no Image.jvm.kt companion exists. 16 ObjectCalls
helpers newly reach the shared tree and are actual on both platforms (expect 1415 -> 1431).
HAND_SHAPED 7 -> 5, allowlist 103 -> 82, shared tree 1003 -> 1005.

Verified: drift gate PASS (shared=1005), parity gate PASS (5 classes / 82), ObjectCalls
parity PASS, PT-tag PASS, check_doc_claims OK, audit_claims PASS 6/6, ktfmtCheck,
compileKotlinJvm + compileKotlinIosArm64 + :processor:test + :jvmTest BUILD SUCCESSFUL,
all 11 demos compile unchanged.

Assisted by AI

Co-Authored-By: Claude <noreply@anthropic.com>
…ysics chain (D3)

The generated desktop copy and the hand-written iOS `class StaticBody3D : Node3D` in
IosGodotApi.kt are both deleted; the class is generated once into src/sharedApi and
PER_PLATFORM_WRAPPERS loses the entry (30 -> 29, iOS collision classes 11 -> 10). On iOS
the chain becomes StaticBody3D : PhysicsBody3D : CollisionObject3D : Node3D, which is
task 117's D3 satisfied by construction rather than by a hand edit; the two re-declared
collisionLayer/collisionMask properties now come from CollisionObject3D with the same Long
type, the same uint32 ptrcall helpers and the same bind hashes, so no call site changes.

iOS gains StaticBody3D's own 9 members (physicsMaterialOverride, constantLinearVelocity,
constantAngularVelocity and their get/set pairs) plus the companion fromHandle/wrap, plus
PhysicsBody3D's 14 and CollisionObject3D's 38 through the corrected chain; AnimatableBody3D
(already shared as : StaticBody3D) inherits the fix. The shared draft is signature-identical
to the deleted desktop file, so desktop is unchanged and no int width moved.

HAND_SHAPED 5 -> 4, allowlist 82 -> 68 (the 14 StaticBody3D lines, including the
`supertype | *` line that carried D3 as a known divergence since P0), shared tree 1005 -> 1006.

Verified: drift gate PASS (shared=1006), parity gate PASS (4 classes / 68), ObjectCalls
parity PASS, PT-tag PASS, check_doc_claims OK, audit_claims PASS 6/6, ktfmtCheck,
compileKotlinJvm + compileKotlinIosArm64 + :processor:test + :jvmTest BUILD SUCCESSFUL,
all 11 demos compile unchanged.

Assisted by AI

Co-Authored-By: Claude <noreply@anthropic.com>
…wn to the three roots

Tweener, PropertyTweener, CallbackTweener and MethodTweener retire together into the shared
tree. They had to go as a set: the iOS hand cluster in IosGodotApi.kt put setTrans/setEase on
the BASE Tweener (Godot declares them on the subclasses) precisely because the generated
subclass members would have clashed with it, which is also why MethodTweener was the
`unsupported` cell iOS did not host at all. PER_PLATFORM_WRAPPERS 29 -> 25;
IOS_UNSUPPORTED_CLASSES is down to DirAccess alone. Tween itself stays hand-written on both
platforms (its iOS Variant tween_property runtime is outside task 117).

Source break on desktop: the fluent setters now return the NULLABLE self type. The hand copies
returned the non-null self through a private wrapOrThis that turned a null engine return into
`this`; the generated self-return collapse keeps the reference-neutral part and ends in
wrap(ret), which is nullable like every other generated object return. So PropertyTweener's
from/fromCurrent/asRelative/setTrans/setEase/setCustomInterpolator/setDelay are
`PropertyTweener?` and CallbackTweener.setDelay is `CallbackTweener?`; chains take `?.`.
wrapOrThis is gone (no callers after the inline collapse); Tween's own wrapOrThis is untouched.
Both constructors go internal -> public and both gain @JvmStatic fromHandle. Tweener and
MethodTweener are otherwise identical to their deleted desktop copies.

iOS gains the whole family: MethodTweener as a class it did not host, PropertyTweener's seven
generated members (from(value: Any?) covers the hand from(Color) through packVariantDesc's
PT_COLOR case), CallbackTweener.setDelay, and Tweener's Signals/companion. Tweener.setTrans /
setEase move to the subclasses Godot declares them on. iOS Tween.tweenMethod returns
MethodTweener? (was Tweener?) — the one hand edit that class needed. The iOS glue
IosGodot.tweenerSetTrans/tweenerSetEase/propertyTweenerFromColor and their three cinterop
imports are deleted; the C shim functions and their three static binds stay for a later cleanup.

HAND_SHAPED 4 -> 3 (GodotObject, RefCounted, GodotCallable — the P3' roots), allowlist 68 -> 62,
shared tree 1006 -> 1010.

Verified: drift gate PASS (shared=1010, collision 7, unsupported 1), parity gate PASS
(3 classes / 62), ObjectCalls parity PASS, PT-tag PASS, check_doc_claims OK, audit_claims
PASS 6/6, ktfmtCheck, compileKotlinJvm + compileKotlinIosArm64 + :processor:test + :jvmTest
BUILD SUCCESSFUL, example_project and templates/starter compile unchanged, 10/11 demos compile
unchanged. Starter-Kit-Match3 needs the paired demos change (Tile.kt:85 and :91 chain
setTrans(...).setEase(...) without `?.`).

Assisted by AI

Co-Authored-By: Claude <noreply@anthropic.com>
…D19)

The generated shared tree calls Godot static methods through the ordinary
instance-shape ObjectCalls helpers with NULL_SEGMENT as the instance
(_null_segment() for method.is_static in scripts/generate_api_wrapper.py).
Desktop/Android pass that MemorySegment.NULL straight to
object_method_bind_ptrcall, which Godot accepts for a static bind. On iOS the
same helpers all ended in the C entry point kanama_ios_godot_ptrcall, which
deliberately early-returns when instance == 0 — the guard commit 30c949a kept
when it added the separate kanama_ios_godot_ptrcall_static entry point. The
per-platform iOS hand copies used the ptrcallStatic* helpers and were fine; the
shared tree never did, so every shared-tree static call was a silent no-op on
device (null / 0 / default) while passing on desktop. 72 call sites across 36
shared classes, present since 7a43afc (2026-09-15, task 104 step 3) and made
visible by this PR moving Image.createFromData off the iOS hand copy.

The fix is one dispatcher in the iOS Kotlin ObjectCalls layer, not the C shim
and not the shared tree. ptrcallDispatch(...) is hand-written above the BEGIN
GENERATED MEMBERS marker and sends a zero instance to
kanama_ios_godot_ptrcall_static, everything else to kanama_ios_godot_ptrcall.
All 1172 plain-ptrcall call sites in the file now go through it; the generated
region does so because the generator emits ptrcallDispatch, so a regen keeps
the fix (fixture and iOS region regenerated; check_wrapper_generator reports
zero drift). ios/bootstrap/kanama_ios_shim.c, the ptrcallStatic* helpers and
their hand users (FileAccess, ImageTexture) are untouched, and desktop needs no
change: src/jvmMain/kotlin/binding/runtime/ObjectCalls.kt has no null-instance
guard on the ptrcall path (line 194's guard is inside notifyPostinitialize).

This repairs 59 of the 72 sites — the ones whose helper marshals through the
generic dispatch. The other 13 return through specialized C entry points
(..._no_args_ret_string, ..._no_args_ret_string_name,
..._no_args_ret_packed_string_array, ..._no_args_ret_typed_array_blob,
..._ret_array_blob, ..._ret_utf8, ..._ret_variant_scalar and
kanama_ios_godot_object_call), each of which keeps its own null-instance guard
and has no _static counterpart yet; they are called out in the changelog as
still no-op on iOS and need shim work this ruling deliberately excludes.

Device-visible probe: the iOS OBJECTCALLS SELFTEST grows from 205 to 212
checks. The seven new rows drive the shared-tree static path through the public
wrapper API rather than the ptrcallStatic* helpers —
Image.createFromData(2, 2, false, FORMAT_RGBA8, ByteArray(16)) (non-null, width
2, height 2), Image.create(4, 3, false, FORMAT_RGBA8) (non-null, width 4),
Thread.isMainThread() and RegEx.createFromString("a+b", false).

Text corrections:
- CHANGELOG.md: the bullet claiming Image's four companion factories are
  "unchanged on both platforms" was false — on iOS createFromData moved from
  the static entry point to the null-instance path. Rewritten to say what is
  true after this fix, plus a new "### Fixed" entry describing the systemic
  defect, the dispatcher and the self-test additions, with recountable numbers.
- docs/contributing/wrapper-maintenance.md: the sentence saying
  PropertyTweener.from reaches ptrcallWithVariantArgRetObject through a
  METHOD_CALL_SHAPE_OVERRIDES entry named the wrong mechanism — that table
  holds only the two ClassDB rows. The real source is the return-type-keyed
  special case in _candidate_for_impl (Object return over a single Variant arg
  when return_type is Node or PropertyTweener).
- docs/contributing/wrapper-maintenance.md: documented the NULL_SEGMENT
  null-instance convention beside the existing "NULL_SEGMENT for a static"
  sentence — desktop passes it to Godot, iOS routes it to the static entry
  point through ptrcallDispatch.

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
Commit 002e2c6 routed the 1172 plain ptrcall sites through ptrcallDispatch and
repaired 59 of the 72 shared-tree static call sites. The residue was 13 sites in
EditorExportPlatform, FileDialog, GLTFDocument, JSON, MultiplayerAPI, Node,
Resource and ShaderIncludeDB whose ObjectCalls helper does not marshal through
the generic entry point: they return through a specialised C entry
(..._no_args_ret_string, ..._no_args_ret_string_name,
..._no_args_ret_packed_string_array, ..._no_args_ret_typed_array_blob,
..._ret_array_blob, ..._ret_utf8, ..._ret_variant_scalar and
kanama_ios_godot_object_call), each carrying its own `instance == 0` early
return and having no static sibling. kanama_ios_godot_ptrcall was never the only
guarded entry point.

The property this reaches is stronger than the 13: no Kotlin ObjectCalls helper
can hand a zero instance to a guarded C entry point, so the next static the
generator renders through any shape is covered by construction.

C shim (ios/bootstrap/kanama_ios_shim.c, ios/include/kanama_ios.h). All 22
guarded kanama_ios_godot_* entry points an ObjectCalls helper calls with an
instance now follow the split commit 30c949a established for
kanama_ios_godot_ptrcall: the body moves into an unguarded
`static <symbol>_dispatch(...)`, the existing exported symbol keeps its
null-instance guard and calls it, and a new `<symbol>_static(...)` sibling
without the instance parameter calls it with 0. Four entries zero their
out-parameters before the guard (ret_callable, ret_variant_scalar, object_call,
load_status_with_progress); that preamble is repeated in the guarded entry's
reject branch, because their Kotlin callers decode the out-parameters without
consulting the returned status. No existing guard was removed and no existing
signature changed. The 22 new symbols are declared beside their siblings in
ios/include/kanama_ios.h, which is what the kanama_ios.def cinterop reads.
Guarded entries WITHOUT a method_bind (object_destroy, object_connect_bound,
object_disconnect_bound, ...) are untouched on purpose: their zero check guards
a live engine object handle, which the generator's static marker never reaches.

Kotlin (src/iosMain/.../binding/runtime/ObjectCalls.kt, hand region). One
private dispatcher per C entry point, next to ptrcallDispatch and following it:
22 `<entry>Dispatch` functions that pick the _static sibling when instance == 0.
All 32 call sites of those entry points in the file now go through them, so each
guarded symbol is named exactly once in the file, inside its dispatcher.
`inline` is dropped from ptrcallDispatch (the compiler says it buys nothing) and
the new ones are not inline.

Gate: scripts/check_ios_static_dispatch.py, a local_ci.sh stage beside
check_objectcalls_parity. It parses the shim for every exported
kanama_ios_godot_* function that early-returns on a zero instance, derives the
in-scope set (those that also take a method_bind — the ptrcall shape a static is
rendered through) and fails if ObjectCalls.kt names one anywhere except inside a
*Dispatch function that also calls its _static sibling. Findings are raw-call,
dispatcher-no-static, missing-sibling and parse-error; the entry-point ->
dispatcher table and the out-of-scope object-handle entries print on PASS.
Red run (task 118: every gate ships with its negative test, documented in the
docstring): replacing the ptrcallNoArgsRetStringDispatch call in
ptrcallNoArgsRetString with the raw entry point yields

  [ios_static_dispatch] FAIL raw-call src/iosMain/kotlin/net/multigesture/kanama/binding/runtime/ObjectCalls.kt:1005 calls the guarded entry point kanama_ios_godot_ptrcall_no_args_ret_string from ptrcallNoArgsRetString(), not from a *Dispatch function — a static call site reaches its `instance == 0` guard and silently no-ops

and exit 1; restoring the dispatcher call makes it green again.

Device-visible probes: the iOS OBJECTCALLS SELFTEST grows from 212 to 218
checks. The six new rows drive the six specialised entry points through the
wrapper API — a JSON.stringify/JSON.parseString round-trip (ret_utf8 and
ret_variant_scalar), ShaderIncludeDB.listBuiltInIncludeFiles() non-empty
(no_args_ret_packed_string_array), ShaderIncludeDB.getBuiltInIncludeFile() on a
name from that list (object_call, via callWithVariantArgs),
Resource.generateSceneUniqueId() non-empty (no_args_ret_string) and
MultiplayerAPI.getDefaultInterface() == "SceneMultiplayer"
(no_args_ret_string_name).

Docs: the CHANGELOG "### Fixed" entry no longer carries the 59/13 residue — it
reads 72/72, describes the _dispatch/_static split and names the new gate.
docs/contributing/wrapper-maintenance.md gains the guarded-entry rule beside the
null-instance paragraph; docs/exporting/ios.md says each instance-taking shim
entry point has a _static sibling. docs/reference/generated/gates.md is
regenerated for the new stage.

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
Review of 002e2c6 + a30baf0 found seven items. All seven are fixed here.

1. BLOCKER: two `_dispatch` bodies called the guarded entry point, so their
   `_static` siblings were dead ends. `kanama_ios_godot_ptrcall_ret_raycast_dict_dispatch`
   (:4626) and `..._ret_object_handles_dispatch` (:10222) called
   kanama_ios_godot_ptrcall instead of kanama_ios_godot_ptrcall_dispatch. A
   `_dispatch` body is the UNGUARDED half of a split -- it is reached with a zero
   instance by construction -- so the callee's `instance == 0` guard returned
   before the result cell was written and a static routed through the
   raycast-dictionary or object-handle-list shapes still no-opped, one frame
   below the Kotlin dispatcher where nothing could see it. Both now call the
   unguarded body, as kanama_ios_ptrcall_encode_container (~:4330) already did.
   :10140 is inside kanama_ios_godot_ptrcall_ret_object_array, a guarded entry
   with no sibling that nothing calls, and is left alone.

2. The gate now reads the shim's own `_dispatch` bodies. The Kotlin rule was
   only half the property: for every `*_dispatch` body it fails (`shim-raw-call`)
   if the body names a guarded exported entry point instead of that entry's
   `_dispatch` body. 24 bodies scanned; zero parsed is itself a parse-error,
   because a half of the gate that reads nothing is not a gate.

3. Scope follows the SIGNATURE, not the name prefix. C_DEF_RE, C_DECL_RE and
   the call pattern required `kanama_ios_godot_`, which hid
   kanama_ios_classdb_instantiate_owned (:7898, guarded, takes method_bind +
   instance, called from ObjectCalls.kt:3774) for both previous follow-ups.
   Widened to `kanama_ios_[a-z0-9_]+`. The widened regex brings exactly two new
   exported symbols into scope in the shim -- kanama_ios_classdb_instantiate_owned
   and the _static sibling added here -- and no new call in ObjectCalls.kt beyond
   those two. classdb_instantiate_owned now has the same `_dispatch` / guarded
   entry / `_static` split, a declaration in ios/include/kanama_ios.h, and a
   classdbInstantiateOwnedDispatch in ObjectCalls.kt. Its one shared-tree caller
   (ClassDB.kt:93) passes the singleton, so this was latent, not live -- which is
   the point: the property now holds by construction.

4. KT_FUN_RE matches generic members. It could not match
   `internal inline fun <T> retTypedObjectList(` (ObjectCalls.kt:2997) -- nor ANY
   `fun <T>` member -- so every call inside a generic member was attributed to the
   member above it, and a raw guarded call there read as a call from whatever
   `*Dispatch` happened to precede it. An optional `<...>` after `fun` closes it.

5. C_DECL_RE matches both header forms. It required the opening `(` to end the
   line; 57 of kanama_ios.h's declarations put the parameter list on that line.
   A `_static` sibling written on one line would have been reported
   `missing-sibling` -- a false positive, which is how gates get ignored.

6. "Guarded" now means an early return: an `if (...)` whose condition tests
   `instance == 0` / `object == 0` and whose block returns, not a zero comparison
   anywhere in the body. The guarded set is unchanged by the tightening -- the
   same 25, plus classdb_instantiate_owned from item 3 = 26, printed on PASS
   (24 of them called from ObjectCalls.kt, each through its dispatcher).

   The gate also learned `--shim` / `--header` / `--objectcalls` (and the
   matching env vars) so its negative tests run against scratch copies without
   touching the tree, and it strips C comments and string literals before
   parsing, so a name in a comment is never read as a call.

   Red runs (task 118: every gate change ships with one), all verbatim in the
   handoff: the :4626 defect re-introduced on a scratch shim yields
   `shim-raw-call ...:4626`; a generic member inserted between a `*Dispatch` and
   a raw guarded call yields `raw-call ...:260 ... from scratchGenericMember()`
   where the pre-fix regex exited 0; and a one-lined `_static` declaration in a
   scratch header still PASSes where the pre-fix C_DECL_RE reported
   `missing-sibling`.

7. Docs and CHANGELOG. docs/exporting/ios.md no longer claims every
   instance-taking entry point has a `_static` sibling -- it is qualified to the
   ones an `ObjectCalls` helper can reach with the static marker, and names the
   three kinds that keep their single guarded form
   (ptrcall_string_arg, ptrcall_ret_object_array, the object-handle entries).
   The CHANGELOG `### Fixed` entry regains the qualifier from a30baf0's message
   ("that an `ObjectCalls` helper calls with an instance"), corrects 22 to 23 +
   classdb_instantiate_owned, moves the "72 of 72" claim after the dead-end fix
   that makes it true, and describes the gate's shim-side scan.
   docs/contributing/wrapper-maintenance.md gains the `_dispatch`-body rule.
   docs/reference/generated/gates.md is regenerated (the gate's docstring first
   sentence is what that page quotes).

Gates: check_ios_static_dispatch, check_wrapper_generator,
check_objectcalls_parity, check_pt_tag_tables, check_doc_claims,
generate_gates_index --check, clang -fsyntax-only (same 7 pre-existing
warnings as HEAD), ktfmtFormat (no reformat), compileKotlinIosArm64 and
createIosDeviceDebugXcframework.

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
…dispatch

On device (Match3, iPhone, 0fca5ab) the PTRCALL matrix printed 70/70 and the
Kotlin OBJECTCALLS self-test then SIGSEGV'd inside
RenderingServer::_mesh_create_from_surfaces with a null `this`.

The row is task 100 parcel 10's Array[Dictionary] argument probe. Its comment
claimed "the RenderingServer exists before extensions initialise". It does not:
Godot 4.7.2 main/main.cpp runs initialize_extensions(INITIALIZATION_LEVEL_SCENE)
long before register_server_singletons() adds the RenderingServer singleton
(servers/register_server_types.cpp). So getSingleton("RenderingServer") returned
0, the old C null-instance guard turned the call into a no-op returning RID 0,
and the row asserted exactly RID(0L) -- it had passed for the wrong reason since
the day it was written. D19 routes a zero instance to the static entry point, so
the call finally reached Godot, with no instance. The dispatch did its job; the
row was wrong. Fix the row.

- requireSingleton(name) in the self-test resolves the singleton, records
  check("singleton-present(<name>)") and hands back the segment. All 13
  getSingleton sites (Input, Time, OS, ClassDB x2, Geometry3D x2,
  ProjectSettings, InputMap, TranslationServer, RenderingServer, Engine,
  NativeMenu) go through it, and every dependent call in a row is now skipped
  when the singleton is absent -- recorded as a FAILED check with a reason,
  never silently passed, never executed with a zero instance. The redundant
  "input-singleton" row is subsumed by singleton-present(Input).

- The RenderingServer row moves to a one-shot first-frame phase:
  kanamaIosRuntimeObjectCallsSelfTestFrame
  (@cname("kanama_ios_runtime_objectcalls_selftest_frame")), same check
  machinery, printing
  "[kanama][ios][kn] OBJECTCALLS SELFTEST (frame 1): N passed, M failed".
  kanama_ios_frame calls it exactly once, under KANAMA_IOS_DEBUG_VARIANT_CHECKS,
  when g_main_loop_callback_frame_count first reaches 1, before
  kanama_ios_runtime_frame(); the extern sits beside the scene-init one. The
  assertion is unchanged -- the engine rejects an empty surface list with
  ERR_FAIL_COND_V and returns an invalid RID -- but it now runs against a real
  instance.

- Singleton lookups fail loudly on both platforms. iOS ObjectCalls.getSingleton
  prints [kanama][ios][kn] ERROR: getSingleton("<name>") returned null -- not
  registered at this initialization level (Godot's own error print for this does
  not reach the device console capture); desktop does the equivalent on
  System.err with the [kanama:kt] prefix the runtime already uses. Both return
  the null segment unchanged -- no throw, the return shape is public behaviour
  (task 124 decides that).

- Audit of every other handle used as an instance in the self-test. Every
  constructObject-backed row asserts a round-trip value a zero instance cannot
  produce, so the row's own check is its guard -- except eight whose assertion is
  a DEFAULT (empty list, empty string, zero Rect2i, "no signal fired", "all
  components finite"), i.e. exactly the RenderingServer shape. Those now go
  through a matching requireObject(class) helper and skip on failure:
  TileMap.get_used_rect, AnimationPlayer.animation_get_next,
  GridMap.get_used_cells(+_in_octant), Camera3D.get_camera_projection,
  Camera3D.get_frustum, and the two lambda-Callable free-ordering rows
  (lamFreeReceiver, lamOrderEmitter/lamOrderReceiver). Object-returning helper
  results used as instances (imgHandle, Resource.duplicate, InputEventKey.create,
  the shared-static Image/RegEx returns, ctrl) already carry a non-null check or
  a short-circuiting assertion.

Totals: OBJECTCALLS SELFTEST 218 -> 236 at scene init (-1 moved row,
-1 redundant input-singleton, +12 singleton-present, +8 object-constructed) and
2 on frame 1.

CHANGELOG ### Fixed records the false pass and both mitigations;
docs/contributing/backends/ios.md gains a Rules bullet naming the two phases and
their summary lines; docs/contributing/wrapper-maintenance.md's null-instance
section says a failed singleton lookup now reaches Godot on iOS exactly as on
desktop, hence the log line.

Gates: check_ios_static_dispatch, check_wrapper_generator,
check_objectcalls_parity, check_pt_tag_tables, check_doc_claims,
generate_gates_index --check, clang -fsyntax-only (same 7 pre-existing warnings
as HEAD), ktfmtFormat (no reformat), compileKotlinJvm, compileKotlinIosArm64 and
createIosDeviceDebugXcframework (both selftest symbols exported).

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
…e they broke

The 4e9805d phone run (Match3, iPhone 12, GL Compatibility) did not crash and the
runner passed, but the now-honest self-test failed four rows at scene init and one on
frame 1. Three of the five could never have failed for the right reason.

THE RULE, now in the self-test header and docs/contributing/backends/ios.md:
a probe must assert a value the NO-OP PATH CANNOT PRODUCE. A static routed with a zero
instance used to hit the C guard and return null / 0 / false / "" / an empty list / a
zeroed struct; a probe whose EXPECTED value is any of those cannot tell the working call
from the call that never happened.

NativeMenu (2 rows) -- the same false pass as RenderingServer, MOVED to frame 1.
Engine gets its "NativeMenu" entry from register_server_singletons()
(servers/register_server_types.cpp:400), run at main/main.cpp:3864, long after
initialize_extensions(INITIALIZATION_LEVEL_SCENE) at main/main.cpp:3793 -- so the
scene-init lookup returned 0 and the guard answered with the null Callable the row
asserted. The row moves rather than goes because the NativeMenu OBJECT exists far
earlier on iOS: DisplayServerAppleEmbedded, which platform/ios inherits through
drivers/apple_embedded, constructs one at
drivers/apple_embedded/display_server_apple_embedded.mm:65, NativeMenu's constructor
sets its own singleton (servers/display/native_menu.h:154), and DisplayServer::create
runs at main/main.cpp:3368.

ShaderIncludeDB (2 rows) -- DELETED; they were indistinguishable on this renderer.
Godot registers the built-in include files only from the RenderingDevice renderer
(servers/rendering/renderer_rd/renderer_scene_render_rd.cpp:1796-1798). Match3 runs GL
Compatibility, so the honest answer is the empty list and the empty source -- which is
exactly what the old no-op produced. Their two C entry points keep their coverage
through replacements that assert a value a no-op cannot fake:

  * GLTFDocument.getSupportedGltfExtensions() -- same shared-tree
    ptrcallNoArgsRetPackedStringList helper, same NULL_SEGMENT static marker -- must be
    non-empty AND contain "KHR_lights_punctual". That set is hard-coded in
    GLTFDocument::get_supported_gltf_extensions_hashset (modules/gltf/gltf_document.cpp:6968)
    and bound by initialize_gltf_module at MODULE_INITIALIZATION_LEVEL_SCENE, which
    main/main.cpp runs immediately BEFORE extension SCENE init (main/main.cpp:3792-3793,
    834-835 on the other setup path). It does not depend on the renderer.
  * ObjectCalls.ptrcallWithStringArgRetString(getSha256Bind, NULL_SEGMENT,
    "res://project.binary") must return 64 lowercase hex characters. Helper-level on
    purpose: FileAccess is per-platform on iOS and hosts no shared-tree static of this
    shape, but the route (objectCallDispatch -> kanama_ios_godot_object_call_static) is
    identical. Guarded by a new iOS FileAccess.fileExists -- the desktop member's exact
    shape, static bind through NULL_SEGMENT -- and a missing res://project.binary is a
    recorded FAILURE, not a skip.

RenderingServer (frame 1) -- the EXPECTATION was wrong and had never been checked
against 4.7.2. Follow-up 4 kept RID(0L) on the claim that the engine rejects an empty
surface list with ERR_FAIL_COND_V. It does not:
RenderingServer::_mesh_create_from_surfaces (servers/rendering/rendering_server.cpp:1996-2002)
has no guard at all, and mesh_create_from_surfaces
(servers/rendering/rendering_server_default.h:363) opens with mesh_allocate() --
mesh_owner.allocate_rid() in the GL Compatibility mesh storage
(drivers/gles3/storage/mesh_storage.cpp:65-67) -- so an empty list yields a VALID, EMPTY
mesh. An invalid RID was also the no-op answer, so the row could not tell the fix from
the defect either way. It now asserts a valid RID and frees it with
RenderingServer.free_rid, which also gives the frame-1 phase its non-default assertion.

Camera3D.get_camera_projection -- a fourth row found by the item-4 audit and
strengthened. It asserted isFinite() on the four diagonal cells, which 0.0f satisfies,
so the zeroed 64-byte return buffer passed it. Its stated reason ("treeless camera
projection values aren't deterministic") was also unverified and wrong:
Camera3D::get_camera_projection opens with
ERR_FAIL_COND_V_MSG(!is_inside_tree(), Projection(), ...) (scene/3d/camera_3d.cpp:300-303)
and Projection is `= default` over member initialisers spelling the IDENTITY matrix
(core/math/projection.h:55-60). The row now asserts the identity.

The rest of the audit: every other default-valued assertion is admissible -- it is an
INSTANCE row whose instance requireSingleton / requireObject has already proven non-zero
(so the static route is not taken and the default is a real answer), or it sits in a row
that also asserts a non-default value through the same helper. One is called out in
place rather than fixed: plane-array-ret(get_frustum finite) is vacuously true because
Camera3D::get_frustum returns an empty list outside the world tree
(scene/3d/camera_3d.cpp:792-798) and a populated frustum needs a viewport that does not
exist at scene-level extension init; its comment now says so and names the non-default
row that does carry the Plane record decode.

Expected device totals: OBJECTCALLS SELFTEST 236 passed, 0 failed (unchanged -- four
rows out, four in) and OBJECTCALLS SELFTEST (frame 1) 4 passed, 0 failed (was 2).

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
Independent review of 4e9805d + 4a7146a found no blocker. Applied:

- CHANGELOG: the bold "236 / 2 on frame 1" totals in the follow-up-4 entry
  read as current; they are now marked as superseded by 236 / 4 in the same
  sentence. camera_3d.cpp citation range corrected to 299-302 (the
  ERR_FAIL_COND_V_MSG is line 300).
- Self-test: `plane-array-ret(get_frustum finite)` was vacuously true
  (`all {}` on an empty list). It now asserts the documented off-world
  answer, an empty list (scene/3d/camera_3d.cpp:792-798), which is
  falsifiable. Row count unchanged (236 / 4).
- getSingleton null log line (both platforms): says that the line is
  expected only for editor/web-only singletons on this platform, so a
  legitimately absent JavaScriptBridge / EditorInterface does not read as
  a bug.

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
@falcon4ever
falcon4ever marked this pull request as ready for review September 23, 2026 01:39
@falcon4ever
falcon4ever merged commit 27b8b17 into main Sep 23, 2026
10 checks passed
@falcon4ever
falcon4ever deleted the task-117/p2 branch September 23, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant