diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c3054f79..1aceedd37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -453,7 +453,6 @@ jobs: - uses: ./.github/actions/setup-ci-deps with: target: emscripten-wasm32-webgl - gradle: false save-toolchains: true sccache-access-key-id: ${{ secrets.R2_SCCACHE_ACCESS_KEY_ID }} sccache-secret-access-key: ${{ secrets.R2_SCCACHE_SECRET_ACCESS_KEY }} @@ -465,6 +464,15 @@ jobs: mise run install-native-package emscripten-wasm32-webgl build/packages/native/dist/maplibre-native-c-emscripten-wasm32-webgl.tar.gz + - run: mise run //bindings/kotlin:check-wasm-generated + env: + MISE_TASK_SKIP: "//:build" + - run: mise run //bindings/kotlin:check-wasm-externs + env: + MISE_TASK_SKIP: "//:build" + - run: mise run //bindings/kotlin:wasmJsTest + env: + MISE_TASK_SKIP: "//:build" - run: mise run //bindings/rust:test:browser emscripten-wasm32-webgl env: MISE_TASK_SKIP: "//:build" diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml index 64636fd31..afcff4b44 100644 --- a/.github/workflows/snapshots.yml +++ b/.github/workflows/snapshots.yml @@ -502,6 +502,15 @@ jobs: run-id: ${{ needs.plan.outputs.run_id }} pattern: native-package-linux-x64-*-${{ env.SNAPSHOT_SHA }} path: build/packages/native/maven-input + # The wasmJs publication carries the prelinked browser module, which this + # partition takes from the Emscripten package rather than linking itself. + - if: ${{ matrix.partition == 'linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + run-id: ${{ needs.plan.outputs.run_id }} + name: native-package-emscripten-wasm32-webgl-${{ env.SNAPSHOT_SHA }} + path: build/packages/native/maven-input - run: mise run //:kotlin:publish-snapshot stage ${{ matrix.partition }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -597,6 +606,15 @@ jobs: run-id: ${{ needs.plan.outputs.run_id }} pattern: native-package-linux-x64-*-${{ env.SNAPSHOT_SHA }} path: build/packages/native/maven-input + # The wasmJs publication carries the prelinked browser module, which this + # partition takes from the Emscripten package rather than linking itself. + - if: ${{ matrix.partition == 'linux' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + github-token: ${{ github.token }} + run-id: ${{ needs.plan.outputs.run_id }} + name: native-package-emscripten-wasm32-webgl-${{ env.SNAPSHOT_SHA }} + path: build/packages/native/maven-input - run: mise run //:kotlin:publish-snapshot publish-leaves ${{ matrix.partition }} publish-kotlin-roots: diff --git a/.mise/tasks/kotlin/publish-snapshot b/.mise/tasks/kotlin/publish-snapshot index 08ffd3430..f8609f7a7 100755 --- a/.mise/tasks/kotlin/publish-snapshot +++ b/.mise/tasks/kotlin/publish-snapshot @@ -41,6 +41,7 @@ prepare_native_inputs() { android-arm64-vulkan android-x64-egl android-x64-vulkan + emscripten-wasm32-webgl linux-x64-egl linux-x64-vulkan ) @@ -152,6 +153,18 @@ publish_linux_leaves() { LinuxX64 -- \ "$@" \ -Pmaplibre.runtime.vulkan.linuxX64.installDir="$install_root/linux-x64-vulkan" + + # A browser host fetches the prelinked module rather than loading a library, so + # the wasmJs publication carries that module as a classified archive instead of + # depending on a runtime publication. The module comes out of the browser + # package the same way every other target's library does, which is why this + # publishes from the partition that has the Emscripten artifact. + publish_tasks \ + :bindings:kotlin \ + "$repository" \ + WasmJs -- \ + "$@" \ + -Pmaplibre.browser.moduleDir="$install_root/emscripten-wasm32-webgl/lib/browser" } publish_apple_leaves() { diff --git a/.mise/tasks/kotlin/verify-staging b/.mise/tasks/kotlin/verify-staging index 773decb24..94b9824b2 100755 --- a/.mise/tasks/kotlin/verify-staging +++ b/.mise/tasks/kotlin/verify-staging @@ -29,6 +29,7 @@ MODULES = { "maplibre-native-ffi-runtime-vulkan-android", "maplibre-native-ffi-runtime-vulkan-jvm", "maplibre-native-ffi-runtime-vulkan-linuxx64", + "maplibre-native-ffi-wasm-js", } # Modules with public Kotlin API. Their javadoc jars must carry the Dokka site, @@ -41,6 +42,7 @@ API_BEARING_MODULES = { "maplibre-native-ffi-jvm", "maplibre-native-ffi-linuxx64", "maplibre-native-ffi-macosarm64", + "maplibre-native-ffi-wasm-js", } ROOT_TARGET_MODULES = { @@ -51,6 +53,7 @@ ROOT_TARGET_MODULES = { "jvm": "maplibre-native-ffi-jvm", "linuxX64": "maplibre-native-ffi-linuxx64", "macosArm64": "maplibre-native-ffi-macosarm64", + "wasmJs": "maplibre-native-ffi-wasm-js", }, "maplibre-native-ffi-runtime-opengl": { "android": "maplibre-native-ffi-runtime-opengl-android", @@ -382,6 +385,34 @@ def verify_native(root: pathlib.Path) -> None: raise SystemExit(f"{interop} is missing {expected}") +BROWSER_MODULE_FILES = { + "maplibre_native_c.mjs", + "maplibre_native_c.wasm", +} + + +def verify_browser(root: pathlib.Path) -> None: + """A browser host fetches the module rather than linking it, so the wasmJs + publication carries it as a classified archive instead of taking it from a + runtime module. An archive missing either file publishes a binding no page + can load.""" + archive = one( + module_files(root, "maplibre-native-ffi-wasm-js", "-browser-module.zip"), + "browser module archive", + ) + with zipfile.ZipFile(archive) as zip_file: + names = set(zip_file.namelist()) + missing = sorted(BROWSER_MODULE_FILES - names) + if missing: + raise SystemExit(f"{archive} is missing {missing}") + # The module is statically linked, so it redistributes MapLibre Native and every vendored + # dependency inside the wasm. Shipping it without their notices is a licensing defect that + # nothing about the archive makes visible, which is why it is checked before publication + # rather than left to a reader. + if not any(name.startswith("licenses/") and not name.endswith("/") for name in names): + raise SystemExit(f"{archive} carries no third-party notices under licenses/") + + def main() -> int: if len(sys.argv) != 2: print( @@ -395,6 +426,7 @@ def main() -> int: verify_android_runtime_independence(root) verify_jvm(root) verify_native(root) + verify_browser(root) return 0 diff --git a/CMakeLists.txt b/CMakeLists.txt index ec85aebad..b35499608 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,11 @@ mln_ffi_install_c_api_library(maplibre_native_c) # they never depend on the bare target name. add_library(maplibre_native_ffi::c ALIAS maplibre_native_c) +# The browser's distributable artifact is a linked module rather than a library, +# so it is produced here rather than by whatever consumes it. +include(mln_ffi_browser_module) +mln_ffi_add_browser_module(mln_ffi_browser_module maplibre_native_c) + if(BUILD_TESTING AND MLN_FFI_ARTIFACT_NAME) include(cmake/mln_ffi_tests.cmake) mln_ffi_add_c_api_test() diff --git a/bindings/kotlin/browser-test/maplibre-native-kotlin.mjs b/bindings/kotlin/browser-test/maplibre-native-kotlin.mjs new file mode 100644 index 000000000..7c302d439 --- /dev/null +++ b/bindings/kotlin/browser-test/maplibre-native-kotlin.mjs @@ -0,0 +1,33 @@ +// The Kotlin distribution the module boots, for the browser test suite. +// +// The module imports `./maplibre-native-kotlin.mjs` on the pthread +// -sPROXY_TO_PTHREAD gives main() and calls `mlnKotlinMain()`. An application +// serves its own distribution under that name and exports the entry point from +// Kotlin. A test binary has no entry point to export: the compiler emits the +// suite as `startUnitTests`, which only JavaScript can call. So this stands in +// for the application, and everything below it is the same Kotlin the module +// would boot in production. +import * as suite from "./maplibre-native-kotlin-suite.mjs"; + +export function mlnKotlinMain() { + let status; + try { + suite.mlnKotlinTestBegin(); + suite.startUnitTests(); + status = suite.mlnKotlinTestFailures() === 0 ? 0 : 1; + } catch (error) { + console.error("maplibre: the browser suite did not finish", error); + status = 70; + } + + // The status leaves this thread through the process exit, because nothing + // else crosses back to the host: the suite runs in a realm of its own, and + // main() is holding a runtime keepalive that would otherwise keep the module + // alive with nothing left to run. mln_kotlin_exit() clears that keepalive on + // the host's thread and exits there, which is where Module.onExit is raised. + try { + globalThis.Module._mln_kotlin_exit(status); + } catch { + // Emscripten unwinds the calling thread by throwing out of exit(). + } +} diff --git a/bindings/kotlin/build.gradle.kts b/bindings/kotlin/build.gradle.kts index 1e35764b2..7d3f898a0 100644 --- a/bindings/kotlin/build.gradle.kts +++ b/bindings/kotlin/build.gradle.kts @@ -1,4 +1,8 @@ +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.tasks.bundling.Zip import org.gradle.api.tasks.testing.Test +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile @@ -28,6 +32,37 @@ val androidTargets = providers.gradleProperty("maplibre.android.abis").getOrElse(AndroidTarget.DEFAULT_ABIS) ) val checkedInJextractSources = layout.projectDirectory.dir("src/jvmMain/generated") +// Struct offsets the browser binding writes descriptors at, and the externals it calls the module +// through. Checked in like the jextract output, and generated from the headers with the pinned +// Emscripten clang rather than hand-maintained. +val checkedInWasmLayoutSources = layout.projectDirectory.dir("src/wasmJsMain/generated") +// The prelinked Emscripten module the browser binding drives, which the browser build leaves beside +// its wasm. Named by a property the way the host native install is, because the browser target has +// no install step to read it from. +val browserModuleSourceDir = + providers + .gradleProperty("maplibre.browser.moduleDir") + .map(rootProject::file) + .orElse(rootProject.layout.buildDirectory.dir("browser-module-unconfigured").map { it.asFile }) +val browserModuleConfigured = providers.gradleProperty("maplibre.browser.moduleDir").isPresent +// The module and its wasm, which the module resolves against its own URL. +val browserModuleFiles = listOf("maplibre_native_c.mjs", "maplibre_native_c.wasm") +// Where those two are collected. A directory of their own, so both the test harness and the +// published archive carry the module and nothing else out of the browser build tree. +val packagedBrowserModule = layout.buildDirectory.dir("wasmJsBrowserModule") +// The third-party notices, which the install prefix keeps two levels above lib/browser. +// +// The module is statically linked: MapLibre Native and its vendored dependencies are inside the +// wasm, so an archive carrying only this repository's LICENSE redistributes them without their +// notices. The JVM runtime jars already ship these from the same prefix, and the browser archive is +// the same redistribution by a different route. +val browserNoticeDir = browserModuleSourceDir.map { + it.parentFile.parentFile.resolve("share/maplibre-native-c/licenses") +} +// Where the browser suite runs from. The module boots the Kotlin distribution served beside it, and +// the Kotlin test binary resolves its wasm and its import object against its own URL, so one flat +// directory holds everything the run fetches. +val packagedBrowserSuite = layout.buildDirectory.dir("wasmJsBrowserSuite") val packagedAndroidBindingLibs = layout.buildDirectory.dir("generated/jniLibs/androidMain") val generatedJavaCppSources = layout.buildDirectory.dir("generated/sources/javacpp/androidMain/java") @@ -41,6 +76,25 @@ kotlin { linuxX64() macosArm64() + // The browser binding calls a prelinked Emscripten module through JavaScript rather than a + // shared library. The interop it does that through is experimental in Kotlin 2.4, so the opt-ins + // are target-wide rather than repeated on every declaration that reaches native. + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + // Karma loads a test binary as a page script, which would run the suite on the page's own + // thread. That thread is the module's main runtime thread, and it serves the proxied calls and + // timers every other thread depends on, so the binding blocks there at the cost of a deadlock. + // wasmJsBrowserSuite runs the same test binary the way the module runs an application. + browser { testTask { enabled = false } } + compilerOptions { + optIn.addAll( + "kotlin.js.ExperimentalWasmJsInterop", + "kotlin.wasm.ExperimentalWasmInterop", + "kotlin.wasm.unsafe.UnsafeWasmMemoryApi", + ) + } + } + jvmToolchain(libs.versions.java.toolchain.get().toInt()) compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } @@ -92,6 +146,8 @@ kotlin { sourceSets { androidMain { dependencies { implementation(libs.javacpp) } } + wasmJsMain { kotlin.srcDir(checkedInWasmLayoutSources) } + commonTest.dependencies { implementation(kotlin("test")) } } } @@ -118,6 +174,7 @@ canonicalizeKmpRootMetadata( "jvm" to "$mavenArtifact-jvm", "linuxX64" to "$mavenArtifact-linuxx64", "macosArm64" to "$mavenArtifact-macosarm64", + "wasmJs" to "$mavenArtifact-wasm-js", ), ) @@ -184,6 +241,123 @@ tasks.named("jvmTest") { } } +val packageBrowserModule = + tasks.register("packageBrowserModule") { + group = "build" + description = "Collects the prelinked Emscripten module that a browser host loads." + from(browserModuleSourceDir) { include(browserModuleFiles) } + into(packagedBrowserModule) + } + +// The same files as one archive. Every other platform ships a library that a host loads through its +// own foreign-function interface, and the runtime publications carry that library. A browser host +// has no link step and no loader path, so what it consumes is the linked module itself, served +// beside its page. That makes the module a classified artifact on the wasmJs publication rather +// than a runtime of its own. The archive is flat because the module resolves its wasm against its +// own URL, so unpacking it into the directory that serves the page is the whole installation. +val browserModuleArchive = + tasks.register("browserModuleArchive") { + group = "build" + description = + "Archives the prelinked Emscripten module that a browser host serves beside its page." + dependsOn(packageBrowserModule) + archiveBaseName.set("$mavenArtifact-wasm-js") + archiveVersion.set(mavenVersion) + archiveClassifier.set("browser-module") + destinationDirectory.set(layout.buildDirectory.dir("libs")) + from(packagedBrowserModule) + from(rootProject.file("LICENSE")) + from(browserNoticeDir) { into("licenses") } + // A missing file would otherwise be skipped silently and publish an archive that no host can + // load. Plain values rather than providers, because this runs under the configuration cache. + val collectedModule = packagedBrowserModule.get().asFile + val expectedFiles = browserModuleFiles + val notices = browserNoticeDir.get() + doFirst { + val missing = expectedFiles.filterNot { collectedModule.resolve(it).isFile } + check(missing.isEmpty()) { + "The browser module archive is missing $missing. Build the module with " + + "`mise run build emscripten-wasm32-webgl` and name it with " + + "-Pmaplibre.browser.moduleDir=build/emscripten-wasm32-webgl/install/lib/browser." + } + // Refused rather than shipped without them: publishing a statically linked module with no + // third-party notices is a licensing defect, and it is invisible in the resulting archive. + check(notices.isDirectory && notices.listFiles().orEmpty().isNotEmpty()) { + "The browser module archive found no third-party notices at $notices. They are installed " + + "beside the module, so name an install prefix's lib/browser with " + + "-Pmaplibre.browser.moduleDir=build/emscripten-wasm32-webgl/install/lib/browser." + } + } + } + +// The binding fetches the module at run time rather than linking it, so a consumer that resolves +// the wasmJs variant alone has nothing to fetch. Attaching the archive here is what puts the module +// under the same coordinates and the same version as the binding it was linked against. +plugins.withId("maven-publish") { + extensions.configure { + publications + .withType() + .matching { it.name == "wasmJs" } + .configureEach { artifact(browserModuleArchive) } + } +} + +// The module, the Kotlin test binary, and the entry point that boots it, collected into the one +// directory the runner serves. +// +// The test binary is renamed to the name its entry point imports, because the Kotlin compiler names +// the file after the compilation and the entry point is checked in. The rename reaches only that +// one file, and the files it imports beside itself keep the names it imports them under. +val packageBrowserSuite = + tasks.register("packageBrowserSuite") { + group = "verification" + description = "Collects the browser suite: the module, the test binary, and its entry point." + from(packageBrowserModule) + from(tasks.named("wasmJsTestTestDevelopmentExecutableCompileSync")) { + rename { + val isBinary = + it.endsWith(".mjs") && + !it.endsWith(".import-object.mjs") && + !it.endsWith(".js-builtins.mjs") + if (isBinary) "maplibre-native-kotlin-suite.mjs" else it + } + } + from(layout.projectDirectory.dir("browser-test")) + into(packagedBrowserSuite) + } + +// The suite. The runner serves the staged directory with the cross-origin isolation the module's +// pthreads need, opens it in a headless Chromium, and turns the page's report into an exit status. +// It is the same runner the C API and Rust browser suites use. +tasks.register("wasmJsBrowserSuite") { + group = "verification" + description = "Runs the Kotlin browser binding tests in headless Chromium." + dependsOn(packageBrowserSuite) + inputs.dir(packagedBrowserSuite).withPropertyName("browserSuite") + workingDir = rootProject.layout.projectDirectory.asFile + commandLine( + "node", + rootProject.layout.projectDirectory.file("scripts/run-browser-test.mjs").asFile.path, + packagedBrowserSuite.get().file("maplibre_native_c.mjs").asFile.path, + "--timeout-seconds", + "600", + // Software WebGL2, for a runner with no GPU. + "--render-backend", + "opengl", + // A canvas the page displays, which the presentation coverage renders to. Only a document has + // one to hand the module, so the page instantiates the module itself. + "--page-canvas", + ) + val moduleConfigured = browserModuleConfigured + doFirst { + check(moduleConfigured) { + "The wasmJs browser suite drives the prelinked Emscripten module. Build it with " + + "`mise run build emscripten-wasm32-webgl` and name it with " + + "-Pmaplibre.browser.moduleDir=build/emscripten-wasm32-webgl/install/lib/browser." + } + } +} + // AGP's KMP library plugin registers no lint variant, so `NewApi` never runs for // androidMain. This task stands in for it. val checkAndroidApiFloor = diff --git a/bindings/kotlin/emscripten/mln_kotlin.h b/bindings/kotlin/emscripten/mln_kotlin.h new file mode 100644 index 000000000..9afae5cb7 --- /dev/null +++ b/bindings/kotlin/emscripten/mln_kotlin.h @@ -0,0 +1,210 @@ +/** + * @file mln_kotlin.h + * The module entry points the Kotlin/Wasm binding calls that the public C API + * does not provide. + * + * Two things a separate WebAssembly module cannot do for itself, and nothing + * else. It cannot receive a callback raised on a MapLibre worker thread, + * because a JavaScript function belongs to the agent that defined it and each + * worker is a different agent; the ring below carries those records to the + * thread Kotlin runs on. And it cannot create a WebGL context, because + * mln_webgl_context_descriptor.context indexes this module's own table and + * EmscriptenWebGLContextAttributes is a sysroot struct the offset generator + * never sees. + * + * Everything else the binding needs is an ordinary C API entry point, declared + * in generated Kotlin and checked against this module by + * scripts/check-browser-exports.py. + * + * This header targets C23. + */ + +#ifndef MLN_KOTLIN_H +#define MLN_KOTLIN_H + +#include +#include +#include + +#include "maplibre_native_c/callback_adapter.h" +#include "maplibre_native_c/runtime.h" +#include "maplibre_native_c/style.h" + +/** What a ring record carries, which selects how its payload is released. */ +enum mln_kotlin_record_kind { + MLN_KOTLIN_RECORD_LOG = 1, + MLN_KOTLIN_RECORD_LOG_RETIRED = 2, + MLN_KOTLIN_RECORD_RESOURCE_REQUEST = 3, + MLN_KOTLIN_RECORD_RESOURCE_PROVIDER_RETIRED = 4, + MLN_KOTLIN_RECORD_TILE_FETCH = 5, + MLN_KOTLIN_RECORD_TILE_CANCEL = 6, +}; + +/** + * One record on its way from a MapLibre thread to the thread Kotlin runs on. + * + * A retirement arrives behind every record it retires, so a drain that stops + * delivering at the marker is what makes "cleared" mean no later invocation. + * The two tile kinds mark retirement with a tile_z of 255. + */ +typedef struct mln_kotlin_record { + uint32_t kind; + uint32_t tile_z; + uint32_t tile_x; + uint32_t tile_y; + /** + * The adapter record for MLN_KOTLIN_RECORD_LOG and + * MLN_KOTLIN_RECORD_RESOURCE_REQUEST, which Kotlin releases with + * mln_adapter_log_record_destroy() or by completing the request, releasing + * it, and calling mln_adapter_resource_provider_request_destroy(). The custom + * geometry callbacks' user_data for the two tile kinds, null for a + * retirement. + */ + void* payload; +} mln_kotlin_record; + +/** + * Ends the program with status, for a host that has one -- a test runner. + * + * Drops the keepalive that leaves the binding's thread running and forces the + * exit, because a backend keepalive can outlive it. A host that never calls + * this keeps the thread parked, which is what a map wants. + */ +void mln_kotlin_exit(int status); + +/** Takes the oldest record, or returns false when the ring is empty. */ +bool mln_kotlin_take_record(mln_kotlin_record* out); + +/** How many records the ring has dropped, cumulative. */ +uint64_t mln_kotlin_dropped_records(void); + +/** + * Names the wake source a producing thread signals after pushing. + * + * Signalled under the ring lock, so no thread can signal a source Kotlin has + * already destroyed. + */ +void mln_kotlin_set_wake(mln_wake_source source); + +/** + * Installs the log listener, or updates whether it consumes. + * + * The adapter identifies a registration by its state's address and takes no + * user data, so one state serves the module's lifetime and re-installing it + * updates consume without retiring anything. + */ +mln_status mln_kotlin_log_install(uint32_t consume); + +/** Clears the log listener. */ +mln_status mln_kotlin_log_clear(void); + +/** + * The adapter callbacks, by function table index. + * + * Kotlin cannot take the address of a wasm function; only C can, and a function + * has a table index only once something does. + */ +mln_resource_transform_callback mln_kotlin_rewrite_transform_callback(void); +mln_resource_provider_callback mln_kotlin_queued_provider_callback(void); +mln_adapter_queued_resource_request_listener +mln_kotlin_resource_request_listener(); +mln_custom_geometry_source_tile_callback mln_kotlin_tile_fetch_callback(void); +mln_custom_geometry_source_tile_callback mln_kotlin_tile_cancel_callback(void); + +/** + * Registers a private OffscreenCanvas under name. + * + * The canvas a host that reads frames back wants: never displayed, and a WebGL2 + * context cannot exist without one. A canvas the page displays arrives instead + * through -sOFFSCREENCANVASES_TO_PTHREAD, already registered when Kotlin + * starts. The caller owns the registration. + * + * Returns false for a name of 64 bytes or longer, which + * mln_kotlin_webgl_context_create() could not build a selector for, and for an + * extent outside 1 to 16384 pixels. + */ +bool mln_kotlin_webgl_canvas_create( + const char* name, uint32_t width, uint32_t height +); + +/** + * Removes a canvas registration this thread created. + * + * Call it after the context created against the canvas is destroyed. A canvas + * the page transferred stays registered for the thread's lifetime, because the + * page still displays that element and the transfer cannot be repeated. + */ +void mln_kotlin_webgl_canvas_destroy(const char* name); + +/** + * Sizes a registered canvas's drawing buffer, or reports no usable canvas. + * + * A surface session renders into its canvas's default framebuffer, which is + * only as large as the canvas, so changing such a session's extent takes this + * call and then mln_render_session_resize() or mln_opengl_surface_set_target(). + * Neither implies the other. Only the drawing buffer is reallocated, so every + * texture, buffer, and program the session built stays as it was. + */ +bool mln_kotlin_webgl_canvas_resize( + const char* name, uint32_t width, uint32_t height +); + +/** + * Creates a WebGL2 context against a registered canvas, or returns 0. + * + * The extent sizes the canvas's drawing buffer before the context is made, so a + * caller that registered the canvas at one size and renders at another passes + * the size it renders at. + */ +int32_t mln_kotlin_webgl_context_create( + const char* name, uint32_t width, uint32_t height +); + +/** + * Destroys a context on the thread that created it. + * + * Call it once every render target using the context is detached or destroyed, + * because the C API borrows the handle for a target's lifetime. This releases + * every object made in the context, textures included. The canvas registration + * outlives it and is released separately. + */ +void mln_kotlin_webgl_context_destroy(int32_t context); + +/** Creates an RGBA8 texture in context, or returns 0. */ +uint32_t mln_kotlin_webgl_texture_create( + int32_t context, uint32_t width, uint32_t height +); + +/** Destroys a texture created in context. */ +void mln_kotlin_webgl_texture_destroy(int32_t context, uint32_t texture); + +/** + * Reads a rendered frame out of a context this thread owns. + * + * texture names a two-dimensional texture of context, or is zero for the + * default framebuffer a surface session renders into. out_pixels receives + * width * height * 4 bytes of RGBA8, bottom row first, which is GL's order + * rather than the top-down order mln_texture_read_premultiplied_rgba8() uses. + * + * Stalls the calling thread until the frame is done. On failure out_pixels is + * unspecified rather than unwritten: a read that fails partway has already + * written. + */ +bool mln_kotlin_webgl_read_pixels( + int32_t context, uint32_t texture, uint32_t width, uint32_t height, + uint8_t* out_pixels, size_t out_capacity +); + +/** + * Blits a rendered texture onto the default framebuffer of its context. + * + * How a texture session's frame reaches a transferred page canvas without the + * pixels leaving the GPU. A surface session needs none of this: it already + * renders into that framebuffer. The browser composites the canvas when the + * task that drew into it ends, and nothing here can force that sooner. + */ +bool mln_kotlin_webgl_present_texture( + int32_t context, uint32_t texture, uint32_t width, uint32_t height +); + +#endif diff --git a/bindings/kotlin/emscripten/mln_kotlin_callbacks.c b/bindings/kotlin/emscripten/mln_kotlin_callbacks.c new file mode 100644 index 000000000..78ddbcc4e --- /dev/null +++ b/bindings/kotlin/emscripten/mln_kotlin_callbacks.c @@ -0,0 +1,238 @@ +// The one path from a MapLibre thread into Kotlin. +// +// A Kotlin/Wasm module lives in one JavaScript realm, and a function installed +// with Emscripten's addFunction belongs to the agent that installed it +// (emscripten-core#21273), so MapLibre's worker, network, and logging threads +// reach nothing when they call one. Every native callback Kotlin wants +// therefore lands in this file, on whichever thread produced it, and is queued +// for the thread Kotlin runs on. Kotlin drains the ring inside its pump loop. +// +// callback_adapter.h does the hard half: it copies each borrowed payload into a +// native-owned record and answers MapLibre on the host's behalf. This file adds +// the queue and the wake. + +#include +#include +#include +#include +#include + +#include "maplibre_native_c/callback_adapter.h" +#include "mln_kotlin.h" + +// Bounded, because an unbounded queue turns a logging burst into the page's +// memory ceiling. A full ring drops its oldest record and counts the drop, so +// Kotlin reports lost records rather than believing it saw them all. +#define MLN_KOTLIN_RING_CAPACITY 1024 + +// Kotlin reads these offsets from a hand-written layout. +_Static_assert(sizeof(mln_kotlin_record) == 20, "record layout changed"); +_Static_assert(offsetof(mln_kotlin_record, payload) == 16, "payload moved"); + +static pthread_mutex_t ring_mutex = PTHREAD_MUTEX_INITIALIZER; +static mln_kotlin_record ring[MLN_KOTLIN_RING_CAPACITY]; +static uint32_t ring_head; +static uint32_t ring_size; +static uint64_t ring_dropped; +static mln_wake_source ring_wake = MLN_HANDLE_NULL; + +// Releases a record the ring evicted. A queued request that is merely destroyed +// leaves the native loader waiting for a completion that never arrives, so a +// dropped one is failed the way the adapter fails a request it cannot copy. +static void mln_kotlin_discard(const mln_kotlin_record* record) { + if (record->kind == MLN_KOTLIN_RECORD_LOG) { + mln_adapter_log_record_destroy(record->payload); + return; + } + if (record->kind != MLN_KOTLIN_RECORD_RESOURCE_REQUEST) { + return; + } + mln_adapter_queued_resource_request* request = record->payload; + const mln_resource_response response = { + .size = sizeof(mln_resource_response), + .status = MLN_RESOURCE_RESPONSE_STATUS_ERROR, + .error_reason = MLN_RESOURCE_ERROR_REASON_OTHER, + .error_message = "maplibre kotlin callback queue overflowed", + }; + (void)mln_resource_request_complete(request->handle, &response); + mln_resource_request_release(request->handle); + mln_adapter_resource_provider_request_destroy(request); +} + +// Runs on whichever MapLibre thread produced the callback, so it queues and +// returns. The wake source is read and signalled under the ring lock, which is +// the lock mln_kotlin_set_wake() takes, so no thread signals a source Kotlin +// has already cleared. +static void mln_kotlin_push(mln_kotlin_record record) { + mln_kotlin_record evicted = {0}; + bool dropped = false; + pthread_mutex_lock(&ring_mutex); + if (ring_size == MLN_KOTLIN_RING_CAPACITY) { + evicted = ring[ring_head]; + ring_head = (ring_head + 1) % MLN_KOTLIN_RING_CAPACITY; + ring_size -= 1; + ring_dropped += 1; + dropped = true; + } + ring[(ring_head + ring_size) % MLN_KOTLIN_RING_CAPACITY] = record; + ring_size += 1; + if (ring_wake != MLN_HANDLE_NULL) { + (void)mln_wake_source_signal(ring_wake); + } + pthread_mutex_unlock(&ring_mutex); + // Outside the lock, because completing a request is not work that a thread + // trying to log should wait behind. + if (dropped) { + mln_kotlin_discard(&evicted); + } +} + +static void mln_kotlin_on_log_record(void* record) { + mln_kotlin_push((mln_kotlin_record){ + .kind = + record == NULL ? MLN_KOTLIN_RECORD_LOG_RETIRED : MLN_KOTLIN_RECORD_LOG, + .payload = record, + }); +} + +static void mln_kotlin_on_resource_request(void* request) { + mln_kotlin_push((mln_kotlin_record){ + .kind = request == NULL ? MLN_KOTLIN_RECORD_RESOURCE_PROVIDER_RETIRED + : MLN_KOTLIN_RECORD_RESOURCE_REQUEST, + .payload = request, + }); +} + +static void mln_kotlin_on_tile_fetch( + void* user_data, mln_canonical_tile_id tile_id +) { + mln_kotlin_push((mln_kotlin_record){ + .kind = MLN_KOTLIN_RECORD_TILE_FETCH, + .tile_z = tile_id.z, + .tile_x = tile_id.x, + .tile_y = tile_id.y, + .payload = user_data, + }); +} + +static void mln_kotlin_on_tile_cancel( + void* user_data, mln_canonical_tile_id tile_id +) { + mln_kotlin_push((mln_kotlin_record){ + .kind = MLN_KOTLIN_RECORD_TILE_CANCEL, + .tile_z = tile_id.z, + .tile_x = tile_id.x, + .tile_y = tile_id.y, + .payload = user_data, + }); +} + +/** + * Takes the oldest queued record, or reports that the ring is empty. + * + * Call this from the thread Kotlin runs on, and release each record's payload + * with the adapter function that its kind names. + */ +EMSCRIPTEN_KEEPALIVE bool mln_kotlin_take_record(mln_kotlin_record* out) { + if (out == NULL) { + return false; + } + bool taken = false; + pthread_mutex_lock(&ring_mutex); + if (ring_size > 0) { + *out = ring[ring_head]; + ring_head = (ring_head + 1) % MLN_KOTLIN_RING_CAPACITY; + ring_size -= 1; + taken = true; + } + pthread_mutex_unlock(&ring_mutex); + return taken; +} + +/** Counts every record the ring has dropped since the module started. */ +EMSCRIPTEN_KEEPALIVE uint64_t mln_kotlin_dropped_records(void) { + pthread_mutex_lock(&ring_mutex); + const uint64_t dropped = ring_dropped; + pthread_mutex_unlock(&ring_mutex); + return dropped; +} + +/** + * Sets the wake source that a queued record signals, or clears it with + * MLN_HANDLE_NULL. + * + * A signal releases a thread parked in mln_runtime_pump(), so a host that pumps + * with an infinite timeout returns as soon as a record lands. Clear the source + * before destroying it: a MapLibre thread signals under the lock this call + * takes, so a cleared source is one no such thread still holds. + */ +EMSCRIPTEN_KEEPALIVE void mln_kotlin_set_wake(mln_wake_source source) { + pthread_mutex_lock(&ring_mutex); + ring_wake = source; + pthread_mutex_unlock(&ring_mutex); +} + +// One state for the module's lifetime. mln_adapter_log_record_listener takes no +// user data and the adapter treats this address as the registration's identity, +// so a second state would leave the compiled-in listener above unable to say +// which registration produced a record it was handed. +static mln_adapter_log_callback_state log_state = { + .listener = mln_kotlin_on_log_record, + .consume = 0, +}; + +/** + * Installs the queueing log callback, reporting consume for every record. + * + * Reinstalling keeps the same registration identity, so a record queued across + * the call still belongs to this registration and nothing is retired. + * + * Returns the status of mln_adapter_log_set_callback(). + */ +EMSCRIPTEN_KEEPALIVE mln_status mln_kotlin_log_install(uint32_t consume) { + log_state.consume = consume; + return mln_adapter_log_set_callback(&log_state); +} + +/** + * Clears the log callback, after which one retirement record is queued. + * + * Returns the status of mln_adapter_log_set_callback(). + */ +EMSCRIPTEN_KEEPALIVE mln_status mln_kotlin_log_clear(void) { + return mln_adapter_log_set_callback(NULL); +} + +// A WebAssembly function has a table index only once something takes its +// address, so the getters below are how Kotlin obtains one for a callback it +// stores in a native struct. + +/** The mln_resource_transform.callback that applies a rewrite rule table. */ +EMSCRIPTEN_KEEPALIVE mln_resource_transform_callback +mln_kotlin_rewrite_transform_callback(void) { + return mln_adapter_resource_transform_rewrite_callback; +} + +/** The mln_resource_provider.callback a queued provider registers. */ +EMSCRIPTEN_KEEPALIVE mln_resource_provider_callback +mln_kotlin_queued_provider_callback(void) { + return mln_adapter_queued_resource_provider_callback; +} + +/** The mln_adapter_queued_resource_provider.listener that feeds the ring. */ +EMSCRIPTEN_KEEPALIVE mln_adapter_queued_resource_request_listener +mln_kotlin_resource_request_listener(void) { + return mln_kotlin_on_resource_request; +} + +/** The mln_custom_geometry_source_options.fetch_tile that feeds the ring. */ +EMSCRIPTEN_KEEPALIVE mln_custom_geometry_source_tile_callback +mln_kotlin_tile_fetch_callback(void) { + return mln_kotlin_on_tile_fetch; +} + +/** The mln_custom_geometry_source_options.cancel_tile that feeds the ring. */ +EMSCRIPTEN_KEEPALIVE mln_custom_geometry_source_tile_callback +mln_kotlin_tile_cancel_callback(void) { + return mln_kotlin_on_tile_cancel; +} diff --git a/bindings/kotlin/emscripten/mln_kotlin_host.js b/bindings/kotlin/emscripten/mln_kotlin_host.js new file mode 100644 index 000000000..9fb93ed45 --- /dev/null +++ b/bindings/kotlin/emscripten/mln_kotlin_host.js @@ -0,0 +1,70 @@ +// The Kotlin module's boot and the canvas registry it renders through. +// +// The registry is GL.offscreenCanvases rather than specialHTMLTargets, because +// findCanvasEventTarget(), which resolves the selector under +// -sOFFSCREENCANVAS_SUPPORT, searches the former and never consults the latter. +// It is also where -sOFFSCREENCANVASES_TO_PTHREAD puts a canvas the page +// transferred, so a private canvas and a displayed one are found the same way. +// +// An entry carries its canvas under both names its consumers unwrap: +// emscripten's WebGL path looks for `offscreenCanvas`, and its own transfer +// path stores `canvas`. +addToLibrary({ + // Imports the Kotlin/Wasm module into the realm of the pthread that + // -sPROXY_TO_PTHREAD gave main(), which is the thread Kotlin may block on. + // The specifier resolves against this worker's module URL, so the Kotlin + // distribution is served beside maplibre_native_c.mjs. + // + // Module is assigned to globalThis because a Kotlin @JsFun body compiles to + // an arrow function in the generated import object, which can see nothing + // else. + mln_kotlin_boot_module: () => { + globalThis.Module = Module; + import("./maplibre-native-kotlin.mjs") + .then((module) => module.mlnKotlinMain()) + .catch((error) => { + console.error("maplibre: the Kotlin module failed to start", error); + }); + }, + + mln_kotlin_canvas_register__deps: ["$GL", "$UTF8ToString"], + mln_kotlin_canvas_register: (name, width, height) => { + const id = UTF8ToString(name); + const canvas = new OffscreenCanvas(width, height); + GL.offscreenCanvases[id] = { canvas, offscreenCanvas: canvas, id }; + }, + + mln_kotlin_canvas_unregister__deps: ["$GL", "$UTF8ToString"], + mln_kotlin_canvas_unregister: (name) => { + delete GL.offscreenCanvases[UTF8ToString(name)]; + }, + + // Written here rather than through emscripten_set_canvas_element_size(), + // which resolves the same registry but then assigns to the entry rather than + // to the canvas inside it. + mln_kotlin_canvas_size__deps: ["$GL", "$UTF8ToString"], + mln_kotlin_canvas_size: (name, width, height) => { + const registry = GL.offscreenCanvases; + const id = UTF8ToString(name); + // Own properties only. The registry is a plain object, so an id of + // `toString` or `constructor` would otherwise report a canvas that no + // registration put there. An entry is also null while its canvas moves to + // another thread, which is present but not usable. + const entry = Object.hasOwn(registry, id) ? registry[id] : undefined; + const canvas = entry && (entry.offscreenCanvas || entry.canvas); + if (!canvas) { + return 0; + } + canvas.width = width; + canvas.height = height; + // A transferred canvas carries its size in shared memory so that the page + // and this thread agree on it. Left stale, + // emscripten_get_canvas_element_size() would keep reporting whatever the + // element measured before the transfer. + if (entry.canvasSharedPtr) { + HEAP32[entry.canvasSharedPtr >> 2] = width; + HEAP32[(entry.canvasSharedPtr + 4) >> 2] = height; + } + return 1; + }, +}); diff --git a/bindings/kotlin/emscripten/mln_kotlin_main.c b/bindings/kotlin/emscripten/mln_kotlin_main.c new file mode 100644 index 000000000..c0aea6975 --- /dev/null +++ b/bindings/kotlin/emscripten/mln_kotlin_main.c @@ -0,0 +1,30 @@ +// The entry point that puts Kotlin on a thread it may block. +// +// -sPROXY_TO_PTHREAD runs main() on a pthread rather than on the agent that +// instantiated the module, so this thread parks in mln_runtime_pump() while the +// host agent keeps its event loop. The keepalive is what stops the thread from +// exiting once main() returns, as crt1_proxy_main.c does for a proxied main. +// mln_kotlin_boot_module() imports the Kotlin/Wasm module into this thread's +// realm, which is what makes every C call Kotlin issues a same-thread call. + +#include +#include + +#include "mln_kotlin.h" + +void mln_kotlin_boot_module(void); + +int main(void) { + emscripten_runtime_keepalive_push(); + mln_kotlin_boot_module(); + return 0; +} + +EMSCRIPTEN_KEEPALIVE void mln_kotlin_exit(int status) { + // The keepalive that leaves this thread running is what a host has to drop to + // end the program, and a backend keepalive can outlive it, so the exit is + // forced rather than waited for. Runners read the status; a host that never + // calls this keeps the thread parked, which is what a map wants. + emscripten_runtime_keepalive_pop(); + emscripten_force_exit(status); +} diff --git a/bindings/kotlin/emscripten/mln_kotlin_pre.js b/bindings/kotlin/emscripten/mln_kotlin_pre.js new file mode 100644 index 000000000..2eb68ce07 --- /dev/null +++ b/bindings/kotlin/emscripten/mln_kotlin_pre.js @@ -0,0 +1,36 @@ +// The page canvas, registered before the thread that will own it is created. +// +// Emscripten transfers a canvas to a pthread only as that thread is created, +// and the thread this binding runs on is created during instantiation, so a +// canvas the page displays has to be here before the factory resolves. The +// registry is consulted first (libpthread.js:723), ahead of the DOM lookup that +// fails pthread_create outright when a named selector matches nothing -- which +// is why an entry is registered either way. A host with no on-screen map +// transfers nothing and gets the placeholder, and its texture sessions never +// touch it. +// The name carries no "#", and both halves of the round trip need it that way: +// pthread_create looks this registry up by the transfer list entry verbatim +// (libpthread.js:723), while findCanvasEventTarget strips a leading "#" before +// looking up the same registry (libhtml5.js:357). A hash satisfies the first +// and breaks the second, which transfers the canvas and then cannot find it. +// The entry carries a shared size block because thread creation writes the +// owning thread into it for every canvas it transfers, without checking that +// one is there (libpthread.js:799-801). An entry without it takes +// `undefined + 8 >> 2`, which is address zero, so the write lands on the heap's +// first bytes instead. Emscripten allocates the same three fields when it +// builds an entry itself (libpthread.js:745-749). +Module["preRun"] ??= []; +Module["preRun"].push(() => { + const canvas = Module["mlnPageCanvas"] ?? new OffscreenCanvas(1, 1); + canvas.id = "maplibre"; + const shared = Module["_malloc"](12); + HEAP32[shared >> 2] = canvas.width; + HEAP32[(shared + 4) >> 2] = canvas.height; + HEAPU32[(shared + 8) >> 2] = 0; + GL.offscreenCanvases["maplibre"] = { + canvas, + offscreenCanvas: canvas, + canvasSharedPtr: shared, + id: "maplibre", + }; +}); diff --git a/bindings/kotlin/emscripten/mln_kotlin_webgl.c b/bindings/kotlin/emscripten/mln_kotlin_webgl.c new file mode 100644 index 000000000..b88b0d61c --- /dev/null +++ b/bindings/kotlin/emscripten/mln_kotlin_webgl.c @@ -0,0 +1,317 @@ +// The WebGL work Kotlin does on the thread its maps render on. +// +// Every other platform hands a session a context the host made with its own +// platform API. A browser host cannot: mln_webgl_context_descriptor.context is +// an index into this Emscripten module's context table, so a context the page +// created with canvas.getContext("webgl2") names nothing this module can look +// up. The context is made here instead, on the thread that renders, because a +// WebGL context belongs to the thread that created it. +// +// This is C rather than Kotlin externs for one reason: +// EmscriptenWebGLContextAttributes is a sixteen-field struct in the emsdk +// sysroot, which the offset generator never sees. Hardcoded offsets would +// survive an emsdk bump as a context that quietly falls back to WebGL 1. +// +// Every entry point restores the GL state it changed. MapLibre's GL backend +// remembers what it last set and skips a redundant call, so state left changed +// behind its back is state the next frame renders against without knowing. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mln_kotlin.h" + +// The canvas registry, from mln_kotlin_host.js. +void mln_kotlin_canvas_register(const char* name, int width, int height); +void mln_kotlin_canvas_unregister(const char* name); +int mln_kotlin_canvas_size(const char* name, int width, int height); + +// Long enough for an element id a host would write, and bounded because the +// selector below is built on the stack. A longer id is refused rather than +// truncated, because a truncated id names a different canvas or none. +#define MLN_KOTLIN_CANVAS_ID_BYTES 64 + +// A rectangle in device pixels that both a texture and a readback have to fit. +// GLsizei is signed and an RGBA8 image of this size has to fit a size_t, so the +// bound keeps either from wrapping into a smaller number than it describes. +static bool mln_kotlin_extent_fits(uint32_t width, uint32_t height) { + const uint32_t limit = 16384; + return width > 0 && height > 0 && width <= limit && height <= limit; +} + +// Discards errors left by earlier work so that a check afterwards reports this +// file's own. Bounded because a lost context reports the loss for as long as it +// stays lost, and an unbounded drain would never end. +static void mln_kotlin_clear_gl_errors(void) { + for (int guard = 0; guard < 16; guard += 1) { + if (glGetError() == GL_NO_ERROR) { + return; + } + } +} + +// Makes a context current, or reports that the handle names no context this +// thread can use. Every entry point below starts here, because a render session +// restores whatever was current before its frame. +static bool mln_kotlin_bind(int32_t context) { + if (context <= 0) { + return false; + } + return emscripten_webgl_make_context_current( + (EMSCRIPTEN_WEBGL_CONTEXT_HANDLE)context + ) == EMSCRIPTEN_RESULT_SUCCESS; +} + +EMSCRIPTEN_KEEPALIVE bool mln_kotlin_webgl_canvas_create( + const char* name, uint32_t width, uint32_t height +) { + if ( + name == NULL || name[0] == '\0' || !mln_kotlin_extent_fits(width, height) || + strlen(name) >= MLN_KOTLIN_CANVAS_ID_BYTES + ) { + return false; + } + mln_kotlin_canvas_register(name, (int)width, (int)height); + return true; +} + +EMSCRIPTEN_KEEPALIVE void mln_kotlin_webgl_canvas_destroy(const char* name) { + if (name != NULL) { + mln_kotlin_canvas_unregister(name); + } +} + +EMSCRIPTEN_KEEPALIVE bool mln_kotlin_webgl_canvas_resize( + const char* name, uint32_t width, uint32_t height +) { + if (name == NULL || !mln_kotlin_extent_fits(width, height)) { + return false; + } + return mln_kotlin_canvas_size(name, (int)width, (int)height) != 0; +} + +EMSCRIPTEN_KEEPALIVE int32_t mln_kotlin_webgl_context_create( + const char* name, uint32_t width, uint32_t height +) { + if ( + name == NULL || name[0] == '\0' || !mln_kotlin_extent_fits(width, height) || + strlen(name) >= MLN_KOTLIN_CANVAS_ID_BYTES + ) { + return 0; + } + // Sized here so that width and height mean the same thing for a transferred + // canvas as for a private one. A transferred canvas arrives at whatever its + // element measured, which is a CSS layout size rather than device pixels. + if (mln_kotlin_canvas_size(name, (int)width, (int)height) == 0) { + return 0; + } + + EmscriptenWebGLContextAttributes attributes; + emscripten_webgl_init_context_attributes(&attributes); + // WebGL2 is the GLES 3.0 that MapLibre's OpenGL backend targets. + attributes.majorVersion = 2; + attributes.minorVersion = 0; + attributes.depth = EM_TRUE; + attributes.stencil = EM_TRUE; + attributes.antialias = EM_FALSE; + // A host reads a surface session's frame out of this canvas's default + // framebuffer in a later task than the one that drew it. A displayed canvas + // is composited at the end of each task and would otherwise be cleared as + // part of that. A texture session never touches this buffer. + attributes.preserveDrawingBuffer = EM_TRUE; + // Implicit swap is what presenting depends on: the browser pushes what this + // context drew when the task that drew it ends. emscripten_webgl_commit_frame + // is a documented no-op in this emsdk, so explicit control would present + // nothing at all. + attributes.explicitSwapControl = EM_FALSE; + // Proxying the context to the page would turn every GL call MapLibre makes + // into a cross-thread round trip, and needs -sOFFSCREEN_FRAMEBUFFER, which + // this module does not link with. + attributes.proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW; + + // A registry key with Emscripten's prefix on it, and deliberately not a CSS + // selector, so escaping the id here would be a bug. + // findCanvasEventTarget(), the resolver under -sOFFSCREENCANVAS_SUPPORT, + // drops one leading character and looks the remainder up in + // GL.offscreenCanvases as a property name. + char target[MLN_KOTLIN_CANVAS_ID_BYTES + 1]; + (void)snprintf(target, sizeof(target), "#%s", name); + const EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = + emscripten_webgl_create_context(target, &attributes); + if (context == 0) { + return 0; + } + if ( + emscripten_webgl_make_context_current(context) != EMSCRIPTEN_RESULT_SUCCESS + ) { + (void)emscripten_webgl_destroy_context(context); + return 0; + } + return (int32_t)context; +} + +EMSCRIPTEN_KEEPALIVE void mln_kotlin_webgl_context_destroy(int32_t context) { + if (context > 0) { + (void)emscripten_webgl_destroy_context( + (EMSCRIPTEN_WEBGL_CONTEXT_HANDLE)context + ); + } +} + +EMSCRIPTEN_KEEPALIVE uint32_t mln_kotlin_webgl_texture_create( + int32_t context, uint32_t width, uint32_t height +) { + if (!mln_kotlin_extent_fits(width, height) || !mln_kotlin_bind(context)) { + return 0; + } + + GLint previous = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous); + mln_kotlin_clear_gl_errors(); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA8, (GLsizei)width, (GLsizei)height, 0, GL_RGBA, + GL_UNSIGNED_BYTE, NULL + ); + // One level, because a render target is drawn at its own size and never + // sampled from a smaller one. A texture asking for mipmaps would be + // incomplete until something built them. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + const bool created = glGetError() == GL_NO_ERROR; + + glBindTexture(GL_TEXTURE_2D, (GLuint)previous); + if (!created) { + glDeleteTextures(1, &texture); + return 0; + } + return texture; +} + +EMSCRIPTEN_KEEPALIVE void mln_kotlin_webgl_texture_destroy( + int32_t context, uint32_t texture +) { + if (texture == 0 || !mln_kotlin_bind(context)) { + return; + } + GLuint name = texture; + glDeleteTextures(1, &name); +} + +EMSCRIPTEN_KEEPALIVE bool mln_kotlin_webgl_read_pixels( + int32_t context, uint32_t texture, uint32_t width, uint32_t height, + uint8_t* out_pixels, size_t out_capacity +) { + if ( + out_pixels == NULL || !mln_kotlin_extent_fits(width, height) || + out_capacity < (size_t)width * (size_t)height * 4U || + !mln_kotlin_bind(context) + ) { + return false; + } + + // Put back before this returns. A read ignores the scissor rectangle, so only + // the binding matters here, but MapLibre leaves its own framebuffer bound + // between frames and assumes it is still there. + GLint previous = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &previous); + mln_kotlin_clear_gl_errors(); + + // Framebuffer zero for a surface target: it is the canvas's, and naming a + // framebuffer of our own would read something the session never drew into. + GLuint framebuffer = 0; + bool read = true; + if (texture != 0) { + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer); + glFramebufferTexture2D( + GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, (GLuint)texture, + 0 + ); + read = + glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + } else { + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + } + + if (read) { + // Rows are width * 4 bytes and therefore already four-byte aligned, which + // is the default pack alignment, so no state is left changed for the next + // frame. + glReadPixels( + 0, 0, (GLsizei)width, (GLsizei)height, GL_RGBA, GL_UNSIGNED_BYTE, + out_pixels + ); + read = glGetError() == GL_NO_ERROR; + } + + glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)previous); + if (framebuffer != 0) { + glDeleteFramebuffers(1, &framebuffer); + } + return read; +} + +EMSCRIPTEN_KEEPALIVE bool mln_kotlin_webgl_present_texture( + int32_t context, uint32_t texture, uint32_t width, uint32_t height +) { + if ( + texture == 0 || !mln_kotlin_extent_fits(width, height) || + !mln_kotlin_bind(context) + ) { + return false; + } + + GLint previous_read = 0; + GLint previous_draw = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &previous_read); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &previous_draw); + const GLboolean scissored = glIsEnabled(GL_SCISSOR_TEST); + mln_kotlin_clear_gl_errors(); + + GLuint framebuffer = 0; + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer); + glFramebufferTexture2D( + GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, (GLuint)texture, 0 + ); + bool presented = + glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + + if (presented) { + // Zero is the canvas's own framebuffer, which is the one the browser + // composites; naming a framebuffer of our own would present nothing. + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + if (scissored) { + glDisable(GL_SCISSOR_TEST); + } + // GL_NEAREST because source and destination are the same size, and because + // a multi-sampled or format-converting blit is the only case where + // GL_LINEAR is allowed. + glBlitFramebuffer( + 0, 0, (GLsizei)width, (GLsizei)height, 0, 0, (GLsizei)width, + (GLsizei)height, GL_COLOR_BUFFER_BIT, GL_NEAREST + ); + presented = glGetError() == GL_NO_ERROR; + if (scissored) { + glEnable(GL_SCISSOR_TEST); + } + } + + glBindFramebuffer(GL_READ_FRAMEBUFFER, (GLuint)previous_read); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, (GLuint)previous_draw); + glDeleteFramebuffers(1, &framebuffer); + return presented; +} diff --git a/bindings/kotlin/mise.toml b/bindings/kotlin/mise.toml index 17dc3bfe0..6a891b93e 100644 --- a/bindings/kotlin/mise.toml +++ b/bindings/kotlin/mise.toml @@ -8,6 +8,44 @@ description = "Regenerate the committed JVM FFM declarations with jextract." dir = "../.." run = "./gradlew :bindings:kotlin:generateJvmJextractBindings --rerun-tasks" +[tasks.generate-wasm] +description = "Regenerate the committed browser struct offsets and entry points." +dir = "../.." +# Both generators read the headers with the pinned Emscripten clang, which is +# the compiler that lays out the module the binding calls, and take their paths +# from $EMSDK. +# +# The generators write each declaration on one line, however long it runs. +# Formatting them here is what makes the committed files the same text the +# generators produce, so `mise run fix` has nothing left to rewrite and +# check-wasm-generated has nothing to report. +run = ''' +set -euo pipefail +python3 scripts/generate-wasm-struct-layouts.py +python3 scripts/generate-wasm-externs.py +dprint fmt "bindings/kotlin/src/wasmJsMain/generated/**/*.kt" +''' + +[tasks.check-wasm-generated] +description = "Verify the committed browser struct offsets and entry points match the headers." +depends = [":generate-wasm"] +dir = "../.." +# The export check compares the generated Kotlin against the module, and both +# are built from the same headers, so a header change that nobody regenerated +# for passes it. This is what catches that: it regenerates and reports any +# difference, following the pattern the hygiene job uses for every other +# generated file in the repository. +run = "git diff --exit-code -- bindings/kotlin/src/wasmJsMain/generated/" + +[tasks.check-wasm-externs] +description = "Verify each generated browser entry point matches the linked module." +depends = [{ task = "//:build", args = ["emscripten-wasm32-webgl"] }] +dir = "../.." +# A C caller's every call is checked by the compiler; a browser binding calls by +# name through JavaScript, so nothing checks it unless this runs. It reads the +# module the suite is about to load, out of the same install prefix. +run = "python3 scripts/check-browser-exports.py" + [tasks.api] description = "Generate Dokka HTML API reference for the Kotlin binding." dir = "../.." @@ -120,6 +158,20 @@ esac ":bindings:kotlin:${kotlin_target}Test" ''' +[tasks.wasmJsTest] +description = "Run the Kotlin browser binding tests in headless Chromium." +depends = [{ task = "//:build", args = ["emscripten-wasm32-webgl"] }] +dir = "../.." +# The browser build installs the module under its prefix, beside its wasm, and that prefix is also +# what the packaged browser artifact unpacks to. Pointing the tests there means they drive the same +# layout a host downloads, and it keeps them working in CI, where the packaged artifact replaces the +# build tree before this task runs. +run = ''' +./gradlew \ + -Pmaplibre.browser.moduleDir=build/emscripten-wasm32-webgl/install/lib/browser \ + :bindings:kotlin:wasmJsBrowserSuite +''' + [tasks.iosBuild] description = "Build the Kotlin binding and embedded Metal runtime for an iOS target." usage = ''' diff --git a/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/Maplibre.kt b/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/Maplibre.kt index 86e29f58f..6132d4763 100644 --- a/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/Maplibre.kt +++ b/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/Maplibre.kt @@ -57,8 +57,8 @@ public actual object Maplibre { } /** Installs or replaces the process-global native log callback. */ - public actual fun setLogCallback(callback: LogCallback) { - LogCallbackState.set(callback) + public actual fun setLogCallback(callback: LogCallback, consume: Boolean) { + LogCallbackState.set(callback, consume) } /** Clears the process-global native log callback. */ diff --git a/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt b/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt index 571ee1529..5e73d1894 100644 --- a/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt +++ b/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt @@ -16,7 +16,8 @@ import org.maplibre.nativeffi.log.LogSeverity /** Owns process-global Android JNI logging callback state. */ @OptIn(ExperimentalAtomicApi::class) -internal class LogCallbackState private constructor(private val callback: LogCallback) : +internal class LogCallbackState +private constructor(private val callback: LogCallback, private val consume: Boolean) : AutoCloseable { private val gate = CallbackGate("log callbacks") { nativeCallback.close() } private val nativeCallback = @@ -40,7 +41,8 @@ internal class LogCallbackState private constructor(private val callback: LogCal code, JavaCppSupport.cString(message), ) - if (callback.log(record)) 1 else 0 + callback.log(record) + if (consume) 1 else 0 } catch (_: Throwable) { 0 } finally { @@ -56,9 +58,9 @@ internal class LogCallbackState private constructor(private val callback: LogCal private val updateLock = AtomicInt(0) private val current = AtomicReference(null) - fun set(callback: LogCallback) { + fun set(callback: LogCallback, consume: Boolean) { NativeAccess.ensureLoaded() - val replacement = LogCallbackState(callback) + val replacement = LogCallbackState(callback, consume) var previous: LogCallbackState? = null try { withUpdateLock { diff --git a/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt b/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt index db5867cac..2b0088ff8 100644 --- a/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt +++ b/bindings/kotlin/src/androidMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt @@ -683,6 +683,12 @@ private fun setOpenGLContext( get_proc_address(pointerOrNull(context.getProcAddress)) } } + // A WebGL handle indexes the browser module's context table, and an Android host has no such + // table, so the descriptor is well formed but names nothing that exists here. + is WebglContextDescriptor -> + throw Status.unsupported( + "A WebGL context can only be used by the browser binding, not by the Android binding." + ) } } diff --git a/bindings/kotlin/src/appleTest/kotlin/org/maplibre/nativeffi/render/RenderSessionHandleTest.kt b/bindings/kotlin/src/appleTest/kotlin/org/maplibre/nativeffi/render/RenderSessionHandleTest.kt index d6eaac719..bf240fa58 100644 --- a/bindings/kotlin/src/appleTest/kotlin/org/maplibre/nativeffi/render/RenderSessionHandleTest.kt +++ b/bindings/kotlin/src/appleTest/kotlin/org/maplibre/nativeffi/render/RenderSessionHandleTest.kt @@ -118,7 +118,7 @@ class RenderSessionHandleTest { if (!metalSupportedOrInapplicable()) return val device = MTLCreateSystemDefaultDevice() ?: error("MTLCreateSystemDefaultDevice returned nil") - Maplibre.setLogCallback(LogCallback { true }) + Maplibre.setLogCallback(LogCallback {}, consume = true) Maplibre.setAsyncLogSeverities(emptySet()) try { val runtime = RuntimeHandle.create(org.maplibre.nativeffi.runtime.RuntimeOptions()) @@ -369,7 +369,7 @@ class RenderSessionHandleTest { if (!metalSupportedOrInapplicable()) return val device = MTLCreateSystemDefaultDevice() ?: error("MTLCreateSystemDefaultDevice returned nil") - Maplibre.setLogCallback(LogCallback { true }) + Maplibre.setLogCallback(LogCallback {}, consume = true) Maplibre.setAsyncLogSeverities(emptySet()) try { val runtime = RuntimeHandle.create(org.maplibre.nativeffi.runtime.RuntimeOptions()) @@ -460,7 +460,7 @@ class RenderSessionHandleTest { if (!metalSupportedOrInapplicable()) return val device = MTLCreateSystemDefaultDevice() ?: error("MTLCreateSystemDefaultDevice returned nil") - Maplibre.setLogCallback(LogCallback { true }) + Maplibre.setLogCallback(LogCallback {}, consume = true) Maplibre.setAsyncLogSeverities(emptySet()) try { val runtime = RuntimeHandle.create(org.maplibre.nativeffi.runtime.RuntimeOptions()) @@ -676,7 +676,7 @@ class RenderSessionHandleTest { if (!metalSupportedOrInapplicable()) return val device = MTLCreateSystemDefaultDevice() ?: error("MTLCreateSystemDefaultDevice returned nil") - Maplibre.setLogCallback(LogCallback { true }) + Maplibre.setLogCallback(LogCallback {}, consume = true) Maplibre.setAsyncLogSeverities(emptySet()) try { val runtime = RuntimeHandle.create(org.maplibre.nativeffi.runtime.RuntimeOptions()) diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/Maplibre.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/Maplibre.kt index fe173b0a9..e57364c14 100644 --- a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/Maplibre.kt +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/Maplibre.kt @@ -31,8 +31,14 @@ public expect object Maplibre { /** Sets Maplibre Native's process-global network status. */ public fun setNetworkStatus(status: NetworkStatus) - /** Installs or replaces the process-global native log callback. */ - public fun setLogCallback(callback: LogCallback) + /** + * Installs or replaces the process-global native log callback. + * + * A consumed record does not reach MapLibre's platform logger. The decision is fixed here rather + * than taken per record, because native needs it on the thread that produced the record and a + * host is not always able to answer there. + */ + public fun setLogCallback(callback: LogCallback, consume: Boolean = false) /** Clears the process-global native log callback. */ public fun clearLogCallback() diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackGate.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackGate.kt index 7ef0bc848..f415b97b7 100644 --- a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackGate.kt +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackGate.kt @@ -32,6 +32,20 @@ internal class CallbackGate(private val name: String, private val closeNative: ( } } + /** + * Stops admitting callbacks and returns once the last body already inside has left. + * + * Waiting is what a retired callback owes its host: the host may dispose of whatever it gave the + * callback the moment this returns, and a body that resumed afterwards would use it. So the + * closer waits for the bodies it is not, on [yieldWhileClosing], which is a yield to the thread + * holding the count on a target with threads and a park on the browser, where the body holding it + * is a suspended stack. + * + * A closer that *is* inside this gate's callback is the one case that cannot wait, because the + * body it would wait for is the frame below it. It stops admitting and returns; the body releases + * the gate as it leaves, and [closeNative] runs there instead. That is what makes a callback that + * ends its own registration legal rather than a deadlock. + */ override fun close() { val closingFromCallback = threadState.isInCallback() while (true) { diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleStateCore.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleStateCore.kt index 9a847afc1..399394856 100644 --- a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleStateCore.kt +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleStateCore.kt @@ -73,6 +73,21 @@ internal class HandleStateCore( } } + /** + * Destroys this handle once, and runs [afterSuccess] for the bookkeeping the destroy released. + * + * A failing [destroy] leaves the handle live and closable again, because the C API refused and + * the native handle is still there. [afterSuccess] is the opposite: it runs after the handle has + * been marked closed, since the native handle is gone by then and a wrapper that still called + * itself live would offer calls that could only fail. A later `close` therefore returns without + * reaching it. + * + * So **[afterSuccess] gets one attempt**, and what it does not finish is not finished by anyone. + * A body of it that can fail -- releasing a callback registration, on a target where that waits + * for a body already inside it -- has to make sure that the accounting the rest of the binding + * depends on happens anyway, rather than leaving a parent retained by a handle that no longer + * exists. + */ fun closeOnce(destroy: () -> Int, afterSuccess: () -> Unit = {}) { if (!releaseState.compareAndSet(STATE_LIVE, STATE_RELEASING)) { when (releaseState.load()) { diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/status/Status.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/status/Status.kt index 3f11e6dfa..10e21810d 100644 --- a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/status/Status.kt +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/internal/status/Status.kt @@ -4,6 +4,7 @@ import org.maplibre.nativeffi.error.InvalidArgumentException import org.maplibre.nativeffi.error.InvalidStateException import org.maplibre.nativeffi.error.MaplibreException import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException /** Converts C ABI status values to Kotlin exceptions. */ internal object Status { @@ -52,6 +53,17 @@ internal object Status { if (!condition) throw invalidArgument(diagnostic()) } + /** + * Creates a binding-owned unsupported error without reaching native for a diagnostic. + * + * Some inputs are shaped by the common API but meaningful on only one platform: a WebGL context + * names an entry in the browser module's own table, and no desktop or mobile target has that + * table to look it up in. The binding refuses those here rather than passing them down, because + * what native would receive is a well-formed descriptor naming something that does not exist. + */ + fun unsupported(diagnostic: String): UnsupportedFeatureException = + UnsupportedFeatureException(MaplibreStatus.UNSUPPORTED.nativeCode, diagnostic) + /** Creates the binding-owned error for closing a callback owner from inside its callback. */ fun callbackReentry(typeName: String): InvalidStateException = invalidState("$typeName callback cannot be closed from inside its callback") diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/log/LogCallback.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/log/LogCallback.kt index 4641f8b8f..064abbe22 100644 --- a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/log/LogCallback.kt +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/log/LogCallback.kt @@ -6,8 +6,12 @@ package org.maplibre.nativeffi.log * Native code may invoke this callback on logging or worker threads. The callback should return * quickly and avoid calling Maplibre APIs. The binding copies each record before invoking Kotlin * code and contains callback exceptions so they do not unwind into native code. + * + * Whether a record also reaches MapLibre's platform logger is fixed when the callback is + * registered, not decided per record, because a host that cannot answer on the producing thread has + * no way to decide it there. */ public fun interface LogCallback { - /** Returns true when the callback consumed the record, false to let native logging handle it. */ - public fun log(record: LogRecord): Boolean + /** Receives one log record. */ + public fun log(record: LogRecord) } diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/render/WebglContextDescriptor.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/render/WebglContextDescriptor.kt new file mode 100644 index 000000000..34faee853 --- /dev/null +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/render/WebglContextDescriptor.kt @@ -0,0 +1,39 @@ +package org.maplibre.nativeffi.render + +/** + * The `WebglContext` that a [WebglContextDescriptor] came from. + * + * Only the browser binding's context class implements this, and common code needs nothing from it + * beyond identity. It exists so that a descriptor names an object rather than only a recyclable + * handle; see [WebglContextDescriptor.owner]. + */ +internal interface WebglContextOwner + +/** + * WebGL context descriptor for OpenGL render targets in a browser. + * + * The browser owns the context and a session draws into it rather than creating one, so the handle + * is borrowed for as long as the render target exists. It is an `EMSCRIPTEN_WEBGL_CONTEXT_HANDLE` + * rather than a pointer: the browser module keeps its contexts in a table of its own, and what + * crosses the boundary is the entry's index. That is why this arm carries an [Int] where the other + * two carry a [NativePointer]. + * + * A host obtains one from the context it wants drawn to rather than by building one, which is the + * whole difference between this arm and the other two. Everywhere else the host owns the graphics + * API and fills a descriptor in from what it made; here the binding made the context, so the + * binding fills this in and none of it is the host's to change. + * + * That is also what makes the handle safe to attach with. The module allocates a context handle and + * frees it again when the context is destroyed, so the number names one context only until the next + * context reuses it, and a descriptor kept past its context's close would otherwise name whichever + * context inherited that number. So a descriptor carries the context it came from beside the + * handle, and attaching resolves that rather than the number. A descriptor from a closed context is + * refused however many contexts have been created since. + */ +public class WebglContextDescriptor +internal constructor( + /** Borrowed `EMSCRIPTEN_WEBGL_CONTEXT_HANDLE`. Always positive. */ + public val context: Int, + /** The `WebglContext` this names, which is what a render target is really attached to. */ + internal val owner: WebglContextOwner, +) : OpenGLContextDescriptor diff --git a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCore.kt b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCore.kt index 4445adbf6..fba473b06 100644 --- a/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCore.kt +++ b/bindings/kotlin/src/commonMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCore.kt @@ -167,9 +167,25 @@ internal class ResourceRequestHandleCore(private val releaseNative: () -> Unit) } } + /** + * Releases the native request if this wrapper owns it, and goes on owning it if that failed. + * + * The accounting is written first, because it is what makes the release happen once: two stacks + * reaching here together must not both call native. But a release that then failed never + * happened, and a reference that says otherwise is one nothing retries -- the request would + * stay open in native, with MapLibre waiting on it, for as long as the page lives. So the + * ownership goes back and the failure is reported, which leaves the next close able to try + * again. + */ fun releaseIfOwned() { - if (state.compareAndSet(STATE_PROVIDER_OWNED, STATE_RELEASE_ACCOUNTED)) { + if (!state.compareAndSet(STATE_PROVIDER_OWNED, STATE_RELEASE_ACCOUNTED)) return + try { releaseNative() + } catch (error: Throwable) { + // Conditional, so that a release native has meanwhile been told about some other way is + // not handed back to this wrapper. + state.compareAndSet(STATE_RELEASE_ACCOUNTED, STATE_PROVIDER_OWNED) + throw error } } diff --git a/bindings/kotlin/src/commonTest/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCoreTest.kt b/bindings/kotlin/src/commonTest/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCoreTest.kt index 6ebd73043..bde11fe9d 100644 --- a/bindings/kotlin/src/commonTest/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCoreTest.kt +++ b/bindings/kotlin/src/commonTest/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandleCoreTest.kt @@ -69,6 +69,39 @@ class ResourceRequestHandleCoreTest { assertEquals(1, completions) } + /** + * A release that failed leaves the request this wrapper's to release, rather than nobody's. + * + * Releasing is the one call a request handle makes that has no answer to report and no second + * chance: the wrapper accounts for it before calling native, so that two stacks closing together + * cannot both call. The failure this drives is what that accounting must not survive -- a page + * whose allocator refuses the block the call's arguments are packed into never reaches C at all, + * and a reference that recorded the release anyway would leave MapLibre waiting on a request + * nothing can now answer or retire. + * + * So the claim is about the state the failure left, not the error: the close reports it, and the + * close after it really does reach native. + */ + @Test + fun aReleaseThatFailedLeavesTheRequestReleasableAgain() { + var attempts = 0 + val core = ResourceRequestHandleCore { + attempts++ + // Only the first, so that what the second close does is observable rather than another + // failure. + if (attempts == 1) error("the release never reached native") + } + + assertEquals( + ResourceProviderDecision.HANDLE, + core.finishProviderDecision(ResourceProviderDecision.HANDLE), + ) + assertFailsWith { core.close() } + core.close() + + assertEquals(2, attempts, "a failed release left the request accounted for but never given up") + } + @Test fun completedHandleRejectsFurtherCompletion() { val core = ResourceRequestHandleCore {} diff --git a/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/Maplibre.kt b/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/Maplibre.kt index 26fe1c0db..e7d20521b 100644 --- a/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/Maplibre.kt +++ b/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/Maplibre.kt @@ -62,8 +62,8 @@ public actual object Maplibre { } /** Installs or replaces the process-global native log callback. */ - public actual fun setLogCallback(callback: LogCallback) { - LogCallbackState.set(callback) + public actual fun setLogCallback(callback: LogCallback, consume: Boolean) { + LogCallbackState.set(callback, consume) } /** Clears the process-global native log callback. */ diff --git a/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt b/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt index 8b3927356..55e82aae4 100644 --- a/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt +++ b/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt @@ -19,7 +19,8 @@ import org.maplibre.nativeffi.log.LogSeverity /** Owns process-global JVM FFM logging callback state. */ @OptIn(ExperimentalAtomicApi::class) -internal class LogCallbackState private constructor(private val callback: LogCallback) : +internal class LogCallbackState +private constructor(private val callback: LogCallback, private val consume: Boolean) : AutoCloseable { private val arena = Arena.ofShared() private val gate = CallbackGate("log callbacks") { arena.close() } @@ -61,7 +62,8 @@ internal class LogCallbackState private constructor(private val callback: LogCal code, copyCString(message), ) - if (callback.log(record)) 1 else 0 + callback.log(record) + if (consume) 1 else 0 } catch (_: Throwable) { 0 } finally { @@ -88,9 +90,9 @@ internal class LogCallbackState private constructor(private val callback: LogCal private val updateLock = AtomicInt(0) private val current = AtomicReference(null) - fun set(callback: LogCallback) { + fun set(callback: LogCallback, consume: Boolean) { NativeAccess.ensureLoaded() - val replacement = LogCallbackState(callback) + val replacement = LogCallbackState(callback, consume) var previous: LogCallbackState? = null try { withUpdateLock { diff --git a/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/loader/NativeAccess.kt b/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/loader/NativeAccess.kt index a85622c3d..ac7069223 100644 --- a/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/loader/NativeAccess.kt +++ b/bindings/kotlin/src/jvmMain/kotlin/org/maplibre/nativeffi/internal/loader/NativeAccess.kt @@ -178,6 +178,7 @@ import org.maplibre.nativeffi.render.VulkanContextDescriptor import org.maplibre.nativeffi.render.VulkanOwnedTextureDescriptor import org.maplibre.nativeffi.render.VulkanOwnedTextureFrame import org.maplibre.nativeffi.render.VulkanSurfaceDescriptor +import org.maplibre.nativeffi.render.WebglContextDescriptor import org.maplibre.nativeffi.render.WglContextDescriptor import org.maplibre.nativeffi.resource.ResourceErrorReason import org.maplibre.nativeffi.resource.ResourceKind @@ -2786,6 +2787,12 @@ internal object NativeAccess { ) fillEglContext(mln_opengl_context_descriptor.data.egl(data), context) } + // A WebGL handle indexes the browser module's context table, and a JVM host has no such + // table, so the descriptor is well formed but names nothing that exists here. + is WebglContextDescriptor -> + throw Status.unsupported( + "A WebGL context can only be used by the browser binding, not by the JVM binding." + ) } } diff --git a/bindings/kotlin/src/jvmTest/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackStateTest.kt b/bindings/kotlin/src/jvmTest/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackStateTest.kt index b23e09e3c..020a4afc9 100644 --- a/bindings/kotlin/src/jvmTest/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackStateTest.kt +++ b/bindings/kotlin/src/jvmTest/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackStateTest.kt @@ -16,12 +16,7 @@ class LogCallbackStateTest { var copiedRecord: LogRecord? = null try { - LogCallbackState.set( - LogCallback { record -> - copiedRecord = record - true - } - ) + LogCallbackState.set(LogCallback { record -> copiedRecord = record }, consume = true) val state = assertNotNull(LogCallbackState.currentForTesting()) Arena.ofConfined().use { arena -> diff --git a/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/Maplibre.kt b/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/Maplibre.kt index 6270421e1..11455cdd1 100644 --- a/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/Maplibre.kt +++ b/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/Maplibre.kt @@ -82,8 +82,8 @@ public actual object Maplibre { } /** Installs or replaces the process-global native log callback. */ - public actual fun setLogCallback(callback: LogCallback) { - LogCallbackState.set(callback) + public actual fun setLogCallback(callback: LogCallback, consume: Boolean) { + LogCallbackState.set(callback, consume) } /** Clears the process-global native log callback. */ diff --git a/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt b/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt index 17c24b6d5..f6a18be6f 100644 --- a/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt +++ b/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt @@ -15,7 +15,8 @@ import org.maplibre.nativeffi.log.LogSeverity /** Owns process-global logging callback state. */ @OptIn(ExperimentalForeignApi::class) -internal class LogCallbackState private constructor(private val callback: LogCallback) : +internal class LogCallbackState +private constructor(private val callback: LogCallback, private val consume: Boolean) : AutoCloseable { private val gate = CallbackGate("log callbacks") @@ -29,7 +30,8 @@ internal class LogCallbackState private constructor(private val callback: LogCal code, MemoryUtil.copyCString(message), ) - if (callback.log(record)) 1U else 0U + callback.log(record) + if (consume) 1U else 0U } catch (_: Throwable) { 0U } finally { @@ -46,20 +48,21 @@ internal class LogCallbackState private constructor(private val callback: LogCal internal companion object { private val registry = LogCallbackRegistry() - fun set(callback: LogCallback) { + fun set(callback: LogCallback, consume: Boolean) { registry.current()?.checkCanClose() - registry.set(LogCallbackState(callback)) { + registry.set(LogCallbackState(callback, consume)) { mln_log_set_callback(staticCFunction(::logCallback), null) } } fun setForTesting( callback: LogCallback, + consume: Boolean, install: () -> Int, captureReplacement: (LogCallbackState) -> Unit, ) { registry.current()?.checkCanClose() - registry.set(LogCallbackState(callback).also(captureReplacement), install) + registry.set(LogCallbackState(callback, consume).also(captureReplacement), install) } fun clear() { diff --git a/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/struct/RenderStructs.kt b/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/struct/RenderStructs.kt index b5a878d8f..41500607e 100644 --- a/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/struct/RenderStructs.kt +++ b/bindings/kotlin/src/nativeMain/kotlin/org/maplibre/nativeffi/internal/struct/RenderStructs.kt @@ -35,6 +35,7 @@ import org.maplibre.nativeffi.internal.c.mln_vulkan_owned_texture_descriptor_def import org.maplibre.nativeffi.internal.c.mln_vulkan_surface_descriptor import org.maplibre.nativeffi.internal.c.mln_vulkan_surface_descriptor_default import org.maplibre.nativeffi.internal.c.mln_wgl_context_descriptor +import org.maplibre.nativeffi.internal.status.Status import org.maplibre.nativeffi.render.EglContextDescriptor import org.maplibre.nativeffi.render.MetalBorrowedTextureDescriptor import org.maplibre.nativeffi.render.MetalContextDescriptor @@ -51,6 +52,7 @@ import org.maplibre.nativeffi.render.VulkanBorrowedTextureDescriptor import org.maplibre.nativeffi.render.VulkanContextDescriptor import org.maplibre.nativeffi.render.VulkanOwnedTextureDescriptor import org.maplibre.nativeffi.render.VulkanSurfaceDescriptor +import org.maplibre.nativeffi.render.WebglContextDescriptor import org.maplibre.nativeffi.render.WglContextDescriptor /** Internal materializers and readers for render target descriptors and frames. */ @@ -233,6 +235,13 @@ internal object RenderStructs { native.platform = MLN_OPENGL_CONTEXT_PLATFORM_EGL fillEglContext(native.data.egl, context) } + + // A WebGL handle indexes the browser module's context table, and a Kotlin/Native target has + // no such table, so the descriptor is well formed but names nothing that exists here. + is WebglContextDescriptor -> + throw Status.unsupported( + "A WebGL context can only be used by the browser binding, not by a Kotlin/Native target." + ) } } diff --git a/bindings/kotlin/src/nativeTest/kotlin/org/maplibre/nativeffi/log/LogCallbackStateTest.kt b/bindings/kotlin/src/nativeTest/kotlin/org/maplibre/nativeffi/log/LogCallbackStateTest.kt index 1aefb54ec..06c217373 100644 --- a/bindings/kotlin/src/nativeTest/kotlin/org/maplibre/nativeffi/log/LogCallbackStateTest.kt +++ b/bindings/kotlin/src/nativeTest/kotlin/org/maplibre/nativeffi/log/LogCallbackStateTest.kt @@ -40,12 +40,7 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { var initialState: LogCallbackState? = null var replacementState: LogCallbackState? = null try { - Maplibre.setLogCallback( - LogCallback { record -> - records += record - true - } - ) + Maplibre.setLogCallback(LogCallback { record -> records += record }, consume = true) initialState = LogCallbackState.currentForTesting() memScoped { assertEquals(1U, initialState?.invoke(1U, 3U, 7L, "hello".cstr.getPointer(this))) @@ -58,10 +53,8 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { assertEquals("hello", records.single().message) Maplibre.setLogCallback( - LogCallback { record -> - replacementRecords += record - false - } + LogCallback { record -> replacementRecords += record }, + consume = false, ) replacementState = LogCallbackState.currentForTesting() assertEquals(0U, initialState?.invoke(1U, 3U, 8L, null)) @@ -86,12 +79,7 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { fun logCallbackCopiesMessageAndPreservesUnknownRawEnums() { var record: LogRecord? = null try { - Maplibre.setLogCallback( - LogCallback { - record = it - true - } - ) + Maplibre.setLogCallback(LogCallback { record = it }, consume = true) val state = requireNotNull(LogCallbackState.currentForTesting()) memScoped { assertEquals(1U, state.invoke(900U, 901U, 12L, "future".cstr.getPointer(this))) } @@ -114,7 +102,8 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { val error = assertFailsWith { LogCallbackState.setForTesting( - LogCallback { true }, + LogCallback {}, + consume = true, install = { MaplibreStatus.NATIVE_ERROR.nativeCode }, captureReplacement = { replacementState = it }, ) @@ -134,8 +123,8 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { LogCallback { state.close() accepted += 1 - true - } + }, + consume = true, ) state = requireNotNull(LogCallbackState.currentForTesting()) @@ -155,8 +144,8 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { Maplibre.setLogCallback( LogCallback { clearError = assertFailsWith { Maplibre.clearLogCallback() } - true - } + }, + consume = true, ) state = requireNotNull(LogCallbackState.currentForTesting()) @@ -176,9 +165,11 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { Maplibre.setLogCallback( LogCallback { setError = - assertFailsWith { Maplibre.setLogCallback(LogCallback { true }) } - true - } + assertFailsWith { + Maplibre.setLogCallback(LogCallback {}, consume = true) + } + }, + consume = true, ) state = requireNotNull(LogCallbackState.currentForTesting()) @@ -204,8 +195,8 @@ class LogCallbackStateTest : org.maplibre.nativeffi.NativeTestBase() { accepted.addAndFetch(1) phase.store(LOG_PHASE_ENTERED) waitForLogPhase(phase, LOG_PHASE_RELEASE) - true - } + }, + consume = true, ) state = requireNotNull(LogCallbackState.currentForTesting()) diff --git a/bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi/internal/wasm/generated/EntryPoints.kt b/bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi/internal/wasm/generated/EntryPoints.kt new file mode 100644 index 000000000..557a89d24 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi/internal/wasm/generated/EntryPoints.kt @@ -0,0 +1,1514 @@ +// Generated by scripts/generate-wasm-externs.py. Edit the generator. +// +// One declaration per C entry point this binding calls, lowered for +// wasm32-unknown-emscripten. Pointers are `Int` because the target is 32-bit; +// handles are `Long` because they are 64-bit and cross as BigInt. A function +// that returns a struct by value takes `out_return` and returns nothing. + +package org.maplibre.nativeffi.internal.wasm.generated + +@JsFun( + "(fetch_tile, cancel_tile, user_data) => { globalThis.__maplibreNativeC._mln_adapter_custom_geometry_callbacks_retire(fetch_tile, cancel_tile, user_data) }" +) +internal external fun mln_adapter_custom_geometry_callbacks_retire( + fetch_tile: Int, + cancel_tile: Int, + user_data: Int, +) + +@JsFun("(record) => { globalThis.__maplibreNativeC._mln_adapter_log_record_destroy(record) }") +internal external fun mln_adapter_log_record_destroy(record: Int) + +@JsFun( + "(provider) => { globalThis.__maplibreNativeC._mln_adapter_queued_resource_provider_retire(provider) }" +) +internal external fun mln_adapter_queued_resource_provider_retire(provider: Int) + +@JsFun( + "(request) => { globalThis.__maplibreNativeC._mln_adapter_resource_provider_request_destroy(request) }" +) +internal external fun mln_adapter_resource_provider_request_destroy(request: Int) + +@JsFun( + "(user_data, kind, url, out_response) => globalThis.__maplibreNativeC._mln_adapter_resource_transform_rewrite_callback(user_data, kind, url, out_response)" +) +internal external fun mln_adapter_resource_transform_rewrite_callback( + user_data: Int, + kind: Int, + url: Int, + out_response: Int, +): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_c_version()") +internal external fun mln_c_version(): Int + +@JsFun("(result) => { globalThis.__maplibreNativeC._mln_feature_extension_result_destroy(result) }") +internal external fun mln_feature_extension_result_destroy(result: Long) + +@JsFun( + "(result, out_info) => globalThis.__maplibreNativeC._mln_feature_extension_result_get(result, out_info)" +) +internal external fun mln_feature_extension_result_get(result: Long, out_info: Int): Int + +@JsFun( + "(result, out_count) => globalThis.__maplibreNativeC._mln_feature_query_result_count(result, out_count)" +) +internal external fun mln_feature_query_result_count(result: Long, out_count: Int): Int + +@JsFun("(result) => { globalThis.__maplibreNativeC._mln_feature_query_result_destroy(result) }") +internal external fun mln_feature_query_result_destroy(result: Long) + +@JsFun( + "(result, index, out_feature) => globalThis.__maplibreNativeC._mln_feature_query_result_get(result, index, out_feature)" +) +internal external fun mln_feature_query_result_get(result: Long, index: Int, out_feature: Int): Int + +@JsFun("(snapshot) => { globalThis.__maplibreNativeC._mln_json_snapshot_destroy(snapshot) }") +internal external fun mln_json_snapshot_destroy(snapshot: Long) + +@JsFun( + "(snapshot, out_value) => globalThis.__maplibreNativeC._mln_json_snapshot_get(snapshot, out_value)" +) +internal external fun mln_json_snapshot_get(snapshot: Long, out_value: Int): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_dropped_records()") +internal external fun mln_kotlin_dropped_records(): Long + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_log_clear()") +internal external fun mln_kotlin_log_clear(): Int + +@JsFun("(consume) => globalThis.__maplibreNativeC._mln_kotlin_log_install(consume)") +internal external fun mln_kotlin_log_install(consume: Int): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_queued_provider_callback()") +internal external fun mln_kotlin_queued_provider_callback(): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_resource_request_listener()") +internal external fun mln_kotlin_resource_request_listener(): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_rewrite_transform_callback()") +internal external fun mln_kotlin_rewrite_transform_callback(): Int + +@JsFun("(source) => { globalThis.__maplibreNativeC._mln_kotlin_set_wake(source) }") +internal external fun mln_kotlin_set_wake(source: Long) + +@JsFun("(out) => globalThis.__maplibreNativeC._mln_kotlin_take_record(out)") +internal external fun mln_kotlin_take_record(out: Int): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_tile_cancel_callback()") +internal external fun mln_kotlin_tile_cancel_callback(): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_kotlin_tile_fetch_callback()") +internal external fun mln_kotlin_tile_fetch_callback(): Int + +@JsFun( + "(name, width, height) => globalThis.__maplibreNativeC._mln_kotlin_webgl_canvas_create(name, width, height)" +) +internal external fun mln_kotlin_webgl_canvas_create(name: Int, width: Int, height: Int): Int + +@JsFun("(name) => { globalThis.__maplibreNativeC._mln_kotlin_webgl_canvas_destroy(name) }") +internal external fun mln_kotlin_webgl_canvas_destroy(name: Int) + +@JsFun( + "(name, width, height) => globalThis.__maplibreNativeC._mln_kotlin_webgl_canvas_resize(name, width, height)" +) +internal external fun mln_kotlin_webgl_canvas_resize(name: Int, width: Int, height: Int): Int + +@JsFun( + "(name, width, height) => globalThis.__maplibreNativeC._mln_kotlin_webgl_context_create(name, width, height)" +) +internal external fun mln_kotlin_webgl_context_create(name: Int, width: Int, height: Int): Int + +@JsFun("(context) => { globalThis.__maplibreNativeC._mln_kotlin_webgl_context_destroy(context) }") +internal external fun mln_kotlin_webgl_context_destroy(context: Int) + +@JsFun( + "(context, texture, width, height) => globalThis.__maplibreNativeC._mln_kotlin_webgl_present_texture(context, texture, width, height)" +) +internal external fun mln_kotlin_webgl_present_texture( + context: Int, + texture: Int, + width: Int, + height: Int, +): Int + +@JsFun( + "(context, texture, width, height, out_pixels, out_capacity) => globalThis.__maplibreNativeC._mln_kotlin_webgl_read_pixels(context, texture, width, height, out_pixels, out_capacity)" +) +internal external fun mln_kotlin_webgl_read_pixels( + context: Int, + texture: Int, + width: Int, + height: Int, + out_pixels: Int, + out_capacity: Int, +): Int + +@JsFun( + "(context, width, height) => globalThis.__maplibreNativeC._mln_kotlin_webgl_texture_create(context, width, height)" +) +internal external fun mln_kotlin_webgl_texture_create(context: Int, width: Int, height: Int): Int + +@JsFun( + "(context, texture) => { globalThis.__maplibreNativeC._mln_kotlin_webgl_texture_destroy(context, texture) }" +) +internal external fun mln_kotlin_webgl_texture_destroy(context: Int, texture: Int) + +@JsFun( + "(meters, out_coordinate) => globalThis.__maplibreNativeC._mln_lat_lng_for_projected_meters(meters, out_coordinate)" +) +internal external fun mln_lat_lng_for_projected_meters(meters: Int, out_coordinate: Int): Int + +@JsFun("(mask) => globalThis.__maplibreNativeC._mln_log_set_async_severity_mask(mask)") +internal external fun mln_log_set_async_severity_mask(mask: Int): Int + +@JsFun( + "(map, layer_id, source_id, before_layer_id) => globalThis.__maplibreNativeC._mln_map_add_color_relief_layer(map, layer_id, source_id, before_layer_id)" +) +internal external fun mln_map_add_color_relief_layer( + map: Long, + layer_id: Int, + source_id: Int, + before_layer_id: Int, +): Int + +@JsFun( + "(map, source_id, options) => globalThis.__maplibreNativeC._mln_map_add_custom_geometry_source(map, source_id, options)" +) +internal external fun mln_map_add_custom_geometry_source( + map: Long, + source_id: Int, + options: Int, +): Int + +@JsFun( + "(map, source_id, data, options) => globalThis.__maplibreNativeC._mln_map_add_geojson_source_data(map, source_id, data, options)" +) +internal external fun mln_map_add_geojson_source_data( + map: Long, + source_id: Int, + data: Int, + options: Int, +): Int + +@JsFun( + "(map, source_id, url, options) => globalThis.__maplibreNativeC._mln_map_add_geojson_source_url(map, source_id, url, options)" +) +internal external fun mln_map_add_geojson_source_url( + map: Long, + source_id: Int, + url: Int, + options: Int, +): Int + +@JsFun( + "(map, layer_id, source_id, before_layer_id) => globalThis.__maplibreNativeC._mln_map_add_hillshade_layer(map, layer_id, source_id, before_layer_id)" +) +internal external fun mln_map_add_hillshade_layer( + map: Long, + layer_id: Int, + source_id: Int, + before_layer_id: Int, +): Int + +@JsFun( + "(map, source_id, coordinates, coordinate_count, image) => globalThis.__maplibreNativeC._mln_map_add_image_source_image(map, source_id, coordinates, coordinate_count, image)" +) +internal external fun mln_map_add_image_source_image( + map: Long, + source_id: Int, + coordinates: Int, + coordinate_count: Int, + image: Int, +): Int + +@JsFun( + "(map, source_id, coordinates, coordinate_count, url) => globalThis.__maplibreNativeC._mln_map_add_image_source_url(map, source_id, coordinates, coordinate_count, url)" +) +internal external fun mln_map_add_image_source_url( + map: Long, + source_id: Int, + coordinates: Int, + coordinate_count: Int, + url: Int, +): Int + +@JsFun( + "(map, layer_id, before_layer_id) => globalThis.__maplibreNativeC._mln_map_add_location_indicator_layer(map, layer_id, before_layer_id)" +) +internal external fun mln_map_add_location_indicator_layer( + map: Long, + layer_id: Int, + before_layer_id: Int, +): Int + +@JsFun( + "(map, source_id, tiles, tile_count, options) => globalThis.__maplibreNativeC._mln_map_add_raster_dem_source_tiles(map, source_id, tiles, tile_count, options)" +) +internal external fun mln_map_add_raster_dem_source_tiles( + map: Long, + source_id: Int, + tiles: Int, + tile_count: Int, + options: Int, +): Int + +@JsFun( + "(map, source_id, url, options) => globalThis.__maplibreNativeC._mln_map_add_raster_dem_source_url(map, source_id, url, options)" +) +internal external fun mln_map_add_raster_dem_source_url( + map: Long, + source_id: Int, + url: Int, + options: Int, +): Int + +@JsFun( + "(map, source_id, tiles, tile_count, options) => globalThis.__maplibreNativeC._mln_map_add_raster_source_tiles(map, source_id, tiles, tile_count, options)" +) +internal external fun mln_map_add_raster_source_tiles( + map: Long, + source_id: Int, + tiles: Int, + tile_count: Int, + options: Int, +): Int + +@JsFun( + "(map, source_id, url, options) => globalThis.__maplibreNativeC._mln_map_add_raster_source_url(map, source_id, url, options)" +) +internal external fun mln_map_add_raster_source_url( + map: Long, + source_id: Int, + url: Int, + options: Int, +): Int + +@JsFun( + "(map, layer_json, before_layer_id) => globalThis.__maplibreNativeC._mln_map_add_style_layer_json(map, layer_json, before_layer_id)" +) +internal external fun mln_map_add_style_layer_json( + map: Long, + layer_json: Int, + before_layer_id: Int, +): Int + +@JsFun( + "(map, source_id, source_json) => globalThis.__maplibreNativeC._mln_map_add_style_source_json(map, source_id, source_json)" +) +internal external fun mln_map_add_style_source_json( + map: Long, + source_id: Int, + source_json: Int, +): Int + +@JsFun( + "(map, source_id, tiles, tile_count, options) => globalThis.__maplibreNativeC._mln_map_add_vector_source_tiles(map, source_id, tiles, tile_count, options)" +) +internal external fun mln_map_add_vector_source_tiles( + map: Long, + source_id: Int, + tiles: Int, + tile_count: Int, + options: Int, +): Int + +@JsFun( + "(map, source_id, url, options) => globalThis.__maplibreNativeC._mln_map_add_vector_source_url(map, source_id, url, options)" +) +internal external fun mln_map_add_vector_source_url( + map: Long, + source_id: Int, + url: Int, + options: Int, +): Int + +@JsFun( + "(map, geometry, fit_options, out_camera) => globalThis.__maplibreNativeC._mln_map_camera_for_geometry(map, geometry, fit_options, out_camera)" +) +internal external fun mln_map_camera_for_geometry( + map: Long, + geometry: Int, + fit_options: Int, + out_camera: Int, +): Int + +@JsFun( + "(map, bounds, fit_options, out_camera) => globalThis.__maplibreNativeC._mln_map_camera_for_lat_lng_bounds(map, bounds, fit_options, out_camera)" +) +internal external fun mln_map_camera_for_lat_lng_bounds( + map: Long, + bounds: Int, + fit_options: Int, + out_camera: Int, +): Int + +@JsFun( + "(map, coordinates, coordinate_count, fit_options, out_camera) => globalThis.__maplibreNativeC._mln_map_camera_for_lat_lngs(map, coordinates, coordinate_count, fit_options, out_camera)" +) +internal external fun mln_map_camera_for_lat_lngs( + map: Long, + coordinates: Int, + coordinate_count: Int, + fit_options: Int, + out_camera: Int, +): Int + +@JsFun("(map) => globalThis.__maplibreNativeC._mln_map_cancel_transitions(map)") +internal external fun mln_map_cancel_transitions(map: Long): Int + +@JsFun( + "(map, layer_id, out_source_id, source_id_capacity, out_source_id_size) => globalThis.__maplibreNativeC._mln_map_copy_layer_source_id(map, layer_id, out_source_id, source_id_capacity, out_source_id_size)" +) +internal external fun mln_map_copy_layer_source_id( + map: Long, + layer_id: Int, + out_source_id: Int, + source_id_capacity: Int, + out_source_id_size: Int, +): Int + +@JsFun( + "(map, layer_id, out_source_layer, source_layer_capacity, out_source_layer_size) => globalThis.__maplibreNativeC._mln_map_copy_layer_source_layer(map, layer_id, out_source_layer, source_layer_capacity, out_source_layer_size)" +) +internal external fun mln_map_copy_layer_source_layer( + map: Long, + layer_id: Int, + out_source_layer: Int, + source_layer_capacity: Int, + out_source_layer_size: Int, +): Int + +@JsFun( + "(map, out_json, json_capacity, out_json_size) => globalThis.__maplibreNativeC._mln_map_copy_loaded_style_json(map, out_json, json_capacity, out_json_size)" +) +internal external fun mln_map_copy_loaded_style_json( + map: Long, + out_json: Int, + json_capacity: Int, + out_json_size: Int, +): Int + +@JsFun( + "(map, image_id, out_pixels, pixel_capacity, out_byte_length, out_found) => globalThis.__maplibreNativeC._mln_map_copy_style_image_premultiplied_rgba8(map, image_id, out_pixels, pixel_capacity, out_byte_length, out_found)" +) +internal external fun mln_map_copy_style_image_premultiplied_rgba8( + map: Long, + image_id: Int, + out_pixels: Int, + pixel_capacity: Int, + out_byte_length: Int, + out_found: Int, +): Int + +@JsFun( + "(map, image_id, out_stretch_x, stretch_x_capacity, out_stretch_x_count, out_stretch_y, stretch_y_capacity, out_stretch_y_count, out_found) => globalThis.__maplibreNativeC._mln_map_copy_style_image_stretches(map, image_id, out_stretch_x, stretch_x_capacity, out_stretch_x_count, out_stretch_y, stretch_y_capacity, out_stretch_y_count, out_found)" +) +internal external fun mln_map_copy_style_image_stretches( + map: Long, + image_id: Int, + out_stretch_x: Int, + stretch_x_capacity: Int, + out_stretch_x_count: Int, + out_stretch_y: Int, + stretch_y_capacity: Int, + out_stretch_y_count: Int, + out_found: Int, +): Int + +@JsFun( + "(map, source_id, out_attribution, attribution_capacity, out_attribution_size, out_found) => globalThis.__maplibreNativeC._mln_map_copy_style_source_attribution(map, source_id, out_attribution, attribution_capacity, out_attribution_size, out_found)" +) +internal external fun mln_map_copy_style_source_attribution( + map: Long, + source_id: Int, + out_attribution: Int, + attribution_capacity: Int, + out_attribution_size: Int, + out_found: Int, +): Int + +@JsFun( + "(map, source_id, out_url, url_capacity, out_url_size, out_found) => globalThis.__maplibreNativeC._mln_map_copy_style_source_url(map, source_id, out_url, url_capacity, out_url_size, out_found)" +) +internal external fun mln_map_copy_style_source_url( + map: Long, + source_id: Int, + out_url: Int, + url_capacity: Int, + out_url_size: Int, + out_found: Int, +): Int + +@JsFun( + "(map, out_url, url_capacity, out_url_size) => globalThis.__maplibreNativeC._mln_map_copy_style_url(map, out_url, url_capacity, out_url_size)" +) +internal external fun mln_map_copy_style_url( + map: Long, + out_url: Int, + url_capacity: Int, + out_url_size: Int, +): Int + +@JsFun( + "(runtime, options, out_map) => globalThis.__maplibreNativeC._mln_map_create(runtime, options, out_map)" +) +internal external fun mln_map_create(runtime: Long, options: Int, out_map: Int): Int + +@JsFun("(map) => globalThis.__maplibreNativeC._mln_map_destroy(map)") +internal external fun mln_map_destroy(map: Long): Int + +@JsFun("(map) => globalThis.__maplibreNativeC._mln_map_dump_debug_logs(map)") +internal external fun mln_map_dump_debug_logs(map: Long): Int + +@JsFun( + "(map, camera, animation) => globalThis.__maplibreNativeC._mln_map_ease_to(map, camera, animation)" +) +internal external fun mln_map_ease_to(map: Long, camera: Int, animation: Int): Int + +@JsFun( + "(map, camera, animation) => globalThis.__maplibreNativeC._mln_map_fly_to(map, camera, animation)" +) +internal external fun mln_map_fly_to(map: Long, camera: Int, animation: Int): Int + +@JsFun("(map, out_options) => globalThis.__maplibreNativeC._mln_map_get_bounds(map, out_options)") +internal external fun mln_map_get_bounds(map: Long, out_options: Int): Int + +@JsFun("(map, out_camera) => globalThis.__maplibreNativeC._mln_map_get_camera(map, out_camera)") +internal external fun mln_map_get_camera(map: Long, out_camera: Int): Int + +@JsFun( + "(map, out_options) => globalThis.__maplibreNativeC._mln_map_get_debug_options(map, out_options)" +) +internal external fun mln_map_get_debug_options(map: Long, out_options: Int): Int + +@JsFun( + "(map, out_options) => globalThis.__maplibreNativeC._mln_map_get_free_camera_options(map, out_options)" +) +internal external fun mln_map_get_free_camera_options(map: Long, out_options: Int): Int + +@JsFun( + "(map, source_id, out_coordinates, coordinate_capacity, out_coordinate_count, out_found) => globalThis.__maplibreNativeC._mln_map_get_image_source_coordinates(map, source_id, out_coordinates, coordinate_capacity, out_coordinate_count, out_found)" +) +internal external fun mln_map_get_image_source_coordinates( + map: Long, + source_id: Int, + out_coordinates: Int, + coordinate_capacity: Int, + out_coordinate_count: Int, + out_found: Int, +): Int + +@JsFun( + "(map, layer_id, out_filter) => globalThis.__maplibreNativeC._mln_map_get_layer_filter(map, layer_id, out_filter)" +) +internal external fun mln_map_get_layer_filter(map: Long, layer_id: Int, out_filter: Int): Int + +@JsFun( + "(map, layer_id, out_max_zoom) => globalThis.__maplibreNativeC._mln_map_get_layer_max_zoom(map, layer_id, out_max_zoom)" +) +internal external fun mln_map_get_layer_max_zoom(map: Long, layer_id: Int, out_max_zoom: Int): Int + +@JsFun( + "(map, layer_id, out_min_zoom) => globalThis.__maplibreNativeC._mln_map_get_layer_min_zoom(map, layer_id, out_min_zoom)" +) +internal external fun mln_map_get_layer_min_zoom(map: Long, layer_id: Int, out_min_zoom: Int): Int + +@JsFun( + "(map, layer_id, property_name, out_value) => globalThis.__maplibreNativeC._mln_map_get_layer_property(map, layer_id, property_name, out_value)" +) +internal external fun mln_map_get_layer_property( + map: Long, + layer_id: Int, + property_name: Int, + out_value: Int, +): Int + +@JsFun( + "(map, layer_id, out_visibility) => globalThis.__maplibreNativeC._mln_map_get_layer_visibility(map, layer_id, out_visibility)" +) +internal external fun mln_map_get_layer_visibility( + map: Long, + layer_id: Int, + out_visibility: Int, +): Int + +@JsFun( + "(map, out_mode) => globalThis.__maplibreNativeC._mln_map_get_projection_mode(map, out_mode)" +) +internal external fun mln_map_get_projection_mode(map: Long, out_mode: Int): Int + +@JsFun( + "(map, out_enabled) => globalThis.__maplibreNativeC._mln_map_get_rendering_stats_view_enabled(map, out_enabled)" +) +internal external fun mln_map_get_rendering_stats_view_enabled(map: Long, out_enabled: Int): Int + +@JsFun( + "(map, out_width, out_height, out_scale_factor) => globalThis.__maplibreNativeC._mln_map_get_size(map, out_width, out_height, out_scale_factor)" +) +internal external fun mln_map_get_size( + map: Long, + out_width: Int, + out_height: Int, + out_scale_factor: Int, +): Int + +@JsFun( + "(map, image_id, out_info, out_found) => globalThis.__maplibreNativeC._mln_map_get_style_image_info(map, image_id, out_info, out_found)" +) +internal external fun mln_map_get_style_image_info( + map: Long, + image_id: Int, + out_info: Int, + out_found: Int, +): Int + +@JsFun( + "(map, layer_id, out_layer, out_found) => globalThis.__maplibreNativeC._mln_map_get_style_layer_json(map, layer_id, out_layer, out_found)" +) +internal external fun mln_map_get_style_layer_json( + map: Long, + layer_id: Int, + out_layer: Int, + out_found: Int, +): Int + +@JsFun( + "(map, layer_id, out_layer_type, out_found) => globalThis.__maplibreNativeC._mln_map_get_style_layer_type(map, layer_id, out_layer_type, out_found)" +) +internal external fun mln_map_get_style_layer_type( + map: Long, + layer_id: Int, + out_layer_type: Int, + out_found: Int, +): Int + +@JsFun( + "(map, property_name, out_value) => globalThis.__maplibreNativeC._mln_map_get_style_light_property(map, property_name, out_value)" +) +internal external fun mln_map_get_style_light_property( + map: Long, + property_name: Int, + out_value: Int, +): Int + +@JsFun( + "(map, source_id, out_info, out_found) => globalThis.__maplibreNativeC._mln_map_get_style_source_info(map, source_id, out_info, out_found)" +) +internal external fun mln_map_get_style_source_info( + map: Long, + source_id: Int, + out_info: Int, + out_found: Int, +): Int + +@JsFun( + "(map, source_id, out_tile_urls, out_found) => globalThis.__maplibreNativeC._mln_map_get_style_source_tile_urls(map, source_id, out_tile_urls, out_found)" +) +internal external fun mln_map_get_style_source_tile_urls( + map: Long, + source_id: Int, + out_tile_urls: Int, + out_found: Int, +): Int + +@JsFun( + "(map, source_id, out_source_type, out_found) => globalThis.__maplibreNativeC._mln_map_get_style_source_type(map, source_id, out_source_type, out_found)" +) +internal external fun mln_map_get_style_source_type( + map: Long, + source_id: Int, + out_source_type: Int, + out_found: Int, +): Int + +@JsFun( + "(map, out_options) => globalThis.__maplibreNativeC._mln_map_get_style_transition_options(map, out_options)" +) +internal external fun mln_map_get_style_transition_options(map: Long, out_options: Int): Int + +@JsFun( + "(map, out_options) => globalThis.__maplibreNativeC._mln_map_get_tile_options(map, out_options)" +) +internal external fun mln_map_get_tile_options(map: Long, out_options: Int): Int + +@JsFun( + "(map, out_options) => globalThis.__maplibreNativeC._mln_map_get_viewport_options(map, out_options)" +) +internal external fun mln_map_get_viewport_options(map: Long, out_options: Int): Int + +@JsFun( + "(map, source_id, bounds) => globalThis.__maplibreNativeC._mln_map_invalidate_custom_geometry_source_region(map, source_id, bounds)" +) +internal external fun mln_map_invalidate_custom_geometry_source_region( + map: Long, + source_id: Int, + bounds: Int, +): Int + +@JsFun( + "(map, source_id, tile_id) => globalThis.__maplibreNativeC._mln_map_invalidate_custom_geometry_source_tile(map, source_id, tile_id)" +) +internal external fun mln_map_invalidate_custom_geometry_source_tile( + map: Long, + source_id: Int, + tile_id: Int, +): Int + +@JsFun( + "(map, out_loaded) => globalThis.__maplibreNativeC._mln_map_is_fully_loaded(map, out_loaded)" +) +internal external fun mln_map_is_fully_loaded(map: Long, out_loaded: Int): Int + +@JsFun( + "(map, out_in_progress) => globalThis.__maplibreNativeC._mln_map_is_gesture_in_progress(map, out_in_progress)" +) +internal external fun mln_map_is_gesture_in_progress(map: Long, out_in_progress: Int): Int + +@JsFun("(map, camera) => globalThis.__maplibreNativeC._mln_map_jump_to(map, camera)") +internal external fun mln_map_jump_to(map: Long, camera: Int): Int + +@JsFun( + "(map, camera, out_bounds) => globalThis.__maplibreNativeC._mln_map_lat_lng_bounds_for_camera(map, camera, out_bounds)" +) +internal external fun mln_map_lat_lng_bounds_for_camera( + map: Long, + camera: Int, + out_bounds: Int, +): Int + +@JsFun( + "(map, camera, out_bounds) => globalThis.__maplibreNativeC._mln_map_lat_lng_bounds_for_camera_unwrapped(map, camera, out_bounds)" +) +internal external fun mln_map_lat_lng_bounds_for_camera_unwrapped( + map: Long, + camera: Int, + out_bounds: Int, +): Int + +@JsFun( + "(map, point, out_coordinate) => globalThis.__maplibreNativeC._mln_map_lat_lng_for_pixel(map, point, out_coordinate)" +) +internal external fun mln_map_lat_lng_for_pixel(map: Long, point: Int, out_coordinate: Int): Int + +@JsFun( + "(map, points, point_count, out_coordinates) => globalThis.__maplibreNativeC._mln_map_lat_lngs_for_pixels(map, points, point_count, out_coordinates)" +) +internal external fun mln_map_lat_lngs_for_pixels( + map: Long, + points: Int, + point_count: Int, + out_coordinates: Int, +): Int + +@JsFun( + "(map, out_layer_ids) => globalThis.__maplibreNativeC._mln_map_list_style_layer_ids(map, out_layer_ids)" +) +internal external fun mln_map_list_style_layer_ids(map: Long, out_layer_ids: Int): Int + +@JsFun( + "(map, out_source_ids) => globalThis.__maplibreNativeC._mln_map_list_style_source_ids(map, out_source_ids)" +) +internal external fun mln_map_list_style_source_ids(map: Long, out_source_ids: Int): Int + +@JsFun( + "(map, delta_x, delta_y) => globalThis.__maplibreNativeC._mln_map_move_by(map, delta_x, delta_y)" +) +internal external fun mln_map_move_by(map: Long, delta_x: Double, delta_y: Double): Int + +@JsFun( + "(map, delta_x, delta_y, animation) => globalThis.__maplibreNativeC._mln_map_move_by_animated(map, delta_x, delta_y, animation)" +) +internal external fun mln_map_move_by_animated( + map: Long, + delta_x: Double, + delta_y: Double, + animation: Int, +): Int + +@JsFun( + "(map, layer_id, before_layer_id) => globalThis.__maplibreNativeC._mln_map_move_style_layer(map, layer_id, before_layer_id)" +) +internal external fun mln_map_move_style_layer(map: Long, layer_id: Int, before_layer_id: Int): Int + +@JsFun("(out_return) => { globalThis.__maplibreNativeC._mln_map_options_default(out_return) }") +internal external fun mln_map_options_default(out_return: Int) + +@JsFun("(map, pitch) => globalThis.__maplibreNativeC._mln_map_pitch_by(map, pitch)") +internal external fun mln_map_pitch_by(map: Long, pitch: Double): Int + +@JsFun( + "(map, pitch, animation) => globalThis.__maplibreNativeC._mln_map_pitch_by_animated(map, pitch, animation)" +) +internal external fun mln_map_pitch_by_animated(map: Long, pitch: Double, animation: Int): Int + +@JsFun( + "(map, coordinate, out_point) => globalThis.__maplibreNativeC._mln_map_pixel_for_lat_lng(map, coordinate, out_point)" +) +internal external fun mln_map_pixel_for_lat_lng(map: Long, coordinate: Int, out_point: Int): Int + +@JsFun( + "(map, coordinates, coordinate_count, out_points) => globalThis.__maplibreNativeC._mln_map_pixels_for_lat_lngs(map, coordinates, coordinate_count, out_points)" +) +internal external fun mln_map_pixels_for_lat_lngs( + map: Long, + coordinates: Int, + coordinate_count: Int, + out_points: Int, +): Int + +@JsFun( + "(map, out_projection) => globalThis.__maplibreNativeC._mln_map_projection_create(map, out_projection)" +) +internal external fun mln_map_projection_create(map: Long, out_projection: Int): Int + +@JsFun("(projection) => globalThis.__maplibreNativeC._mln_map_projection_destroy(projection)") +internal external fun mln_map_projection_destroy(projection: Long): Int + +@JsFun( + "(projection, out_camera) => globalThis.__maplibreNativeC._mln_map_projection_get_camera(projection, out_camera)" +) +internal external fun mln_map_projection_get_camera(projection: Long, out_camera: Int): Int + +@JsFun( + "(projection, point, out_coordinate) => globalThis.__maplibreNativeC._mln_map_projection_lat_lng_for_pixel(projection, point, out_coordinate)" +) +internal external fun mln_map_projection_lat_lng_for_pixel( + projection: Long, + point: Int, + out_coordinate: Int, +): Int + +@JsFun( + "(projection, coordinate, out_point) => globalThis.__maplibreNativeC._mln_map_projection_pixel_for_lat_lng(projection, coordinate, out_point)" +) +internal external fun mln_map_projection_pixel_for_lat_lng( + projection: Long, + coordinate: Int, + out_point: Int, +): Int + +@JsFun( + "(projection, camera) => globalThis.__maplibreNativeC._mln_map_projection_set_camera(projection, camera)" +) +internal external fun mln_map_projection_set_camera(projection: Long, camera: Int): Int + +@JsFun( + "(projection, coordinates, coordinate_count, padding) => globalThis.__maplibreNativeC._mln_map_projection_set_visible_coordinates(projection, coordinates, coordinate_count, padding)" +) +internal external fun mln_map_projection_set_visible_coordinates( + projection: Long, + coordinates: Int, + coordinate_count: Int, + padding: Int, +): Int + +@JsFun( + "(projection, geometry, padding) => globalThis.__maplibreNativeC._mln_map_projection_set_visible_geometry(projection, geometry, padding)" +) +internal external fun mln_map_projection_set_visible_geometry( + projection: Long, + geometry: Int, + padding: Int, +): Int + +@JsFun( + "(map, image_id, out_removed) => globalThis.__maplibreNativeC._mln_map_remove_style_image(map, image_id, out_removed)" +) +internal external fun mln_map_remove_style_image(map: Long, image_id: Int, out_removed: Int): Int + +@JsFun( + "(map, layer_id, out_removed) => globalThis.__maplibreNativeC._mln_map_remove_style_layer(map, layer_id, out_removed)" +) +internal external fun mln_map_remove_style_layer(map: Long, layer_id: Int, out_removed: Int): Int + +@JsFun( + "(map, source_id, out_removed) => globalThis.__maplibreNativeC._mln_map_remove_style_source(map, source_id, out_removed)" +) +internal external fun mln_map_remove_style_source(map: Long, source_id: Int, out_removed: Int): Int + +@JsFun("(map) => globalThis.__maplibreNativeC._mln_map_request_repaint(map)") +internal external fun mln_map_request_repaint(map: Long): Int + +@JsFun("(map) => globalThis.__maplibreNativeC._mln_map_request_still_image(map)") +internal external fun mln_map_request_still_image(map: Long): Int + +@JsFun( + "(map, first, second) => globalThis.__maplibreNativeC._mln_map_rotate_by(map, first, second)" +) +internal external fun mln_map_rotate_by(map: Long, first: Int, second: Int): Int + +@JsFun( + "(map, first, second, animation) => globalThis.__maplibreNativeC._mln_map_rotate_by_animated(map, first, second, animation)" +) +internal external fun mln_map_rotate_by_animated( + map: Long, + first: Int, + second: Int, + animation: Int, +): Int + +@JsFun("(map, scale, anchor) => globalThis.__maplibreNativeC._mln_map_scale_by(map, scale, anchor)") +internal external fun mln_map_scale_by(map: Long, scale: Double, anchor: Int): Int + +@JsFun( + "(map, scale, anchor, animation) => globalThis.__maplibreNativeC._mln_map_scale_by_animated(map, scale, anchor, animation)" +) +internal external fun mln_map_scale_by_animated( + map: Long, + scale: Double, + anchor: Int, + animation: Int, +): Int + +@JsFun("(map, options) => globalThis.__maplibreNativeC._mln_map_set_bounds(map, options)") +internal external fun mln_map_set_bounds(map: Long, options: Int): Int + +@JsFun( + "(map, source_id, tile_id, data) => globalThis.__maplibreNativeC._mln_map_set_custom_geometry_source_tile_data(map, source_id, tile_id, data)" +) +internal external fun mln_map_set_custom_geometry_source_tile_data( + map: Long, + source_id: Int, + tile_id: Int, + data: Int, +): Int + +@JsFun("(map, options) => globalThis.__maplibreNativeC._mln_map_set_debug_options(map, options)") +internal external fun mln_map_set_debug_options(map: Long, options: Int): Int + +@JsFun( + "(map, options) => globalThis.__maplibreNativeC._mln_map_set_free_camera_options(map, options)" +) +internal external fun mln_map_set_free_camera_options(map: Long, options: Int): Int + +@JsFun( + "(map, source_id, data) => globalThis.__maplibreNativeC._mln_map_set_geojson_source_data(map, source_id, data)" +) +internal external fun mln_map_set_geojson_source_data(map: Long, source_id: Int, data: Int): Int + +@JsFun( + "(map, source_id, url) => globalThis.__maplibreNativeC._mln_map_set_geojson_source_url(map, source_id, url)" +) +internal external fun mln_map_set_geojson_source_url(map: Long, source_id: Int, url: Int): Int + +@JsFun( + "(map, in_progress) => globalThis.__maplibreNativeC._mln_map_set_gesture_in_progress(map, in_progress)" +) +internal external fun mln_map_set_gesture_in_progress(map: Long, in_progress: Int): Int + +@JsFun( + "(map, source_id, coordinates, coordinate_count) => globalThis.__maplibreNativeC._mln_map_set_image_source_coordinates(map, source_id, coordinates, coordinate_count)" +) +internal external fun mln_map_set_image_source_coordinates( + map: Long, + source_id: Int, + coordinates: Int, + coordinate_count: Int, +): Int + +@JsFun( + "(map, source_id, image) => globalThis.__maplibreNativeC._mln_map_set_image_source_image(map, source_id, image)" +) +internal external fun mln_map_set_image_source_image(map: Long, source_id: Int, image: Int): Int + +@JsFun( + "(map, source_id, url) => globalThis.__maplibreNativeC._mln_map_set_image_source_url(map, source_id, url)" +) +internal external fun mln_map_set_image_source_url(map: Long, source_id: Int, url: Int): Int + +@JsFun( + "(map, layer_id, filter) => globalThis.__maplibreNativeC._mln_map_set_layer_filter(map, layer_id, filter)" +) +internal external fun mln_map_set_layer_filter(map: Long, layer_id: Int, filter: Int): Int + +@JsFun( + "(map, layer_id, max_zoom) => globalThis.__maplibreNativeC._mln_map_set_layer_max_zoom(map, layer_id, max_zoom)" +) +internal external fun mln_map_set_layer_max_zoom(map: Long, layer_id: Int, max_zoom: Double): Int + +@JsFun( + "(map, layer_id, min_zoom) => globalThis.__maplibreNativeC._mln_map_set_layer_min_zoom(map, layer_id, min_zoom)" +) +internal external fun mln_map_set_layer_min_zoom(map: Long, layer_id: Int, min_zoom: Double): Int + +@JsFun( + "(map, layer_id, property_name, value) => globalThis.__maplibreNativeC._mln_map_set_layer_property(map, layer_id, property_name, value)" +) +internal external fun mln_map_set_layer_property( + map: Long, + layer_id: Int, + property_name: Int, + value: Int, +): Int + +@JsFun( + "(map, layer_id, source_id) => globalThis.__maplibreNativeC._mln_map_set_layer_source_id(map, layer_id, source_id)" +) +internal external fun mln_map_set_layer_source_id(map: Long, layer_id: Int, source_id: Int): Int + +@JsFun( + "(map, layer_id, source_layer) => globalThis.__maplibreNativeC._mln_map_set_layer_source_layer(map, layer_id, source_layer)" +) +internal external fun mln_map_set_layer_source_layer( + map: Long, + layer_id: Int, + source_layer: Int, +): Int + +@JsFun( + "(map, layer_id, visibility) => globalThis.__maplibreNativeC._mln_map_set_layer_visibility(map, layer_id, visibility)" +) +internal external fun mln_map_set_layer_visibility(map: Long, layer_id: Int, visibility: Int): Int + +@JsFun( + "(map, layer_id, radius) => globalThis.__maplibreNativeC._mln_map_set_location_indicator_accuracy_radius(map, layer_id, radius)" +) +internal external fun mln_map_set_location_indicator_accuracy_radius( + map: Long, + layer_id: Int, + radius: Double, +): Int + +@JsFun( + "(map, layer_id, bearing) => globalThis.__maplibreNativeC._mln_map_set_location_indicator_bearing(map, layer_id, bearing)" +) +internal external fun mln_map_set_location_indicator_bearing( + map: Long, + layer_id: Int, + bearing: Double, +): Int + +@JsFun( + "(map, layer_id, image_kind, image_id) => globalThis.__maplibreNativeC._mln_map_set_location_indicator_image_name(map, layer_id, image_kind, image_id)" +) +internal external fun mln_map_set_location_indicator_image_name( + map: Long, + layer_id: Int, + image_kind: Int, + image_id: Int, +): Int + +@JsFun( + "(map, layer_id, coordinate, altitude) => globalThis.__maplibreNativeC._mln_map_set_location_indicator_location(map, layer_id, coordinate, altitude)" +) +internal external fun mln_map_set_location_indicator_location( + map: Long, + layer_id: Int, + coordinate: Int, + altitude: Double, +): Int + +@JsFun("(map, mode) => globalThis.__maplibreNativeC._mln_map_set_projection_mode(map, mode)") +internal external fun mln_map_set_projection_mode(map: Long, mode: Int): Int + +@JsFun( + "(map, enabled) => globalThis.__maplibreNativeC._mln_map_set_rendering_stats_view_enabled(map, enabled)" +) +internal external fun mln_map_set_rendering_stats_view_enabled(map: Long, enabled: Int): Int + +@JsFun( + "(map, image_id, image, options) => globalThis.__maplibreNativeC._mln_map_set_style_image(map, image_id, image, options)" +) +internal external fun mln_map_set_style_image( + map: Long, + image_id: Int, + image: Int, + options: Int, +): Int + +@JsFun("(map, json) => globalThis.__maplibreNativeC._mln_map_set_style_json(map, json)") +internal external fun mln_map_set_style_json(map: Long, json: Int): Int + +@JsFun( + "(map, light_json) => globalThis.__maplibreNativeC._mln_map_set_style_light_json(map, light_json)" +) +internal external fun mln_map_set_style_light_json(map: Long, light_json: Int): Int + +@JsFun( + "(map, property_name, value) => globalThis.__maplibreNativeC._mln_map_set_style_light_property(map, property_name, value)" +) +internal external fun mln_map_set_style_light_property( + map: Long, + property_name: Int, + value: Int, +): Int + +@JsFun( + "(map, options) => globalThis.__maplibreNativeC._mln_map_set_style_transition_options(map, options)" +) +internal external fun mln_map_set_style_transition_options(map: Long, options: Int): Int + +@JsFun("(map, url) => globalThis.__maplibreNativeC._mln_map_set_style_url(map, url)") +internal external fun mln_map_set_style_url(map: Long, url: Int): Int + +@JsFun("(map, options) => globalThis.__maplibreNativeC._mln_map_set_tile_options(map, options)") +internal external fun mln_map_set_tile_options(map: Long, options: Int): Int + +@JsFun("(map, options) => globalThis.__maplibreNativeC._mln_map_set_viewport_options(map, options)") +internal external fun mln_map_set_viewport_options(map: Long, options: Int): Int + +@JsFun( + "(map, image_id, out_exists) => globalThis.__maplibreNativeC._mln_map_style_image_exists(map, image_id, out_exists)" +) +internal external fun mln_map_style_image_exists(map: Long, image_id: Int, out_exists: Int): Int + +@JsFun( + "(map, layer_id, out_exists) => globalThis.__maplibreNativeC._mln_map_style_layer_exists(map, layer_id, out_exists)" +) +internal external fun mln_map_style_layer_exists(map: Long, layer_id: Int, out_exists: Int): Int + +@JsFun( + "(map, source_id, out_exists) => globalThis.__maplibreNativeC._mln_map_style_source_exists(map, source_id, out_exists)" +) +internal external fun mln_map_style_source_exists(map: Long, source_id: Int, out_exists: Int): Int + +@JsFun("(out_status) => globalThis.__maplibreNativeC._mln_network_status_get(out_status)") +internal external fun mln_network_status_get(out_status: Int): Int + +@JsFun("(status) => globalThis.__maplibreNativeC._mln_network_status_set(status)") +internal external fun mln_network_status_set(status: Int): Int + +@JsFun( + "(list, out_count) => globalThis.__maplibreNativeC._mln_offline_region_list_count(list, out_count)" +) +internal external fun mln_offline_region_list_count(list: Long, out_count: Int): Int + +@JsFun("(list) => { globalThis.__maplibreNativeC._mln_offline_region_list_destroy(list) }") +internal external fun mln_offline_region_list_destroy(list: Long) + +@JsFun( + "(list, index, out_info) => globalThis.__maplibreNativeC._mln_offline_region_list_get(list, index, out_info)" +) +internal external fun mln_offline_region_list_get(list: Long, index: Int, out_info: Int): Int + +@JsFun( + "(snapshot) => { globalThis.__maplibreNativeC._mln_offline_region_snapshot_destroy(snapshot) }" +) +internal external fun mln_offline_region_snapshot_destroy(snapshot: Long) + +@JsFun( + "(snapshot, out_info) => globalThis.__maplibreNativeC._mln_offline_region_snapshot_get(snapshot, out_info)" +) +internal external fun mln_offline_region_snapshot_get(snapshot: Long, out_info: Int): Int + +@JsFun( + "(map, descriptor, out_session) => globalThis.__maplibreNativeC._mln_opengl_borrowed_texture_attach(map, descriptor, out_session)" +) +internal external fun mln_opengl_borrowed_texture_attach( + map: Long, + descriptor: Int, + out_session: Int, +): Int + +@JsFun( + "(session, descriptor) => globalThis.__maplibreNativeC._mln_opengl_borrowed_texture_set_target(session, descriptor)" +) +internal external fun mln_opengl_borrowed_texture_set_target(session: Long, descriptor: Int): Int + +@JsFun( + "(session, out_frame) => globalThis.__maplibreNativeC._mln_opengl_owned_texture_acquire_frame(session, out_frame)" +) +internal external fun mln_opengl_owned_texture_acquire_frame(session: Long, out_frame: Int): Int + +@JsFun( + "(map, descriptor, out_session) => globalThis.__maplibreNativeC._mln_opengl_owned_texture_attach(map, descriptor, out_session)" +) +internal external fun mln_opengl_owned_texture_attach( + map: Long, + descriptor: Int, + out_session: Int, +): Int + +@JsFun( + "(session, frame) => globalThis.__maplibreNativeC._mln_opengl_owned_texture_release_frame(session, frame)" +) +internal external fun mln_opengl_owned_texture_release_frame(session: Long, frame: Int): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_opengl_supported_context_provider_mask()") +internal external fun mln_opengl_supported_context_provider_mask(): Int + +@JsFun( + "(map, descriptor, out_session) => globalThis.__maplibreNativeC._mln_opengl_surface_attach(map, descriptor, out_session)" +) +internal external fun mln_opengl_surface_attach(map: Long, descriptor: Int, out_session: Int): Int + +@JsFun( + "(session, descriptor) => globalThis.__maplibreNativeC._mln_opengl_surface_set_target(session, descriptor)" +) +internal external fun mln_opengl_surface_set_target(session: Long, descriptor: Int): Int + +@JsFun( + "(coordinate, out_meters) => globalThis.__maplibreNativeC._mln_projected_meters_for_lat_lng(coordinate, out_meters)" +) +internal external fun mln_projected_meters_for_lat_lng(coordinate: Int, out_meters: Int): Int + +@JsFun("(session) => globalThis.__maplibreNativeC._mln_render_session_clear_data(session)") +internal external fun mln_render_session_clear_data(session: Long): Int + +@JsFun("(session) => globalThis.__maplibreNativeC._mln_render_session_destroy(session)") +internal external fun mln_render_session_destroy(session: Long): Int + +@JsFun("(session) => globalThis.__maplibreNativeC._mln_render_session_detach(session)") +internal external fun mln_render_session_detach(session: Long): Int + +@JsFun("(session) => globalThis.__maplibreNativeC._mln_render_session_dump_debug_logs(session)") +internal external fun mln_render_session_dump_debug_logs(session: Long): Int + +@JsFun( + "(session, selector, out_state) => globalThis.__maplibreNativeC._mln_render_session_get_feature_state(session, selector, out_state)" +) +internal external fun mln_render_session_get_feature_state( + session: Long, + selector: Int, + out_state: Int, +): Int + +@JsFun( + "(session, source_id, feature, extension, extension_field, arguments_, out_result) => globalThis.__maplibreNativeC._mln_render_session_query_feature_extensions(session, source_id, feature, extension, extension_field, arguments_, out_result)" +) +internal external fun mln_render_session_query_feature_extensions( + session: Long, + source_id: Int, + feature: Int, + extension: Int, + extension_field: Int, + arguments_: Int, + out_result: Int, +): Int + +@JsFun( + "(session, geometry, options, out_result) => globalThis.__maplibreNativeC._mln_render_session_query_rendered_features(session, geometry, options, out_result)" +) +internal external fun mln_render_session_query_rendered_features( + session: Long, + geometry: Int, + options: Int, + out_result: Int, +): Int + +@JsFun( + "(session, source_id, options, out_result) => globalThis.__maplibreNativeC._mln_render_session_query_source_features(session, source_id, options, out_result)" +) +internal external fun mln_render_session_query_source_features( + session: Long, + source_id: Int, + options: Int, + out_result: Int, +): Int + +@JsFun("(session) => globalThis.__maplibreNativeC._mln_render_session_reduce_memory_use(session)") +internal external fun mln_render_session_reduce_memory_use(session: Long): Int + +@JsFun( + "(session, selector) => globalThis.__maplibreNativeC._mln_render_session_remove_feature_state(session, selector)" +) +internal external fun mln_render_session_remove_feature_state(session: Long, selector: Int): Int + +@JsFun( + "(session, out_rendered) => globalThis.__maplibreNativeC._mln_render_session_render_update(session, out_rendered)" +) +internal external fun mln_render_session_render_update(session: Long, out_rendered: Int): Int + +@JsFun( + "(session, width, height, scale_factor) => globalThis.__maplibreNativeC._mln_render_session_resize(session, width, height, scale_factor)" +) +internal external fun mln_render_session_resize( + session: Long, + width: Int, + height: Int, + scale_factor: Double, +): Int + +@JsFun( + "(session, selector, state) => globalThis.__maplibreNativeC._mln_render_session_set_feature_state(session, selector, state)" +) +internal external fun mln_render_session_set_feature_state( + session: Long, + selector: Int, + state: Int, +): Int + +@JsFun( + "(extent, out_width, out_height) => globalThis.__maplibreNativeC._mln_render_target_extent_physical_size(extent, out_width, out_height)" +) +internal external fun mln_render_target_extent_physical_size( + extent: Int, + out_width: Int, + out_height: Int, +): Int + +@JsFun( + "(handle, out_cancelled) => globalThis.__maplibreNativeC._mln_resource_request_cancelled(handle, out_cancelled)" +) +internal external fun mln_resource_request_cancelled(handle: Long, out_cancelled: Int): Int + +@JsFun( + "(handle, response) => globalThis.__maplibreNativeC._mln_resource_request_complete(handle, response)" +) +internal external fun mln_resource_request_complete(handle: Long, response: Int): Int + +@JsFun("(handle) => { globalThis.__maplibreNativeC._mln_resource_request_release(handle) }") +internal external fun mln_resource_request_release(handle: Long) + +@JsFun( + "(runtime) => globalThis.__maplibreNativeC._mln_runtime_clear_http_header_transform(runtime)" +) +internal external fun mln_runtime_clear_http_header_transform(runtime: Long): Int + +@JsFun("(runtime) => globalThis.__maplibreNativeC._mln_runtime_clear_resource_provider(runtime)") +internal external fun mln_runtime_clear_resource_provider(runtime: Long): Int + +@JsFun("(runtime) => globalThis.__maplibreNativeC._mln_runtime_clear_resource_transform(runtime)") +internal external fun mln_runtime_clear_resource_transform(runtime: Long): Int + +@JsFun( + "(options, out_runtime) => globalThis.__maplibreNativeC._mln_runtime_create(options, out_runtime)" +) +internal external fun mln_runtime_create(options: Int, out_runtime: Int): Int + +@JsFun("(runtime) => globalThis.__maplibreNativeC._mln_runtime_destroy(runtime)") +internal external fun mln_runtime_destroy(runtime: Long): Int + +@JsFun( + "(runtime, operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_operation_discard(runtime, operation_id)" +) +internal external fun mln_runtime_offline_operation_discard(runtime: Long, operation_id: Long): Int + +@JsFun( + "(runtime, definition, metadata, metadata_size, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_create_start(runtime, definition, metadata, metadata_size, out_operation_id)" +) +internal external fun mln_runtime_offline_region_create_start( + runtime: Long, + definition: Int, + metadata: Int, + metadata_size: Int, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, operation_id, out_region) => globalThis.__maplibreNativeC._mln_runtime_offline_region_create_take_result(runtime, operation_id, out_region)" +) +internal external fun mln_runtime_offline_region_create_take_result( + runtime: Long, + operation_id: Long, + out_region: Int, +): Int + +@JsFun( + "(runtime, region_id, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_delete_start(runtime, region_id, out_operation_id)" +) +internal external fun mln_runtime_offline_region_delete_start( + runtime: Long, + region_id: Long, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, region_id, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_get_start(runtime, region_id, out_operation_id)" +) +internal external fun mln_runtime_offline_region_get_start( + runtime: Long, + region_id: Long, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, region_id, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_get_status_start(runtime, region_id, out_operation_id)" +) +internal external fun mln_runtime_offline_region_get_status_start( + runtime: Long, + region_id: Long, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, operation_id, out_status) => globalThis.__maplibreNativeC._mln_runtime_offline_region_get_status_take_result(runtime, operation_id, out_status)" +) +internal external fun mln_runtime_offline_region_get_status_take_result( + runtime: Long, + operation_id: Long, + out_status: Int, +): Int + +@JsFun( + "(runtime, operation_id, out_region, out_found) => globalThis.__maplibreNativeC._mln_runtime_offline_region_get_take_result(runtime, operation_id, out_region, out_found)" +) +internal external fun mln_runtime_offline_region_get_take_result( + runtime: Long, + operation_id: Long, + out_region: Int, + out_found: Int, +): Int + +@JsFun( + "(runtime, region_id, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_invalidate_start(runtime, region_id, out_operation_id)" +) +internal external fun mln_runtime_offline_region_invalidate_start( + runtime: Long, + region_id: Long, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, region_id, state, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_set_download_state_start(runtime, region_id, state, out_operation_id)" +) +internal external fun mln_runtime_offline_region_set_download_state_start( + runtime: Long, + region_id: Long, + state: Int, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, region_id, observed, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_set_observed_start(runtime, region_id, observed, out_operation_id)" +) +internal external fun mln_runtime_offline_region_set_observed_start( + runtime: Long, + region_id: Long, + observed: Int, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, region_id, metadata, metadata_size, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_region_update_metadata_start(runtime, region_id, metadata, metadata_size, out_operation_id)" +) +internal external fun mln_runtime_offline_region_update_metadata_start( + runtime: Long, + region_id: Long, + metadata: Int, + metadata_size: Int, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, operation_id, out_region) => globalThis.__maplibreNativeC._mln_runtime_offline_region_update_metadata_take_result(runtime, operation_id, out_region)" +) +internal external fun mln_runtime_offline_region_update_metadata_take_result( + runtime: Long, + operation_id: Long, + out_region: Int, +): Int + +@JsFun( + "(runtime, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_regions_list_start(runtime, out_operation_id)" +) +internal external fun mln_runtime_offline_regions_list_start( + runtime: Long, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, operation_id, out_regions) => globalThis.__maplibreNativeC._mln_runtime_offline_regions_list_take_result(runtime, operation_id, out_regions)" +) +internal external fun mln_runtime_offline_regions_list_take_result( + runtime: Long, + operation_id: Long, + out_regions: Int, +): Int + +@JsFun( + "(runtime, side_database_path, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_offline_regions_merge_database_start(runtime, side_database_path, out_operation_id)" +) +internal external fun mln_runtime_offline_regions_merge_database_start( + runtime: Long, + side_database_path: Int, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, operation_id, out_regions) => globalThis.__maplibreNativeC._mln_runtime_offline_regions_merge_database_take_result(runtime, operation_id, out_regions)" +) +internal external fun mln_runtime_offline_regions_merge_database_take_result( + runtime: Long, + operation_id: Long, + out_regions: Int, +): Int + +@JsFun( + "(runtime, out_event, out_has_event) => globalThis.__maplibreNativeC._mln_runtime_poll_event(runtime, out_event, out_has_event)" +) +internal external fun mln_runtime_poll_event(runtime: Long, out_event: Int, out_has_event: Int): Int + +@JsFun( + "(runtime, timeout_ms) => globalThis.__maplibreNativeC._mln_runtime_pump(runtime, timeout_ms)" +) +internal external fun mln_runtime_pump(runtime: Long, timeout_ms: Long): Int + +@JsFun( + "(runtime, operation, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_run_ambient_cache_operation_start(runtime, operation, out_operation_id)" +) +internal external fun mln_runtime_run_ambient_cache_operation_start( + runtime: Long, + operation: Int, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, size, out_operation_id) => globalThis.__maplibreNativeC._mln_runtime_set_maximum_ambient_cache_size_start(runtime, size, out_operation_id)" +) +internal external fun mln_runtime_set_maximum_ambient_cache_size_start( + runtime: Long, + size: Long, + out_operation_id: Int, +): Int + +@JsFun( + "(runtime, provider) => globalThis.__maplibreNativeC._mln_runtime_set_resource_provider(runtime, provider)" +) +internal external fun mln_runtime_set_resource_provider(runtime: Long, provider: Int): Int + +@JsFun( + "(runtime, transform) => globalThis.__maplibreNativeC._mln_runtime_set_resource_transform(runtime, transform)" +) +internal external fun mln_runtime_set_resource_transform(runtime: Long, transform: Int): Int + +@JsFun( + "(runtime, out_source) => globalThis.__maplibreNativeC._mln_runtime_wake_source_acquire(runtime, out_source)" +) +internal external fun mln_runtime_wake_source_acquire(runtime: Long, out_source: Int): Int + +@JsFun( + "(list, out_count) => globalThis.__maplibreNativeC._mln_style_id_list_count(list, out_count)" +) +internal external fun mln_style_id_list_count(list: Long, out_count: Int): Int + +@JsFun("(list) => { globalThis.__maplibreNativeC._mln_style_id_list_destroy(list) }") +internal external fun mln_style_id_list_destroy(list: Long) + +@JsFun( + "(list, index, out_id) => globalThis.__maplibreNativeC._mln_style_id_list_get(list, index, out_id)" +) +internal external fun mln_style_id_list_get(list: Long, index: Int, out_id: Int): Int + +@JsFun( + "(list, out_count) => globalThis.__maplibreNativeC._mln_style_string_list_count(list, out_count)" +) +internal external fun mln_style_string_list_count(list: Long, out_count: Int): Int + +@JsFun("(list) => { globalThis.__maplibreNativeC._mln_style_string_list_destroy(list) }") +internal external fun mln_style_string_list_destroy(list: Long) + +@JsFun( + "(list, index, out_value) => globalThis.__maplibreNativeC._mln_style_string_list_get(list, index, out_value)" +) +internal external fun mln_style_string_list_get(list: Long, index: Int, out_value: Int): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_supported_render_backend_mask()") +internal external fun mln_supported_render_backend_mask(): Int + +@JsFun( + "(session, out_data, out_data_capacity, out_info) => globalThis.__maplibreNativeC._mln_texture_read_premultiplied_rgba8(session, out_data, out_data_capacity, out_info)" +) +internal external fun mln_texture_read_premultiplied_rgba8( + session: Long, + out_data: Int, + out_data_capacity: Int, + out_info: Int, +): Int + +@JsFun("() => globalThis.__maplibreNativeC._mln_thread_last_error_message()") +internal external fun mln_thread_last_error_message(): Int + +@JsFun("(source) => { globalThis.__maplibreNativeC._mln_wake_source_destroy(source) }") +internal external fun mln_wake_source_destroy(source: Long) diff --git a/bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi/internal/wasm/generated/StructLayouts.kt b/bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi/internal/wasm/generated/StructLayouts.kt new file mode 100644 index 000000000..a8b2c8acb --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi/internal/wasm/generated/StructLayouts.kt @@ -0,0 +1,3307 @@ +// Generated by scripts/generate-wasm-struct-layouts.py. Edit the generator. +// +// Offsets and sizes are measured for wasm32-unknown-emscripten by the pinned +// Emscripten clang, and cover the descriptors this binding names. +// +// The accessors exist so that no hand-written code names a *field* offset. An +// offset alone says where four bytes are, not whether they hold an integer, an +// enum, or a pointer, and reading a descriptor at the wrong width is the failure +// this generated layer exists to prevent. Hand-written code still positions its +// own scratch -- an out-parameter placed after a descriptor, say -- and those +// offsets belong to the caller rather than to any C struct. + +package org.maplibre.nativeffi.internal.wasm.generated + +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer + +/** Values of `enum mln_adapter_resource_route_flags`. */ +internal object MlnAdapterResourceRouteFlags { + const val MLN_ADAPTER_RESOURCE_ROUTE_FLAGS_NONE: Int = 0 + const val MLN_ADAPTER_RESOURCE_ROUTE_MATCH_GLOB: Int = 1 + const val MLN_ADAPTER_RESOURCE_ROUTE_USE_REQUESTED_URL: Int = 2 +} + +/** Values of `enum mln_adapter_url_match_flags`. */ +internal object MlnAdapterUrlMatchFlags { + const val MLN_ADAPTER_URL_MATCH_FLAGS_NONE: Int = 0 + const val MLN_ADAPTER_URL_MATCH_GLOB: Int = 1 +} + +/** Values of `enum mln_animation_option_field`. */ +internal object MlnAnimationOptionField { + const val MLN_ANIMATION_OPTION_DURATION: Int = 1 + const val MLN_ANIMATION_OPTION_VELOCITY: Int = 2 + const val MLN_ANIMATION_OPTION_MIN_ZOOM: Int = 4 + const val MLN_ANIMATION_OPTION_EASING: Int = 8 + const val MLN_ANIMATION_OPTION_TRANSITION_ID: Int = 16 +} + +/** Values of `enum mln_bound_option_field`. */ +internal object MlnBoundOptionField { + const val MLN_BOUND_OPTION_BOUNDS: Int = 1 + const val MLN_BOUND_OPTION_MIN_ZOOM: Int = 2 + const val MLN_BOUND_OPTION_MAX_ZOOM: Int = 4 + const val MLN_BOUND_OPTION_MIN_PITCH: Int = 8 + const val MLN_BOUND_OPTION_MAX_PITCH: Int = 16 + const val MLN_BOUND_OPTION_UNBOUNDED: Int = 32 +} + +/** Values of `enum mln_camera_fit_option_field`. */ +internal object MlnCameraFitOptionField { + const val MLN_CAMERA_FIT_OPTION_PADDING: Int = 1 + const val MLN_CAMERA_FIT_OPTION_BEARING: Int = 2 + const val MLN_CAMERA_FIT_OPTION_PITCH: Int = 4 +} + +/** Values of `enum mln_camera_option_field`. */ +internal object MlnCameraOptionField { + const val MLN_CAMERA_OPTION_CENTER: Int = 1 + const val MLN_CAMERA_OPTION_ZOOM: Int = 2 + const val MLN_CAMERA_OPTION_BEARING: Int = 4 + const val MLN_CAMERA_OPTION_PITCH: Int = 8 + const val MLN_CAMERA_OPTION_CENTER_ALTITUDE: Int = 16 + const val MLN_CAMERA_OPTION_PADDING: Int = 32 + const val MLN_CAMERA_OPTION_ANCHOR: Int = 64 + const val MLN_CAMERA_OPTION_ROLL: Int = 128 + const val MLN_CAMERA_OPTION_FOV: Int = 256 +} + +/** Values of `enum mln_custom_geometry_source_option_field`. */ +internal object MlnCustomGeometrySourceOptionField { + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_MIN_ZOOM: Int = 1 + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_MAX_ZOOM: Int = 2 + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_TOLERANCE: Int = 4 + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_TILE_SIZE: Int = 8 + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_BUFFER: Int = 16 + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_CLIP: Int = 32 + const val MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_WRAP: Int = 64 +} + +/** Values of `enum mln_feature_extension_result_type`. */ +internal object MlnFeatureExtensionResultType { + const val MLN_FEATURE_EXTENSION_RESULT_TYPE_VALUE: Int = 1 + const val MLN_FEATURE_EXTENSION_RESULT_TYPE_FEATURE_COLLECTION: Int = 2 +} + +/** Values of `enum mln_feature_identifier_type`. */ +internal object MlnFeatureIdentifierType { + const val MLN_FEATURE_IDENTIFIER_TYPE_NULL: Int = 0 + const val MLN_FEATURE_IDENTIFIER_TYPE_UINT: Int = 1 + const val MLN_FEATURE_IDENTIFIER_TYPE_INT: Int = 2 + const val MLN_FEATURE_IDENTIFIER_TYPE_DOUBLE: Int = 3 + const val MLN_FEATURE_IDENTIFIER_TYPE_STRING: Int = 4 +} + +/** Values of `enum mln_feature_state_selector_field`. */ +internal object MlnFeatureStateSelectorField { + const val MLN_FEATURE_STATE_SELECTOR_SOURCE_LAYER_ID: Int = 1 + const val MLN_FEATURE_STATE_SELECTOR_FEATURE_ID: Int = 2 + const val MLN_FEATURE_STATE_SELECTOR_STATE_KEY: Int = 4 +} + +/** Values of `enum mln_free_camera_option_field`. */ +internal object MlnFreeCameraOptionField { + const val MLN_FREE_CAMERA_OPTION_POSITION: Int = 1 + const val MLN_FREE_CAMERA_OPTION_ORIENTATION: Int = 2 +} + +/** Values of `enum mln_geojson_source_option_field`. */ +internal object MlnGeojsonSourceOptionField { + const val MLN_GEOJSON_SOURCE_OPTION_MIN_ZOOM: Int = 1 + const val MLN_GEOJSON_SOURCE_OPTION_MAX_ZOOM: Int = 2 + const val MLN_GEOJSON_SOURCE_OPTION_TOLERANCE: Int = 4 + const val MLN_GEOJSON_SOURCE_OPTION_CLUSTER_MAX_ZOOM: Int = 8 + const val MLN_GEOJSON_SOURCE_OPTION_CLUSTER_PROPERTIES: Int = 16 + const val MLN_GEOJSON_SOURCE_OPTION_TILE_SIZE: Int = 32 + const val MLN_GEOJSON_SOURCE_OPTION_BUFFER: Int = 64 + const val MLN_GEOJSON_SOURCE_OPTION_CLUSTER_RADIUS: Int = 128 + const val MLN_GEOJSON_SOURCE_OPTION_CLUSTER_MIN_POINTS: Int = 256 + const val MLN_GEOJSON_SOURCE_OPTION_LINE_METRICS: Int = 512 + const val MLN_GEOJSON_SOURCE_OPTION_CLUSTER: Int = 1024 + const val MLN_GEOJSON_SOURCE_OPTION_SYNCHRONOUS_UPDATE: Int = 2048 +} + +/** Values of `enum mln_geojson_type`. */ +internal object MlnGeojsonType { + const val MLN_GEOJSON_TYPE_GEOMETRY: Int = 1 + const val MLN_GEOJSON_TYPE_FEATURE: Int = 2 + const val MLN_GEOJSON_TYPE_FEATURE_COLLECTION: Int = 3 +} + +/** Values of `enum mln_geometry_type`. */ +internal object MlnGeometryType { + const val MLN_GEOMETRY_TYPE_EMPTY: Int = 0 + const val MLN_GEOMETRY_TYPE_POINT: Int = 1 + const val MLN_GEOMETRY_TYPE_LINE_STRING: Int = 2 + const val MLN_GEOMETRY_TYPE_POLYGON: Int = 3 + const val MLN_GEOMETRY_TYPE_MULTI_POINT: Int = 4 + const val MLN_GEOMETRY_TYPE_MULTI_LINE_STRING: Int = 5 + const val MLN_GEOMETRY_TYPE_MULTI_POLYGON: Int = 6 + const val MLN_GEOMETRY_TYPE_GEOMETRY_COLLECTION: Int = 7 +} + +/** Values of `enum mln_json_value_type`. */ +internal object MlnJsonValueType { + const val MLN_JSON_VALUE_TYPE_NULL: Int = 0 + const val MLN_JSON_VALUE_TYPE_BOOL: Int = 1 + const val MLN_JSON_VALUE_TYPE_UINT: Int = 2 + const val MLN_JSON_VALUE_TYPE_INT: Int = 3 + const val MLN_JSON_VALUE_TYPE_DOUBLE: Int = 4 + const val MLN_JSON_VALUE_TYPE_STRING: Int = 5 + const val MLN_JSON_VALUE_TYPE_ARRAY: Int = 6 + const val MLN_JSON_VALUE_TYPE_OBJECT: Int = 7 +} + +/** Values of `enum mln_kotlin_record_kind`. */ +internal object MlnKotlinRecordKind { + const val MLN_KOTLIN_RECORD_LOG: Int = 1 + const val MLN_KOTLIN_RECORD_LOG_RETIRED: Int = 2 + const val MLN_KOTLIN_RECORD_RESOURCE_REQUEST: Int = 3 + const val MLN_KOTLIN_RECORD_RESOURCE_PROVIDER_RETIRED: Int = 4 + const val MLN_KOTLIN_RECORD_TILE_FETCH: Int = 5 + const val MLN_KOTLIN_RECORD_TILE_CANCEL: Int = 6 +} + +/** Values of `enum mln_map_tile_option_field`. */ +internal object MlnMapTileOptionField { + const val MLN_MAP_TILE_OPTION_PREFETCH_ZOOM_DELTA: Int = 1 + const val MLN_MAP_TILE_OPTION_LOD_MIN_RADIUS: Int = 2 + const val MLN_MAP_TILE_OPTION_LOD_SCALE: Int = 4 + const val MLN_MAP_TILE_OPTION_LOD_PITCH_THRESHOLD: Int = 8 + const val MLN_MAP_TILE_OPTION_LOD_ZOOM_SHIFT: Int = 16 + const val MLN_MAP_TILE_OPTION_LOD_MODE: Int = 32 +} + +/** Values of `enum mln_map_viewport_option_field`. */ +internal object MlnMapViewportOptionField { + const val MLN_MAP_VIEWPORT_OPTION_NORTH_ORIENTATION: Int = 1 + const val MLN_MAP_VIEWPORT_OPTION_CONSTRAIN_MODE: Int = 2 + const val MLN_MAP_VIEWPORT_OPTION_VIEWPORT_MODE: Int = 4 + const val MLN_MAP_VIEWPORT_OPTION_FRUSTUM_OFFSET: Int = 8 +} + +/** Values of `enum mln_offline_region_definition_type`. */ +internal object MlnOfflineRegionDefinitionType { + const val MLN_OFFLINE_REGION_DEFINITION_TILE_PYRAMID: Int = 1 + const val MLN_OFFLINE_REGION_DEFINITION_GEOMETRY: Int = 2 +} + +/** Values of `enum mln_opengl_context_platform`. */ +internal object MlnOpenglContextPlatform { + const val MLN_OPENGL_CONTEXT_PLATFORM_UNSPECIFIED: Int = 0 + const val MLN_OPENGL_CONTEXT_PLATFORM_WGL: Int = 1 + const val MLN_OPENGL_CONTEXT_PLATFORM_EGL: Int = 2 + const val MLN_OPENGL_CONTEXT_PLATFORM_WEBGL: Int = 3 +} + +/** Values of `enum mln_projection_mode_field`. */ +internal object MlnProjectionModeField { + const val MLN_PROJECTION_MODE_AXONOMETRIC: Int = 1 + const val MLN_PROJECTION_MODE_X_SKEW: Int = 2 + const val MLN_PROJECTION_MODE_Y_SKEW: Int = 4 +} + +/** Values of `enum mln_queried_feature_field`. */ +internal object MlnQueriedFeatureField { + const val MLN_QUERIED_FEATURE_SOURCE_ID: Int = 1 + const val MLN_QUERIED_FEATURE_SOURCE_LAYER_ID: Int = 2 + const val MLN_QUERIED_FEATURE_STATE: Int = 4 +} + +/** Values of `enum mln_rendered_feature_query_option_field`. */ +internal object MlnRenderedFeatureQueryOptionField { + const val MLN_RENDERED_FEATURE_QUERY_OPTION_LAYER_IDS: Int = 1 +} + +/** Values of `enum mln_rendered_query_geometry_type`. */ +internal object MlnRenderedQueryGeometryType { + const val MLN_RENDERED_QUERY_GEOMETRY_TYPE_POINT: Int = 1 + const val MLN_RENDERED_QUERY_GEOMETRY_TYPE_BOX: Int = 2 + const val MLN_RENDERED_QUERY_GEOMETRY_TYPE_LINE_STRING: Int = 3 +} + +/** Values of `enum mln_runtime_event_payload_type`. */ +internal object MlnRuntimeEventPayloadType { + const val MLN_RUNTIME_EVENT_PAYLOAD_NONE: Int = 0 + const val MLN_RUNTIME_EVENT_PAYLOAD_RENDER_FRAME: Int = 1 + const val MLN_RUNTIME_EVENT_PAYLOAD_RENDER_MAP: Int = 2 + const val MLN_RUNTIME_EVENT_PAYLOAD_STYLE_IMAGE_MISSING: Int = 3 + const val MLN_RUNTIME_EVENT_PAYLOAD_TILE_ACTION: Int = 4 + const val MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_REGION_STATUS: Int = 5 + const val MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_REGION_RESPONSE_ERROR: Int = 6 + const val MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_REGION_TILE_COUNT_LIMIT: Int = 7 + const val MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_OPERATION_COMPLETED: Int = 8 + const val MLN_RUNTIME_EVENT_PAYLOAD_CAMERA_TRANSITION_FINISHED: Int = 9 +} + +/** Values of `enum mln_source_feature_query_option_field`. */ +internal object MlnSourceFeatureQueryOptionField { + const val MLN_SOURCE_FEATURE_QUERY_OPTION_SOURCE_LAYER_IDS: Int = 1 +} + +/** Values of `enum mln_style_image_option_field`. */ +internal object MlnStyleImageOptionField { + const val MLN_STYLE_IMAGE_OPTION_PIXEL_RATIO: Int = 1 + const val MLN_STYLE_IMAGE_OPTION_SDF: Int = 2 + const val MLN_STYLE_IMAGE_OPTION_STRETCH_X: Int = 4 + const val MLN_STYLE_IMAGE_OPTION_STRETCH_Y: Int = 8 + const val MLN_STYLE_IMAGE_OPTION_CONTENT: Int = 16 + const val MLN_STYLE_IMAGE_OPTION_TEXT_FIT_WIDTH: Int = 32 + const val MLN_STYLE_IMAGE_OPTION_TEXT_FIT_HEIGHT: Int = 64 +} + +/** Values of `enum mln_style_source_info_field`. */ +internal object MlnStyleSourceInfoField { + const val MLN_STYLE_SOURCE_INFO_URL: Int = 1 + const val MLN_STYLE_SOURCE_INFO_TILEJSON: Int = 2 + const val MLN_STYLE_SOURCE_INFO_BOUNDS: Int = 4 + const val MLN_STYLE_SOURCE_INFO_TILE_SIZE: Int = 8 + const val MLN_STYLE_SOURCE_INFO_VECTOR_ENCODING: Int = 16 + const val MLN_STYLE_SOURCE_INFO_RASTER_ENCODING: Int = 32 +} + +/** Values of `enum mln_style_tile_source_option_field`. */ +internal object MlnStyleTileSourceOptionField { + const val MLN_STYLE_TILE_SOURCE_OPTION_MIN_ZOOM: Int = 1 + const val MLN_STYLE_TILE_SOURCE_OPTION_MAX_ZOOM: Int = 2 + const val MLN_STYLE_TILE_SOURCE_OPTION_ATTRIBUTION: Int = 4 + const val MLN_STYLE_TILE_SOURCE_OPTION_SCHEME: Int = 8 + const val MLN_STYLE_TILE_SOURCE_OPTION_BOUNDS: Int = 16 + const val MLN_STYLE_TILE_SOURCE_OPTION_TILE_SIZE: Int = 32 + const val MLN_STYLE_TILE_SOURCE_OPTION_VECTOR_ENCODING: Int = 64 + const val MLN_STYLE_TILE_SOURCE_OPTION_RASTER_ENCODING: Int = 128 +} + +/** Values of `enum mln_style_transition_option_field`. */ +internal object MlnStyleTransitionOptionField { + const val MLN_STYLE_TRANSITION_OPTION_DURATION: Int = 1 + const val MLN_STYLE_TRANSITION_OPTION_DELAY: Int = 2 + const val MLN_STYLE_TRANSITION_OPTION_ENABLE_PLACEMENT_TRANSITIONS: Int = 4 +} + +/** Fields of `struct mln_adapter_log_record`. */ +internal object MlnAdapterLogRecord { + const val SIZEOF: Int = 32 + + fun owner(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setOwner(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun retireCallback(base: HeapPointer): Boolean = Heap.loadByte(base + 4) != 0.toByte() + + fun setRetireCallback(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 4, if (value) 1 else 0) + } + + fun severity(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setSeverity(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun event(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setEvent(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun code(base: HeapPointer): Long = Heap.loadLong(base + 16) + + fun setCode(base: HeapPointer, value: Long) { + Heap.storeLong(base + 16, value) + } + + fun message(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 24)) + + fun setMessage(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 24, value.address) + } +} + +/** Fields of `struct mln_adapter_queued_resource_provider`. */ +internal object MlnAdapterQueuedResourceProvider { + const val SIZEOF: Int = 12 + + fun routes(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setRoutes(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun routeCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setRouteCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_LISTENER: Int = 8 +} + +/** Fields of `struct mln_adapter_queued_resource_provider_route`. */ +internal object MlnAdapterQueuedResourceProviderRoute { + const val SIZEOF: Int = 12 + + fun kind(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun flags(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFlags(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun url(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } +} + +/** Fields of `struct mln_adapter_queued_resource_request`. */ +internal object MlnAdapterQueuedResourceRequest { + const val SIZEOF: Int = 112 + + fun owner(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setOwner(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun handle(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setHandle(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + fun requestedUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setRequestedUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } + + fun resolvedUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 20)) + + fun setResolvedUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 20, value.address) + } + + fun kind(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + fun loadingMethod(base: HeapPointer): Int = Heap.loadInt(base + 28) + + fun setLoadingMethod(base: HeapPointer, value: Int) { + Heap.storeInt(base + 28, value) + } + + fun priority(base: HeapPointer): Int = Heap.loadInt(base + 32) + + fun setPriority(base: HeapPointer, value: Int) { + Heap.storeInt(base + 32, value) + } + + fun usage(base: HeapPointer): Int = Heap.loadInt(base + 36) + + fun setUsage(base: HeapPointer, value: Int) { + Heap.storeInt(base + 36, value) + } + + fun storagePolicy(base: HeapPointer): Int = Heap.loadInt(base + 40) + + fun setStoragePolicy(base: HeapPointer, value: Int) { + Heap.storeInt(base + 40, value) + } + + fun hasRange(base: HeapPointer): Boolean = Heap.loadByte(base + 44) != 0.toByte() + + fun setHasRange(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 44, if (value) 1 else 0) + } + + fun rangeStart(base: HeapPointer): Long = Heap.loadLong(base + 48) + + fun setRangeStart(base: HeapPointer, value: Long) { + Heap.storeLong(base + 48, value) + } + + fun rangeEnd(base: HeapPointer): Long = Heap.loadLong(base + 56) + + fun setRangeEnd(base: HeapPointer, value: Long) { + Heap.storeLong(base + 56, value) + } + + fun hasPriorModified(base: HeapPointer): Boolean = Heap.loadByte(base + 64) != 0.toByte() + + fun setHasPriorModified(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 64, if (value) 1 else 0) + } + + fun priorModifiedUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 72) + + fun setPriorModifiedUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 72, value) + } + + fun hasPriorExpires(base: HeapPointer): Boolean = Heap.loadByte(base + 80) != 0.toByte() + + fun setHasPriorExpires(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 80, if (value) 1 else 0) + } + + fun priorExpiresUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 88) + + fun setPriorExpiresUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 88, value) + } + + fun priorEtag(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 96)) + + fun setPriorEtag(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 96, value.address) + } + + fun priorData(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 100)) + + fun setPriorData(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 100, value.address) + } + + fun priorDataSize(base: HeapPointer): Int = Heap.loadInt(base + 104) + + fun setPriorDataSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 104, value) + } +} + +/** Fields of `struct mln_adapter_resource_rewrite_rule`. */ +internal object MlnAdapterResourceRewriteRule { + const val SIZEOF: Int = 16 + + fun kind(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun flags(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFlags(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun url(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun replacementUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 12)) + + fun setReplacementUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 12, value.address) + } +} + +/** Fields of `struct mln_adapter_resource_rewrite_rules`. */ +internal object MlnAdapterResourceRewriteRules { + const val SIZEOF: Int = 8 + + fun rules(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setRules(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun count(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_animation_options`. */ +internal object MlnAnimationOptions { + const val SIZEOF: Int = 72 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun durationMs(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setDurationMs(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun velocity(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setVelocity(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + const val OFFSET_EASING: Int = 32 + + fun transitionId(base: HeapPointer): Long = Heap.loadLong(base + 64) + + fun setTransitionId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 64, value) + } +} + +/** Fields of `struct mln_bound_options`. */ +internal object MlnBoundOptions { + const val SIZEOF: Int = 72 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_BOUNDS: Int = 8 + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 40) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 40, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 48) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 48, value) + } + + fun minPitch(base: HeapPointer): Double = Heap.loadDouble(base + 56) + + fun setMinPitch(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 56, value) + } + + fun maxPitch(base: HeapPointer): Double = Heap.loadDouble(base + 64) + + fun setMaxPitch(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 64, value) + } +} + +/** Fields of `struct mln_camera_fit_options`. */ +internal object MlnCameraFitOptions { + const val SIZEOF: Int = 56 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_PADDING: Int = 8 + + fun bearing(base: HeapPointer): Double = Heap.loadDouble(base + 40) + + fun setBearing(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 40, value) + } + + fun pitch(base: HeapPointer): Double = Heap.loadDouble(base + 48) + + fun setPitch(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 48, value) + } +} + +/** Fields of `struct mln_camera_options`. */ +internal object MlnCameraOptions { + const val SIZEOF: Int = 120 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun latitude(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setLatitude(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun longitude(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setLongitude(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun centerAltitude(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setCenterAltitude(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + const val OFFSET_PADDING: Int = 32 + const val OFFSET_ANCHOR: Int = 64 + + fun zoom(base: HeapPointer): Double = Heap.loadDouble(base + 80) + + fun setZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 80, value) + } + + fun bearing(base: HeapPointer): Double = Heap.loadDouble(base + 88) + + fun setBearing(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 88, value) + } + + fun pitch(base: HeapPointer): Double = Heap.loadDouble(base + 96) + + fun setPitch(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 96, value) + } + + fun roll(base: HeapPointer): Double = Heap.loadDouble(base + 104) + + fun setRoll(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 104, value) + } + + fun fieldOfView(base: HeapPointer): Double = Heap.loadDouble(base + 112) + + fun setFieldOfView(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 112, value) + } +} + +/** Fields of `struct mln_canonical_tile_id`. */ +internal object MlnCanonicalTileId { + const val SIZEOF: Int = 12 + + fun z(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setZ(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun x(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setX(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun y(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setY(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } +} + +/** Fields of `struct mln_coordinate_span`. */ +internal object MlnCoordinateSpan { + const val SIZEOF: Int = 8 + + fun coordinates(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setCoordinates(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun coordinateCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setCoordinateCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_custom_geometry_source_options`. */ +internal object MlnCustomGeometrySourceOptions { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_FETCH_TILE: Int = 8 + const val OFFSET_CANCEL_TILE: Int = 12 + + fun userData(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setUserData(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 32) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 32, value) + } + + fun tolerance(base: HeapPointer): Double = Heap.loadDouble(base + 40) + + fun setTolerance(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 40, value) + } + + fun tileSize(base: HeapPointer): Int = Heap.loadInt(base + 48) + + fun setTileSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 48, value) + } + + fun buffer(base: HeapPointer): Int = Heap.loadInt(base + 52) + + fun setBuffer(base: HeapPointer, value: Int) { + Heap.storeInt(base + 52, value) + } + + fun clip(base: HeapPointer): Boolean = Heap.loadByte(base + 56) != 0.toByte() + + fun setClip(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 56, if (value) 1 else 0) + } + + fun wrap(base: HeapPointer): Boolean = Heap.loadByte(base + 57) != 0.toByte() + + fun setWrap(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 57, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_edge_insets`. */ +internal object MlnEdgeInsets { + const val SIZEOF: Int = 32 + + fun top(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setTop(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun left(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setLeft(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun bottom(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setBottom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun right(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setRight(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } +} + +/** Fields of `struct mln_egl_context_descriptor`. */ +internal object MlnEglContextDescriptor { + const val SIZEOF: Int = 20 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun display(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setDisplay(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + fun config(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setConfig(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun shareContext(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 12)) + + fun setShareContext(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 12, value.address) + } + + fun getProcAddress(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setGetProcAddress(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } +} + +/** Fields of `struct mln_feature`. */ +internal object MlnFeature { + const val SIZEOF: Int = 32 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun geometry(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setGeometry(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + fun properties(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setProperties(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun propertyCount(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setPropertyCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun identifierType(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setIdentifierType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + const val OFFSET_IDENTIFIER: Int = 24 +} + +/** Fields of `struct mln_feature_collection`. */ +internal object MlnFeatureCollection { + const val SIZEOF: Int = 8 + + fun features(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setFeatures(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun featureCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFeatureCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_feature_extension_result_info`. */ +internal object MlnFeatureExtensionResultInfo { + const val SIZEOF: Int = 16 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_feature_state_selector`. */ +internal object MlnFeatureStateSelector { + const val SIZEOF: Int = 40 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_SOURCE_ID: Int = 8 + const val OFFSET_SOURCE_LAYER_ID: Int = 16 + const val OFFSET_FEATURE_ID: Int = 24 + const val OFFSET_STATE_KEY: Int = 32 +} + +/** Fields of `struct mln_free_camera_options`. */ +internal object MlnFreeCameraOptions { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_POSITION: Int = 8 + const val OFFSET_ORIENTATION: Int = 32 +} + +/** Fields of `struct mln_geojson`. */ +internal object MlnGeojson { + const val SIZEOF: Int = 16 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_geojson_source_options`. */ +internal object MlnGeojsonSourceOptions { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun tolerance(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setTolerance(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + fun clusterMaxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 32) + + fun setClusterMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 32, value) + } + + fun clusterProperties(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 40)) + + fun setClusterProperties(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 40, value.address) + } + + fun tileSize(base: HeapPointer): Int = Heap.loadInt(base + 44) + + fun setTileSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 44, value) + } + + fun buffer(base: HeapPointer): Int = Heap.loadInt(base + 48) + + fun setBuffer(base: HeapPointer, value: Int) { + Heap.storeInt(base + 48, value) + } + + fun clusterRadius(base: HeapPointer): Int = Heap.loadInt(base + 52) + + fun setClusterRadius(base: HeapPointer, value: Int) { + Heap.storeInt(base + 52, value) + } + + fun clusterMinPoints(base: HeapPointer): Int = Heap.loadInt(base + 56) + + fun setClusterMinPoints(base: HeapPointer, value: Int) { + Heap.storeInt(base + 56, value) + } + + fun lineMetrics(base: HeapPointer): Boolean = Heap.loadByte(base + 60) != 0.toByte() + + fun setLineMetrics(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 60, if (value) 1 else 0) + } + + fun cluster(base: HeapPointer): Boolean = Heap.loadByte(base + 61) != 0.toByte() + + fun setCluster(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 61, if (value) 1 else 0) + } + + fun synchronousUpdate(base: HeapPointer): Boolean = Heap.loadByte(base + 62) != 0.toByte() + + fun setSynchronousUpdate(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 62, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_geometry`. */ +internal object MlnGeometry { + const val SIZEOF: Int = 24 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_geometry_collection`. */ +internal object MlnGeometryCollection { + const val SIZEOF: Int = 8 + + fun geometries(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setGeometries(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun geometryCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setGeometryCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_image_content`. */ +internal object MlnImageContent { + const val SIZEOF: Int = 16 + + fun left(base: HeapPointer): Float = Heap.loadFloat(base + 0) + + fun setLeft(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 0, value) + } + + fun top(base: HeapPointer): Float = Heap.loadFloat(base + 4) + + fun setTop(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 4, value) + } + + fun right(base: HeapPointer): Float = Heap.loadFloat(base + 8) + + fun setRight(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 8, value) + } + + fun bottom(base: HeapPointer): Float = Heap.loadFloat(base + 12) + + fun setBottom(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 12, value) + } +} + +/** Fields of `struct mln_image_stretch`. */ +internal object MlnImageStretch { + const val SIZEOF: Int = 8 + + fun from(base: HeapPointer): Float = Heap.loadFloat(base + 0) + + fun setFrom(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 0, value) + } + + fun to(base: HeapPointer): Float = Heap.loadFloat(base + 4) + + fun setTo(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 4, value) + } +} + +/** Fields of `struct mln_json_array`. */ +internal object MlnJsonArray { + const val SIZEOF: Int = 8 + + fun values(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setValues(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun valueCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setValueCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_json_member`. */ +internal object MlnJsonMember { + const val SIZEOF: Int = 12 + const val OFFSET_KEY: Int = 0 + + fun value(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setValue(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } +} + +/** Fields of `struct mln_json_object`. */ +internal object MlnJsonObject { + const val SIZEOF: Int = 8 + + fun members(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setMembers(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun memberCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setMemberCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_json_value`. */ +internal object MlnJsonValue { + const val SIZEOF: Int = 16 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_kotlin_record`. */ +internal object MlnKotlinRecord { + const val SIZEOF: Int = 20 + + fun kind(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun tileZ(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setTileZ(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun tileX(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setTileX(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun tileY(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setTileY(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun payload(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setPayload(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } +} + +/** Fields of `struct mln_lat_lng`. */ +internal object MlnLatLng { + const val SIZEOF: Int = 16 + + fun latitude(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setLatitude(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun longitude(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setLongitude(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } +} + +/** Fields of `struct mln_lat_lng_bounds`. */ +internal object MlnLatLngBounds { + const val SIZEOF: Int = 32 + const val OFFSET_SOUTHWEST: Int = 0 + const val OFFSET_NORTHEAST: Int = 16 +} + +/** Fields of `struct mln_map_options`. */ +internal object MlnMapOptions { + const val SIZEOF: Int = 32 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun width(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun height(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun scaleFactor(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setScaleFactor(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun mapMode(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setMapMode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + fun fastPforEnabled(base: HeapPointer): Boolean = Heap.loadByte(base + 28) != 0.toByte() + + fun setFastPforEnabled(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 28, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_map_tile_options`. */ +internal object MlnMapTileOptions { + const val SIZEOF: Int = 56 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun prefetchZoomDelta(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setPrefetchZoomDelta(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun lodMinRadius(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setLodMinRadius(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun lodScale(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setLodScale(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + fun lodPitchThreshold(base: HeapPointer): Double = Heap.loadDouble(base + 32) + + fun setLodPitchThreshold(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 32, value) + } + + fun lodZoomShift(base: HeapPointer): Double = Heap.loadDouble(base + 40) + + fun setLodZoomShift(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 40, value) + } + + fun lodMode(base: HeapPointer): Int = Heap.loadInt(base + 48) + + fun setLodMode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 48, value) + } +} + +/** Fields of `struct mln_map_viewport_options`. */ +internal object MlnMapViewportOptions { + const val SIZEOF: Int = 56 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun northOrientation(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setNorthOrientation(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun constrainMode(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setConstrainMode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun viewportMode(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setViewportMode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + const val OFFSET_FRUSTUM_OFFSET: Int = 24 +} + +/** Fields of `struct mln_multi_line_geometry`. */ +internal object MlnMultiLineGeometry { + const val SIZEOF: Int = 8 + + fun lines(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setLines(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun lineCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setLineCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_multi_polygon_geometry`. */ +internal object MlnMultiPolygonGeometry { + const val SIZEOF: Int = 8 + + fun polygons(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setPolygons(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun polygonCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setPolygonCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_offline_geometry_region_definition`. */ +internal object MlnOfflineGeometryRegionDefinition { + const val SIZEOF: Int = 40 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun styleUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setStyleUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + fun geometry(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setGeometry(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + fun pixelRatio(base: HeapPointer): Float = Heap.loadFloat(base + 32) + + fun setPixelRatio(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 32, value) + } + + fun includeIdeographs(base: HeapPointer): Boolean = Heap.loadByte(base + 36) != 0.toByte() + + fun setIncludeIdeographs(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 36, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_offline_region_definition`. */ +internal object MlnOfflineRegionDefinition { + const val SIZEOF: Int = 72 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_offline_region_info`. */ +internal object MlnOfflineRegionInfo { + const val SIZEOF: Int = 96 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun id(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + const val OFFSET_DEFINITION: Int = 16 + + fun metadata(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 88)) + + fun setMetadata(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 88, value.address) + } + + fun metadataSize(base: HeapPointer): Int = Heap.loadInt(base + 92) + + fun setMetadataSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 92, value) + } +} + +/** Fields of `struct mln_offline_region_status`. */ +internal object MlnOfflineRegionStatus { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun downloadState(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setDownloadState(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun completedResourceCount(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setCompletedResourceCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + fun completedResourceSize(base: HeapPointer): Long = Heap.loadLong(base + 16) + + fun setCompletedResourceSize(base: HeapPointer, value: Long) { + Heap.storeLong(base + 16, value) + } + + fun completedTileCount(base: HeapPointer): Long = Heap.loadLong(base + 24) + + fun setCompletedTileCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 24, value) + } + + fun requiredTileCount(base: HeapPointer): Long = Heap.loadLong(base + 32) + + fun setRequiredTileCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 32, value) + } + + fun completedTileSize(base: HeapPointer): Long = Heap.loadLong(base + 40) + + fun setCompletedTileSize(base: HeapPointer, value: Long) { + Heap.storeLong(base + 40, value) + } + + fun requiredResourceCount(base: HeapPointer): Long = Heap.loadLong(base + 48) + + fun setRequiredResourceCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 48, value) + } + + fun requiredResourceCountIsPrecise(base: HeapPointer): Boolean = + Heap.loadByte(base + 56) != 0.toByte() + + fun setRequiredResourceCountIsPrecise(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 56, if (value) 1 else 0) + } + + fun complete(base: HeapPointer): Boolean = Heap.loadByte(base + 57) != 0.toByte() + + fun setComplete(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 57, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_offline_tile_pyramid_region_definition`. */ +internal object MlnOfflineTilePyramidRegionDefinition { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun styleUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setStyleUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + const val OFFSET_BOUNDS: Int = 8 + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 40) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 40, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 48) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 48, value) + } + + fun pixelRatio(base: HeapPointer): Float = Heap.loadFloat(base + 56) + + fun setPixelRatio(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 56, value) + } + + fun includeIdeographs(base: HeapPointer): Boolean = Heap.loadByte(base + 60) != 0.toByte() + + fun setIncludeIdeographs(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 60, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_opengl_borrowed_texture_descriptor`. */ +internal object MlnOpenglBorrowedTextureDescriptor { + const val SIZEOF: Int = 80 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + const val OFFSET_EXTENT: Int = 8 + + fun physicalWidth(base: HeapPointer): Int = Heap.loadInt(base + 32) + + fun setPhysicalWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 32, value) + } + + fun physicalHeight(base: HeapPointer): Int = Heap.loadInt(base + 36) + + fun setPhysicalHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 36, value) + } + + const val OFFSET_CONTEXT: Int = 40 + + fun texture(base: HeapPointer): Int = Heap.loadInt(base + 68) + + fun setTexture(base: HeapPointer, value: Int) { + Heap.storeInt(base + 68, value) + } + + fun target(base: HeapPointer): Int = Heap.loadInt(base + 72) + + fun setTarget(base: HeapPointer, value: Int) { + Heap.storeInt(base + 72, value) + } +} + +/** Fields of `struct mln_opengl_context_descriptor`. */ +internal object MlnOpenglContextDescriptor { + const val SIZEOF: Int = 28 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun platform(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setPlatform(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_opengl_owned_texture_descriptor`. */ +internal object MlnOpenglOwnedTextureDescriptor { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + const val OFFSET_EXTENT: Int = 8 + const val OFFSET_CONTEXT: Int = 32 +} + +/** Fields of `struct mln_opengl_owned_texture_frame`. */ +internal object MlnOpenglOwnedTextureFrame { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun generation(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setGeneration(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + fun width(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + fun height(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } + + fun scaleFactor(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setScaleFactor(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } + + fun frameId(base: HeapPointer): Long = Heap.loadLong(base + 32) + + fun setFrameId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 32, value) + } + + fun texture(base: HeapPointer): Int = Heap.loadInt(base + 40) + + fun setTexture(base: HeapPointer, value: Int) { + Heap.storeInt(base + 40, value) + } + + fun target(base: HeapPointer): Int = Heap.loadInt(base + 44) + + fun setTarget(base: HeapPointer, value: Int) { + Heap.storeInt(base + 44, value) + } + + fun internalFormat(base: HeapPointer): Int = Heap.loadInt(base + 48) + + fun setInternalFormat(base: HeapPointer, value: Int) { + Heap.storeInt(base + 48, value) + } + + fun format(base: HeapPointer): Int = Heap.loadInt(base + 52) + + fun setFormat(base: HeapPointer, value: Int) { + Heap.storeInt(base + 52, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 56) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 56, value) + } +} + +/** Fields of `struct mln_opengl_surface_descriptor`. */ +internal object MlnOpenglSurfaceDescriptor { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + const val OFFSET_EXTENT: Int = 8 + const val OFFSET_CONTEXT: Int = 32 + + fun surface(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 60)) + + fun setSurface(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 60, value.address) + } +} + +/** Fields of `struct mln_polygon_geometry`. */ +internal object MlnPolygonGeometry { + const val SIZEOF: Int = 8 + + fun rings(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setRings(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun ringCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setRingCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_premultiplied_rgba8_image`. */ +internal object MlnPremultipliedRgba8Image { + const val SIZEOF: Int = 24 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun width(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun height(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun stride(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setStride(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun pixels(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setPixels(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } + + fun byteLength(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setByteLength(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } +} + +/** Fields of `struct mln_projected_meters`. */ +internal object MlnProjectedMeters { + const val SIZEOF: Int = 16 + + fun northing(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setNorthing(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun easting(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setEasting(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } +} + +/** Fields of `struct mln_projection_mode`. */ +internal object MlnProjectionMode { + const val SIZEOF: Int = 32 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun axonometric(base: HeapPointer): Boolean = Heap.loadByte(base + 8) != 0.toByte() + + fun setAxonometric(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 8, if (value) 1 else 0) + } + + fun xSkew(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setXSkew(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun ySkew(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setYSkew(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } +} + +/** Fields of `struct mln_quaternion`. */ +internal object MlnQuaternion { + const val SIZEOF: Int = 32 + + fun x(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setX(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun y(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setY(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun z(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setZ(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun w(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setW(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } +} + +/** Fields of `struct mln_queried_feature`. */ +internal object MlnQueriedFeature { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_FEATURE: Int = 8 + const val OFFSET_SOURCE_ID: Int = 40 + const val OFFSET_SOURCE_LAYER_ID: Int = 48 + + fun state(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 56)) + + fun setState(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 56, value.address) + } +} + +/** Fields of `struct mln_render_target_extent`. */ +internal object MlnRenderTargetExtent { + const val SIZEOF: Int = 24 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun width(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun height(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun scaleFactor(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setScaleFactor(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } +} + +/** Fields of `struct mln_rendered_feature_query_options`. */ +internal object MlnRenderedFeatureQueryOptions { + const val SIZEOF: Int = 20 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun layerIds(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setLayerIds(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun layerIdCount(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setLayerIdCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun filter(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setFilter(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } +} + +/** Fields of `struct mln_rendered_query_geometry`. */ +internal object MlnRenderedQueryGeometry { + const val SIZEOF: Int = 40 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_DATA: Int = 8 +} + +/** Fields of `struct mln_rendering_stats`. */ +internal object MlnRenderingStats { + const val SIZEOF: Int = 48 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun encodingTime(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setEncodingTime(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun renderingTime(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setRenderingTime(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun frameCount(base: HeapPointer): Long = Heap.loadLong(base + 24) + + fun setFrameCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 24, value) + } + + fun drawCallCount(base: HeapPointer): Long = Heap.loadLong(base + 32) + + fun setDrawCallCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 32, value) + } + + fun totalDrawCallCount(base: HeapPointer): Long = Heap.loadLong(base + 40) + + fun setTotalDrawCallCount(base: HeapPointer, value: Long) { + Heap.storeLong(base + 40, value) + } +} + +/** Fields of `struct mln_resource_provider`. */ +internal object MlnResourceProvider { + const val SIZEOF: Int = 12 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + const val OFFSET_CALLBACK: Int = 4 + + fun userData(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setUserData(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } +} + +/** Fields of `struct mln_resource_request`. */ +internal object MlnResourceRequest { + const val SIZEOF: Int = 104 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun requestedUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setRequestedUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + fun resolvedUrl(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setResolvedUrl(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun kind(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun loadingMethod(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setLoadingMethod(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + fun priority(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setPriority(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } + + fun usage(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setUsage(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + fun storagePolicy(base: HeapPointer): Int = Heap.loadInt(base + 28) + + fun setStoragePolicy(base: HeapPointer, value: Int) { + Heap.storeInt(base + 28, value) + } + + fun hasRange(base: HeapPointer): Boolean = Heap.loadByte(base + 32) != 0.toByte() + + fun setHasRange(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 32, if (value) 1 else 0) + } + + fun rangeStart(base: HeapPointer): Long = Heap.loadLong(base + 40) + + fun setRangeStart(base: HeapPointer, value: Long) { + Heap.storeLong(base + 40, value) + } + + fun rangeEnd(base: HeapPointer): Long = Heap.loadLong(base + 48) + + fun setRangeEnd(base: HeapPointer, value: Long) { + Heap.storeLong(base + 48, value) + } + + fun hasPriorModified(base: HeapPointer): Boolean = Heap.loadByte(base + 56) != 0.toByte() + + fun setHasPriorModified(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 56, if (value) 1 else 0) + } + + fun priorModifiedUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 64) + + fun setPriorModifiedUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 64, value) + } + + fun hasPriorExpires(base: HeapPointer): Boolean = Heap.loadByte(base + 72) != 0.toByte() + + fun setHasPriorExpires(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 72, if (value) 1 else 0) + } + + fun priorExpiresUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 80) + + fun setPriorExpiresUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 80, value) + } + + fun priorEtag(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 88)) + + fun setPriorEtag(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 88, value.address) + } + + fun priorData(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 92)) + + fun setPriorData(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 92, value.address) + } + + fun priorDataSize(base: HeapPointer): Int = Heap.loadInt(base + 96) + + fun setPriorDataSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 96, value) + } +} + +/** Fields of `struct mln_resource_response`. */ +internal object MlnResourceResponse { + const val SIZEOF: Int = 72 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun status(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setStatus(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun errorReason(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setErrorReason(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun bytes(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 12)) + + fun setBytes(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 12, value.address) + } + + fun byteCount(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setByteCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + fun errorMessage(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 20)) + + fun setErrorMessage(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 20, value.address) + } + + fun mustRevalidate(base: HeapPointer): Boolean = Heap.loadByte(base + 24) != 0.toByte() + + fun setMustRevalidate(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 24, if (value) 1 else 0) + } + + fun hasModified(base: HeapPointer): Boolean = Heap.loadByte(base + 25) != 0.toByte() + + fun setHasModified(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 25, if (value) 1 else 0) + } + + fun modifiedUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 32) + + fun setModifiedUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 32, value) + } + + fun hasExpires(base: HeapPointer): Boolean = Heap.loadByte(base + 40) != 0.toByte() + + fun setHasExpires(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 40, if (value) 1 else 0) + } + + fun expiresUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 48) + + fun setExpiresUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 48, value) + } + + fun etag(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 56)) + + fun setEtag(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 56, value.address) + } + + fun hasRetryAfter(base: HeapPointer): Boolean = Heap.loadByte(base + 60) != 0.toByte() + + fun setHasRetryAfter(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 60, if (value) 1 else 0) + } + + fun retryAfterUnixMs(base: HeapPointer): Long = Heap.loadLong(base + 64) + + fun setRetryAfterUnixMs(base: HeapPointer, value: Long) { + Heap.storeLong(base + 64, value) + } +} + +/** Fields of `struct mln_resource_transform`. */ +internal object MlnResourceTransform { + const val SIZEOF: Int = 12 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + const val OFFSET_CALLBACK: Int = 4 + + fun userData(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setUserData(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } +} + +/** Fields of `struct mln_runtime_event`. */ +internal object MlnRuntimeEvent { + const val SIZEOF: Int = 48 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun sourceType(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setSourceType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun source(base: HeapPointer): Long = Heap.loadLong(base + 16) + + fun setSource(base: HeapPointer, value: Long) { + Heap.storeLong(base + 16, value) + } + + fun code(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setCode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + fun payloadType(base: HeapPointer): Int = Heap.loadInt(base + 28) + + fun setPayloadType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 28, value) + } + + fun payload(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 32)) + + fun setPayload(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 32, value.address) + } + + fun payloadSize(base: HeapPointer): Int = Heap.loadInt(base + 36) + + fun setPayloadSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 36, value) + } + + fun message(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 40)) + + fun setMessage(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 40, value.address) + } + + fun messageSize(base: HeapPointer): Int = Heap.loadInt(base + 44) + + fun setMessageSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 44, value) + } +} + +/** Fields of `struct mln_runtime_event_camera_transition_finished`. */ +internal object MlnRuntimeEventCameraTransitionFinished { + const val SIZEOF: Int = 16 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun transitionId(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setTransitionId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } +} + +/** Fields of `struct mln_runtime_event_offline_operation_completed`. */ +internal object MlnRuntimeEventOfflineOperationCompleted { + const val SIZEOF: Int = 32 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun operationId(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setOperationId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + fun operationKind(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setOperationKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + fun resultKind(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setResultKind(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } + + fun resultStatus(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setResultStatus(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + fun found(base: HeapPointer): Boolean = Heap.loadByte(base + 28) != 0.toByte() + + fun setFound(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 28, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_runtime_event_offline_region_response_error`. */ +internal object MlnRuntimeEventOfflineRegionResponseError { + const val SIZEOF: Int = 24 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun regionId(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setRegionId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + fun reason(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setReason(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } +} + +/** Fields of `struct mln_runtime_event_offline_region_status`. */ +internal object MlnRuntimeEventOfflineRegionStatus { + const val SIZEOF: Int = 80 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun regionId(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setRegionId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + const val OFFSET_STATUS: Int = 16 +} + +/** Fields of `struct mln_runtime_event_offline_region_tile_count_limit`. */ +internal object MlnRuntimeEventOfflineRegionTileCountLimit { + const val SIZEOF: Int = 24 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun regionId(base: HeapPointer): Long = Heap.loadLong(base + 8) + + fun setRegionId(base: HeapPointer, value: Long) { + Heap.storeLong(base + 8, value) + } + + fun limit(base: HeapPointer): Long = Heap.loadLong(base + 16) + + fun setLimit(base: HeapPointer, value: Long) { + Heap.storeLong(base + 16, value) + } +} + +/** Fields of `struct mln_runtime_event_render_frame`. */ +internal object MlnRuntimeEventRenderFrame { + const val SIZEOF: Int = 64 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun mode(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setMode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun needsRepaint(base: HeapPointer): Boolean = Heap.loadByte(base + 8) != 0.toByte() + + fun setNeedsRepaint(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 8, if (value) 1 else 0) + } + + fun placementChanged(base: HeapPointer): Boolean = Heap.loadByte(base + 9) != 0.toByte() + + fun setPlacementChanged(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 9, if (value) 1 else 0) + } + + const val OFFSET_STATS: Int = 16 +} + +/** Fields of `struct mln_runtime_event_render_map`. */ +internal object MlnRuntimeEventRenderMap { + const val SIZEOF: Int = 8 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun mode(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setMode(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_runtime_event_style_image_missing`. */ +internal object MlnRuntimeEventStyleImageMissing { + const val SIZEOF: Int = 12 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun imageId(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setImageId(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + fun imageIdSize(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setImageIdSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } +} + +/** Fields of `struct mln_runtime_event_tile_action`. */ +internal object MlnRuntimeEventTileAction { + const val SIZEOF: Int = 36 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun operation(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setOperation(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + const val OFFSET_TILE_ID: Int = 8 + + fun sourceId(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 28)) + + fun setSourceId(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 28, value.address) + } + + fun sourceIdSize(base: HeapPointer): Int = Heap.loadInt(base + 32) + + fun setSourceIdSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 32, value) + } +} + +/** Fields of `struct mln_runtime_options`. */ +internal object MlnRuntimeOptions { + const val SIZEOF: Int = 16 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun flags(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFlags(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun assetPath(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setAssetPath(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun cachePath(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 12)) + + fun setCachePath(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 12, value.address) + } +} + +/** Fields of `struct mln_screen_box`. */ +internal object MlnScreenBox { + const val SIZEOF: Int = 32 + const val OFFSET_MIN: Int = 0 + const val OFFSET_MAX: Int = 16 +} + +/** Fields of `struct mln_screen_line_string`. */ +internal object MlnScreenLineString { + const val SIZEOF: Int = 8 + + fun points(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setPoints(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun pointCount(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setPointCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_screen_point`. */ +internal object MlnScreenPoint { + const val SIZEOF: Int = 16 + + fun x(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setX(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun y(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setY(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } +} + +/** Fields of `struct mln_source_feature_query_options`. */ +internal object MlnSourceFeatureQueryOptions { + const val SIZEOF: Int = 20 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun sourceLayerIds(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setSourceLayerIds(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun sourceLayerIdCount(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setSourceLayerIdCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun filter(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setFilter(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } +} + +/** Fields of `struct mln_string_view`. */ +internal object MlnStringView { + const val SIZEOF: Int = 8 + + fun data(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 0)) + + fun setData(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 0, value.address) + } + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_style_image_info`. */ +internal object MlnStyleImageInfo { + const val SIZEOF: Int = 60 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun width(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun height(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun stride(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setStride(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun byteLength(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setByteLength(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } + + fun stretchXCount(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setStretchXCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } + + fun stretchYCount(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setStretchYCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + const val OFFSET_CONTENT: Int = 28 + + fun textFitWidth(base: HeapPointer): Int = Heap.loadInt(base + 44) + + fun setTextFitWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 44, value) + } + + fun textFitHeight(base: HeapPointer): Int = Heap.loadInt(base + 48) + + fun setTextFitHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 48, value) + } + + fun pixelRatio(base: HeapPointer): Float = Heap.loadFloat(base + 52) + + fun setPixelRatio(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 52, value) + } + + fun sdf(base: HeapPointer): Boolean = Heap.loadByte(base + 56) != 0.toByte() + + fun setSdf(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 56, if (value) 1 else 0) + } + + fun hasContent(base: HeapPointer): Boolean = Heap.loadByte(base + 57) != 0.toByte() + + fun setHasContent(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 57, if (value) 1 else 0) + } + + fun hasTextFitWidth(base: HeapPointer): Boolean = Heap.loadByte(base + 58) != 0.toByte() + + fun setHasTextFitWidth(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 58, if (value) 1 else 0) + } + + fun hasTextFitHeight(base: HeapPointer): Boolean = Heap.loadByte(base + 59) != 0.toByte() + + fun setHasTextFitHeight(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 59, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_style_image_options`. */ +internal object MlnStyleImageOptions { + const val SIZEOF: Int = 56 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun stretchX(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setStretchX(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun stretchXCount(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setStretchXCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun stretchY(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 16)) + + fun setStretchY(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 16, value.address) + } + + fun stretchYCount(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setStretchYCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } + + const val OFFSET_CONTENT: Int = 24 + + fun textFitWidth(base: HeapPointer): Int = Heap.loadInt(base + 40) + + fun setTextFitWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 40, value) + } + + fun textFitHeight(base: HeapPointer): Int = Heap.loadInt(base + 44) + + fun setTextFitHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 44, value) + } + + fun pixelRatio(base: HeapPointer): Float = Heap.loadFloat(base + 48) + + fun setPixelRatio(base: HeapPointer, value: Float) { + Heap.storeFloat(base + 48, value) + } + + fun sdf(base: HeapPointer): Boolean = Heap.loadByte(base + 52) != 0.toByte() + + fun setSdf(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 52, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_style_source_info`. */ +internal object MlnStyleSourceInfo { + const val SIZEOF: Int = 104 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun type(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setType(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun idSize(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setIdSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun isVolatile(base: HeapPointer): Boolean = Heap.loadByte(base + 16) != 0.toByte() + + fun setIsVolatile(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 16, if (value) 1 else 0) + } + + fun hasAttribution(base: HeapPointer): Boolean = Heap.loadByte(base + 17) != 0.toByte() + + fun setHasAttribution(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 17, if (value) 1 else 0) + } + + fun attributionSize(base: HeapPointer): Int = Heap.loadInt(base + 20) + + fun setAttributionSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 20, value) + } + + fun urlSize(base: HeapPointer): Int = Heap.loadInt(base + 24) + + fun setUrlSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 24, value) + } + + fun tileCount(base: HeapPointer): Int = Heap.loadInt(base + 28) + + fun setTileCount(base: HeapPointer, value: Int) { + Heap.storeInt(base + 28, value) + } + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 32) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 32, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 40) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 40, value) + } + + fun scheme(base: HeapPointer): Int = Heap.loadInt(base + 48) + + fun setScheme(base: HeapPointer, value: Int) { + Heap.storeInt(base + 48, value) + } + + const val OFFSET_BOUNDS: Int = 56 + + fun tileSize(base: HeapPointer): Int = Heap.loadInt(base + 88) + + fun setTileSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 88, value) + } + + fun vectorEncoding(base: HeapPointer): Int = Heap.loadInt(base + 92) + + fun setVectorEncoding(base: HeapPointer, value: Int) { + Heap.storeInt(base + 92, value) + } + + fun rasterEncoding(base: HeapPointer): Int = Heap.loadInt(base + 96) + + fun setRasterEncoding(base: HeapPointer, value: Int) { + Heap.storeInt(base + 96, value) + } +} + +/** Fields of `struct mln_style_tile_source_options`. */ +internal object MlnStyleTileSourceOptions { + const val SIZEOF: Int = 88 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun minZoom(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setMinZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun maxZoom(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setMaxZoom(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + const val OFFSET_ATTRIBUTION: Int = 24 + + fun scheme(base: HeapPointer): Int = Heap.loadInt(base + 32) + + fun setScheme(base: HeapPointer, value: Int) { + Heap.storeInt(base + 32, value) + } + + const val OFFSET_BOUNDS: Int = 40 + + fun tileSize(base: HeapPointer): Int = Heap.loadInt(base + 72) + + fun setTileSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 72, value) + } + + fun vectorEncoding(base: HeapPointer): Int = Heap.loadInt(base + 76) + + fun setVectorEncoding(base: HeapPointer, value: Int) { + Heap.storeInt(base + 76, value) + } + + fun rasterEncoding(base: HeapPointer): Int = Heap.loadInt(base + 80) + + fun setRasterEncoding(base: HeapPointer, value: Int) { + Heap.storeInt(base + 80, value) + } +} + +/** Fields of `struct mln_style_transition_options`. */ +internal object MlnStyleTransitionOptions { + const val SIZEOF: Int = 32 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun fields(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setFields(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun durationMs(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setDurationMs(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun delayMs(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setDelayMs(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun enablePlacementTransitions(base: HeapPointer): Boolean = + Heap.loadByte(base + 24) != 0.toByte() + + fun setEnablePlacementTransitions(base: HeapPointer, value: Boolean) { + Heap.storeByte(base + 24, if (value) 1 else 0) + } +} + +/** Fields of `struct mln_texture_image_info`. */ +internal object MlnTextureImageInfo { + const val SIZEOF: Int = 20 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun width(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setWidth(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun height(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setHeight(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun stride(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setStride(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun byteLength(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setByteLength(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } +} + +/** Fields of `struct mln_tile_id`. */ +internal object MlnTileId { + const val SIZEOF: Int = 20 + + fun overscaledZ(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setOverscaledZ(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun wrap(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setWrap(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } + + fun canonicalZ(base: HeapPointer): Int = Heap.loadInt(base + 8) + + fun setCanonicalZ(base: HeapPointer, value: Int) { + Heap.storeInt(base + 8, value) + } + + fun canonicalX(base: HeapPointer): Int = Heap.loadInt(base + 12) + + fun setCanonicalX(base: HeapPointer, value: Int) { + Heap.storeInt(base + 12, value) + } + + fun canonicalY(base: HeapPointer): Int = Heap.loadInt(base + 16) + + fun setCanonicalY(base: HeapPointer, value: Int) { + Heap.storeInt(base + 16, value) + } +} + +/** Fields of `struct mln_unit_bezier`. */ +internal object MlnUnitBezier { + const val SIZEOF: Int = 32 + + fun x1(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setX1(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun y1(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setY1(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun x2(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setX2(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } + + fun y2(base: HeapPointer): Double = Heap.loadDouble(base + 24) + + fun setY2(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 24, value) + } +} + +/** Fields of `struct mln_vec3`. */ +internal object MlnVec3 { + const val SIZEOF: Int = 24 + + fun x(base: HeapPointer): Double = Heap.loadDouble(base + 0) + + fun setX(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 0, value) + } + + fun y(base: HeapPointer): Double = Heap.loadDouble(base + 8) + + fun setY(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 8, value) + } + + fun z(base: HeapPointer): Double = Heap.loadDouble(base + 16) + + fun setZ(base: HeapPointer, value: Double) { + Heap.storeDouble(base + 16, value) + } +} + +/** Fields of `struct mln_webgl_context_descriptor`. */ +internal object MlnWebglContextDescriptor { + const val SIZEOF: Int = 8 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun context(base: HeapPointer): Int = Heap.loadInt(base + 4) + + fun setContext(base: HeapPointer, value: Int) { + Heap.storeInt(base + 4, value) + } +} + +/** Fields of `struct mln_wgl_context_descriptor`. */ +internal object MlnWglContextDescriptor { + const val SIZEOF: Int = 16 + + fun size(base: HeapPointer): Int = Heap.loadInt(base + 0) + + fun setSize(base: HeapPointer, value: Int) { + Heap.storeInt(base + 0, value) + } + + fun deviceContext(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 4)) + + fun setDeviceContext(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 4, value.address) + } + + fun shareContext(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 8)) + + fun setShareContext(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 8, value.address) + } + + fun getProcAddress(base: HeapPointer): HeapPointer = HeapPointer(Heap.loadInt(base + 12)) + + fun setGetProcAddress(base: HeapPointer, value: HeapPointer) { + Heap.storeInt(base + 12, value.address) + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/Maplibre.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/Maplibre.kt new file mode 100644 index 000000000..ff1cbb185 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/Maplibre.kt @@ -0,0 +1,140 @@ +package org.maplibre.nativeffi + +import org.maplibre.nativeffi.error.AbiVersionMismatchException +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.ProjectedMeters +import org.maplibre.nativeffi.internal.callback.LogCallbackState +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.BrowserModule +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLng +import org.maplibre.nativeffi.internal.wasm.generated.MlnProjectedMeters +import org.maplibre.nativeffi.internal.wasm.generated.mln_c_version +import org.maplibre.nativeffi.internal.wasm.generated.mln_lat_lng_for_projected_meters +import org.maplibre.nativeffi.internal.wasm.generated.mln_log_set_async_severity_mask +import org.maplibre.nativeffi.internal.wasm.generated.mln_network_status_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_network_status_set +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_supported_context_provider_mask +import org.maplibre.nativeffi.internal.wasm.generated.mln_projected_meters_for_lat_lng +import org.maplibre.nativeffi.internal.wasm.generated.mln_supported_render_backend_mask +import org.maplibre.nativeffi.log.LogCallback +import org.maplibre.nativeffi.log.LogSeverity +import org.maplibre.nativeffi.render.OpenGLContextProvider +import org.maplibre.nativeffi.render.RenderBackend +import org.maplibre.nativeffi.runtime.NetworkStatus + +/** Process-global entry points for the Kotlin/Wasm browser binding. */ +public actual object Maplibre { + /** C ABI contract version expected by this browser binding. */ + public actual const val EXPECTED_C_ABI_VERSION: Long = 0L + + /** The native default async log severity mask: error and warning. */ + private const val DEFAULT_LOG_SEVERITY_MASK: Int = (1 shl 1) or (1 shl 2) + + /** + * Names the Emscripten module that this binding runs inside of, and checks its C ABI version. + * + * The module is instantiated before Kotlin exists, because it is what imported this binding, so + * there is nothing here to load. + */ + public actual fun loadNativeLibrary() { + BrowserModule.attach() + checkCompatibleCAbi() + } + + internal fun checkCompatibleCAbi(actualVersion: Long = cVersion()) { + if (actualVersion == EXPECTED_C_ABI_VERSION) { + return + } + + throw AbiVersionMismatchException(actualVersion, EXPECTED_C_ABI_VERSION) + } + + // Every entry point below names the module before it reaches native, because a host application's + // own main() runs while this distribution is being imported, which is before the module calls + // mlnKotlinMain(). + + /** Returns the native C ABI contract version. */ + public actual fun cVersion(): Long { + BrowserModule.attach() + return mln_c_version().toUInt().toLong() + } + + /** Returns the render backends compiled into the loaded browser module. */ + public actual fun supportedRenderBackends(): Set { + BrowserModule.attach() + return RenderBackend.fromMask(mln_supported_render_backend_mask()) + } + + /** Returns the OpenGL context providers compiled into the loaded browser module. */ + public actual fun supportedOpenGLContextProviders(): Set { + BrowserModule.attach() + return OpenGLContextProvider.fromMask(mln_opengl_supported_context_provider_mask()) + } + + /** Reads Maplibre Native's process-global network status. */ + public actual val networkStatus: NetworkStatus + get() { + BrowserModule.attach() + return Heap.withScratch(4) { out -> + Status.check(mln_network_status_get(out.address)) + NetworkStatus.fromNative(Heap.loadInt(out)) + } + } + + /** Sets Maplibre Native's process-global network status. */ + public actual fun setNetworkStatus(status: NetworkStatus) { + Status.requireArgument(status.isKnown) { + "Unknown network status cannot be used as input: ${status.nativeValue}" + } + BrowserModule.attach() + Status.check(mln_network_status_set(status.nativeValue)) + } + + /** Installs or replaces the process-global native log callback. */ + public actual fun setLogCallback(callback: LogCallback, consume: Boolean) { + LogCallbackState.set(callback, consume) + } + + /** Clears the process-global native log callback. */ + public actual fun clearLogCallback() { + LogCallbackState.clear() + } + + /** Configures severities that native logging may dispatch asynchronously. */ + public actual fun setAsyncLogSeverities(severities: Set) { + val mask = severities.fold(0) { accumulated, severity -> accumulated or severity.nativeMask } + BrowserModule.attach() + Status.check(mln_log_set_async_severity_mask(mask)) + } + + /** Restores the native default async log severity mask. */ + public actual fun restoreDefaultAsyncLogSeverities() { + BrowserModule.attach() + Status.check(mln_log_set_async_severity_mask(DEFAULT_LOG_SEVERITY_MASK)) + } + + /** Converts a geographic coordinate to spherical Mercator projected meters. */ + public actual fun projectedMetersForLatLng(coordinate: LatLng): ProjectedMeters { + BrowserModule.attach() + return Heap.withScratch(MlnLatLng.SIZEOF + MlnProjectedMeters.SIZEOF) { scratch -> + val out = scratch + MlnLatLng.SIZEOF + MlnLatLng.setLatitude(scratch, coordinate.latitude) + MlnLatLng.setLongitude(scratch, coordinate.longitude) + Status.check(mln_projected_meters_for_lat_lng(scratch.address, out.address)) + ProjectedMeters(MlnProjectedMeters.northing(out), MlnProjectedMeters.easting(out)) + } + } + + /** Converts spherical Mercator projected meters to a geographic coordinate. */ + public actual fun latLngForProjectedMeters(meters: ProjectedMeters): LatLng { + BrowserModule.attach() + return Heap.withScratch(MlnProjectedMeters.SIZEOF + MlnLatLng.SIZEOF) { scratch -> + val out = scratch + MlnProjectedMeters.SIZEOF + MlnProjectedMeters.setNorthing(scratch, meters.northing) + MlnProjectedMeters.setEasting(scratch, meters.easting) + Status.check(mln_lat_lng_for_projected_meters(scratch.address, out.address)) + LatLng(MlnLatLng.latitude(out), MlnLatLng.longitude(out)) + } + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackRing.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackRing.kt new file mode 100644 index 000000000..3137962f9 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackRing.kt @@ -0,0 +1,112 @@ +package org.maplibre.nativeffi.internal.callback + +import org.maplibre.nativeffi.geo.CanonicalTileId +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.generated.MlnKotlinRecord +import org.maplibre.nativeffi.internal.wasm.generated.MlnKotlinRecordKind +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_dropped_records +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_set_wake +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_take_record + +/** One custom geometry source's tile callbacks, as the ring reaches them. */ +internal interface RingTileCallbacks { + fun tile(tileId: CanonicalTileId, cancelled: Boolean) + + /** Reports that native raises no further tile callback for this registration. */ + fun retired() +} + +/** + * The one path from a MapLibre thread into this binding. + * + * A JavaScript function belongs to the agent that defined it, so no MapLibre thread may enter this + * WebAssembly instance. The C shim copies each callback into a bounded ring instead, and this + * drains the ring inside `pump`. A retirement marker travels in the same ring, behind the records + * it retires. + */ +internal object CallbackRing { + /** The tile z that `mln_adapter_custom_geometry_callbacks_retire` marks a retirement with. */ + private const val RETIREMENT_TILE_Z = 255 + + private val tileCallbacks = mutableMapOf() + private var nextTileToken = 1 + private var wake = 0L + + /** How many records the ring dropped because a host stopped draining it, cumulative. */ + val droppedRecords: Long + get() = mln_kotlin_dropped_records() + + /** Delivers every queued record, oldest first. */ + fun drain() { + Heap.withScratch(MlnKotlinRecord.SIZEOF) { record -> + while (mln_kotlin_take_record(record.address) != 0) { + // Contained, because a record that fails to decode must not strand the ones behind it, and + // the pump this runs inside is nobody's callback to report to. The branch that takes a + // payload releases it whatever its delivery does. + runCatching { deliver(record) } + } + } + } + + /** + * Names the wake source that a producing thread signals after queueing a record. + * + * One source at a time, so with two runtimes a record releases the newer one's parked pump and + * the other returns on its own timeout. + */ + fun setWake(source: Long) { + wake = source + mln_kotlin_set_wake(source) + } + + /** Clears [source] if it is the one installed, before the runtime that owns it destroys it. */ + fun clearWake(source: Long) { + if (wake != source) return + wake = 0 + mln_kotlin_set_wake(0) + } + + /** Registers [callbacks] and returns the `user_data` to register natively for them. */ + fun addTileCallbacks(callbacks: RingTileCallbacks): HeapPointer { + // A token rather than an address, because native carries this value back unread. Counting from + // one keeps it distinguishable from a null user_data. + val token = nextTileToken + nextTileToken += 1 + tileCallbacks[token] = callbacks + return HeapPointer(token) + } + + private fun deliver(record: HeapPointer) { + val payload = MlnKotlinRecord.payload(record) + when (MlnKotlinRecord.kind(record)) { + MlnKotlinRecordKind.MLN_KOTLIN_RECORD_LOG -> LogCallbackState.deliver(payload) + MlnKotlinRecordKind.MLN_KOTLIN_RECORD_LOG_RETIRED -> LogCallbackState.retired() + MlnKotlinRecordKind.MLN_KOTLIN_RECORD_RESOURCE_REQUEST -> + QueuedResourceProviders.deliver(payload) + MlnKotlinRecordKind.MLN_KOTLIN_RECORD_RESOURCE_PROVIDER_RETIRED -> + QueuedResourceProviders.retired() + MlnKotlinRecordKind.MLN_KOTLIN_RECORD_TILE_FETCH -> tile(record, payload, cancelled = false) + MlnKotlinRecordKind.MLN_KOTLIN_RECORD_TILE_CANCEL -> tile(record, payload, cancelled = true) + } + } + + private fun tile(record: HeapPointer, userData: HeapPointer, cancelled: Boolean) { + val z = MlnKotlinRecord.tileZ(record) + if (z == RETIREMENT_TILE_Z) { + // Retirement invokes both callbacks once, so the second one finds the entry already gone. + tileCallbacks.remove(userData.address)?.retired() + return + } + val callbacks = tileCallbacks[userData.address] ?: return + // Unsigned in C and signed here, so they widen through their bit pattern into the Long the + // public type carries the whole unsigned domain in. + val tileId = + CanonicalTileId( + z, + MlnKotlinRecord.tileX(record).toUInt().toLong(), + MlnKotlinRecord.tileY(record).toUInt().toLong(), + ) + callbacks.tile(tileId, cancelled) + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackThreadState.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackThreadState.kt new file mode 100644 index 000000000..c559a6951 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/CallbackThreadState.kt @@ -0,0 +1,17 @@ +package org.maplibre.nativeffi.internal.callback + +// A plain count where the other targets keep a thread local: Kotlin/Wasm has one thread, and every +// callback body reaches it from the ring drain on that thread, so this count belongs to it. +internal actual class CallbackThreadState actual constructor() { + private var depth = 0 + + actual fun enter() { + depth += 1 + } + + actual fun exit() { + depth -= 1 + } + + actual fun isInCallback(): Boolean = depth > 0 +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt new file mode 100644 index 000000000..8e32bd749 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/LogCallbackState.kt @@ -0,0 +1,97 @@ +package org.maplibre.nativeffi.internal.callback + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterLogRecord +import org.maplibre.nativeffi.internal.wasm.generated.mln_adapter_log_record_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_log_clear +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_log_install +import org.maplibre.nativeffi.log.LogCallback +import org.maplibre.nativeffi.log.LogEvent +import org.maplibre.nativeffi.log.LogRecord +import org.maplibre.nativeffi.log.LogSeverity + +/** Owns the process-global log callback registration. */ +internal object LogCallbackState { + private const val SUBJECT = "log callbacks" + private const val INSTALL = "mln_kotlin_log_install" + private const val CLEAR = "mln_kotlin_log_clear" + + /** The registration that records produced now belong to. */ + private var current: Registration? = null + + /** + * The registrations a clear retired, oldest first. + * + * A cleared registration keeps receiving until its marker comes out of the ring, because every + * record ahead of that marker is one it was installed for. + */ + private val retiring = ArrayDeque() + + fun set(callback: LogCallback, consume: Boolean) { + current?.checkCanClose() + val replacement = Registration(callback) + InjectedFaults.beginCall(INSTALL) + // Installed over the previous registration rather than cleared first: the shim identifies its + // registration by one state address, so a clear would leave native logging to nobody until the + // install landed, and a refused install would leave it that way for good. + Status.check(mln_kotlin_log_install(if (consume) 1 else 0)) + val previous = current + current = replacement + // Native saw no retirement, so no marker is coming and the replaced registration stops here. + previous?.close() + } + + fun clear() { + current?.checkCanClose() + InjectedFaults.beginCall(CLEAR) + Status.check(mln_kotlin_log_clear()) + current?.let { retiring.addLast(it) } + current = null + } + + /** Delivers one `mln_adapter_log_record` and releases it. */ + fun deliver(record: HeapPointer) { + try { + val target = retiring.firstOrNull() ?: current ?: return + target.deliver( + LogRecord( + LogSeverity.fromNative(MlnAdapterLogRecord.severity(record)), + LogEvent.fromNative(MlnAdapterLogRecord.event(record)), + MlnAdapterLogRecord.code(record), + Heap.loadUtf8(MlnAdapterLogRecord.message(record)), + ) + ) + } finally { + mln_adapter_log_record_destroy(record.address) + } + } + + /** + * Retires the oldest cleared registration, which every record ahead of the marker belonged to. + */ + fun retired() { + retiring.removeFirstOrNull()?.close() + } + + /** One host callback's registration, which outlives its native registration by the clear. */ + private class Registration(private val callback: LogCallback) { + private val gate = CallbackGate(SUBJECT) + + fun deliver(record: LogRecord) { + val lease = gate.enter() ?: return + try { + // Contained: a failing callback must not stop the drain, and no frame above it is native. + runCatching { callback.log(record) } + } finally { + lease.close() + } + } + + fun checkCanClose() = gate.checkCanClose() + + fun close() = gate.close() + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/QueuedResourceProviders.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/QueuedResourceProviders.kt new file mode 100644 index 000000000..c84781e0a --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/QueuedResourceProviders.kt @@ -0,0 +1,321 @@ +package org.maplibre.nativeffi.internal.callback + +import org.maplibre.nativeffi.internal.lifecycle.NativeResourceRequest +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterQueuedResourceProvider +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterQueuedResourceProviderRoute +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterQueuedResourceRequest +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterResourceRouteFlags +import org.maplibre.nativeffi.internal.wasm.generated.MlnResourceProvider +import org.maplibre.nativeffi.internal.wasm.generated.mln_adapter_queued_resource_provider_retire +import org.maplibre.nativeffi.internal.wasm.generated.mln_adapter_resource_provider_request_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_queued_provider_callback +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_resource_request_listener +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_clear_resource_provider +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_set_resource_provider +import org.maplibre.nativeffi.resource.QueuedResourceProviderCallback +import org.maplibre.nativeffi.resource.ResourceErrorReason +import org.maplibre.nativeffi.resource.ResourceKind +import org.maplibre.nativeffi.resource.ResourceLoadingMethod +import org.maplibre.nativeffi.resource.ResourcePriority +import org.maplibre.nativeffi.resource.ResourceProviderRoute +import org.maplibre.nativeffi.resource.ResourceRequest +import org.maplibre.nativeffi.resource.ResourceRequestHandle +import org.maplibre.nativeffi.resource.ResourceResponse +import org.maplibre.nativeffi.resource.ResourceResponseStatus +import org.maplibre.nativeffi.resource.ResourceStoragePolicy +import org.maplibre.nativeffi.resource.ResourceUsage + +/** The rule kind that matches every resource kind, `MLN_ADAPTER_RESOURCE_KIND_ANY`. */ +internal const val RESOURCE_KIND_ANY: Int = -1 + +/** + * Owns the queued resource provider registrations that the ring delivers to. + * + * MapLibre needs a pass-through decision on the thread that raised the request, and this binding + * cannot answer there, so routes declared at registration claim requests and host code answers them + * later. The retirement marker is what says the routes are read no more, and so when their heap can + * go. + * + * The registrations are global rather than per runtime, because a queued record names no provider: + * one ring cannot say which of two a request was claimed for. A second concurrent registration + * reports invalid argument instead. + */ +internal object QueuedResourceProviders { + private const val SUBJECT = "resource provider callbacks" + private const val SET = "mln_runtime_set_resource_provider" + private const val CLEAR = "mln_runtime_clear_resource_provider" + + private var current: Registration? = null + + /** The registrations awaiting their marker, oldest first; delivery goes to the oldest. */ + private val retiring = ArrayDeque() + + /** + * The registrations still holding a block of the module's heap, for the tests. + * + * A registration that native refused holds none, and one a marker has released holds none, so + * this is what says a refusal left nothing behind. + */ + val liveRegistrations: Int + get() = (if (current == null) 0 else 1) + retiring.size + + /** + * Registers or replaces [runtime]'s queued provider, keeping the previous one if native refuses. + */ + fun set( + runtime: Long, + routes: List, + callback: QueuedResourceProviderCallback, + ) { + val previous = current + previous?.checkCanClose() + Status.requireArgument(previous == null || previous.runtime == runtime) { + "One queued resource provider can be registered at a time, because a queued request names " + + "the routes that claimed it rather than the provider that declared them. Clear the other " + + "runtime's provider first." + } + val replacement = Registration(runtime, routes, callback) + try { + InjectedFaults.beginCall(SET) + Status.check(mln_runtime_set_resource_provider(runtime, replacement.descriptor.address)) + } catch (error: Throwable) { + replacement.release() + throw error + } + current = replacement + previous?.retire() + } + + /** Clears [runtime]'s queued provider, which native accepts whether or not one was set. */ + fun clear(runtime: Long) { + current?.checkCanClose() + InjectedFaults.beginCall(CLEAR) + Status.check(mln_runtime_clear_resource_provider(runtime)) + val previous = current + current = null + previous?.retire() + } + + /** Retires [runtime]'s provider after a runtime close, which dropped the registration itself. */ + fun retireFor(runtime: Long) { + val previous = current ?: return + if (previous.runtime != runtime) return + current = null + previous.retire() + } + + /** Delivers one `mln_adapter_queued_resource_request` and releases it. */ + fun deliver(record: HeapPointer) { + val handle = + ResourceRequestHandle.forQueuedRequest( + NativeResourceRequest(MlnAdapterQueuedResourceRequest.handle(record)) + ) + try { + val target = retiring.firstOrNull() ?: current + val request = readRequest(record) + if (target == null || !target.deliver(request, handle)) { + // Native is waiting for this request, and no host code will answer it, so it is failed + // rather than left outstanding for the life of the page. + fail(handle, "the resource provider that claimed this request has been retired") + } + } catch (_: Throwable) { + fail(handle, "the resource request could not be copied for the provider callback") + } finally { + mln_adapter_resource_provider_request_destroy(record.address) + } + } + + /** Retires the oldest registration, which every request ahead of the marker was claimed for. */ + fun retired() { + retiring.removeFirstOrNull()?.release() + } + + /** Completes a request no host callback took, and closes the handle if that failed too. */ + private fun fail(handle: ResourceRequestHandle, diagnostic: String) { + val response = + ResourceResponse(ResourceResponseStatus.ERROR).apply { + errorReason = ResourceErrorReason.OTHER + errorMessage = diagnostic + } + try { + handle.complete(response) + } catch (_: Throwable) { + handle.close() + } + } + + private fun readRequest(record: HeapPointer): ResourceRequest = + ResourceRequest( + requestedUrl = Heap.loadUtf8(MlnAdapterQueuedResourceRequest.requestedUrl(record)), + resolvedUrl = Heap.loadUtf8(MlnAdapterQueuedResourceRequest.resolvedUrl(record)), + kind = ResourceKind.fromNative(MlnAdapterQueuedResourceRequest.kind(record)), + loadingMethod = + ResourceLoadingMethod.fromNative(MlnAdapterQueuedResourceRequest.loadingMethod(record)), + priority = ResourcePriority.fromNative(MlnAdapterQueuedResourceRequest.priority(record)), + usage = ResourceUsage.fromNative(MlnAdapterQueuedResourceRequest.usage(record)), + storagePolicy = + ResourceStoragePolicy.fromNative(MlnAdapterQueuedResourceRequest.storagePolicy(record)), + range = + if (MlnAdapterQueuedResourceRequest.hasRange(record)) { + ResourceRequest.ByteRange( + MlnAdapterQueuedResourceRequest.rangeStart(record), + MlnAdapterQueuedResourceRequest.rangeEnd(record), + ) + } else { + null + }, + priorModifiedUnixMs = + if (MlnAdapterQueuedResourceRequest.hasPriorModified(record)) { + MlnAdapterQueuedResourceRequest.priorModifiedUnixMs(record) + } else { + null + }, + priorExpiresUnixMs = + if (MlnAdapterQueuedResourceRequest.hasPriorExpires(record)) { + MlnAdapterQueuedResourceRequest.priorExpiresUnixMs(record) + } else { + null + }, + // Null and empty mean different things here: no prior ETag at all, against one that is the + // empty string. Reading the string would collapse them. + priorEtag = + MlnAdapterQueuedResourceRequest.priorEtag(record).let { + if (it.address == 0) null else Heap.loadUtf8(it) + }, + priorData = + MlnAdapterQueuedResourceRequest.priorData(record).let { + if (it.address == 0) { + ByteArray(0) + } else { + Heap.loadBytes(it, MlnAdapterQueuedResourceRequest.priorDataSize(record)) + } + }, + ) + + /** + * One host callback's registration, and the native descriptor it is registered through. + * + * The descriptor, its route table, and the route URLs share one heap block that native borrows + * for the registration's whole life, and that the retirement marker releases. + */ + private class Registration( + val runtime: Long, + routes: List, + private val callback: QueuedResourceProviderCallback, + ) { + private val gate = CallbackGate(SUBJECT) + private var retirementAsked = false + + /** The `mln_resource_provider` to register, in the block this registration owns. */ + val descriptor: HeapPointer + + private val block: HeapPointer + private val provider: HeapPointer + + init { + routes.forEach { Heap.requireCString(it.url, "route url") } + var total = HeapArena.aligned(MlnResourceProvider.SIZEOF.toLong(), POINTER_ALIGN) + total += HeapArena.aligned(MlnAdapterQueuedResourceProvider.SIZEOF.toLong(), POINTER_ALIGN) + total += + HeapArena.aligned( + Heap.sizeOf(MlnAdapterQueuedResourceProviderRoute.SIZEOF, routes.size).toLong(), + POINTER_ALIGN, + ) + routes.forEach { total += Heap.utf8Size(it.url).toLong() } + Status.requireArgument(total <= Int.MAX_VALUE) { "the route table is too large to place" } + + block = Heap.acquire(total.toInt()) + try { + val arena = HeapArena(block, total.toInt()) + descriptor = arena.allocate(MlnResourceProvider.SIZEOF, POINTER_ALIGN) + provider = arena.allocate(MlnAdapterQueuedResourceProvider.SIZEOF, POINTER_ALIGN) + val table = + arena.allocate( + Heap.sizeOf(MlnAdapterQueuedResourceProviderRoute.SIZEOF, routes.size), + POINTER_ALIGN, + ) + routes.forEachIndexed { index, route -> + val entry = table + index * MlnAdapterQueuedResourceProviderRoute.SIZEOF + val url = arena.allocate(Heap.utf8Size(route.url), BYTE_ALIGN) + Heap.storeUtf8(url, route.url) + MlnAdapterQueuedResourceProviderRoute.setKind( + entry, + route.kind?.nativeValue ?: RESOURCE_KIND_ANY, + ) + MlnAdapterQueuedResourceProviderRoute.setFlags(entry, flagsOf(route)) + MlnAdapterQueuedResourceProviderRoute.setUrl(entry, url) + } + MlnAdapterQueuedResourceProvider.setRoutes(provider, table) + MlnAdapterQueuedResourceProvider.setRouteCount(provider, routes.size) + // The layout generator leaves a function-pointer field to its caller, so the table index + // the + // shim reports is written at the offset the generator declares for it. + Heap.storeInt( + provider + MlnAdapterQueuedResourceProvider.OFFSET_LISTENER, + mln_kotlin_resource_request_listener(), + ) + MlnResourceProvider.setSize(descriptor, MlnResourceProvider.SIZEOF) + Heap.storeInt( + descriptor + MlnResourceProvider.OFFSET_CALLBACK, + mln_kotlin_queued_provider_callback(), + ) + MlnResourceProvider.setUserData(descriptor, provider) + } catch (error: Throwable) { + Heap.release(block) + throw error + } + } + + /** Runs [callback], or reports that this registration has stopped admitting requests. */ + fun deliver(request: ResourceRequest, handle: ResourceRequestHandle): Boolean { + val lease = gate.enter() ?: return false + try { + callback.handle(request, handle) + } catch (_: Throwable) { + fail(handle, "the resource provider callback failed") + } finally { + lease.close() + } + return true + } + + fun checkCanClose() = gate.checkCanClose() + + /** Asks native for the marker that says the routes are read no more. */ + fun retire() { + if (retirementAsked) return + retirementAsked = true + retiring.addLast(this) + mln_adapter_queued_resource_provider_retire(provider.address) + } + + /** Stops delivering and releases the block the routes live in. */ + fun release() { + try { + gate.close() + } finally { + Heap.release(block) + } + } + + private companion object { + const val POINTER_ALIGN = 4 + const val BYTE_ALIGN = 1 + } + } + + private fun flagsOf(route: ResourceProviderRoute): Int { + var flags = MlnAdapterResourceRouteFlags.MLN_ADAPTER_RESOURCE_ROUTE_FLAGS_NONE + if (route.matchGlob) + flags = flags or MlnAdapterResourceRouteFlags.MLN_ADAPTER_RESOURCE_ROUTE_MATCH_GLOB + if (route.useRequestedUrl) { + flags = flags or MlnAdapterResourceRouteFlags.MLN_ADAPTER_RESOURCE_ROUTE_USE_REQUESTED_URL + } + return flags + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/ResourceRewriteRules.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/ResourceRewriteRules.kt new file mode 100644 index 000000000..9b24f9732 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/callback/ResourceRewriteRules.kt @@ -0,0 +1,145 @@ +package org.maplibre.nativeffi.internal.callback + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterResourceRewriteRule +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterResourceRewriteRules +import org.maplibre.nativeffi.internal.wasm.generated.MlnAdapterUrlMatchFlags +import org.maplibre.nativeffi.internal.wasm.generated.MlnResourceTransform +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_rewrite_transform_callback +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_clear_resource_transform +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_set_resource_transform +import org.maplibre.nativeffi.resource.ResourceUrlRewriteRule + +/** + * One runtime's native rule table, which answers this target's resource transforms. + * + * MapLibre needs a transformed URL on the thread that raised the request, and a rule table is the + * answer the C API offers a binding that cannot run host code there. The table is read only while + * the transform is registered, so the block holding it is released once the call that replaced or + * cleared it returns. + */ +internal class ResourceRewriteRules { + private var installed: HeapPointer? = null + set(value) { + if ((field == null) != (value == null)) liveRegistrations += if (value == null) -1 else 1 + field = value + } + + /** Registers or replaces [runtime]'s rewrite rules. */ + fun set(runtime: Long, rules: List) { + val block = place(rules) + try { + InjectedFaults.beginCall(SET) + Status.check(mln_runtime_set_resource_transform(runtime, block.descriptor.address)) + } catch (error: Throwable) { + Heap.release(block.base) + throw error + } + // The call above returned, so native reads the previous table no more. + installed?.let { Heap.release(it) } + installed = block.base + } + + /** Clears [runtime]'s rewrite rules, which native accepts whether or not a table was set. */ + fun clear(runtime: Long) { + InjectedFaults.beginCall(CLEAR) + Status.check(mln_runtime_clear_resource_transform(runtime)) + installed?.let { Heap.release(it) } + installed = null + } + + /** Releases the installed table after native has dropped it with the runtime that held it. */ + fun release() { + installed?.let { Heap.release(it) } + installed = null + } + + private class Placed(val base: HeapPointer, val descriptor: HeapPointer) + + /** Places the transform descriptor, the rule table, and every rule URL in one block. */ + private fun place(rules: List): Placed { + rules.forEach { + Heap.requireCString(it.url, "rule url") + it.replacementUrl?.let { replacement -> Heap.requireCString(replacement, "replacement url") } + } + var total = HeapArena.aligned(MlnResourceTransform.SIZEOF.toLong(), POINTER_ALIGN) + total += HeapArena.aligned(MlnAdapterResourceRewriteRules.SIZEOF.toLong(), POINTER_ALIGN) + total += + HeapArena.aligned( + Heap.sizeOf(MlnAdapterResourceRewriteRule.SIZEOF, rules.size).toLong(), + POINTER_ALIGN, + ) + rules.forEach { + total += Heap.utf8Size(it.url).toLong() + it.replacementUrl?.let { replacement -> total += Heap.utf8Size(replacement).toLong() } + } + Status.requireArgument(total <= Int.MAX_VALUE) { "the rule table is too large to place" } + + val base = Heap.acquire(total.toInt()) + try { + val arena = HeapArena(base, total.toInt()) + val descriptor = arena.allocate(MlnResourceTransform.SIZEOF, POINTER_ALIGN) + val table = arena.allocate(MlnAdapterResourceRewriteRules.SIZEOF, POINTER_ALIGN) + val entries = + arena.allocate(Heap.sizeOf(MlnAdapterResourceRewriteRule.SIZEOF, rules.size), POINTER_ALIGN) + rules.forEachIndexed { index, rule -> + val entry = entries + index * MlnAdapterResourceRewriteRule.SIZEOF + MlnAdapterResourceRewriteRule.setKind(entry, rule.kind?.nativeValue ?: RESOURCE_KIND_ANY) + MlnAdapterResourceRewriteRule.setFlags( + entry, + if (rule.matchGlob) { + MlnAdapterUrlMatchFlags.MLN_ADAPTER_URL_MATCH_GLOB + } else { + MlnAdapterUrlMatchFlags.MLN_ADAPTER_URL_MATCH_FLAGS_NONE + }, + ) + MlnAdapterResourceRewriteRule.setUrl(entry, write(arena, rule.url)) + // A null replacement leaves the URL unchanged, so it stays the null pointer the zeroed + // block already holds. + rule.replacementUrl?.let { + MlnAdapterResourceRewriteRule.setReplacementUrl(entry, write(arena, it)) + } + } + MlnAdapterResourceRewriteRules.setRules(table, entries) + MlnAdapterResourceRewriteRules.setCount(table, rules.size) + MlnResourceTransform.setSize(descriptor, MlnResourceTransform.SIZEOF) + // The layout generator leaves a function-pointer field to its caller, so the table index the + // shim reports is written at the offset the generator declares for it. + Heap.storeInt( + descriptor + MlnResourceTransform.OFFSET_CALLBACK, + mln_kotlin_rewrite_transform_callback(), + ) + MlnResourceTransform.setUserData(descriptor, table) + return Placed(base, descriptor) + } catch (error: Throwable) { + Heap.release(base) + throw error + } + } + + private fun write(arena: HeapArena, text: String): HeapPointer { + val pointer = arena.allocate(Heap.utf8Size(text), BYTE_ALIGN) + Heap.storeUtf8(pointer, text) + return pointer + } + + internal companion object { + /** + * The rule tables still holding a block of the module's heap, across every runtime. + * + * For the tests: a table native refused holds none, so this is what says a refusal left the + * previous one standing and nothing else. + */ + var liveRegistrations: Int = 0 + private set + + const val SET = "mln_runtime_set_resource_transform" + const val CLEAR = "mln_runtime_clear_resource_transform" + const val POINTER_ALIGN = 4 + const val BYTE_ALIGN = 1 + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/CloseYield.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/CloseYield.kt new file mode 100644 index 000000000..df15beb83 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/lifecycle/CloseYield.kt @@ -0,0 +1,5 @@ +package org.maplibre.nativeffi.internal.lifecycle + +// Kotlin/Wasm has one thread and no scheduler call that gives it up, so the spin is the yield. The +// count a close waits on is held by a native call on another thread, exactly as it is elsewhere. +internal actual fun yieldWhileClosing() {} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/status/NativeDiagnostics.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/status/NativeDiagnostics.kt new file mode 100644 index 000000000..c816c4c0e --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/status/NativeDiagnostics.kt @@ -0,0 +1,10 @@ +package org.maplibre.nativeffi.internal.status + +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.generated.mln_thread_last_error_message + +internal actual object NativeDiagnostics { + actual fun currentDiagnostic(): String = + Heap.loadUtf8(HeapPointer(mln_thread_last_error_message())) +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/BrowserModule.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/BrowserModule.kt new file mode 100644 index 000000000..9f3a26178 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/BrowserModule.kt @@ -0,0 +1,56 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.internal.status.Status + +// Emscripten publishes the module on the global scope of the thread that runs it, under a name any +// other module also takes, so an entry point decides which one this is. Reported rather than +// thrown, because a `@JsFun` body compiles to an arrow function that sees only its arguments. +@JsFun( + """ + () => { + const module = globalThis.Module + if (module === undefined || typeof module._mln_c_version !== "function") return false + globalThis.__maplibreNativeC = module + return true + } +""" +) +private external fun aliasModule(): Boolean + +/** + * Starts the binding on the thread that the Emscripten module imported it into. + * + * The module's `main()` runs on the pthread that `-sPROXY_TO_PTHREAD` gave it, imports this + * distribution from `maplibre-native-kotlin.mjs` beside the module, and calls this. That thread may + * block, so every call this binding makes into the C API is a same-thread call, as on every other + * platform. + * + * A distribution built as an executable runs its own `main()` while it is being imported, which is + * before this, so [org.maplibre.nativeffi.Maplibre.loadNativeLibrary] names the module as well. + * + * `@JsExport` rather than `@WasmExport`: the module calls this as a named export of the generated + * JavaScript, and a raw WebAssembly export is reachable only through `wasmExports`. + */ +@OptIn(ExperimentalJsExport::class) +@JsExport +public fun mlnKotlinMain() { + BrowserModule.attach() +} + +/** The Emscripten module that this binding calls, which is the one Kotlin was imported into. */ +internal object BrowserModule { + private var attached = false + + /** Names the module for the generated entry points, each of which reads it on every call. */ + fun attach() { + if (attached) return + if (!aliasModule()) { + throw Status.invalidState( + "The MapLibre Native browser module is not on this thread's global scope. This binding runs " + + "inside the module, on the thread that its main() imported Kotlin into. A Kotlin module " + + "that a page or a worker of its own loaded has no native code to call." + ) + } + attached = true + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/CameraMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/CameraMarshal.kt new file mode 100644 index 000000000..a765a0b61 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/CameraMarshal.kt @@ -0,0 +1,137 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.camera.CameraOptions +import org.maplibre.nativeffi.camera.EdgeInsets +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.ScreenPoint +import org.maplibre.nativeffi.internal.wasm.generated.MlnCameraOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnCameraOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnEdgeInsets +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLng +import org.maplibre.nativeffi.internal.wasm.generated.MlnScreenPoint + +/** + * Places a [CameraOptions] into the Emscripten heap, and reads one back. + * + * The C descriptor pairs its values with a bit per field, so an absent Kotlin value is a bit left + * clear rather than a sentinel written into the value. Reading works the same way: a bit that is + * clear produces null rather than whatever the field happened to hold. + * + * Every offset and width here comes from the generated accessors, so this code names fields. + */ +internal object CameraMarshal { + /** Bytes one camera descriptor occupies, including its nested padding and anchor. */ + val SIZEOF: Int = MlnCameraOptions.SIZEOF + + /** + * Writes the descriptor header alone, for a buffer native fills. + * + * An output descriptor still states its size: native reads it to decide which fields it may + * write, and a zeroed block would ask for a zero-sized camera. + */ + fun writeHeader(base: HeapPointer) { + MlnCameraOptions.setSize(base, MlnCameraOptions.SIZEOF) + } + + /** Writes [camera] at [base], setting a field's bit only where the value is present. */ + fun write(base: HeapPointer, camera: CameraOptions) { + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnCameraOptions.setSize(base, MlnCameraOptions.SIZEOF) + var fields = 0 + camera.center?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_CENTER + MlnCameraOptions.setLatitude(base, it.latitude) + MlnCameraOptions.setLongitude(base, it.longitude) + } + camera.centerAltitude?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_CENTER_ALTITUDE + MlnCameraOptions.setCenterAltitude(base, it) + } + camera.padding?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_PADDING + writeEdgeInsets(base + MlnCameraOptions.OFFSET_PADDING, it) + } + camera.anchor?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_ANCHOR + val anchor = base + MlnCameraOptions.OFFSET_ANCHOR + MlnScreenPoint.setX(anchor, it.x) + MlnScreenPoint.setY(anchor, it.y) + } + camera.zoom?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_ZOOM + MlnCameraOptions.setZoom(base, it) + } + camera.bearing?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_BEARING + MlnCameraOptions.setBearing(base, it) + } + camera.pitch?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_PITCH + MlnCameraOptions.setPitch(base, it) + } + camera.roll?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_ROLL + MlnCameraOptions.setRoll(base, it) + } + camera.fieldOfView?.let { + fields = fields or MlnCameraOptionField.MLN_CAMERA_OPTION_FOV + MlnCameraOptions.setFieldOfView(base, it) + } + MlnCameraOptions.setFields(base, fields) + } + + /** Reads the camera at [base], producing null for every field whose bit is clear. */ + fun read(base: HeapPointer): CameraOptions { + val fields = MlnCameraOptions.fields(base) + fun has(bit: Int) = (fields and bit) != 0 + return CameraOptions().also { + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_CENTER)) { + it.center = LatLng(MlnCameraOptions.latitude(base), MlnCameraOptions.longitude(base)) + } + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_CENTER_ALTITUDE)) { + it.centerAltitude = MlnCameraOptions.centerAltitude(base) + } + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_PADDING)) { + it.padding = readEdgeInsets(base + MlnCameraOptions.OFFSET_PADDING) + } + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_ANCHOR)) { + val anchor = base + MlnCameraOptions.OFFSET_ANCHOR + it.anchor = ScreenPoint(MlnScreenPoint.x(anchor), MlnScreenPoint.y(anchor)) + } + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_ZOOM)) it.zoom = MlnCameraOptions.zoom(base) + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_BEARING)) { + it.bearing = MlnCameraOptions.bearing(base) + } + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_PITCH)) it.pitch = MlnCameraOptions.pitch(base) + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_ROLL)) it.roll = MlnCameraOptions.roll(base) + if (has(MlnCameraOptionField.MLN_CAMERA_OPTION_FOV)) { + it.fieldOfView = MlnCameraOptions.fieldOfView(base) + } + } + } + + /** Writes an edge-inset descriptor, which carries no field mask of its own. */ + fun writeEdgeInsets(base: HeapPointer, insets: EdgeInsets) { + MlnEdgeInsets.setTop(base, insets.top) + MlnEdgeInsets.setLeft(base, insets.left) + MlnEdgeInsets.setBottom(base, insets.bottom) + MlnEdgeInsets.setRight(base, insets.right) + } + + fun readEdgeInsets(base: HeapPointer): EdgeInsets = + EdgeInsets( + MlnEdgeInsets.top(base), + MlnEdgeInsets.left(base), + MlnEdgeInsets.bottom(base), + MlnEdgeInsets.right(base), + ) + + fun writeLatLng(base: HeapPointer, coordinate: LatLng) { + MlnLatLng.setLatitude(base, coordinate.latitude) + MlnLatLng.setLongitude(base, coordinate.longitude) + } + + fun readLatLng(base: HeapPointer): LatLng = + LatLng(MlnLatLng.latitude(base), MlnLatLng.longitude(base)) +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/CustomGeometryBridge.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/CustomGeometryBridge.kt new file mode 100644 index 000000000..e828bfb9b --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/CustomGeometryBridge.kt @@ -0,0 +1,93 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.geo.CanonicalTileId +import org.maplibre.nativeffi.internal.callback.CallbackGate +import org.maplibre.nativeffi.internal.callback.CallbackRing +import org.maplibre.nativeffi.internal.callback.RingTileCallbacks +import org.maplibre.nativeffi.internal.wasm.generated.mln_adapter_custom_geometry_callbacks_retire +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_tile_cancel_callback +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_tile_fetch_callback +import org.maplibre.nativeffi.style.CustomGeometrySourceCallback + +/** + * One custom geometry source's registration of a Kotlin tile callback. + * + * The callbacks a source is added with are the browser module's own: they queue the tile id into + * the ring the runtime drains, so a request raised on MapLibre's tile-loader worker arrives on the + * thread this binding runs on. What carries the registration across that hop is the `user_data` + * value, which native returns unread with every tile. + * + * A source can be removed, and its map closed, while requests for it are still in the ring. [close] + * stops delivering at once and then asks the adapter to retire the callbacks, which queues a marker + * behind every record already in flight; the ring forgets the registration when that marker + * arrives, so a stale record finds nothing rather than a registration that reused its `user_data`. + */ +internal class CustomGeometryBridge +private constructor(private val callback: CustomGeometrySourceCallback) : + RingTileCallbacks, AutoCloseable { + private val gate = CallbackGate(SUBJECT, ::retire) + private var live = true + + /** The `user_data` to register, which the ring resolves back to this registration. */ + val userData: HeapPointer = CallbackRing.addTileCallbacks(this) + + override fun tile(tileId: CanonicalTileId, cancelled: Boolean) { + val lease = gate.enter() ?: return + try { + if (cancelled) callback.cancelTile(tileId) else callback.fetchTile(tileId) + } catch (_: Throwable) { + // A host failure leaves the tile unanswered, which is a state the source already has a + // meaning for: MapLibre shows nothing there until the host supplies data or invalidates it. + // There is no native frame above this to report into. + } finally { + lease.close() + } + } + + /** The ring drops the registration on the marker, and there is nothing else here to release. */ + override fun retired() = Unit + + /** + * Stops delivering to this callback and waits for a delivery already inside it. + * + * Waiting is what a retired callback owes its host: the host may dispose of whatever it gave the + * callback the moment this returns, and a body that resumed afterwards would use it. A host that + * removes its own source from inside `fetchTile` is the one caller that cannot wait, because the + * body it would wait for is the frame below it; [CallbackGate] stops admitting and leaves the + * retirement to that body as it returns. + */ + override fun close() { + if (!live) return + live = false + liveCount -= 1 + gate.close() + } + + /** Queues the marker that ends this registration, once the last delivery has left. */ + private fun retire() { + mln_adapter_custom_geometry_callbacks_retire( + mln_kotlin_tile_fetch_callback(), + mln_kotlin_tile_cancel_callback(), + userData.address, + ) + } + + internal companion object { + private const val SUBJECT = "custom geometry callbacks" + + private var liveCount = 0 + + /** Registers [callback] with the ring and returns the registration to hold. */ + fun install(callback: CustomGeometrySourceCallback): CustomGeometryBridge = + CustomGeometryBridge(callback).also { liveCount += 1 } + + /** The callbacks to register in the descriptor, which the browser module compiles in. */ + fun fetchCallback(): Int = mln_kotlin_tile_fetch_callback() + + fun cancelCallback(): Int = mln_kotlin_tile_cancel_callback() + + /** How many sources still hold a registration, for the tests that assert a teardown. */ + val liveRegistrations: Int + get() = liveCount + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/GeoJsonMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/GeoJsonMarshal.kt new file mode 100644 index 000000000..4d1d293d9 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/GeoJsonMarshal.kt @@ -0,0 +1,183 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.geo.Feature +import org.maplibre.nativeffi.geo.FeatureIdentifier +import org.maplibre.nativeffi.geo.GeoJson +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeature +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureCollection +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureIdentifierType +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeojson +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeojsonType + +/** + * Places a [GeoJson] or [Feature] tree into the Emscripten heap. + * + * A GeoJSON descriptor is the outermost of the three trees this binding writes: its arms reach into + * geometry through [GeometryMarshal] and into feature properties through [JsonMarshal], and all + * three share one arena so that the whole graph is one allocation and one release. The arena + * arithmetic comes from [JsonMarshal] for the same reason — the checked measure lives in one place. + * + * Depth is bounded by whichever marshaller owns the nesting: a GeoJSON descriptor is itself flat, + * so a feature collection adds no level of its own and nothing here needs a depth of its own. + * + * Measuring and writing walk the same shape in the same order, and each pair sits together so that + * a change to one is visible against the other. + */ +internal object GeoJsonMarshal { + /** Bytes [value] needs, including its root descriptor. */ + fun measure(value: GeoJson): Int = + JsonMarshal.plus(JsonMarshal.measureBlock(MlnGeojson.SIZEOF), measurePayload(value)).toInt() + + private fun measurePayload(value: GeoJson): Long = + when (value) { + // The geometry and feature arms hold a pointer, so the thing pointed at needs a block of its + // own; only the collection arm is held in place inside the root descriptor. + is GeoJson.GeometryValue -> measureGeometry(value.geometry) + is GeoJson.FeatureValue -> measureFeatureValue(value.feature) + is GeoJson.FeatureCollection -> + value.features.fold(JsonMarshal.measureArray(MlnFeature.SIZEOF, value.features.size)) { + total, + feature -> + JsonMarshal.plus(total, measureFeaturePayload(feature)) + } + } + + /** Writes [value] into [arena] and returns the root descriptor's address. */ + fun write(arena: HeapArena, value: GeoJson): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnGeojson.SIZEOF) + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnGeojson.setSize(base, MlnGeojson.SIZEOF) + val data = base + MlnGeojson.OFFSET_DATA + when (value) { + is GeoJson.GeometryValue -> { + MlnGeojson.setType(base, MlnGeojsonType.MLN_GEOJSON_TYPE_GEOMETRY) + // The arm is a bare pointer rather than a struct, so there is no generated field + // accessor to name; what is written is the union's own address. + Heap.storeInt(data, GeometryMarshal.write(arena, value.geometry).address) + } + is GeoJson.FeatureValue -> { + MlnGeojson.setType(base, MlnGeojsonType.MLN_GEOJSON_TYPE_FEATURE) + Heap.storeInt(data, writeFeature(arena, value.feature).address) + } + is GeoJson.FeatureCollection -> { + MlnGeojson.setType(base, MlnGeojsonType.MLN_GEOJSON_TYPE_FEATURE_COLLECTION) + val features = JsonMarshal.allocateArray(arena, MlnFeature.SIZEOF, value.features.size) + value.features.forEachIndexed { index, feature -> + writeFeatureInto(arena, features + index * MlnFeature.SIZEOF, feature) + } + MlnFeatureCollection.setFeatures(data, features) + MlnFeatureCollection.setFeatureCount(data, value.features.size) + } + } + return base + } + + /** Bytes [feature] needs, including its root descriptor. */ + fun measureFeature(feature: Feature): Int = measureFeatureValue(feature).toInt() + + /** Writes [feature] into [arena] and returns its descriptor's address. */ + fun writeFeature(arena: HeapArena, feature: Feature): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnFeature.SIZEOF) + writeFeatureInto(arena, base, feature) + return base + } + + private fun measureFeatureValue(feature: Feature): Long = + JsonMarshal.plus(JsonMarshal.measureBlock(MlnFeature.SIZEOF), measureFeaturePayload(feature)) + + /** + * Bytes [feature] needs below its own descriptor. + * + * Every addition is bounded as it is taken rather than only at the end. A subtotal that wrapped + * would produce a small positive size that passed both this check and the arena's, and the write + * that followed would run past the block. + */ + private fun measureFeaturePayload(feature: Feature): Long { + val geometry = measureGeometry(feature.geometry) + // Properties are a root member array rather than an object's, so their values start at depth 0. + val properties = JsonMarshal.measureMembers(feature.properties, 0) + return JsonMarshal.plus( + JsonMarshal.plus(geometry, properties), + measureIdentifier(feature.identifier), + ) + } + + private fun writeFeatureInto(arena: HeapArena, base: HeapPointer, feature: Feature) { + MlnFeature.setSize(base, MlnFeature.SIZEOF) + MlnFeature.setGeometry(base, GeometryMarshal.write(arena, feature.geometry)) + MlnFeature.setProperties(base, JsonMarshal.writeMembers(arena, feature.properties, 0)) + MlnFeature.setPropertyCount(base, feature.properties.size) + writeIdentifier(arena, base, feature.identifier) + } + + private fun measureIdentifier(identifier: FeatureIdentifier): Long = + when (identifier) { + // Scalars live in the descriptor's own union arm, so they need no storage of their own. + FeatureIdentifier.Null -> 0L + is FeatureIdentifier.UInt -> 0L + is FeatureIdentifier.Int -> 0L + is FeatureIdentifier.DoubleValue -> 0L + is FeatureIdentifier.StringValue -> JsonMarshal.measureText(identifier.value) + // An identifier read back from a native tag this binding did not recognise. Its shape is + // unknown, so there is nothing to measure and nothing that could be written back. + is FeatureIdentifier.Unknown -> throw unknownIdentifier(identifier) + } + + private fun writeIdentifier(arena: HeapArena, base: HeapPointer, identifier: FeatureIdentifier) { + val data = base + MlnFeature.OFFSET_IDENTIFIER + when (identifier) { + FeatureIdentifier.Null -> + MlnFeature.setIdentifierType( + base, + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_NULL, + ) + is FeatureIdentifier.UInt -> { + MlnFeature.setIdentifierType( + base, + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_UINT, + ) + // Carried as the bit pattern it was read as. The C arm is unsigned and Kotlin's Long + // is not, so reinterpreting here would change the identifier rather than preserve it. + Heap.storeLong(data, identifier.value) + } + is FeatureIdentifier.Int -> { + MlnFeature.setIdentifierType(base, MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_INT) + Heap.storeLong(data, identifier.value) + } + is FeatureIdentifier.DoubleValue -> { + MlnFeature.setIdentifierType( + base, + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_DOUBLE, + ) + Heap.storeDouble(data, identifier.value) + } + is FeatureIdentifier.StringValue -> { + MlnFeature.setIdentifierType( + base, + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_STRING, + ) + JsonMarshal.writeText(arena, data, identifier.value) + } + is FeatureIdentifier.Unknown -> throw unknownIdentifier(identifier) + } + } + + /** + * Bytes a geometry tree occupies here, paired with [GeometryMarshal.write]. + * + * Rounded up even though a geometry tree already measures to a multiple of this arena's alignment + * today: that marshaller measures against its own struct widths, and a header change there must + * not start costing this measure padding it never accounted for. + */ + private fun measureGeometry(geometry: Geometry): Long = + JsonMarshal.measureBlock(GeometryMarshal.measure(geometry)) + + private fun unknownIdentifier(identifier: FeatureIdentifier.Unknown) = + Status.invalidArgument( + "A feature identifier of unknown native type ${identifier.rawType} cannot be sent to native; " + + "it was read from a tag this binding does not recognise." + ) +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/GeometryMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/GeometryMarshal.kt new file mode 100644 index 000000000..a17a9305d --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/GeometryMarshal.kt @@ -0,0 +1,292 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnCoordinateSpan +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeometry +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeometryCollection +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeometryType +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLng +import org.maplibre.nativeffi.internal.wasm.generated.MlnMultiLineGeometry +import org.maplibre.nativeffi.internal.wasm.generated.MlnMultiPolygonGeometry +import org.maplibre.nativeffi.internal.wasm.generated.MlnPolygonGeometry + +/** + * Places a [Geometry] tree into the Emscripten heap. + * + * The C descriptor is a tagged union whose arms point at spans that point at further spans, and + * every one of those pointers has to address memory native can read. So the tree is measured first, + * placed in one arena, and handed to native as a single root pointer — which also means one + * allocation and one release however deep the tree goes. + * + * Measuring and writing walk the same shape in the same order. They are written next to each other + * for that reason: a change to one that is not made to the other is what the arena's own bounds + * check reports. + */ +internal object GeometryMarshal { + private const val POINTER_ALIGN = 4 + private const val COORDINATE_ALIGN = 8 + + /** + * Bytes [geometry] needs, including its root descriptor. + * + * Every addition and every element count is bounded as it is taken rather than only at the end. A + * subtotal that wrapped would produce a small positive size that passed both this check and the + * arena's, and the write that followed would run past the block. + */ + fun measure(geometry: Geometry): Int = + plus(MlnGeometry.SIZEOF.toLong(), measurePayload(geometry, 0)).toInt() + + /** Adds two measured sizes, refusing a total the heap could not address. */ + private fun plus(left: Long, right: Long): Long { + val total = left + right + if (total > Int.MAX_VALUE || total < 0) { + throw Status.invalidArgument("geometry is too large to place in the module's heap") + } + return total + } + + /** Sizes an array of [count] elements, refusing one the heap could not address. */ + private fun sizeOf(elementBytes: Int, count: Int): Long { + Status.requireArgument(count >= 0) { "geometry element count must be non-negative" } + return plus(elementBytes.toLong() * count, 0) + } + + private fun measurePayload(geometry: Geometry, depth: Int): Long { + requireDepth(depth) + return when (geometry) { + is Geometry.Empty -> 0L + // Held by value inside the root descriptor's union arm, so it needs no + // storage of its own. + is Geometry.Point -> 0L + is Geometry.LineString -> coordinates(geometry.coordinates.size) + is Geometry.MultiPoint -> coordinates(geometry.coordinates.size) + is Geometry.Polygon -> rings(geometry.rings) + is Geometry.MultiLineString -> rings(geometry.lines) + is Geometry.MultiPolygon -> + geometry.polygons.fold( + HeapArena.aligned( + sizeOf(MlnPolygonGeometry.SIZEOF, geometry.polygons.size), + POINTER_ALIGN, + ) + ) { total, polygon -> + plus(total, rings(polygon)) + } + is Geometry.Collection -> + geometry.geometries.fold( + HeapArena.aligned(sizeOf(MlnGeometry.SIZEOF, geometry.geometries.size), COORDINATE_ALIGN) + ) { total, child -> + plus(total, measurePayload(child, depth + 1)) + } + // A geometry read back from a native tag this binding did not recognise. Its shape is + // unknown, so there is nothing to measure and nothing that could be written back. + is Geometry.Unknown -> throw unknownGeometry(geometry) + } + } + + private fun unknownGeometry(geometry: Geometry.Unknown) = + Status.invalidArgument( + "A geometry of unknown native type ${geometry.rawType} cannot be sent to native; it was " + + "read from a tag this binding does not recognise." + ) + + private fun coordinates(count: Int): Long = + HeapArena.aligned(sizeOf(MlnLatLng.SIZEOF, count), COORDINATE_ALIGN) + + private fun rings(rings: List>): Long = + rings.fold(HeapArena.aligned(sizeOf(MlnCoordinateSpan.SIZEOF, rings.size), POINTER_ALIGN)) { + total, + ring -> + plus(total, coordinates(ring.size)) + } + + /** + * Refuses a tree deeper than the C API accepts. + * + * Checked before recursing rather than left to native: the walk below would otherwise descend an + * over-deep tree first, and a deep enough one exhausts this module's stack before native ever + * sees it. + */ + private fun requireDepth(depth: Int) { + if (depth > Geometry.MAX_COLLECTION_DEPTH) { + throw Status.invalidArgument( + "geometry nests deeper than the ${Geometry.MAX_COLLECTION_DEPTH} levels the C API accepts" + ) + } + } + + /** Writes [geometry] into [arena] and returns the root descriptor's address. */ + fun write(arena: HeapArena, geometry: Geometry): HeapPointer { + val root = arena.allocate(MlnGeometry.SIZEOF, COORDINATE_ALIGN) + writeInto(arena, root, geometry, 0) + return root + } + + private fun writeInto(arena: HeapArena, base: HeapPointer, geometry: Geometry, depth: Int) { + requireDepth(depth) + MlnGeometry.setSize(base, MlnGeometry.SIZEOF) + val data = base + MlnGeometry.OFFSET_DATA + when (geometry) { + is Geometry.Empty -> MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_EMPTY) + is Geometry.Point -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_POINT) + // The point arm holds the coordinate by value rather than by pointer. + MlnLatLng.setLatitude(data, geometry.coordinate.latitude) + MlnLatLng.setLongitude(data, geometry.coordinate.longitude) + } + is Geometry.LineString -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_LINE_STRING) + writeSpan(arena, data, geometry.coordinates) + } + is Geometry.MultiPoint -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_MULTI_POINT) + writeSpan(arena, data, geometry.coordinates) + } + is Geometry.Polygon -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_POLYGON) + val spans = writeSpans(arena, geometry.rings) + MlnPolygonGeometry.setRings(data, spans) + MlnPolygonGeometry.setRingCount(data, geometry.rings.size) + } + is Geometry.MultiLineString -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_MULTI_LINE_STRING) + val spans = writeSpans(arena, geometry.lines) + MlnMultiLineGeometry.setLines(data, spans) + MlnMultiLineGeometry.setLineCount(data, geometry.lines.size) + } + is Geometry.MultiPolygon -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_MULTI_POLYGON) + val polygons = + arena.allocate( + sizeOf(MlnPolygonGeometry.SIZEOF, geometry.polygons.size).toInt(), + POINTER_ALIGN, + ) + geometry.polygons.forEachIndexed { index, rings -> + val entry = polygons + index * MlnPolygonGeometry.SIZEOF + MlnPolygonGeometry.setRings(entry, writeSpans(arena, rings)) + MlnPolygonGeometry.setRingCount(entry, rings.size) + } + MlnMultiPolygonGeometry.setPolygons(data, polygons) + MlnMultiPolygonGeometry.setPolygonCount(data, geometry.polygons.size) + } + is Geometry.Unknown -> throw unknownGeometry(geometry) + is Geometry.Collection -> { + MlnGeometry.setType(base, MlnGeometryType.MLN_GEOMETRY_TYPE_GEOMETRY_COLLECTION) + val children = + arena.allocate( + sizeOf(MlnGeometry.SIZEOF, geometry.geometries.size).toInt(), + COORDINATE_ALIGN, + ) + geometry.geometries.forEachIndexed { index, child -> + writeInto(arena, children + index * MlnGeometry.SIZEOF, child, depth + 1) + } + MlnGeometryCollection.setGeometries(data, children) + MlnGeometryCollection.setGeometryCount(data, geometry.geometries.size) + } + } + } + + /** Writes one coordinate span in place at [span], with its coordinates in the arena. */ + private fun writeSpan(arena: HeapArena, span: HeapPointer, coordinates: List) { + MlnCoordinateSpan.setCoordinates(span, writeCoordinates(arena, coordinates)) + MlnCoordinateSpan.setCoordinateCount(span, coordinates.size) + } + + /** Writes an array of spans and returns where it starts. */ + private fun writeSpans(arena: HeapArena, rings: List>): HeapPointer { + val spans = arena.allocate(sizeOf(MlnCoordinateSpan.SIZEOF, rings.size).toInt(), POINTER_ALIGN) + rings.forEachIndexed { index, ring -> + writeSpan(arena, spans + index * MlnCoordinateSpan.SIZEOF, ring) + } + return spans + } + + /** + * Reads a geometry tree native owns, copying every coordinate into Kotlin. + * + * The read half lives beside the write half deliberately. It was written twice — once for an + * offline region definition and once for a queried feature — and the two copies drifted: one + * checked the counts native reported and the other did not. A tree is native-owned storage that + * its destroy frees, so everything below is copied out rather than left as a view. + */ + fun read(base: HeapPointer, depth: Int): Geometry { + requireDepth(depth) + val data = base + MlnGeometry.OFFSET_DATA + return when (val type = MlnGeometry.type(base)) { + MlnGeometryType.MLN_GEOMETRY_TYPE_EMPTY -> Geometry.Empty + // The point arm holds the coordinate by value rather than by pointer. + MlnGeometryType.MLN_GEOMETRY_TYPE_POINT -> Geometry.Point(CameraMarshal.readLatLng(data)) + MlnGeometryType.MLN_GEOMETRY_TYPE_LINE_STRING -> Geometry.LineString(readSpan(data)) + MlnGeometryType.MLN_GEOMETRY_TYPE_MULTI_POINT -> Geometry.MultiPoint(readSpan(data)) + MlnGeometryType.MLN_GEOMETRY_TYPE_POLYGON -> Geometry.Polygon(readRings(data)) + MlnGeometryType.MLN_GEOMETRY_TYPE_MULTI_LINE_STRING -> { + val lines = MlnMultiLineGeometry.lines(data) + Geometry.MultiLineString( + List(readCount(MlnMultiLineGeometry.lineCount(data))) { index -> + readSpan(lines + index * MlnCoordinateSpan.SIZEOF) + } + ) + } + MlnGeometryType.MLN_GEOMETRY_TYPE_MULTI_POLYGON -> { + val polygons = MlnMultiPolygonGeometry.polygons(data) + Geometry.MultiPolygon( + List(readCount(MlnMultiPolygonGeometry.polygonCount(data))) { index -> + readRings(polygons + index * MlnPolygonGeometry.SIZEOF) + } + ) + } + MlnGeometryType.MLN_GEOMETRY_TYPE_GEOMETRY_COLLECTION -> { + val geometries = MlnGeometryCollection.geometries(data) + Geometry.Collection( + List(readCount(MlnGeometryCollection.geometryCount(data))) { index -> + read(geometries + index * MlnGeometry.SIZEOF, depth + 1) + } + ) + } + // A tag from a newer C API than this binding was generated against. The geometry is kept + // rather than rejected so a caller can still see the rest of the tree it arrived in. + else -> Geometry.Unknown(type, MlnGeometry.size(base)) + } + } + + private fun readRings(base: HeapPointer): List> { + val rings = MlnPolygonGeometry.rings(base) + return List(readCount(MlnPolygonGeometry.ringCount(base))) { index -> + readSpan(rings + index * MlnCoordinateSpan.SIZEOF) + } + } + + private fun readSpan(base: HeapPointer): List { + val coordinates = MlnCoordinateSpan.coordinates(base) + return List(readCount(MlnCoordinateSpan.coordinateCount(base))) { index -> + CameraMarshal.readLatLng(coordinates + index * MlnLatLng.SIZEOF) + } + } + + /** + * Refuses a count native reported that no real descriptor could carry. + * + * `size_t` is 32 bits on this target, so a count past [Int.MAX_VALUE] arrives negative. The heap + * could not hold a tree that large, so a negative one means the address being read is not the + * descriptor it was taken for, and continuing would index arbitrary memory. + */ + private fun readCount(count: Int): Int { + if (count < 0) { + throw Status.invalidState( + "The MapLibre Native browser module reported a geometry element count of $count" + ) + } + return count + } + + private fun writeCoordinates(arena: HeapArena, coordinates: List): HeapPointer { + val array = arena.allocate(sizeOf(MlnLatLng.SIZEOF, coordinates.size).toInt(), COORDINATE_ALIGN) + coordinates.forEachIndexed { index, coordinate -> + val entry = array + index * MlnLatLng.SIZEOF + MlnLatLng.setLatitude(entry, coordinate.latitude) + MlnLatLng.setLongitude(entry, coordinate.longitude) + } + return array + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/Heap.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/Heap.kt new file mode 100644 index 000000000..84cf8d53e --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/Heap.kt @@ -0,0 +1,353 @@ +package org.maplibre.nativeffi.internal.wasm + +import kotlin.wasm.unsafe.withScopedMemoryAllocator +import org.maplibre.nativeffi.error.MaplibreException +import org.maplibre.nativeffi.internal.status.Status + +/** + * The Emscripten module's linear memory, which is not this module's. + * + * A WebAssembly module cannot address another module's memory, so every descriptor, string, and + * pixel buffer crosses through JavaScript. The cost that matters is the number of crossings, not + * the number of bytes: a typed-array element assignment is one crossing each, so copying an image a + * byte at a time costs one call per byte. + * + * The way out is that this module's own memory *is* reachable from JavaScript. Kotlin/Wasm exports + * it, and the compiler places `wasmExports` in scope for the snippets below precisely so externals + * can reach the instance. So a bulk transfer stages the bytes in this module's linear memory with + * ordinary wasm stores, then copies the whole run across in one call. + * + * The module is linked without memory growth, so a heap view stays valid for the module's life. + * Adding growth later would mean re-reading `HEAPU8` on every access instead of holding a view. + * + * These are top-level because `@JsFun` may only implement a top-level external function. + */ +@JsFun("(size) => globalThis.__maplibreNativeC._malloc(size)") +private external fun heapAllocate(size: Int): Int + +@JsFun("(address) => globalThis.__maplibreNativeC._free(address)") +private external fun heapFree(address: Int) + +// A `double` rather than an `int`, because a module linked at Emscripten's 2 GiB maximum has a heap +// length no Int holds, and a length that came back negative would make the bound it feeds accept +// every request instead of refusing the ones past the heap. +@JsFun("() => globalThis.__maplibreNativeC.HEAPU8.length") +private external fun heapByteLength(): Double + +@JsFun( + "(address, length) => { globalThis.__maplibreNativeC.HEAPU8.fill(0, address, address + length) }" +) +private external fun heapClear(address: Int, length: Int) + +@JsFun("(text) => globalThis.__maplibreNativeC.lengthBytesUTF8(text)") +private external fun heapUtf8Length(text: String): Int + +@JsFun( + "(text, address, capacity) => { globalThis.__maplibreNativeC.stringToUTF8(text, address, capacity) }" +) +private external fun heapWriteUtf8(text: String, address: Int, capacity: Int) + +@JsFun("(address) => globalThis.__maplibreNativeC.UTF8ToString(address)") +private external fun heapReadUtf8(address: Int): String + +@JsFun("(address) => globalThis.__maplibreNativeC.HEAPU8[address]") +private external fun heapLoadByte(address: Int): Int + +@JsFun("(address, value) => { globalThis.__maplibreNativeC.HEAPU8[address] = value }") +private external fun heapStoreByte(address: Int, value: Int) + +@JsFun("(address) => globalThis.__maplibreNativeC.HEAPU16[address >>> 1]") +private external fun heapLoadUShort(address: Int): Int + +@JsFun("(address) => (globalThis.__maplibreNativeC.HEAPU16[address >>> 1] << 16) >> 16") +private external fun heapLoadShort(address: Int): Int + +@JsFun("(address, value) => { globalThis.__maplibreNativeC.HEAPU16[address >>> 1] = value }") +private external fun heapStoreShort(address: Int, value: Int) + +@JsFun("(address) => globalThis.__maplibreNativeC.HEAPU32[address >>> 2] | 0") +private external fun heapLoadInt(address: Int): Int + +@JsFun("(address, value) => { globalThis.__maplibreNativeC.HEAPU32[address >>> 2] = value }") +private external fun heapStoreInt(address: Int, value: Int) + +@JsFun("(address) => globalThis.__maplibreNativeC.HEAPF32[address >>> 2]") +private external fun heapLoadFloat(address: Int): Float + +@JsFun("(address, value) => { globalThis.__maplibreNativeC.HEAPF32[address >>> 2] = value }") +private external fun heapStoreFloat(address: Int, value: Float) + +@JsFun("(address) => globalThis.__maplibreNativeC.HEAPF64[address >>> 3]") +private external fun heapLoadDouble(address: Int): Double + +@JsFun("(address, value) => { globalThis.__maplibreNativeC.HEAPF64[address >>> 3] = value }") +private external fun heapStoreDouble(address: Int, value: Double) + +// A handle is a 64-bit generational identifier whose kind occupies the top byte, so it exceeds the +// range a JavaScript number represents exactly. It crosses as a BigInt, which is what the module's +// own i64 interface expects, and which Kotlin maps to Long. +@JsFun("(address) => new BigInt64Array(globalThis.__maplibreNativeC.HEAPU8.buffer)[address >>> 3]") +private external fun heapLoadLong(address: Int): Long + +@JsFun( + "(address, value) => { new BigInt64Array(globalThis.__maplibreNativeC.HEAPU8.buffer)[address >>> 3] = value }" +) +private external fun heapStoreLong(address: Int, value: Long) + +/** Copies a run out of this module's memory into the Emscripten heap, in one crossing. */ +@JsFun( + """ + (source, destination, length) => { + globalThis.__maplibreNativeC.HEAPU8.set( + new Uint8Array(wasmExports.memory.buffer, source, length), destination) + } +""" +) +private external fun heapCopyIn(source: Int, destination: Int, length: Int) + +/** Copies a run out of the Emscripten heap into this module's memory, in one crossing. */ +@JsFun( + """ + (source, destination, length) => { + new Uint8Array(wasmExports.memory.buffer, destination, length).set( + globalThis.__maplibreNativeC.HEAPU8.subarray(source, source + length)) + } +""" +) +private external fun heapCopyOut(source: Int, destination: Int, length: Int) + +/** An address in the Emscripten heap. Pointers are 32-bit on this target; handles are not. */ +internal value class HeapPointer(val address: Int) { + operator fun plus(offset: Int): HeapPointer = HeapPointer(address + offset) +} + +internal object Heap { + /** + * Refuses an address a typed-array view would not read where it was asked to. + * + * The accessors below index a view by element rather than by byte, because `HEAPF64[address >>> + * 3]` is one shift where a `DataView` call is a method dispatch. The shift discards the low bits, + * so a misaligned address does not read slowly — it reads a *different* address, and the value + * that comes back belongs to whatever the neighbouring field is. That failure is silent and it is + * not local: the descriptor still parses, and the wrong value only surfaces somewhere far from + * the marshaller that misplaced it. + * + * Every descriptor these accessors reach is aligned by the C ABI already, so a violation here is + * a marshalling bug rather than a caller's mistake. It is checked rather than assumed because the + * one that was found — a `size_t` packed ahead of a struct in a shared scratch block — looked + * correct in the code that wrote it. + */ + private fun requireAligned(pointer: HeapPointer, width: Int) { + if (pointer.address and (width - 1) != 0) { + throw Status.invalidState( + "A $width-byte field was placed at address ${pointer.address}, which is not $width-byte " + + "aligned; the descriptor holding it is laid out wrongly." + ) + } + } + + /** + * Allocates [size] zeroed bytes of Emscripten heap for the body, and frees them afterwards. + * + * Descriptors reach native as a pointer to bytes the caller owns, so every call that passes one + * needs scratch. The C API reads whole descriptors, so the region starts zeroed rather than + * carrying whatever the allocator last held there. Freeing in a finally block matters more here + * than it would natively: a browser host cannot restart the process to recover leaked heap. + */ + fun withScratch(size: Int, body: (HeapPointer) -> T): T { + val block = acquire(size) + try { + return body(block) + } finally { + release(block) + } + } + + /** + * Takes [size] zeroed bytes of Emscripten heap that outlive the call that asked for them. + * + * [withScratch] is what a call into native should use, and this is for the few blocks whose + * lifetime belongs to a component rather than to a call: a + * [org.maplibre.nativeffi.render .NativeBuffer]'s storage, and the route and rule tables that + * native borrows for as long as a registration stands. + * + * The caller releases it with [release]. Nothing else does: a browser host has no finalizer, and + * only a final shutdown reclaims what this hands out by discarding the whole heap. + */ + fun acquire(size: Int): HeapPointer { + Status.requireArgument(size > 0) { "scratch size must be positive" } + // Named before the allocator is reached, because a host's own main() runs while this + // distribution is imported, which is before the module calls mlnKotlinMain(). + BrowserModule.attach() + val address = heapAllocate(size) + // Reachable, which it was not always: the module is linked with `-sABORTING_MALLOC=0`, so an + // exhausted heap returns null here rather than aborting the module out from under the page. The + // size is not checked against the heap first, the way a caller's own buffer length is, because + // that would put a boundary crossing on the path every call into this binding takes to say what + // the allocator is about to say anyway. + if (address == 0) throw allocationFailure(size) + heapClear(address, size) + return HeapPointer(address) + } + + /** Returns a block [acquire] handed out. Freeing in a finally is why [withScratch] exists. */ + fun release(block: HeapPointer) { + heapFree(block.address) + } + + /** + * The failure an acquisition of [size] bytes reports when the module's allocator refuses. + * + * Named rather than thrown inline because three places raise it: this file's scratch, a + * [org.maplibre.nativeffi.render.NativeBuffer] a host asked for, and [InjectedFaults], which has + * to produce the error a real failure would. A caller cannot tell the three apart, and three + * spellings of one failure would drift the first time any of them was reworded. + */ + fun allocationFailure(size: Int): MaplibreException = + Status.invalidState("The MapLibre Native browser module could not allocate $size bytes") + + /** + * The whole of the module's linear memory, in bytes. + * + * A ceiling rather than a reading. The heap is fixed at link time, so a request larger than this + * cannot be served however empty the heap is, and no amount of freeing would change that; a + * request smaller than it may still fail, because the same memory holds the module's code, its + * threads' stacks, and everything already allocated. So this bounds what a caller asks for and + * sizes nothing. + */ + fun byteLength(): Long = heapByteLength().toLong() + + /** + * Sizes an array of [count] elements, refusing one this target could not address. + * + * A pointer is 32 bits here, so an element count large enough to wrap the product would produce a + * small positive size: the scratch would be allocated, the real count would still be handed to + * native, and native would read past the block. + */ + fun sizeOf(elementBytes: Int, count: Int): Int { + Status.requireArgument(count >= 0) { "element count must be non-negative" } + val bytes = elementBytes.toLong() * count + Status.requireArgument(bytes <= Int.MAX_VALUE) { + "$count elements of $elementBytes bytes cannot be addressed on this target" + } + return bytes.toInt() + } + + /** + * Rejects a string C would truncate when it is passed as null-terminated text. + * + * Only for arguments that cross as a bare `const char*`. A `mln_string_view` carries its own + * length, so an embedded NUL is ordinary content there and must not be refused; a null-terminated + * argument would instead be silently cut at the first one, and native would act on a prefix the + * caller never asked for. + */ + fun requireCString(value: String, subject: String) { + Status.requireArgument('\u0000' !in value) { "$subject cannot contain embedded NUL characters" } + } + + /** Bytes a null-terminated copy of [text] occupies, including the terminator. */ + fun utf8Size(text: String): Int = heapUtf8Length(text) + 1 + + /** + * Writes [text] at [pointer] as null-terminated UTF-8. + * + * The caller sizes the region with [utf8Size]; the module's own writer handles the encoding, so + * nothing here re-implements it. + */ + fun storeUtf8(pointer: HeapPointer, text: String) { + heapWriteUtf8(text, pointer.address, utf8Size(text)) + } + + /** Reads a null-terminated UTF-8 string, copying it into Kotlin before it can be invalidated. */ + fun loadUtf8(pointer: HeapPointer): String = + if (pointer.address == 0) "" else heapReadUtf8(pointer.address) + + fun loadByte(pointer: HeapPointer): Byte = heapLoadByte(pointer.address).toByte() + + fun storeByte(pointer: HeapPointer, value: Byte) { + heapStoreByte(pointer.address, value.toInt() and 0xFF) + } + + fun loadUShort(pointer: HeapPointer): Int { + requireAligned(pointer, 2) + return heapLoadUShort(pointer.address) + } + + fun loadShort(pointer: HeapPointer): Int { + requireAligned(pointer, 2) + return heapLoadShort(pointer.address) + } + + fun storeShort(pointer: HeapPointer, value: Int) { + requireAligned(pointer, 2) + heapStoreShort(pointer.address, value) + } + + fun loadInt(pointer: HeapPointer): Int { + requireAligned(pointer, 4) + return heapLoadInt(pointer.address) + } + + fun storeInt(pointer: HeapPointer, value: Int) { + requireAligned(pointer, 4) + heapStoreInt(pointer.address, value) + } + + fun loadLong(pointer: HeapPointer): Long { + requireAligned(pointer, 8) + return heapLoadLong(pointer.address) + } + + fun storeLong(pointer: HeapPointer, value: Long) { + requireAligned(pointer, 8) + heapStoreLong(pointer.address, value) + } + + fun loadFloat(pointer: HeapPointer): Float { + requireAligned(pointer, 4) + return heapLoadFloat(pointer.address) + } + + fun storeFloat(pointer: HeapPointer, value: Float) { + requireAligned(pointer, 4) + heapStoreFloat(pointer.address, value) + } + + fun loadDouble(pointer: HeapPointer): Double { + requireAligned(pointer, 8) + return heapLoadDouble(pointer.address) + } + + fun storeDouble(pointer: HeapPointer, value: Double) { + requireAligned(pointer, 8) + heapStoreDouble(pointer.address, value) + } + + /** + * Writes [bytes] into the Emscripten heap at [pointer] using one boundary crossing. + * + * The staging loop runs entirely inside this module, so it costs wasm stores rather than + * JavaScript calls; only [heapCopyIn] crosses. + */ + fun storeBytes(pointer: HeapPointer, bytes: ByteArray) { + if (bytes.isEmpty()) return + withScopedMemoryAllocator { allocator -> + val staging = allocator.allocate(bytes.size) + for (index in bytes.indices) (staging + index).storeByte(bytes[index]) + heapCopyIn(staging.address.toInt(), pointer.address, bytes.size) + } + } + + /** Reads [length] bytes from the Emscripten heap at [pointer] using one boundary crossing. */ + fun loadBytes(pointer: HeapPointer, length: Int): ByteArray { + Status.requireArgument(length >= 0) { "length must be non-negative" } + if (length == 0) return ByteArray(0) + val bytes = ByteArray(length) + withScopedMemoryAllocator { allocator -> + val staging = allocator.allocate(length) + heapCopyOut(pointer.address, staging.address.toInt(), length) + for (index in 0 until length) bytes[index] = (staging + index).loadByte() + } + return bytes + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/HeapArena.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/HeapArena.kt new file mode 100644 index 000000000..515ee29e9 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/HeapArena.kt @@ -0,0 +1,53 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.internal.status.Status + +/** + * A bump allocator over one scratch block. + * + * A geometry descriptor is a tree of spans that point at each other, so placing one means many + * small allocations that all have to be freed together. Taking one block and carving it up makes + * that a single acquisition and a single release, and removes any question of which piece frees + * which. + * + * The block is measured before it is taken, so running out is a binding error rather than a + * partially written descriptor: a caller measures, allocates, and then writes. + */ +internal class HeapArena(private val base: HeapPointer, private val capacity: Int) { + private var used = 0 + + /** Reserves [bytes] aligned to [align], and returns where they start. */ + fun allocate(bytes: Int, align: Int): HeapPointer { + // A negative count means a caller's own size arithmetic wrapped. Refusing it + // here matters because the bounds check below only guards the upper end: a + // negative would move `used` backwards and hand back storage the next write + // runs past. + Status.requireArgument(bytes >= 0) { "arena allocation size must be non-negative" } + // Aligned against the absolute address rather than the offset. An arena + // whose base is odd of the alignment would otherwise hand back a misaligned + // descriptor while believing it had aligned one. + val absolute = base.address.toLong() + used + val padding = ((align - (absolute % align)) % align).toInt() + // Long arithmetic throughout, so a size that would wrap a 32-bit count is + // caught here instead of passing the check and writing past the block. + val start = used.toLong() + padding + if (start + bytes.toLong() > capacity.toLong()) { + throw Status.invalidState( + "The browser binding measured $capacity bytes for a descriptor and needs more; " + + "its measure and its write disagree." + ) + } + used = (start + bytes).toInt() + return base + start.toInt() + } + + internal companion object { + /** + * Rounds [bytes] up to the next multiple of [align]. + * + * Long-valued, because a measure that wrapped a 32-bit count would produce a small positive + * size, pass the arena's bounds check, and then write the real element count past the block. + */ + fun aligned(bytes: Long, align: Int): Long = (bytes + align - 1) / align * align + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/InjectedFaults.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/InjectedFaults.kt new file mode 100644 index 000000000..7d390eea7 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/InjectedFaults.kt @@ -0,0 +1,120 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.error.MaplibreException +import org.maplibre.nativeffi.error.MaplibreStatus + +/** + * Failures this suite has to prove the binding survives, and which the module will not produce. + * + * **This exists for the tests.** Nothing in the binding arms it, and everything below is inert + * until something does, so each hook is one field read on the path it sits on. The binding + * specification allows an internal seam for an allocation or copy failure raised after a native + * handle has been acquired -- both a result handle's copy and the wrapper an acquired frame is + * still to be given -- because neither can be produced through the public library on demand. + * + * What the injected failures replace is the answer, never the recovery. A faulted copy does not + * reach native at all, which is what makes the state afterwards the real thing rather than a + * simulation of it: the result handle it refused is still live, so the replay that follows really + * does tell a released handle from a leaked one. + */ +internal object InjectedFaults { + private var failResultCopies = false + private val copiedResults = mutableListOf() + + private var failNextFrameWrap = false + + private var failedEntryPoint: String? = null + private var failedStatus = MaplibreStatus.OK + private var failedDiagnostic = "" + + /** Forgets an arming that was never taken, so a failing test cannot leak one. */ + fun reset() { + failResultCopies = false + copiedResults.clear() + failNextFrameWrap = false + failedEntryPoint = null + } + + /** + * Makes the next call to [entryPoint] report [status] with [diagnostic] instead of reaching it. + * + * The calls that carry this seam are the registration installs and the frame release: each one + * hands native something the binding has already built, and the recovery afterwards -- keeping + * the previous registration, keeping the frame open for a retry -- is what BND-122 and BND-169 + * ask for. Native refuses none of them for any input the public library can produce. + * + * Naming the entry point rather than intercepting one is deliberate. Every call is now a direct + * extern, so there is no chokepoint to hook; the seam is the [beginCall] line at the few sites + * that need it, and it is inert for every other call in the binding. + */ + fun failNextCall(entryPoint: String, status: MaplibreStatus, diagnostic: String) { + failedEntryPoint = entryPoint + failedStatus = status + failedDiagnostic = diagnostic + } + + /** Reports the armed failure for [entryPoint], once, instead of letting the call through. */ + fun beginCall(entryPoint: String) { + if (failedEntryPoint != entryPoint) return + failedEntryPoint = null + throw MaplibreException.forStatus(failedStatus, failedStatus.nativeCode, failedDiagnostic) + } + + /** + * Makes the next owned-frame acquisition fail after native has handed the frame over. + * + * The window this stands in is the one BND-172 names: native has the frame, and the binding has + * still to copy the descriptor into a Kotlin value and wrap it. Both of those are object + * construction, which fails only when the Kotlin heap is exhausted -- a condition a host cannot + * ask for and could not leave behind for the next test if it could. + */ + fun failNextFrameWrap() { + failNextFrameWrap = true + } + + /** Fails one frame wrap, of a descriptor of [bytes], if that is armed. */ + fun beginFrameWrap(bytes: Int) { + if (!failNextFrameWrap) return + failNextFrameWrap = false + throw Heap.allocationFailure(bytes) + } + + /** Makes every result-handle copy fail as an allocation failure, until [takeCopiedResults]. */ + fun failResultCopies() { + failResultCopies = true + copiedResults.clear() + } + + /** + * Disarms the copy failure and reports the result handles it was asked about, oldest first. + * + * The handles are what the test replays: a destroyed one is stale to native, so replaying it is + * how a caller tells a released result handle from a leaked one. Nothing else can -- a leaked + * handle does nothing observable until the module runs out of table slots. + */ + fun takeCopiedResults(): List { + failResultCopies = false + val copied = copiedResults.toList() + copiedResults.clear() + return copied + } + + /** + * Fails the copy of [handle], which is about to acquire [bytes] of scratch, if that is armed. + * + * Called at the top of every read that copies a native snapshot, list, or result handle and + * destroys it. The failure is the one the module's allocator would have raised for that scratch, + * because that is the failure the copy has: everything below this line reads native storage + * through a block this binding has to allocate first. + * + * What cannot be produced here is the *placement*, not the failure. A real allocation failure is + * ordinary now that the module reports one instead of aborting, but exhausting the heap in the + * window between native handing back a result handle and the copy would mean filling half a + * gigabyte and leaving it full for whichever test ran next. + */ + fun beginResultCopy(handle: Long, bytes: Int) { + if (!failResultCopies) return + copiedResults += handle + throw Heap.allocationFailure(bytes) + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/JsonMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/JsonMarshal.kt new file mode 100644 index 000000000..7780a5bd8 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/JsonMarshal.kt @@ -0,0 +1,287 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnJsonArray +import org.maplibre.nativeffi.internal.wasm.generated.MlnJsonMember +import org.maplibre.nativeffi.internal.wasm.generated.MlnJsonObject +import org.maplibre.nativeffi.internal.wasm.generated.MlnJsonValue +import org.maplibre.nativeffi.internal.wasm.generated.MlnJsonValueType +import org.maplibre.nativeffi.internal.wasm.generated.MlnStringView +import org.maplibre.nativeffi.json.JsonValue + +/** + * Places a [JsonValue] tree into the Emscripten heap, and reads one back. + * + * The C descriptor is a tagged union whose array and object arms point at further descriptors, so + * the same approach [GeometryMarshal] takes applies here: measure the tree, place it in one arena, + * and hand native a single root pointer. Measuring and writing walk the same shape in the same + * order, and each pair sits together so that a change to one is visible against the other. + * + * The arena arithmetic below is shared with [GeoJsonMarshal], which places the same member arrays + * and string views inside features. One copy of the checked arithmetic is deliberate: a second copy + * is a second place for an unchecked subtotal to appear. + */ +internal object JsonMarshal { + /** + * Alignment every block in one of these trees is placed and measured at. + * + * One width for all of them rather than each struct's own, because a member is twelve bytes and a + * key is any length: a member array followed by a value array would need padding that a measure + * working from sizes alone cannot see. Placing every block at the widest alignment these + * descriptors ask for makes a measured size exactly the space the write consumes, as long as the + * arena's own base is at least this aligned — which the module's allocator guarantees. + */ + private const val BLOCK_ALIGN = 8 + + /** Adds two measured sizes, refusing a total the heap could not address. */ + fun plus(left: Long, right: Long): Long { + val total = left + right + if (total > Int.MAX_VALUE || total < 0) { + throw Status.invalidArgument("descriptor tree is too large to place in the module's heap") + } + return total + } + + /** Bytes one block of [bytes] occupies, including the padding that follows it. */ + fun measureBlock(bytes: Int): Long = plus(HeapArena.aligned(bytes.toLong(), BLOCK_ALIGN), 0) + + /** Reserves the block [measureBlock] accounted for. */ + fun allocateBlock(arena: HeapArena, bytes: Int): HeapPointer = arena.allocate(bytes, BLOCK_ALIGN) + + /** Bytes an array of [count] elements occupies, including the padding that follows it. */ + fun measureArray(elementBytes: Int, count: Int): Long = + measureBlock(Heap.sizeOf(elementBytes, count)) + + /** Reserves the array [measureArray] accounted for, and returns where it starts. */ + fun allocateArray(arena: HeapArena, elementBytes: Int, count: Int): HeapPointer = + allocateBlock(arena, Heap.sizeOf(elementBytes, count)) + + /** Bytes the storage behind a string view of [text] occupies. */ + fun measureText(text: String): Long = measureBlock(Heap.utf8Size(text)) + + /** Writes the string view at [view], with the bytes it points at placed in [arena]. */ + fun writeText(arena: HeapArena, view: HeapPointer, text: String) { + val bytes = Heap.utf8Size(text) + val storage = allocateBlock(arena, bytes) + Heap.storeUtf8(storage, text) + MlnStringView.setData(view, storage) + // The view's size excludes the terminator the writer above added: native reads the run the view + // describes rather than scanning for a null, and the terminator is there only because the + // module's own UTF-8 writer emits one. + MlnStringView.setSize(view, bytes - 1) + } + + /** + * Reads the string view at [view]. + * + * A view is a pointer and a length, so the length is what bounds the read. Native is free to + * point one into the middle of a buffer it owns, where scanning for a terminator would run past + * the text and into whatever follows it. + */ + fun readText(view: HeapPointer): String { + val data = MlnStringView.data(view) + val size = readCount(MlnStringView.size(view)) + if (size == 0 || data.address == 0) return "" + return Heap.loadBytes(data, size).decodeToString() + } + + /** Bytes [value] needs, including its root descriptor. */ + fun measure(value: JsonValue): Int = measureValue(value, 0).toInt() + + /** Writes [value] into [arena] and returns the root descriptor's address. */ + fun write(arena: HeapArena, value: JsonValue): HeapPointer = writeValue(arena, value, 0) + + /** Bytes a descriptor for [value] and everything below it occupy, placed at [depth]. */ + fun measureValue(value: JsonValue, depth: Int): Long = + plus(measureBlock(MlnJsonValue.SIZEOF), measurePayload(value, depth)) + + /** Places a descriptor for [value] and everything below it, and returns its address. */ + fun writeValue(arena: HeapArena, value: JsonValue, depth: Int): HeapPointer { + val base = allocateBlock(arena, MlnJsonValue.SIZEOF) + writeInto(arena, base, value, depth) + return base + } + + /** + * Bytes [value] needs below its own descriptor. + * + * Every addition and every element count is bounded as it is taken rather than only at the end. A + * subtotal that wrapped would produce a small positive size that passed both this check and the + * arena's, and the write that followed would run past the block. + */ + private fun measurePayload(value: JsonValue, depth: Int): Long { + requireDepth(depth) + return when (value) { + // Scalars live in the descriptor's own union arm, so they need no storage of their own. + JsonValue.Null -> 0L + is JsonValue.Bool -> 0L + is JsonValue.UInt -> 0L + is JsonValue.Int -> 0L + is JsonValue.DoubleValue -> 0L + is JsonValue.StringValue -> measureText(value.value) + is JsonValue.Array -> + value.values.fold(measureArray(MlnJsonValue.SIZEOF, value.values.size)) { total, child -> + plus(total, measurePayload(child, depth + 1)) + } + is JsonValue.ObjectValue -> measureMembers(value.members, depth + 1) + // A value read back from a native tag this binding did not recognise. Its shape is unknown, + // so there is nothing to measure and nothing that could be written back. + is JsonValue.Unknown -> throw unknownValue(value) + } + } + + private fun writeInto(arena: HeapArena, base: HeapPointer, value: JsonValue, depth: Int) { + requireDepth(depth) + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnJsonValue.setSize(base, MlnJsonValue.SIZEOF) + val data = base + MlnJsonValue.OFFSET_DATA + when (value) { + JsonValue.Null -> MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_NULL) + is JsonValue.Bool -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_BOOL) + // The arm is a C `bool`, which is one byte on this target rather than the union's width. + Heap.storeByte(data, if (value.value) 1.toByte() else 0.toByte()) + } + is JsonValue.UInt -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_UINT) + // Carried as the bit pattern it was read as. The C arm is unsigned and Kotlin's Long + // is not, so reinterpreting here would change the value rather than preserve it. + Heap.storeLong(data, value.value) + } + is JsonValue.Int -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_INT) + Heap.storeLong(data, value.value) + } + is JsonValue.DoubleValue -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_DOUBLE) + Heap.storeDouble(data, value.value) + } + is JsonValue.StringValue -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_STRING) + writeText(arena, data, value.value) + } + is JsonValue.Array -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_ARRAY) + val values = allocateArray(arena, MlnJsonValue.SIZEOF, value.values.size) + value.values.forEachIndexed { index, child -> + writeInto(arena, values + index * MlnJsonValue.SIZEOF, child, depth + 1) + } + MlnJsonArray.setValues(data, values) + MlnJsonArray.setValueCount(data, value.values.size) + } + is JsonValue.ObjectValue -> { + MlnJsonValue.setType(base, MlnJsonValueType.MLN_JSON_VALUE_TYPE_OBJECT) + MlnJsonObject.setMembers(data, writeMembers(arena, value.members, depth + 1)) + MlnJsonObject.setMemberCount(data, value.members.size) + } + is JsonValue.Unknown -> throw unknownValue(value) + } + } + + /** + * Bytes a member array and everything below it occupy, with the member values at [depth]. + * + * A member holds its value by pointer rather than in place, so each one costs a descriptor of its + * own on top of the array. Feature properties are the same array with the same shape, which is + * why this is reachable from [GeoJsonMarshal] rather than folded into the object arm. + */ + fun measureMembers(members: List, depth: Int): Long = + members.fold(measureArray(MlnJsonMember.SIZEOF, members.size)) { total, member -> + plus(plus(total, measureText(member.key)), measureValue(member.value, depth)) + } + + /** Writes a member array and returns where it starts. */ + fun writeMembers(arena: HeapArena, members: List, depth: Int): HeapPointer { + val block = allocateArray(arena, MlnJsonMember.SIZEOF, members.size) + members.forEachIndexed { index, member -> + val entry = block + index * MlnJsonMember.SIZEOF + writeText(arena, entry + MlnJsonMember.OFFSET_KEY, member.key) + MlnJsonMember.setValue(entry, writeValue(arena, member.value, depth)) + } + return block + } + + /** Reads the value descriptor at [base], copying every string into Kotlin as it goes. */ + fun read(base: HeapPointer): JsonValue = readValue(base, 0) + + private fun readValue(base: HeapPointer, depth: Int): JsonValue { + requireDepth(depth) + val data = base + MlnJsonValue.OFFSET_DATA + return when (MlnJsonValue.type(base)) { + MlnJsonValueType.MLN_JSON_VALUE_TYPE_NULL -> JsonValue.Null + MlnJsonValueType.MLN_JSON_VALUE_TYPE_BOOL -> JsonValue.Bool(Heap.loadByte(data) != 0.toByte()) + MlnJsonValueType.MLN_JSON_VALUE_TYPE_UINT -> JsonValue.UInt(Heap.loadLong(data)) + MlnJsonValueType.MLN_JSON_VALUE_TYPE_INT -> JsonValue.Int(Heap.loadLong(data)) + MlnJsonValueType.MLN_JSON_VALUE_TYPE_DOUBLE -> JsonValue.DoubleValue(Heap.loadDouble(data)) + MlnJsonValueType.MLN_JSON_VALUE_TYPE_STRING -> JsonValue.StringValue(readText(data)) + MlnJsonValueType.MLN_JSON_VALUE_TYPE_ARRAY -> { + val values = MlnJsonArray.values(data) + JsonValue.Array( + List(readCount(MlnJsonArray.valueCount(data))) { index -> + readValue(values + index * MlnJsonValue.SIZEOF, depth + 1) + } + ) + } + MlnJsonValueType.MLN_JSON_VALUE_TYPE_OBJECT -> + JsonValue.ObjectValue( + readMembers( + MlnJsonObject.members(data), + readCount(MlnJsonObject.memberCount(data)), + depth + 1, + ) + ) + // A tag from a newer C API than this binding was generated against. The value is kept rather + // than rejected so a caller can still see the rest of the tree it arrived in. + else -> JsonValue.Unknown(MlnJsonValue.type(base), MlnJsonValue.size(base)) + } + } + + /** Reads [count] members starting at [members], with their values at [depth]. */ + fun readMembers(members: HeapPointer, count: Int, depth: Int): List = + List(count) { index -> + val entry = members + index * MlnJsonMember.SIZEOF + JsonValue.Member( + readText(entry + MlnJsonMember.OFFSET_KEY), + readValue(MlnJsonMember.value(entry), depth), + ) + } + + /** + * Refuses a tree deeper than the C API accepts. + * + * Checked before recursing rather than left to native: the walks above would otherwise descend an + * over-deep tree first, and a deep enough one exhausts this module's stack before native ever + * sees it. The read path is bounded for the same reason, since the descriptor it walks is only as + * trustworthy as the module that produced it. + */ + private fun requireDepth(depth: Int) { + if (depth > JsonValue.MAX_DESCRIPTOR_DEPTH) { + throw Status.invalidArgument( + "JSON value nests deeper than the ${JsonValue.MAX_DESCRIPTOR_DEPTH} levels the C API accepts" + ) + } + } + + /** + * Refuses a count or length native reported that no real descriptor could carry. + * + * `size_t` is 32 bits on this target, so a value past [Int.MAX_VALUE] arrives negative. The heap + * could not hold a descriptor that large, so a negative one means the address being read is not + * the descriptor it was taken for, and continuing would index arbitrary memory. + */ + private fun readCount(count: Int): Int { + if (count < 0) { + throw Status.invalidState( + "The MapLibre Native browser module reported a descriptor count of $count" + ) + } + return count + } + + private fun unknownValue(value: JsonValue.Unknown) = + Status.invalidArgument( + "A JSON value of unknown native type ${value.rawType} cannot be sent to native; it was read " + + "from a tag this binding does not recognise." + ) +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/MapOptionsMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/MapOptionsMarshal.kt new file mode 100644 index 000000000..f6a21effa --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/MapOptionsMarshal.kt @@ -0,0 +1,272 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.camera.BoundOptions +import org.maplibre.nativeffi.camera.BoundsConstraint +import org.maplibre.nativeffi.geo.LatLngBounds +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnBoundOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnBoundOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLngBounds +import org.maplibre.nativeffi.internal.wasm.generated.MlnMapOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnMapTileOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnMapTileOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnMapViewportOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnMapViewportOptions +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_options_default +import org.maplibre.nativeffi.map.ConstrainMode +import org.maplibre.nativeffi.map.MapOptions +import org.maplibre.nativeffi.map.NorthOrientation +import org.maplibre.nativeffi.map.TileLodMode +import org.maplibre.nativeffi.map.TileOptions +import org.maplibre.nativeffi.map.ViewportMode +import org.maplibre.nativeffi.map.ViewportOptions + +/** + * Places the map's own descriptors into the Emscripten heap, and reads them back. + * + * Three of the four pair their values with a bit per field, so an absent Kotlin value is a bit left + * clear and a clear bit reads back as null. [MapOptions] is the exception and says why below. + * + * Every offset and width here comes from the generated accessors, so this code names fields. + */ +internal object MapOptionsMarshal { + val MAP_OPTIONS_SIZEOF: Int = MlnMapOptions.SIZEOF + val BOUND_OPTIONS_SIZEOF: Int = MlnBoundOptions.SIZEOF + val VIEWPORT_OPTIONS_SIZEOF: Int = MlnMapViewportOptions.SIZEOF + val TILE_OPTIONS_SIZEOF: Int = MlnMapTileOptions.SIZEOF + + /** + * Writes [options] at [base], leaving every absent value at the C API's own default. + * + * This descriptor carries no field mask, so an absent value still has to be a value: a zeroed + * block asks for a map of zero width at zero scale, which the C API rejects. The defaults are + * read from the module rather than copied here as constants, so a map created through this + * binding matches one created through any other. + */ + fun writeMapOptions(base: HeapPointer, options: MapOptions) { + // The entry point returns the descriptor by value, which this target lowers to a write through + // a destination the caller passes. + mln_map_options_default(base.address) + // The default carries a size too, but it is the module's rather than this binding's. Stating it + // here keeps every descriptor reporting the size these offsets were generated against. + MlnMapOptions.setSize(base, MlnMapOptions.SIZEOF) + options.width?.let { MlnMapOptions.setWidth(base, it) } + options.height?.let { MlnMapOptions.setHeight(base, it) } + options.scaleFactor?.let { MlnMapOptions.setScaleFactor(base, it) } + options.mapMode?.let { + Status.requireArgument(it.isKnown) { + "Unknown map mode cannot be used as input: ${it.nativeValue}" + } + MlnMapOptions.setMapMode(base, it.nativeValue) + } + options.fastPforEnabled?.let { MlnMapOptions.setFastPforEnabled(base, it) } + } + + /** + * Writes a bound descriptor's header alone, for a buffer native fills. + * + * An output descriptor still states its size: native reads it to decide which fields it may + * write, and a zeroed block would ask for a zero-sized descriptor. + */ + fun writeBoundOptionsHeader(base: HeapPointer) { + MlnBoundOptions.setSize(base, MlnBoundOptions.SIZEOF) + } + + /** Writes [options] at [base], setting a field's bit only where the value is present. */ + fun writeBoundOptions(base: HeapPointer, options: BoundOptions) { + MlnBoundOptions.setSize(base, MlnBoundOptions.SIZEOF) + var fields = 0 + // The two constraint bits are mutually exclusive, and the unbounded one leaves the bounds + // unread, so the sealed constraint maps to one bit or the other rather than to both. + when (val constraint = options.bounds) { + is BoundsConstraint.Bounded -> { + fields = fields or MlnBoundOptionField.MLN_BOUND_OPTION_BOUNDS + writeLatLngBounds(base + MlnBoundOptions.OFFSET_BOUNDS, constraint.bounds) + } + BoundsConstraint.Unbounded -> { + fields = fields or MlnBoundOptionField.MLN_BOUND_OPTION_UNBOUNDED + } + null -> {} + } + options.minZoom?.let { + fields = fields or MlnBoundOptionField.MLN_BOUND_OPTION_MIN_ZOOM + MlnBoundOptions.setMinZoom(base, it) + } + options.maxZoom?.let { + fields = fields or MlnBoundOptionField.MLN_BOUND_OPTION_MAX_ZOOM + MlnBoundOptions.setMaxZoom(base, it) + } + options.minPitch?.let { + fields = fields or MlnBoundOptionField.MLN_BOUND_OPTION_MIN_PITCH + MlnBoundOptions.setMinPitch(base, it) + } + options.maxPitch?.let { + fields = fields or MlnBoundOptionField.MLN_BOUND_OPTION_MAX_PITCH + MlnBoundOptions.setMaxPitch(base, it) + } + MlnBoundOptions.setFields(base, fields) + } + + /** Reads the bounds at [base], producing null for every field whose bit is clear. */ + fun readBoundOptions(base: HeapPointer): BoundOptions { + val fields = MlnBoundOptions.fields(base) + fun has(bit: Int) = (fields and bit) != 0 + return BoundOptions().also { + if (has(MlnBoundOptionField.MLN_BOUND_OPTION_BOUNDS)) { + it.bounds = BoundsConstraint.Bounded(readLatLngBounds(base + MlnBoundOptions.OFFSET_BOUNDS)) + } else if (has(MlnBoundOptionField.MLN_BOUND_OPTION_UNBOUNDED)) { + it.bounds = BoundsConstraint.Unbounded + } + if (has(MlnBoundOptionField.MLN_BOUND_OPTION_MIN_ZOOM)) { + it.minZoom = MlnBoundOptions.minZoom(base) + } + if (has(MlnBoundOptionField.MLN_BOUND_OPTION_MAX_ZOOM)) { + it.maxZoom = MlnBoundOptions.maxZoom(base) + } + if (has(MlnBoundOptionField.MLN_BOUND_OPTION_MIN_PITCH)) { + it.minPitch = MlnBoundOptions.minPitch(base) + } + if (has(MlnBoundOptionField.MLN_BOUND_OPTION_MAX_PITCH)) { + it.maxPitch = MlnBoundOptions.maxPitch(base) + } + } + } + + /** Writes a viewport descriptor's header alone, for a buffer native fills. */ + fun writeViewportOptionsHeader(base: HeapPointer) { + MlnMapViewportOptions.setSize(base, MlnMapViewportOptions.SIZEOF) + } + + /** Writes [options] at [base], setting a field's bit only where the value is present. */ + fun writeViewportOptions(base: HeapPointer, options: ViewportOptions) { + MlnMapViewportOptions.setSize(base, MlnMapViewportOptions.SIZEOF) + var fields = 0 + options.northOrientation?.let { + // The open domain preserves a value native reported, so it can hold one this binding never + // named. Sending that back would ask native for a viewport it has no meaning for. + Status.requireArgument(it.isKnown) { + "Unknown north orientation cannot be used as input: ${it.nativeValue}" + } + fields = fields or MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_NORTH_ORIENTATION + MlnMapViewportOptions.setNorthOrientation(base, it.nativeValue) + } + options.constrainMode?.let { + Status.requireArgument(it.isKnown) { + "Unknown constrain mode cannot be used as input: ${it.nativeValue}" + } + fields = fields or MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_CONSTRAIN_MODE + MlnMapViewportOptions.setConstrainMode(base, it.nativeValue) + } + options.viewportMode?.let { + Status.requireArgument(it.isKnown) { + "Unknown viewport mode cannot be used as input: ${it.nativeValue}" + } + fields = fields or MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_VIEWPORT_MODE + MlnMapViewportOptions.setViewportMode(base, it.nativeValue) + } + options.frustumOffset?.let { + fields = fields or MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_FRUSTUM_OFFSET + CameraMarshal.writeEdgeInsets(base + MlnMapViewportOptions.OFFSET_FRUSTUM_OFFSET, it) + } + MlnMapViewportOptions.setFields(base, fields) + } + + /** Reads the viewport options at [base], producing null for every field whose bit is clear. */ + fun readViewportOptions(base: HeapPointer): ViewportOptions { + val fields = MlnMapViewportOptions.fields(base) + fun has(bit: Int) = (fields and bit) != 0 + return ViewportOptions().also { + if (has(MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_NORTH_ORIENTATION)) { + it.northOrientation = + NorthOrientation.fromNative(MlnMapViewportOptions.northOrientation(base)) + } + if (has(MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_CONSTRAIN_MODE)) { + it.constrainMode = ConstrainMode.fromNative(MlnMapViewportOptions.constrainMode(base)) + } + if (has(MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_VIEWPORT_MODE)) { + it.viewportMode = ViewportMode.fromNative(MlnMapViewportOptions.viewportMode(base)) + } + if (has(MlnMapViewportOptionField.MLN_MAP_VIEWPORT_OPTION_FRUSTUM_OFFSET)) { + it.frustumOffset = + CameraMarshal.readEdgeInsets(base + MlnMapViewportOptions.OFFSET_FRUSTUM_OFFSET) + } + } + } + + /** Writes a tile descriptor's header alone, for a buffer native fills. */ + fun writeTileOptionsHeader(base: HeapPointer) { + MlnMapTileOptions.setSize(base, MlnMapTileOptions.SIZEOF) + } + + /** Writes [options] at [base], setting a field's bit only where the value is present. */ + fun writeTileOptions(base: HeapPointer, options: TileOptions) { + MlnMapTileOptions.setSize(base, MlnMapTileOptions.SIZEOF) + var fields = 0 + options.prefetchZoomDelta?.let { + fields = fields or MlnMapTileOptionField.MLN_MAP_TILE_OPTION_PREFETCH_ZOOM_DELTA + MlnMapTileOptions.setPrefetchZoomDelta(base, it) + } + options.lodMinRadius?.let { + fields = fields or MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_MIN_RADIUS + MlnMapTileOptions.setLodMinRadius(base, it) + } + options.lodScale?.let { + fields = fields or MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_SCALE + MlnMapTileOptions.setLodScale(base, it) + } + options.lodPitchThreshold?.let { + fields = fields or MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_PITCH_THRESHOLD + MlnMapTileOptions.setLodPitchThreshold(base, it) + } + options.lodZoomShift?.let { + fields = fields or MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_ZOOM_SHIFT + MlnMapTileOptions.setLodZoomShift(base, it) + } + options.lodMode?.let { + Status.requireArgument(it.isKnown) { + "Unknown tile LOD mode cannot be used as input: ${it.nativeValue}" + } + fields = fields or MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_MODE + MlnMapTileOptions.setLodMode(base, it.nativeValue) + } + MlnMapTileOptions.setFields(base, fields) + } + + /** Reads the tile options at [base], producing null for every field whose bit is clear. */ + fun readTileOptions(base: HeapPointer): TileOptions { + val fields = MlnMapTileOptions.fields(base) + fun has(bit: Int) = (fields and bit) != 0 + return TileOptions().also { + if (has(MlnMapTileOptionField.MLN_MAP_TILE_OPTION_PREFETCH_ZOOM_DELTA)) { + it.prefetchZoomDelta = MlnMapTileOptions.prefetchZoomDelta(base) + } + if (has(MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_MIN_RADIUS)) { + it.lodMinRadius = MlnMapTileOptions.lodMinRadius(base) + } + if (has(MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_SCALE)) { + it.lodScale = MlnMapTileOptions.lodScale(base) + } + if (has(MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_PITCH_THRESHOLD)) { + it.lodPitchThreshold = MlnMapTileOptions.lodPitchThreshold(base) + } + if (has(MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_ZOOM_SHIFT)) { + it.lodZoomShift = MlnMapTileOptions.lodZoomShift(base) + } + if (has(MlnMapTileOptionField.MLN_MAP_TILE_OPTION_LOD_MODE)) { + it.lodMode = TileLodMode.fromNative(MlnMapTileOptions.lodMode(base)) + } + } + } + + /** Writes a bounds pair, which carries no field mask of its own. */ + fun writeLatLngBounds(base: HeapPointer, bounds: LatLngBounds) { + CameraMarshal.writeLatLng(base + MlnLatLngBounds.OFFSET_SOUTHWEST, bounds.southwest) + CameraMarshal.writeLatLng(base + MlnLatLngBounds.OFFSET_NORTHEAST, bounds.northeast) + } + + fun readLatLngBounds(base: HeapPointer): LatLngBounds = + LatLngBounds( + CameraMarshal.readLatLng(base + MlnLatLngBounds.OFFSET_SOUTHWEST), + CameraMarshal.readLatLng(base + MlnLatLngBounds.OFFSET_NORTHEAST), + ) +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/OfflineMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/OfflineMarshal.kt new file mode 100644 index 000000000..3a54d474b --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/OfflineMarshal.kt @@ -0,0 +1,428 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.geo.LatLngBounds +import org.maplibre.nativeffi.geo.TileId +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLngBounds +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineGeometryRegionDefinition +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineRegionDefinition +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineRegionDefinitionType +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineRegionInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineRegionStatus +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineTilePyramidRegionDefinition +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderingStats +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEvent +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventCameraTransitionFinished +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventOfflineOperationCompleted +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventOfflineRegionResponseError +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventOfflineRegionStatus +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventOfflineRegionTileCountLimit +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventPayloadType +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventRenderFrame +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventRenderMap +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventStyleImageMissing +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventTileAction +import org.maplibre.nativeffi.internal.wasm.generated.MlnTileId +import org.maplibre.nativeffi.map.RenderingStats +import org.maplibre.nativeffi.map.TileOperation +import org.maplibre.nativeffi.offline.OfflineRegionDefinition +import org.maplibre.nativeffi.offline.OfflineRegionDownloadState +import org.maplibre.nativeffi.offline.OfflineRegionInfo +import org.maplibre.nativeffi.offline.OfflineRegionStatus +import org.maplibre.nativeffi.render.RenderMode +import org.maplibre.nativeffi.resource.ResourceErrorReason +import org.maplibre.nativeffi.runtime.OfflineOperationKind +import org.maplibre.nativeffi.runtime.OfflineOperationResultKind +import org.maplibre.nativeffi.runtime.RuntimeEvent +import org.maplibre.nativeffi.runtime.RuntimeEventPayload +import org.maplibre.nativeffi.runtime.RuntimeEventSourceType +import org.maplibre.nativeffi.runtime.RuntimeEventType +import org.maplibre.nativeffi.runtime.RuntimeHandle + +/** Alignment the offline descriptors need, which is their widest member: a double. */ +private const val DESCRIPTOR_ALIGN = 8 + +/** A C string carries no alignment requirement of its own. */ +private const val TEXT_ALIGN = 1 + +/** + * Places an offline region descriptor into the Emscripten heap, and reads one back. + * + * A region definition is a tagged union whose arms point at a style URL and, for the geometry arm, + * at a whole geometry tree. So it is measured first and written into one arena, the same way + * [GeometryMarshal] handles the tree it embeds: one acquisition and one release however large the + * definition turns out to be. + * + * Reading is the mirror image, and it copies. Native hands back pointers into storage owned by the + * snapshot or list the info came from, which the caller destroys as soon as it has read it. + */ +internal object OfflineMarshal { + /** Bytes [definition] needs, including its style URL and any embedded geometry. */ + fun measureDefinition(definition: OfflineRegionDefinition): Int { + val payload = + when (definition) { + is OfflineRegionDefinition.TilePyramid -> measureText(definition.styleUrl) + is OfflineRegionDefinition.GeometryRegion -> + measureText(definition.styleUrl) + + HeapArena.aligned( + GeometryMarshal.measure(definition.geometry).toLong(), + DESCRIPTOR_ALIGN, + ) + is OfflineRegionDefinition.Unknown -> throw unknownDefinition(definition) + } + return bounded( + HeapArena.aligned(MlnOfflineRegionDefinition.SIZEOF.toLong(), DESCRIPTOR_ALIGN) + payload + ) + } + + /** Writes [definition] into [arena] and returns the tagged descriptor's address. */ + fun writeDefinition(arena: HeapArena, definition: OfflineRegionDefinition): HeapPointer { + val base = arena.allocate(MlnOfflineRegionDefinition.SIZEOF, DESCRIPTOR_ALIGN) + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. The arm inside + // the union carries its own, for the same reason. + MlnOfflineRegionDefinition.setSize(base, MlnOfflineRegionDefinition.SIZEOF) + val data = base + MlnOfflineRegionDefinition.OFFSET_DATA + when (definition) { + is OfflineRegionDefinition.TilePyramid -> { + MlnOfflineRegionDefinition.setType( + base, + MlnOfflineRegionDefinitionType.MLN_OFFLINE_REGION_DEFINITION_TILE_PYRAMID, + ) + MlnOfflineTilePyramidRegionDefinition.setSize( + data, + MlnOfflineTilePyramidRegionDefinition.SIZEOF, + ) + MlnOfflineTilePyramidRegionDefinition.setStyleUrl( + data, + writeText(arena, definition.styleUrl), + ) + writeBounds(data + MlnOfflineTilePyramidRegionDefinition.OFFSET_BOUNDS, definition.bounds) + MlnOfflineTilePyramidRegionDefinition.setMinZoom(data, definition.minZoom) + MlnOfflineTilePyramidRegionDefinition.setMaxZoom(data, definition.maxZoom) + MlnOfflineTilePyramidRegionDefinition.setPixelRatio(data, definition.pixelRatio) + MlnOfflineTilePyramidRegionDefinition.setIncludeIdeographs( + data, + definition.includeIdeographs, + ) + } + is OfflineRegionDefinition.GeometryRegion -> { + MlnOfflineRegionDefinition.setType( + base, + MlnOfflineRegionDefinitionType.MLN_OFFLINE_REGION_DEFINITION_GEOMETRY, + ) + MlnOfflineGeometryRegionDefinition.setSize(data, MlnOfflineGeometryRegionDefinition.SIZEOF) + MlnOfflineGeometryRegionDefinition.setStyleUrl(data, writeText(arena, definition.styleUrl)) + MlnOfflineGeometryRegionDefinition.setGeometry( + data, + GeometryMarshal.write(arena, definition.geometry), + ) + MlnOfflineGeometryRegionDefinition.setMinZoom(data, definition.minZoom) + MlnOfflineGeometryRegionDefinition.setMaxZoom(data, definition.maxZoom) + MlnOfflineGeometryRegionDefinition.setPixelRatio(data, definition.pixelRatio) + MlnOfflineGeometryRegionDefinition.setIncludeIdeographs(data, definition.includeIdeographs) + } + is OfflineRegionDefinition.Unknown -> throw unknownDefinition(definition) + } + return base + } + + /** + * Writes the region-info header alone, for a buffer native fills. + * + * An output descriptor still states its size: native reads it to decide whether it may write the + * fields this binding expects, and refuses a zeroed block outright. + */ + fun writeRegionInfoHeader(base: HeapPointer) { + MlnOfflineRegionInfo.setSize(base, MlnOfflineRegionInfo.SIZEOF) + } + + /** Reads the region info at [base], copying its metadata out of snapshot-owned storage. */ + fun readRegionInfo(base: HeapPointer): OfflineRegionInfo = + OfflineRegionInfo( + MlnOfflineRegionInfo.id(base), + readDefinition(base + MlnOfflineRegionInfo.OFFSET_DEFINITION), + readBytes(MlnOfflineRegionInfo.metadata(base), MlnOfflineRegionInfo.metadataSize(base)), + ) + + /** Writes the region-status header alone, for a buffer native fills. */ + fun writeStatusHeader(base: HeapPointer) { + MlnOfflineRegionStatus.setSize(base, MlnOfflineRegionStatus.SIZEOF) + } + + /** Reads the region status at [base]. Every field is a value, so nothing here borrows. */ + fun readStatus(base: HeapPointer): OfflineRegionStatus = + OfflineRegionStatus( + OfflineRegionDownloadState.fromNative(MlnOfflineRegionStatus.downloadState(base)), + MlnOfflineRegionStatus.completedResourceCount(base), + MlnOfflineRegionStatus.completedResourceSize(base), + MlnOfflineRegionStatus.completedTileCount(base), + MlnOfflineRegionStatus.requiredTileCount(base), + MlnOfflineRegionStatus.completedTileSize(base), + MlnOfflineRegionStatus.requiredResourceCount(base), + MlnOfflineRegionStatus.requiredResourceCountIsPrecise(base), + MlnOfflineRegionStatus.complete(base), + ) + + /** + * Reads the tagged definition at [base]. + * + * A tag this binding does not name is preserved rather than guessed at: the arm behind it has an + * unknown shape, so reading any field of it would be reading at an offset that means nothing. + */ + private fun readDefinition(base: HeapPointer): OfflineRegionDefinition { + val data = base + MlnOfflineRegionDefinition.OFFSET_DATA + return when (val type = MlnOfflineRegionDefinition.type(base)) { + MlnOfflineRegionDefinitionType.MLN_OFFLINE_REGION_DEFINITION_TILE_PYRAMID -> + OfflineRegionDefinition.TilePyramid( + Heap.loadUtf8(MlnOfflineTilePyramidRegionDefinition.styleUrl(data)), + readBounds(data + MlnOfflineTilePyramidRegionDefinition.OFFSET_BOUNDS), + MlnOfflineTilePyramidRegionDefinition.minZoom(data), + MlnOfflineTilePyramidRegionDefinition.maxZoom(data), + MlnOfflineTilePyramidRegionDefinition.pixelRatio(data), + MlnOfflineTilePyramidRegionDefinition.includeIdeographs(data), + ) + MlnOfflineRegionDefinitionType.MLN_OFFLINE_REGION_DEFINITION_GEOMETRY -> + OfflineRegionDefinition.GeometryRegion( + Heap.loadUtf8(MlnOfflineGeometryRegionDefinition.styleUrl(data)), + GeometryMarshal.read(MlnOfflineGeometryRegionDefinition.geometry(data), 0), + MlnOfflineGeometryRegionDefinition.minZoom(data), + MlnOfflineGeometryRegionDefinition.maxZoom(data), + MlnOfflineGeometryRegionDefinition.pixelRatio(data), + MlnOfflineGeometryRegionDefinition.includeIdeographs(data), + ) + else -> OfflineRegionDefinition.Unknown(type, MlnOfflineRegionDefinition.size(base)) + } + } + + private fun writeBounds(base: HeapPointer, bounds: LatLngBounds) { + CameraMarshal.writeLatLng(base + MlnLatLngBounds.OFFSET_SOUTHWEST, bounds.southwest) + CameraMarshal.writeLatLng(base + MlnLatLngBounds.OFFSET_NORTHEAST, bounds.northeast) + } + + private fun readBounds(base: HeapPointer): LatLngBounds = + LatLngBounds( + CameraMarshal.readLatLng(base + MlnLatLngBounds.OFFSET_SOUTHWEST), + CameraMarshal.readLatLng(base + MlnLatLngBounds.OFFSET_NORTHEAST), + ) + + private fun measureText(text: String): Long = + HeapArena.aligned(Heap.utf8Size(text).toLong(), DESCRIPTOR_ALIGN) + + private fun writeText(arena: HeapArena, text: String): HeapPointer { + val bytes = Heap.utf8Size(text) + val pointer = arena.allocate(bytes, TEXT_ALIGN) + Heap.storeUtf8(pointer, text) + return pointer + } + + private fun unknownDefinition(definition: OfflineRegionDefinition.Unknown) = + Status.invalidArgument( + "An offline region definition of unknown native type ${definition.rawType} cannot be sent " + + "to native; it was read from a tag this binding does not recognise." + ) + + /** Converts a measured size to the count a 32-bit pointer can address, or refuses it. */ + private fun bounded(bytes: Long): Int { + Status.requireArgument(bytes in 1..Int.MAX_VALUE.toLong()) { + "an offline region definition of $bytes bytes cannot be addressed on this target" + } + return bytes.toInt() + } +} + +/** + * Copies one runtime event out of the queue. + * + * Every string and payload the C API reports here points into runtime-owned storage that the next + * poll for the same runtime overwrites, so nothing may be left borrowed: the public event has to be + * whole by the time this returns. That is the whole reason this reads eagerly rather than exposing + * lazy accessors over the descriptor. + * + * It lives beside the offline marshalling because two of its payloads are offline descriptors and a + * third reports an offline operation's outcome. + */ +internal object RuntimeEventMarshal { + /** Writes the event header alone, for the buffer native fills on each poll. */ + fun writeHeader(base: HeapPointer) { + MlnRuntimeEvent.setSize(base, MlnRuntimeEvent.SIZEOF) + } + + /** + * Reads the event at [base], attributing it to whichever handle raised it. + * + * A map-originated event names its map by native id, which the runtime resolves against the maps + * it still holds. An id names one map for the life of the process, so a lookup that misses means + * the map has been closed rather than that the wrong one might be found; the public contract + * already allows a null map for exactly that case. + */ + fun readEvent(base: HeapPointer, runtime: RuntimeHandle): RuntimeEvent { + val sourceType = RuntimeEventSourceType.fromNative(MlnRuntimeEvent.sourceType(base)) + val source = MlnRuntimeEvent.source(base) + return RuntimeEvent( + RuntimeEventType.fromNative(MlnRuntimeEvent.type(base)), + sourceType, + runtime.takeIf { sourceType == RuntimeEventSourceType.RUNTIME }, + if (sourceType == RuntimeEventSourceType.MAP && source != 0L) runtime.liveMap(source) + else null, + MlnRuntimeEvent.code(base), + readPayload( + MlnRuntimeEvent.payloadType(base), + MlnRuntimeEvent.payload(base), + MlnRuntimeEvent.payloadSize(base), + ), + readText(MlnRuntimeEvent.message(base), MlnRuntimeEvent.messageSize(base)), + ) + } + + /** + * Reads the payload [payloadType] selects. + * + * A payload shorter than the struct this binding was generated against is read as unknown rather + * than field by field: the fields past the reported size belong to a module built from other + * headers, and reading them would report whatever the runtime's storage held there. + */ + private fun readPayload( + payloadType: Int, + payload: HeapPointer, + payloadSize: Int, + ): RuntimeEventPayload { + fun fits(required: Int) = payload.address != 0 && payloadSize >= required + return when (payloadType) { + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_NONE -> RuntimeEventPayload.None + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_RENDER_FRAME -> + if (fits(MlnRuntimeEventRenderFrame.SIZEOF)) readRenderFrame(payload) + else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_RENDER_MAP -> + if (fits(MlnRuntimeEventRenderMap.SIZEOF)) { + RuntimeEventPayload.RenderMap( + RenderMode.fromNative(MlnRuntimeEventRenderMap.mode(payload)) + ) + } else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_STYLE_IMAGE_MISSING -> + if (fits(MlnRuntimeEventStyleImageMissing.SIZEOF)) { + RuntimeEventPayload.StyleImageMissing( + readText( + MlnRuntimeEventStyleImageMissing.imageId(payload), + MlnRuntimeEventStyleImageMissing.imageIdSize(payload), + ) + ) + } else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_TILE_ACTION -> + if (fits(MlnRuntimeEventTileAction.SIZEOF)) readTileAction(payload) + else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_REGION_STATUS -> + if (fits(MlnRuntimeEventOfflineRegionStatus.SIZEOF)) { + RuntimeEventPayload.OfflineRegionStatusChanged( + MlnRuntimeEventOfflineRegionStatus.regionId(payload), + OfflineMarshal.readStatus(payload + MlnRuntimeEventOfflineRegionStatus.OFFSET_STATUS), + ) + } else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_REGION_RESPONSE_ERROR -> + if (fits(MlnRuntimeEventOfflineRegionResponseError.SIZEOF)) { + RuntimeEventPayload.OfflineRegionResponseError( + MlnRuntimeEventOfflineRegionResponseError.regionId(payload), + ResourceErrorReason.fromNative( + MlnRuntimeEventOfflineRegionResponseError.reason(payload) + ), + ) + } else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_REGION_TILE_COUNT_LIMIT -> + if (fits(MlnRuntimeEventOfflineRegionTileCountLimit.SIZEOF)) { + RuntimeEventPayload.OfflineRegionTileCountLimit( + MlnRuntimeEventOfflineRegionTileCountLimit.regionId(payload), + MlnRuntimeEventOfflineRegionTileCountLimit.limit(payload), + ) + } else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_OFFLINE_OPERATION_COMPLETED -> + if (fits(MlnRuntimeEventOfflineOperationCompleted.SIZEOF)) { + readOperationCompleted(payload) + } else readUnknown(payloadType, payload, payloadSize) + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_CAMERA_TRANSITION_FINISHED -> + if (fits(MlnRuntimeEventCameraTransitionFinished.SIZEOF)) { + RuntimeEventPayload.CameraTransitionFinished( + MlnRuntimeEventCameraTransitionFinished.transitionId(payload) + ) + } else readUnknown(payloadType, payload, payloadSize) + else -> readUnknown(payloadType, payload, payloadSize) + } + } + + private fun readRenderFrame(payload: HeapPointer): RuntimeEventPayload.RenderFrame { + val stats = payload + MlnRuntimeEventRenderFrame.OFFSET_STATS + return RuntimeEventPayload.RenderFrame( + RenderMode.fromNative(MlnRuntimeEventRenderFrame.mode(payload)), + MlnRuntimeEventRenderFrame.needsRepaint(payload), + MlnRuntimeEventRenderFrame.placementChanged(payload), + RenderingStats( + MlnRenderingStats.encodingTime(stats), + MlnRenderingStats.renderingTime(stats), + MlnRenderingStats.frameCount(stats), + MlnRenderingStats.drawCallCount(stats), + MlnRenderingStats.totalDrawCallCount(stats), + ), + ) + } + + private fun readTileAction(payload: HeapPointer): RuntimeEventPayload.TileAction { + val tile = payload + MlnRuntimeEventTileAction.OFFSET_TILE_ID + return RuntimeEventPayload.TileAction( + TileOperation.fromNative(MlnRuntimeEventTileAction.operation(payload)), + TileId( + // Zoom and tile coordinates are unsigned in C, so the widening cannot go through Int: + // a high-bit value would arrive as a negative zoom. + unsigned(MlnTileId.overscaledZ(tile)), + MlnTileId.wrap(tile), + unsigned(MlnTileId.canonicalZ(tile)), + unsigned(MlnTileId.canonicalX(tile)), + unsigned(MlnTileId.canonicalY(tile)), + ), + readText( + MlnRuntimeEventTileAction.sourceId(payload), + MlnRuntimeEventTileAction.sourceIdSize(payload), + ), + ) + } + + private fun readOperationCompleted( + payload: HeapPointer + ): RuntimeEventPayload.OfflineOperationCompleted = + RuntimeEventPayload.OfflineOperationCompleted( + MlnRuntimeEventOfflineOperationCompleted.operationId(payload), + OfflineOperationKind.fromNative( + MlnRuntimeEventOfflineOperationCompleted.operationKind(payload) + ), + OfflineOperationResultKind.fromNative( + MlnRuntimeEventOfflineOperationCompleted.resultKind(payload) + ), + MlnRuntimeEventOfflineOperationCompleted.resultStatus(payload), + MlnRuntimeEventOfflineOperationCompleted.found(payload), + ) + + private fun readUnknown( + payloadType: Int, + payload: HeapPointer, + payloadSize: Int, + ): RuntimeEventPayload.Unknown = + RuntimeEventPayload.Unknown(payloadType, unsigned(payloadSize), readBytes(payload, payloadSize)) + + private fun unsigned(value: Int): Long = value.toUInt().toLong() +} + +/** + * Copies [length] bytes of borrowed native storage, tolerating the null span. + * + * The C API spells an absent span as a null pointer with a zero length, and a present-but-empty one + * the same way, so both arrive here and neither is an error. + */ +private fun readBytes(pointer: HeapPointer, length: Int): ByteArray = + if (pointer.address == 0 || length <= 0) ByteArray(0) else Heap.loadBytes(pointer, length) + +/** + * Copies [length] bytes of borrowed native storage as text. + * + * Length-delimited rather than null-delimited because that is what the C API documents: the length + * excludes a terminator that a payload is not obliged to carry. + */ +private fun readText(pointer: HeapPointer, length: Int): String = + readBytes(pointer, length).decodeToString() diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/RenderMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/RenderMarshal.kt new file mode 100644 index 000000000..bfc9bab3e --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/RenderMarshal.kt @@ -0,0 +1,598 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.geo.Feature +import org.maplibre.nativeffi.geo.FeatureIdentifier +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.ScreenPoint +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnEglContextDescriptor +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeature +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureCollection +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureExtensionResultInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureExtensionResultType +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureIdentifierType +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureStateSelector +import org.maplibre.nativeffi.internal.wasm.generated.MlnFeatureStateSelectorField +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLng +import org.maplibre.nativeffi.internal.wasm.generated.MlnOpenglBorrowedTextureDescriptor +import org.maplibre.nativeffi.internal.wasm.generated.MlnOpenglContextDescriptor +import org.maplibre.nativeffi.internal.wasm.generated.MlnOpenglContextPlatform +import org.maplibre.nativeffi.internal.wasm.generated.MlnOpenglOwnedTextureFrame +import org.maplibre.nativeffi.internal.wasm.generated.MlnOpenglSurfaceDescriptor +import org.maplibre.nativeffi.internal.wasm.generated.MlnQueriedFeature +import org.maplibre.nativeffi.internal.wasm.generated.MlnQueriedFeatureField +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderTargetExtent +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderedFeatureQueryOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderedFeatureQueryOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderedQueryGeometry +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderedQueryGeometryType +import org.maplibre.nativeffi.internal.wasm.generated.MlnScreenBox +import org.maplibre.nativeffi.internal.wasm.generated.MlnScreenLineString +import org.maplibre.nativeffi.internal.wasm.generated.MlnScreenPoint +import org.maplibre.nativeffi.internal.wasm.generated.MlnSourceFeatureQueryOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnSourceFeatureQueryOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnStringView +import org.maplibre.nativeffi.internal.wasm.generated.MlnTextureImageInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnWebglContextDescriptor +import org.maplibre.nativeffi.internal.wasm.generated.MlnWglContextDescriptor +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.query.FeatureExtensionResult +import org.maplibre.nativeffi.query.FeatureStateSelector +import org.maplibre.nativeffi.query.QueriedFeature +import org.maplibre.nativeffi.query.RenderedFeatureQueryOptions +import org.maplibre.nativeffi.query.RenderedQueryGeometry +import org.maplibre.nativeffi.query.SourceFeatureQueryOptions +import org.maplibre.nativeffi.render.EglContextDescriptor +import org.maplibre.nativeffi.render.FrameScope +import org.maplibre.nativeffi.render.NativePointer +import org.maplibre.nativeffi.render.OpenGLBorrowedTextureDescriptor +import org.maplibre.nativeffi.render.OpenGLContextDescriptor +import org.maplibre.nativeffi.render.OpenGLOwnedTextureFrame +import org.maplibre.nativeffi.render.OpenGLSurfaceDescriptor +import org.maplibre.nativeffi.render.RenderTargetExtent +import org.maplibre.nativeffi.render.TextureImageInfo +import org.maplibre.nativeffi.render.WebglContextDescriptor +import org.maplibre.nativeffi.render.WglContextDescriptor + +/** + * Places a render session's descriptors into the Emscripten heap, and reads its results back. + * + * A render target descriptor is flat and goes straight into scratch. A query descriptor is a tree — + * options carry a filter that carries an array of values — so it follows the rule the other + * marshallers do: measure the tree, place it in one arena, hand native a single root pointer. The + * arena arithmetic comes from [JsonMarshal], which is also what makes a filter, a feature, and a + * geometry shareable with one block; a second copy of that checked arithmetic would be a second + * place for an unchecked subtotal to appear. + * + * Reading is what this file adds that the others do not have. A query result is native-owned + * storage that its destroy frees, so every string, JSON value, geometry, and feature below is + * copied into Kotlin here rather than left as a view onto memory that is about to go away. + * + * Every offset and width comes from the generated accessors, so this code names fields. + */ +internal object RenderMarshal { + /** Bytes a handle-valued or pointer-valued output slot occupies. */ + const val OUT_SLOT_BYTES: Int = 8 + + // ---------------------------------------------------------------- render targets + + /** Writes a render target extent, which states its own size as every descriptor does. */ + fun writeExtent(base: HeapPointer, extent: RenderTargetExtent) { + MlnRenderTargetExtent.setSize(base, MlnRenderTargetExtent.SIZEOF) + MlnRenderTargetExtent.setWidth(base, extent.width) + MlnRenderTargetExtent.setHeight(base, extent.height) + MlnRenderTargetExtent.setScaleFactor(base, extent.scaleFactor) + } + + /** + * Writes the platform arm of an OpenGL context descriptor. + * + * The arms are the ones the common API declares. This build's OpenGL backend is compiled against + * WebGL and accepts only its own provider, so a descriptor naming another one is written as given + * and refused by native, which is where a build's capability is actually known. + */ + fun writeOpenGLContext(base: HeapPointer, context: OpenGLContextDescriptor) { + MlnOpenglContextDescriptor.setSize(base, MlnOpenglContextDescriptor.SIZEOF) + val data = base + MlnOpenglContextDescriptor.OFFSET_DATA + when (context) { + is WglContextDescriptor -> { + MlnOpenglContextDescriptor.setPlatform( + base, + MlnOpenglContextPlatform.MLN_OPENGL_CONTEXT_PLATFORM_WGL, + ) + MlnWglContextDescriptor.setSize(data, MlnWglContextDescriptor.SIZEOF) + MlnWglContextDescriptor.setDeviceContext(data, address(context.deviceContext)) + MlnWglContextDescriptor.setShareContext(data, address(context.shareContext)) + MlnWglContextDescriptor.setGetProcAddress(data, address(context.getProcAddress)) + } + is EglContextDescriptor -> { + MlnOpenglContextDescriptor.setPlatform( + base, + MlnOpenglContextPlatform.MLN_OPENGL_CONTEXT_PLATFORM_EGL, + ) + MlnEglContextDescriptor.setSize(data, MlnEglContextDescriptor.SIZEOF) + MlnEglContextDescriptor.setDisplay(data, address(context.display)) + MlnEglContextDescriptor.setConfig(data, address(context.config)) + MlnEglContextDescriptor.setShareContext(data, address(context.shareContext)) + MlnEglContextDescriptor.setGetProcAddress(data, address(context.getProcAddress)) + } + // The arm this target actually renders through. A WebGL context is not an address but an + // entry in the module's own context table, so it crosses as the index rather than through + // the pointer narrowing the other two arms need. + is WebglContextDescriptor -> { + MlnOpenglContextDescriptor.setPlatform( + base, + MlnOpenglContextPlatform.MLN_OPENGL_CONTEXT_PLATFORM_WEBGL, + ) + MlnWebglContextDescriptor.setSize(data, MlnWebglContextDescriptor.SIZEOF) + // Native documents the handle as positive, and a zero here is the value a host gets back + // from a context it failed to create, so it is refused before it reaches a render target. + Status.requireArgument(context.context > 0) { + "A WebGL context handle must be positive, but was ${context.context}" + } + MlnWebglContextDescriptor.setContext(data, context.context) + } + } + } + + const val OPENGL_SURFACE_SIZEOF: Int = MlnOpenglSurfaceDescriptor.SIZEOF + + /** + * Writes an OpenGL surface descriptor. + * + * The surface field is what a browser makes different. Every other OpenGL provider names a + * drawable beside the context — an HDC, an EGLSurface — and a WebGL context has none: it is bound + * to the canvas it was created on, and that canvas's default framebuffer is what the session + * presents to. So native requires this field to be null here, and passing anything else is + * refused there rather than silently ignored. + */ + fun writeOpenGLSurface(base: HeapPointer, descriptor: OpenGLSurfaceDescriptor) { + MlnOpenglSurfaceDescriptor.setSize(base, MlnOpenglSurfaceDescriptor.SIZEOF) + writeExtent(base + MlnOpenglSurfaceDescriptor.OFFSET_EXTENT, descriptor.extent) + writeOpenGLContext(base + MlnOpenglSurfaceDescriptor.OFFSET_CONTEXT, descriptor.context) + MlnOpenglSurfaceDescriptor.setSurface(base, address(descriptor.surface)) + } + + const val OPENGL_BORROWED_TEXTURE_SIZEOF: Int = MlnOpenglBorrowedTextureDescriptor.SIZEOF + + fun writeOpenGLBorrowedTexture(base: HeapPointer, descriptor: OpenGLBorrowedTextureDescriptor) { + MlnOpenglBorrowedTextureDescriptor.setSize(base, MlnOpenglBorrowedTextureDescriptor.SIZEOF) + writeExtent(base + MlnOpenglBorrowedTextureDescriptor.OFFSET_EXTENT, descriptor.extent) + // A caller-owned texture is sized by its owner, so its physical size is stated rather than + // derived from the extent above. + MlnOpenglBorrowedTextureDescriptor.setPhysicalWidth(base, descriptor.physicalWidth) + MlnOpenglBorrowedTextureDescriptor.setPhysicalHeight(base, descriptor.physicalHeight) + writeOpenGLContext(base + MlnOpenglBorrowedTextureDescriptor.OFFSET_CONTEXT, descriptor.context) + MlnOpenglBorrowedTextureDescriptor.setTexture(base, descriptor.texture) + MlnOpenglBorrowedTextureDescriptor.setTarget(base, descriptor.target) + } + + /** + * Narrows a borrowed backend address to what this target can hold. + * + * A [NativePointer] is sixty-four bits because the C ABI is on most targets. Here it is not: a + * browser module addresses thirty-two, so a wider address names memory native could never reach + * and is refused rather than truncated into one that looks valid. + */ + private fun address(pointer: NativePointer): HeapPointer { + val value = pointer.address + Status.requireArgument(value >= 0 && value <= MAX_ADDRESS) { + "a native pointer must fit a 32-bit address on this target" + } + return HeapPointer(value.toInt()) + } + + // ---------------------------------------------------------------- texture readback + + const val TEXTURE_IMAGE_INFO_SIZEOF: Int = MlnTextureImageInfo.SIZEOF + + /** + * Writes the readback metadata header alone, for a descriptor native fills. + * + * An output descriptor states its size too: native reads it to decide which fields it may write, + * and a zeroed block asks for a zero-sized one, which it refuses. + */ + fun writeTextureImageInfoHeader(base: HeapPointer) { + MlnTextureImageInfo.setSize(base, MlnTextureImageInfo.SIZEOF) + } + + fun readTextureImageInfo(base: HeapPointer): TextureImageInfo = + TextureImageInfo( + MlnTextureImageInfo.width(base), + MlnTextureImageInfo.height(base), + MlnTextureImageInfo.stride(base), + // A byte length is `size_t`, which is unsigned and thirty-two bits here, so its top bit is + // part of the length rather than a sign. + MlnTextureImageInfo.byteLength(base).toLong() and MAX_ADDRESS, + ) + + // ---------------------------------------------------------------- owned texture frames + + const val OPENGL_OWNED_TEXTURE_FRAME_SIZEOF: Int = MlnOpenglOwnedTextureFrame.SIZEOF + + fun writeOpenGLFrameHeader(base: HeapPointer) { + MlnOpenglOwnedTextureFrame.setSize(base, MlnOpenglOwnedTextureFrame.SIZEOF) + } + + fun readOpenGLFrame(base: HeapPointer, scope: FrameScope): OpenGLOwnedTextureFrame = + OpenGLOwnedTextureFrame( + scope, + MlnOpenglOwnedTextureFrame.generation(base), + MlnOpenglOwnedTextureFrame.width(base), + MlnOpenglOwnedTextureFrame.height(base), + MlnOpenglOwnedTextureFrame.scaleFactor(base), + MlnOpenglOwnedTextureFrame.frameId(base), + MlnOpenglOwnedTextureFrame.texture(base), + MlnOpenglOwnedTextureFrame.target(base), + MlnOpenglOwnedTextureFrame.internalFormat(base), + MlnOpenglOwnedTextureFrame.format(base), + MlnOpenglOwnedTextureFrame.type(base), + ) + + /** + * Rebuilds an acquired frame's descriptor so that it can be released. + * + * Nothing keeps the descriptor native filled at acquire: it lived in scratch that call freed, and + * a browser host cannot be handed a heap address to hold across its own frame loop. Rebuilding is + * sound because the C API matches a release by value — it compares the frame's generation and + * frame id against the acquired ones — rather than by the pointer those values arrive through. + */ + fun writeOpenGLFrame(base: HeapPointer, frame: OpenGLOwnedTextureFrame) { + writeOpenGLFrameHeader(base) + MlnOpenglOwnedTextureFrame.setGeneration(base, frame.generation()) + MlnOpenglOwnedTextureFrame.setWidth(base, frame.width()) + MlnOpenglOwnedTextureFrame.setHeight(base, frame.height()) + MlnOpenglOwnedTextureFrame.setScaleFactor(base, frame.scaleFactor()) + MlnOpenglOwnedTextureFrame.setFrameId(base, frame.frameId()) + MlnOpenglOwnedTextureFrame.setTexture(base, frame.texture()) + MlnOpenglOwnedTextureFrame.setTarget(base, frame.target()) + MlnOpenglOwnedTextureFrame.setInternalFormat(base, frame.internalFormat()) + MlnOpenglOwnedTextureFrame.setFormat(base, frame.format()) + MlnOpenglOwnedTextureFrame.setType(base, frame.type()) + } + + // ---------------------------------------------------------------- string view arguments + + /** + * Bytes a string view passed as its own argument needs. + * + * A C parameter of type `mln_string_view` is taken by value, which this target lowers to a + * pointer to the view, so the view itself needs a block as well as its text. + */ + fun measureStringViewRoot(text: String): Long = + JsonMarshal.plus(JsonMarshal.measureBlock(MlnStringView.SIZEOF), JsonMarshal.measureText(text)) + + fun writeStringViewRoot(arena: HeapArena, text: String): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnStringView.SIZEOF) + JsonMarshal.writeText(arena, base, text) + return base + } + + // ---------------------------------------------------------------- feature state + + fun measureFeatureStateSelector(selector: FeatureStateSelector): Long { + var total = + JsonMarshal.plus( + JsonMarshal.measureBlock(MlnFeatureStateSelector.SIZEOF), + JsonMarshal.measureText(selector.sourceId), + ) + selector.sourceLayerId?.let { total = JsonMarshal.plus(total, JsonMarshal.measureText(it)) } + selector.featureId?.let { total = JsonMarshal.plus(total, JsonMarshal.measureText(it)) } + selector.stateKey?.let { total = JsonMarshal.plus(total, JsonMarshal.measureText(it)) } + return total + } + + fun writeFeatureStateSelector(arena: HeapArena, selector: FeatureStateSelector): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnFeatureStateSelector.SIZEOF) + MlnFeatureStateSelector.setSize(base, MlnFeatureStateSelector.SIZEOF) + // The source ID is required and carries no field bit. The rest are present only where a bit + // says so, so an absent Kotlin value leaves a bit clear rather than writing an empty view that + // native would read as a present, empty ID. + JsonMarshal.writeText(arena, base + MlnFeatureStateSelector.OFFSET_SOURCE_ID, selector.sourceId) + var fields = 0 + selector.sourceLayerId?.let { + fields = fields or MlnFeatureStateSelectorField.MLN_FEATURE_STATE_SELECTOR_SOURCE_LAYER_ID + JsonMarshal.writeText(arena, base + MlnFeatureStateSelector.OFFSET_SOURCE_LAYER_ID, it) + } + selector.featureId?.let { + fields = fields or MlnFeatureStateSelectorField.MLN_FEATURE_STATE_SELECTOR_FEATURE_ID + JsonMarshal.writeText(arena, base + MlnFeatureStateSelector.OFFSET_FEATURE_ID, it) + } + selector.stateKey?.let { + fields = fields or MlnFeatureStateSelectorField.MLN_FEATURE_STATE_SELECTOR_STATE_KEY + JsonMarshal.writeText(arena, base + MlnFeatureStateSelector.OFFSET_STATE_KEY, it) + } + MlnFeatureStateSelector.setFields(base, fields) + return base + } + + // ---------------------------------------------------------------- query inputs + + fun measureRenderedQueryGeometry(geometry: RenderedQueryGeometry): Long { + val root = JsonMarshal.measureBlock(MlnRenderedQueryGeometry.SIZEOF) + return when (geometry) { + // A point and a box live in the descriptor's own union arm, so neither needs storage of its + // own; only a line string points somewhere else. + is RenderedQueryGeometry.Point -> root + is RenderedQueryGeometry.Box -> root + is RenderedQueryGeometry.LineString -> + JsonMarshal.plus( + root, + JsonMarshal.measureArray(MlnScreenPoint.SIZEOF, geometry.points.size), + ) + } + } + + fun writeRenderedQueryGeometry(arena: HeapArena, geometry: RenderedQueryGeometry): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnRenderedQueryGeometry.SIZEOF) + MlnRenderedQueryGeometry.setSize(base, MlnRenderedQueryGeometry.SIZEOF) + val data = base + MlnRenderedQueryGeometry.OFFSET_DATA + when (geometry) { + is RenderedQueryGeometry.Point -> { + MlnRenderedQueryGeometry.setType( + base, + MlnRenderedQueryGeometryType.MLN_RENDERED_QUERY_GEOMETRY_TYPE_POINT, + ) + writeScreenPoint(data, geometry.point) + } + is RenderedQueryGeometry.Box -> { + MlnRenderedQueryGeometry.setType( + base, + MlnRenderedQueryGeometryType.MLN_RENDERED_QUERY_GEOMETRY_TYPE_BOX, + ) + writeScreenPoint(data + MlnScreenBox.OFFSET_MIN, geometry.box.min) + writeScreenPoint(data + MlnScreenBox.OFFSET_MAX, geometry.box.max) + } + is RenderedQueryGeometry.LineString -> { + MlnRenderedQueryGeometry.setType( + base, + MlnRenderedQueryGeometryType.MLN_RENDERED_QUERY_GEOMETRY_TYPE_LINE_STRING, + ) + val points = JsonMarshal.allocateArray(arena, MlnScreenPoint.SIZEOF, geometry.points.size) + geometry.points.forEachIndexed { index, point -> + writeScreenPoint(points + index * MlnScreenPoint.SIZEOF, point) + } + MlnScreenLineString.setPoints(data, points) + MlnScreenLineString.setPointCount(data, geometry.points.size) + } + } + return base + } + + private fun writeScreenPoint(base: HeapPointer, point: ScreenPoint) { + MlnScreenPoint.setX(base, point.x) + MlnScreenPoint.setY(base, point.y) + } + + fun measureRenderedFeatureQueryOptions(options: RenderedFeatureQueryOptions?): Long { + if (options == null) return 0L + var total = JsonMarshal.measureBlock(MlnRenderedFeatureQueryOptions.SIZEOF) + options.layerIds?.let { total = JsonMarshal.plus(total, measureStringViewArray(it)) } + options.filter?.let { total = JsonMarshal.plus(total, JsonMarshal.measureValue(it, 0)) } + return total + } + + /** Returns the null pointer for absent options, which the C API reads as its own defaults. */ + fun writeRenderedFeatureQueryOptions( + arena: HeapArena, + options: RenderedFeatureQueryOptions?, + ): HeapPointer { + if (options == null) return HeapPointer(0) + val base = JsonMarshal.allocateBlock(arena, MlnRenderedFeatureQueryOptions.SIZEOF) + MlnRenderedFeatureQueryOptions.setSize(base, MlnRenderedFeatureQueryOptions.SIZEOF) + var fields = 0 + options.layerIds?.let { layerIds -> + fields = + fields or MlnRenderedFeatureQueryOptionField.MLN_RENDERED_FEATURE_QUERY_OPTION_LAYER_IDS + MlnRenderedFeatureQueryOptions.setLayerIds(base, writeStringViewArray(arena, layerIds)) + MlnRenderedFeatureQueryOptions.setLayerIdCount(base, layerIds.size) + } + // A filter carries no field bit of its own: the C API reads a null pointer as no filter. + options.filter?.let { + MlnRenderedFeatureQueryOptions.setFilter(base, JsonMarshal.write(arena, it)) + } + MlnRenderedFeatureQueryOptions.setFields(base, fields) + return base + } + + fun measureSourceFeatureQueryOptions(options: SourceFeatureQueryOptions?): Long { + if (options == null) return 0L + var total = JsonMarshal.measureBlock(MlnSourceFeatureQueryOptions.SIZEOF) + options.sourceLayerIds?.let { total = JsonMarshal.plus(total, measureStringViewArray(it)) } + options.filter?.let { total = JsonMarshal.plus(total, JsonMarshal.measureValue(it, 0)) } + return total + } + + fun writeSourceFeatureQueryOptions( + arena: HeapArena, + options: SourceFeatureQueryOptions?, + ): HeapPointer { + if (options == null) return HeapPointer(0) + val base = JsonMarshal.allocateBlock(arena, MlnSourceFeatureQueryOptions.SIZEOF) + MlnSourceFeatureQueryOptions.setSize(base, MlnSourceFeatureQueryOptions.SIZEOF) + var fields = 0 + options.sourceLayerIds?.let { sourceLayerIds -> + fields = + fields or MlnSourceFeatureQueryOptionField.MLN_SOURCE_FEATURE_QUERY_OPTION_SOURCE_LAYER_IDS + MlnSourceFeatureQueryOptions.setSourceLayerIds( + base, + writeStringViewArray(arena, sourceLayerIds), + ) + MlnSourceFeatureQueryOptions.setSourceLayerIdCount(base, sourceLayerIds.size) + } + options.filter?.let { + MlnSourceFeatureQueryOptions.setFilter(base, JsonMarshal.write(arena, it)) + } + MlnSourceFeatureQueryOptions.setFields(base, fields) + return base + } + + private fun measureStringViewArray(values: List): Long = + values.fold(JsonMarshal.measureArray(MlnStringView.SIZEOF, values.size)) { total, value -> + JsonMarshal.plus(total, JsonMarshal.measureText(value)) + } + + private fun writeStringViewArray(arena: HeapArena, values: List): HeapPointer { + val base = JsonMarshal.allocateArray(arena, MlnStringView.SIZEOF, values.size) + values.forEachIndexed { index, value -> + JsonMarshal.writeText(arena, base + index * MlnStringView.SIZEOF, value) + } + return base + } + + // ---------------------------------------------------------------- query results + + const val QUERIED_FEATURE_SIZEOF: Int = MlnQueriedFeature.SIZEOF + + fun writeQueriedFeatureHeader(base: HeapPointer) { + MlnQueriedFeature.setSize(base, MlnQueriedFeature.SIZEOF) + } + + fun readQueriedFeature(base: HeapPointer): QueriedFeature { + val fields = MlnQueriedFeature.fields(base) + val sourceId = + if ((fields and MlnQueriedFeatureField.MLN_QUERIED_FEATURE_SOURCE_ID) != 0) { + JsonMarshal.readText(base + MlnQueriedFeature.OFFSET_SOURCE_ID) + } else { + null + } + val sourceLayerId = + if ((fields and MlnQueriedFeatureField.MLN_QUERIED_FEATURE_SOURCE_LAYER_ID) != 0) { + JsonMarshal.readText(base + MlnQueriedFeature.OFFSET_SOURCE_LAYER_ID) + } else { + null + } + val state = + if ((fields and MlnQueriedFeatureField.MLN_QUERIED_FEATURE_STATE) != 0) { + readJsonPointer(MlnQueriedFeature.state(base)) + } else { + null + } + return QueriedFeature( + readFeature(base + MlnQueriedFeature.OFFSET_FEATURE), + sourceId, + sourceLayerId, + state, + ) + } + + const val FEATURE_EXTENSION_RESULT_INFO_SIZEOF: Int = MlnFeatureExtensionResultInfo.SIZEOF + + fun writeFeatureExtensionResultInfoHeader(base: HeapPointer) { + MlnFeatureExtensionResultInfo.setSize(base, MlnFeatureExtensionResultInfo.SIZEOF) + } + + fun readFeatureExtensionResultInfo(base: HeapPointer): FeatureExtensionResult { + val data = base + MlnFeatureExtensionResultInfo.OFFSET_DATA + return when (val type = MlnFeatureExtensionResultInfo.type(base)) { + MlnFeatureExtensionResultType.MLN_FEATURE_EXTENSION_RESULT_TYPE_VALUE -> + // The arm is a bare pointer rather than a struct, so there is no generated field accessor + // to name; what is read is the union's own address. + FeatureExtensionResult.Value( + readJsonPointer(HeapPointer(Heap.loadInt(data))) ?: JsonValue.Null + ) + MlnFeatureExtensionResultType.MLN_FEATURE_EXTENSION_RESULT_TYPE_FEATURE_COLLECTION -> + FeatureExtensionResult.FeatureCollection(readFeatureCollection(data)) + // A tag from a newer C API than this binding was generated against. The arm it selects is + // unknown, so nothing here may read the union; the raw tag is kept so a caller can see what + // arrived. + else -> FeatureExtensionResult.Unknown(type) + } + } + + /** Reads a JSON value behind a pointer native may leave null to mean absent. */ + fun readJsonPointer(base: HeapPointer): JsonValue? = + if (base.address == 0) null else JsonMarshal.read(base) + + private fun readFeatureCollection(base: HeapPointer): List { + val features = MlnFeatureCollection.features(base) + return List(readCount(MlnFeatureCollection.featureCount(base))) { index -> + readFeature(features + index * MlnFeature.SIZEOF) + } + } + + // ---------------------------------------------------------------- features, reading only + + /** + * Reads a feature descriptor native owns. + * + * Writing lives in [GeoJsonMarshal], which has to measure and place a tree. Reading only walks + * pointers native has already placed, so it needs none of that machinery — but it does have to + * copy, because the storage it walks belongs to a result handle that is about to be destroyed. + */ + fun readFeature(base: HeapPointer): Feature = + Feature( + readGeometry(MlnFeature.geometry(base), 0), + JsonMarshal.readMembers( + MlnFeature.properties(base), + readCount(MlnFeature.propertyCount(base)), + // Properties are a root member array rather than an object's, so their values start at + // depth zero, matching how they are written. + 0, + ), + readFeatureIdentifier(base), + ) + + private fun readFeatureIdentifier(base: HeapPointer): FeatureIdentifier { + val data = base + MlnFeature.OFFSET_IDENTIFIER + return when (val type = MlnFeature.identifierType(base)) { + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_NULL -> FeatureIdentifier.Null + // Carried as the bit pattern it was read as. The C arm is unsigned and Kotlin's Long is not, + // so reinterpreting here would change the identifier rather than preserve it. + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_UINT -> + FeatureIdentifier.UInt(Heap.loadLong(data)) + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_INT -> + FeatureIdentifier.Int(Heap.loadLong(data)) + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_DOUBLE -> + FeatureIdentifier.DoubleValue(Heap.loadDouble(data)) + MlnFeatureIdentifierType.MLN_FEATURE_IDENTIFIER_TYPE_STRING -> + FeatureIdentifier.StringValue(JsonMarshal.readText(data)) + // A tag from a newer C API than this binding was generated against, kept rather than + // rejected so a caller can still see the rest of the feature it arrived in. + else -> FeatureIdentifier.Unknown(type) + } + } + + // ---------------------------------------------------------------- geometry, reading only + + /** Reads a geometry tree native owns. Both halves live in [GeometryMarshal]. */ + fun readGeometry(base: HeapPointer, depth: Int): Geometry = GeometryMarshal.read(base, depth) + + private fun readLatLng(base: HeapPointer): LatLng = + LatLng(MlnLatLng.latitude(base), MlnLatLng.longitude(base)) + + /** + * Refuses a tree deeper than the C API accepts. + * + * Checked before recursing rather than left to the walk: a deep enough tree exhausts this + * module's own stack, and the descriptor being read is only as trustworthy as the module that + * produced it. + */ + private fun requireGeometryDepth(depth: Int) { + if (depth > Geometry.MAX_COLLECTION_DEPTH) { + throw Status.invalidArgument( + "geometry nests deeper than the ${Geometry.MAX_COLLECTION_DEPTH} levels the C API accepts" + ) + } + } + + /** + * Refuses a count native reported that no real descriptor could carry. + * + * `size_t` is thirty-two bits on this target, so a value past [Int.MAX_VALUE] arrives negative. + * The heap could not hold a descriptor that large, so a negative count means the address being + * read is not the descriptor it was taken for, and continuing would index arbitrary memory. + */ + private fun readCount(count: Int): Int { + if (count < 0) { + throw Status.invalidState( + "The MapLibre Native browser module reported a descriptor count of $count" + ) + } + return count + } + + /** The largest address this target can hold, as an unsigned thirty-two-bit value. */ + private const val MAX_ADDRESS = 0xFFFFFFFFL +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/ResourceMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/ResourceMarshal.kt new file mode 100644 index 000000000..12eed91be --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/ResourceMarshal.kt @@ -0,0 +1,163 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnResourceRequest +import org.maplibre.nativeffi.internal.wasm.generated.MlnResourceResponse +import org.maplibre.nativeffi.resource.ResourceKind +import org.maplibre.nativeffi.resource.ResourceLoadingMethod +import org.maplibre.nativeffi.resource.ResourcePriority +import org.maplibre.nativeffi.resource.ResourceRequest +import org.maplibre.nativeffi.resource.ResourceResponse +import org.maplibre.nativeffi.resource.ResourceStoragePolicy +import org.maplibre.nativeffi.resource.ResourceTransformRequest +import org.maplibre.nativeffi.resource.ResourceUsage + +/** + * Reads the resource descriptors native lends a callback, and places the one it takes back. + * + * Both directions are copies. A request and its strings are borrowed for the callback's duration + * only, so everything a host keeps is copied into Kotlin before the callback returns; a response is + * placed in one scratch block that outlives the call it is passed to and nothing longer. + * + * Every offset and width here comes from the generated accessors, so this code names fields. + */ +internal object ResourceMarshal { + // A response descriptor carries `int64_t` timestamps, so the block it starts in is aligned for + // them; its strings and bytes have no alignment of their own. + private const val DESCRIPTOR_ALIGN = 8 + private const val BYTE_ALIGN = 1 + + /** + * Copies the request at [base] into Kotlin. + * + * Called on the thread the host lives on, while the thread that produced the request waits, so + * the descriptor and everything it points at are still valid here. + */ + fun readRequest(base: HeapPointer): ResourceRequest = + ResourceRequest( + requestedUrl = Heap.loadUtf8(MlnResourceRequest.requestedUrl(base)), + resolvedUrl = Heap.loadUtf8(MlnResourceRequest.resolvedUrl(base)), + kind = ResourceKind.fromNative(MlnResourceRequest.kind(base)), + loadingMethod = ResourceLoadingMethod.fromNative(MlnResourceRequest.loadingMethod(base)), + priority = ResourcePriority.fromNative(MlnResourceRequest.priority(base)), + usage = ResourceUsage.fromNative(MlnResourceRequest.usage(base)), + storagePolicy = ResourceStoragePolicy.fromNative(MlnResourceRequest.storagePolicy(base)), + range = + if (MlnResourceRequest.hasRange(base)) { + ResourceRequest.ByteRange( + MlnResourceRequest.rangeStart(base), + MlnResourceRequest.rangeEnd(base), + ) + } else { + null + }, + priorModifiedUnixMs = + if (MlnResourceRequest.hasPriorModified(base)) { + MlnResourceRequest.priorModifiedUnixMs(base) + } else { + null + }, + priorExpiresUnixMs = + if (MlnResourceRequest.hasPriorExpires(base)) { + MlnResourceRequest.priorExpiresUnixMs(base) + } else { + null + }, + // Null and empty mean different things here: no prior ETag at all, against one that is the + // empty string. Reading the string would collapse them. + priorEtag = optionalUtf8(MlnResourceRequest.priorEtag(base)), + priorData = + readBytes(MlnResourceRequest.priorData(base), MlnResourceRequest.priorDataSize(base)), + ) + + /** Copies the transform request native lends a URL transform callback. */ + fun readTransformRequest(kind: Int, url: HeapPointer): ResourceTransformRequest = + ResourceTransformRequest(ResourceKind.fromNative(kind), Heap.loadUtf8(url)) + + /** + * Places [response] in scratch, calls [body] with the descriptor, and releases the scratch. + * + * The descriptor points at bytes and strings placed in the same block, so the whole response is + * one acquisition and one release however large its payload. Native copies everything it keeps + * before the completion call returns, which is what lets the block go at the end of [body]. + * + * Each of the response's copying properties is read exactly once, because reading one copies the + * value it holds. + */ + fun withResponse(response: ResourceResponse, body: (HeapPointer) -> T): T { + // An unknown reason came from a native value this binding does not recognise, so sending it + // back would ask native to store a reason it never produced. + Status.requireArgument(response.errorReason.isKnown) { + "Unknown resource error reason cannot be used as input: ${response.errorReason.nativeValue}" + } + val bytes = response.bytes + val errorMessage = checkedText(response.errorMessage, "error message") + val etag = checkedText(response.etag, "ETag") + + var total = HeapArena.aligned(MlnResourceResponse.SIZEOF.toLong(), DESCRIPTOR_ALIGN) + total = plus(total, Heap.sizeOf(Byte.SIZE_BYTES, bytes.size).toLong()) + errorMessage?.let { total = plus(total, Heap.utf8Size(it).toLong()) } + etag?.let { total = plus(total, Heap.utf8Size(it).toLong()) } + + return Heap.withScratch(total.toInt()) { scratch -> + val arena = HeapArena(scratch, total.toInt()) + val base = arena.allocate(MlnResourceResponse.SIZEOF, DESCRIPTOR_ALIGN) + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnResourceResponse.setSize(base, MlnResourceResponse.SIZEOF) + MlnResourceResponse.setStatus(base, response.status.nativeValue) + MlnResourceResponse.setErrorReason(base, response.errorReason.nativeValue) + if (bytes.isNotEmpty()) { + val payload = arena.allocate(Heap.sizeOf(Byte.SIZE_BYTES, bytes.size), BYTE_ALIGN) + Heap.storeBytes(payload, bytes) + MlnResourceResponse.setBytes(base, payload) + MlnResourceResponse.setByteCount(base, bytes.size) + } + errorMessage?.let { MlnResourceResponse.setErrorMessage(base, writeText(arena, it)) } + MlnResourceResponse.setMustRevalidate(base, response.mustRevalidate) + // An absent timestamp is a flag left clear rather than a sentinel written into the value. + response.modifiedUnixMs?.let { + MlnResourceResponse.setHasModified(base, true) + MlnResourceResponse.setModifiedUnixMs(base, it) + } + response.expiresUnixMs?.let { + MlnResourceResponse.setHasExpires(base, true) + MlnResourceResponse.setExpiresUnixMs(base, it) + } + etag?.let { MlnResourceResponse.setEtag(base, writeText(arena, it)) } + response.retryAfterUnixMs?.let { + MlnResourceResponse.setHasRetryAfter(base, true) + MlnResourceResponse.setRetryAfterUnixMs(base, it) + } + body(base) + } + } + + private fun optionalUtf8(pointer: HeapPointer): String? = + if (pointer.address == 0) null else Heap.loadUtf8(pointer) + + private fun readBytes(pointer: HeapPointer, byteCount: Int): ByteArray = + if (pointer.address == 0) ByteArray(0) else Heap.loadBytes(pointer, byteCount) + + /** Refuses text a C string cannot carry, before it is measured against the block it goes in. */ + private fun checkedText(value: String?, description: String): String? { + value ?: return null + Status.requireArgument('\u0000' !in value) { "$description contains embedded NUL" } + return value + } + + private fun writeText(arena: HeapArena, value: String): HeapPointer { + val pointer = arena.allocate(Heap.utf8Size(value), BYTE_ALIGN) + Heap.storeUtf8(pointer, value) + return pointer + } + + /** Adds two measured sizes, refusing a total this target could not address. */ + private fun plus(left: Long, right: Long): Long { + val total = left + right + if (total > Int.MAX_VALUE || total < 0) { + throw Status.invalidArgument("the response is too large to place in the module's heap") + } + return total + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/SourceMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/SourceMarshal.kt new file mode 100644 index 000000000..a82bb517e --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/SourceMarshal.kt @@ -0,0 +1,308 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnCustomGeometrySourceOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnCustomGeometrySourceOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeojsonSourceOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnGeojsonSourceOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleSourceInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleSourceInfoField +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleTileSourceOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleTileSourceOptions +import org.maplibre.nativeffi.style.CustomGeometrySourceOptions +import org.maplibre.nativeffi.style.GeoJsonSourceOptions +import org.maplibre.nativeffi.style.RasterDemEncoding +import org.maplibre.nativeffi.style.SourceInfo +import org.maplibre.nativeffi.style.SourceType +import org.maplibre.nativeffi.style.TileJson +import org.maplibre.nativeffi.style.TileScheme +import org.maplibre.nativeffi.style.TileSourceOptions +import org.maplibre.nativeffi.style.VectorTileEncoding + +/** + * Places the style source descriptors into the Emscripten heap, and reads source metadata back. + * + * Each source descriptor pairs its values with a bit per field, so an absent Kotlin value is a bit + * left clear rather than a sentinel written into the value. + * + * Three of them reach past their own bytes. A tile source carries its attribution as a string view, + * so the text is placed beside the descriptor and the descriptor is measured before it is written. + * A GeoJSON source borrows a JSON graph for the call, and a custom geometry source borrows the + * callbacks the module's function table holds; both arrive as addresses their owners placed. + * + * Every offset and width here comes from the generated accessors, so this code names fields. + */ +internal object SourceMarshal { + val GEOJSON_SOURCE_OPTIONS_SIZEOF: Int = MlnGeojsonSourceOptions.SIZEOF + val CUSTOM_GEOMETRY_SOURCE_OPTIONS_SIZEOF: Int = MlnCustomGeometrySourceOptions.SIZEOF + val SOURCE_INFO_SIZEOF: Int = MlnStyleSourceInfo.SIZEOF + + /** + * Writes [options] at [base], with [clusterProperties] addressing the placed JSON graph. + * + * The C descriptor borrows the cluster properties for the call, so the graph is placed by whoever + * owns the JSON marshalling and has to stay alive until the call returns. Passing the options + * without the graph would set the field's bit over a null pointer, so it is refused here rather + * than left for native to reject. + */ + fun writeGeoJsonSourceOptions( + base: HeapPointer, + options: GeoJsonSourceOptions, + clusterProperties: HeapPointer?, + ) { + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnGeojsonSourceOptions.setSize(base, MlnGeojsonSourceOptions.SIZEOF) + var fields = 0 + options.minZoom?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_MIN_ZOOM + MlnGeojsonSourceOptions.setMinZoom(base, it) + } + options.maxZoom?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_MAX_ZOOM + MlnGeojsonSourceOptions.setMaxZoom(base, it) + } + options.tolerance?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_TOLERANCE + MlnGeojsonSourceOptions.setTolerance(base, it) + } + options.clusterMaxZoom?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_CLUSTER_MAX_ZOOM + MlnGeojsonSourceOptions.setClusterMaxZoom(base, it) + } + options.clusterProperties?.let { + val graph = + clusterProperties + ?: throw Status.invalidArgument( + "cluster properties were requested without a placed JSON graph" + ) + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_CLUSTER_PROPERTIES + MlnGeojsonSourceOptions.setClusterProperties(base, graph) + } + options.tileSize?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_TILE_SIZE + MlnGeojsonSourceOptions.setTileSize(base, it) + } + options.buffer?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_BUFFER + MlnGeojsonSourceOptions.setBuffer(base, it) + } + options.clusterRadius?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_CLUSTER_RADIUS + MlnGeojsonSourceOptions.setClusterRadius(base, it) + } + options.clusterMinPoints?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_CLUSTER_MIN_POINTS + MlnGeojsonSourceOptions.setClusterMinPoints(base, it) + } + options.lineMetrics?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_LINE_METRICS + MlnGeojsonSourceOptions.setLineMetrics(base, it) + } + options.cluster?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_CLUSTER + MlnGeojsonSourceOptions.setCluster(base, it) + } + options.synchronousUpdate?.let { + fields = fields or MlnGeojsonSourceOptionField.MLN_GEOJSON_SOURCE_OPTION_SYNCHRONOUS_UPDATE + MlnGeojsonSourceOptions.setSynchronousUpdate(base, it) + } + MlnGeojsonSourceOptions.setFields(base, fields) + } + + /** + * Bytes [options] needs, including the descriptor and any attribution text. + * + * The attribution crosses as a string view over memory native reads during the call, so the text + * is placed beside the descriptor: one acquisition and one release however the options are + * shaped. Measuring first is what lets both live in one block. + */ + fun measureTileSourceOptions(options: TileSourceOptions): Int { + val attribution = options.attribution?.let { JsonMarshal.measureText(it) } ?: 0L + // Measured and added through the shared arena helpers, so the padding a block leaves behind and + // a total that would wrap a 32-bit count are accounted for the one way every descriptor here + // accounts for them. + return JsonMarshal.plus(JsonMarshal.measureBlock(MlnStyleTileSourceOptions.SIZEOF), attribution) + .toInt() + } + + /** Writes [options] into [arena] and returns the descriptor's address. */ + fun writeTileSourceOptions(arena: HeapArena, options: TileSourceOptions): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnStyleTileSourceOptions.SIZEOF) + MlnStyleTileSourceOptions.setSize(base, MlnStyleTileSourceOptions.SIZEOF) + var fields = 0 + options.minZoom?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_MIN_ZOOM + MlnStyleTileSourceOptions.setMinZoom(base, it) + } + options.maxZoom?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_MAX_ZOOM + MlnStyleTileSourceOptions.setMaxZoom(base, it) + } + options.attribution?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_ATTRIBUTION + JsonMarshal.writeText(arena, base + MlnStyleTileSourceOptions.OFFSET_ATTRIBUTION, it) + } + options.scheme?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_SCHEME + MlnStyleTileSourceOptions.setScheme(base, it.nativeValue) + } + options.bounds?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_BOUNDS + MapOptionsMarshal.writeLatLngBounds(base + MlnStyleTileSourceOptions.OFFSET_BOUNDS, it) + } + options.tileSize?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_TILE_SIZE + MlnStyleTileSourceOptions.setTileSize(base, it) + } + options.vectorEncoding?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_VECTOR_ENCODING + MlnStyleTileSourceOptions.setVectorEncoding(base, it.nativeValue) + } + options.rasterDemEncoding?.let { + fields = fields or MlnStyleTileSourceOptionField.MLN_STYLE_TILE_SOURCE_OPTION_RASTER_ENCODING + MlnStyleTileSourceOptions.setRasterEncoding(base, it.nativeValue) + } + MlnStyleTileSourceOptions.setFields(base, fields) + return base + } + + /** + * Writes [options] at [base], with the tile callbacks the caller registered. + * + * [fetchTile] and [cancelTile] are indices into the module's function table rather than heap + * addresses, because a WebAssembly module's code lives outside the memory a pointer addresses. + * The callback bridge owns them and the context [userData] addresses, and keeps both alive for as + * long as the source exists. A [cancelTile] of zero leaves the optional cancel callback unset. + */ + fun writeCustomGeometrySourceOptions( + base: HeapPointer, + options: CustomGeometrySourceOptions, + fetchTile: Int, + cancelTile: Int, + userData: HeapPointer, + ) { + MlnCustomGeometrySourceOptions.setSize(base, MlnCustomGeometrySourceOptions.SIZEOF) + // Function pointers carry no generated accessor, because an offset alone cannot say what a + // table index means; they are the one place here that names a field by its offset constant. + Heap.storeInt(base + MlnCustomGeometrySourceOptions.OFFSET_FETCH_TILE, fetchTile) + Heap.storeInt(base + MlnCustomGeometrySourceOptions.OFFSET_CANCEL_TILE, cancelTile) + MlnCustomGeometrySourceOptions.setUserData(base, userData) + var fields = 0 + options.minZoom?.let { + fields = + fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_MIN_ZOOM + MlnCustomGeometrySourceOptions.setMinZoom(base, it) + } + options.maxZoom?.let { + fields = + fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_MAX_ZOOM + MlnCustomGeometrySourceOptions.setMaxZoom(base, it) + } + options.tolerance?.let { + fields = + fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_TOLERANCE + MlnCustomGeometrySourceOptions.setTolerance(base, it) + } + options.tileSize?.let { + fields = + fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_TILE_SIZE + MlnCustomGeometrySourceOptions.setTileSize(base, it) + } + options.buffer?.let { + fields = fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_BUFFER + MlnCustomGeometrySourceOptions.setBuffer(base, it) + } + options.clip?.let { + fields = fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_CLIP + MlnCustomGeometrySourceOptions.setClip(base, it) + } + options.wrap?.let { + fields = fields or MlnCustomGeometrySourceOptionField.MLN_CUSTOM_GEOMETRY_SOURCE_OPTION_WRAP + MlnCustomGeometrySourceOptions.setWrap(base, it) + } + MlnCustomGeometrySourceOptions.setFields(base, fields) + } + + /** + * Writes a source metadata header alone, for a buffer native fills. + * + * An output descriptor still states its size: native reads it to decide which fields it may + * write, and a zeroed block would ask for a zero-sized descriptor. + */ + fun writeSourceInfoHeader(base: HeapPointer) { + MlnStyleSourceInfo.setSize(base, MlnStyleSourceInfo.SIZEOF) + } + + /** Reports whether the source at [base] carries attribution worth a second call to copy. */ + fun sourceInfoHasAttribution(base: HeapPointer): Boolean = MlnStyleSourceInfo.hasAttribution(base) + + /** Reports whether the source at [base] retains a URL, which a second call copies. */ + fun sourceInfoHasUrl(base: HeapPointer): Boolean = + sourceInfoHas(base, MlnStyleSourceInfoField.MLN_STYLE_SOURCE_INFO_URL) + + /** Reports whether the source at [base] was defined with inline TileJSON. */ + fun sourceInfoHasTileJson(base: HeapPointer): Boolean = + sourceInfoHas(base, MlnStyleSourceInfoField.MLN_STYLE_SOURCE_INFO_TILEJSON) + + /** + * Reads the source metadata at [base], with the strings the caller copied. + * + * The C descriptor carries string lengths and counts rather than string contents, so the + * attribution, the URL, and the inline tile URLs arrive from separate calls rather than from + * these bytes. + */ + fun readSourceInfo( + base: HeapPointer, + attribution: String?, + url: String?, + tileUrls: List?, + ): SourceInfo = + SourceInfo( + SourceType.fromNative(MlnStyleSourceInfo.type(base)), + MlnStyleSourceInfo.isVolatile(base), + attribution, + if (sourceInfoHasUrl(base)) url else null, + if (sourceInfoHasTileJson(base)) readTileJson(base, tileUrls.orEmpty()) else null, + if (sourceInfoHas(base, MlnStyleSourceInfoField.MLN_STYLE_SOURCE_INFO_TILE_SIZE)) { + MlnStyleSourceInfo.tileSize(base) + } else { + null + }, + if (sourceInfoHas(base, MlnStyleSourceInfoField.MLN_STYLE_SOURCE_INFO_VECTOR_ENCODING)) { + VectorTileEncoding.fromNative(MlnStyleSourceInfo.vectorEncoding(base)) + } else { + null + }, + if (sourceInfoHas(base, MlnStyleSourceInfoField.MLN_STYLE_SOURCE_INFO_RASTER_ENCODING)) { + RasterDemEncoding.fromNative(MlnStyleSourceInfo.rasterEncoding(base)) + } else { + null + }, + ) + + private fun readTileJson(base: HeapPointer, tileUrls: List): TileJson = + TileJson( + tileUrls, + MlnStyleSourceInfo.minZoom(base), + MlnStyleSourceInfo.maxZoom(base), + TileScheme.fromNative(MlnStyleSourceInfo.scheme(base)), + if (sourceInfoHas(base, MlnStyleSourceInfoField.MLN_STYLE_SOURCE_INFO_BOUNDS)) { + MapOptionsMarshal.readLatLngBounds(base + MlnStyleSourceInfo.OFFSET_BOUNDS) + } else { + null + }, + ) + + private fun sourceInfoHas(base: HeapPointer, field: Int): Boolean = + (MlnStyleSourceInfo.fields(base) and field) != 0 + + /** + * Reads a source type from the out-parameter at [pointer]. + * + * The C API reports it as a bare enum rather than inside a descriptor, so the caller positions + * the four bytes and this names what they hold. + */ + fun readSourceType(pointer: HeapPointer): SourceType = + SourceType.fromNative(Heap.loadInt(pointer)) +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/StyleMarshal.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/StyleMarshal.kt new file mode 100644 index 000000000..7d23c93b4 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/internal/wasm/StyleMarshal.kt @@ -0,0 +1,261 @@ +package org.maplibre.nativeffi.internal.wasm + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.generated.MlnImageContent +import org.maplibre.nativeffi.internal.wasm.generated.MlnImageStretch +import org.maplibre.nativeffi.internal.wasm.generated.MlnPremultipliedRgba8Image +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleImageInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleImageOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleImageOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleTransitionOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleTransitionOptions +import org.maplibre.nativeffi.render.PremultipliedRgba8Image +import org.maplibre.nativeffi.style.ImageContent +import org.maplibre.nativeffi.style.ImageStretch +import org.maplibre.nativeffi.style.StyleImageInfo +import org.maplibre.nativeffi.style.StyleImageOptions +import org.maplibre.nativeffi.style.StyleImageTextFit +import org.maplibre.nativeffi.style.StyleTransitionOptions + +/** + * Places the style's image and transition descriptors into the Emscripten heap, and reads them + * back. + * + * The transition options pair their values with a bit per field, so an absent Kotlin value is a bit + * left clear and a clear bit reads back as null. Image metadata reports optional fields through + * flags of its own instead, and reading honours them the same way. + * + * Every offset and width here comes from the generated accessors, so this code names fields. + */ +internal object StyleMarshal { + val IMAGE_INFO_SIZEOF: Int = MlnStyleImageInfo.SIZEOF + val TRANSITION_OPTIONS_SIZEOF: Int = MlnStyleTransitionOptions.SIZEOF + val IMAGE_STRETCH_SIZEOF: Int = MlnImageStretch.SIZEOF + + /** + * Writes an image metadata header alone, for a buffer native fills. + * + * An output descriptor still states its size: native reads it to decide which fields it may + * write, and a zeroed block would ask for a zero-sized descriptor. + */ + fun writeImageInfoHeader(base: HeapPointer) { + MlnStyleImageInfo.setSize(base, MlnStyleImageInfo.SIZEOF) + } + + /** Reads the image metadata at [base], producing null for every field whose flag is clear. */ + fun readImageInfo(base: HeapPointer): StyleImageInfo = + StyleImageInfo( + width = MlnStyleImageInfo.width(base), + height = MlnStyleImageInfo.height(base), + stride = MlnStyleImageInfo.stride(base), + byteLength = size(MlnStyleImageInfo.byteLength(base)), + pixelRatio = MlnStyleImageInfo.pixelRatio(base), + sdf = MlnStyleImageInfo.sdf(base), + stretchXCount = size(MlnStyleImageInfo.stretchXCount(base)), + stretchYCount = size(MlnStyleImageInfo.stretchYCount(base)), + content = + if (MlnStyleImageInfo.hasContent(base)) { + readImageContent(base + MlnStyleImageInfo.OFFSET_CONTENT) + } else { + null + }, + textFitWidth = + if (MlnStyleImageInfo.hasTextFitWidth(base)) { + StyleImageTextFit.fromNative(MlnStyleImageInfo.textFitWidth(base)) + } else { + null + }, + textFitHeight = + if (MlnStyleImageInfo.hasTextFitHeight(base)) { + StyleImageTextFit.fromNative(MlnStyleImageInfo.textFitHeight(base)) + } else { + null + }, + ) + + /** + * Bytes [options] needs, including the descriptor and either stretch array. + * + * The stretch arrays cross as pointers over memory native reads during the call, so they are + * placed beside the descriptor: one acquisition and one release however the options are shaped. + * Measuring first is what lets all three live in one block. + */ + fun measureImageOptions(options: StyleImageOptions): Int { + val stretchX = measureStretches(options.stretchX) + val stretchY = measureStretches(options.stretchY) + // Measured and added through the shared arena helpers, so the padding a block leaves behind and + // a total that would wrap a 32-bit count are accounted for the one way every descriptor here + // accounts for them. + return JsonMarshal.plus( + JsonMarshal.plus(JsonMarshal.measureBlock(MlnStyleImageOptions.SIZEOF), stretchX), + stretchY, + ) + .toInt() + } + + /** Writes [options] into [arena] and returns the descriptor's address. */ + fun writeImageOptions(arena: HeapArena, options: StyleImageOptions): HeapPointer { + val base = JsonMarshal.allocateBlock(arena, MlnStyleImageOptions.SIZEOF) + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnStyleImageOptions.setSize(base, MlnStyleImageOptions.SIZEOF) + var fields = 0 + options.pixelRatio?.let { + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_PIXEL_RATIO + MlnStyleImageOptions.setPixelRatio(base, it) + } + options.sdf?.let { + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_SDF + MlnStyleImageOptions.setSdf(base, it) + } + options.stretchX?.let { + // An empty list is still a present value: it says the image stretches nowhere horizontally, + // which is not what leaving the bit clear means. + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_STRETCH_X + MlnStyleImageOptions.setStretchX(base, writeStretches(arena, it)) + MlnStyleImageOptions.setStretchXCount(base, it.size) + } + options.stretchY?.let { + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_STRETCH_Y + MlnStyleImageOptions.setStretchY(base, writeStretches(arena, it)) + MlnStyleImageOptions.setStretchYCount(base, it.size) + } + options.content?.let { + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_CONTENT + writeImageContent(base + MlnStyleImageOptions.OFFSET_CONTENT, it) + } + options.textFitWidth?.let { + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_TEXT_FIT_WIDTH + MlnStyleImageOptions.setTextFitWidth(base, it.nativeValue) + } + options.textFitHeight?.let { + fields = fields or MlnStyleImageOptionField.MLN_STYLE_IMAGE_OPTION_TEXT_FIT_HEIGHT + MlnStyleImageOptions.setTextFitHeight(base, it.nativeValue) + } + MlnStyleImageOptions.setFields(base, fields) + return base + } + + private fun measureStretches(stretches: List?): Long = + if (stretches == null) 0L else JsonMarshal.measureArray(MlnImageStretch.SIZEOF, stretches.size) + + private fun writeStretches(arena: HeapArena, stretches: List): HeapPointer { + val block = JsonMarshal.allocateArray(arena, MlnImageStretch.SIZEOF, stretches.size) + stretches.forEachIndexed { index, stretch -> + val entry = block + index * MlnImageStretch.SIZEOF + MlnImageStretch.setFrom(entry, stretch.from) + MlnImageStretch.setTo(entry, stretch.to) + } + return block + } + + /** + * Reads one stretchable interval out of an array native filled. + * + * These arrive as a bare array rather than inside a descriptor, so the caller positions the + * element with [IMAGE_STRETCH_SIZEOF] and this names what the eight bytes hold. + */ + fun readImageStretch(base: HeapPointer): ImageStretch = + ImageStretch(MlnImageStretch.from(base), MlnImageStretch.to(base)) + + /** Writes a transition header alone, for a buffer native fills. */ + fun writeTransitionOptionsHeader(base: HeapPointer) { + MlnStyleTransitionOptions.setSize(base, MlnStyleTransitionOptions.SIZEOF) + } + + /** Writes [options] at [base], setting a field's bit only where the value is present. */ + fun writeTransitionOptions(base: HeapPointer, options: StyleTransitionOptions) { + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against so native can tell which fields it may read. + MlnStyleTransitionOptions.setSize(base, MlnStyleTransitionOptions.SIZEOF) + var fields = 0 + options.durationMs?.let { + fields = fields or MlnStyleTransitionOptionField.MLN_STYLE_TRANSITION_OPTION_DURATION + MlnStyleTransitionOptions.setDurationMs(base, it) + } + options.delayMs?.let { + fields = fields or MlnStyleTransitionOptionField.MLN_STYLE_TRANSITION_OPTION_DELAY + MlnStyleTransitionOptions.setDelayMs(base, it) + } + options.enablePlacementTransitions?.let { + // MapLibre Native always holds a value for the cross-fade, so this bit carries the one + // distinction it cannot: a caller that omitted the field against one that cleared it. + fields = + fields or + MlnStyleTransitionOptionField.MLN_STYLE_TRANSITION_OPTION_ENABLE_PLACEMENT_TRANSITIONS + MlnStyleTransitionOptions.setEnablePlacementTransitions(base, it) + } + MlnStyleTransitionOptions.setFields(base, fields) + } + + /** Reads the transition options at [base], producing null for every field whose bit is clear. */ + fun readTransitionOptions(base: HeapPointer): StyleTransitionOptions { + val fields = MlnStyleTransitionOptions.fields(base) + fun has(bit: Int) = (fields and bit) != 0 + return StyleTransitionOptions().also { + if (has(MlnStyleTransitionOptionField.MLN_STYLE_TRANSITION_OPTION_DURATION)) { + it.durationMs = MlnStyleTransitionOptions.durationMs(base) + } + if (has(MlnStyleTransitionOptionField.MLN_STYLE_TRANSITION_OPTION_DELAY)) { + it.delayMs = MlnStyleTransitionOptions.delayMs(base) + } + if ( + has(MlnStyleTransitionOptionField.MLN_STYLE_TRANSITION_OPTION_ENABLE_PLACEMENT_TRANSITIONS) + ) { + it.enablePlacementTransitions = MlnStyleTransitionOptions.enablePlacementTransitions(base) + } + } + } + + /** + * Places [image] and its pixels in one scratch block, and calls [body] with the descriptor. + * + * The descriptor points at the pixels rather than carrying them, so both live in one block: one + * acquisition and one release whatever the image measures. The pixels are read out of the Kotlin + * image once, because that accessor hands back a fresh copy of the whole image on every read. + */ + fun withImage(image: PremultipliedRgba8Image, body: (HeapPointer) -> T): T { + val pixels = image.pixels + // Long arithmetic, because a sum that wrapped a 32-bit count would allocate a small block while + // the real pixel length still reached the copy below. + val total = MlnPremultipliedRgba8Image.SIZEOF.toLong() + pixels.size + Status.requireArgument(total <= Int.MAX_VALUE.toLong()) { + "image is too large to place in the module's heap" + } + return Heap.withScratch(total.toInt()) { base -> + val storage = base + MlnPremultipliedRgba8Image.SIZEOF + Heap.storeBytes(storage, pixels) + MlnPremultipliedRgba8Image.setSize(base, MlnPremultipliedRgba8Image.SIZEOF) + MlnPremultipliedRgba8Image.setWidth(base, image.width) + MlnPremultipliedRgba8Image.setHeight(base, image.height) + MlnPremultipliedRgba8Image.setStride(base, image.stride) + MlnPremultipliedRgba8Image.setPixels(base, storage) + MlnPremultipliedRgba8Image.setByteLength(base, pixels.size) + body(base) + } + } + + /** Writes a content box, which carries no field mask of its own. */ + private fun writeImageContent(base: HeapPointer, content: ImageContent) { + MlnImageContent.setLeft(base, content.left) + MlnImageContent.setTop(base, content.top) + MlnImageContent.setRight(base, content.right) + MlnImageContent.setBottom(base, content.bottom) + } + + private fun readImageContent(base: HeapPointer): ImageContent = + ImageContent( + MlnImageContent.left(base), + MlnImageContent.top(base), + MlnImageContent.right(base), + MlnImageContent.bottom(base), + ) + + /** + * Widens a `size_t`, which is 32 bits on this target and unsigned. + * + * The generated accessor reads it as a signed Int, so a length past two gigabytes would arrive + * negative and be reported as a negative count. + */ + private fun size(value: Int): Long = value.toUInt().toLong() +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/map/MapHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/map/MapHandle.kt new file mode 100644 index 000000000..20fa2764d --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/map/MapHandle.kt @@ -0,0 +1,2399 @@ +package org.maplibre.nativeffi.map + +import org.maplibre.nativeffi.camera.AnimationOptions +import org.maplibre.nativeffi.camera.BoundOptions +import org.maplibre.nativeffi.camera.CameraFitOptions +import org.maplibre.nativeffi.camera.CameraOptions +import org.maplibre.nativeffi.camera.FreeCameraOptions +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.geo.CanonicalTileId +import org.maplibre.nativeffi.geo.GeoJson +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.LatLngBounds +import org.maplibre.nativeffi.geo.Quaternion +import org.maplibre.nativeffi.geo.ScreenPoint +import org.maplibre.nativeffi.geo.Vec3 +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore +import org.maplibre.nativeffi.internal.lifecycle.NativeMap +import org.maplibre.nativeffi.internal.lifecycle.NativeMapProjection +import org.maplibre.nativeffi.internal.lifecycle.NativeRenderSession +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.CameraMarshal +import org.maplibre.nativeffi.internal.wasm.CustomGeometryBridge +import org.maplibre.nativeffi.internal.wasm.GeoJsonMarshal +import org.maplibre.nativeffi.internal.wasm.GeometryMarshal +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.JsonMarshal +import org.maplibre.nativeffi.internal.wasm.MapOptionsMarshal +import org.maplibre.nativeffi.internal.wasm.RenderMarshal +import org.maplibre.nativeffi.internal.wasm.SourceMarshal +import org.maplibre.nativeffi.internal.wasm.StyleMarshal +import org.maplibre.nativeffi.internal.wasm.generated.MlnAnimationOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnAnimationOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnCameraFitOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnCameraFitOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnCanonicalTileId +import org.maplibre.nativeffi.internal.wasm.generated.MlnFreeCameraOptionField +import org.maplibre.nativeffi.internal.wasm.generated.MlnFreeCameraOptions +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLng +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLngBounds +import org.maplibre.nativeffi.internal.wasm.generated.MlnOpenglOwnedTextureDescriptor +import org.maplibre.nativeffi.internal.wasm.generated.MlnProjectionMode +import org.maplibre.nativeffi.internal.wasm.generated.MlnProjectionModeField +import org.maplibre.nativeffi.internal.wasm.generated.MlnQuaternion +import org.maplibre.nativeffi.internal.wasm.generated.MlnScreenPoint +import org.maplibre.nativeffi.internal.wasm.generated.MlnStringView +import org.maplibre.nativeffi.internal.wasm.generated.MlnStyleSourceInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnUnitBezier +import org.maplibre.nativeffi.internal.wasm.generated.MlnVec3 +import org.maplibre.nativeffi.internal.wasm.generated.mln_json_snapshot_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_json_snapshot_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_color_relief_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_custom_geometry_source +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_geojson_source_data +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_geojson_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_hillshade_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_image_source_image +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_image_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_location_indicator_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_raster_dem_source_tiles +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_raster_dem_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_raster_source_tiles +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_raster_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_style_layer_json +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_style_source_json +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_vector_source_tiles +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_add_vector_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_camera_for_geometry +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_camera_for_lat_lng_bounds +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_camera_for_lat_lngs +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_cancel_transitions +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_layer_source_id +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_layer_source_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_loaded_style_json +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_style_image_premultiplied_rgba8 +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_style_image_stretches +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_style_source_attribution +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_style_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_copy_style_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_create +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_dump_debug_logs +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_ease_to +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_fly_to +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_bounds +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_camera +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_debug_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_free_camera_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_image_source_coordinates +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_layer_filter +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_layer_max_zoom +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_layer_min_zoom +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_layer_property +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_layer_visibility +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_projection_mode +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_rendering_stats_view_enabled +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_size +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_image_info +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_layer_json +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_layer_type +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_light_property +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_source_info +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_source_tile_urls +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_source_type +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_style_transition_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_tile_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_viewport_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_invalidate_custom_geometry_source_region +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_invalidate_custom_geometry_source_tile +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_is_fully_loaded +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_is_gesture_in_progress +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_jump_to +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_lat_lng_bounds_for_camera +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_lat_lng_bounds_for_camera_unwrapped +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_lat_lng_for_pixel +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_lat_lngs_for_pixels +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_list_style_layer_ids +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_list_style_source_ids +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_move_by +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_move_by_animated +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_move_style_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_pitch_by +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_pitch_by_animated +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_pixel_for_lat_lng +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_pixels_for_lat_lngs +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_create +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_remove_style_image +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_remove_style_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_remove_style_source +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_request_repaint +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_request_still_image +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_rotate_by +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_rotate_by_animated +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_scale_by +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_scale_by_animated +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_bounds +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_custom_geometry_source_tile_data +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_debug_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_free_camera_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_geojson_source_data +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_geojson_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_gesture_in_progress +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_image_source_coordinates +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_image_source_image +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_image_source_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_filter +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_max_zoom +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_min_zoom +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_property +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_source_id +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_source_layer +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_layer_visibility +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_location_indicator_accuracy_radius +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_location_indicator_bearing +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_location_indicator_image_name +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_location_indicator_location +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_projection_mode +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_rendering_stats_view_enabled +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_style_image +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_style_json +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_style_light_json +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_style_light_property +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_style_transition_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_style_url +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_tile_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_set_viewport_options +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_style_image_exists +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_style_layer_exists +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_style_source_exists +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_borrowed_texture_attach +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_owned_texture_attach +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_surface_attach +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_id_list_count +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_id_list_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_id_list_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_string_list_count +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_string_list_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_string_list_get +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.render.MetalBorrowedTextureDescriptor +import org.maplibre.nativeffi.render.MetalOwnedTextureDescriptor +import org.maplibre.nativeffi.render.MetalSurfaceDescriptor +import org.maplibre.nativeffi.render.OpenGLBorrowedTextureDescriptor +import org.maplibre.nativeffi.render.OpenGLContextDescriptor +import org.maplibre.nativeffi.render.OpenGLOwnedTextureDescriptor +import org.maplibre.nativeffi.render.OpenGLSurfaceDescriptor +import org.maplibre.nativeffi.render.PremultipliedRgba8Image +import org.maplibre.nativeffi.render.RenderSessionHandle +import org.maplibre.nativeffi.render.VulkanBorrowedTextureDescriptor +import org.maplibre.nativeffi.render.VulkanOwnedTextureDescriptor +import org.maplibre.nativeffi.render.VulkanSurfaceDescriptor +import org.maplibre.nativeffi.render.WebglContext +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.style.CustomGeometrySourceOptions +import org.maplibre.nativeffi.style.GeoJsonSourceOptions +import org.maplibre.nativeffi.style.ImageStretch +import org.maplibre.nativeffi.style.LocationIndicatorImageKind +import org.maplibre.nativeffi.style.SourceInfo +import org.maplibre.nativeffi.style.SourceType +import org.maplibre.nativeffi.style.StyleImage +import org.maplibre.nativeffi.style.StyleImageInfo +import org.maplibre.nativeffi.style.StyleImageOptions +import org.maplibre.nativeffi.style.StyleLayerVisibility +import org.maplibre.nativeffi.style.StyleTransitionOptions +import org.maplibre.nativeffi.style.TileSourceOptions + +/** Bytes one C API handle occupies. Handles are 64-bit whatever a pointer is on this target. */ +private const val HANDLE_BYTES = 8 + +/** Bytes a `size_t`, a `uint32_t`, a pointer, a `bool`, and a `double` occupy on wasm32. */ +private const val SIZE_BYTES = 4 +private const val POINTER_BYTES = 4 +private const val BOOL_BYTES = 1 +private const val DOUBLE_BYTES = 8 + +/** Coordinates an image source carries, which the C API fixes at the corners of a quad. */ +private const val IMAGE_SOURCE_COORDINATE_COUNT = 4 + +/** The terminator a C string ends at, which is why one may not appear inside the text. */ +private const val NUL = '\u0000' + +/** + * An owned map, on the thread the module gave this binding. + * + * That thread created the runtime this map belongs to, which is what makes it the map's owner + * thread as far as the C API is concerned, so every call here is an ordinary synchronous call as on + * every other platform. + * + * The browser build compiles one render backend, OpenGL against WebGL, and every render target that + * backend has: a native surface, which here is the canvas the context is bound to, and both the + * owned and the borrowed texture target. See the Metal and Vulkan members below for what compiling + * one backend leaves unreachable. + */ +public actual class MapHandle +private constructor(private val runtime: RuntimeHandle, private val handle: NativeMap) : + AutoCloseable { + private val runtimeRetention = runtime.retainChild("MapHandle") + private val core = HandleStateCore("MapHandle", handle.raw, runtime) + + /** + * The tile callback registration behind each custom geometry source this map holds, by source id. + * + * A registration outlives the call that made it, because native keeps asking for tiles for as + * long as the source is in the style. What ends it is the source ending: removal, a new style + * that does not carry it, or this map closing. + */ + private val customGeometrySources = mutableMapOf() + + private inline fun live(body: () -> T): T { + core.requireLive() + return body() + } + + public actual val isClosed: Boolean + get() = core.isReleased() + + public actual fun runtime(): RuntimeHandle = runtime + + public actual fun setStyleUrl(url: String) { + // One of the two calls here that takes a null-terminated C string rather than a string view, so + // an embedded NUL would truncate the URL instead of being carried as a byte. + requireValidCString(url, "url") + live { + Heap.withScratch(Heap.utf8Size(url)) { text -> + Heap.storeUtf8(text, url) + Status.check(mln_map_set_style_url(handle.raw, text.address)) + } + } + } + + public actual fun setStyleJson(json: String) { + requireValidCString(json, "json") + live { + Heap.withScratch(Heap.utf8Size(json)) { text -> + Heap.storeUtf8(text, json) + Status.check(mln_map_set_style_json(handle.raw, text.address)) + } + // The style this parsed replaces the one the sources were added to, so none of them exists + // any more. A style set by URL loads later instead, and its registrations are released when + // the load is reported; see `RuntimeHandle.pollEvent`. + clearCustomGeometrySources() + } + } + + public actual fun loadedStyleJson(): String = copyMapText(::mln_map_copy_loaded_style_json) + + public actual fun styleUrl(): String = copyMapText(::mln_map_copy_style_url) + + public actual fun addStyleSourceJson(sourceId: String, sourceJson: JsonValue) { + live { callWithIdAndJson(::mln_map_add_style_source_json, sourceId, sourceJson) } + } + + public actual fun removeStyleSource(sourceId: String): Boolean { + val removed = flagForId(::mln_map_remove_style_source, sourceId) + // Only a source native really removed, because a refused removal leaves the source in the style + // and it goes on asking for tiles. + if (removed) customGeometrySources.remove(sourceId)?.close() + return removed + } + + public actual fun styleSourceExists(sourceId: String): Boolean = + flagForId(::mln_map_style_source_exists, sourceId) + + public actual fun styleSourceType(sourceId: String): SourceType? = live { + withArena(bytes(stringViewBytes(sourceId), blockBytes(SIZE_BYTES), blockBytes(BOOL_BYTES))) { + arena -> + val view = writeStringView(arena, sourceId) + val type = allocate(arena, SIZE_BYTES) + val found = allocate(arena, BOOL_BYTES) + Status.check( + mln_map_get_style_source_type(handle.raw, view.address, type.address, found.address) + ) + if (isSet(found)) SourceMarshal.readSourceType(type) else null + } + } + + public actual fun styleSourceInfo(sourceId: String): SourceInfo? = live { + withArena( + bytes( + stringViewBytes(sourceId), + blockBytes(SourceMarshal.SOURCE_INFO_SIZEOF), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, sourceId) + val info = allocate(arena, SourceMarshal.SOURCE_INFO_SIZEOF) + val found = allocate(arena, BOOL_BYTES) + // An output descriptor states its own size too: native reads it to decide which fields it may + // write, and a zeroed block would ask for a zero-sized descriptor. + SourceMarshal.writeSourceInfoHeader(info) + Status.check( + mln_map_get_style_source_info(handle.raw, view.address, info.address, found.address) + ) + if (!isSet(found)) { + return@withArena null + } + // The descriptor carries lengths and counts rather than the strings themselves, so the three + // copies below run while it is still alive and are folded into the value it returns. + SourceMarshal.readSourceInfo( + info, + copyStyleSourceAttribution(sourceId, info), + copyStyleSourceUrl(sourceId, info), + styleSourceTileUrls(sourceId, info), + ) + } + } + + /** Copies the attribution the metadata at [info] reports a length for. */ + private fun copyStyleSourceAttribution(sourceId: String, info: HeapPointer): String? { + // A source with no attribution reports no size, which is distinct from reporting an empty one. + if (!SourceMarshal.sourceInfoHasAttribution(info)) return null + val capacity = + readCount(MlnStyleSourceInfo.attributionSize(info), "style source attribution size") + if (capacity == 0) return "" + return copyStyleSourceText( + ::mln_map_copy_style_source_attribution, + sourceId, + capacity, + "style source attribution size", + ) + } + + /** Copies the URL the metadata at [info] reports a length for. */ + private fun copyStyleSourceUrl(sourceId: String, info: HeapPointer): String? { + if (!SourceMarshal.sourceInfoHasUrl(info)) return null + val capacity = readCount(MlnStyleSourceInfo.urlSize(info), "style source URL size") + if (capacity == 0) return "" + return copyStyleSourceText( + ::mln_map_copy_style_source_url, + sourceId, + capacity, + "style source URL size", + ) + } + + /** + * Copies one of the texts a source metadata length sizes. + * + * Null when the source went missing between the metadata call and this one, which is a race the + * caller loses rather than an error. + */ + private fun copyStyleSourceText( + entry: (Long, Int, Int, Int, Int, Int) -> Int, + sourceId: String, + capacity: Int, + subject: String, + ): String? = live { + withArena( + bytes( + stringViewBytes(sourceId), + blockBytes(capacity), + blockBytes(SIZE_BYTES), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, sourceId) + val text = allocate(arena, capacity) + val copied = allocate(arena, SIZE_BYTES) + val found = allocate(arena, BOOL_BYTES) + Status.check( + entry(handle.raw, view.address, text.address, capacity, copied.address, found.address) + ) + if (!isSet(found)) { + null + } else { + Heap.loadBytes(text, readCount(Heap.loadInt(copied), subject)).decodeToString() + } + } + } + + /** Copies the inline TileJSON tile URLs of the source the metadata at [info] describes. */ + private fun styleSourceTileUrls(sourceId: String, info: HeapPointer): List? { + if (!SourceMarshal.sourceInfoHasTileJson(info)) return null + val list = live { + withArena( + bytes(stringViewBytes(sourceId), blockBytes(HANDLE_BYTES), blockBytes(BOOL_BYTES)) + ) { arena -> + val view = writeStringView(arena, sourceId) + // Native refuses an out-parameter that is not the null handle, which the zeroed arena + // already satisfies. + val out = allocate(arena, HANDLE_BYTES) + val found = allocate(arena, BOOL_BYTES) + Status.check( + mln_map_get_style_source_tile_urls(handle.raw, view.address, out.address, found.address) + ) + if (isSet(found)) Heap.loadLong(out) else 0L + } + } + return readStyleStringList(list) + } + + public actual fun styleSourceIds(): List = listStyleIds(::mln_map_list_style_source_ids) + + public actual fun addGeoJsonSourceUrl( + sourceId: String, + url: String, + options: GeoJsonSourceOptions?, + ) { + live { + withArena( + bytes(stringViewBytes(sourceId), stringViewBytes(url), geoJsonSourceOptionsBytes(options)) + ) { arena -> + val sourceView = writeStringView(arena, sourceId) + val urlView = writeStringView(arena, url) + val descriptor = writeGeoJsonSourceOptions(arena, options) + Status.check( + mln_map_add_geojson_source_url( + handle.raw, + sourceView.address, + urlView.address, + descriptor.address, + ) + ) + } + } + } + + public actual fun addGeoJsonSourceData( + sourceId: String, + data: GeoJson, + options: GeoJsonSourceOptions?, + ) { + live { + withArena( + bytes( + stringViewBytes(sourceId), + GeoJsonMarshal.measure(data).toLong(), + geoJsonSourceOptionsBytes(options), + ) + ) { arena -> + val sourceView = writeStringView(arena, sourceId) + val root = GeoJsonMarshal.write(arena, data) + val descriptor = writeGeoJsonSourceOptions(arena, options) + Status.check( + mln_map_add_geojson_source_data( + handle.raw, + sourceView.address, + root.address, + descriptor.address, + ) + ) + } + } + } + + public actual fun setGeoJsonSourceUrl(sourceId: String, url: String) { + live { callWithTwoIds(::mln_map_set_geojson_source_url, sourceId, url) } + } + + public actual fun setGeoJsonSourceData(sourceId: String, data: GeoJson) { + live { + withArena(bytes(stringViewBytes(sourceId), GeoJsonMarshal.measure(data).toLong())) { arena -> + val view = writeStringView(arena, sourceId) + val root = GeoJsonMarshal.write(arena, data) + Status.check(mln_map_set_geojson_source_data(handle.raw, view.address, root.address)) + } + } + } + + /** + * Adds a custom geometry source whose tiles the host supplies. + * + * MapLibre asks for a tile on the worker the source's tile loader runs on, which is not the + * thread this binding runs on, so the module queues the request and the runtime delivers it from + * [org.maplibre.nativeffi.runtime.RuntimeHandle.pump]. The callback may answer from inside itself + * with [setCustomGeometrySourceTileData], exactly as it may on every other platform. + * + * The callback registration lives as long as the source does. It is released when the source is + * removed with [removeStyleSource], when a new style drops it, and when this map is closed, and a + * request that arrives after any of those is dropped rather than delivered. + */ + public actual fun addCustomGeometrySource( + sourceId: String, + options: CustomGeometrySourceOptions, + ) { + live { + // Installed before native is told about it, because the module names the callback by the + // pointer this places: a source added first could ask for a tile that had nowhere to go. + val bridge = CustomGeometryBridge.install(options.callback) + var added = false + try { + withArena( + bytes( + stringViewBytes(sourceId), + blockBytes(SourceMarshal.CUSTOM_GEOMETRY_SOURCE_OPTIONS_SIZEOF), + ) + ) { arena -> + val view = writeStringView(arena, sourceId) + val descriptor = allocate(arena, SourceMarshal.CUSTOM_GEOMETRY_SOURCE_OPTIONS_SIZEOF) + SourceMarshal.writeCustomGeometrySourceOptions( + descriptor, + options, + CustomGeometryBridge.fetchCallback(), + CustomGeometryBridge.cancelCallback(), + bridge.userData, + ) + Status.check( + mln_map_add_custom_geometry_source(handle.raw, view.address, descriptor.address) + ) + } + added = true + } finally { + // A refused source has no callbacks to serve, so the registration goes back rather than + // standing for a source that does not exist. + if (!added) bridge.close() + } + // Replacing an id native accepted means the previous source is gone, so its registration is + // released here rather than left to be found by a request it can no longer answer. + customGeometrySources.put(sourceId, bridge)?.close() + } + } + + public actual fun setCustomGeometrySourceTileData( + sourceId: String, + tileId: CanonicalTileId, + data: GeoJson, + ) { + live { + withArena( + bytes( + stringViewBytes(sourceId), + blockBytes(MlnCanonicalTileId.SIZEOF), + GeoJsonMarshal.measure(data).toLong(), + ) + ) { arena -> + val view = writeStringView(arena, sourceId) + val tile = writeCanonicalTileId(arena, tileId) + val root = GeoJsonMarshal.write(arena, data) + Status.check( + mln_map_set_custom_geometry_source_tile_data( + handle.raw, + view.address, + tile.address, + root.address, + ) + ) + } + } + } + + public actual fun invalidateCustomGeometrySourceTile(sourceId: String, tileId: CanonicalTileId) { + live { + withArena(bytes(stringViewBytes(sourceId), blockBytes(MlnCanonicalTileId.SIZEOF))) { arena -> + val view = writeStringView(arena, sourceId) + val tile = writeCanonicalTileId(arena, tileId) + Status.check( + mln_map_invalidate_custom_geometry_source_tile(handle.raw, view.address, tile.address) + ) + } + } + } + + public actual fun invalidateCustomGeometrySourceRegion(sourceId: String, bounds: LatLngBounds) { + live { + withArena(bytes(stringViewBytes(sourceId), blockBytes(MlnLatLngBounds.SIZEOF))) { arena -> + val view = writeStringView(arena, sourceId) + val region = allocate(arena, MlnLatLngBounds.SIZEOF) + MapOptionsMarshal.writeLatLngBounds(region, bounds) + Status.check( + mln_map_invalidate_custom_geometry_source_region(handle.raw, view.address, region.address) + ) + } + } + } + + public actual fun addVectorSourceUrl(sourceId: String, url: String, options: TileSourceOptions?) { + addTileSourceUrl(::mln_map_add_vector_source_url, sourceId, url, options) + } + + public actual fun addVectorSourceTiles( + sourceId: String, + tiles: List, + options: TileSourceOptions?, + ) { + addTileSourceTiles(::mln_map_add_vector_source_tiles, sourceId, tiles, options) + } + + public actual fun addRasterSourceUrl(sourceId: String, url: String, options: TileSourceOptions?) { + addTileSourceUrl(::mln_map_add_raster_source_url, sourceId, url, options) + } + + public actual fun addRasterSourceTiles( + sourceId: String, + tiles: List, + options: TileSourceOptions?, + ) { + addTileSourceTiles(::mln_map_add_raster_source_tiles, sourceId, tiles, options) + } + + public actual fun addRasterDemSourceUrl( + sourceId: String, + url: String, + options: TileSourceOptions?, + ) { + addTileSourceUrl(::mln_map_add_raster_dem_source_url, sourceId, url, options) + } + + public actual fun addRasterDemSourceTiles( + sourceId: String, + tiles: List, + options: TileSourceOptions?, + ) { + addTileSourceTiles(::mln_map_add_raster_dem_source_tiles, sourceId, tiles, options) + } + + public actual fun setStyleImage( + imageId: String, + image: PremultipliedRgba8Image, + options: StyleImageOptions, + ) { + live { + // Two blocks rather than one: the image marshaller owns the descriptor-plus-pixels pairing + // and + // takes its own scratch, and folding that into this arena would mean measuring the pixels + // here as well as there. + StyleMarshal.withImage(image) { imageDescriptor -> + withArena( + bytes(stringViewBytes(imageId), StyleMarshal.measureImageOptions(options).toLong()) + ) { arena -> + val view = writeStringView(arena, imageId) + val optionsDescriptor = StyleMarshal.writeImageOptions(arena, options) + Status.check( + mln_map_set_style_image( + handle.raw, + view.address, + imageDescriptor.address, + optionsDescriptor.address, + ) + ) + } + } + } + } + + public actual fun removeStyleImage(imageId: String): Boolean = + flagForId(::mln_map_remove_style_image, imageId) + + public actual fun styleImageExists(imageId: String): Boolean = + flagForId(::mln_map_style_image_exists, imageId) + + public actual fun styleImageInfo(imageId: String): StyleImageInfo? = live { + withArena( + bytes( + stringViewBytes(imageId), + blockBytes(StyleMarshal.IMAGE_INFO_SIZEOF), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, imageId) + val info = allocate(arena, StyleMarshal.IMAGE_INFO_SIZEOF) + val found = allocate(arena, BOOL_BYTES) + StyleMarshal.writeImageInfoHeader(info) + Status.check( + mln_map_get_style_image_info(handle.raw, view.address, info.address, found.address) + ) + if (isSet(found)) StyleMarshal.readImageInfo(info) else null + } + } + + public actual fun styleImageStretches( + imageId: String + ): Pair, List>? { + // A null destination with zero capacity is the C API's size probe: it fills both counts and + // succeeds without copying, which is how the arrays below are sized. + val counts = + live { + withArena( + bytes( + stringViewBytes(imageId), + blockBytes(SIZE_BYTES), + blockBytes(SIZE_BYTES), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, imageId) + val xCount = allocate(arena, SIZE_BYTES) + val yCount = allocate(arena, SIZE_BYTES) + val found = allocate(arena, BOOL_BYTES) + copyStyleImageStretches(view, HeapPointer(0), 0, xCount, HeapPointer(0), 0, yCount, found) + if (!isSet(found)) { + null + } else { + readCount(Heap.loadInt(xCount), "style image stretch x count") to + readCount(Heap.loadInt(yCount), "style image stretch y count") + } + } + } ?: return null + + val (xCapacity, yCapacity) = counts + val xBytes = Heap.sizeOf(StyleMarshal.IMAGE_STRETCH_SIZEOF, xCapacity) + val yBytes = Heap.sizeOf(StyleMarshal.IMAGE_STRETCH_SIZEOF, yCapacity) + return live { + withArena( + bytes( + stringViewBytes(imageId), + blockBytes(xBytes), + blockBytes(yBytes), + blockBytes(SIZE_BYTES), + blockBytes(SIZE_BYTES), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, imageId) + val stretchX = allocate(arena, xBytes) + val stretchY = allocate(arena, yBytes) + val xCount = allocate(arena, SIZE_BYTES) + val yCount = allocate(arena, SIZE_BYTES) + val found = allocate(arena, BOOL_BYTES) + copyStyleImageStretches( + view, + stretchX, + xCapacity, + xCount, + stretchY, + yCapacity, + yCount, + found, + ) + // The image was there a moment ago, so it going missing between the two calls is a race the + // caller loses rather than an error; report it the way the first call would have. + if (!isSet(found)) { + null + } else { + readStretches(stretchX, readCount(Heap.loadInt(xCount), "style image stretch x count")) to + readStretches(stretchY, readCount(Heap.loadInt(yCount), "style image stretch y count")) + } + } + } + } + + public actual fun copyStyleImagePremultipliedRgba8(imageId: String): StyleImage? { + // The metadata says how large the pixel buffer has to be, and reports absence, so it is read + // first rather than probed for a second time here. + val info = styleImageInfo(imageId) ?: return null + val capacity = readLength(info.byteLength, "style image byte length") + return live { + withArena( + bytes( + stringViewBytes(imageId), + blockBytes(capacity), + blockBytes(SIZE_BYTES), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, imageId) + val pixels = allocate(arena, capacity) + val copied = allocate(arena, SIZE_BYTES) + val found = allocate(arena, BOOL_BYTES) + Status.check( + mln_map_copy_style_image_premultiplied_rgba8( + handle.raw, + view.address, + pixels.address, + capacity, + copied.address, + found.address, + ) + ) + if (!isSet(found)) { + null + } else { + StyleImage( + PremultipliedRgba8Image( + info.width, + info.height, + info.stride, + Heap.loadBytes( + pixels, + readCount(Heap.loadInt(copied), "style image copied byte length"), + ), + ), + info.pixelRatio, + info.sdf, + ) + } + } + } + } + + public actual fun addImageSourceUrl(sourceId: String, coordinates: List, url: String) { + live { + val coordinateBytes = Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size) + withArena( + bytes(stringViewBytes(sourceId), blockBytes(coordinateBytes), stringViewBytes(url)) + ) { arena -> + val sourceView = writeStringView(arena, sourceId) + val quad = writeLatLngs(arena, coordinates) + val urlView = writeStringView(arena, url) + Status.check( + mln_map_add_image_source_url( + handle.raw, + sourceView.address, + quad.address, + coordinates.size, + urlView.address, + ) + ) + } + } + } + + public actual fun addImageSourceImage( + sourceId: String, + coordinates: List, + image: PremultipliedRgba8Image, + ) { + live { + StyleMarshal.withImage(image) { imageDescriptor -> + val coordinateBytes = Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size) + withArena(bytes(stringViewBytes(sourceId), blockBytes(coordinateBytes))) { arena -> + val view = writeStringView(arena, sourceId) + val quad = writeLatLngs(arena, coordinates) + Status.check( + mln_map_add_image_source_image( + handle.raw, + view.address, + quad.address, + coordinates.size, + imageDescriptor.address, + ) + ) + } + } + } + } + + public actual fun setImageSourceUrl(sourceId: String, url: String) { + live { callWithTwoIds(::mln_map_set_image_source_url, sourceId, url) } + } + + public actual fun setImageSourceImage(sourceId: String, image: PremultipliedRgba8Image) { + live { + StyleMarshal.withImage(image) { imageDescriptor -> + withArena(stringViewBytes(sourceId)) { arena -> + val view = writeStringView(arena, sourceId) + Status.check( + mln_map_set_image_source_image(handle.raw, view.address, imageDescriptor.address) + ) + } + } + } + } + + public actual fun setImageSourceCoordinates(sourceId: String, coordinates: List) { + live { + val coordinateBytes = Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size) + withArena(bytes(stringViewBytes(sourceId), blockBytes(coordinateBytes))) { arena -> + val view = writeStringView(arena, sourceId) + val quad = writeLatLngs(arena, coordinates) + Status.check( + mln_map_set_image_source_coordinates( + handle.raw, + view.address, + quad.address, + coordinates.size, + ) + ) + } + } + } + + public actual fun imageSourceCoordinates(sourceId: String): List? = live { + // An image source's coordinates are the four corners of a quad, so the buffer is fixed rather + // than probed for. + val coordinateBytes = Heap.sizeOf(MlnLatLng.SIZEOF, IMAGE_SOURCE_COORDINATE_COUNT) + withArena( + bytes( + stringViewBytes(sourceId), + blockBytes(coordinateBytes), + blockBytes(SIZE_BYTES), + blockBytes(BOOL_BYTES), + ) + ) { arena -> + val view = writeStringView(arena, sourceId) + val quad = allocate(arena, coordinateBytes) + val count = allocate(arena, SIZE_BYTES) + val found = allocate(arena, BOOL_BYTES) + Status.check( + mln_map_get_image_source_coordinates( + handle.raw, + view.address, + quad.address, + IMAGE_SOURCE_COORDINATE_COUNT, + count.address, + found.address, + ) + ) + if (!isSet(found)) { + null + } else { + readLatLngs(quad, readCount(Heap.loadInt(count), "image source coordinate count")) + } + } + } + + public actual fun addStyleLayerJson(layerJson: JsonValue, beforeLayerId: String) { + live { + withArena(bytes(JsonMarshal.measure(layerJson).toLong(), stringViewBytes(beforeLayerId))) { + arena -> + val root = JsonMarshal.write(arena, layerJson) + val view = writeStringView(arena, beforeLayerId) + Status.check(mln_map_add_style_layer_json(handle.raw, root.address, view.address)) + } + } + } + + public actual fun addHillshadeLayer(layerId: String, sourceId: String, beforeLayerId: String) { + live { callWithThreeIds(::mln_map_add_hillshade_layer, layerId, sourceId, beforeLayerId) } + } + + public actual fun addColorReliefLayer(layerId: String, sourceId: String, beforeLayerId: String) { + live { callWithThreeIds(::mln_map_add_color_relief_layer, layerId, sourceId, beforeLayerId) } + } + + public actual fun addLocationIndicatorLayer(layerId: String, beforeLayerId: String) { + live { callWithTwoIds(::mln_map_add_location_indicator_layer, layerId, beforeLayerId) } + } + + public actual fun setLocationIndicatorLocation( + layerId: String, + coordinate: LatLng, + altitude: Double, + ) { + live { + withArena(bytes(stringViewBytes(layerId), blockBytes(MlnLatLng.SIZEOF))) { arena -> + val view = writeStringView(arena, layerId) + val position = allocate(arena, MlnLatLng.SIZEOF) + CameraMarshal.writeLatLng(position, coordinate) + Status.check( + mln_map_set_location_indicator_location( + handle.raw, + view.address, + position.address, + altitude, + ) + ) + } + } + } + + public actual fun setLocationIndicatorBearing(layerId: String, bearing: Double) { + live { setDoubleForId(::mln_map_set_location_indicator_bearing, layerId, bearing) } + } + + public actual fun setLocationIndicatorAccuracyRadius(layerId: String, radius: Double) { + live { setDoubleForId(::mln_map_set_location_indicator_accuracy_radius, layerId, radius) } + } + + public actual fun setLocationIndicatorImageName( + layerId: String, + imageKind: LocationIndicatorImageKind, + imageId: String, + ) { + live { + withArena(bytes(stringViewBytes(layerId), stringViewBytes(imageId))) { arena -> + val layerView = writeStringView(arena, layerId) + val imageView = writeStringView(arena, imageId) + Status.check( + mln_map_set_location_indicator_image_name( + handle.raw, + layerView.address, + imageKind.nativeValue, + imageView.address, + ) + ) + } + } + } + + public actual fun removeStyleLayer(layerId: String): Boolean = + flagForId(::mln_map_remove_style_layer, layerId) + + public actual fun styleLayerExists(layerId: String): Boolean = + flagForId(::mln_map_style_layer_exists, layerId) + + public actual fun styleLayerType(layerId: String): String? = live { + withArena( + bytes(stringViewBytes(layerId), blockBytes(MlnStringView.SIZEOF), blockBytes(BOOL_BYTES)) + ) { arena -> + val view = writeStringView(arena, layerId) + val type = allocate(arena, MlnStringView.SIZEOF) + val found = allocate(arena, BOOL_BYTES) + Status.check( + mln_map_get_style_layer_type(handle.raw, view.address, type.address, found.address) + ) + // Copied here rather than handed back as a view: the bytes belong to the style, which the + // next call on this map may replace. + if (isSet(found)) JsonMarshal.readText(type) else null + } + } + + public actual fun styleLayerIds(): List = listStyleIds(::mln_map_list_style_layer_ids) + + public actual fun moveStyleLayer(layerId: String, beforeLayerId: String) { + live { callWithTwoIds(::mln_map_move_style_layer, layerId, beforeLayerId) } + } + + public actual fun styleLayerJson(layerId: String): JsonValue? { + val snapshot = live { + withArena( + bytes(stringViewBytes(layerId), blockBytes(HANDLE_BYTES), blockBytes(BOOL_BYTES)) + ) { arena -> + val view = writeStringView(arena, layerId) + val out = allocate(arena, HANDLE_BYTES) + val found = allocate(arena, BOOL_BYTES) + Status.check( + mln_map_get_style_layer_json(handle.raw, view.address, out.address, found.address) + ) + if (isSet(found)) Heap.loadLong(out) else 0L + } + } + return readJsonSnapshot(snapshot) + } + + public actual fun setStyleLightJson(lightJson: JsonValue) { + live { + withArena(JsonMarshal.measure(lightJson).toLong()) { arena -> + val root = JsonMarshal.write(arena, lightJson) + Status.check(mln_map_set_style_light_json(handle.raw, root.address)) + } + } + } + + public actual fun setStyleLightProperty(propertyName: String, value: JsonValue) { + live { callWithIdAndJson(::mln_map_set_style_light_property, propertyName, value) } + } + + public actual fun styleLightProperty(propertyName: String): JsonValue? = + jsonSnapshotForId(::mln_map_get_style_light_property, propertyName) + + public actual fun setStyleTransitionOptions(options: StyleTransitionOptions) { + live { + Heap.withScratch(StyleMarshal.TRANSITION_OPTIONS_SIZEOF) { descriptor -> + StyleMarshal.writeTransitionOptions(descriptor, options) + Status.check(mln_map_set_style_transition_options(handle.raw, descriptor.address)) + } + } + } + + public actual fun styleTransitionOptions(): StyleTransitionOptions = live { + Heap.withScratch(StyleMarshal.TRANSITION_OPTIONS_SIZEOF) { out -> + StyleMarshal.writeTransitionOptionsHeader(out) + Status.check(mln_map_get_style_transition_options(handle.raw, out.address)) + StyleMarshal.readTransitionOptions(out) + } + } + + public actual fun setLayerProperty(layerId: String, propertyName: String, value: JsonValue) { + live { + withArena( + bytes( + stringViewBytes(layerId), + stringViewBytes(propertyName), + JsonMarshal.measure(value).toLong(), + ) + ) { arena -> + val layerView = writeStringView(arena, layerId) + val propertyView = writeStringView(arena, propertyName) + val root = JsonMarshal.write(arena, value) + Status.check( + mln_map_set_layer_property( + handle.raw, + layerView.address, + propertyView.address, + root.address, + ) + ) + } + } + } + + public actual fun layerProperty(layerId: String, propertyName: String): JsonValue? { + val snapshot = live { + withArena( + bytes(stringViewBytes(layerId), stringViewBytes(propertyName), blockBytes(HANDLE_BYTES)) + ) { arena -> + val layerView = writeStringView(arena, layerId) + val propertyView = writeStringView(arena, propertyName) + val out = allocate(arena, HANDLE_BYTES) + Status.check( + mln_map_get_layer_property( + handle.raw, + layerView.address, + propertyView.address, + out.address, + ) + ) + Heap.loadLong(out) + } + } + return readJsonSnapshot(snapshot) + } + + public actual fun setLayerFilter(layerId: String, filter: JsonValue) { + live { callWithIdAndJson(::mln_map_set_layer_filter, layerId, filter) } + } + + public actual fun clearLayerFilter(layerId: String) { + // The same entry point with a null filter, which is how the C API spells "no filter" rather + // than an empty expression, which would mean something else. + live { callWithIdAndJson(::mln_map_set_layer_filter, layerId, null) } + } + + public actual fun layerFilter(layerId: String): JsonValue? = + jsonSnapshotForId(::mln_map_get_layer_filter, layerId) + + public actual fun setLayerSourceLayer(layerId: String, sourceLayer: String) { + live { callWithTwoIds(::mln_map_set_layer_source_layer, layerId, sourceLayer) } + } + + public actual fun layerSourceLayer(layerId: String): String = + copyLayerText(::mln_map_copy_layer_source_layer, layerId) + + public actual fun setLayerSourceId(layerId: String, sourceId: String) { + live { callWithTwoIds(::mln_map_set_layer_source_id, layerId, sourceId) } + } + + public actual fun layerSourceId(layerId: String): String = + copyLayerText(::mln_map_copy_layer_source_id, layerId) + + public actual fun setLayerMinZoom(layerId: String, minZoom: Double) { + live { setDoubleForId(::mln_map_set_layer_min_zoom, layerId, minZoom) } + } + + public actual fun layerMinZoom(layerId: String): Double = + doubleForId(::mln_map_get_layer_min_zoom, layerId) + + public actual fun setLayerMaxZoom(layerId: String, maxZoom: Double) { + live { setDoubleForId(::mln_map_set_layer_max_zoom, layerId, maxZoom) } + } + + public actual fun layerMaxZoom(layerId: String): Double = + doubleForId(::mln_map_get_layer_max_zoom, layerId) + + public actual fun setLayerVisibility(layerId: String, visibility: StyleLayerVisibility) { + live { + withArena(stringViewBytes(layerId)) { arena -> + val view = writeStringView(arena, layerId) + Status.check(mln_map_set_layer_visibility(handle.raw, view.address, visibility.nativeValue)) + } + } + } + + public actual fun layerVisibility(layerId: String): StyleLayerVisibility = live { + withArena(bytes(stringViewBytes(layerId), blockBytes(SIZE_BYTES))) { arena -> + val view = writeStringView(arena, layerId) + val out = allocate(arena, SIZE_BYTES) + Status.check(mln_map_get_layer_visibility(handle.raw, view.address, out.address)) + StyleLayerVisibility.fromNative(Heap.loadInt(out)) + } + } + + public actual fun requestRepaint() { + live { Status.check(mln_map_request_repaint(handle.raw)) } + } + + public actual fun requestStillImage() { + live { Status.check(mln_map_request_still_image(handle.raw)) } + } + + public actual var debugOptions: Set + get() = live { + Heap.withScratch(SIZE_BYTES) { out -> + Status.check(mln_map_get_debug_options(handle.raw, out.address)) + val mask = Heap.loadInt(out) + DebugOption.entries.filterTo(mutableSetOf()) { (mask and it.nativeMask) != 0 } + } + } + set(options) { + val mask = options.fold(0) { accumulated, option -> accumulated or option.nativeMask } + live { Status.check(mln_map_set_debug_options(handle.raw, mask)) } + } + + public actual var isRenderingStatsViewEnabled: Boolean + get() = flagForMap(::mln_map_get_rendering_stats_view_enabled) + set(enabled) { + live { setFlagForMap(::mln_map_set_rendering_stats_view_enabled, enabled) } + } + + public actual val isFullyLoaded: Boolean + get() = flagForMap(::mln_map_is_fully_loaded) + + public actual fun dumpDebugLogs() { + live { Status.check(mln_map_dump_debug_logs(handle.raw)) } + } + + public actual val size: MapSize + get() = live { + // The scale factor goes first because it is the only member here that needs eight-byte + // alignment: the heap views these reads go through index by width rather than by byte, so a + // double placed at an address the allocator did not align would be read somewhere else + // entirely rather than merely slowly. + Heap.withScratch(DOUBLE_BYTES + SIZE_BYTES + SIZE_BYTES) { scaleFactor -> + val width = scaleFactor + DOUBLE_BYTES + val height = width + SIZE_BYTES + Status.check( + mln_map_get_size(handle.raw, width.address, height.address, scaleFactor.address) + ) + MapSize(Heap.loadInt(width), Heap.loadInt(height), Heap.loadDouble(scaleFactor)) + } + } + + public actual var viewportOptions: ViewportOptions + get() = live { + Heap.withScratch(MapOptionsMarshal.VIEWPORT_OPTIONS_SIZEOF) { out -> + MapOptionsMarshal.writeViewportOptionsHeader(out) + Status.check(mln_map_get_viewport_options(handle.raw, out.address)) + MapOptionsMarshal.readViewportOptions(out) + } + } + set(options) { + live { + Heap.withScratch(MapOptionsMarshal.VIEWPORT_OPTIONS_SIZEOF) { descriptor -> + MapOptionsMarshal.writeViewportOptions(descriptor, options) + Status.check(mln_map_set_viewport_options(handle.raw, descriptor.address)) + } + } + } + + public actual var tileOptions: TileOptions + get() = live { + Heap.withScratch(MapOptionsMarshal.TILE_OPTIONS_SIZEOF) { out -> + MapOptionsMarshal.writeTileOptionsHeader(out) + Status.check(mln_map_get_tile_options(handle.raw, out.address)) + MapOptionsMarshal.readTileOptions(out) + } + } + set(options) { + live { + Heap.withScratch(MapOptionsMarshal.TILE_OPTIONS_SIZEOF) { descriptor -> + MapOptionsMarshal.writeTileOptions(descriptor, options) + Status.check(mln_map_set_tile_options(handle.raw, descriptor.address)) + } + } + } + + public actual val camera: CameraOptions + get() = live { + Heap.withScratch(CameraMarshal.SIZEOF) { out -> + CameraMarshal.writeHeader(out) + Status.check(mln_map_get_camera(handle.raw, out.address)) + CameraMarshal.read(out) + } + } + + public actual fun jumpTo(camera: CameraOptions) { + live { + Heap.withScratch(CameraMarshal.SIZEOF) { descriptor -> + CameraMarshal.write(descriptor, camera) + Status.check(mln_map_jump_to(handle.raw, descriptor.address)) + } + } + } + + public actual fun easeTo(camera: CameraOptions, animation: AnimationOptions?) { + live { transitionTo(::mln_map_ease_to, camera, animation) } + } + + public actual fun flyTo(camera: CameraOptions, animation: AnimationOptions?) { + live { transitionTo(::mln_map_fly_to, camera, animation) } + } + + public actual fun moveBy(deltaX: Double, deltaY: Double) { + live { Status.check(mln_map_move_by(handle.raw, deltaX, deltaY)) } + } + + public actual fun moveByAnimated(deltaX: Double, deltaY: Double, animation: AnimationOptions?) { + live { + withArena(animationBytes(animation)) { arena -> + val descriptor = writeAnimationOptions(arena, animation) + Status.check(mln_map_move_by_animated(handle.raw, deltaX, deltaY, descriptor.address)) + } + } + } + + public actual fun scaleBy(scale: Double, anchor: ScreenPoint?) { + live { + withArena(anchorBytes(anchor)) { arena -> + val point = writeScreenPointOrNull(arena, anchor) + Status.check(mln_map_scale_by(handle.raw, scale, point.address)) + } + } + } + + public actual fun scaleByAnimated( + scale: Double, + anchor: ScreenPoint?, + animation: AnimationOptions?, + ) { + live { + withArena(bytes(anchorBytes(anchor), animationBytes(animation))) { arena -> + val point = writeScreenPointOrNull(arena, anchor) + val descriptor = writeAnimationOptions(arena, animation) + Status.check( + mln_map_scale_by_animated(handle.raw, scale, point.address, descriptor.address) + ) + } + } + } + + public actual fun rotateBy(first: ScreenPoint, second: ScreenPoint) { + live { + withArena(bytes(blockBytes(MlnScreenPoint.SIZEOF), blockBytes(MlnScreenPoint.SIZEOF))) { arena + -> + val start = writeScreenPoint(arena, first) + val end = writeScreenPoint(arena, second) + Status.check(mln_map_rotate_by(handle.raw, start.address, end.address)) + } + } + } + + public actual fun rotateByAnimated( + first: ScreenPoint, + second: ScreenPoint, + animation: AnimationOptions?, + ) { + live { + withArena( + bytes( + blockBytes(MlnScreenPoint.SIZEOF), + blockBytes(MlnScreenPoint.SIZEOF), + animationBytes(animation), + ) + ) { arena -> + val start = writeScreenPoint(arena, first) + val end = writeScreenPoint(arena, second) + val descriptor = writeAnimationOptions(arena, animation) + Status.check( + mln_map_rotate_by_animated(handle.raw, start.address, end.address, descriptor.address) + ) + } + } + } + + public actual fun pitchBy(pitch: Double) { + live { Status.check(mln_map_pitch_by(handle.raw, pitch)) } + } + + public actual fun pitchByAnimated(pitch: Double, animation: AnimationOptions?) { + live { + withArena(animationBytes(animation)) { arena -> + val descriptor = writeAnimationOptions(arena, animation) + Status.check(mln_map_pitch_by_animated(handle.raw, pitch, descriptor.address)) + } + } + } + + public actual fun cancelTransitions() { + live { Status.check(mln_map_cancel_transitions(handle.raw)) } + } + + public actual var isGestureInProgress: Boolean + get() = flagForMap(::mln_map_is_gesture_in_progress) + set(inProgress) { + live { setFlagForMap(::mln_map_set_gesture_in_progress, inProgress) } + } + + public actual fun cameraForLatLngBounds( + bounds: LatLngBounds, + fitOptions: CameraFitOptions?, + ): CameraOptions = live { + withArena( + bytes( + blockBytes(MlnLatLngBounds.SIZEOF), + fitOptionsBytes(fitOptions), + blockBytes(CameraMarshal.SIZEOF), + ) + ) { arena -> + val region = allocate(arena, MlnLatLngBounds.SIZEOF) + MapOptionsMarshal.writeLatLngBounds(region, bounds) + val fit = writeCameraFitOptions(arena, fitOptions) + val out = allocate(arena, CameraMarshal.SIZEOF) + CameraMarshal.writeHeader(out) + Status.check( + mln_map_camera_for_lat_lng_bounds(handle.raw, region.address, fit.address, out.address) + ) + CameraMarshal.read(out) + } + } + + public actual fun cameraForLatLngs( + coordinates: List, + fitOptions: CameraFitOptions?, + ): CameraOptions = live { + val coordinateBytes = Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size) + withArena( + bytes( + blockBytes(coordinateBytes), + fitOptionsBytes(fitOptions), + blockBytes(CameraMarshal.SIZEOF), + ) + ) { arena -> + val points = writeLatLngs(arena, coordinates) + val fit = writeCameraFitOptions(arena, fitOptions) + val out = allocate(arena, CameraMarshal.SIZEOF) + CameraMarshal.writeHeader(out) + Status.check( + mln_map_camera_for_lat_lngs( + handle.raw, + points.address, + coordinates.size, + fit.address, + out.address, + ) + ) + CameraMarshal.read(out) + } + } + + public actual fun cameraForGeometry( + geometry: Geometry, + fitOptions: CameraFitOptions?, + ): CameraOptions = live { + // Measured before the block is taken. A geometry tree is many nested spans, and the arena + // carves them out of one allocation rather than taking one per node. + withArena( + bytes( + GeometryMarshal.measure(geometry).toLong(), + fitOptionsBytes(fitOptions), + blockBytes(CameraMarshal.SIZEOF), + ) + ) { arena -> + val root = GeometryMarshal.write(arena, geometry) + val fit = writeCameraFitOptions(arena, fitOptions) + val out = allocate(arena, CameraMarshal.SIZEOF) + CameraMarshal.writeHeader(out) + Status.check(mln_map_camera_for_geometry(handle.raw, root.address, fit.address, out.address)) + CameraMarshal.read(out) + } + } + + public actual fun latLngBoundsForCamera(camera: CameraOptions): LatLngBounds = + boundsForCamera(::mln_map_lat_lng_bounds_for_camera, camera) + + public actual fun latLngBoundsForCameraUnwrapped(camera: CameraOptions): LatLngBounds = + boundsForCamera(::mln_map_lat_lng_bounds_for_camera_unwrapped, camera) + + public actual var bounds: BoundOptions + get() = live { + Heap.withScratch(MapOptionsMarshal.BOUND_OPTIONS_SIZEOF) { out -> + MapOptionsMarshal.writeBoundOptionsHeader(out) + Status.check(mln_map_get_bounds(handle.raw, out.address)) + MapOptionsMarshal.readBoundOptions(out) + } + } + set(options) { + live { + Heap.withScratch(MapOptionsMarshal.BOUND_OPTIONS_SIZEOF) { descriptor -> + MapOptionsMarshal.writeBoundOptions(descriptor, options) + Status.check(mln_map_set_bounds(handle.raw, descriptor.address)) + } + } + } + + public actual var freeCameraOptions: FreeCameraOptions + get() = live { + Heap.withScratch(MlnFreeCameraOptions.SIZEOF) { out -> + MlnFreeCameraOptions.setSize(out, MlnFreeCameraOptions.SIZEOF) + Status.check(mln_map_get_free_camera_options(handle.raw, out.address)) + readFreeCameraOptions(out) + } + } + set(options) { + live { + Heap.withScratch(MlnFreeCameraOptions.SIZEOF) { descriptor -> + writeFreeCameraOptions(descriptor, options) + Status.check(mln_map_set_free_camera_options(handle.raw, descriptor.address)) + } + } + } + + public actual var projectionMode: ProjectionModeOptions + get() = live { + Heap.withScratch(MlnProjectionMode.SIZEOF) { out -> + MlnProjectionMode.setSize(out, MlnProjectionMode.SIZEOF) + Status.check(mln_map_get_projection_mode(handle.raw, out.address)) + readProjectionMode(out) + } + } + set(mode) { + live { + Heap.withScratch(MlnProjectionMode.SIZEOF) { descriptor -> + writeProjectionMode(descriptor, mode) + Status.check(mln_map_set_projection_mode(handle.raw, descriptor.address)) + } + } + } + + public actual fun pixelForLatLng(coordinate: LatLng): ScreenPoint = live { + Heap.withScratch(MlnLatLng.SIZEOF + MlnScreenPoint.SIZEOF) { scratch -> + val out = scratch + MlnLatLng.SIZEOF + CameraMarshal.writeLatLng(scratch, coordinate) + Status.check(mln_map_pixel_for_lat_lng(handle.raw, scratch.address, out.address)) + ScreenPoint(MlnScreenPoint.x(out), MlnScreenPoint.y(out)) + } + } + + public actual fun latLngForPixel(point: ScreenPoint): LatLng = live { + Heap.withScratch(MlnScreenPoint.SIZEOF + MlnLatLng.SIZEOF) { scratch -> + val out = scratch + MlnScreenPoint.SIZEOF + MlnScreenPoint.setX(scratch, point.x) + MlnScreenPoint.setY(scratch, point.y) + Status.check(mln_map_lat_lng_for_pixel(handle.raw, scratch.address, out.address)) + CameraMarshal.readLatLng(out) + } + } + + public actual fun pixelsForLatLngs(coordinates: List): List { + // An empty run would ask for a zero-byte block, which cannot be acquired, and there is nothing + // for native to project either way. + if (coordinates.isEmpty()) return emptyList() + return live { + val inputBytes = Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size) + val outputBytes = Heap.sizeOf(MlnScreenPoint.SIZEOF, coordinates.size) + withArena(bytes(blockBytes(inputBytes), blockBytes(outputBytes))) { arena -> + val points = writeLatLngs(arena, coordinates) + val out = allocate(arena, outputBytes) + Status.check( + mln_map_pixels_for_lat_lngs(handle.raw, points.address, coordinates.size, out.address) + ) + List(coordinates.size) { index -> + val entry = out + index * MlnScreenPoint.SIZEOF + ScreenPoint(MlnScreenPoint.x(entry), MlnScreenPoint.y(entry)) + } + } + } + } + + public actual fun latLngsForPixels(points: List): List { + if (points.isEmpty()) return emptyList() + return live { + val inputBytes = Heap.sizeOf(MlnScreenPoint.SIZEOF, points.size) + val outputBytes = Heap.sizeOf(MlnLatLng.SIZEOF, points.size) + withArena(bytes(blockBytes(inputBytes), blockBytes(outputBytes))) { arena -> + val block = allocate(arena, inputBytes) + points.forEachIndexed { index, point -> + val entry = block + index * MlnScreenPoint.SIZEOF + MlnScreenPoint.setX(entry, point.x) + MlnScreenPoint.setY(entry, point.y) + } + val out = allocate(arena, outputBytes) + Status.check( + mln_map_lat_lngs_for_pixels(handle.raw, block.address, points.size, out.address) + ) + readLatLngs(out, points.size) + } + } + } + + public actual fun attachMetalOwnedTexture( + descriptor: MetalOwnedTextureDescriptor + ): RenderSessionHandle = throw unsupportedBackend("Metal") + + public actual fun attachMetalBorrowedTexture( + descriptor: MetalBorrowedTextureDescriptor + ): RenderSessionHandle = throw unsupportedBackend("Metal") + + public actual fun attachVulkanOwnedTexture( + descriptor: VulkanOwnedTextureDescriptor + ): RenderSessionHandle = throw unsupportedBackend("Vulkan") + + public actual fun attachVulkanBorrowedTexture( + descriptor: VulkanBorrowedTextureDescriptor + ): RenderSessionHandle = throw unsupportedBackend("Vulkan") + + public actual fun attachOpenGLOwnedTexture( + descriptor: OpenGLOwnedTextureDescriptor + ): RenderSessionHandle = live { + withWebglContext(descriptor.context) { retention -> + // The out-handle goes first because it is the only member here that needs eight-byte + // alignment. + Heap.withScratch(HANDLE_BYTES + MlnOpenglOwnedTextureDescriptor.SIZEOF) { out -> + val block = out + HANDLE_BYTES + // The render marshaller owns the descriptors a session sets on itself, and an owned texture + // is only ever attached, so its two nested descriptors are placed here. + MlnOpenglOwnedTextureDescriptor.setSize(block, MlnOpenglOwnedTextureDescriptor.SIZEOF) + RenderMarshal.writeExtent( + block + MlnOpenglOwnedTextureDescriptor.OFFSET_EXTENT, + descriptor.extent, + ) + RenderMarshal.writeOpenGLContext( + block + MlnOpenglOwnedTextureDescriptor.OFFSET_CONTEXT, + descriptor.context, + ) + attach(::mln_opengl_owned_texture_attach, block, out, retention) + } + } + } + + public actual fun attachOpenGLBorrowedTexture( + descriptor: OpenGLBorrowedTextureDescriptor + ): RenderSessionHandle = live { + withWebglContext(descriptor.context) { retention -> + Heap.withScratch(HANDLE_BYTES + RenderMarshal.OPENGL_BORROWED_TEXTURE_SIZEOF) { out -> + val block = out + HANDLE_BYTES + RenderMarshal.writeOpenGLBorrowedTexture(block, descriptor) + attach(::mln_opengl_borrowed_texture_attach, block, out, retention) + } + } + } + + public actual fun attachMetalSurface(descriptor: MetalSurfaceDescriptor): RenderSessionHandle = + throw unsupportedBackend("Metal") + + public actual fun attachVulkanSurface(descriptor: VulkanSurfaceDescriptor): RenderSessionHandle = + throw unsupportedBackend("Vulkan") + + /** + * Attaches a surface target that presents through the canvas its WebGL context is bound to. + * + * `descriptor.surface` must be the null pointer. Every other OpenGL provider names a drawable + * there — an HDC, an EGLSurface — and a browser has none to name: the context already selects a + * canvas, and the session renders into that canvas's default framebuffer. Presenting is the + * browser compositing that canvas, so a canvas the page displays shows the frame with no copy. + * + * The context names an entry in the browser module's own table rather than anything a host could + * produce, so it comes from [org.maplibre.nativeffi.render.WebglContext], created on this thread. + */ + public actual fun attachOpenGLSurface(descriptor: OpenGLSurfaceDescriptor): RenderSessionHandle = + live { + withWebglContext(descriptor.context) { retention -> + Heap.withScratch(HANDLE_BYTES + RenderMarshal.OPENGL_SURFACE_SIZEOF) { out -> + val block = out + HANDLE_BYTES + RenderMarshal.writeOpenGLSurface(block, descriptor) + attach(::mln_opengl_surface_attach, block, out, retention) + } + } + } + + public actual fun createProjection(): MapProjectionHandle = live { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(mln_map_projection_create(handle.raw, out.address)) + MapProjectionHandle.fromNative(NativeMapProjection(Heap.loadLong(out))) + } + } + + public actual override fun close() { + core.closeOnce( + destroy = { mln_map_destroy(handle.raw) }, + // The registry holds a strong reference, because this target has neither finalization nor a + // weak reference to hold one with, so the entry goes when the map closes. + afterSuccess = { + clearCustomGeometrySources() + runtime.unregisterMap(this) + runtimeRetention.close() + }, + ) + } + + /** + * Releases the registrations whose sources the newly loaded style dropped. + * + * Called when a `MAP_STYLE_LOADED` event is polled, which is the only moment a style set by URL + * announces that it has replaced the previous one. What decides is whether the id still names a + * custom vector source, rather than whether the style changed: a style document cannot declare + * one, so an id that still names one names a source this binding added, and the entry under it is + * the registration that source was added with. + */ + internal fun releaseDetachedCustomGeometrySources() { + if (customGeometrySources.isEmpty()) return + val detached = + customGeometrySources.keys.filter { sourceId -> + styleSourceType(sourceId) != SourceType.CUSTOM_VECTOR + } + for (sourceId in detached) customGeometrySources.remove(sourceId)?.close() + } + + private fun clearCustomGeometrySources() { + // Emptied before anything is closed, so a close that failed part way through could not leave a + // registration reachable under an id whose source has already gone. + val bridges = customGeometrySources.values.toList() + customGeometrySources.clear() + for (bridge in bridges) bridge.close() + } + + /** The native map, for the wrappers this map owns. */ + internal fun nativeHandle(): NativeMap = live { handle } + + internal fun nativeHandleId(): Long = core.handleId() + + internal fun retainChild(childTypeName: String): HandleStateCore.ChildRetention = + core.retainChild(childTypeName) + + // ---------------------------------------------------------------- shared call shapes + // + // Each of these takes the entry point it calls, because the C API spells one shape many times: + // six existence queries over a string view and a boolean, four property setters over a string + // view and a JSON tree. The parameters are in C order, so a helper's body reads as the call it + // makes. + + /** One map argument and one boolean output, which several of these queries share. */ + private fun flagForMap(entry: (Long, Int) -> Int): Boolean = live { + Heap.withScratch(BOOL_BYTES) { out -> + Status.check(entry(handle.raw, out.address)) + isSet(out) + } + } + + private fun setFlagForMap(entry: (Long, Int) -> Int, value: Boolean) { + Status.check(entry(handle.raw, if (value) 1 else 0)) + } + + /** One string-view argument and one boolean output, which the existence queries share. */ + private fun flagForId(entry: (Long, Int, Int) -> Int, id: String): Boolean = live { + withArena(bytes(stringViewBytes(id), blockBytes(BOOL_BYTES))) { arena -> + val view = writeStringView(arena, id) + val out = allocate(arena, BOOL_BYTES) + Status.check(entry(handle.raw, view.address, out.address)) + isSet(out) + } + } + + /** One string-view argument and one double, which the layer and indicator setters share. */ + private fun setDoubleForId(entry: (Long, Int, Double) -> Int, id: String, value: Double) { + withArena(stringViewBytes(id)) { arena -> + val view = writeStringView(arena, id) + Status.check(entry(handle.raw, view.address, value)) + } + } + + /** One string-view argument and one double output, which the zoom-bound getters share. */ + private fun doubleForId(entry: (Long, Int, Int) -> Int, id: String): Double = live { + withArena(bytes(stringViewBytes(id), blockBytes(DOUBLE_BYTES))) { arena -> + val view = writeStringView(arena, id) + val out = allocate(arena, DOUBLE_BYTES) + Status.check(entry(handle.raw, view.address, out.address)) + Heap.loadDouble(out) + } + } + + /** Two string-view arguments, which most of the style mutators take. */ + private fun callWithTwoIds(entry: (Long, Int, Int) -> Int, first: String, second: String) { + withArena(bytes(stringViewBytes(first), stringViewBytes(second))) { arena -> + val firstView = writeStringView(arena, first) + val secondView = writeStringView(arena, second) + Status.check(entry(handle.raw, firstView.address, secondView.address)) + } + } + + /** Three string-view arguments, which the typed layer additions take. */ + private fun callWithThreeIds( + entry: (Long, Int, Int, Int) -> Int, + first: String, + second: String, + third: String, + ) { + withArena(bytes(stringViewBytes(first), stringViewBytes(second), stringViewBytes(third))) { + arena -> + val firstView = writeStringView(arena, first) + val secondView = writeStringView(arena, second) + val thirdView = writeStringView(arena, third) + Status.check(entry(handle.raw, firstView.address, secondView.address, thirdView.address)) + } + } + + /** + * One string-view argument and one JSON tree, which the property setters take. + * + * A null [value] reaches native as the null pointer the C API documents, which is how clearing a + * layer filter is spelled. + */ + private fun callWithIdAndJson(entry: (Long, Int, Int) -> Int, id: String, value: JsonValue?) { + withArena(bytes(stringViewBytes(id), value?.let { JsonMarshal.measure(it).toLong() } ?: 0L)) { + arena -> + val view = writeStringView(arena, id) + val root = value?.let { JsonMarshal.write(arena, it) } ?: HeapPointer(0) + Status.check(entry(handle.raw, view.address, root.address)) + } + } + + /** One string-view argument and one snapshot output, which the property getters take. */ + private fun jsonSnapshotForId(entry: (Long, Int, Int) -> Int, id: String): JsonValue? { + val snapshot = live { + withArena(bytes(stringViewBytes(id), blockBytes(HANDLE_BYTES))) { arena -> + val view = writeStringView(arena, id) + val out = allocate(arena, HANDLE_BYTES) + Status.check(entry(handle.raw, view.address, out.address)) + Heap.loadLong(out) + } + } + return readJsonSnapshot(snapshot) + } + + private fun boundsForCamera(entry: (Long, Int, Int) -> Int, camera: CameraOptions): LatLngBounds = + live { + Heap.withScratch(CameraMarshal.SIZEOF + MlnLatLngBounds.SIZEOF) { descriptor -> + val out = descriptor + CameraMarshal.SIZEOF + CameraMarshal.write(descriptor, camera) + Status.check(entry(handle.raw, descriptor.address, out.address)) + MapOptionsMarshal.readLatLngBounds(out) + } + } + + private fun transitionTo( + entry: (Long, Int, Int) -> Int, + camera: CameraOptions, + animation: AnimationOptions?, + ) { + withArena(bytes(blockBytes(CameraMarshal.SIZEOF), animationBytes(animation))) { arena -> + val cameraDescriptor = allocate(arena, CameraMarshal.SIZEOF) + CameraMarshal.write(cameraDescriptor, camera) + val animationDescriptor = writeAnimationOptions(arena, animation) + Status.check(entry(handle.raw, cameraDescriptor.address, animationDescriptor.address)) + } + } + + /** + * Holds the WebGL context an OpenGL target names open for as long as [body] and its session need + * it. + * + * Released again when the attach fails, so a refused target leaves nothing holding the context. + * The retention is idempotent, so releasing it here and in the session is the same release. + */ + private fun withWebglContext( + context: OpenGLContextDescriptor, + body: (HandleStateCore.ChildRetention?) -> T, + ): T { + val retention = WebglContext.retainForTarget(context) + try { + return body(retention) + } catch (error: Throwable) { + retention?.close() + throw error + } + } + + private fun attach( + entry: (Long, Int, Int) -> Int, + descriptor: HeapPointer, + out: HeapPointer, + contextRetention: HandleStateCore.ChildRetention?, + ): RenderSessionHandle { + Status.check(entry(handle.raw, descriptor.address, out.address)) + return RenderSessionHandle.fromNative( + this, + NativeRenderSession(Heap.loadLong(out)), + contextRetention, + ) + } + + private fun addTileSourceUrl( + entry: (Long, Int, Int, Int) -> Int, + sourceId: String, + url: String, + options: TileSourceOptions?, + ) { + live { + withArena( + bytes(stringViewBytes(sourceId), stringViewBytes(url), tileSourceOptionsBytes(options)) + ) { arena -> + val sourceView = writeStringView(arena, sourceId) + val urlView = writeStringView(arena, url) + val descriptor = writeTileSourceOptions(arena, options) + Status.check(entry(handle.raw, sourceView.address, urlView.address, descriptor.address)) + } + } + } + + private fun addTileSourceTiles( + entry: (Long, Int, Int, Int, Int) -> Int, + sourceId: String, + tiles: List, + options: TileSourceOptions?, + ) { + live { + withArena( + bytes( + stringViewBytes(sourceId), + stringViewArrayBytes(tiles), + tileSourceOptionsBytes(options), + ) + ) { arena -> + val sourceView = writeStringView(arena, sourceId) + val templates = writeStringViewArray(arena, tiles) + val descriptor = writeTileSourceOptions(arena, options) + Status.check( + entry(handle.raw, sourceView.address, templates.address, tiles.size, descriptor.address) + ) + } + } + } + + private fun copyStyleImageStretches( + view: HeapPointer, + stretchX: HeapPointer, + xCapacity: Int, + xCount: HeapPointer, + stretchY: HeapPointer, + yCapacity: Int, + yCount: HeapPointer, + found: HeapPointer, + ) { + Status.check( + mln_map_copy_style_image_stretches( + handle.raw, + view.address, + stretchX.address, + xCapacity, + xCount.address, + stretchY.address, + yCapacity, + yCount.address, + found.address, + ) + ) + } + + /** + * Probes the required length and then copies, for the texts a map answers about itself. + * + * A null buffer with zero capacity is a size probe the C API answers with OK, so the two calls + * below are how a caller learns a length it has no descriptor field for. + */ + private fun copyMapText(entry: (Long, Int, Int, Int) -> Int): String = live { + val required = + Heap.withScratch(SIZE_BYTES) { out -> + Status.check(entry(handle.raw, 0, 0, out.address)) + readCount(Heap.loadInt(out), "map text size") + } + if (required == 0) { + return@live "" + } + withArena(bytes(blockBytes(required), blockBytes(SIZE_BYTES))) { arena -> + val text = allocate(arena, required) + val copied = allocate(arena, SIZE_BYTES) + Status.check(entry(handle.raw, text.address, required, copied.address)) + Heap.loadBytes(text, readCount(Heap.loadInt(copied), "map copied text size")).decodeToString() + } + } + + /** The same probe-then-copy shape, for the texts a map answers about one layer. */ + private fun copyLayerText(entry: (Long, Int, Int, Int, Int) -> Int, layerId: String): String = + live { + val required = + withArena(bytes(stringViewBytes(layerId), blockBytes(SIZE_BYTES))) { arena -> + val view = writeStringView(arena, layerId) + val out = allocate(arena, SIZE_BYTES) + Status.check(entry(handle.raw, view.address, 0, 0, out.address)) + readCount(Heap.loadInt(out), "layer text size") + } + if (required == 0) { + return@live "" + } + withArena(bytes(stringViewBytes(layerId), blockBytes(required), blockBytes(SIZE_BYTES))) { + arena -> + val view = writeStringView(arena, layerId) + val text = allocate(arena, required) + val copied = allocate(arena, SIZE_BYTES) + Status.check(entry(handle.raw, view.address, text.address, required, copied.address)) + Heap.loadBytes(text, readCount(Heap.loadInt(copied), "layer copied text size")) + .decodeToString() + } + } + + private fun listStyleIds(entry: (Long, Int) -> Int): List { + val list = live { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(entry(handle.raw, out.address)) + Heap.loadLong(out) + } + } + return readStyleIdList(list) + } + + // ---------------------------------------------------------------- owned results + + /** Copies every ID out of a list and destroys it. */ + private fun readStyleIdList(list: Long): List { + if (list == 0L) return emptyList() + try { + InjectedFaults.beginResultCopy(list, SIZE_BYTES + MlnStringView.SIZEOF) + return Heap.withScratch(SIZE_BYTES + MlnStringView.SIZEOF) { count -> + val id = count + SIZE_BYTES + Status.check(mln_style_id_list_count(list, count.address)) + List(readCount(Heap.loadInt(count), "style ID count")) { index -> + Status.check(mln_style_id_list_get(list, index, id.address)) + // Copied before the list is destroyed below: the view points into storage the destroy + // frees. + JsonMarshal.readText(id) + } + } + } finally { + mln_style_id_list_destroy(list) + } + } + + /** Copies every string out of a list and destroys it. */ + private fun readStyleStringList(list: Long): List { + if (list == 0L) return emptyList() + try { + InjectedFaults.beginResultCopy(list, SIZE_BYTES + MlnStringView.SIZEOF) + return Heap.withScratch(SIZE_BYTES + MlnStringView.SIZEOF) { count -> + val value = count + SIZE_BYTES + Status.check(mln_style_string_list_count(list, count.address)) + List(readCount(Heap.loadInt(count), "style string count")) { index -> + Status.check(mln_style_string_list_get(list, index, value.address)) + JsonMarshal.readText(value) + } + } + } finally { + mln_style_string_list_destroy(list) + } + } + + /** Copies a JSON snapshot's tree and destroys it. */ + private fun readJsonSnapshot(snapshot: Long): JsonValue? { + // The C API reports an absent value as the null snapshot rather than as a failure. + if (snapshot == 0L) return null + try { + InjectedFaults.beginResultCopy(snapshot, POINTER_BYTES) + return Heap.withScratch(POINTER_BYTES) { out -> + Status.check(mln_json_snapshot_get(snapshot, out.address)) + val root = HeapPointer(Heap.loadInt(out)) + if (root.address == 0) null else JsonMarshal.read(root) + } + } finally { + mln_json_snapshot_destroy(snapshot) + } + } + + // ---------------------------------------------------------------- descriptors written here + // + // The animation, camera-fit, free-camera, and projection descriptors have no marshaller of their + // own yet, and this is their only call site. They follow the same rule every descriptor here + // does: the leading size field carries the size this binding was generated against, and an absent + // Kotlin value is a field bit left clear rather than a sentinel written into the value. + + private fun animationBytes(animation: AnimationOptions?): Long = + if (animation == null) 0L else blockBytes(MlnAnimationOptions.SIZEOF) + + /** Returns the null pointer for absent animation, which the C API reads as its own defaults. */ + private fun writeAnimationOptions(arena: HeapArena, animation: AnimationOptions?): HeapPointer { + if (animation == null) return HeapPointer(0) + val base = allocate(arena, MlnAnimationOptions.SIZEOF) + MlnAnimationOptions.setSize(base, MlnAnimationOptions.SIZEOF) + var fields = 0 + animation.durationMs?.let { + fields = fields or MlnAnimationOptionField.MLN_ANIMATION_OPTION_DURATION + MlnAnimationOptions.setDurationMs(base, it) + } + animation.velocity?.let { + fields = fields or MlnAnimationOptionField.MLN_ANIMATION_OPTION_VELOCITY + MlnAnimationOptions.setVelocity(base, it) + } + animation.minZoom?.let { + fields = fields or MlnAnimationOptionField.MLN_ANIMATION_OPTION_MIN_ZOOM + MlnAnimationOptions.setMinZoom(base, it) + } + animation.easing?.let { + fields = fields or MlnAnimationOptionField.MLN_ANIMATION_OPTION_EASING + val easing = base + MlnAnimationOptions.OFFSET_EASING + MlnUnitBezier.setX1(easing, it.x1) + MlnUnitBezier.setY1(easing, it.y1) + MlnUnitBezier.setX2(easing, it.x2) + MlnUnitBezier.setY2(easing, it.y2) + } + animation.transitionId?.let { + fields = fields or MlnAnimationOptionField.MLN_ANIMATION_OPTION_TRANSITION_ID + MlnAnimationOptions.setTransitionId(base, it) + } + MlnAnimationOptions.setFields(base, fields) + return base + } + + private fun fitOptionsBytes(fitOptions: CameraFitOptions?): Long = + if (fitOptions == null) 0L else blockBytes(MlnCameraFitOptions.SIZEOF) + + /** Returns the null pointer for absent fit options, which the C API reads as its own defaults. */ + private fun writeCameraFitOptions(arena: HeapArena, fitOptions: CameraFitOptions?): HeapPointer { + if (fitOptions == null) return HeapPointer(0) + val base = allocate(arena, MlnCameraFitOptions.SIZEOF) + MlnCameraFitOptions.setSize(base, MlnCameraFitOptions.SIZEOF) + var fields = 0 + fitOptions.padding?.let { + fields = fields or MlnCameraFitOptionField.MLN_CAMERA_FIT_OPTION_PADDING + CameraMarshal.writeEdgeInsets(base + MlnCameraFitOptions.OFFSET_PADDING, it) + } + fitOptions.bearing?.let { + fields = fields or MlnCameraFitOptionField.MLN_CAMERA_FIT_OPTION_BEARING + MlnCameraFitOptions.setBearing(base, it) + } + fitOptions.pitch?.let { + fields = fields or MlnCameraFitOptionField.MLN_CAMERA_FIT_OPTION_PITCH + MlnCameraFitOptions.setPitch(base, it) + } + MlnCameraFitOptions.setFields(base, fields) + return base + } + + private fun writeFreeCameraOptions(base: HeapPointer, options: FreeCameraOptions) { + MlnFreeCameraOptions.setSize(base, MlnFreeCameraOptions.SIZEOF) + var fields = 0 + options.position?.let { + fields = fields or MlnFreeCameraOptionField.MLN_FREE_CAMERA_OPTION_POSITION + val position = base + MlnFreeCameraOptions.OFFSET_POSITION + MlnVec3.setX(position, it.x) + MlnVec3.setY(position, it.y) + MlnVec3.setZ(position, it.z) + } + options.orientation?.let { + fields = fields or MlnFreeCameraOptionField.MLN_FREE_CAMERA_OPTION_ORIENTATION + val orientation = base + MlnFreeCameraOptions.OFFSET_ORIENTATION + MlnQuaternion.setX(orientation, it.x) + MlnQuaternion.setY(orientation, it.y) + MlnQuaternion.setZ(orientation, it.z) + MlnQuaternion.setW(orientation, it.w) + } + MlnFreeCameraOptions.setFields(base, fields) + } + + private fun readFreeCameraOptions(base: HeapPointer): FreeCameraOptions { + val fields = MlnFreeCameraOptions.fields(base) + return FreeCameraOptions().also { + if ((fields and MlnFreeCameraOptionField.MLN_FREE_CAMERA_OPTION_POSITION) != 0) { + val position = base + MlnFreeCameraOptions.OFFSET_POSITION + it.position = Vec3(MlnVec3.x(position), MlnVec3.y(position), MlnVec3.z(position)) + } + if ((fields and MlnFreeCameraOptionField.MLN_FREE_CAMERA_OPTION_ORIENTATION) != 0) { + val orientation = base + MlnFreeCameraOptions.OFFSET_ORIENTATION + it.orientation = + Quaternion( + MlnQuaternion.x(orientation), + MlnQuaternion.y(orientation), + MlnQuaternion.z(orientation), + MlnQuaternion.w(orientation), + ) + } + } + } + + private fun writeProjectionMode(base: HeapPointer, mode: ProjectionModeOptions) { + MlnProjectionMode.setSize(base, MlnProjectionMode.SIZEOF) + var fields = 0 + mode.axonometric?.let { + fields = fields or MlnProjectionModeField.MLN_PROJECTION_MODE_AXONOMETRIC + MlnProjectionMode.setAxonometric(base, it) + } + mode.xSkew?.let { + fields = fields or MlnProjectionModeField.MLN_PROJECTION_MODE_X_SKEW + MlnProjectionMode.setXSkew(base, it) + } + mode.ySkew?.let { + fields = fields or MlnProjectionModeField.MLN_PROJECTION_MODE_Y_SKEW + MlnProjectionMode.setYSkew(base, it) + } + MlnProjectionMode.setFields(base, fields) + } + + private fun readProjectionMode(base: HeapPointer): ProjectionModeOptions { + val fields = MlnProjectionMode.fields(base) + return ProjectionModeOptions().also { + if ((fields and MlnProjectionModeField.MLN_PROJECTION_MODE_AXONOMETRIC) != 0) { + it.axonometric = MlnProjectionMode.axonometric(base) + } + if ((fields and MlnProjectionModeField.MLN_PROJECTION_MODE_X_SKEW) != 0) { + it.xSkew = MlnProjectionMode.xSkew(base) + } + if ((fields and MlnProjectionModeField.MLN_PROJECTION_MODE_Y_SKEW) != 0) { + it.ySkew = MlnProjectionMode.ySkew(base) + } + } + } + + private fun writeCanonicalTileId(arena: HeapArena, tileId: CanonicalTileId): HeapPointer { + val base = allocate(arena, MlnCanonicalTileId.SIZEOF) + MlnCanonicalTileId.setZ(base, tileId.z) + // The C fields are unsigned 32-bit and the Kotlin ones are Long so that the whole domain fits, + // so these carry the bit pattern rather than a converted value. The public type already + // refuses anything outside that domain. + MlnCanonicalTileId.setX(base, tileId.x.toInt()) + MlnCanonicalTileId.setY(base, tileId.y.toInt()) + return base + } + + /** + * Bytes [options] needs, including the cluster properties the descriptor borrows. + * + * The source marshaller writes the descriptor but does not place the graph, because the graph is + * a JSON tree and that file owns none of the JSON arithmetic. It is measured and placed here, in + * the same block, so it lives exactly as long as the call that points at it. + */ + private fun geoJsonSourceOptionsBytes(options: GeoJsonSourceOptions?): Long { + if (options == null) return 0L + val clusterProperties = + options.clusterProperties?.let { JsonMarshal.measure(it).toLong() } ?: 0L + return JsonMarshal.plus( + blockBytes(SourceMarshal.GEOJSON_SOURCE_OPTIONS_SIZEOF), + clusterProperties, + ) + } + + /** Returns the null pointer for absent options, which the C API reads as its own defaults. */ + private fun writeGeoJsonSourceOptions( + arena: HeapArena, + options: GeoJsonSourceOptions?, + ): HeapPointer { + if (options == null) return HeapPointer(0) + val base = allocate(arena, SourceMarshal.GEOJSON_SOURCE_OPTIONS_SIZEOF) + val clusterProperties = options.clusterProperties?.let { JsonMarshal.write(arena, it) } + SourceMarshal.writeGeoJsonSourceOptions(base, options, clusterProperties) + return base + } + + private fun tileSourceOptionsBytes(options: TileSourceOptions?): Long = + if (options == null) 0L else SourceMarshal.measureTileSourceOptions(options).toLong() + + private fun writeTileSourceOptions(arena: HeapArena, options: TileSourceOptions?): HeapPointer = + if (options == null) HeapPointer(0) else SourceMarshal.writeTileSourceOptions(arena, options) + + // ---------------------------------------------------------------- arrays and scalars + + private fun writeLatLngs(arena: HeapArena, coordinates: List): HeapPointer { + val block = allocate(arena, Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size)) + coordinates.forEachIndexed { index, coordinate -> + CameraMarshal.writeLatLng(block + index * MlnLatLng.SIZEOF, coordinate) + } + return block + } + + private fun readLatLngs(base: HeapPointer, count: Int): List = + List(count) { index -> CameraMarshal.readLatLng(base + index * MlnLatLng.SIZEOF) } + + private fun readStretches(base: HeapPointer, count: Int): List = + List(count) { index -> + StyleMarshal.readImageStretch(base + index * StyleMarshal.IMAGE_STRETCH_SIZEOF) + } + + private fun anchorBytes(anchor: ScreenPoint?): Long = + if (anchor == null) 0L else blockBytes(MlnScreenPoint.SIZEOF) + + /** Returns the null pointer for an absent anchor, which the C API reads as the screen centre. */ + private fun writeScreenPointOrNull(arena: HeapArena, point: ScreenPoint?): HeapPointer = + if (point == null) HeapPointer(0) else writeScreenPoint(arena, point) + + private fun writeScreenPoint(arena: HeapArena, point: ScreenPoint): HeapPointer { + val base = allocate(arena, MlnScreenPoint.SIZEOF) + MlnScreenPoint.setX(base, point.x) + MlnScreenPoint.setY(base, point.y) + return base + } + + private fun stringViewBytes(text: String): Long = + JsonMarshal.plus(blockBytes(MlnStringView.SIZEOF), JsonMarshal.measureText(text)) + + /** + * Writes a string view the caller passes by address. + * + * A C argument of type `mln_string_view` is passed by value, which this target lowers to a + * pointer to the view, so the view itself needs storage of its own alongside its bytes. + */ + private fun writeStringView(arena: HeapArena, text: String): HeapPointer { + val view = allocate(arena, MlnStringView.SIZEOF) + JsonMarshal.writeText(arena, view, text) + return view + } + + private fun stringViewArrayBytes(values: List): Long = + values.fold(JsonMarshal.measureArray(MlnStringView.SIZEOF, values.size)) { total, value -> + JsonMarshal.plus(total, JsonMarshal.measureText(value)) + } + + private fun writeStringViewArray(arena: HeapArena, values: List): HeapPointer { + val block = JsonMarshal.allocateArray(arena, MlnStringView.SIZEOF, values.size) + values.forEachIndexed { index, value -> + JsonMarshal.writeText(arena, block + index * MlnStringView.SIZEOF, value) + } + return block + } + + private fun isSet(flag: HeapPointer): Boolean = Heap.loadByte(flag) != 0.toByte() + + // ---------------------------------------------------------------- arena arithmetic + + /** + * Takes one measured block and carves [body]'s descriptors out of it. + * + * The block starts zeroed, so an output slot inside it is already the null handle the C API + * requires callers to pass. + */ + private fun withArena(bytes: Long, body: (HeapArena) -> T): T { + Status.requireArgument(bytes in 0..Int.MAX_VALUE.toLong()) { + "a descriptor block must be non-negative and addressable on this target" + } + // Zero is the ordinary case for a call whose descriptors are all optional and all absent: + // `scaleBy(scale, null)` passes a null anchor, which the C API reads as the screen centre, so + // there is nothing to place. An empty arena rather than an empty allocation, because the + // module's allocator has no zero-sized block to give and none is wanted. Any allocation + // against this fails the arena's own bounds check, which is what a measure of zero followed by + // a write should do. + if (bytes == 0L) return body(HeapArena(HeapPointer(0), 0)) + val size = bytes.toInt() + return Heap.withScratch(size) { block -> body(HeapArena(block, size)) } + } + + /** + * Adds measured sizes through the one checked addition this binding uses. + * + * The arithmetic lives in [JsonMarshal] rather than being repeated here, because a second copy is + * a second place for an unchecked subtotal to appear. + */ + private fun bytes(vararg sizes: Long): Long = + sizes.fold(0L) { total, size -> JsonMarshal.plus(total, size) } + + /** Bytes one block occupies in these arenas, including the padding that follows it. */ + private fun blockBytes(size: Int): Long = JsonMarshal.measureBlock(size) + + /** Reserves the block [blockBytes] accounted for. */ + private fun allocate(arena: HeapArena, size: Int): HeapPointer = + JsonMarshal.allocateBlock(arena, size) + + /** Rejects a string C would truncate when it is passed as null-terminated text. */ + private fun requireValidCString(value: String, subject: String) { + Heap.requireCString(value, subject) + } + + /** + * Refuses a count native reported that no real result could carry. + * + * `size_t` is 32 bits on this target, so a value past [Int.MAX_VALUE] arrives negative. The heap + * could not hold a result that large, so a negative one means the address being read is not the + * out-parameter it was taken for, and continuing would size a buffer from a number that is not a + * length. + */ + private fun readCount(count: Int, subject: String): Int { + if (count < 0) { + throw Status.invalidState("The MapLibre Native browser module reported a $subject of $count") + } + return count + } + + /** The same refusal for a length this binding already widened out of a descriptor field. */ + private fun readLength(length: Long, subject: String): Int { + if (length < 0 || length > Int.MAX_VALUE) { + throw Status.invalidState("The MapLibre Native browser module reported a $subject of $length") + } + return length.toInt() + } + + private fun unsupportedBackend(backend: String): UnsupportedFeatureException = + UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "$backend render targets are not supported by the browser build of MapLibre Native, " + + "which compiles OpenGL against WebGL", + ) + + public actual companion object { + public actual fun create(runtime: RuntimeHandle, options: MapOptions): MapHandle = + Heap.withScratch(HANDLE_BYTES + MapOptionsMarshal.MAP_OPTIONS_SIZEOF) { out -> + val descriptor = out + HANDLE_BYTES + MapOptionsMarshal.writeMapOptions(descriptor, options) + Status.check(mln_map_create(runtime.nativeHandle().raw, descriptor.address, out.address)) + MapHandle(runtime, NativeMap(Heap.loadLong(out))).also(runtime::registerMap) + } + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/map/MapProjectionHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/map/MapProjectionHandle.kt new file mode 100644 index 000000000..397455eed --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/map/MapProjectionHandle.kt @@ -0,0 +1,132 @@ +package org.maplibre.nativeffi.map + +import org.maplibre.nativeffi.camera.CameraOptions +import org.maplibre.nativeffi.camera.EdgeInsets +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.ScreenPoint +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore +import org.maplibre.nativeffi.internal.lifecycle.NativeMapProjection +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.CameraMarshal +import org.maplibre.nativeffi.internal.wasm.GeometryMarshal +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.generated.MlnEdgeInsets +import org.maplibre.nativeffi.internal.wasm.generated.MlnLatLng +import org.maplibre.nativeffi.internal.wasm.generated.MlnScreenPoint +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_get_camera +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_lat_lng_for_pixel +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_pixel_for_lat_lng +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_set_camera +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_set_visible_coordinates +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_projection_set_visible_geometry + +/** + * A standalone projection snapshot, owned by the thread the module runs its maps on. + * + * A projection is affine to the thread that owns the map it was taken from, which is the thread + * this binding runs on, so every call here is an ordinary synchronous call as on every other + * platform. + */ +public actual class MapProjectionHandle +internal constructor(private val handle: NativeMapProjection) : AutoCloseable { + private val core = HandleStateCore(TYPE_NAME, handle.raw) + + private inline fun live(body: () -> T): T { + core.requireLive() + return body() + } + + public actual val camera: CameraOptions + get() = live { + Heap.withScratch(CameraMarshal.SIZEOF) { out -> + // An output descriptor states its own size too: native reads it to decide which fields it + // may write, and a zeroed block would ask for a zero-sized camera. + CameraMarshal.writeHeader(out) + Status.check(mln_map_projection_get_camera(handle.raw, out.address)) + CameraMarshal.read(out) + } + } + + public actual fun setCamera(camera: CameraOptions) { + live { + Heap.withScratch(CameraMarshal.SIZEOF) { descriptor -> + CameraMarshal.write(descriptor, camera) + Status.check(mln_map_projection_set_camera(handle.raw, descriptor.address)) + } + } + } + + public actual fun setVisibleCoordinates(coordinates: List, padding: EdgeInsets) { + live { + // The coordinates and the padding share one block, so this costs one scratch acquisition + // rather than two. + val coordinateBytes = Heap.sizeOf(MlnLatLng.SIZEOF, coordinates.size) + Heap.withScratch(coordinateBytes + MlnEdgeInsets.SIZEOF) { scratch -> + coordinates.forEachIndexed { index, coordinate -> + CameraMarshal.writeLatLng(scratch + index * MlnLatLng.SIZEOF, coordinate) + } + val insets = scratch + coordinateBytes + CameraMarshal.writeEdgeInsets(insets, padding) + Status.check( + mln_map_projection_set_visible_coordinates( + handle.raw, + scratch.address, + coordinates.size, + insets.address, + ) + ) + } + } + } + + public actual fun setVisibleGeometry(geometry: Geometry, padding: EdgeInsets) { + live { + // Measured before the block is taken. A geometry tree is many nested spans, and the arena + // carves them out of one allocation rather than taking one per node. + val geometryBytes = GeometryMarshal.measure(geometry) + Heap.withScratch(geometryBytes + MlnEdgeInsets.SIZEOF) { scratch -> + val root = GeometryMarshal.write(HeapArena(scratch, geometryBytes), geometry) + val insets = scratch + geometryBytes + CameraMarshal.writeEdgeInsets(insets, padding) + Status.check( + mln_map_projection_set_visible_geometry(handle.raw, root.address, insets.address) + ) + } + } + } + + public actual fun pixelForLatLng(coordinate: LatLng): ScreenPoint = live { + Heap.withScratch(MlnLatLng.SIZEOF + MlnScreenPoint.SIZEOF) { scratch -> + val out = scratch + MlnLatLng.SIZEOF + CameraMarshal.writeLatLng(scratch, coordinate) + Status.check(mln_map_projection_pixel_for_lat_lng(handle.raw, scratch.address, out.address)) + ScreenPoint(MlnScreenPoint.x(out), MlnScreenPoint.y(out)) + } + } + + public actual fun latLngForPixel(point: ScreenPoint): LatLng = live { + Heap.withScratch(MlnScreenPoint.SIZEOF + MlnLatLng.SIZEOF) { scratch -> + val out = scratch + MlnScreenPoint.SIZEOF + MlnScreenPoint.setX(scratch, point.x) + MlnScreenPoint.setY(scratch, point.y) + Status.check(mln_map_projection_lat_lng_for_pixel(handle.raw, scratch.address, out.address)) + CameraMarshal.readLatLng(out) + } + } + + public actual val isClosed: Boolean + get() = core.isReleased() + + public actual override fun close() { + core.closeOnce(destroy = { mln_map_projection_destroy(handle.raw) }) + } + + internal companion object { + private const val TYPE_NAME = "MapProjectionHandle" + + fun fromNative(handle: NativeMapProjection): MapProjectionHandle = MapProjectionHandle(handle) + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/MetalOwnedTextureFrameHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/MetalOwnedTextureFrameHandle.kt new file mode 100644 index 000000000..7bd2144f1 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/MetalOwnedTextureFrameHandle.kt @@ -0,0 +1,29 @@ +package org.maplibre.nativeffi.render + +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException + +/** + * Metal session-owned texture frames do not exist in a browser build. + * + * MapLibre Native compiles one render backend per build, and the browser target compiles OpenGL + * against WebGL. Nothing in this build can attach a Metal render target, so nothing can produce one + * of these frames. The type exists because the common API declares it, and it has no constructor a + * caller could reach -- which is the binding reporting the build's real capability rather than + * inventing a rule of its own. + */ +public actual class MetalOwnedTextureFrameHandle private constructor() : AutoCloseable { + public actual fun frame(): MetalOwnedTextureFrame = + throw UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "Metal render targets are not supported by the browser build of MapLibre Native", + ) + + /** Always closed: no instance is reachable, so none is ever open. */ + public actual val isClosed: Boolean + get() = true + + public actual override fun close() { + // Unreachable; no instance exists. + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/NativeBuffer.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/NativeBuffer.kt new file mode 100644 index 000000000..92d9c70a6 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/NativeBuffer.kt @@ -0,0 +1,82 @@ +package org.maplibre.nativeffi.render + +import org.maplibre.nativeffi.internal.lifecycle.BorrowedResourceCore +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer + +/** + * Reusable readback and upload storage, held in the Emscripten heap. + * + * Native writes readback pixels through a pointer, so the storage has to live where native can + * address it -- which is the module's heap, not this module's. That is also why the buffer is + * explicit rather than a `ByteArray`: a garbage-collected Kotlin array has no address native could + * be given, and copying one in and out per frame would cost a transfer each way. + * + * A browser host cannot recover leaked heap by restarting a process, and Kotlin/Wasm has no + * finalizer to release it from, so closing this is the only thing that frees it. + */ +public actual class NativeBuffer +private constructor(private val address: Int, private val length: Long) : AutoCloseable { + private val core = + BorrowedResourceCore("NativeBuffer") { if (address != 0) Heap.release(HeapPointer(address)) } + + public actual fun byteLength(): Long = core.withOpenResource { length } + + public actual fun toByteArray(): ByteArray = core.withOpenResource { + // One boundary crossing regardless of size; see Heap. + Heap.loadBytes(HeapPointer(address), length.toInt()) + } + + /** Runs [block] with the buffer's address, keeping it open for the call. */ + internal fun borrow(block: (HeapPointer, Long) -> T): T = core.withOpenResource { + block(HeapPointer(address), length) + } + + internal fun ensureCapacity(requiredBytes: Long) { + core.withOpenResource { + Status.requireArgument(length >= requiredBytes) { + "buffer is smaller than required byte length" + } + } + } + + public actual override fun close(): Unit = core.close() + + public actual companion object { + /** + * Takes [byteLength] bytes of the module's heap, or says why it could not. + * + * Two of the three ways this fails are the caller's, and they are separated because the remedy + * differs. A negative length, or one no 32-bit pointer could address, is a wrong argument. A + * length past the module's whole linear memory is also a wrong argument even though it looks + * like a shortage: the heap is fixed at link time, so nothing a host frees would ever make that + * request succeed. Only the third is a state failure — the heap is real but what is left of it + * is not enough — and that one is the allocator's to report. + * + * It does report it, rather than taking the page's module down, because this build links with + * `-sABORTING_MALLOC=0`. Emscripten's default is to abort on an allocation the heap cannot + * serve, which would make the check below unreachable and leave a host with no error at all. + */ + public actual fun allocate(byteLength: Long): NativeBuffer { + Status.requireArgument(byteLength >= 0) { "byteLength must be non-negative" } + // Pointers are 32 bits on this target, so a length native could not address is rejected here + // rather than becoming a truncated allocation. + Status.requireArgument(byteLength <= Int.MAX_VALUE) { + "byteLength must fit a 32-bit pointer on this target" + } + // Asked of the module rather than assumed from the link settings, so this stays right if the + // heap is linked at another size. It is a good deal tighter than the pointer bound above: the + // module's memory is half a gigabyte by default, where a 32-bit pointer addresses four. + val heapBytes = Heap.byteLength() + Status.requireArgument(byteLength <= heapBytes) { + "byteLength must not exceed the browser module's whole $heapBytes-byte heap" + } + if (byteLength == 0L) return NativeBuffer(0, 0) + // The scratch allocator's own acquisition, which reports an exhausted heap the same way: a + // caller cannot tell the two apart, and two spellings of one failure drift the first time + // either is reworded. + return NativeBuffer(Heap.acquire(byteLength.toInt()).address, byteLength) + } + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/OpenGLOwnedTextureFrameHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/OpenGLOwnedTextureFrameHandle.kt new file mode 100644 index 000000000..115153ebe --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/OpenGLOwnedTextureFrameHandle.kt @@ -0,0 +1,53 @@ +package org.maplibre.nativeffi.render + +/** + * Explicit handle for an OpenGL session-owned texture frame. + * + * The frame's texture is borrowed from the session and stays valid only until this handle is + * closed, which is why the handle is explicit rather than a value a host could keep. A browser host + * has no finalizer to fall back on, so nothing releases the frame if this is not closed — and a + * session with a frame still acquired refuses to render, resize, detach, or close. + * + * The frame's values are held here rather than in the module's heap. Native filled a descriptor in + * scratch that its acquire call freed, and release matches a frame by its generation and frame id + * rather than by the address those arrive through, so the descriptor is rebuilt for the release. + */ +public actual class OpenGLOwnedTextureFrameHandle +internal constructor( + private val session: RenderSessionHandle, + private val scope: FrameScope, + private val frameValue: OpenGLOwnedTextureFrame, +) : AutoCloseable { + private val core = OwnedTextureFrameHandleCore("OpenGLOwnedTextureFrameHandle") + + public actual fun frame(): OpenGLOwnedTextureFrame { + core.ensureOpen() + return frameValue + } + + public actual val isClosed: Boolean + get() = core.isClosed() + + public actual override fun close() { + core.close( + releaseNative = { session.releaseOpenGLFrame(frameValue) }, + ownerClosed = { session.isClosed }, + releaseLocal = ::releaseLocal, + ) + } + + /** + * Retires the frame locally, after native has released it. + * + * The scope closes first so the frame's values stop reading as live, and the session's borrow is + * given back last whatever that does, because a borrow left standing would block every later + * session call. + */ + private fun releaseLocal() { + try { + scope.close() + } finally { + session.finishFrameBorrow() + } + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt new file mode 100644 index 000000000..74c0ab742 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/RenderSessionHandle.kt @@ -0,0 +1,546 @@ +package org.maplibre.nativeffi.render + +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.geo.Feature +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore +import org.maplibre.nativeffi.internal.lifecycle.NativeRenderSession +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.GeoJsonMarshal +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.JsonMarshal +import org.maplibre.nativeffi.internal.wasm.RenderMarshal +import org.maplibre.nativeffi.internal.wasm.generated.mln_feature_extension_result_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_feature_extension_result_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_feature_query_result_count +import org.maplibre.nativeffi.internal.wasm.generated.mln_feature_query_result_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_feature_query_result_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_json_snapshot_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_json_snapshot_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_borrowed_texture_set_target +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_owned_texture_acquire_frame +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_owned_texture_release_frame +import org.maplibre.nativeffi.internal.wasm.generated.mln_opengl_surface_set_target +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_clear_data +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_detach +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_dump_debug_logs +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_get_feature_state +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_query_feature_extensions +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_query_rendered_features +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_query_source_features +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_reduce_memory_use +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_remove_feature_state +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_render_update +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_resize +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_session_set_feature_state +import org.maplibre.nativeffi.internal.wasm.generated.mln_texture_read_premultiplied_rgba8 +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.query.FeatureExtensionResult +import org.maplibre.nativeffi.query.FeatureStateSelector +import org.maplibre.nativeffi.query.QueriedFeature +import org.maplibre.nativeffi.query.RenderedFeatureQueryOptions +import org.maplibre.nativeffi.query.RenderedQueryGeometry +import org.maplibre.nativeffi.query.SourceFeatureQueryOptions + +/** + * A render session, owned by the thread this binding runs on. + * + * That thread created the runtime the session's map belongs to, so it is the session's owner thread + * as far as the C API is concerned, and every call below is an ordinary synchronous call made on + * it. + * + * The browser build compiles one render backend, OpenGL against WebGL, and every render target that + * backend has: a native surface, which here is the canvas the context is bound to, and both the + * owned and the borrowed texture target. See the Metal and Vulkan members below for what compiling + * one backend leaves unreachable. + * + * A session holds its WebGL context open for as long as it borrows it. The backend makes that + * context current on every frame and again while it releases its GL objects, so closing the context + * first returns an invalid-state status naming this session rather than leaving native to work in a + * context that is gone. [detach] and [close] release it. + */ +public actual class RenderSessionHandle +internal constructor( + private val map: MapHandle, + private val handle: NativeRenderSession, + // Null only for the WGL and EGL arms of an OpenGL context descriptor, which name a graphics API + // this module was not built against and which native refuses at attach. + private val contextRetention: HandleStateCore.ChildRetention?, +) : AutoCloseable { + // Held so that closing the map while a session is attached is the binding's own INVALID_STATE, + // which is what the common KDoc promises. Naming the map to HandleStateCore does not do that. + private val mapRetention = map.retainChild(TYPE_NAME) + private val core = HandleStateCore(TYPE_NAME, handle.raw, map) + private val activeFrame = ActiveFrameState() + + /** + * Checks this handle is live and then runs [body], without holding a use count across it. + * + * A use count covers a host that uses a handle on one thread and closes it on another, and this + * binding has one thread: a close can only arrive from a frame below the call it would wait for, + * which is the wait `yieldWhileClosing` refuses to make. + */ + private inline fun live(body: () -> T): T { + core.requireLive() + return body() + } + + public actual val isClosed: Boolean + get() = core.isReleased() + + public actual fun map(): MapHandle = map + + public actual fun resize(width: Int, height: Int, scaleFactor: Double) { + activeFrame.ensureInactive("resize") + Status.requireArgument(width >= 0) { "width must be non-negative" } + Status.requireArgument(height >= 0) { "height must be non-negative" } + live { Status.check(mln_render_session_resize(handle.raw, width, height, scaleFactor)) } + } + + public actual fun setMetalSurfaceTarget(descriptor: MetalSurfaceDescriptor) { + throw unsupportedBackend("Metal") + } + + public actual fun setVulkanSurfaceTarget(descriptor: VulkanSurfaceDescriptor) { + throw unsupportedBackend("Vulkan") + } + + /** + * Takes a new extent for a surface session, which is all this can change in a browser. + * + * There is no surface object to replace: the session presents through the canvas its context is + * bound to, and `descriptor.surface` must be [NativePointer.NULL] as it was at attach. Sizing + * that canvas's drawing buffer is [WebglContext.resizeCanvas], and neither call implies the + * other. + */ + public actual fun setOpenGLSurfaceTarget(descriptor: OpenGLSurfaceDescriptor) { + activeFrame.ensureInactive("set target") + WebglContext.requireOpenForTarget(descriptor.context) + live { + Heap.withScratch(RenderMarshal.OPENGL_SURFACE_SIZEOF) { block -> + RenderMarshal.writeOpenGLSurface(block, descriptor) + Status.check(mln_opengl_surface_set_target(handle.raw, block.address)) + } + } + } + + public actual fun setMetalBorrowedTextureTarget(descriptor: MetalBorrowedTextureDescriptor) { + throw unsupportedBackend("Metal") + } + + public actual fun setVulkanBorrowedTextureTarget(descriptor: VulkanBorrowedTextureDescriptor) { + throw unsupportedBackend("Vulkan") + } + + public actual fun setOpenGLBorrowedTextureTarget(descriptor: OpenGLBorrowedTextureDescriptor) { + activeFrame.ensureInactive("set target") + WebglContext.requireOpenForTarget(descriptor.context) + live { + Heap.withScratch(RenderMarshal.OPENGL_BORROWED_TEXTURE_SIZEOF) { block -> + RenderMarshal.writeOpenGLBorrowedTexture(block, descriptor) + Status.check(mln_opengl_borrowed_texture_set_target(handle.raw, block.address)) + } + } + } + + public actual fun renderUpdate(): Boolean { + activeFrame.ensureInactive("render") + return live { + Heap.withScratch(BOOL_BYTES) { out -> + Status.check(mln_render_session_render_update(handle.raw, out.address)) + Heap.loadByte(out) != 0.toByte() + } + } + } + + public actual fun detach() { + activeFrame.ensureInactive("detach") + live { Status.check(mln_render_session_detach(handle.raw)) } + // A detached session is live for destruction and nothing else, so it holds neither the map nor + // the WebGL context whose GL objects the detach released. + mapRetention.close() + contextRetention?.close() + } + + public actual fun reduceMemoryUse() { + activeFrame.ensureInactive("reduce memory use") + live { Status.check(mln_render_session_reduce_memory_use(handle.raw)) } + } + + public actual fun clearData() { + activeFrame.ensureInactive("clear data") + live { Status.check(mln_render_session_clear_data(handle.raw)) } + } + + public actual fun dumpDebugLogs() { + activeFrame.ensureInactive("dump debug logs") + live { Status.check(mln_render_session_dump_debug_logs(handle.raw)) } + } + + public actual fun setFeatureState(selector: FeatureStateSelector, value: JsonValue) { + activeFrame.ensureInactive("set feature state") + live { + // One block for both, so both are measured before either is written. + val bytes = + RenderMarshal.measureFeatureStateSelector(selector) + JsonMarshal.measureValue(value, 0) + withArena(bytes) { arena -> + val selectorBlock = RenderMarshal.writeFeatureStateSelector(arena, selector) + val stateBlock = JsonMarshal.write(arena, value) + Status.check( + mln_render_session_set_feature_state( + handle.raw, + selectorBlock.address, + stateBlock.address, + ) + ) + } + } + } + + public actual fun getFeatureState(selector: FeatureStateSelector): JsonValue { + activeFrame.ensureInactive("get feature state") + val snapshot = live { + val bytes = + RenderMarshal.measureFeatureStateSelector(selector) + RenderMarshal.OUT_SLOT_BYTES.toLong() + withArena(bytes) { arena -> + val selectorBlock = RenderMarshal.writeFeatureStateSelector(arena, selector) + val out = arena.allocate(RenderMarshal.OUT_SLOT_BYTES, RenderMarshal.OUT_SLOT_BYTES) + Status.check( + mln_render_session_get_feature_state(handle.raw, selectorBlock.address, out.address) + ) + Heap.loadLong(out) + } + } + return readJsonSnapshot(snapshot) ?: JsonValue.ObjectValue(emptyList()) + } + + public actual fun removeFeatureState(selector: FeatureStateSelector) { + activeFrame.ensureInactive("remove feature state") + live { + withArena(RenderMarshal.measureFeatureStateSelector(selector)) { arena -> + val selectorBlock = RenderMarshal.writeFeatureStateSelector(arena, selector) + Status.check(mln_render_session_remove_feature_state(handle.raw, selectorBlock.address)) + } + } + } + + public actual fun queryRenderedFeatures( + geometry: RenderedQueryGeometry, + options: RenderedFeatureQueryOptions?, + ): List { + activeFrame.ensureInactive("query rendered features") + val result = live { + val bytes = + RenderMarshal.measureRenderedQueryGeometry(geometry) + + RenderMarshal.measureRenderedFeatureQueryOptions(options) + + RenderMarshal.OUT_SLOT_BYTES.toLong() + withArena(bytes) { arena -> + val geometryBlock = RenderMarshal.writeRenderedQueryGeometry(arena, geometry) + val optionsBlock = RenderMarshal.writeRenderedFeatureQueryOptions(arena, options) + val out = arena.allocate(RenderMarshal.OUT_SLOT_BYTES, RenderMarshal.OUT_SLOT_BYTES) + Status.check( + mln_render_session_query_rendered_features( + handle.raw, + geometryBlock.address, + optionsBlock.address, + out.address, + ) + ) + Heap.loadLong(out) + } + } + return readFeatureQueryResult(result) + } + + public actual fun querySourceFeatures( + sourceId: String, + options: SourceFeatureQueryOptions?, + ): List { + activeFrame.ensureInactive("query source features") + val result = live { + val bytes = + RenderMarshal.measureStringViewRoot(sourceId) + + RenderMarshal.measureSourceFeatureQueryOptions(options) + + RenderMarshal.OUT_SLOT_BYTES.toLong() + withArena(bytes) { arena -> + // A source ID is a string view by value in C, which this target passes indirectly, so the + // argument is a pointer to the view rather than to its bytes. + val sourceBlock = RenderMarshal.writeStringViewRoot(arena, sourceId) + val optionsBlock = RenderMarshal.writeSourceFeatureQueryOptions(arena, options) + val out = arena.allocate(RenderMarshal.OUT_SLOT_BYTES, RenderMarshal.OUT_SLOT_BYTES) + Status.check( + mln_render_session_query_source_features( + handle.raw, + sourceBlock.address, + optionsBlock.address, + out.address, + ) + ) + Heap.loadLong(out) + } + } + return readFeatureQueryResult(result) + } + + public actual fun queryFeatureExtension( + sourceId: String, + feature: Feature, + extension: String, + extensionField: String, + arguments: JsonValue?, + ): FeatureExtensionResult { + activeFrame.ensureInactive("query feature extension") + val result = live { + val bytes = + RenderMarshal.measureStringViewRoot(sourceId) + + GeoJsonMarshal.measureFeature(feature).toLong() + + RenderMarshal.measureStringViewRoot(extension) + + RenderMarshal.measureStringViewRoot(extensionField) + + (arguments?.let { JsonMarshal.measureValue(it, 0) } ?: 0L) + + RenderMarshal.OUT_SLOT_BYTES.toLong() + withArena(bytes) { arena -> + val sourceBlock = RenderMarshal.writeStringViewRoot(arena, sourceId) + val featureBlock = GeoJsonMarshal.writeFeature(arena, feature) + val extensionBlock = RenderMarshal.writeStringViewRoot(arena, extension) + val fieldBlock = RenderMarshal.writeStringViewRoot(arena, extensionField) + // Absent arguments reach native as the null pointer the C API documents, not as an empty + // object, which would mean something else. + val argumentBlock = arguments?.let { JsonMarshal.write(arena, it) } ?: HeapPointer(0) + val out = arena.allocate(RenderMarshal.OUT_SLOT_BYTES, RenderMarshal.OUT_SLOT_BYTES) + Status.check( + mln_render_session_query_feature_extensions( + handle.raw, + sourceBlock.address, + featureBlock.address, + extensionBlock.address, + fieldBlock.address, + argumentBlock.address, + out.address, + ) + ) + Heap.loadLong(out) + } + } + return readFeatureExtensionResult(result) + } + + public actual fun textureImageInfo(): TextureImageInfo { + activeFrame.ensureInactive("read texture data") + return live { + Heap.withScratch(RenderMarshal.TEXTURE_IMAGE_INFO_SIZEOF) { out -> + RenderMarshal.writeTextureImageInfoHeader(out) + // A null destination with zero capacity is the C API's size probe: it fills the metadata + // and succeeds without copying. + val status = mln_texture_read_premultiplied_rgba8(handle.raw, 0, 0, out.address) + val info = RenderMarshal.readTextureImageInfo(out) + // A backend that answered with a length still answered the question the caller asked, even + // where it also rejected the empty destination. + val answered = + status == MaplibreStatus.OK.nativeCode || + (status == MaplibreStatus.INVALID_ARGUMENT.nativeCode && info.byteLength > 0L) + if (!answered) Status.check(status) + info + } + } + } + + public actual fun readPremultipliedRgba8(buffer: NativeBuffer): TextureImageInfo { + activeFrame.ensureInactive("read texture data") + return live { + Heap.withScratch(RenderMarshal.TEXTURE_IMAGE_INFO_SIZEOF) { out -> + RenderMarshal.writeTextureImageInfoHeader(out) + buffer.borrow { pixels, length -> + Status.check( + mln_texture_read_premultiplied_rgba8( + handle.raw, + pixels.address, + length.toInt(), + out.address, + ) + ) + } + val info = RenderMarshal.readTextureImageInfo(out) + // An empty destination reaches native as the null pointer and zero capacity that mean a + // size probe, which succeeds without copying, so recheck the capacity here. + buffer.ensureCapacity(info.byteLength) + info + } + } + } + + public actual fun acquireMetalOwnedTextureFrame(): MetalOwnedTextureFrameHandle = + throw unsupportedBackend("Metal") + + public actual fun acquireVulkanOwnedTextureFrame(): VulkanOwnedTextureFrameHandle = + throw unsupportedBackend("Vulkan") + + /** + * Takes the session's next frame, giving it back to native if this cannot hand it to the caller. + * + * Copying the descriptor into a Kotlin value and wrapping it is object construction, which fails + * on an exhausted Kotlin heap, and a session holding a frame no handle can release refuses to + * render, resize, detach, and close for the life of the host. So the release below is made from + * the descriptor native just filled, before the scratch is freed, rather than from the value that + * failed to be built; the C API matches a release by the frame's generation and frame id rather + * than by the address they arrive through. + */ + public actual fun acquireOpenGLOwnedTextureFrame(): OpenGLOwnedTextureFrameHandle { + activeFrame.beginAcquire() + try { + return live { + Heap.withScratch(RenderMarshal.OPENGL_OWNED_TEXTURE_FRAME_SIZEOF) { out -> + RenderMarshal.writeOpenGLFrameHeader(out) + Status.check(mln_opengl_owned_texture_acquire_frame(handle.raw, out.address)) + try { + // The seam for the exhaustion this window cannot be put into on request; see + // InjectedFaults. Armed or real, the recovery below is the same one. + InjectedFaults.beginFrameWrap(RenderMarshal.OPENGL_OWNED_TEXTURE_FRAME_SIZEOF) + val scope = FrameScope() + OpenGLOwnedTextureFrameHandle(this, scope, RenderMarshal.readOpenGLFrame(out, scope)) + } catch (error: Throwable) { + FrameAcquirePolicy.cleanupAfterWrapperFailure( + acquired = true, + releaseNative = { + Status.check(mln_opengl_owned_texture_release_frame(handle.raw, out.address)) + }, + // The borrow is the outer catch's, which sees this failure too. + closeLocal = {}, + failure = error, + ) + } + } + } + } catch (error: Throwable) { + activeFrame.endBorrow() + throw error + } + } + + public actual override fun close() { + activeFrame.ensureInactive("destroy") + core.closeOnce( + destroy = { mln_render_session_destroy(handle.raw) }, + // Both retentions are idempotent, because a detach released them already. Released after the + // destroy, because destroying is itself GL work in the context this holds. + afterSuccess = { + mapRetention.close() + contextRetention?.close() + }, + ) + } + + internal fun releaseOpenGLFrame(frame: OpenGLOwnedTextureFrame) { + live { + Heap.withScratch(RenderMarshal.OPENGL_OWNED_TEXTURE_FRAME_SIZEOF) { descriptor -> + RenderMarshal.writeOpenGLFrame(descriptor, frame) + // A native release refuses nothing a host can ask for, so BND-169's retry is reachable + // only through the seam. + InjectedFaults.beginCall("mln_opengl_owned_texture_release_frame") + Status.check(mln_opengl_owned_texture_release_frame(handle.raw, descriptor.address)) + } + } + } + + internal fun finishFrameBorrow() { + activeFrame.endBorrow() + } + + /** + * Takes one measured block and carves [body]'s descriptors out of it. + * + * The block starts zeroed, so an output slot inside it is already the null handle the C API + * requires callers to pass. + */ + private fun withArena(bytes: Long, body: (HeapArena) -> T): T { + Status.requireArgument(bytes in 1..Int.MAX_VALUE.toLong()) { + "a descriptor block must be positive and addressable on this target" + } + val size = bytes.toInt() + return Heap.withScratch(size) { block -> body(HeapArena(block, size)) } + } + + /** Copies a query result and destroys it. A result is a snapshot with no owner thread. */ + private fun readFeatureQueryResult(result: Long): List { + try { + InjectedFaults.beginResultCopy(result, SIZE_BYTES) + val count = + Heap.withScratch(SIZE_BYTES) { out -> + Status.check(mln_feature_query_result_count(result, out.address)) + Heap.loadInt(out) + } + // A count is `size_t`, thirty-two bits here, so a negative one means the handle read is not + // the result it was taken for. + if (count < 0) { + throw Status.invalidState( + "The MapLibre Native browser module reported a query result count of $count" + ) + } + if (count == 0) return emptyList() + return Heap.withScratch(RenderMarshal.QUERIED_FEATURE_SIZEOF) { out -> + List(count) { index -> + // Rewritten every iteration, not only once: the size field is what tells native which + // fields it may fill, and the block is reused across features. + RenderMarshal.writeQueriedFeatureHeader(out) + Status.check(mln_feature_query_result_get(result, index, out.address)) + // Copied before the result is destroyed below: every string and JSON value in it is a + // view into storage the destroy frees. + RenderMarshal.readQueriedFeature(out) + } + } + } finally { + mln_feature_query_result_destroy(result) + } + } + + private fun readFeatureExtensionResult(result: Long): FeatureExtensionResult { + try { + InjectedFaults.beginResultCopy(result, RenderMarshal.FEATURE_EXTENSION_RESULT_INFO_SIZEOF) + return Heap.withScratch(RenderMarshal.FEATURE_EXTENSION_RESULT_INFO_SIZEOF) { out -> + RenderMarshal.writeFeatureExtensionResultInfoHeader(out) + Status.check(mln_feature_extension_result_get(result, out.address)) + RenderMarshal.readFeatureExtensionResultInfo(out) + } + } finally { + mln_feature_extension_result_destroy(result) + } + } + + private fun readJsonSnapshot(snapshot: Long): JsonValue? { + // The C API reports an absent value as the null snapshot rather than as a failure. + if (snapshot == 0L) return null + try { + InjectedFaults.beginResultCopy(snapshot, POINTER_BYTES) + return Heap.withScratch(POINTER_BYTES) { out -> + Status.check(mln_json_snapshot_get(snapshot, out.address)) + RenderMarshal.readJsonPointer(HeapPointer(Heap.loadInt(out))) + } + } finally { + mln_json_snapshot_destroy(snapshot) + } + } + + private fun unsupportedBackend(backend: String): UnsupportedFeatureException = + UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "$backend render targets are not supported by the browser build of MapLibre Native, " + + "which compiles OpenGL against WebGL", + ) + + internal companion object { + private const val TYPE_NAME = "RenderSessionHandle" + private const val BOOL_BYTES = 1 + private const val POINTER_BYTES = 4 + private const val SIZE_BYTES = 4 + + fun fromNative( + map: MapHandle, + handle: NativeRenderSession, + contextRetention: HandleStateCore.ChildRetention?, + ): RenderSessionHandle = RenderSessionHandle(map, handle, contextRetention) + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/RenderTargetExtentWasmJs.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/RenderTargetExtentWasmJs.kt new file mode 100644 index 000000000..0c1a235c8 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/RenderTargetExtentWasmJs.kt @@ -0,0 +1,37 @@ +package org.maplibre.nativeffi.render + +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.generated.MlnRenderTargetExtent +import org.maplibre.nativeffi.internal.wasm.generated.mln_render_target_extent_physical_size + +// The two output words follow the descriptor in one allocation, so this costs one scratch +// acquisition rather than three. +private val OUT_WIDTH_OFFSET = MlnRenderTargetExtent.SIZEOF +private val OUT_HEIGHT_OFFSET = MlnRenderTargetExtent.SIZEOF + 4 +private val SCRATCH_SIZE = MlnRenderTargetExtent.SIZEOF + 8 + +/** + * Calls the C API's own scaling, so a browser host derives the same physical size as every other + * platform rather than repeating the formula and rounding differently. + */ +public actual fun RenderTargetExtent.physicalSize(): PhysicalRenderTargetSize = + Heap.withScratch(SCRATCH_SIZE) { scratch -> + // The leading `size` field is how the C API versions a descriptor, so it carries the size the + // binding was generated against rather than being left zero. + MlnRenderTargetExtent.setSize(scratch, MlnRenderTargetExtent.SIZEOF) + MlnRenderTargetExtent.setWidth(scratch, width) + MlnRenderTargetExtent.setHeight(scratch, height) + MlnRenderTargetExtent.setScaleFactor(scratch, scaleFactor) + Status.check( + mln_render_target_extent_physical_size( + scratch.address, + (scratch + OUT_WIDTH_OFFSET).address, + (scratch + OUT_HEIGHT_OFFSET).address, + ) + ) + PhysicalRenderTargetSize( + Heap.loadInt(scratch + OUT_WIDTH_OFFSET), + Heap.loadInt(scratch + OUT_HEIGHT_OFFSET), + ) + } diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/VulkanOwnedTextureFrameHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/VulkanOwnedTextureFrameHandle.kt new file mode 100644 index 000000000..566b14ff9 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/VulkanOwnedTextureFrameHandle.kt @@ -0,0 +1,29 @@ +package org.maplibre.nativeffi.render + +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException + +/** + * Vulkan session-owned texture frames do not exist in a browser build. + * + * MapLibre Native compiles one render backend per build, and the browser target compiles OpenGL + * against WebGL. Nothing in this build can attach a Vulkan render target, so nothing can produce + * one of these frames. The type exists because the common API declares it, and it has no + * constructor a caller could reach -- which is the binding reporting the build's real capability + * rather than inventing a rule of its own. + */ +public actual class VulkanOwnedTextureFrameHandle private constructor() : AutoCloseable { + public actual fun frame(): VulkanOwnedTextureFrame = + throw UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "Vulkan render targets are not supported by the browser build of MapLibre Native", + ) + + /** Always closed: no instance is reachable, so none is ever open. */ + public actual val isClosed: Boolean + get() = true + + public actual override fun close() { + // Unreachable; no instance exists. + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/WebglContext.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/WebglContext.kt new file mode 100644 index 000000000..aa1988daa --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/render/WebglContext.kt @@ -0,0 +1,379 @@ +package org.maplibre.nativeffi.render + +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_canvas_create +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_canvas_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_canvas_resize +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_context_create +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_context_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_present_texture +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_read_pixels +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_texture_create +import org.maplibre.nativeffi.internal.wasm.generated.mln_kotlin_webgl_texture_destroy + +/** What the release bookkeeping calls this, and what a failure blaming it names. */ +private const val TYPE_NAME = "WebglContext" + +/** + * A WebGL context on the thread this binding runs on, and the GL work a host does in it. + * + * Every other platform hands a render target a context the host made with its own graphics API, and + * a browser host cannot make one at all: the handle a [WebglContextDescriptor] carries indexes the + * Emscripten module's own context table, so a context the page created with + * `canvas.getContext("webgl2")` names nothing native can look up. A WebGL context also belongs to + * the agent that created it, and this binding renders on the module's own thread rather than on the + * page. So on a desktop or a phone the graphics API is EGL, Metal, or Vulkan, which the host + * genuinely owns; in a browser it is this module, and the context is the binding's to make. + * + * WebGL has no share groups, so a texture belongs to the one context it was made in. A host that + * wants a texture for a caller-owned target, or wants a rendered one on the page, issues those + * calls here — [createTexture], [presentTexture], [readPixels]. + * + * The canvas behind a context is either the one page canvas ([createForPageCanvas]) or a private + * `OffscreenCanvas` that nothing displays ([createOffscreen]). Closing releases the context and, + * with it, every texture made in it. There is no finalizer behind that: a browser host cannot + * recover leaked GPU resources by restarting a process. A render target borrows the handle for its + * whole life, so a context with a target attached refuses to close and names the render session + * instead. + */ +public class WebglContext +private constructor( + private val handle: Int, + private val canvas: String, + private val page: Boolean, +) : AutoCloseable { + private val core = HandleStateCore(TYPE_NAME, handle.toLong()) + + /** + * What a descriptor carries so that it names this context rather than only its number. + * + * A separate object because [WebglContextOwner] is internal and a public class may not expose an + * internal supertype. + */ + private val identity = Identity(this) + + /** Reports whether this context has been released. */ + public val isClosed: Boolean + get() = core.isReleased() + + /** + * Returns a descriptor naming this context, for a render target to be attached with. + * + * The descriptor names this object and not merely its handle, which is what makes it safe to hold + * on to. Emscripten frees a context handle when its context is destroyed and gives the number to + * the next context created, so a descriptor carrying only the number would, once this context is + * closed, start naming whichever context inherited it. + */ + public fun descriptor(): WebglContextDescriptor { + core.requireLive() + return WebglContextDescriptor(handle, identity) + } + + /** + * Sizes the drawing buffer of the canvas this context was created against. + * + * A surface target renders into that canvas's default framebuffer, and the framebuffer is only as + * large as the canvas, so changing such a target's extent means changing both: this, and then + * [RenderSessionHandle.resize] or [RenderSessionHandle.setOpenGLSurfaceTarget] with the matching + * extent. Neither implies the other — the session's extent is what MapLibre lays a frame out for, + * and this is what the frame has room to land in. + * + * The context's contents survive: resizing reallocates the drawing buffer and nothing else, so + * every texture, buffer, and program the session built stays as it was. + */ + public fun resizeCanvas(width: Int, height: Int) { + requireExtent(width, height) + core.requireLive() + val resized = withCanvasName { name -> + mln_kotlin_webgl_canvas_resize(name, width, height) != 0 + } + if (!resized) { + throw Status.invalidState( + "The MapLibre Native browser module could not size the canvas \"$canvas\" to ${width}x$height." + ) + } + } + + /** + * Creates an RGBA8 texture in this context, for a caller-owned render target to draw into. + * + * The texture belongs to this context and to no other, so it is named in a descriptor that names + * this context. It is the host's: nothing tracks it, a render target only borrows it, and + * [destroyTexture] is what releases it — before this context is closed, or with it, since closing + * a context releases everything made in it. + */ + public fun createTexture(width: Int, height: Int): Int { + requireExtent(width, height) + core.requireLive() + val texture = mln_kotlin_webgl_texture_create(handle, width, height) + if (texture == 0) { + throw Status.invalidState( + "The MapLibre Native browser module could not create a ${width}x$height texture." + ) + } + return texture + } + + /** Releases a texture from [createTexture], once no target borrows it any more. */ + public fun destroyTexture(texture: Int) { + core.requireLive() + mln_kotlin_webgl_texture_destroy(handle, texture) + } + + /** + * Puts a texture this context owns onto the canvas this context draws to. + * + * A texture target renders into a framebuffer of its own, so something has to move those pixels + * onto the canvas's default framebuffer. Native blits them there, which keeps them in GPU memory: + * they are never read back, never enter the module's heap, and never cross into JavaScript. A + * surface target needs none of this, because it already renders into that framebuffer. + * + * [texture] is a name from [createTexture] for a caller-owned target, or + * [OpenGLOwnedTextureFrame.texture] for a session-owned one, and [width] and [height] are its + * size in device pixels. + * + * The frame becomes visible on the next turn of this thread's event loop rather than as this + * returns: a browser composites a canvas when the task that drew into it ends. + */ + public fun presentTexture(texture: Int, width: Int, height: Int) { + Status.requireArgument(texture != 0) { "texture must name a texture" } + requireExtent(width, height) + core.requireLive() + if (mln_kotlin_webgl_present_texture(handle, texture, width, height) == 0) { + throw Status.invalidState( + "The MapLibre Native browser module could not present texture $texture at ${width}x$height." + ) + } + } + + /** + * Reads a rendered frame out of this context, as RGBA8 with row zero at the bottom. + * + * [texture] names a texture of this context, or is zero for the default framebuffer of the canvas + * the context is bound to — which is what a surface target renders into and what [presentTexture] + * blits onto. + * + * This is the expensive way to use a frame: it stalls this thread until the GPU is done, and it + * copies every pixel through the module's heap. A host that only wants the frame seen presents it + * instead. Reading back is for a host that consumes the pixels itself — encoding an image, + * comparing two frames — and for telling a frame that was never drawn from a frame that was drawn + * and never composited, which are the same symptom from the page's side and completely different + * underneath. + */ + public fun readPixels(texture: Int, width: Int, height: Int): ByteArray { + requireExtent(width, height) + core.requireLive() + // Multiplied in Long rather than as an Int product of two caller-supplied extents, whose + // product for a 20000-by-20000 read wraps to a small allocation that native is then handed the + // real extents for. + val pixelCount = width.toLong() * height.toLong() + Status.requireArgument(pixelCount <= MAX_READBACK_PIXELS) { + "a ${width}x$height frame is $pixelCount pixels, and a readback can address at most " + + "$MAX_READBACK_PIXELS on this target" + } + val bytes = (pixelCount * BYTES_PER_PIXEL).toInt() + return Heap.withScratch(bytes) { pixels -> + if ( + mln_kotlin_webgl_read_pixels(handle, texture, width, height, pixels.address, bytes) == 0 + ) { + throw Status.invalidState( + "The MapLibre Native browser module could not read a ${width}x$height frame." + ) + } + Heap.loadBytes(pixels, bytes) + } + } + + override fun close() { + core.closeOnce( + // Destroying a context reports nothing: a handle that names no context on this thread is + // already the state a close is asking for. + destroy = { + mln_kotlin_webgl_context_destroy(handle) + MaplibreStatus.OK.nativeCode + }, + afterSuccess = { + if (page) { + // The page still displays the element, and a canvas reaches this thread only as it is + // created, so the registration outlives every context made against it. + pageCanvas = null + } else { + withName(canvas, ::mln_kotlin_webgl_canvas_destroy) + } + }, + ) + } + + private fun withCanvasName(body: (Int) -> T): T = withName(canvas, body) + + private class Identity(val context: WebglContext) : WebglContextOwner + + public companion object { + /** + * Creates a WebGL2 context against the canvas the page displays. + * + * There is one such canvas. A page transfers it to this thread as the module is instantiated, + * by passing an `OffscreenCanvas` as the module option `mlnPageCanvas`, and a canvas can be + * transferred to a thread only as that thread is created — so the number of on-screen maps is + * fixed before any Kotlin runs. A host that transferred nothing gets a placeholder canvas that + * nothing displays, and a second live context for the page canvas is refused rather than + * silently drawing where the first one draws. + * + * [width] and [height] size the canvas's drawing buffer in device pixels, which for a surface + * target is that target's physical extent and for a texture target is the size anything + * [presentTexture] shows must fit. + */ + public fun createForPageCanvas(width: Int, height: Int): WebglContext { + requireExtent(width, height) + pageCanvas?.let { + throw Status.invalidState( + "The page canvas already has a WebGL context. This build supports one on-screen canvas, " + + "transferred to the render thread as the module was instantiated, so a second " + + "on-screen map is not something the binding can create. Close the first context, or " + + "render the second map to a texture." + ) + } + val context = create(PAGE_CANVAS, width, height, page = true) + pageCanvas = context + return context + } + + /** + * Creates a WebGL2 context against a private `OffscreenCanvas` on this thread. + * + * Nothing displays that canvas, which is what a host that reads frames back wants, and there is + * no limit on how many there are. A texture target renders into a framebuffer of its own rather + * than into the canvas, so for one the size bounds nothing the map draws and only has to be + * positive, because a zero-sized canvas has no drawing buffer to create a context against. + */ + public fun createOffscreen(width: Int, height: Int): WebglContext { + requireExtent(width, height) + val name = "mln-offscreen-${offscreenCanvases++}" + if ( + withName(name) { address -> mln_kotlin_webgl_canvas_create(address, width, height) } == 0 + ) { + throw Status.invalidState( + "The MapLibre Native browser module could not create a ${width}x$height offscreen canvas." + ) + } + try { + return create(name, width, height, page = false) + } catch (error: Throwable) { + // The canvas outlives a context that could not be created against it, and nothing else + // holds a name this method invented. + withName(name, ::mln_kotlin_webgl_canvas_destroy) + throw error + } + } + + /** + * Retains the context a render target is about to borrow, for as long as that target lives. + * + * A render target names its context by handle for its whole life, and the backend makes that + * handle current on every frame and again while it tears its GL objects down. Destroying the + * context underneath it would leave a live target naming a context that is gone, so the target + * holds the context open the way a render session holds its map open, and closing the context + * first reports the live child instead. + * + * Returns null for the WGL and EGL arms, which name a context from a graphics API this module + * was not built against. Nothing here can retain one, and native is where a build's capability + * is known, so those are passed down and refused there. + */ + internal fun retainForTarget( + context: OpenGLContextDescriptor + ): HandleStateCore.ChildRetention? { + val owner = requireOpenForTarget(context) ?: return null + return owner.core.retainChild("RenderSessionHandle") + } + + /** + * Reports that [context] still names an open context, and returns the one it names. + * + * The context comes off the descriptor rather than out of a lookup by handle, and that is what + * makes a stale descriptor safe. Emscripten's handles are allocated and freed, so the number in + * a descriptor from a closed context is one a later context can be given; a lookup by number + * would find that later context and hand a render target a context the host never named. + * + * Asked at every entry point that hands native a context descriptor, not only at attach. Native + * compares a WebGL descriptor by its handle alone — `opengl_context_matches` in + * `src/render/render_session_common.cpp` has nothing else to compare — so a retarget whose + * descriptor came from a closed context whose number has since come back would be accepted + * there, and the texture beside it would belong to whatever inherited the number. + * + * Returns null for the WGL and EGL arms, which this build was not compiled against and which + * carry no object to resolve; native is where a missing provider is refused. + */ + internal fun requireOpenForTarget(context: OpenGLContextDescriptor): WebglContext? { + if (context !is WebglContextDescriptor) return null + // Total, because the descriptor's constructor is internal and descriptor() is its one caller. + val owner = (context.owner as Identity).context + if (owner.isClosed) { + throw Status.invalidArgument( + "A WebGL context descriptor names a WebglContext that has been closed. A render target's " + + "context comes from WebglContext.descriptor() and stays open until every target that " + + "borrowed it is detached or closed; the handle ${context.context} it carries may " + + "since have been reused by another context, which is why this is refused rather than " + + "resolved." + ) + } + return owner + } + + private fun create(name: String, width: Int, height: Int, page: Boolean): WebglContext { + val handle = + withName(name) { address -> mln_kotlin_webgl_context_create(address, width, height) } + // Zero is what the module reports for a context it could not create, and it is also the value + // the C API refuses in a descriptor, so it becomes a failure here rather than a handle that + // fails at attach. + if (handle == 0) { + throw Status.invalidState( + "The MapLibre Native browser module could not create a ${width}x$height WebGL2 context " + + "for the canvas \"$name\". The browser may have no WebGL2 support, or too many " + + "contexts may be live." + ) + } + return WebglContext(handle, name, page) + } + + /** Runs [body] with [name] staged in the module's heap, which is how the shim takes one. */ + private fun withName(name: String, body: (Int) -> T): T = + Heap.withScratch(Heap.utf8Size(name)) { address -> + Heap.storeUtf8(address, name) + body(address.address) + } + + private fun requireExtent(width: Int, height: Int) { + Status.requireArgument(width > 0) { "width must be positive, but was $width" } + Status.requireArgument(height > 0) { "height must be positive, but was $height" } + } + + /** + * The context holding the page canvas, so a second one is refused rather than made. + * + * A plain field because this binding runs on one thread: the module's `main()` imported Kotlin + * into it, and nothing here is reachable from another agent. + */ + private var pageCanvas: WebglContext? = null + + /** Names offscreen canvases apart. Never reused, so a stale name cannot find a live canvas. */ + private var offscreenCanvases = 0 + + /** + * The registry key the page canvas is transferred under. + * + * Fixed at link time by `-sOFFSCREENCANVASES_TO_PTHREAD` in + * `cmake/mln_ffi_browser_module.cmake` and registered by + * `bindings/kotlin/emscripten/mln_kotlin_pre.js`, which is why a host names no canvas here. + */ + private const val PAGE_CANVAS = "maplibre" + + /** RGBA8, which is what a readback produces and what native writes. */ + private const val BYTES_PER_PIXEL = 4 + + /** The largest frame a readback can stage, which is what a 32-bit pointer leaves room for. */ + private const val MAX_READBACK_PIXELS = Int.MAX_VALUE / BYTES_PER_PIXEL + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/resource/QueuedResourceProvider.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/resource/QueuedResourceProvider.kt new file mode 100644 index 000000000..37ff5d002 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/resource/QueuedResourceProvider.kt @@ -0,0 +1,40 @@ +package org.maplibre.nativeffi.resource + +/** + * One route that a queued resource provider claims. + * + * A route compares [url] against the request's resolved URL, or against its requested URL when + * [useRequestedUrl] is set. With [matchGlob] the comparison reads [url] as the glob pattern + * language the C API reference defines. A null [kind] matches every resource kind. + */ +public class ResourceProviderRoute( + public val url: String, + public val kind: ResourceKind? = null, + public val matchGlob: Boolean = false, + public val useRequestedUrl: Boolean = false, +) + +/** + * Receives the requests that a queued resource provider's routes claim. + * + * The binding invokes this from `pump`, on the thread the runtime runs on, rather than on the + * MapLibre thread that produced the request. Complete or close [handle] to answer, from this call + * or from a later one. + */ +public fun interface QueuedResourceProviderCallback { + public fun handle(request: ResourceRequest, handle: ResourceRequestHandle) +} + +/** + * One rule in a resource URL rewrite table. + * + * A rule compares [url] against the request URL, reading it as a glob pattern when [matchGlob] is + * set, and the first matching rule replaces the URL with [replacementUrl]. A null [replacementUrl] + * leaves the URL unchanged, and a null [kind] matches every resource kind. + */ +public class ResourceUrlRewriteRule( + public val url: String, + public val replacementUrl: String?, + public val kind: ResourceKind? = null, + public val matchGlob: Boolean = false, +) diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandle.kt new file mode 100644 index 000000000..f93fbff4c --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/resource/ResourceRequestHandle.kt @@ -0,0 +1,80 @@ +package org.maplibre.nativeffi.resource + +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.lifecycle.NativeResourceRequest +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.ResourceMarshal +import org.maplibre.nativeffi.internal.wasm.generated.mln_resource_request_cancelled +import org.maplibre.nativeffi.internal.wasm.generated.mln_resource_request_complete +import org.maplibre.nativeffi.internal.wasm.generated.mln_resource_request_release + +/** + * Owned browser handle for a resource provider request. + * + * A request reaches host code from the ring drain, already claimed by the route that matched it, so + * this handle owns the native request from the moment it is built. Kotlin/Wasm has no finalization: + * a handle that is neither completed nor closed keeps MapLibre waiting for a response for as long + * as the page lives. + */ +public actual class ResourceRequestHandle +private constructor(private val request: NativeResourceRequest) : AutoCloseable { + private val core = ResourceRequestHandleCore { mln_resource_request_release(request.raw) } + + public actual fun complete(response: ResourceResponse) { + val operation = core.beginComplete() + var reachedNative = false + try { + val nativeStatus = + ResourceMarshal.withResponse(response) { descriptor -> + // Set once the call has come back, because acquiring the block the response is placed in + // can fail on an exhausted heap. That is a completion that never reached C, and the + // request is still the host's one chance to answer. + mln_resource_request_complete(request.raw, descriptor.address).also { + reachedNative = true + } + } + val nativeFailure = + if (nativeStatus == MaplibreStatus.OK.nativeCode) null else Status.exception(nativeStatus) + // Marked completed whatever native answered, because a rejected completion has still used up + // the request's one chance to be answered. + operation.markCompleted() + nativeFailure?.let { throw it } + } catch (error: Throwable) { + if (reachedNative) { + operation.markCompleted() + } else { + operation.markNotReachedNative() + } + throw error + } finally { + operation.close() + } + } + + public actual fun isCancelled(): Boolean = core.withLiveHandle { + Heap.withScratch(BOOLEAN_BYTES) { outCancelled -> + Status.check(mln_resource_request_cancelled(request.raw, outCancelled.address)) + Heap.loadByte(outCancelled) != 0.toByte() + } + } + + public actual override fun close() { + core.close() + } + + internal companion object { + /** + * Wraps the request a queued provider route claimed. + * + * The route decided handled ownership before the request reached host code, so the native + * request belongs to this wrapper and completing or closing it is what releases the request. + */ + fun forQueuedRequest(request: NativeResourceRequest): ResourceRequestHandle = + ResourceRequestHandle(request).also { + it.core.finishProviderDecision(ResourceProviderDecision.HANDLE) + } + + private const val BOOLEAN_BYTES: Int = 1 + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/OfflineOperationHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/OfflineOperationHandle.kt new file mode 100644 index 000000000..b2e361ed6 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/OfflineOperationHandle.kt @@ -0,0 +1,91 @@ +package org.maplibre.nativeffi.runtime + +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore + +/** + * One offline database operation the runtime's owner thread started. + * + * The wrapper owns nothing native beyond an id, so nothing here is dispatched: every call that + * reaches the C API goes through the runtime, which is what places it on the owner thread. What + * this does own is the requirement that the operation is eventually taken or discarded, because + * until then the runtime holds its result. + * + * It retains its runtime for its whole life, so closing a runtime with operations still outstanding + * reports them rather than stranding results the host can no longer reach. + * + * The other platforms also register a leak report here, which non-deterministic cleanup runs if a + * host drops the wrapper. A browser has no such cleanup to hang one on, so a dropped operation is + * simply held by the runtime until the runtime itself is closed. + */ +public actual class OfflineOperationHandle +internal constructor( + private val runtime: RuntimeHandle, + public actual val id: Long, + public actual val kind: OfflineOperationKind, + public actual val resultKind: OfflineOperationResultKind, +) : AutoCloseable { + private val runtimeRetention: HandleStateCore.ChildRetention = + runtime.retainChild("OfflineOperationHandle") + private var closed = false + + init { + require(id != 0L) { "offline operation id must not be zero" } + } + + public actual val isClosed: Boolean + get() = closed + + /** Reports this operation's id, refusing a wrapper that belongs to another runtime. */ + internal fun requireLive(expectedRuntime: RuntimeHandle): Long { + if (closed) { + throw InvalidStateException( + MaplibreStatus.INVALID_STATE.nativeCode, + "OfflineOperationHandle is already closed", + ) + } + // An id names one operation within one runtime, so passing this to another runtime would take + // whatever operation happens to carry the same id there. + if (runtime !== expectedRuntime) { + throw InvalidStateException( + MaplibreStatus.INVALID_STATE.nativeCode, + "OfflineOperationHandle belongs to a different RuntimeHandle", + ) + } + return id + } + + /** + * Reports this operation's id, refusing a wrapper whose result is a different shape. + * + * The kinds are what make the take methods type-safe: the wrapper's type parameter is erased, so + * this is what stops a region-list result being taken as a status. + */ + internal fun requireLive( + expectedRuntime: RuntimeHandle, + expectedKind: OfflineOperationKind, + expectedResultKind: OfflineOperationResultKind, + ): Long { + val operationId = requireLive(expectedRuntime) + if (kind != expectedKind || resultKind != expectedResultKind) { + throw InvalidStateException( + MaplibreStatus.INVALID_STATE.nativeCode, + "OfflineOperationHandle has kind $kind/$resultKind, expected $expectedKind/$expectedResultKind", + ) + } + return operationId + } + + /** Retires this wrapper once native no longer holds a result for it. */ + internal fun markConsumed() { + if (closed) return + closed = true + runtimeRetention.close() + } + + public actual override fun close() { + if (closed) return + runtime.discardOfflineOperation(this) + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/RuntimeHandle.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/RuntimeHandle.kt new file mode 100644 index 000000000..605a51001 --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/RuntimeHandle.kt @@ -0,0 +1,655 @@ +package org.maplibre.nativeffi.runtime + +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.internal.callback.CallbackRing +import org.maplibre.nativeffi.internal.callback.QueuedResourceProviders +import org.maplibre.nativeffi.internal.callback.ResourceRewriteRules +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore +import org.maplibre.nativeffi.internal.lifecycle.NativeOfflineRegionList +import org.maplibre.nativeffi.internal.lifecycle.NativeOfflineRegionSnapshot +import org.maplibre.nativeffi.internal.lifecycle.NativeRuntime +import org.maplibre.nativeffi.internal.lifecycle.NativeWakeSource +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.OfflineMarshal +import org.maplibre.nativeffi.internal.wasm.RuntimeEventMarshal +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineRegionInfo +import org.maplibre.nativeffi.internal.wasm.generated.MlnOfflineRegionStatus +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEvent +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeOptions +import org.maplibre.nativeffi.internal.wasm.generated.mln_offline_region_list_count +import org.maplibre.nativeffi.internal.wasm.generated.mln_offline_region_list_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_offline_region_list_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_offline_region_snapshot_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_offline_region_snapshot_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_clear_http_header_transform +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_create +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_destroy +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_operation_discard +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_create_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_create_take_result +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_delete_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_get_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_get_status_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_get_status_take_result +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_get_take_result +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_invalidate_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_set_download_state_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_set_observed_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_update_metadata_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_region_update_metadata_take_result +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_regions_list_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_regions_list_take_result +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_regions_merge_database_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_offline_regions_merge_database_take_result +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_poll_event +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_pump +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_run_ambient_cache_operation_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_set_maximum_ambient_cache_size_start +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_wake_source_acquire +import org.maplibre.nativeffi.internal.wasm.generated.mln_wake_source_destroy +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.offline.OfflineRegionDefinition +import org.maplibre.nativeffi.offline.OfflineRegionDownloadState +import org.maplibre.nativeffi.offline.OfflineRegionInfo +import org.maplibre.nativeffi.offline.OfflineRegionStatus +import org.maplibre.nativeffi.resource.HttpHeaderTransformCallback +import org.maplibre.nativeffi.resource.QueuedResourceProviderCallback +import org.maplibre.nativeffi.resource.ResourceProviderCallback +import org.maplibre.nativeffi.resource.ResourceProviderRoute +import org.maplibre.nativeffi.resource.ResourceTransformCallback +import org.maplibre.nativeffi.resource.ResourceUrlRewriteRule + +/** Bytes one C API handle occupies. Handles are 64-bit whatever a pointer is on this target. */ +private const val HANDLE_BYTES = 8 + +/** What the failure for closing a parent that still has children calls this wrapper. */ +private const val TYPE_NAME = "RuntimeHandle" + +/** Bytes a `size_t` and a `bool` occupy on wasm32. */ +private const val SIZE_BYTES = 4 +private const val BOOL_BYTES = 1 + +/** + * An owned runtime, on the thread this binding runs. + * + * Kotlin/Wasm runs on the Emscripten pthread that the module's `main()` imported it into, where + * blocking is legal, so every call here is an ordinary synchronous C call made from the runtime's + * owner thread. [pump] is the one that parks, and it is also where this binding delivers the + * callbacks that MapLibre's own threads produced. + */ +public actual class RuntimeHandle private constructor(private val handle: NativeRuntime) : + AutoCloseable { + private val core = HandleStateCore(TYPE_NAME, handle.raw) + private val rewriteRules = ResourceRewriteRules() + + /** The wake source the record ring signals, so a queued record releases a parked [pump]. */ + private var ringWake: Long = 0 + + init { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(mln_runtime_wake_source_acquire(handle.raw, out.address)) + ringWake = Heap.loadLong(out) + } + CallbackRing.setWake(ringWake) + } + + /** Checks this handle is live and then runs [body]; native refuses a stale handle itself. */ + private inline fun live(body: () -> T): T { + core.requireLive() + return body() + } + + public actual val isClosed: Boolean + get() = core.isReleased() + + public actual fun pump(timeoutMillis: Long) { + live { + Status.check(mln_runtime_pump(handle.raw, timeoutMillis)) + // The one place a host gives this binding its thread back, and so the only place a callback + // MapLibre raised on another thread reaches host code. + CallbackRing.drain() + } + } + + public actual fun acquireWakeSource(): WakeSource = live { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(mln_runtime_wake_source_acquire(handle.raw, out.address)) + WakeSource.fromNative(NativeWakeSource(Heap.loadLong(out))) + } + } + + public actual fun startAmbientCacheOperation( + operation: AmbientCacheOperation + ): OfflineOperationHandle = + startOperation(OfflineOperationKind.AMBIENT_CACHE, OfflineOperationResultKind.NONE) { out -> + mln_runtime_run_ambient_cache_operation_start(handle.raw, operation.nativeValue, out.address) + } + + public actual fun startSetMaximumAmbientCacheSize(size: Long): OfflineOperationHandle { + // Unsigned in C, so a negative value would arrive as an enormous budget rather than a mistake. + Status.requireArgument(size >= 0) { "size must be non-negative" } + return startOperation( + OfflineOperationKind.SET_MAXIMUM_AMBIENT_CACHE_SIZE, + OfflineOperationResultKind.NONE, + ) { out -> + mln_runtime_set_maximum_ambient_cache_size_start(handle.raw, size, out.address) + } + } + + public actual fun startCreateOfflineRegion( + definition: OfflineRegionDefinition, + metadata: ByteArray, + ): OfflineOperationHandle { + // A definition is a tree, and the arena carves it out of one allocation rather than many. + val definitionBytes = OfflineMarshal.measureDefinition(definition) + return Heap.withScratch(definitionBytes) { scratch -> + val root = OfflineMarshal.writeDefinition(HeapArena(scratch, definitionBytes), definition) + withMetadata(metadata) { metadataBytes -> + startOperation(OfflineOperationKind.REGION_CREATE, OfflineOperationResultKind.REGION) { out + -> + mln_runtime_offline_region_create_start( + handle.raw, + root.address, + metadataBytes.address, + metadata.size, + out.address, + ) + } + } + } + } + + public actual fun startOfflineRegion(id: Long): OfflineOperationHandle = + startOperation(OfflineOperationKind.REGION_GET, OfflineOperationResultKind.OPTIONAL_REGION) { + out -> + mln_runtime_offline_region_get_start(handle.raw, id, out.address) + } + + public actual fun startOfflineRegions(): OfflineOperationHandle> = + startOperation(OfflineOperationKind.REGIONS_LIST, OfflineOperationResultKind.REGION_LIST) { out + -> + mln_runtime_offline_regions_list_start(handle.raw, out.address) + } + + public actual fun startMergeOfflineRegionsDatabase( + path: String + ): OfflineOperationHandle> = + Heap.withScratch(Heap.utf8Size(path)) { scratch -> + // A bare `const char*`, so an embedded NUL would merge a database named by a prefix of this. + Heap.requireCString(path, "path") + Heap.storeUtf8(scratch, path) + startOperation( + OfflineOperationKind.REGIONS_MERGE_DATABASE, + OfflineOperationResultKind.REGION_LIST, + ) { out -> + mln_runtime_offline_regions_merge_database_start(handle.raw, scratch.address, out.address) + } + } + + public actual fun startUpdateOfflineRegionMetadata( + id: Long, + metadata: ByteArray, + ): OfflineOperationHandle = + withMetadata(metadata) { metadataBytes -> + startOperation( + OfflineOperationKind.REGION_UPDATE_METADATA, + OfflineOperationResultKind.REGION, + ) { out -> + mln_runtime_offline_region_update_metadata_start( + handle.raw, + id, + metadataBytes.address, + metadata.size, + out.address, + ) + } + } + + public actual fun startOfflineRegionStatus( + id: Long + ): OfflineOperationHandle = + startOperation( + OfflineOperationKind.REGION_GET_STATUS, + OfflineOperationResultKind.REGION_STATUS, + ) { out -> + mln_runtime_offline_region_get_status_start(handle.raw, id, out.address) + } + + public actual fun startSetOfflineRegionObserved( + id: Long, + observed: Boolean, + ): OfflineOperationHandle = + startOperation(OfflineOperationKind.REGION_SET_OBSERVED, OfflineOperationResultKind.NONE) { out + -> + mln_runtime_offline_region_set_observed_start( + handle.raw, + id, + if (observed) 1 else 0, + out.address, + ) + } + + public actual fun startSetOfflineRegionDownloadState( + id: Long, + downloadState: OfflineRegionDownloadState, + ): OfflineOperationHandle { + // An open domain on the way out of native; only the named values mean anything on the way in. + Status.requireArgument(downloadState.isKnown) { + "Unknown offline region download state cannot be used as input: ${downloadState.nativeValue}" + } + return startOperation( + OfflineOperationKind.REGION_SET_DOWNLOAD_STATE, + OfflineOperationResultKind.NONE, + ) { out -> + mln_runtime_offline_region_set_download_state_start( + handle.raw, + id, + downloadState.nativeValue, + out.address, + ) + } + } + + public actual fun startInvalidateOfflineRegion(id: Long): OfflineOperationHandle = + startOperation(OfflineOperationKind.REGION_INVALIDATE, OfflineOperationResultKind.NONE) { out -> + mln_runtime_offline_region_invalidate_start(handle.raw, id, out.address) + } + + public actual fun startDeleteOfflineRegion(id: Long): OfflineOperationHandle = + startOperation(OfflineOperationKind.REGION_DELETE, OfflineOperationResultKind.NONE) { out -> + mln_runtime_offline_region_delete_start(handle.raw, id, out.address) + } + + public actual fun takeCreateOfflineRegionResult( + operation: OfflineOperationHandle + ): OfflineRegionInfo { + val operationId = + operation.requireLive( + this, + OfflineOperationKind.REGION_CREATE, + OfflineOperationResultKind.REGION, + ) + return takeRegionSnapshot(operation::markConsumed) { out -> + mln_runtime_offline_region_create_take_result(handle.raw, operationId, out.address) + } + } + + public actual fun takeOfflineRegionResult( + operation: OfflineOperationHandle + ): OfflineRegionInfo? { + val operationId = + operation.requireLive( + this, + OfflineOperationKind.REGION_GET, + OfflineOperationResultKind.OPTIONAL_REGION, + ) + return live { + Heap.withScratch(HANDLE_BYTES + BOOL_BYTES) { out -> + val found = out + HANDLE_BYTES + Status.check( + mln_runtime_offline_region_get_take_result( + handle.raw, + operationId, + out.address, + found.address, + ) + ) + try { + if (Heap.loadByte(found) == 0.toByte()) { + null + } else { + readSnapshot(NativeOfflineRegionSnapshot(Heap.loadLong(out))) + } + } finally { + operation.markConsumed() + } + } + } + } + + public actual fun takeOfflineRegionsResult( + operation: OfflineOperationHandle> + ): List { + val operationId = + operation.requireLive( + this, + OfflineOperationKind.REGIONS_LIST, + OfflineOperationResultKind.REGION_LIST, + ) + return takeRegionList(operation::markConsumed) { out -> + mln_runtime_offline_regions_list_take_result(handle.raw, operationId, out.address) + } + } + + public actual fun takeMergeOfflineRegionsDatabaseResult( + operation: OfflineOperationHandle> + ): List { + val operationId = + operation.requireLive( + this, + OfflineOperationKind.REGIONS_MERGE_DATABASE, + OfflineOperationResultKind.REGION_LIST, + ) + return takeRegionList(operation::markConsumed) { out -> + mln_runtime_offline_regions_merge_database_take_result(handle.raw, operationId, out.address) + } + } + + public actual fun takeUpdateOfflineRegionMetadataResult( + operation: OfflineOperationHandle + ): OfflineRegionInfo { + val operationId = + operation.requireLive( + this, + OfflineOperationKind.REGION_UPDATE_METADATA, + OfflineOperationResultKind.REGION, + ) + return takeRegionSnapshot(operation::markConsumed) { out -> + mln_runtime_offline_region_update_metadata_take_result(handle.raw, operationId, out.address) + } + } + + public actual fun takeOfflineRegionStatusResult( + operation: OfflineOperationHandle + ): OfflineRegionStatus { + val operationId = + operation.requireLive( + this, + OfflineOperationKind.REGION_GET_STATUS, + OfflineOperationResultKind.REGION_STATUS, + ) + return live { + Heap.withScratch(MlnOfflineRegionStatus.SIZEOF) { out -> + // Native reads an output descriptor's size to decide which fields it may write. + OfflineMarshal.writeStatusHeader(out) + Status.check( + mln_runtime_offline_region_get_status_take_result(handle.raw, operationId, out.address) + ) + try { + OfflineMarshal.readStatus(out) + } finally { + operation.markConsumed() + } + } + } + } + + /** + * Reports that this target answers a resource provider through declared routes. + * + * MapLibre needs a pass-through decision on the thread that raised the request, and that thread + * is a separate JavaScript agent which cannot enter this module. + */ + public actual fun setResourceProvider(callback: ResourceProviderCallback) { + throw UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "A resource provider callback that answers on the thread MapLibre raised it on is not " + + "supported in the browser, where that thread is a separate JavaScript agent. Declare the " + + "routes to claim with setResourceProvider(routes, callback) instead.", + ) + } + + /** + * Registers a queued resource provider that claims the requests matching [routes]. + * + * A request a route claims is copied by the C API's queued adapter, and [callback] receives it + * from [pump] on this thread. A request no route claims continues through the native loader. + */ + public fun setResourceProvider( + routes: List, + callback: QueuedResourceProviderCallback, + ) { + live { QueuedResourceProviders.set(handle.raw, routes, callback) } + } + + public actual fun clearResourceProvider() { + live { QueuedResourceProviders.clear(handle.raw) } + } + + /** Reports that this target answers a resource transform through a rule table. */ + public actual fun setResourceTransform(callback: ResourceTransformCallback) { + throw UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "A resource transform callback that answers on the thread MapLibre raised it on is not " + + "supported in the browser, where that thread is a separate JavaScript agent. Declare the " + + "rewrites with setResourceUrlRewriteRules(rules) instead.", + ) + } + + /** Registers or replaces the URL rewrite rules that this runtime's resource transform applies. */ + public fun setResourceUrlRewriteRules(rules: List) { + live { rewriteRules.set(handle.raw, rules) } + } + + public actual fun clearResourceTransform() { + live { rewriteRules.clear(handle.raw) } + } + + /** + * Reports that the browser does not support outgoing HTTP header transforms. + * + * The fetch transport follows redirects itself, so it cannot strip a transformed header before a + * cross-origin hop, and the C API reports the same status for the same reason. + */ + public actual fun setHttpHeaderTransform(callback: HttpHeaderTransformCallback) { + throw UnsupportedFeatureException( + MaplibreStatus.UNSUPPORTED.nativeCode, + "An outgoing HTTP header transform is not supported in the browser, whose fetch transport " + + "follows redirects itself and so cannot keep transformed headers out of a cross-origin " + + "redirect. Serve those requests with a resource provider instead.", + ) + } + + public actual fun clearHttpHeaderTransform() { + live { Status.check(mln_runtime_clear_http_header_transform(handle.raw)) } + } + + public actual fun pollEvent(): RuntimeEvent? { + val event = live { + Heap.withScratch(MlnRuntimeEvent.SIZEOF + BOOL_BYTES) { block -> + val hasEvent = block + MlnRuntimeEvent.SIZEOF + RuntimeEventMarshal.writeHeader(block) + Status.check(mln_runtime_poll_event(handle.raw, block.address, hasEvent.address)) + // Copied rather than viewed: the next poll for this runtime overwrites the storage the + // descriptor points at. + if (Heap.loadByte(hasEvent) == 0.toByte()) null + else RuntimeEventMarshal.readEvent(block, this) + } + } + // A loaded style is the only announcement a style set by URL makes, so it is where a source + // the new style dropped stops being one this binding holds a callback for. + if (event?.type == RuntimeEventType.MAP_STYLE_LOADED) { + event.mapSource?.releaseDetachedCustomGeometrySources() + } + return event + } + + public actual override fun close() { + core.closeOnce( + destroy = { mln_runtime_destroy(handle.raw) }, + afterSuccess = { + // Destroying the runtime released its callbacks. The provider's routes outlive this by the + // marker that says native reads them no more. + QueuedResourceProviders.retireFor(handle.raw) + rewriteRules.release() + CallbackRing.clearWake(ringWake) + mln_wake_source_destroy(ringWake) + ringWake = 0 + }, + ) + } + + public actual companion object { + public actual fun create(options: RuntimeOptions): RuntimeHandle { + val assetPath = options.assetPath + val cachePath = options.cachePath + // Both cross as bare `const char*`, so a NUL would truncate the path rather than be rejected. + assetPath?.let { Heap.requireCString(it, "assetPath") } + cachePath?.let { Heap.requireCString(it, "cachePath") } + val assetBytes = assetPath?.let { Heap.utf8Size(it) } ?: 0 + val cacheBytes = cachePath?.let { Heap.utf8Size(it) } ?: 0 + // The handle goes first because it is the only member here that needs eight-byte alignment. + return Heap.withScratch(HANDLE_BYTES + MlnRuntimeOptions.SIZEOF + assetBytes + cacheBytes) { + scratch -> + val descriptor = scratch + HANDLE_BYTES + // The leading size field is how the C API versions a descriptor: it carries the size this + // binding was generated against, so native can tell which fields it may read. + MlnRuntimeOptions.setSize(descriptor, MlnRuntimeOptions.SIZEOF) + var text = descriptor + MlnRuntimeOptions.SIZEOF + assetPath?.let { + Heap.storeUtf8(text, it) + MlnRuntimeOptions.setAssetPath(descriptor, text) + text += assetBytes + } + cachePath?.let { + Heap.storeUtf8(text, it) + MlnRuntimeOptions.setCachePath(descriptor, text) + } + // The thread that runs this becomes the runtime's owner thread. + Status.check(mln_runtime_create(descriptor.address, scratch.address)) + val created = NativeRuntime(Heap.loadLong(scratch)) + try { + RuntimeHandle(created) + } catch (error: Throwable) { + // The wrapper never existed, so nothing else will destroy what native just created. + mln_runtime_destroy(created.raw) + throw error + } + } + } + } + + /** + * Drops an operation's stored result and suppresses its completion event, without cancelling the + * native database work. A runtime that is already gone took that state with it. + */ + internal fun discardOfflineOperation(operation: OfflineOperationHandle<*>) { + if (operation.isClosed) return + val operationId = operation.requireLive(this) + try { + core.requireLive() + } catch (error: InvalidStateException) { + operation.markConsumed() + throw error + } + Status.check(mln_runtime_offline_operation_discard(handle.raw, operationId)) + operation.markConsumed() + } + + internal fun retainChild(childTypeName: String): HandleStateCore.ChildRetention = + core.retainChild(childTypeName) + + /** + * The maps this runtime raised events for, so a map-originated event can name its handle. + * + * A strong reference, where Kotlin/Native holds a weak one: Kotlin/Wasm has neither finalization + * nor weak references, so `MapHandle.close` is what removes the entry. + */ + private val liveMaps = mutableMapOf() + + internal fun registerMap(map: MapHandle) { + liveMaps[map.nativeHandleId()] = map + } + + internal fun unregisterMap(map: MapHandle) { + // An id names one map for the life of the process, so this key can only be this map's. + liveMaps.remove(map.nativeHandleId()) + } + + /** Resolves the map a runtime event names, or null once that map has been closed. */ + internal fun liveMap(nativeHandleId: Long): MapHandle? = liveMaps[nativeHandleId] + + /** The native runtime, for the wrappers this runtime owns. */ + internal fun nativeHandle(): NativeRuntime = live { handle } + + /** Starts an offline database operation and wraps the id that [start] writes into its out. */ + private fun startOperation( + kind: OfflineOperationKind, + resultKind: OfflineOperationResultKind, + start: (HeapPointer) -> Int, + ): OfflineOperationHandle = live { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(start(out)) + OfflineOperationHandle(this, Heap.loadLong(out), kind, resultKind) + } + } + + /** Takes a completed operation's snapshot result and copies the region out of it. */ + private fun takeRegionSnapshot( + markConsumed: () -> Unit, + take: (HeapPointer) -> Int, + ): OfflineRegionInfo = live { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(take(out)) + try { + readSnapshot(NativeOfflineRegionSnapshot(Heap.loadLong(out))) + } finally { + markConsumed() + } + } + } + + /** Takes a completed operation's list result and copies every region out of it. */ + private fun takeRegionList( + markConsumed: () -> Unit, + take: (HeapPointer) -> Int, + ): List = live { + Heap.withScratch(HANDLE_BYTES) { out -> + Status.check(take(out)) + try { + readList(NativeOfflineRegionList(Heap.loadLong(out))) + } finally { + markConsumed() + } + } + } + + /** Copies a snapshot's region out and destroys it. */ + private fun readSnapshot(snapshot: NativeOfflineRegionSnapshot): OfflineRegionInfo = + try { + InjectedFaults.beginResultCopy(snapshot.raw, MlnOfflineRegionInfo.SIZEOF) + Heap.withScratch(MlnOfflineRegionInfo.SIZEOF) { info -> + OfflineMarshal.writeRegionInfoHeader(info) + Status.check(mln_offline_region_snapshot_get(snapshot.raw, info.address)) + OfflineMarshal.readRegionInfo(info) + } + } finally { + // The pointers the info carries belong to this snapshot, so it outlives the copy and no more. + mln_offline_region_snapshot_destroy(snapshot.raw) + } + + /** Copies every region out of a list and destroys it. */ + private fun readList(list: NativeOfflineRegionList): List = + try { + InjectedFaults.beginResultCopy(list.raw, MlnOfflineRegionInfo.SIZEOF + SIZE_BYTES) + // The descriptor goes first and the count after it, because a descriptor holds 64-bit fields + // and the heap views index by width: a misplaced one reads at the wrong offsets entirely. + Heap.withScratch(MlnOfflineRegionInfo.SIZEOF + SIZE_BYTES) { info -> + val count = info + MlnOfflineRegionInfo.SIZEOF + Status.check(mln_offline_region_list_count(list.raw, count.address)) + List(Heap.loadInt(count)) { index -> + OfflineMarshal.writeRegionInfoHeader(info) + Status.check(mln_offline_region_list_get(list.raw, index, info.address)) + OfflineMarshal.readRegionInfo(info) + } + } + } finally { + mln_offline_region_list_destroy(list.raw) + } + + /** Places [metadata] in its own block and runs [body] with where it starts. */ + private fun withMetadata(metadata: ByteArray, body: (HeapPointer) -> T): T { + // Absent metadata is the null pointer with a zero size, and zero-byte scratch cannot be taken. + if (metadata.isEmpty()) return body(HeapPointer(0)) + return Heap.withScratch(metadata.size) { scratch -> + Heap.storeBytes(scratch, metadata) + body(scratch) + } + } +} diff --git a/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/WakeSource.kt b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/WakeSource.kt new file mode 100644 index 000000000..f92e23a0c --- /dev/null +++ b/bindings/kotlin/src/wasmJsMain/kotlin/org/maplibre/nativeffi/runtime/WakeSource.kt @@ -0,0 +1,59 @@ +package org.maplibre.nativeffi.runtime + +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.lifecycle.HandleStateCore +import org.maplibre.nativeffi.internal.lifecycle.NativeWakeSource +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.BrowserModule + +// `(i64) -> i32` and `(i64) -> ()`: a handle is a 64-bit generational identifier, so it crosses as +// a BigInt rather than a number, which is what the module's own i64 interface expects. +@JsFun("(handle) => globalThis.__maplibreNativeC._mln_wake_source_signal(handle)") +private external fun signalWakeSource(handle: Long): Int + +@JsFun("(handle) => globalThis.__maplibreNativeC._mln_wake_source_destroy(handle)") +private external fun destroyWakeSource(handle: Long) + +/** + * Releases a runtime owner thread parked in [RuntimeHandle.pump]. + * + * Unlike almost everything else in this binding, these two calls are not proxied to the runtime's + * owner thread. The C API documents a wake source as callable from any thread -- it takes one small + * lock and returns -- and proxying it would defeat its purpose, since the thread it exists to + * release is the one that would have to run the proxied call. + * + * That also makes it usable from a callback stack, which cannot suspend, and from JavaScript event + * handlers a host wires up outside a scope. + */ +public actual class WakeSource private constructor(private val source: NativeWakeSource) : + AutoCloseable { + private val core = HandleStateCore("WakeSource", source.raw) + + public actual val isClosed: Boolean + get() = core.isReleased() + + public actual fun signal() { + BrowserModule.attach() + core.withLive { Status.check(signalWakeSource(source.raw)) } + } + + /** + * Releases the wake source. + * + * A wake source is not owner-affine, so a host may hold one past the end of every runtime and + * close it whenever it likes, which is the behaviour every other target has. + */ + public actual override fun close() { + // Destruction returns nothing, so the close reports success on its behalf. + core.closeOnce( + destroy = { + destroyWakeSource(source.raw) + MaplibreStatus.OK.nativeCode + } + ) + } + + internal companion object { + fun fromNative(source: NativeWakeSource): WakeSource = WakeSource(source) + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/BrowserSuiteEntryPoint.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/BrowserSuiteEntryPoint.kt new file mode 100644 index 000000000..de34f99b4 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/BrowserSuiteEntryPoint.kt @@ -0,0 +1,63 @@ +package org.maplibre.nativeffi + +import kotlin.wasm.WasmExport +import org.maplibre.nativeffi.internal.wasm.BrowserModule + +// The adapter kotlin-test runs the suite through, installed on the namespace kotlin-test reads it +// from. It is written in JavaScript because that is what the namespace holds: kotlin-test wraps the +// object below and hands its two methods the suite and test bodies as ordinary JS functions. +// +// Reporting each test as a line is the whole of what a failing run says. There is no test framework +// on this thread to report to, and `console.log` in this worker's realm is what the runner +// collects. +@JsFun( + """ + () => { + const state = { failures: 0, path: [] } + globalThis.__mlnTestState = state + globalThis.kotlinTest = { + adapter: { + suite: (name, ignored, suiteFn) => { + if (ignored) return + state.path.push(name) + try { suiteFn() } finally { state.path.pop() } + }, + test: (name, ignored, testFn) => { + const qualified = state.path.concat(name).join(".") + if (ignored) { console.log("IGNORED " + qualified); return } + try { + testFn() + console.log("PASSED " + qualified) + } catch (error) { + state.failures++ + console.log("FAILED " + qualified + ": " + (error && error.stack || error)) + } + }, + }, + } + } +""" +) +private external fun installTestAdapter() + +@JsFun("() => globalThis.__mlnTestState.failures") private external fun testFailures(): Int + +/** + * Prepares the suite to run inside the module, on the thread the module imported it into. + * + * `bindings/kotlin/browser-test/maplibre-native-kotlin.mjs` stands in for the application a host + * would serve: it calls this, then the compiler-emitted `startUnitTests`, then + * [mlnKotlinTestFailures]. A test binary cannot be its own entry point the way an application is, + * because the compiler emits the suite as an export only JavaScript can call. + * + * Naming the module first is what makes every later call work: each generated entry point reads + * `globalThis.__maplibreNativeC`, and nothing else on this thread sets it. + */ +@WasmExport("mlnKotlinTestBegin") +internal fun mlnKotlinTestBegin() { + BrowserModule.attach() + installTestAdapter() +} + +/** The number of tests that failed, which is what the run's exit status is built from. */ +@WasmExport("mlnKotlinTestFailures") internal fun mlnKotlinTestFailures(): Int = testFailures() diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/BrowserTestSupport.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/BrowserTestSupport.kt new file mode 100644 index 000000000..cb1f78278 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/BrowserTestSupport.kt @@ -0,0 +1,380 @@ +package org.maplibre.nativeffi + +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.map.MapOptions +import org.maplibre.nativeffi.render.WebglContext +import org.maplibre.nativeffi.runtime.RuntimeEvent +import org.maplibre.nativeffi.runtime.RuntimeEventType +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.runtime.RuntimeOptions + +// --------------------------------------------------------------------------------------------- +// Requirements from the binding specification's test table that this target does not have. +// +// Each is recorded here rather than dropped, so the gap between this suite and the table is a +// stated one. Everything else in the table is covered by a test marked `Spec coverage: BND-xxx`. +// +// **No second thread this binding can reach.** Kotlin/Wasm runs on the Emscripten pthread the +// module's `main()` imported it into, and a Kotlin/Wasm module cannot create a thread of its own: +// there is no `pthread_create` it can call and no worker that would share its memory. Every call +// this binding makes into the C API is therefore made from the same native thread, and every +// callback body reaches it by being drained from the module's record ring on that same thread. So +// no test here can make a call from the wrong thread, race a release against a use, or hold a +// handle from a thread that does not own it: +// - BND-046 — concurrent releases. +// - BND-049, BND-190, BND-191 — wrong-thread calls. +// - BND-145 — completing a handled request from another thread. +// - BND-153 — a release waiting for an in-flight use from elsewhere. +// - BND-193, BND-194, BND-195, BND-196 — render sessions on a second thread. +// - BND-197 — a release racing a use of the same handle. +// - BND-174 — closing a map whose session was attached on another thread. +// +// **No cleanup outside explicit release.** Kotlin/Wasm has no finalization, no reference queue and +// no weak reference, so there is no non-deterministic cleanup to hang a leak report on: +// - BND-044 — cleanup hooks reporting leaked thread-affine handles. +// - BND-048 — best-effort cleanup failure reported through a leak channel. +// +// **Answered by a native rule table rather than by host code.** MapLibre raises a resource +// transform on worker threads, each of which is a separate JavaScript agent, so this binding +// registers `mln_adapter_resource_transform_rewrite_callback` and reports the host-callback form +// as unsupported, which is what the specification's `#### The browser` clause sanctions. No host +// code receives a transform request, so there is nothing for a copy to protect: +// - BND-141 — transform request data copied into language-owned values. +// BND-140 is covered by `ResourceProviderBrowserTest`, through the rule table. +// +// **Decided by route rather than by a callback return.** Requests reach host code through +// `mln_adapter_queued_resource_provider`, which claims a request by matching a route declared at +// registration. The specification states that BND-150 does not apply to such a binding, because +// there is no callback return path left to override. +// +// **Absent by design in this module.** +// - BND-158, BND-159 — outgoing HTTP header transforms. The browser's fetch transport follows +// redirects itself, so a transformed header cannot be kept out of a cross-origin hop. Asserted +// as permanently unsupported by `RuntimeHandleBrowserTest` instead. +// - BND-160's Metal and Vulkan halves — the browser module is built with OpenGL only. The +// unsupported-backend errors those attach paths report are covered. +// +// **Injected through an internal seam.** The module will not produce these on request, so +// `InjectedFaults` produces them instead, which the binding specification's test-seam rules allow. +// Each of these tests asserts the state the failure left behind rather than only the error it +// reported: +// - BND-066's copy-failure half — `StyleBrowserTest` and `QueryBrowserTest` fail the copy after a +// native list, snapshot, or result handle is acquired, and replay the handle to prove native +// destroyed it. +// - BND-122's failed-replacement half — `ResourceProviderBrowserTest` has native refuse the +// provider and rewrite-rule installs. The custom geometry family needs no seam: native refuses a +// duplicate source id by itself, which `CustomGeometrySourceBrowserTest` uses. +// - BND-169 — `RenderSessionBrowserTest` has native refuse a frame release, and the frame stays +// retryable. +// - BND-172 — `RenderSessionBrowserTest` fails the wrapper construction that follows a successful +// frame acquire, and the session goes on rendering, resizing, and handing out frames, which it +// could not do if the frame it acquired had been stranded. +// --------------------------------------------------------------------------------------------- + +/** + * The origin this module was served from. + * + * Used where a test needs a URL the module's HTTP transport can really fetch. This thread is a + * worker of the host page, so its location is the module's own URL; the page is cross-origin + * isolated, and a request anywhere else would be refused before it left the browser. + */ +@JsFun("() => globalThis.location.origin") internal external fun pageOrigin(): String + +@JsFun("() => Date.now()") private external fun nowMillis(): Double + +/** Reports how long [body] took, for the waits whose whole claim is that they ended early. */ +internal fun elapsedMillis(body: () -> Unit): Long { + val started = nowMillis() + body() + return (nowMillis() - started).toLong() +} + +/** + * Asserts that native no longer holds the snapshot, list, or result handle [handle] named. + * + * Asking native is the only way to tell a destroyed result handle from a leaked one. A leaked one + * sits in the module's handle table doing nothing until the page is gone. A destroyed one names a + * retired generation of its slot, and native answers that with an invalid argument saying so. So + * the handle is replayed through [replay], which is any entry point taking a handle of this [kind] + * and one output pointer. + * + * The replay is what a released handle costs a caller anyway, and the binding's own wrappers cannot + * express it: a result handle never leaves the call that reads it. + */ +internal fun assertResultHandleDestroyed(handle: Long, kind: String, replay: (Long, Int) -> Int) { + assertTrue(handle != 0L, "no native $kind was acquired, so nothing was there to destroy") + val error = + assertFailsWith( + "native still holds $kind $handle, so the failed copy leaked it" + ) { + Heap.withScratch(RESULT_HANDLE_OUT_BYTES) { out -> Status.check(replay(handle, out.address)) } + } + assertTrue(error.diagnostic.contains(kind), error.diagnostic) + assertTrue(error.diagnostic.contains("stale"), error.diagnostic) +} + +/** Room for the widest output any of the replayed entry points above writes. */ +private const val RESULT_HANDLE_OUT_BYTES = 8 + +/** + * The one `` element of the host page this thread can render into. + * + * A canvas reaches a pthread by being transferred as that thread is created, and the module's link + * names this element id in `-sOFFSCREENCANVASES_TO_PTHREAD`. So there is exactly one, it is the + * page's own element rather than a private surface, and no test can make a second: a page hosting + * more than one on-screen map is a documented limitation of this binding rather than something a + * test could reach. + * + * The context is created once and never destroyed, because a WebGL context belongs to its canvas — + * asking the same canvas for a second one hands back the first. That is also what a page host does: + * one canvas, one context, for as long as the page lives. + */ +internal object PageCanvas { + /** + * The size the canvas is used at. + * + * Small, because a software rasteriser draws every pixel of every frame these tests render, and + * an image that fills a viewport says everything a larger one would. + */ + const val WIDTH: Int = 64 + const val HEIGHT: Int = 32 + + private var shared: WebglContext? = null + + /** The context every presenting test renders through, sized back to [WIDTH] by [HEIGHT]. */ + fun context(): WebglContext { + val context = shared ?: WebglContext.createForPageCanvas(WIDTH, HEIGHT).also { shared = it } + context.resizeCanvas(WIDTH, HEIGHT) + return context + } +} + +/** + * Asserts that the page canvas's own drawing buffer holds one opaque colour. + * + * Read with `glReadPixels` against framebuffer zero, which for a transferred canvas is the element + * the page displays rather than a surface of this binding's own. That is what makes this a claim + * about presenting: a surface session draws straight into this framebuffer, and a texture session's + * frame reaches it only by being blitted there. + * + * What it does not claim is that the browser composited what it read. Compositing happens when the + * task that drew ends, and the page's `` element can only be sampled from the page's own + * agent, which this thread is not. + */ +internal fun assertPresentedColor( + context: WebglContext, + red: Int, + green: Int, + blue: Int, + width: Int = PageCanvas.WIDTH, + height: Int = PageCanvas.HEIGHT, +) { + assertUniformColor(context.readPixels(0, width, height), red, green, blue, width, height, "page") +} + +/** + * Asserts that a frame read back out of a render target is one opaque colour. + * + * Read back rather than presented, which is the point of having both: a canvas showing the wrong + * colour says nothing about whether the map drew the right one, and these two assertions together + * say which half failed. + */ +internal fun assertRenderedColor( + pixels: ByteArray, + red: Int, + green: Int, + blue: Int, + width: Int = PageCanvas.WIDTH, + height: Int = PageCanvas.HEIGHT, +) { + assertUniformColor(pixels, red, green, blue, width, height, "rendered") +} + +/** + * Asserts every pixel of an image is one opaque colour. + * + * Checked as a whole image rather than a sample: these styles paint one background over the whole + * viewport, so a frame that arrived only in part shows up here where a single sample would miss it. + * The tolerance is for the round trip through a float shader and an eight-bit target. + */ +private fun assertUniformColor( + pixels: ByteArray, + red: Int, + green: Int, + blue: Int, + width: Int, + height: Int, + where: String, +) { + assertEquals(width * height * 4, pixels.size, "the $where image is the wrong size") + assertTrue( + pixels.any { it != 0.toByte() }, + "the $where image is entirely zero, so no frame ever reached it", + ) + for (y in 0 until height) { + for (x in 0 until width) { + val offset = (y * width + x) * 4 + assertChannel(pixels, offset, red, "red", x, y, where) + assertChannel(pixels, offset + 1, green, "green", x, y, where) + assertChannel(pixels, offset + 2, blue, "blue", x, y, where) + // Exactly opaque. A frame with the wrong alpha would darken or lighten the channels above + // without changing which colour they name. + assertChannel(pixels, offset + 3, 255, "alpha", x, y, where) + } + } +} + +private fun assertChannel( + pixels: ByteArray, + offset: Int, + expected: Int, + channel: String, + x: Int, + y: Int, + where: String, +) { + val actual = pixels[offset].toInt() and 0xFF + assertTrue( + actual in (expected - COLOR_TOLERANCE)..(expected + COLOR_TOLERANCE), + "the $where pixel ($x, $y) has $channel $actual, but the frame's is $expected", + ) +} + +private const val COLOR_TOLERANCE = 3 + +/** A style painting one opaque background colour and nothing else: no network, no tiles. */ +internal fun backgroundStyle(color: String): String = + """{"version":8,"sources":{},"layers":[""" + + """{"id":"background","type":"background","paint":{"background-color":"$color"}}]}""" + +/** A style with a background layer and nothing else: no network, no tiles, no glyphs. */ +internal const val BACKGROUND_STYLE_JSON: String = + """{"version":8,"sources":{},"layers":[{"id":"background","type":"background"}]}""" + +/** The smallest style MapLibre parses, for tests that only need a loaded style. */ +internal const val EMPTY_STYLE_JSON: String = """{"version":8,"sources":{},"layers":[]}""" + +/** + * How long each wait pumps for, and how many times. + * + * A pump blocks this thread on the runtime's own condition variable, which is legal here and is + * what gives MapLibre's workers a chance to run. The product bounds a wait at a few seconds, far + * longer than any style this suite loads takes. + */ +private const val PUMP_MILLIS = 2L +private const val PUMP_ATTEMPTS = 2_000 + +/** + * Runs [body] with a runtime, closing it afterwards. + * + * The provider is cleared and the ring drained before the close, because a queued provider's + * registration is only released when its retirement marker comes out of the ring — and a runtime + * that is gone can no longer drain one. A registration left waiting for its marker is the module's + * state rather than this runtime's, so it would be the *next* test that found its provider + * unreachable. + */ +internal fun withRuntime( + options: RuntimeOptions = RuntimeOptions(), + body: (RuntimeHandle) -> T, +): T { + val runtime = RuntimeHandle.create(options) + try { + return body(runtime) + } finally { + if (!runtime.isClosed) { + runCatching { runtime.clearResourceProvider() } + runCatching { pumpTurns(runtime, RETIREMENT_PUMPS) } + } + runtime.close() + } +} + +/** + * Enough turns for the retirement markers of anything a test registered to come out of the ring. + */ +private const val RETIREMENT_PUMPS = 8 + +/** Runs [body] with a runtime and a map of [width] by [height], closing both afterwards. */ +internal fun withMap( + width: Int = 128, + height: Int = 128, + options: RuntimeOptions = RuntimeOptions(), + body: (RuntimeHandle, MapHandle) -> T, +): T = + withRuntime(options) { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + this.width = width + this.height = height + }, + ) + try { + body(runtime, map) + } finally { + map.close() + } + } + +/** Pumps until [predicate] holds, draining events into [onEvent] as they arrive. */ +internal fun pumpUntil( + runtime: RuntimeHandle, + onEvent: (RuntimeEvent) -> Unit = {}, + predicate: () -> Boolean, +): Boolean { + repeat(PUMP_ATTEMPTS) { + if (predicate()) return true + runtime.pump(PUMP_MILLIS) + while (true) { + val event = runtime.pollEvent() ?: break + onEvent(event) + } + } + return predicate() +} + +/** Pumps until the map raises [type], returning the copied event. */ +internal fun waitForMapEvent( + runtime: RuntimeHandle, + map: MapHandle, + type: RuntimeEventType, +): RuntimeEvent { + var found: RuntimeEvent? = null + pumpUntil(runtime, onEvent = { if (it.type == type && it.mapSource == map) found = it }) { + found != null + } + return found ?: error("the map raised no $type event") +} + +/** Pumps until the runtime's queue is empty, so a later wait observes only new events. */ +internal fun drain(runtime: RuntimeHandle) { + repeat(64) { + runtime.pump(PUMP_MILLIS) + var drained = false + while (runtime.pollEvent() != null) { + drained = true + } + if (!drained) return + } +} + +/** + * Pumps [turns] times without waiting for anything. + * + * Used where the claim is that nothing arrives. A record native produced reaches host code by being + * drained inside a pump, so the runtime has to be pumped that many times before its absence means + * anything. + */ +internal fun pumpTurns(runtime: RuntimeHandle, turns: Int) { + repeat(turns) { + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/MaplibreBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/MaplibreBrowserTest.kt new file mode 100644 index 000000000..7d2602b39 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/MaplibreBrowserTest.kt @@ -0,0 +1,100 @@ +package org.maplibre.nativeffi + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import org.maplibre.nativeffi.error.AbiVersionMismatchException +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.NativeErrorException +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.render.OpenGLContextProvider +import org.maplibre.nativeffi.render.RenderBackend +import org.maplibre.nativeffi.runtime.NetworkStatus + +/** + * The entry points a host reaches before any runtime exists. + * + * The module is already instantiated when Kotlin starts — it is what imported this module, on the + * thread it gave it — so loading is an attach rather than a fetch. These are the first calls that + * cross into it, and so also what says whether it is reachable at all. + */ +class MaplibreBrowserTest { + // Spec coverage: BND-001, BND-160. + + @Test + fun anAttachedModuleAgreesWithTheBindingAboutWhatItIs() { + // Reaching this line means the attach found the module on this thread's global scope and named + // it where every generated extern reads it. What follows is the first answer read back out. + Maplibre.loadNativeLibrary() + assertEquals(Maplibre.EXPECTED_C_ABI_VERSION, Maplibre.cVersion()) + assertEquals(setOf(RenderBackend.OPENGL), Maplibre.supportedRenderBackends()) + assertEquals(setOf(OpenGLContextProvider.WEBGL), Maplibre.supportedOpenGLContextProviders()) + } + + @Test + fun aModuleReportingAnotherAbiVersionIsRefusedBeforeAnyHandleExists() { + // No loadable module reports a version other than the one this binding was generated for, so + // the guard is driven through the version seam the attach itself calls. What it protects is + // ahead of every handle: a module that disagreed about the ABI would have been accepted and + // then read descriptors at offsets that are not its own. + val error = + assertFailsWith { + Maplibre.checkCompatibleCAbi(Maplibre.EXPECTED_C_ABI_VERSION + 1L) + } + + assertEquals(MaplibreStatus.NATIVE_ERROR, error.status) + assertIs(error) + assertEquals(MaplibreStatus.NATIVE_ERROR.nativeCode, error.nativeStatusCode) + assertEquals(Maplibre.EXPECTED_C_ABI_VERSION + 1L, error.actualVersion) + assertEquals(Maplibre.EXPECTED_C_ABI_VERSION, error.expectedVersion) + assertTrue(error.diagnostic.contains("C ABI version")) + + // The attached module is unaffected, so the guard rejected rather than tore anything down. + Maplibre.loadNativeLibrary() + } + + @Test + fun aCoordinateSurvivesAProjectionRoundTrip() { + // Spec coverage: BND-103. + // Two descriptors written at generated offsets, handed to native as pointers into the module's + // heap, and read back. A layout that disagreed with the module would return a different + // coordinate rather than fail, so the round trip is what checks it. + val coordinate = LatLng(latitude = 37.8199, longitude = -122.4783) + + val meters = Maplibre.projectedMetersForLatLng(coordinate) + val returned = Maplibre.latLngForProjectedMeters(meters) + + assertTrue(meters.northing > 0.0 && meters.easting < 0.0, "unexpected meters $meters") + assertTrue( + abs(returned.latitude - coordinate.latitude) < TOLERANCE_DEGREES && + abs(returned.longitude - coordinate.longitude) < TOLERANCE_DEGREES, + "$returned is not $coordinate", + ) + } + + @Test + fun networkStatusRoundTripsAndRejectsAValueNativeCannotBeGiven() { + // Spec coverage: BND-068. + // Process-global state written and read back through an out-parameter, which is the shape most + // of this API takes. + try { + Maplibre.setNetworkStatus(NetworkStatus.OFFLINE) + assertEquals(NetworkStatus.OFFLINE, Maplibre.networkStatus) + } finally { + Maplibre.setNetworkStatus(NetworkStatus.ONLINE) + } + assertEquals(NetworkStatus.ONLINE, Maplibre.networkStatus) + + // Binding-owned validation, which fails before anything crosses into the module. + assertFailsWith { Maplibre.setNetworkStatus(NetworkStatus(900)) } + } + + private companion object { + /** Both conversions are double precision, so a round trip loses far less than this. */ + const val TOLERANCE_DEGREES = 1e-9 + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleIdentityBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleIdentityBrowserTest.kt new file mode 100644 index 000000000..d93aa87bb --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/internal/lifecycle/HandleIdentityBrowserTest.kt @@ -0,0 +1,104 @@ +package org.maplibre.nativeffi.internal.lifecycle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.status.Status +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.generated.mln_map_get_size +import org.maplibre.nativeffi.internal.wasm.generated.mln_runtime_pump +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.map.MapOptions +import org.maplibre.nativeffi.withRuntime + +/** + * What native does with a handle id the safe API can no longer produce. + * + * A handle is a generational integer, so the safe wrappers cannot express either case here: a + * released wrapper refuses before it reaches the module, and the kinds are distinct value classes. + * Both are replayed through the generated entry points directly, because what is being checked is + * that native's own generation and kind tags still catch them — the last line of defence under the + * binding's own guards. + */ +class HandleIdentityBrowserTest { + // Spec coverage: BND-045, BND-047. + + @Test + fun aReleasedMapIdIsStaleEvenOnceANewMapHasTakenItsSlot() { + withRuntime { runtime -> + val first = MapHandle.create(runtime, mapOptions()) + val released = first.nativeHandle().raw + first.close() + + // The released slot is the one the next map takes, so the replayed id names a retired + // generation of a slot that is live again. A native side that compared only the slot would + // answer for the new map. + val second = MapHandle.create(runtime, mapOptions()) + try { + val error = assertFailsWith { mapSize(released) } + + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertEquals(MaplibreStatus.INVALID_ARGUMENT.nativeCode, error.nativeStatusCode) + // Native's own words for it: the id names a retired generation rather than an id it + // never issued. + assertTrue(error.diagnostic.contains("mln_map"), error.diagnostic) + assertTrue(error.diagnostic.contains("stale"), error.diagnostic) + + // The live map is unaffected by the replay. + mapSize(second.nativeHandle().raw) + } finally { + second.close() + } + } + } + + @Test + fun aMapIdHandedToARuntimeOperationIsRejectedOnItsKind() { + withRuntime { runtime -> + val map = MapHandle.create(runtime, mapOptions()) + try { + // `NativeMap` and `NativeRuntime` are distinct value classes, so this call has no + // expression in the safe API at all and needs the raw id. + val error = + assertFailsWith { + Status.check(mln_runtime_pump(map.nativeHandle().raw, 0L)) + } + + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertEquals(MaplibreStatus.INVALID_ARGUMENT.nativeCode, error.nativeStatusCode) + // Native names both kinds, so the message says which handle was passed as well as which + // one was wanted. + assertTrue(error.diagnostic.contains("mln_map"), error.diagnostic) + assertTrue(error.diagnostic.contains("mln_runtime"), error.diagnostic) + + // The runtime still works, so the rejection was of the argument and not of the call. + runtime.pump(0) + } finally { + map.close() + } + } + } + + private fun mapOptions() = + MapOptions().apply { + width = 64 + height = 64 + } + + /** Reads a map's extent through the generated entry point, so the handle id can be chosen. */ + private fun mapSize(rawHandle: Long) { + Heap.withScratch(SIZE_SCRATCH_BYTES) { scratch -> + Status.check( + mln_map_get_size(rawHandle, scratch.address, scratch.address + 4, scratch.address + 8) + ) + } + } + + private companion object { + /** Two `uint32_t` and a `double`, with the double at the eight-byte offset it needs. */ + const val SIZE_SCRATCH_BYTES = 16 + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/internal/status/NativeStatusBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/internal/status/NativeStatusBrowserTest.kt new file mode 100644 index 000000000..ae404ba09 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/internal/status/NativeStatusBrowserTest.kt @@ -0,0 +1,157 @@ +package org.maplibre.nativeffi.internal.status + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.Maplibre +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.NativeErrorException +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.render.FrameAcquirePolicy +import org.maplibre.nativeffi.render.MetalContextDescriptor +import org.maplibre.nativeffi.render.MetalOwnedTextureDescriptor +import org.maplibre.nativeffi.render.NativePointer +import org.maplibre.nativeffi.render.RenderTargetExtent +import org.maplibre.nativeffi.runtime.NetworkStatus +import org.maplibre.nativeffi.withMap + +/** + * What a failure looks like by the time it reaches host code. + * + * `mln_thread_last_error_message` reports the message belonging to the *calling* native thread, and + * this binding calls the C API from the thread it runs on, so the message it reads belongs to the + * call that just failed. What these tests check is that it is read there and copied: a diagnostic + * kept as a promise to re-read later would report whatever the next call left behind. + */ +class NativeStatusBrowserTest { + // Spec coverage: BND-020, BND-021, BND-022, BND-023, BND-025, BND-026. + + @Test + fun eachStatusCategoryANativeCallProducesArrivesAsItsOwnExceptionType() { + withMap { _, map -> + // Invalid argument, produced by native coordinate validation. + val invalidArgument = + assertFailsWith { map.pixelForLatLng(LatLng(Double.NaN, 0.0)) } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, invalidArgument.status) + assertEquals(MaplibreStatus.INVALID_ARGUMENT.nativeCode, invalidArgument.nativeStatusCode) + + // Native error, produced by the style parser refusing a document. + val nativeError = assertFailsWith { map.setStyleJson("not a style") } + assertEquals(MaplibreStatus.NATIVE_ERROR, nativeError.status) + assertEquals(MaplibreStatus.NATIVE_ERROR.nativeCode, nativeError.nativeStatusCode) + + // Unsupported, for a backend this module was not built with. + val unsupported = + assertFailsWith { + map.attachMetalOwnedTexture( + MetalOwnedTextureDescriptor( + RenderTargetExtent(16, 16, 1.0), + MetalContextDescriptor(NativePointer.ofAddress(0x10L)), + ) + ) + } + assertEquals(MaplibreStatus.UNSUPPORTED, unsupported.status) + + // Invalid state, from the binding's own closed-handle guard. + val projection = map.createProjection() + projection.close() + val invalidState = + assertFailsWith { projection.pixelForLatLng(LatLng(0.0, 0.0)) } + assertEquals(MaplibreStatus.INVALID_STATE, invalidState.status) + } + } + + @Test + fun anUnknownStatusKeepsItsRawValueRatherThanBecomingAKnownOne() { + // No C call in this module returns a status from a future revision, so the conversion is driven + // directly. What matters is that an unrecognized code is carried rather than collapsed into one + // of the categories this binding happens to know. + val exception = Status.exception(-127) + + assertEquals(MaplibreStatus(-127), exception.status) + assertEquals(-127, exception.nativeStatusCode) + } + + /** + * The diagnostic is copied at the failure rather than read again afterwards. + * + * `mln_thread_last_error_message` reports one message per native thread, and it is replaced by + * whatever fails next. So an exception has to hold its own copy, taken as it was built. + * + * This is also what BND-026 rests on, because a cleanup call that fails cannot be shown to leave + * the original message alone while every message is the same empty string. + */ + @Test + fun aDiagnosticIsCopiedAtTheFailureAndNotReReadLater() { + withMap { _, map -> + val first = + assertFailsWith { map.pixelForLatLng(LatLng(Double.NaN, 0.0)) } + val copied = first.diagnostic + assertTrue(copied.contains("latitude must be finite"), "diagnostic was [$copied]") + + // A second failing call replaces the message the first one left behind on this thread. The + // exception already holds its own copy, so it still says what went wrong. + assertFailsWith { map.setStyleJson("not a style") } + assertEquals(copied, first.diagnostic) + + // And a call that succeeds clears it, which is the case a lazily-read diagnostic would + // report as an empty string. + map.setStyleJson(EMPTY_STYLE_JSON) + assertEquals(copied, first.diagnostic) + + // The cleanup path a failed frame acquire takes makes a native call of its own while the + // original failure is in flight. What the caller is handed is still the original. + val thrown = + assertFailsWith { + FrameAcquirePolicy.cleanupAfterWrapperFailure( + acquired = true, + releaseNative = { map.pixelForLatLng(LatLng(0.0, Double.NaN)) }, + closeLocal = {}, + failure = first, + ) + } + assertSame(first, thrown) + assertEquals(copied, thrown.diagnostic) + } + } + + @Test + fun aClosedHandleIsRefusedByTheBindingWithItsOwnDiagnostic() { + withMap { _, map -> + // A native failure first, so a stale native message exists to be reported by mistake. + val native = + assertFailsWith { map.pixelForLatLng(LatLng(Double.NaN, 0.0)) } + assertTrue(native.diagnostic.contains("latitude"), native.diagnostic) + + val projection = map.createProjection() + projection.close() + + val error = + assertFailsWith { projection.pixelForLatLng(LatLng(0.0, 0.0)) } + + // Binding-owned, so the message names the wrapper rather than repeating what native last + // said, and nothing crossed into the module to produce it. + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + assertEquals("MapProjectionHandle is already closed", error.diagnostic) + assertFalse(error.diagnostic.contains("latitude")) + } + } + + @Test + fun aFailureRaisedBeforeTheModuleIsReachedNamesNoNativeDiagnostic() { + // Binding-owned validation on a process-global entry point: it fails before crossing into the + // module, so there is no native diagnostic for it to inherit. + val error = + assertFailsWith { Maplibre.setNetworkStatus(NetworkStatus(900)) } + + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertTrue(error.diagnostic.contains("900"), error.diagnostic) + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/log/LogCallbackBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/log/LogCallbackBrowserTest.kt new file mode 100644 index 000000000..17af4c49f --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/log/LogCallbackBrowserTest.kt @@ -0,0 +1,275 @@ +package org.maplibre.nativeffi.log + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.Maplibre +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.callback.CallbackRing +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.pumpTurns +import org.maplibre.nativeffi.pumpUntil +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.withMap + +/** + * The process-global log callback, which reaches host code through the module's record ring. + * + * MapLibre logs from whichever thread reaches the condition, and none of them may enter this + * WebAssembly instance. So the C shim copies each record into a bounded ring and signals the + * runtime's wake source, and the binding drains that ring inside `pump`. Two things follow, and + * both shape every test here. + * + * A record arrives on a pump rather than on the call that provoked it, so a test pumps before it + * asserts on what a callback received. + * + * Retirement travels in the same ring, behind the records it retires. Clearing or replacing a + * callback pushes a marker, and the drain stops delivering to that callback when the marker comes + * out — not at the moment the host asked. Which is why every claim below about a callback going + * quiet is made about records produced *after* the clear. + */ +class LogCallbackBrowserTest { + // Spec coverage: BND-120, BND-121, BND-122, BND-123. + + @Test + fun aRecordNativeProducesReachesTheInstalledCallback() { + val records = mutableListOf() + withFailingStyleLoads { runtime, map -> + Maplibre.setLogCallback({ records += it }, consume = false) + + map.setStyleUrl(UNSERVED_STYLE_URL) + assertTrue(pumpUntil(runtime) { records.isNotEmpty() }, "no log record reached the callback") + + val record = assertNotNull(records.firstOrNull()) + assertTrue(record.message.isNotBlank()) + assertTrue(record.severity.nativeValue > 0) + // Copied out of the record before the drain released it, so it still reads afterwards. + assertEquals(record.message, records.first().message) + } + } + + @Test + fun clearingStopsDeliveryAndAReplacementIsTheOnlyOneCalled() { + val first = mutableListOf() + val second = mutableListOf() + withFailingStyleLoads { runtime, map -> + Maplibre.setLogCallback({ first += it }, consume = false) + map.setStyleUrl(UNSERVED_STYLE_URL) + assertTrue(pumpUntil(runtime) { first.isNotEmpty() }, "the first callback heard nothing") + + // Installed with `consume` set, which is the other half of the registration: the decision is + // fixed here because MapLibre needs it on the logging thread, long before the record is + // drained. + Maplibre.setLogCallback({ second += it }, consume = true) + val firstAfterReplace = quiesce(runtime, first) + + map.setStyleUrl(UNSERVED_STYLE_URL + "?replaced") + assertTrue(pumpUntil(runtime) { second.isNotEmpty() }, "the replacement heard nothing") + assertEquals( + firstAfterReplace, + first.size, + "the replaced callback was still called for records produced after it was replaced", + ) + + Maplibre.clearLogCallback() + val secondAfterClear = quiesce(runtime, second) + + map.setStyleUrl(UNSERVED_STYLE_URL + "?cleared") + pumpTurns(runtime, QUIET_PUMPS) + assertEquals(secondAfterClear, second.size, "a cleared callback was called again") + + // Clearing one that is already cleared stays a successful no-op. + Maplibre.clearLogCallback() + } + } + + @Test + fun aFailingCallbackIsContainedAndLaterRecordsStillArrive() { + val seen = mutableListOf() + withFailingStyleLoads { runtime, map -> + Maplibre.setLogCallback( + { + seen += it + // The drain is what runs this, and nothing above it is a native frame to unwind into. A + // failure here must not stop the records behind this one. + throw IllegalStateException("contained") + }, + consume = false, + ) + + map.setStyleUrl(UNSERVED_STYLE_URL) + assertTrue(pumpUntil(runtime) { seen.size >= 2 }, "the drain stopped at the failed callback") + + // And the runtime is unharmed: it still pumps, and still delivers. + map.setStyleUrl(UNSERVED_STYLE_URL + "?after-failure") + val before = seen.size + assertTrue(pumpUntil(runtime) { seen.size > before }, "delivery stopped after a failure") + } + } + + /** + * Replacing or clearing from inside the callback being replaced. + * + * The body runs inside the drain, inside the pump that is delivering to it, so retiring its + * registration would be a close waiting on the frame below it. There is one thread and one stack + * here, so that wait can never finish and is refused instead. + */ + @Test + fun aCallbackCannotBeReplacedOrClearedFromInsideItself() { + var replaceError: Throwable? = null + var clearError: Throwable? = null + withFailingStyleLoads { runtime, map -> + Maplibre.setLogCallback( + { + replaceError = + runCatching { Maplibre.setLogCallback({}, consume = false) }.exceptionOrNull() + clearError = runCatching { Maplibre.clearLogCallback() }.exceptionOrNull() + }, + consume = false, + ) + + map.setStyleUrl(UNSERVED_STYLE_URL) + assertTrue(pumpUntil(runtime) { replaceError != null }, "the callback was never called") + + assertTrue(replaceError is InvalidStateException, "replace reported $replaceError") + assertTrue(clearError is InvalidStateException, "clear reported $clearError") + } + } + + /** + * A replacement native refuses leaves the callback that was already there receiving records. + * + * The binding installs the replacement's registration state before it tells native, because the + * shim reads the listener through the state it was given. So at the moment native refuses, the + * binding holds a registration native has never heard of, and it has to give that one back. + * + * Native has no refusal of its own to offer — the arguments the binding passes are always valid + * by then — so it is injected. + */ + // Spec coverage: BND-122. + @Test + fun aLogCallbackReplacementNativeRefusesKeepsThePreviousCallback() { + val installed = mutableListOf() + val refused = mutableListOf() + withFailingStyleLoads { runtime, map -> + Maplibre.setLogCallback({ installed += it }, consume = false) + map.setStyleUrl(UNSERVED_STYLE_URL) + assertTrue(pumpUntil(runtime) { installed.isNotEmpty() }, "the callback heard nothing") + + try { + InjectedFaults.failNextCall( + "mln_kotlin_log_install", + MaplibreStatus.INVALID_ARGUMENT, + "log callback must not be null", + ) + val error = + assertFailsWith { + Maplibre.setLogCallback({ refused += it }, consume = false) + } + assertEquals("log callback must not be null", error.diagnostic) + } finally { + InjectedFaults.reset() + } + + // The one native already had is still the one it reaches. + val before = installed.size + map.setStyleUrl(UNSERVED_STYLE_URL + "?refused") + assertTrue( + pumpUntil(runtime) { installed.size > before }, + "the previous callback stopped receiving records", + ) + assertTrue(refused.isEmpty(), "the callback native refused received records anyway") + + // A later replacement is accepted, so the refusal left the registration able to take one. + Maplibre.setLogCallback({ refused += it }, consume = false) + map.setStyleUrl(UNSERVED_STYLE_URL + "?accepted") + assertTrue(pumpUntil(runtime) { refused.isNotEmpty() }, "the replacement heard nothing") + } + } + + /** + * The ring does not overflow while the host keeps pumping. + * + * The ring is bounded and drops the oldest record when it is full, which is a delivery loss the + * host would otherwise never learn of — so the shim counts what it dropped and the binding + * reports the count. This is what says the bound and the drain cadence go together: a style load + * that fails is one of the noisiest things MapLibre does, and one drain per pump keeps up with + * it. + */ + @Test + fun aPumpedHostLosesNoRecordToTheRing() { + val records = mutableListOf() + withFailingStyleLoads { runtime, map -> + Maplibre.setLogCallback({ records += it }, consume = false) + val droppedBefore = CallbackRing.droppedRecords + + repeat(NOISY_LOADS) { attempt -> + map.setStyleUrl("$UNSERVED_STYLE_URL?noisy=$attempt") + pumpTurns(runtime, NOISY_PUMPS) + } + + assertTrue(records.isNotEmpty(), "the noisy loads produced no records at all") + assertEquals( + droppedBefore, + CallbackRing.droppedRecords, + "the ring dropped records while the host was pumping", + ) + } + } + + @Test + fun aSeverityMaskRefusesAValueTheModuleHasNoBitFor() { + assertEquals(1 shl 1, LogSeverity.INFO.nativeMask) + assertFailsWith { + Maplibre.setAsyncLogSeverities(setOf(LogSeverity(900))) + } + } + + /** + * Pumps until the drain has caught up with everything already in the ring. + * + * A retirement marker travels behind the records it retires, so a callback that has just been + * replaced may still have records of its own coming. This spends the turns those need and reports + * the count they reached, which is the baseline every "went quiet" assertion is made against. + */ + private fun quiesce(runtime: RuntimeHandle, records: List): Int { + var settled = records.size + repeat(QUIET_PUMPS) { + pumpTurns(runtime, 1) + if (records.size == settled) return settled + settled = records.size + } + return records.size + } + + /** Runs [body] with a map, with async logging on and the log callback cleared afterwards. */ + private fun withFailingStyleLoads(body: (RuntimeHandle, MapHandle) -> Unit) { + try { + Maplibre.setAsyncLogSeverities( + setOf(LogSeverity.INFO, LogSeverity.WARNING, LogSeverity.ERROR) + ) + withMap { runtime, map -> body(runtime, map) } + } finally { + runCatching { Maplibre.clearLogCallback() } + Maplibre.restoreDefaultAsyncLogSeverities() + } + } + + private companion object { + /** Long enough that a callback still installed would have been called at least once. */ + const val QUIET_PUMPS = 200 + + const val NOISY_LOADS = 16 + const val NOISY_PUMPS = 8 + + /** + * A scheme no file source serves, which MapLibre reports through the log as well as an event. + */ + const val UNSERVED_STYLE_URL = "jar:file:/packaged/style.json" + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/CustomGeometrySourceBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/CustomGeometrySourceBrowserTest.kt new file mode 100644 index 000000000..667a0b426 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/CustomGeometrySourceBrowserTest.kt @@ -0,0 +1,665 @@ +package org.maplibre.nativeffi.map + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.PageCanvas +import org.maplibre.nativeffi.assertPresentedColor +import org.maplibre.nativeffi.backgroundStyle +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.geo.CanonicalTileId +import org.maplibre.nativeffi.geo.Feature +import org.maplibre.nativeffi.geo.FeatureIdentifier +import org.maplibre.nativeffi.geo.GeoJson +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.internal.wasm.CustomGeometryBridge +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.pumpTurns +import org.maplibre.nativeffi.render.NativePointer +import org.maplibre.nativeffi.render.OpenGLOwnedTextureDescriptor +import org.maplibre.nativeffi.render.OpenGLSurfaceDescriptor +import org.maplibre.nativeffi.render.RenderSessionHandle +import org.maplibre.nativeffi.render.RenderTargetExtent +import org.maplibre.nativeffi.render.WebglContext +import org.maplibre.nativeffi.resource.ResourceProviderRoute +import org.maplibre.nativeffi.resource.ResourceResponse +import org.maplibre.nativeffi.resource.ResourceResponseStatus +import org.maplibre.nativeffi.runtime.RuntimeEventType +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.runtime.RuntimeOptions +import org.maplibre.nativeffi.style.CustomGeometrySourceCallback +import org.maplibre.nativeffi.style.CustomGeometrySourceOptions +import org.maplibre.nativeffi.style.SourceType +import org.maplibre.nativeffi.waitForMapEvent +import org.maplibre.nativeffi.withMap + +/** + * A custom geometry source whose tiles this binding supplies, end to end. + * + * MapLibre asks for a tile from the worker the source's tile loader runs on, and that worker cannot + * enter this WebAssembly instance. So the C shim copies the tile id into the module's record ring + * and the binding delivers it while draining that ring inside `pump`. The body therefore runs on an + * ordinary stack, after the pump's own C call has returned, and may answer with + * `setCustomGeometrySourceTileData` from inside itself — which is what shared expect/actual code + * written for JVM, Android, or Kotlin/Native does — or record the tile and answer afterwards. + * + * Retirement travels in the same ring, behind the notifications it retires: the shim invokes the + * tile callbacks once more with a tile id no real tile uses. So a source that is gone stops being + * delivered when that marker comes out of the ring, and the notifications already queued for it + * reach nobody. + * + * The proof of the working path has to be the whole chain rather than any part of it, and it has to + * be both answers. A fill layer over the custom source paints one colour over a background of + * another, so the canvas changing colour is the source's geometry arriving and nothing else. + */ +class CustomGeometrySourceBrowserTest { + // Spec coverage: BND-124. + @Test + fun presentsTheTilesItsCallbackWasAskedForAndTheHostSupplied() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val requested = mutableListOf() + val cancelled = mutableListOf() + val deferred = + object : CustomGeometrySourceCallback { + override fun fetchTile(tileId: CanonicalTileId) { + requested.add(tileId) + } + + override fun cancelTile(tileId: CanonicalTileId) { + cancelled.add(tileId) + } + } + + // The shared-code shape: answer the request inside the callback that made it. Failures are + // captured rather than thrown, because nothing above a callback body would catch one and the + // test would then only see a tile that never arrived. + val answeredInline = mutableListOf() + var inlineFailure: Throwable? = null + val inline = + object : CustomGeometrySourceCallback { + override fun fetchTile(tileId: CanonicalTileId) { + runCatching { + map.setCustomGeometrySourceTileData(SOURCE, tileId, worldFill()) + answeredInline.add(tileId) + } + .exceptionOrNull() + ?.let { if (inlineFailure == null) inlineFailure = it } + } + + override fun cancelTile(tileId: CanonicalTileId) {} + } + + val context = PageCanvas.context() + val session = + map.attachOpenGLSurface( + OpenGLSurfaceDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + // A WebGL context is already bound to its canvas, so there is no drawable to name. + NativePointer.NULL, + ) + ) + try { + map.setStyleJson(backgroundStyle(BACKGROUND_COLOR)) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(deferred)) + assertEquals(SourceType.CUSTOM_VECTOR, map.styleSourceType(SOURCE)) + map.addStyleLayerJson(fillLayer(), "") + + // Nothing asks a custom source for a tile until a layer that uses it is being drawn, so the + // request arrives while the map renders rather than when the source is added. + assertTrue( + renderUntil(runtime, session) { requested.isNotEmpty() }, + "the source never asked for a tile", + ) + // The whole world at the zoom the map opens at, which is the tile the fill below covers. + assertContains(requested, CanonicalTileId(0, 0, 0)) + + // The layer has a source and the source has no data, so what the canvas holds so far is the + // background alone. Asserted before the answer, so the colour below is provably the + // geometry arriving rather than a frame that was already there. + renderUntilSettled(runtime, session) + assertPresentedColor(context, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE) + + // The answer, from outside the callback. One polygon covering the world fills the viewport + // at this zoom, so the fill colour is what the canvas must come to show. + for (tileId in requested.toList()) { + map.setCustomGeometrySourceTileData(SOURCE, tileId, worldFill()) + } + assertTrue( + renderUntil(runtime, session) { map.isFullyLoaded }, + "the map never finished loading the tiles the host supplied", + ) + renderUntilSettled(runtime, session) + assertPresentedColor(context, FILL_RED, FILL_GREEN, FILL_BLUE) + + // A cancel is best-effort and may never arrive, so nothing here waits for one. What is true + // whenever one does arrive is that it names a tile this source asked for. + for (tileId in cancelled) assertContains(requested, tileId) + + // Removing the layer and then the source takes the geometry away again, which is the other + // half of the claim: the fill was the source's rather than anything the style held on its + // own. It also puts the canvas back to one colour, so the second half of this test starts + // from a frame that provably holds no custom geometry. + assertTrue(map.removeStyleLayer(FILL_LAYER)) + assertTrue(map.removeStyleSource(SOURCE)) + assertFalse(map.styleSourceExists(SOURCE)) + renderUntilSettled(runtime, session) + assertPresentedColor(context, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE) + + // The same source again, answered from inside the callback this time. This is the workflow + // a multiplatform host writes once and runs everywhere, and the claim is that it is not + // merely accepted here but that its geometry reaches the target: nothing between this and + // the assertion below supplies a tile. + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(inline)) + map.addStyleLayerJson(fillLayer(), "") + assertTrue( + renderUntil(runtime, session) { answeredInline.isNotEmpty() }, + "the callback never answered from inside itself: ${inlineFailure?.message}", + ) + assertNull(inlineFailure, "answering inside the callback failed") + assertTrue( + renderUntil(runtime, session) { map.isFullyLoaded }, + "the map never finished loading the tiles the callback answered with", + ) + renderUntilSettled(runtime, session) + assertPresentedColor(context, FILL_RED, FILL_GREEN, FILL_BLUE) + } finally { + session.close() + } + } + } + + /** + * The teardown paths, with tile requests in flight. + * + * A notification is a copy of a tile id sitting in the ring, so it outlives the source it belongs + * to and can still be there when that source is gone. What the binding promises is that such a + * notification reaches nobody — the retirement marker travels behind it, and delivery stops when + * the marker comes out — and that a source added again under the same id is a new registration + * rather than the old one coming back. + * + * Rendered into a texture of its own, because nothing here is about what a frame looks like and + * the one page canvas belongs to whichever test is presenting. + */ + // Spec coverage: BND-124. + @Test + fun dropsNotificationsForSourcesThatAreGoneAndReusesTheIdCleanly() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val first = RecordingCallback() + val second = RecordingCallback() + // What the registry held before this test, so each teardown below can be asserted to have put + // it back rather than to have reached zero by luck. A registration that outlives its source + // is + // invisible from the outside — it does nothing until native calls it, and the teardown is + // what + // made native calling it impossible — so this is what says it went. + val registrationsBefore = CustomGeometryBridge.liveRegistrations + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + try { + map.setStyleJson(backgroundStyle(BACKGROUND_COLOR)) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(first)) + map.addStyleLayerJson(fillLayer(), "") + assertTrue( + renderUntil(runtime, session) { first.tiles.isNotEmpty() }, + "the source never asked for a tile", + ) + + // Removed with the request still unanswered, so the tile is one MapLibre is still waiting + // on: dropping the layer retires that tile and produces the cancels this source's loader + // pushes into the ring, and the source goes before the ring has been drained. The layer + // goes first because native refuses to remove a source a layer still uses. From here the + // callback must hear nothing at all. + assertTrue(map.removeStyleLayer(FILL_LAYER)) + assertTrue(map.removeStyleSource(SOURCE)) + first.closeEra() + renderTurns(runtime, session, TEARDOWN_ATTEMPTS) + assertEquals(0, first.afterEra, "a removed source's callback was still called") + assertEquals( + registrationsBefore, + CustomGeometryBridge.liveRegistrations, + "removing the source left its registration behind", + ) + + // The same id again, and a different callback. A registration is reached through state + // native carries back, and that state is fresh, so the source added here is a new + // registration rather than the previous one under a familiar name. + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(second)) + map.addStyleLayerJson(fillLayer(), "") + assertTrue( + renderUntil(runtime, session) { second.tiles.isNotEmpty() }, + "the source added under the reused id never asked for a tile", + ) + assertEquals(0, first.afterEra, "the replaced callback was called for the new source") + + // Closing the map with a source still registered is the last teardown path, and the one + // that leaves native with no way to ask again: the map took its style, its sources, and + // their tile loaders with it. Retiring those tiles makes their loader push cancels from + // its own thread, which can land in the ring after the close has returned. + session.close() + map.close() + second.closeEra() + pumpTurns(runtime, TEARDOWN_ATTEMPTS) + assertEquals(0, second.afterEra, "a closed map's source callback was still called") + assertEquals( + registrationsBefore, + CustomGeometryBridge.liveRegistrations, + "closing the map left its source's registration behind", + ) + } finally { + session.close() + } + } finally { + context.close() + } + } + } + + /** + * A style reload, which drops every source the previous style held. + * + * The registration behind a custom geometry source belongs to that source, so a style that + * replaces it ends the registration too — while the tiles the previous style had are being + * retired, which is exactly when their loader pushes the cancels this must not deliver. + * + * A style arrives two ways and the moment differs. A style set as JSON has replaced the previous + * one by the time the call returns, so the registrations go there. A style set by URL loads + * later, and the only announcement it makes is the loaded event, so the registrations go when + * that event is polled — the behaviour `RuntimeHandle.pollEvent` documents on every platform. + * Both are asserted here, each at its own moment, because either alone would leave the other's + * window open. + */ + // Spec coverage: BND-124. + @Test + fun releasesSourcesAStyleReloadDropped() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val callback = RecordingCallback() + val registrationsBefore = CustomGeometryBridge.liveRegistrations + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + try { + map.setStyleJson(backgroundStyle(BACKGROUND_COLOR)) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(callback)) + map.addStyleLayerJson(fillLayer(), "") + assertTrue( + renderUntil(runtime, session) { callback.tiles.isNotEmpty() }, + "the source never asked for a tile", + ) + + map.setStyleJson(backgroundStyle(FILL_COLOR)) + callback.closeEra() + // Asserted before anything is polled or rendered, because this is the claim about a style + // set as JSON: the source is gone by the time the call returns, so its registration is + // too. + assertEquals( + registrationsBefore, + CustomGeometryBridge.liveRegistrations, + "setting a style as JSON left the dropped source's registration behind", + ) + assertFalse(map.styleSourceExists(SOURCE)) + renderTurns(runtime, session, TEARDOWN_ATTEMPTS) + assertEquals(0, callback.afterEra, "a dropped source's callback was still called") + + // The id belongs to nobody now, so it can be taken again — by a source of its own with a + // registration of its own. + val reloaded = RecordingCallback() + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(reloaded)) + assertEquals(SourceType.CUSTOM_VECTOR, map.styleSourceType(SOURCE)) + assertEquals(0, callback.afterEra, "the dropped callback was called for the new source") + + // A style by URL, answered by a provider route so that nothing here waits on a network. + // It + // has not replaced anything yet, so the registration is still the map's — which is what + // makes the event below the moment it stops being. + runtime.setResourceProvider(listOf(ResourceProviderRoute(url = STYLE_URL))) { _, handle -> + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = backgroundStyle(BACKGROUND_COLOR).encodeToByteArray() + } + ) + } + map.setStyleUrl(STYLE_URL) + reloaded.closeEra() + assertEquals( + registrationsBefore + 1, + CustomGeometryBridge.liveRegistrations, + "setting a style by URL released a registration before the style had loaded", + ) + + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + assertEquals( + registrationsBefore, + CustomGeometryBridge.liveRegistrations, + "the loaded style left the dropped source's registration behind", + ) + renderTurns(runtime, session, TEARDOWN_ATTEMPTS) + assertEquals(0, reloaded.afterEra, "a dropped source's callback was still called") + } finally { + session.close() + } + } finally { + context.close() + } + } + } + + /** + * A map closed from inside its own tile callback, and the runtime that has to survive it. + * + * Closing a map releases the source registrations it holds, and releasing one waits for a + * callback body that is already inside it. Here that body is the frame below the close, on the + * one stack this target has, so the wait can never finish and the binding refuses it rather than + * spinning forever. + * + * The refusal is not the claim. By the time it happens the map is destroyed and the wrapper is + * closed, so closing again does nothing and no later call can finish what the teardown did not — + * which makes the state this leaves behind permanent. The claim is therefore that the rest of the + * accounting happened anyway: **the runtime is still closable**. If it were not, a host would be + * left holding a runtime it can never close and a map that no longer exists, for the whole life + * of the page, with nothing it could do about either. + */ + @Test + fun aMapWhoseSourceTeardownFailedStillGivesUpItsRuntime() { + var closeFailure: Throwable? = null + var closed = false + val runtime = RuntimeHandle.create(RuntimeOptions()) + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = WIDTH + height = HEIGHT + }, + ) + val callback = + object : CustomGeometrySourceCallback { + override fun fetchTile(tileId: CanonicalTileId) { + // Only the first tile closes. The rest return at once, so exactly one body is inside the + // registration when the teardown begins. + if (closed) return + closed = true + closeFailure = runCatching { map.close() }.exceptionOrNull() + } + + override fun cancelTile(tileId: CanonicalTileId) {} + } + + val registrationsBefore = CustomGeometryBridge.liveRegistrations + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), context.descriptor()) + ) + map.setStyleJson(backgroundStyle(BACKGROUND_COLOR)) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(callback)) + map.addStyleLayerJson(fillLayer(), "") + assertTrue( + renderUntil(runtime, session) { closed }, + "no tile callback ever ran, so the close below never happened", + ) + session.close() + context.close() + + val failure = assertIs(closeFailure, "closing reported $closeFailure") + assertContains(failure.diagnostic, "callback") + // The map is gone whatever the teardown did: native destroyed it, and a wrapper that called + // itself live afterwards would offer calls that could only fail. + assertTrue(map.isClosed, "a map whose teardown failed was left claiming to be open") + assertEquals( + registrationsBefore, + CustomGeometryBridge.liveRegistrations, + "a source whose release was refused kept its registration, so a late tile could still reach it", + ) + + // The claim. A runtime the closed map still retained would refuse this for the life of the + // page, + // and nothing could release it. + val runtimeFailure = runCatching { runtime.close() }.exceptionOrNull() + assertNull( + runtimeFailure, + "the runtime could not be closed after its map's source teardown failed: $runtimeFailure", + ) + } + + /** Records what it was asked for, and whether anything arrived after its source was retired. */ + private class RecordingCallback : CustomGeometrySourceCallback { + val tiles = mutableListOf() + var afterEra = 0 + private set + + private var retired = false + + /** Marks the point past which this callback must never be called again. */ + fun closeEra() { + retired = true + } + + override fun fetchTile(tileId: CanonicalTileId) { + if (retired) afterEra++ else tiles.add(tileId) + } + + override fun cancelTile(tileId: CanonicalTileId) { + if (retired) afterEra++ + } + } + + /** + * A source native refuses leaves the callback already registered under that id serving tiles. + * + * This family needs nothing injected. A style holds one source per id, so adding a second under + * an id it already carries is refused by native itself — which is precisely a replacement + * failing, and it fails at the point that matters: the binding has already installed the + * replacement's registration state, because the shim reaches a tile callback through the pointer + * that installation places and a source added first could ask for a tile with nowhere to send it. + * + * So the refusal has to give that state back and leave the previous one alone, and both halves + * are asserted: the registry count says the replacement's state went, and the tiles that are + * still asked for say whose callback native reaches. + */ + // Spec coverage: BND-122. + @Test + fun aSourceReplacementNativeRefusesKeepsThePreviousCallback() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val installed = RecordingCallback() + val refused = RecordingCallback() + val registrationsBefore = CustomGeometryBridge.liveRegistrations + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + try { + map.setStyleJson(backgroundStyle(BACKGROUND_COLOR)) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(installed)) + map.addStyleLayerJson(fillLayer(), "") + assertTrue( + renderUntil(runtime, session) { installed.tiles.isNotEmpty() }, + "the source never asked for a tile", + ) + assertEquals(registrationsBefore + 1, CustomGeometryBridge.liveRegistrations) + + // The same id again. Native holds one source per id and says so. + val error = + assertFailsWith { + map.addCustomGeometrySource(SOURCE, CustomGeometrySourceOptions(refused)) + } + assertContains(error.diagnostic, "already exists") + + // The refused source's registration went back rather than holding the shim's listener + // open for a source that does not exist. + assertEquals( + registrationsBefore + 1, + CustomGeometryBridge.liveRegistrations, + "the refused source left its registration behind", + ) + + // And native still asks the callback that was already there, which is the half the count + // cannot show. + val askedBefore = installed.tiles.size + map.invalidateCustomGeometrySourceTile(SOURCE, installed.tiles.first()) + assertTrue( + renderUntil(runtime, session) { installed.tiles.size > askedBefore }, + "the source stopped asking the callback it was added with", + ) + assertTrue(refused.tiles.isEmpty(), "the refused source's callback was called anyway") + + assertTrue(map.removeStyleLayer(FILL_LAYER)) + assertTrue(map.removeStyleSource(SOURCE)) + assertEquals(registrationsBefore, CustomGeometryBridge.liveRegistrations) + } finally { + session.close() + } + } finally { + context.close() + } + } + } + + /** + * Renders until [predicate] holds, pumping the runtime in between. + * + * Both halves matter. Rendering is what makes MapLibre decide which tiles it needs, so a request + * is only produced while frames are being drawn; and the pump is what drains the ring the request + * arrived in. + */ + private fun renderUntil( + runtime: RuntimeHandle, + session: RenderSessionHandle, + predicate: () -> Boolean, + ): Boolean { + repeat(ATTEMPTS) { + if (predicate()) return true + session.renderUpdate() + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + return predicate() + } + + /** Renders [turns] frames, draining the ring between each. */ + private fun renderTurns(runtime: RuntimeHandle, session: RenderSessionHandle, turns: Int) { + repeat(turns) { + session.renderUpdate() + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + } + + /** + * Renders until the session has nothing left to draw. + * + * A render draws the parameters the map last handed the renderer, so the first one after a change + * still paints what came before it; the frame that matters is the last one. + */ + private fun renderUntilSettled(runtime: RuntimeHandle, session: RenderSessionHandle) { + var rendered = false + repeat(ATTEMPTS) { + if (session.renderUpdate()) { + rendered = true + } else if (rendered) { + return + } + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + } + + /** A fill layer over the custom source, which is what makes MapLibre ask for its tiles. */ + private fun fillLayer(): JsonValue = + JsonValue.ObjectValue( + listOf( + JsonValue.Member("id", JsonValue.StringValue(FILL_LAYER)), + JsonValue.Member("type", JsonValue.StringValue("fill")), + JsonValue.Member("source", JsonValue.StringValue(SOURCE)), + JsonValue.Member( + "paint", + JsonValue.ObjectValue( + listOf(JsonValue.Member("fill-color", JsonValue.StringValue(FILL_COLOR))) + ), + ), + ) + ) + + /** + * One polygon covering the world, which fills the viewport at the zoom the map opens at. + * + * A feature collection rather than a bare geometry, because MapLibre tiles a custom source's data + * only when it is one: anything else leaves the tile with no features and the fill invisible. + */ + private fun worldFill(): GeoJson = + GeoJson.FeatureCollection( + listOf( + Feature( + Geometry.Polygon( + listOf( + listOf( + LatLng(-85.0, -180.0), + LatLng(-85.0, 180.0), + LatLng(85.0, 180.0), + LatLng(85.0, -180.0), + LatLng(-85.0, -180.0), + ) + ) + ), + emptyList(), + FeatureIdentifier.Null, + ) + ) + ) + + private companion object { + const val WIDTH = PageCanvas.WIDTH + const val HEIGHT = PageCanvas.HEIGHT + + const val SOURCE = "custom-geometry" + // Answered by the provider above rather than fetched, so nothing here waits on a network. + const val STYLE_URL = "custom://custom-geometry-style.json" + const val FILL_LAYER = "custom-geometry-fill" + + // Three distinct channels each, so a path that swapped or duplicated one would still be caught. + const val BACKGROUND_COLOR = "#2060a0" + const val BACKGROUND_RED = 0x20 + const val BACKGROUND_GREEN = 0x60 + const val BACKGROUND_BLUE = 0xA0 + + const val FILL_COLOR = "#a06020" + const val FILL_RED = 0xA0 + const val FILL_GREEN = 0x60 + const val FILL_BLUE = 0x20 + + const val ATTEMPTS = 200 + const val TEARDOWN_ATTEMPTS = 32 + const val PUMP_MILLIS = 2L + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/DescriptorValidationBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/DescriptorValidationBrowserTest.kt new file mode 100644 index 000000000..72f24886f --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/DescriptorValidationBrowserTest.kt @@ -0,0 +1,231 @@ +package org.maplibre.nativeffi.map + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.Maplibre +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.render.MetalContextDescriptor +import org.maplibre.nativeffi.render.MetalOwnedTextureDescriptor +import org.maplibre.nativeffi.render.NativeBuffer +import org.maplibre.nativeffi.render.NativePointer +import org.maplibre.nativeffi.render.RenderBackend +import org.maplibre.nativeffi.render.RenderTargetExtent +import org.maplibre.nativeffi.render.VulkanBorrowedTextureDescriptor +import org.maplibre.nativeffi.render.VulkanContextDescriptor +import org.maplibre.nativeffi.render.VulkanOwnedTextureDescriptor +import org.maplibre.nativeffi.runtime.NetworkStatus +import org.maplibre.nativeffi.style.StyleLayerVisibility +import org.maplibre.nativeffi.withMap + +/** + * Inputs the binding refuses, and inputs it hands to native to refuse. + * + * The split is the whole point. A value the C ABI has no way to carry — a negative count for an + * unsigned field, an enum sentinel from another revision — is refused here, before anything is + * written into the module's heap. Everything else goes down and comes back as whatever native says, + * because reimplementing native's validation is how a binding starts disagreeing with it. + */ +class DescriptorValidationBrowserTest { + // Spec coverage: BND-068, BND-104, BND-160. + + @Test + fun signedCarriersRefuseValuesTheirUnsignedFieldsCannotHold() { + // Every one of these is a Kotlin Int or Long standing in for a C unsigned field. A negative + // value reaches native as a very large positive one, so it is stopped here instead. + assertFailsWith { + MapOptions().apply { + width = -1 + height = 1 + } + } + assertFailsWith { TileOptions().prefetchZoomDelta = -1 } + assertFailsWith { NativeBuffer.allocate(-1) } + assertFailsWith { RenderTargetExtent(-1, 1, 1.0) } + assertFailsWith { RenderTargetExtent(1, 1, 1.0).width = -1 } + + val nullPointer = NativePointer.NULL + assertFailsWith { + vulkanContext(nullPointer, graphicsQueueFamilyIndex = -1) + } + assertFailsWith { + vulkanContext(nullPointer).graphicsQueueFamilyIndex = -1 + } + assertFailsWith { vulkanBorrowedTexture(nullPointer, format = -1) } + assertFailsWith { vulkanBorrowedTexture(nullPointer).format = -1 } + } + + @Test + fun anEnumSentinelTheCApiCannotBeGivenIsRefusedBeforeDispatch() { + // An unknown value keeps its raw number, because it may be a real value from a later revision + // read back out of native. What it may not do is go back down as input. + assertEquals(900, MapMode(900).nativeValue) + assertEquals(901, TileLodMode(901).nativeValue) + assertEquals(902, NorthOrientation(902).nativeValue) + assertEquals(903, ConstrainMode(903).nativeValue) + assertEquals(904, ViewportMode(904).nativeValue) + assertEquals(905, NetworkStatus(905).nativeValue) + + withMap { runtime, map -> + assertFailsWith { + MapHandle.create(runtime, MapOptions().apply { mapMode = MapMode(900) }) + } + assertFailsWith { + map.tileOptions = TileOptions().apply { lodMode = TileLodMode(901) } + } + assertFailsWith { + map.viewportOptions = ViewportOptions().apply { northOrientation = NorthOrientation(902) } + } + assertFailsWith { + map.viewportOptions = ViewportOptions().apply { constrainMode = ConstrainMode(903) } + } + assertFailsWith { + map.viewportOptions = ViewportOptions().apply { viewportMode = ViewportMode(904) } + } + assertFailsWith { Maplibre.setNetworkStatus(NetworkStatus(905)) } + + // The map is unchanged by any of it, so each refusal happened before the call. + map.tileOptions = TileOptions() + map.viewportOptions = ViewportOptions() + } + } + + @Test + fun invalidMapAndValueInputsCarryNativesOwnRefusal() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + // Coordinate validation is native's, and reaches the caller as the public error shape for + // the status native returned, carrying native's own words for it. + val coordinate = + assertFailsWith { map.pixelForLatLng(LatLng(Double.NaN, 0.0)) } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, coordinate.status) + assertTrue(coordinate.diagnostic.contains("latitude"), coordinate.diagnostic) + + val projection = map.createProjection() + try { + val projected = + assertFailsWith { + projection.pixelForLatLng(LatLng(Double.NaN, 0.0)) + } + assertTrue(projected.diagnostic.contains("latitude"), projected.diagnostic) + } finally { + projection.close() + } + + // This one is a process-global entry point rather than one belonging to a map, and it says + // the same thing. + val meters = + assertFailsWith { + Maplibre.projectedMetersForLatLng(LatLng(Double.NaN, 0.0)) + } + assertTrue(meters.diagnostic.contains("latitude"), meters.diagnostic) + + // A structured value native refuses: JSON has no non-finite number, and the binding does + // not pre-empt that check. + val nonFinite = + assertFailsWith { + map.addStyleLayerJson( + JsonValue.ObjectValue( + listOf( + JsonValue.Member("id", JsonValue.StringValue("invalid-background")), + JsonValue.Member("type", JsonValue.StringValue("background")), + JsonValue.Member( + "paint", + JsonValue.ObjectValue( + listOf( + JsonValue.Member("background-opacity", JsonValue.DoubleValue(Double.NaN)) + ) + ), + ), + ) + ), + "", + ) + } + // Only that a message arrived: the wording of a style-value refusal is MapLibre's and + // moves with it, while what this covers is that native's message reaches the caller. + assertTrue(nonFinite.diagnostic.isNotEmpty(), "diagnostic was empty") + + // An unknown enum that the binding does not own an invariant for goes down and is refused + // there instead. + map.addStyleLayerJson( + JsonValue.ObjectValue( + listOf( + JsonValue.Member("id", JsonValue.StringValue("bg")), + JsonValue.Member("type", JsonValue.StringValue("background")), + ) + ), + "", + ) + assertFailsWith { + map.setLayerVisibility("bg", StyleLayerVisibility(900)) + } + } + } + + @Test + fun aBackendThisModuleWasNotBuiltWithIsRefusedBeforeASessionExists() { + withMap { _, map -> + val supported = Maplibre.supportedRenderBackends() + assertEquals(setOf(RenderBackend.OPENGL), supported) + + val extent = RenderTargetExtent(64, 64, 1.0) + val pointer = NativePointer.ofAddress(0x10L) + + val metal = + assertFailsWith { + map.attachMetalOwnedTexture( + MetalOwnedTextureDescriptor(extent, MetalContextDescriptor(pointer)) + ) + } + assertEquals(MaplibreStatus.UNSUPPORTED, metal.status) + + val vulkan = + assertFailsWith { + map.attachVulkanOwnedTexture( + VulkanOwnedTextureDescriptor(extent, context = vulkanContext(pointer)) + ) + } + assertEquals(MaplibreStatus.UNSUPPORTED, vulkan.status) + + // Refused before a session existed, so the map is still free to take one. + assertEquals(false, map.isClosed) + } + } + + private fun vulkanContext( + pointer: NativePointer, + graphicsQueueFamilyIndex: Int = 0, + ): VulkanContextDescriptor = + VulkanContextDescriptor( + pointer, + pointer, + pointer, + pointer, + graphicsQueueFamilyIndex, + pointer, + pointer, + ) + + private fun vulkanBorrowedTexture( + pointer: NativePointer, + format: Int = 0, + ): VulkanBorrowedTextureDescriptor = + VulkanBorrowedTextureDescriptor( + RenderTargetExtent(1, 1, 1.0), + 1, + 1, + vulkanContext(pointer), + pointer, + pointer, + format, + 0, + ) +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/MapCameraBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/MapCameraBrowserTest.kt new file mode 100644 index 000000000..415ffea1f --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/MapCameraBrowserTest.kt @@ -0,0 +1,369 @@ +package org.maplibre.nativeffi.map + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.camera.AnimationOptions +import org.maplibre.nativeffi.camera.BoundOptions +import org.maplibre.nativeffi.camera.BoundsConstraint +import org.maplibre.nativeffi.camera.CameraFitOptions +import org.maplibre.nativeffi.camera.CameraOptions +import org.maplibre.nativeffi.camera.EdgeInsets +import org.maplibre.nativeffi.camera.FreeCameraOptions +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.LatLngBounds +import org.maplibre.nativeffi.geo.Quaternion +import org.maplibre.nativeffi.geo.ScreenPoint +import org.maplibre.nativeffi.geo.Vec3 +import org.maplibre.nativeffi.pumpUntil +import org.maplibre.nativeffi.runtime.CameraChangeMode +import org.maplibre.nativeffi.runtime.RuntimeEventPayload +import org.maplibre.nativeffi.runtime.RuntimeEventType +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.withMap +import org.maplibre.nativeffi.withRuntime + +/** + * The camera, its transitions, and the projections that read from it. + * + * Camera state is the densest descriptor traffic in the API: optional fields with their own field + * masks, nested insets, and arrays of coordinates that go out and come back. A round trip is the + * only thing that catches a field written at the wrong offset, because native accepts the + * descriptor either way. + */ +class MapCameraBrowserTest { + // Spec coverage: BND-043, BND-060, BND-061, BND-070, BND-087, BND-102, BND-103. + + @Test + fun cameraAndViewportControlsRoundTripThroughTheOwnerThread() { + withMap { _, map -> + map.debugOptions = setOf(DebugOption.TILE_BORDERS, DebugOption.COLLISION) + assertEquals(setOf(DebugOption.TILE_BORDERS, DebugOption.COLLISION), map.debugOptions) + + map.isRenderingStatsViewEnabled = true + assertTrue(map.isRenderingStatsViewEnabled) + map.isRenderingStatsViewEnabled = false + assertFalse(map.isRenderingStatsViewEnabled) + + map.viewportOptions = + ViewportOptions().apply { + viewportMode = ViewportMode.DEFAULT + frustumOffset = EdgeInsets.ZERO + } + assertEquals(ViewportMode.DEFAULT, map.viewportOptions.viewportMode) + assertEquals(EdgeInsets.ZERO, map.viewportOptions.frustumOffset) + + map.tileOptions = + TileOptions().apply { + prefetchZoomDelta = 1 + lodMode = TileLodMode.DEFAULT + } + assertEquals(1, map.tileOptions.prefetchZoomDelta) + assertEquals(TileLodMode.DEFAULT, map.tileOptions.lodMode) + + val camera = + CameraOptions().apply { + center = LatLng(0.0, 0.0) + zoom = 1.0 + } + val animation = AnimationOptions().apply { durationMs = 0.0 } + + map.jumpTo(camera) + val snapshot = map.camera + assertEquals(0.0, assertNotNull(snapshot.center).latitude, TOLERANCE) + assertEquals(1.0, assertNotNull(snapshot.zoom), TOLERANCE) + // Two reads of an unchanged camera compare equal, so the snapshot is a value rather than a + // handle onto whatever the map holds now. + assertEquals(snapshot, map.camera) + + map.easeTo(camera, animation) + map.flyTo(camera, animation) + map.moveBy(0.0, 0.0) + map.moveByAnimated(0.0, 0.0, animation) + map.scaleBy(1.0, null) + map.scaleByAnimated(1.0, null, animation) + map.rotateBy(ScreenPoint(0.0, 0.0), ScreenPoint(0.0, 0.0)) + map.rotateByAnimated(ScreenPoint(0.0, 0.0), ScreenPoint(0.0, 0.0), animation) + map.pitchBy(0.0) + map.pitchByAnimated(0.0, animation) + map.cancelTransitions() + + // A gesture brackets the camera changes inside it. + assertFalse(map.isGestureInProgress) + map.isGestureInProgress = true + map.moveBy(8.0, -4.0) + assertTrue(map.isGestureInProgress) + map.isGestureInProgress = false + assertFalse(map.isGestureInProgress) + + map.jumpTo( + CameraOptions().apply { + center = LatLng(1.0, 1.0) + zoom = 2.0 + } + ) + assertEquals(1.0, assertNotNull(map.camera.center).latitude, TOLERANCE) + assertEquals(2.0, assertNotNull(map.camera.zoom), TOLERANCE) + + val fitOptions = + CameraFitOptions().apply { + padding = EdgeInsets.ZERO + bearing = 0.0 + pitch = 0.0 + } + val bounds = LatLngBounds(LatLng(-10.0, -10.0), LatLng(10.0, 10.0)) + // A null options descriptor and a present one take different branches through the same + // entry point. + map.cameraForLatLngBounds(bounds, null) + map.cameraForLatLngBounds(bounds, fitOptions) + map.cameraForLatLngs(listOf(LatLng(-1.0, -1.0), LatLng(1.0, 1.0)), fitOptions) + map.cameraForGeometry(Geometry.Point(LatLng(0.0, 0.0)), fitOptions) + map.latLngBoundsForCamera(camera) + map.latLngBoundsForCameraUnwrapped(camera) + + // A constraint is a sum type over the same descriptor, so both cases have to read back. + map.bounds = BoundOptions().apply { this.bounds = BoundsConstraint.Bounded(bounds) } + assertEquals(BoundsConstraint.Bounded(bounds), map.bounds.bounds) + map.bounds = BoundOptions().apply { this.bounds = BoundsConstraint.Unbounded } + assertEquals(BoundsConstraint.Unbounded, map.bounds.bounds) + + map.freeCameraOptions = + FreeCameraOptions().apply { + position = Vec3(0.0, 0.0, 0.0) + orientation = Quaternion(0.0, 0.0, 0.0, 1.0) + } + assertNotNull(map.freeCameraOptions.position) + assertNotNull(map.freeCameraOptions.orientation) + + map.projectionMode = ProjectionModeOptions().apply { axonometric = false } + assertEquals(false, map.projectionMode.axonometric) + + map.dumpDebugLogs() + } + } + + @Test + fun projectionHelpersRoundTripSingleAndBatchedCoordinates() { + withMap { _, map -> + val centre = LatLng(37.7749, -122.4194) + map.jumpTo( + CameraOptions().apply { + center = centre + zoom = 10.0 + } + ) + + val point = map.pixelForLatLng(centre) + val returned = map.latLngForPixel(point) + assertEquals(centre.latitude, returned.latitude, TOLERANCE) + assertEquals(centre.longitude, returned.longitude, TOLERANCE) + + // The batched form writes an array out and reads an array back, which is a different + // descriptor shape from the single one above. + val coordinates = listOf(centre, LatLng(0.0, 0.0)) + val points = map.pixelsForLatLngs(coordinates) + assertEquals(2, points.size) + assertTrue(points.all { it.x.isFinite() && it.y.isFinite() }) + val returnedAll = map.latLngsForPixels(points) + assertEquals(2, returnedAll.size) + assertEquals(coordinates[0].latitude, returnedAll[0].latitude, TOLERANCE) + assertEquals(coordinates[0].longitude, returnedAll[0].longitude, TOLERANCE) + + // A projection handle is a standalone snapshot: it keeps working after the map it came + // from has gone. + val projection = map.createProjection() + try { + projection.setCamera( + CameraOptions().apply { + center = LatLng(0.0, 0.0) + zoom = 2.0 + } + ) + assertNotNull(projection.camera.center) + projection.setVisibleCoordinates( + listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0)), + EdgeInsets.ZERO, + ) + projection.setVisibleGeometry( + Geometry.LineString(listOf(LatLng(0.0, 0.0), LatLng(1.0, 1.0))), + EdgeInsets.ZERO, + ) + val projected = projection.latLngForPixel(projection.pixelForLatLng(LatLng(0.0, 0.0))) + assertEquals(0.0, projected.latitude, TOLERANCE) + assertEquals(0.0, projected.longitude, TOLERANCE) + } finally { + projection.close() + } + } + } + + @Test + fun aProjectionOutlivesTheMapItWasTakenFrom() { + withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 128 + height = 128 + }, + ) + map.jumpTo( + CameraOptions().apply { + center = LatLng(10.0, 20.0) + zoom = 4.0 + } + ) + val projection = map.createProjection() + + // A snapshot rather than a view: closing the map does not take the projection with it, and + // it is not a child that holds the map open either. + map.close() + assertTrue(map.isClosed) + assertFalse(projection.isClosed) + + assertEquals(4.0, assertNotNull(projection.camera.zoom), TOLERANCE) + val returned = projection.latLngForPixel(projection.pixelForLatLng(LatLng(10.0, 20.0))) + assertEquals(10.0, returned.latitude, TOLERANCE) + assertEquals(20.0, returned.longitude, TOLERANCE) + + projection.close() + assertTrue(projection.isClosed) + projection.close() + assertFailsWith { projection.pixelForLatLng(LatLng(0.0, 0.0)) } + } + } + + @Test + fun aTransitionReportsItsOwnIdOnceThroughTheEventQueue() { + withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 128 + height = 128 + }, + ) + try { + // A zero-duration ease resolves inside the call and reports its end right away. An id + // above Long.MAX_VALUE round-trips as the unsigned bit pattern the caller passed in, + // which is what says the payload is read at its own width. + val instantId = (Long.MAX_VALUE.toULong() + 1UL).toLong() + map.easeTo(CameraOptions().apply { zoom = 2.0 }, transition(instantId, 0.0)) + val instant = drainCameraEvents(runtime) + assertEquals(listOf(instantId), instant.finished) + assertEquals(CameraChangeMode.IMMEDIATE, instant.lastChangeMode) + + // A running transition stays silent until it releases the camera. + map.easeTo(CameraOptions().apply { zoom = 12.0 }, transition(11L, 5_000.0)) + assertEquals(emptyList(), drainCameraEvents(runtime).finished) + + // A later camera command supersedes it, ending the transition it replaced. + map.easeTo(CameraOptions().apply { zoom = 13.0 }, transition(12L, 5_000.0)) + val superseded = drainCameraEvents(runtime) + assertEquals(listOf(11L), superseded.finished) + assertEquals(CameraChangeMode.ANIMATED, superseded.lastChangeMode) + + // Cancellation ends the superseding transition. + map.cancelTransitions() + assertEquals(listOf(12L), drainCameraEvents(runtime).finished) + + // Omitting the id leaves the transition silent. + map.easeTo( + CameraOptions().apply { zoom = 14.0 }, + AnimationOptions().apply { durationMs = 0.0 }, + ) + assertEquals(emptyList(), drainCameraEvents(runtime).finished) + } finally { + map.close() + } + } + } + + @Test + fun aCompletedTransitionReachesItsCameraAndReportsItsIdOnce() { + withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 128 + height = 128 + mapMode = MapMode.STATIC + }, + ) + try { + map.easeTo(CameraOptions().apply { zoom = 5.0 }, transition(21L, 5_000.0)) + // A still-image request runs a static map's pending transitions to their end. + map.requestStillImage() + + val finished = mutableListOf() + pumpUntil( + runtime, + onEvent = { + if (it.type == RuntimeEventType.MAP_CAMERA_TRANSITION_FINISHED) { + finished += + assertIs(it.payload).transitionId + } + }, + ) { + finished.isNotEmpty() + } + + assertEquals(listOf(21L), finished) + assertEquals(5.0, assertNotNull(map.camera.zoom), TOLERANCE) + + // The completed transition reports its end once; later pumping adds nothing. + repeat(50) { + runtime.pump(1) + while (true) { + val event = runtime.pollEvent() ?: break + if (event.type == RuntimeEventType.MAP_CAMERA_TRANSITION_FINISHED) { + finished += + assertIs(event.payload).transitionId + } + } + } + assertEquals(listOf(21L), finished) + } finally { + map.close() + } + } + } + + private fun transition(transitionId: Long, durationMs: Double): AnimationOptions = + AnimationOptions().apply { + this.transitionId = transitionId + this.durationMs = durationMs + } + + private class CameraEvents(val finished: List, val lastChangeMode: CameraChangeMode?) + + private fun drainCameraEvents(runtime: RuntimeHandle): CameraEvents { + val finished = mutableListOf() + var lastChangeMode: CameraChangeMode? = null + runtime.pump(0) + while (true) { + val event = runtime.pollEvent() ?: return CameraEvents(finished, lastChangeMode) + when (event.type) { + RuntimeEventType.MAP_CAMERA_TRANSITION_FINISHED -> + finished += + assertIs(event.payload).transitionId + RuntimeEventType.MAP_CAMERA_DID_CHANGE -> lastChangeMode = CameraChangeMode(event.code) + else -> Unit + } + } + } + + private companion object { + /** Both directions are double precision, so a round trip loses far less than this. */ + const val TOLERANCE = 1e-6 + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/MapHandleBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/MapHandleBrowserTest.kt new file mode 100644 index 000000000..bba9e0c27 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/MapHandleBrowserTest.kt @@ -0,0 +1,188 @@ +package org.maplibre.nativeffi.map + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.camera.AnimationOptions +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.runtime.RuntimeOptions +import org.maplibre.nativeffi.withRuntime + +/** + * A map exercised through the calls whose descriptors are optional. + * + * The C API reads a null descriptor as its own default — a null anchor is the screen centre, a null + * animation is a zero-duration change — so passing null is ordinary use rather than an edge case. + * On this target that is the one shape where a call places *nothing* in the module's heap, which is + * why it has its own coverage: the binding measures before it allocates, and a measure of zero has + * to stay a legal answer rather than becoming a refused allocation. + */ +class MapHandleBrowserTest { + // Spec coverage: BND-024, BND-042, BND-100, BND-108. + + @Test + fun aMapReportsTheExtentAndModeItWasCreatedWith() { + withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 512 + height = 256 + scaleFactor = 2.0 + mapMode = MapMode.STATIC + fastPforEnabled = true + }, + ) + try { + val size = map.size + assertEquals(512, size.width) + assertEquals(256, size.height) + assertEquals(2.0, size.scaleFactor) + assertEquals(runtime, map.runtime()) + assertEquals(false, map.isClosed) + } finally { + map.close() + } + + // Release runs through the runtime that parented it: closed once, closed idempotently, and + // refusing later use before anything crosses into the module. + assertEquals(true, map.isClosed) + map.close() + assertFailsWith { map.setStyleJson(EMPTY_STYLE_JSON) } + + // The parent outlived its child and is still usable. + runtime.pump(0) + } + } + + @Test + fun theLoadedStyleDocumentAndTheRequestedUrlReadBackSeparately() { + withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 64 + height = 64 + }, + ) + try { + // Nothing parsed and nothing requested yet. + assertEquals("", map.loadedStyleJson()) + assertEquals("", map.styleUrl()) + + // The document reads back byte for byte, so it can be handed straight back. + map.setStyleJson(STYLE_WITH_UNICODE) + assertEquals(STYLE_WITH_UNICODE, map.loadedStyleJson()) + // Inline JSON clears the URL. + assertEquals("", map.styleUrl()) + + // The URL is request state, recorded before the load can succeed, while the document + // still reports the style that last parsed. + map.setStyleUrl("https://example.com/style.json") + assertEquals("https://example.com/style.json", map.styleUrl()) + assertEquals(STYLE_WITH_UNICODE, map.loadedStyleJson()) + } finally { + map.close() + } + } + } + + @Test + fun aNullTerminatedInputCarryingAnEmbeddedNulIsRefused() { + withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 64 + height = 64 + }, + ) + try { + // These two take a null-terminated C string rather than a string view, so a NUL in the + // middle would truncate the value instead of being carried as a byte. + val url = + assertFailsWith { + map.setStyleUrl("https://example.com/a" + NUL + "b.json") + } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, url.status) + assertEquals("url cannot contain embedded NUL characters", url.diagnostic) + + val json = assertFailsWith { map.setStyleJson("{" + NUL + "}") } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, json.status) + assertEquals("json cannot contain embedded NUL characters", json.diagnostic) + + // Refused before the call, so nothing was requested and nothing parsed. + assertEquals("", map.styleUrl()) + assertEquals("", map.loadedStyleJson()) + } finally { + map.close() + } + } + } + + private fun withMap(body: (MapHandle) -> T): T { + val runtime = RuntimeHandle.create(RuntimeOptions()) + try { + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 64 + height = 64 + }, + ) + try { + return body(map) + } finally { + map.close() + } + } finally { + runtime.close() + } + } + + @Test + fun aCameraChangeAcceptsEveryOptionalDescriptorAsNull() { + withMap { map -> + // Each of these measures zero bytes, because the only descriptor it could place is absent. + map.scaleBy(2.0, null) + map.moveByAnimated(10.0, 10.0, null) + map.pitchByAnimated(5.0, null) + map.scaleByAnimated(2.0, null, null) + + // The map still answers afterwards, so the calls reached native rather than being refused + // before they crossed into the module. + assertEquals(false, map.isClosed) + } + } + + @Test + fun aCameraChangeAcceptsTheSameDescriptorsWhenPresent() { + withMap { map -> + // The other half of the pair: the same entry points with a descriptor to place, so the + // zero-byte path above is shown to be a real branch rather than the only one that works. + map.scaleBy(2.0, org.maplibre.nativeffi.geo.ScreenPoint(16.0, 16.0)) + map.moveByAnimated(10.0, 10.0, AnimationOptions().also { it.durationMs = 0.0 }) + assertEquals(false, map.isClosed) + } + } + + private companion object { + /** The character C reads as the end of a string, which no null-terminated input may carry. */ + val NUL: Char = Char(0) + + /** + * A document with non-ASCII text, so the readback is checked for byte fidelity rather than only + * for ASCII surviving a UTF-8 round trip through two heaps. + */ + const val STYLE_WITH_UNICODE = + """{"version":8,"name":"caf\u00e9 \u2014 \u5730\u56fe","sources":{},"layers":[]}""" + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/StyleBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/StyleBrowserTest.kt new file mode 100644 index 000000000..d2f59e862 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/map/StyleBrowserTest.kt @@ -0,0 +1,579 @@ +package org.maplibre.nativeffi.map + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.BACKGROUND_STYLE_JSON +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.assertResultHandleDestroyed +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.geo.CanonicalTileId +import org.maplibre.nativeffi.geo.Feature +import org.maplibre.nativeffi.geo.FeatureIdentifier +import org.maplibre.nativeffi.geo.GeoJson +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapArena +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.JsonMarshal +import org.maplibre.nativeffi.internal.wasm.generated.mln_json_snapshot_get +import org.maplibre.nativeffi.internal.wasm.generated.mln_style_id_list_count +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.render.PremultipliedRgba8Image +import org.maplibre.nativeffi.style.CustomGeometrySourceCallback +import org.maplibre.nativeffi.style.CustomGeometrySourceOptions +import org.maplibre.nativeffi.style.GeoJsonSourceOptions +import org.maplibre.nativeffi.style.LocationIndicatorImageKind +import org.maplibre.nativeffi.style.RasterDemEncoding +import org.maplibre.nativeffi.style.SourceType +import org.maplibre.nativeffi.style.StyleImageOptions +import org.maplibre.nativeffi.style.StyleLayerVisibility +import org.maplibre.nativeffi.style.StyleTransitionOptions +import org.maplibre.nativeffi.style.TileScheme +import org.maplibre.nativeffi.style.TileSourceOptions +import org.maplibre.nativeffi.style.VectorTileEncoding +import org.maplibre.nativeffi.withMap + +/** + * The style a map holds, written and read back through public values. + * + * Everything here crosses the boundary twice: a descriptor written into the module's heap at + * generated offsets, and a result read back out of storage the runtime reuses. The assertions are + * on the round trip rather than on the call succeeding, because a descriptor written at the wrong + * offset is accepted and produces a different answer rather than an error. + */ +class StyleBrowserTest { + // Spec coverage: BND-060, BND-061, BND-062, BND-063, BND-064, BND-066, BND-067, BND-069, + // BND-101, BND-105. + + @Test + fun sourcesAndLayersAreAddedQueriedAndRemovedThroughCopiedValues() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + map.addStyleSourceJson( + "parks", + JsonValue.ObjectValue( + listOf( + JsonValue.Member("type", JsonValue.StringValue("geojson")), + JsonValue.Member( + "data", + JsonValue.ObjectValue( + listOf( + JsonValue.Member("type", JsonValue.StringValue("FeatureCollection")), + JsonValue.Member("features", JsonValue.Array(emptyList())), + ) + ), + ), + ) + ), + ) + + assertTrue(map.styleSourceExists("parks")) + assertEquals(SourceType.GEOJSON, map.styleSourceType("parks")) + assertEquals(SourceType.GEOJSON, map.styleSourceInfo("parks")?.type) + val copiedSourceIds = map.styleSourceIds() + assertTrue(copiedSourceIds.contains("parks")) + + map.addStyleLayerJson( + JsonValue.ObjectValue( + listOf( + JsonValue.Member("id", JsonValue.StringValue("park-circles")), + JsonValue.Member("type", JsonValue.StringValue("circle")), + JsonValue.Member("source", JsonValue.StringValue("parks")), + ) + ), + "", + ) + assertTrue(map.styleLayerExists("park-circles")) + assertEquals("circle", map.styleLayerType("park-circles")) + val copiedLayerIds = map.styleLayerIds() + val copiedLayerJson = map.styleLayerJson("park-circles") + assertTrue(copiedLayerJson is JsonValue.ObjectValue) + + map.moveStyleLayer("park-circles", "") + map.setLayerProperty("park-circles", "circle-radius", JsonValue.DoubleValue(5.0)) + assertNotNull(map.layerProperty("park-circles", "circle-radius")) + map.setLayerFilter( + "park-circles", + JsonValue.Array(listOf(JsonValue.StringValue("has"), JsonValue.StringValue("kind"))), + ) + assertNotNull(map.layerFilter("park-circles")) + map.clearLayerFilter("park-circles") + + assertTrue(map.removeStyleLayer("park-circles")) + assertFalse(map.styleLayerExists("park-circles")) + assertTrue(map.removeStyleSource("parks")) + assertFalse(map.styleSourceExists("parks")) + + // The lists and the layer document were read out of runtime-owned storage that the + // removals + // above have since released, so a view rather than a copy would no longer read back. + assertTrue(copiedSourceIds.contains("parks")) + assertTrue(copiedLayerIds.contains("park-circles")) + assertEquals( + JsonValue.StringValue("park-circles"), + copiedLayerJson.members.firstOrNull { it.key == "id" }?.value, + ) + } + } + + @Test + fun layerBaseAccessorsRoundTripAndRejectWhatTheStyleCannotHold() { + withMap { _, map -> + map.setStyleJson(FILL_STYLE_JSON) + + assertEquals("", map.layerSourceLayer("fill")) + map.setLayerSourceLayer("fill", "roads") + assertEquals("roads", map.layerSourceLayer("fill")) + assertEquals("geo", map.layerSourceId("fill")) + + // A layer type that takes no source is rejected rather than silently ignored. + assertFailsWith { map.setLayerSourceLayer("bg", "roads") } + assertEquals("", map.layerSourceId("bg")) + + // An unset zoom range crosses the boundary as infinities, which is a distinct value from + // any zoom a caller could set. + assertEquals(Double.NEGATIVE_INFINITY, map.layerMinZoom("fill")) + assertEquals(Double.POSITIVE_INFINITY, map.layerMaxZoom("fill")) + map.setLayerMinZoom("fill", 4.0) + map.setLayerMaxZoom("fill", 12.5) + assertEquals(4.0, map.layerMinZoom("fill")) + assertEquals(12.5, map.layerMaxZoom("fill")) + + assertEquals(StyleLayerVisibility.VISIBLE, map.layerVisibility("fill")) + map.setLayerVisibility("fill", StyleLayerVisibility.NONE) + assertEquals(StyleLayerVisibility.NONE, map.layerVisibility("fill")) + + // An unknown raw enum keeps its value and is passed to C, which is what rejects it. + assertEquals(900, StyleLayerVisibility(900).nativeValue) + assertFailsWith { + map.setLayerVisibility("fill", StyleLayerVisibility(900)) + } + assertFailsWith { map.layerMinZoom("missing") } + } + } + + @Test + fun styleTransitionOptionsSeparateAnAbsentFieldFromAPresentZero() { + withMap { _, map -> + // A map with no style yet reports no duration or delay. The placement flag always reports, + // because MapLibre Native always holds a value for it. + val empty = map.styleTransitionOptions() + assertNull(empty.durationMs) + assertNull(empty.delayMs) + assertEquals(true, empty.enablePlacementTransitions) + + // The style parser fills in its own 300ms for a style that declares no transition. + map.setStyleJson(EMPTY_STYLE_JSON) + assertEquals(300.0, map.styleTransitionOptions().durationMs) + assertNull(map.styleTransitionOptions().delayMs) + + map.setStyleJson(TRANSITION_STYLE_JSON) + val declared = map.styleTransitionOptions() + assertEquals(750.0, declared.durationMs) + assertEquals(100.0, declared.delayMs) + + // A present zero stays distinguishable from an absent field, and an absent field clears + // what the style declared rather than merging into it. + val options = + StyleTransitionOptions().apply { + durationMs = 0.0 + enablePlacementTransitions = false + } + map.setStyleTransitionOptions(options) + assertEquals(options, map.styleTransitionOptions()) + + // Omitting the flag leaves the cross-fade on rather than clearing it. + map.setStyleTransitionOptions(StyleTransitionOptions().apply { durationMs = 250.0 }) + assertEquals(true, map.styleTransitionOptions().enablePlacementTransitions) + + // Loading a style replaces the override with what that style declares. + map.setStyleJson(TRANSITION_STYLE_JSON) + assertEquals(declared, map.styleTransitionOptions()) + + assertFailsWith { + map.setStyleTransitionOptions(StyleTransitionOptions().apply { delayMs = -1.0 }) + } + } + } + + /** + * A structured value written into the module's heap and read back out of it. + * + * The descriptor rather than a style: MapLibre stores a parsed style in containers of its own, + * which order members and discard repeats before the binding ever sees them again. What the C API + * requires a binding to preserve is what the `mln_value` descriptor carries, so that is what is + * written and read here — the same seam the other bindings use for this. + */ + @Test + fun aStructuredValueKeepsMemberOrderRepeatedNamesAndIntegerWidth() { + val structured = + JsonValue.ObjectValue( + listOf( + JsonValue.Member("zeta", JsonValue.StringValue("first")), + JsonValue.Member("alpha", JsonValue.UInt(-1L)), + JsonValue.Member("zeta", JsonValue.StringValue("second")), + JsonValue.Member("signed", JsonValue.Int(-9_007_199_254_740_993L)), + JsonValue.Member( + "nested", + JsonValue.Array( + listOf(JsonValue.Bool(true), JsonValue.Null, JsonValue.DoubleValue(0.5)) + ), + ), + ) + ) + + val size = JsonMarshal.measure(structured) + val copied = + Heap.withScratch(size) { block -> + val arena = HeapArena(block, size) + JsonMarshal.read(JsonMarshal.write(arena, structured)) + } + + val members = (copied as JsonValue.ObjectValue).members + assertEquals(listOf("zeta", "alpha", "zeta", "signed", "nested"), members.map { it.key }) + assertEquals(JsonValue.StringValue("first"), members[0].value) + assertEquals(JsonValue.StringValue("second"), members[2].value) + // An unsigned value whose bit pattern is all ones and a signed value past the range a double + // represents exactly both come back at their own width rather than through a double. + assertEquals(JsonValue.UInt(-1L), members[1].value) + assertEquals(JsonValue.Int(-9_007_199_254_740_993L), members[3].value) + assertEquals( + JsonValue.Array(listOf(JsonValue.Bool(true), JsonValue.Null, JsonValue.DoubleValue(0.5))), + members[4].value, + ) + assertEquals(structured, copied) + } + + @Test + fun aLayerPropertyExpressionRoundTripsThroughTheStyle() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + map.addStyleLayerJson( + JsonValue.ObjectValue( + listOf( + JsonValue.Member("id", JsonValue.StringValue("bg")), + JsonValue.Member("type", JsonValue.StringValue("background")), + ) + ), + "", + ) + + // A nested expression, so the value that comes back is a tree rather than a scalar. + val expression = + JsonValue.Array( + listOf( + JsonValue.StringValue("interpolate"), + JsonValue.Array(listOf(JsonValue.StringValue("linear"))), + JsonValue.Array(listOf(JsonValue.StringValue("zoom"))), + JsonValue.DoubleValue(0.0), + JsonValue.DoubleValue(0.25), + JsonValue.DoubleValue(10.0), + JsonValue.DoubleValue(0.75), + ) + ) + map.setLayerProperty("bg", "background-opacity", expression) + + val readBack = assertNotNull(map.layerProperty("bg", "background-opacity")) + val values = (readBack as JsonValue.Array).values + assertEquals(JsonValue.StringValue("interpolate"), values[0]) + assertEquals(JsonValue.Array(listOf(JsonValue.StringValue("zoom"))), values[2]) + assertEquals(JsonValue.DoubleValue(0.75), values.last()) + + // A scalar goes through the same path, so both shapes of the value union are covered. + map.setLayerProperty("bg", "background-opacity", JsonValue.DoubleValue(0.5)) + assertEquals( + JsonValue.DoubleValue(0.5), + assertNotNull(map.layerProperty("bg", "background-opacity")), + ) + } + } + + @Test + fun styleImagesCopyPixelsAndMetadataInBothDirections() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + // Caller-owned storage: the array is mutated straight after the call, so the descriptor has + // to have snapshotted it. + val pixels = byteArrayOf(1, 2, 3, 4) + val image = PremultipliedRgba8Image(1, 1, 4, pixels) + pixels[0] = 9 + + map.setStyleImage( + "dot", + image, + StyleImageOptions().apply { + pixelRatio = 2.0f + sdf = true + }, + ) + assertTrue(map.styleImageExists("dot")) + assertEquals(2.0f, map.styleImageInfo("dot")?.pixelRatio) + assertEquals(true, map.styleImageInfo("dot")?.sdf) + // Read twice, because a readback that released the wrong handle would fail the second time. + assertEquals(image, map.copyStyleImagePremultipliedRgba8("dot")?.image) + assertEquals(image, map.copyStyleImagePremultipliedRgba8("dot")?.image) + + map.addLocationIndicatorLayer("location", "") + assertEquals("location-indicator", map.styleLayerType("location")) + map.setLocationIndicatorLocation("location", LatLng(0.0, 0.0), 0.0) + map.setLocationIndicatorBearing("location", 45.0) + map.setLocationIndicatorAccuracyRadius("location", 10.0) + map.setLocationIndicatorImageName("location", LocationIndicatorImageKind.TOP, "dot") + + assertTrue(map.removeStyleImage("dot")) + assertFalse(map.styleImageExists("dot")) + assertNull(map.styleImageInfo("dot")) + } + } + + @Test + fun tileAndGeoJsonSourceOptionsReachNativeAsWrittenDescriptors() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + map.addVectorSourceTiles( + "vector", + listOf("https://example.com/vector/{z}/{x}/{y}.pbf"), + TileSourceOptions().apply { + minZoom = 0.0 + maxZoom = 14.0 + attribution = "vector attribution" + scheme = TileScheme.XYZ + tileSize = 512 + vectorEncoding = VectorTileEncoding.MVT + }, + ) + assertEquals(SourceType.VECTOR, map.styleSourceType("vector")) + assertEquals("vector attribution", map.styleSourceInfo("vector")?.attribution) + + map.addRasterSourceTiles( + "raster", + listOf("https://example.com/raster/{z}/{x}/{y}.png"), + TileSourceOptions().apply { + tileSize = 256 + scheme = TileScheme.TMS + }, + ) + assertEquals(SourceType.RASTER, map.styleSourceType("raster")) + + map.addRasterDemSourceTiles( + "dem", + listOf("https://example.com/dem/{z}/{x}/{y}.png"), + TileSourceOptions().apply { + tileSize = 512 + rasterDemEncoding = RasterDemEncoding.TERRARIUM + }, + ) + assertEquals(SourceType.RASTER_DEM, map.styleSourceType("dem")) + map.addHillshadeLayer("hillshade", "dem", "") + assertEquals("hillshade", map.styleLayerType("hillshade")) + map.addColorReliefLayer("relief", "dem", "") + assertEquals("color-relief", map.styleLayerType("relief")) + + // A nested descriptor tree: options carrying a structured value, over data carrying a + // geometry, properties and an identifier. + map.addGeoJsonSourceData( + "points", + GeoJson.FeatureCollection( + listOf( + Feature( + Geometry.Point(LatLng(0.0, 0.0)), + listOf(JsonValue.Member("weight", JsonValue.DoubleValue(2.0))), + FeatureIdentifier.StringValue("first"), + ) + ) + ), + GeoJsonSourceOptions().apply { + minZoom = 0.0 + maxZoom = 14.0 + tolerance = 0.5 + tileSize = 256 + buffer = 64 + lineMetrics = true + cluster = true + clusterRadius = 40 + clusterMaxZoom = 13.0 + clusterMinPoints = 2 + clusterProperties = + JsonValue.ObjectValue( + listOf( + JsonValue.Member( + "total", + JsonValue.Array( + listOf( + JsonValue.StringValue("+"), + JsonValue.Array( + listOf(JsonValue.StringValue("get"), JsonValue.StringValue("weight")) + ), + ) + ), + ) + ) + ) + }, + ) + assertEquals(SourceType.GEOJSON, map.styleSourceType("points")) + + // A clustered source indexes every feature as a point, so native refuses replacement data + // that is not a feature collection. The refusal is native's, not the binding's. + assertFailsWith { + map.setGeoJsonSourceData("points", GeoJson.GeometryValue(Geometry.Point(LatLng(1.0, 1.0)))) + } + + // An option value native rejects is not swallowed by the binding either. + assertFailsWith { + map.addGeoJsonSourceUrl( + "invalid-zooms", + "https://example.com/places.geojson", + GeoJsonSourceOptions().apply { + minZoom = 12.0 + maxZoom = 4.0 + }, + ) + } + assertFalse(map.styleSourceExists("invalid-zooms")) + } + } + + @Test + fun imageSourcesCopyCoordinatesAndPixels() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + val coordinates = + listOf(LatLng(1.0, 1.0), LatLng(1.0, 2.0), LatLng(0.0, 2.0), LatLng(0.0, 1.0)) + + map.addImageSourceImage( + "overlay", + coordinates, + PremultipliedRgba8Image(1, 1, 4, byteArrayOf(4, 3, 2, 1)), + ) + assertEquals(SourceType.IMAGE, map.styleSourceType("overlay")) + assertEquals(coordinates, map.imageSourceCoordinates("overlay")) + + val moved = coordinates.reversed() + map.setImageSourceCoordinates("overlay", moved) + assertEquals(moved, map.imageSourceCoordinates("overlay")) + map.setImageSourceImage("overlay", PremultipliedRgba8Image(1, 1, 4, byteArrayOf(1, 1, 1, 1))) + + // The list handed back is the caller's own, so mutating it cannot reach the source. + assertEquals(moved, map.imageSourceCoordinates("overlay")) + } + } + + /** + * A custom geometry source, added and then described by the style that holds it. + * + * The workflow this source exists for — tiles requested by MapLibre and supplied by host code — + * is `CustomGeometrySourceBrowserTest`'s. What is asserted here is that the source is an ordinary + * member of the style once it has been added, and that the rest of its family reports a source it + * cannot find the way native does. + */ + @Test + fun aCustomGeometrySourceJoinsTheStyleItWasAddedTo() { + withMap { _, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + val callback = + object : CustomGeometrySourceCallback { + override fun fetchTile(tileId: CanonicalTileId) = Unit + } + map.addCustomGeometrySource( + "custom", + CustomGeometrySourceOptions(callback).apply { + minZoom = 0.0 + maxZoom = 14.0 + tolerance = 0.375 + tileSize = 512 + buffer = 64 + clip = true + wrap = false + }, + ) + assertTrue(map.styleSourceExists("custom")) + assertEquals(SourceType.CUSTOM_VECTOR, map.styleSourceType("custom")) + + assertTrue(map.removeStyleSource("custom")) + assertFalse(map.styleSourceExists("custom")) + + // With no source to name, native is what rejects the rest of the family, and it does so as + // an invalid argument. + assertFailsWith { + map.setCustomGeometrySourceTileData( + "custom", + CanonicalTileId(0, 0, 0), + GeoJson.FeatureCollection(emptyList()), + ) + } + } + } + + /** + * A list and a snapshot both belong to the call that made them, so a failed copy still ends them. + * + * These two are the other kinds of native result handle the style API produces, and both are read + * the same way a query result is: through a block the binding allocates before it can touch + * native storage at all. So the failure injected is that allocation being refused, and the + * question is whether the handle native had already produced went with it. It cannot be seen from + * host code — a leaked handle sits in the module's table doing nothing — so each one is replayed + * against native afterwards, which is the only party that can say. + */ + // Spec coverage: BND-066. + @Test + fun aFailedListOrSnapshotCopyDestroysTheNativeHandleRatherThanLeakingIt() { + withMap { _, map -> + map.setStyleJson(BACKGROUND_STYLE_JSON) + // Both calls first, so what the injected failure changes is the copy rather than a style + // that had nothing to answer with. + assertTrue(map.styleLayerIds().contains("background")) + assertNotNull(map.styleLayerJson("background")) + + val list: Long + val snapshot: Long + try { + InjectedFaults.failResultCopies() + val listError = assertFailsWith { map.styleLayerIds() } + assertTrue(listError.diagnostic.contains("could not allocate"), listError.diagnostic) + list = + assertNotNull( + InjectedFaults.takeCopiedResults().singleOrNull(), + "listing the layer ids did not reach the copy", + ) + + InjectedFaults.failResultCopies() + assertFailsWith { map.styleLayerJson("background") } + snapshot = + assertNotNull( + InjectedFaults.takeCopiedResults().singleOrNull(), + "reading the layer JSON did not reach the copy", + ) + } finally { + InjectedFaults.reset() + } + assertResultHandleDestroyed(list, "mln_style_id_list", ::mln_style_id_list_count) + assertResultHandleDestroyed(snapshot, "mln_json_snapshot", ::mln_json_snapshot_get) + + // And the style is unharmed: both calls answer as they did before. + assertTrue(map.styleLayerIds().contains("background")) + assertNotNull(map.styleLayerJson("background")) + } + } + + private companion object { + const val FILL_STYLE_JSON = + """{"version":8,"sources":{"geo":{"type":"geojson","data":""" + + """{"type":"FeatureCollection","features":[]}}},"layers":[""" + + """{"id":"bg","type":"background"},{"id":"fill","type":"fill","source":"geo"}]}""" + + const val TRANSITION_STYLE_JSON = + """{"version":8,"transition":{"duration":750,"delay":100},"sources":{},"layers":[]}""" + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/BrowserPresentationTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/BrowserPresentationTest.kt new file mode 100644 index 000000000..a158ae31f --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/BrowserPresentationTest.kt @@ -0,0 +1,301 @@ +package org.maplibre.nativeffi.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.maplibre.nativeffi.PageCanvas +import org.maplibre.nativeffi.assertPresentedColor +import org.maplibre.nativeffi.assertRenderedColor +import org.maplibre.nativeffi.backgroundStyle +import org.maplibre.nativeffi.drain +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.runtime.RuntimeEventType +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.waitForMapEvent +import org.maplibre.nativeffi.withMap + +/** + * Puts a frame on the page's canvas, for each of the three render target families. + * + * This is a different claim from the one `BrowserRenderTest` makes. That test renders into a + * private surface and reads it back, which proves the map drew the right pixels and nothing about + * where they went. Here the target is the `` element the host page owns, transferred to + * this thread as it was created, and every assertion is against framebuffer zero of that element: a + * surface session draws straight into it, and a texture session's frame reaches it only by being + * blitted there. + * + * Presentation is zero-copy in all three cases. Nothing here reads a pixel back to show one; the + * readbacks are assertions about what a target holds, not steps in getting it onto the canvas. + * + * All three share one context, because the module's link names one element id and a canvas hands + * out one WebGL context. A page showing more than one map at once is a documented limitation of + * this binding rather than something a test could reach. + * + * The specification's test table has no row for presenting, because every other platform's host + * does it with its own graphics API and outside the binding entirely. The one row these tests do + * close is BND-171: a texture native can look up has to belong to the render thread's context + * table, and until a host could run its own GL work there it could not supply one. + */ +class BrowserPresentationTest { + @Test + fun presentsASurfaceSessionToThePageCanvas() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val context = PageCanvas.context() + val session = map.attachOpenGLSurface(surfaceDescriptor(context, WIDTH, HEIGHT)) + try { + renderStyle(runtime, map, session, SURFACE_COLOR) + assertPresentedColor(context, SURFACE_RED, SURFACE_GREEN, SURFACE_BLUE) + + // Resizing is two steps, and both are the host's. The canvas's drawing buffer is what a + // frame has room to land in, and the session's extent is what MapLibre lays a frame out + // for; neither implies the other, and a canvas can only be sized on the thread holding it. + // The colour changes with the size so that what arrives is provably the new frame rather + // than the old one still sitting in a preserved drawing buffer. + context.resizeCanvas(HALF_WIDTH, HALF_HEIGHT) + session.resize(HALF_WIDTH, HALF_HEIGHT, 1.0) + renderStyle(runtime, map, session, RESIZED_COLOR) + assertPresentedColor( + context, + RESIZED_RED, + RESIZED_GREEN, + RESIZED_BLUE, + HALF_WIDTH, + HALF_HEIGHT, + ) + + // Detaching gives the map back without closing the session, and the canvas stays with this + // thread rather than with the session, so the same context serves a second one. That is the + // whole reason a canvas is transferred at thread creation instead of at attach. + session.detach() + } finally { + session.close() + } + + val reattached = map.attachOpenGLSurface(surfaceDescriptor(context, HALF_WIDTH, HALF_HEIGHT)) + try { + renderStyle(runtime, map, reattached, REATTACHED_COLOR) + assertPresentedColor( + context, + REATTACHED_RED, + REATTACHED_GREEN, + REATTACHED_BLUE, + HALF_WIDTH, + HALF_HEIGHT, + ) + } finally { + reattached.close() + } + } + } + + @Test + fun presentsAnOwnedTextureToThePageCanvas() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val context = PageCanvas.context() + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), context.descriptor()) + ) + try { + renderStyle(runtime, map, session, OWNED_COLOR) + + // The frame is what names the texture, and it is borrowed for as long as the handle is + // open, + // so the blit happens while it is held rather than after it is given back. + val frame = session.acquireOpenGLOwnedTextureFrame() + try { + val rendered = frame.frame() + assertNotEquals(0, rendered.texture()) + assertEquals(WIDTH, rendered.width()) + assertEquals(HEIGHT, rendered.height()) + // What the target holds, then what the canvas holds. The second is the claim; the first + // is + // what makes a failure of the second say which half broke. + assertRenderedColor( + context.readPixels(rendered.texture(), WIDTH, HEIGHT), + OWNED_RED, + OWNED_GREEN, + OWNED_BLUE, + ) + context.presentTexture(rendered.texture(), rendered.width(), rendered.height()) + } finally { + frame.close() + } + + assertPresentedColor(context, OWNED_RED, OWNED_GREEN, OWNED_BLUE) + } finally { + session.close() + } + } + } + + // Spec coverage: BND-171. + @Test + fun presentsABorrowedTextureAndThenASecondOne() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val context = PageCanvas.context() + // Created in the session's own context, because WebGL shares no objects between contexts: a + // texture made through another context names nothing the session could attach. + val first = context.createTexture(WIDTH, HEIGHT) + val second = context.createTexture(WIDTH, HEIGHT) + assertNotEquals(first, second) + try { + val session = map.attachOpenGLBorrowedTexture(borrowedDescriptor(context, first)) + try { + renderStyle(runtime, map, session, BORROWED_COLOR) + context.presentTexture(first, WIDTH, HEIGHT) + assertPresentedColor(context, BORROWED_RED, BORROWED_GREEN, BORROWED_BLUE) + + // The same session, a different texture, and a different colour. Presenting the second + // texture is what proves the target really moved: presenting the first one would keep + // showing the colour it was left holding. + session.setOpenGLBorrowedTextureTarget(borrowedDescriptor(context, second)) + renderStyle(runtime, map, session, RETARGETED_COLOR) + assertRenderedColor( + context.readPixels(second, WIDTH, HEIGHT), + RETARGETED_RED, + RETARGETED_GREEN, + RETARGETED_BLUE, + ) + context.presentTexture(second, WIDTH, HEIGHT) + assertPresentedColor(context, RETARGETED_RED, RETARGETED_GREEN, RETARGETED_BLUE) + session.close() + + // Closing the session left both textures alone. A caller-owned target only borrows what + // it + // is given, so presenting the first one still puts its own colour on the canvas — which + // it + // could not do if the session had deleted it, or drawn over it, on the way out. + context.presentTexture(first, WIDTH, HEIGHT) + assertPresentedColor(context, BORROWED_RED, BORROWED_GREEN, BORROWED_BLUE) + } finally { + session.close() + } + } finally { + // Released only once no target borrows them: a session naming a destroyed texture renders + // into nothing. + context.destroyTexture(second) + context.destroyTexture(first) + } + } + } + + private fun surfaceDescriptor( + context: WebglContext, + width: Int, + height: Int, + ): OpenGLSurfaceDescriptor = + OpenGLSurfaceDescriptor( + RenderTargetExtent(width, height, 1.0), + context.descriptor(), + // A WebGL context is already bound to its canvas, so there is no drawable to name where every + // other OpenGL provider names one, and native refuses anything else here. + NativePointer.NULL, + ) + + private fun borrowedDescriptor(context: WebglContext, texture: Int) = + OpenGLBorrowedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + WIDTH, + HEIGHT, + context.descriptor(), + texture, + TEXTURE_2D, + ) + + /** + * Loads a background style and renders one frame of it. + * + * Waiting for the style is what makes the colour mean anything. A map that is already renderable + * renders on the first ask, with whatever style it still has, so setting a new style and + * rendering immediately presents the *previous* colour. The queue is drained first so that the + * load being waited for is this one rather than a load already reported. + */ + private fun renderStyle( + runtime: RuntimeHandle, + map: MapHandle, + session: RenderSessionHandle, + color: String, + ) { + drain(runtime) + map.setStyleJson(backgroundStyle(color)) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + assertTrue( + renderUntilSettled(runtime, session), + "the session rendered no frame for the $color background", + ) + } + + /** + * Renders until the session has nothing left to draw, pumping the runtime in between. + * + * Rendering once is not enough after a style change, and that is MapLibre's shape rather than + * this binding's: a render draws the parameters the map last handed the renderer, so the first + * one after a new style still paints the old one. A render can also find nothing to draw at all, + * because the style is parsed on a MapLibre worker and the map only becomes renderable once the + * update that produced has been pumped through. Pumping blocks this thread, which is legal here + * and is what gives that worker a chance to run. + */ + private fun renderUntilSettled(runtime: RuntimeHandle, session: RenderSessionHandle): Boolean { + var rendered = false + repeat(ATTEMPTS) { + if (session.renderUpdate()) { + rendered = true + } else if (rendered) { + return true + } + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + return rendered + } + + private companion object { + const val WIDTH = PageCanvas.WIDTH + const val HEIGHT = PageCanvas.HEIGHT + const val HALF_WIDTH = PageCanvas.WIDTH / 2 + const val HALF_HEIGHT = PageCanvas.HEIGHT / 2 + + // GL_TEXTURE_2D. The C API takes the GL enum unchanged, and this is the only target a render + // target can be attached to. + const val TEXTURE_2D = 3553 + + // Every colour has three distinct channels, so a path that swapped or duplicated one would + // still + // be caught, and no two of these share a colour, so a canvas still holding an earlier frame + // fails rather than passing. + const val SURFACE_COLOR = "#4080c0" + const val SURFACE_RED = 0x40 + const val SURFACE_GREEN = 0x80 + const val SURFACE_BLUE = 0xC0 + + const val RESIZED_COLOR = "#c08040" + const val RESIZED_RED = 0xC0 + const val RESIZED_GREEN = 0x80 + const val RESIZED_BLUE = 0x40 + + const val REATTACHED_COLOR = "#8040c0" + const val REATTACHED_RED = 0x80 + const val REATTACHED_GREEN = 0x40 + const val REATTACHED_BLUE = 0xC0 + + const val OWNED_COLOR = "#20a060" + const val OWNED_RED = 0x20 + const val OWNED_GREEN = 0xA0 + const val OWNED_BLUE = 0x60 + + const val BORROWED_COLOR = "#a02060" + const val BORROWED_RED = 0xA0 + const val BORROWED_GREEN = 0x20 + const val BORROWED_BLUE = 0x60 + + const val RETARGETED_COLOR = "#60a020" + const val RETARGETED_RED = 0x60 + const val RETARGETED_GREEN = 0xA0 + const val RETARGETED_BLUE = 0x20 + + const val ATTEMPTS = 200 + const val PUMP_MILLIS = 2L + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/BrowserRenderTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/BrowserRenderTest.kt new file mode 100644 index 000000000..268c8c65f --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/BrowserRenderTest.kt @@ -0,0 +1,170 @@ +package org.maplibre.nativeffi.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.map.MapOptions +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.runtime.RuntimeOptions + +/** + * Renders a real map frame in a real browser and looks at the pixels. + * + * This is the test the whole browser render path exists for. Everything below it — the module, the + * WebGL context, the descriptors, the readback — can be exercised without a GPU ever being asked to + * draw anything, and each of those pieces can be right while the frame is still blank. So the + * assertion is on the image: a background layer of a known colour fills the viewport, and the + * readback has to come back as that colour rather than as the zeroed buffer a target that never + * rendered would leave. + * + * A style with only a background layer is deliberate. It needs no network, no tiles, and no glyphs, + * so a failure here is a rendering failure rather than a resource one. + */ +class BrowserRenderTest { + @Test + fun rendersABackgroundFrameAndReadsItBack() { + val runtime = RuntimeHandle.create(RuntimeOptions()) + try { + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = WIDTH + height = HEIGHT + }, + ) + try { + // The context has to exist before the target that borrows it, and it outlives the + // session: the C API borrows the handle for the target's lifetime. + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + map.setStyleJson(BACKGROUND_STYLE_JSON) + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + extent = RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context = context.descriptor(), + ) + ) + try { + assertTrue( + renderUntilFrame(runtime, session), + "the session never reported a rendered frame", + ) + + val info = session.textureImageInfo() + assertEquals(WIDTH, info.width) + assertEquals(HEIGHT, info.height) + assertEquals(WIDTH * 4, info.stride) + + val pixels = + NativeBuffer.allocate(info.byteLength).use { buffer -> + assertEquals(info, session.readPremultipliedRgba8(buffer)) + buffer.toByteArray() + } + assertBackgroundImage(pixels, info) + } finally { + session.close() + } + } finally { + context.close() + } + } finally { + map.close() + } + } finally { + runtime.close() + } + } + + /** + * Renders until the session reports a frame, pumping the runtime in between. + * + * The first render has nothing to draw yet: the style is still parsing on a MapLibre worker, and + * the map only becomes renderable once the update it produces has been pumped through. Pumping + * blocks this thread, which is legal here and is what gives that worker a chance to run. + */ + private fun renderUntilFrame(runtime: RuntimeHandle, session: RenderSessionHandle): Boolean { + repeat(ATTEMPTS) { + if (session.renderUpdate()) return true + runtime.pump(PUMP_MILLIS) + // Drained so the queue does not grow without bound while this waits. What the events say does + // not matter here; the rendered frame is the thing being waited for. + while (runtime.pollEvent() != null) {} + } + return false + } + + /** + * Asserts the readback is the background this style paints, and not an empty buffer. + * + * Checked as a whole image rather than a sampled pixel: a background layer covers the viewport, + * so every pixel is the same colour, and a frame that rendered only part of the target would show + * up here where a single sample would miss it. + * + * The comparison has a tolerance because the colour makes a round trip through a float shader and + * an eight-bit render target, and a software rasteriser is allowed to land a step either side. + */ + private fun assertBackgroundImage(pixels: ByteArray, info: TextureImageInfo) { + assertEquals(info.byteLength.toInt(), pixels.size) + assertTrue( + pixels.any { it != ZERO_BYTE }, + "the readback was entirely zero, so nothing rendered", + ) + for (y in 0 until info.height) { + for (x in 0 until info.width) { + val offset = y * info.stride + x * 4 + assertChannel(pixels, offset, BACKGROUND_RED, "red", x, y) + assertChannel(pixels, offset + 1, BACKGROUND_GREEN, "green", x, y) + assertChannel(pixels, offset + 2, BACKGROUND_BLUE, "blue", x, y) + // Opaque, and exactly so: the background is fully opaque, and premultiplied readback of a + // partly transparent frame would darken the channels above rather than only this one. + assertChannel(pixels, offset + 3, 255, "alpha", x, y) + } + } + } + + private fun assertChannel( + pixels: ByteArray, + offset: Int, + expected: Int, + channel: String, + x: Int, + y: Int, + ) { + val actual = pixels[offset].toInt() and 0xFF + assertTrue( + actual in (expected - TOLERANCE)..(expected + TOLERANCE), + "pixel ($x, $y) has $channel $actual, but the background is $expected", + ) + } + + private companion object { + const val WIDTH = 64 + const val HEIGHT = 32 + + // #4080c0, chosen so that no two channels share a value: a readback that swapped or duplicated + // channels would still pass against a grey or a primary. + const val BACKGROUND_RED = 0x40 + const val BACKGROUND_GREEN = 0x80 + const val BACKGROUND_BLUE = 0xC0 + const val TOLERANCE = 2 + + const val ATTEMPTS = 200 + const val PUMP_MILLIS = 2L + const val ZERO_BYTE: Byte = 0 + + const val BACKGROUND_STYLE_JSON = + """ + { + "version": 8, + "name": "kotlin-browser-render-test", + "sources": {}, + "layers": [ + {"id": "background", "type": "background", "paint": {"background-color": "#4080c0"}} + ] + } + """ + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/NativeBufferBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/NativeBufferBrowserTest.kt new file mode 100644 index 000000000..42300cf36 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/NativeBufferBrowserTest.kt @@ -0,0 +1,51 @@ +package org.maplibre.nativeffi.render + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.internal.wasm.Heap + +/** + * What a browser host is told when the module's heap cannot serve it. + * + * A fixed heap makes this the one native failure that is certain rather than unlikely, and for a + * while it was the one the binding could not report at all. Emscripten aborts the module by default + * when an allocation needs a byte past the initial memory, so every `if (address == 0)` in this + * binding was unreachable and a host that asked for too much lost the whole module rather than + * catching an error. The link turns that default off; these are what say so. + */ +class NativeBufferBrowserTest { + @Test + fun aBufferLargerThanTheWholeHeapIsRefusedAsAnArgument() { + // Refused as an argument even though it reads like a shortage. The heap is fixed at link time, + // so no state a host could reach makes this request succeed, and reporting it as invalid state + // would send a caller looking for something to free. + val error = + assertFailsWith { NativeBuffer.allocate(Heap.byteLength() + 1) } + assertContains(error.diagnostic, "whole ${Heap.byteLength()}-byte heap") + } + + @Test + fun aBufferTheHeapCannotServeIsReportedAndLeavesTheModuleUsable() { + // Exactly the heap's size: the largest request this binding accepts, and one no heap can serve, + // because that same memory already holds the module's code, its threads' stacks, and everything + // the suite has allocated. So the allocator is really asked and really refuses, without the + // result depending on how much of the heap happens to be in use when this test runs. Nothing is + // consumed by the refusal either, which is what makes it safe on a page the rest of the suite + // shares. + val heapBytes = Heap.byteLength() + val error = assertFailsWith { NativeBuffer.allocate(heapBytes) } + assertContains(error.diagnostic, "could not allocate $heapBytes bytes") + + // The whole point of reporting rather than aborting. An abort takes the heap, the worker pool, + // and every live handle with it, so a module that took the old path fails here by never + // reaching this line at all. + NativeBuffer.allocate(64).use { buffer -> + assertEquals(64L, buffer.byteLength()) + assertEquals(64, buffer.toByteArray().size) + } + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/QueryBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/QueryBrowserTest.kt new file mode 100644 index 000000000..526d4e691 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/QueryBrowserTest.kt @@ -0,0 +1,359 @@ +package org.maplibre.nativeffi.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.assertResultHandleDestroyed +import org.maplibre.nativeffi.camera.CameraOptions +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.geo.Feature +import org.maplibre.nativeffi.geo.FeatureIdentifier +import org.maplibre.nativeffi.geo.GeoJson +import org.maplibre.nativeffi.geo.Geometry +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.ScreenBox +import org.maplibre.nativeffi.geo.ScreenPoint +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.internal.wasm.generated.mln_feature_query_result_count +import org.maplibre.nativeffi.json.JsonValue +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.query.FeatureExtensionResult +import org.maplibre.nativeffi.query.FeatureStateSelector +import org.maplibre.nativeffi.query.QueriedFeature +import org.maplibre.nativeffi.query.RenderedQueryGeometry +import org.maplibre.nativeffi.query.SourceFeatureQueryOptions +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.style.GeoJsonSourceOptions +import org.maplibre.nativeffi.withMap + +/** + * Feature queries, which only a live render session can answer. + * + * A query reads the tiles the session last rendered, so everything here needs a real frame first. + * What comes back is a copied tree — geometry, properties, identifier, feature state — read out of + * a native result handle that the call releases before it returns, so anything held by reference + * rather than copied would be reading freed storage by the time it is asserted on. + */ +class QueryBrowserTest { + // Spec coverage: BND-065, BND-066, BND-071, BND-105, BND-106, BND-107. + + @Test + fun aQueriedFeatureCarriesACopiedGeometryTreeAndItsIdentifiers() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { session -> + map.setStyleJson(POINTS_STYLE_JSON) + map.jumpTo( + CameraOptions().apply { + center = LatLng(0.0, 0.0) + zoom = 3.0 + } + ) + render(runtime, session) + + val sourceFeatures = querySourceUntilFound(runtime, session, "points", null) + assertTrue(sourceFeatures.isNotEmpty(), "the source query returned nothing") + + val queried = + assertNotNull( + sourceFeatures.firstOrNull { it.feature.identifier is FeatureIdentifier.StringValue } + ) + assertEquals("points", queried.sourceId) + // A GeoJSON source has no source layer, and the C API reports that as absent rather than + // as an empty string. + assertEquals(null, queried.sourceLayerId) + + val geometry = assertIs(queried.feature.geometry) + assertTrue(geometry.coordinate.latitude.isFinite()) + assertEquals(FeatureIdentifier.StringValue("origin"), queried.feature.identifier) + assertEquals( + JsonValue.StringValue("origin"), + queried.feature.properties.firstOrNull { it.key == "name" }?.value, + ) + // A nested property comes back as a tree rather than flattened. + val nested = + assertIs( + assertNotNull(queried.feature.properties.firstOrNull { it.key == "detail" }).value + ) + assertEquals( + // The two integers keep the unsigned width the C API gave them rather than arriving + // as doubles. + JsonValue.Array(listOf(JsonValue.UInt(1L), JsonValue.UInt(2L))), + nested.members.firstOrNull { it.key == "pair" }?.value, + ) + + // Built from a distinct list holding equal contents, so the comparison is by value. + val rebuilt = + Feature( + Geometry.Point(LatLng(geometry.coordinate.latitude, geometry.coordinate.longitude)), + queried.feature.properties.toList(), + FeatureIdentifier.StringValue("origin"), + ) + assertEquals(rebuilt, queried.feature) + + // A rendered query reads the same features through screen space instead, which needs the + // frame that placed them rather than only the tile that holds them. + val viewport = + RenderedQueryGeometry.Box( + ScreenBox(ScreenPoint(0.0, 0.0), ScreenPoint(WIDTH.toDouble(), HEIGHT.toDouble())) + ) + var rendered: List = emptyList() + repeat(ATTEMPTS) { + rendered = session.queryRenderedFeatures(viewport, null) + if (rendered.isNotEmpty()) return@repeat + session.renderUpdate() + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + assertTrue(rendered.isNotEmpty(), "the rendered query returned nothing") + assertTrue( + rendered.any { it.feature.identifier == FeatureIdentifier.StringValue("origin") } + ) + + // Feature state is set on the session and read back through the query result. + val selector = FeatureStateSelector("points").apply { featureId = "origin" } + session.setFeatureState( + selector, + JsonValue.ObjectValue(listOf(JsonValue.Member("hovered", JsonValue.Bool(true)))), + ) + val state = assertIs(session.getFeatureState(selector)) + assertEquals(JsonValue.Bool(true), state.members.firstOrNull { it.key == "hovered" }?.value) + + // Removal is applied with the next update rather than in place, so the read that checks + // it has to follow one. + session.removeFeatureState(selector) + var cleared: JsonValue = session.getFeatureState(selector) + repeat(ATTEMPTS) { + if ( + cleared == JsonValue.Null || + (cleared is JsonValue.ObjectValue && cleared.members.isEmpty()) + ) { + return@repeat + } + session.renderUpdate() + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + cleared = session.getFeatureState(selector) + } + assertTrue( + cleared == JsonValue.Null || + (cleared is JsonValue.ObjectValue && cleared.members.isEmpty()), + "feature state was $cleared after removal", + ) + } + } + } + + @Test + fun aClusterFeatureResolvesItsUnsignedIdAndBoundsItsLeaves() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { session -> + map.setStyleJson(CLUSTER_STYLE_JSON) + map.addGeoJsonSourceData( + "clustered", + GeoJson.FeatureCollection( + (0 until LEAF_COUNT).map { index -> + Feature( + Geometry.Point(LatLng(index * 0.0001, index * 0.0001)), + listOf(JsonValue.Member("index", JsonValue.Int(index.toLong()))), + FeatureIdentifier.Int(index.toLong()), + ) + } + ), + GeoJsonSourceOptions().apply { + cluster = true + clusterRadius = 200 + clusterMaxZoom = 20.0 + }, + ) + map.addStyleLayerJson( + JsonValue.ObjectValue( + listOf( + JsonValue.Member("id", JsonValue.StringValue("clusters")), + JsonValue.Member("type", JsonValue.StringValue("circle")), + JsonValue.Member("source", JsonValue.StringValue("clustered")), + ) + ), + "", + ) + map.jumpTo( + CameraOptions().apply { + center = LatLng(0.0, 0.0) + zoom = 1.0 + } + ) + render(runtime, session) + + // The cluster feature is whichever queried feature carries a cluster_id, and the C API + // requires that property to keep its unsigned width so it can be handed straight back. + var cluster: QueriedFeature? = null + querySourceUntilFound(runtime, session, "clustered", null).forEach { candidate -> + if (candidate.feature.properties.any { it.key == "cluster_id" }) cluster = candidate + } + val found = assertNotNull(cluster, "no cluster feature was queried") + val clusterId = + assertNotNull(found.feature.properties.firstOrNull { it.key == "cluster_id" }).value + assertIs(clusterId) + + // Handed back unmodified, the extension resolves the cluster and returns its leaves. + val all = + session.queryFeatureExtension( + "clustered", + found.feature, + "supercluster", + "leaves", + JsonValue.ObjectValue( + listOf(JsonValue.Member("limit", JsonValue.UInt(LEAF_COUNT.toLong()))) + ), + ) + val allLeaves = assertIs(all).features + assertTrue(allLeaves.isNotEmpty(), "the extension returned no leaves") + + // An unsigned limit bounds the result, and an unsigned offset shifts it. + val bounded = + session.queryFeatureExtension( + "clustered", + found.feature, + "supercluster", + "leaves", + JsonValue.ObjectValue(listOf(JsonValue.Member("limit", JsonValue.UInt(2L)))), + ) + val boundedLeaves = assertIs(bounded).features + assertEquals(2, boundedLeaves.size) + + val shifted = + session.queryFeatureExtension( + "clustered", + found.feature, + "supercluster", + "leaves", + JsonValue.ObjectValue( + listOf( + JsonValue.Member("limit", JsonValue.UInt(2L)), + JsonValue.Member("offset", JsonValue.UInt(1L)), + ) + ), + ) + val shiftedLeaves = assertIs(shifted).features + assertEquals(2, shiftedLeaves.size) + assertEquals(boundedLeaves[1], shiftedLeaves[0]) + } + } + } + + /** + * A query result belongs to the call that made it, so a copy that fails still has to end it. + * + * The failure injected is the one the copy really has: everything a result is read through goes + * via a block the binding allocates first, and the module's allocator can refuse it. What matters + * is not the error — a caller sees an allocation failure either way — but whether the result + * handle native had already produced went with it. A leaked one is silent, so it is replayed + * against native, which is the only party that can say whether it is still there. + */ + // Spec coverage: BND-066. + @Test + fun aFailedResultCopyDestroysTheNativeResultRatherThanLeakingIt() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { session -> + map.setStyleJson(POINTS_STYLE_JSON) + map.jumpTo( + CameraOptions().apply { + center = LatLng(0.0, 0.0) + zoom = 3.0 + } + ) + render(runtime, session) + // The same query first, so what the injected failure changes is this call rather than a + // query that was never going to answer. + assertTrue(querySourceUntilFound(runtime, session, "points", null).isNotEmpty()) + + val acquired: Long + try { + InjectedFaults.failResultCopies() + val error = + assertFailsWith { session.querySourceFeatures("points", null) } + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + assertTrue(error.diagnostic.contains("could not allocate"), error.diagnostic) + acquired = + assertNotNull( + InjectedFaults.takeCopiedResults().singleOrNull(), + "the query did not reach the copy, so nothing proves what it did with the result", + ) + } finally { + InjectedFaults.reset() + } + assertResultHandleDestroyed( + acquired, + "mln_feature_query_result", + ::mln_feature_query_result_count, + ) + + // And the session is unharmed: the next query answers as the first one did. + assertTrue(querySourceUntilFound(runtime, session, "points", null).isNotEmpty()) + } + } + } + + /** Queries until the source has tiles to answer from, pumping and rendering in between. */ + private fun querySourceUntilFound( + runtime: RuntimeHandle, + session: RenderSessionHandle, + sourceId: String, + options: SourceFeatureQueryOptions?, + ): List { + var result: List = emptyList() + repeat(ATTEMPTS) { + result = session.querySourceFeatures(sourceId, options) + if (result.isNotEmpty()) return result + session.renderUpdate() + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + return result + } + + private fun render(runtime: RuntimeHandle, session: RenderSessionHandle) { + repeat(ATTEMPTS) { + if (session.renderUpdate()) return + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + } + + private fun withSession(map: MapHandle, body: (RenderSessionHandle) -> T): T { + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), context.descriptor()) + ) + try { + return body(session) + } finally { + session.close() + } + } finally { + context.close() + } + } + + private companion object { + const val WIDTH = 128 + const val HEIGHT = 128 + const val ATTEMPTS = 400 + const val PUMP_MILLIS = 2L + const val LEAF_COUNT = 8 + + const val POINTS_STYLE_JSON = + """{"version":8,"sources":{"points":{"type":"geojson","data":{"type":"FeatureCollection",""" + + """"features":[{"type":"Feature","id":"origin","geometry":{"type":"Point",""" + + """"coordinates":[0,0]},"properties":{"name":"origin","detail":{"pair":[1,2]}}}]}}},""" + + """"layers":[{"id":"dots","type":"circle","source":"points",""" + + """"paint":{"circle-radius":10}}]}""" + + const val CLUSTER_STYLE_JSON = """{"version":8,"sources":{},"layers":[]}""" + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/RenderSessionBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/RenderSessionBrowserTest.kt new file mode 100644 index 000000000..2529dff46 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/RenderSessionBrowserTest.kt @@ -0,0 +1,480 @@ +package org.maplibre.nativeffi.render + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.maplibre.nativeffi.BACKGROUND_STYLE_JSON +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.withMap + +/** + * A live render session on the browser's only backend. + * + * A WebGL context belongs to the agent that created it, and no host agent can hand one to this + * module, so the context comes from [WebglContext] rather than from a host. Everything else is the + * common render-session API: the session is attached to a map, renders, and hands frames back + * either as an explicit frame handle or through CPU readback. + */ +class RenderSessionBrowserTest { + // Spec coverage: BND-161, BND-162, BND-163, BND-164, BND-165, BND-166, BND-167, BND-168, + // BND-169, BND-170, BND-172, BND-173, BND-175, BND-176. + + @Test + fun aDescriptorMaterializesAnExtentAndABorrowedContextItDoesNotOwn() { + withMap(WIDTH, HEIGHT) { _, map -> + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val descriptor = context.descriptor() + assertNotEquals(0, descriptor.context) + // A fresh descriptor each time, so a caller that mutates one cannot redirect another. + assertNotEquals(descriptor, context.descriptor()) + + val extent = RenderTargetExtent(WIDTH, HEIGHT, 2.0) + val physical = extent.physicalSize() + assertEquals(WIDTH * 2, physical.width) + assertEquals(HEIGHT * 2, physical.height) + + val session = map.attachOpenGLOwnedTexture(OpenGLOwnedTextureDescriptor(extent, descriptor)) + session.close() + + // The session borrowed the context and did not take it: it is still usable for the + // next target after the session that held it has gone. + val second = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + second.close() + } finally { + context.close() + } + } + } + + @Test + fun eachAttachFamilyProducesTheSameSessionShape() { + withMap(WIDTH, HEIGHT) { _, map -> + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + // A session-owned texture target: the session allocates the texture and hands frames + // back through its own accessors. + val owned = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + assertEquals(map, owned.map()) + assertFalse(owned.isClosed) + + // A second session on the same map is refused by native, and the refusal leaves the + // first one alone. + val second = + assertFailsWith { + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + } + assertEquals(MaplibreStatus.INVALID_STATE, second.status) + assertFalse(owned.isClosed) + owned.close() + assertTrue(owned.isClosed) + + // A surface target, which presents through the canvas the context is bound to. There is + // no drawable to name, so the surface pointer is null. + val surface = + map.attachOpenGLSurface( + OpenGLSurfaceDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + NativePointer.NULL, + ) + ) + assertEquals(map, surface.map()) + // A surface session has no texture of its own, so the texture accessors report that + // rather than reading a target that is not there. + assertFailsWith { surface.textureImageInfo() } + surface.close() + } finally { + context.close() + } + } + } + + @Test + fun renderUpdateReportsNoUpdateWithoutClosingTheSession() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { context, session -> + // A map with no style has nothing to draw. That is a false result rather than a failure, + // and it is the C API's own answer rather than something the binding decided. + assertFalse(session.renderUpdate()) + assertFalse(session.isClosed) + + // The session was not closed or spoiled by it: the same session renders once there is + // something to render. + map.setStyleJson(BACKGROUND_STYLE_JSON) + assertTrue(renderOneFrame(runtime, session), "the session never rendered a frame") + assertFalse(session.isClosed) + session.textureImageInfo() + } + } + } + + @Test + fun resizeAndSetTargetChangeTheExtentTheSessionRendersAt() { + withMap(WIDTH, HEIGHT) { runtime, map -> + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + try { + map.setStyleJson(BACKGROUND_STYLE_JSON) + assertTrue(renderOneFrame(runtime, session)) + assertEquals(WIDTH, session.textureImageInfo().width) + + session.resize(HALF_WIDTH, HALF_HEIGHT, 1.0) + assertTrue(renderOneFrame(runtime, session)) + val resized = session.textureImageInfo() + assertEquals(HALF_WIDTH, resized.width) + assertEquals(HALF_HEIGHT, resized.height) + assertEquals(HALF_WIDTH * 4, resized.stride) + + // A session-owned texture has no host-owned target to replace, so `set_target` for a + // target kind this session does not have is refused rather than silently accepted. + val mismatched = + assertFailsWith { + session.setOpenGLSurfaceTarget( + OpenGLSurfaceDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + NativePointer.NULL, + ) + ) + } + assertEquals(MaplibreStatus.UNSUPPORTED, mismatched.status) + // Refused without disturbing the session, which still reports the extent it had. + assertEquals(HALF_WIDTH, session.textureImageInfo().width) + } finally { + session.close() + } + + // The host-owned half of the pair: a surface session takes a new extent through + // `set_target`, which is the one thing a browser surface target can be given. + val surface = + map.attachOpenGLSurface( + OpenGLSurfaceDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + NativePointer.NULL, + ) + ) + try { + surface.setOpenGLSurfaceTarget( + OpenGLSurfaceDescriptor( + RenderTargetExtent(HALF_WIDTH, HALF_HEIGHT, 1.0), + context.descriptor(), + NativePointer.NULL, + ) + ) + assertTrue(renderOneFrame(runtime, surface)) + } finally { + surface.close() + } + } finally { + context.close() + } + } + } + + @Test + fun readbackCopiesMetadataAndRefusesABufferTooSmallToHoldTheImage() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { context, session -> + map.setStyleJson(BACKGROUND_STYLE_JSON) + assertTrue(renderOneFrame(runtime, session)) + + val info = session.textureImageInfo() + assertEquals(WIDTH, info.width) + assertEquals(HEIGHT, info.height) + assertEquals(WIDTH * 4, info.stride) + assertEquals((WIDTH * 4).toLong() * HEIGHT, info.byteLength) + + // A buffer one byte short: the read fails and the caller still owns the buffer. + NativeBuffer.allocate(info.byteLength - 1).use { small -> + val error = + assertFailsWith { session.readPremultipliedRgba8(small) } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertEquals(info.byteLength - 1, small.byteLength()) + } + + // A buffer that fits receives the image, and the same buffer is reusable for the next + // read rather than being consumed by the first. + NativeBuffer.allocate(info.byteLength).use { buffer -> + assertEquals(info, session.readPremultipliedRgba8(buffer)) + val first = buffer.toByteArray() + assertEquals(info.byteLength.toInt(), first.size) + assertTrue(first.any { it != ZERO }, "the readback was entirely zero") + + assertEquals(info, session.readPremultipliedRgba8(buffer)) + assertContentEquals(first, buffer.toByteArray()) + } + } + } + } + + @Test + fun anOwnedTextureFrameExposesItsBackendHandleOnlyWhileItIsActive() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { context, session -> + map.setStyleJson(BACKGROUND_STYLE_JSON) + assertTrue(renderOneFrame(runtime, session)) + + val frame = session.acquireOpenGLOwnedTextureFrame() + val copied = frame.frame() + assertEquals(WIDTH, copied.width()) + assertEquals(HEIGHT, copied.height()) + assertEquals(1.0, copied.scaleFactor()) + assertNotEquals(0, copied.texture()) + assertNotEquals(0, copied.target()) + assertFalse(frame.isClosed) + + // While a frame is active the session may not render, resize, be given a new target, + // or hand out a second frame. + assertFailsWith { session.renderUpdate() } + assertFailsWith { session.resize(WIDTH, HEIGHT, 1.0) } + assertFailsWith { session.acquireOpenGLOwnedTextureFrame() } + assertFailsWith { + session.setOpenGLBorrowedTextureTarget( + OpenGLBorrowedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + WIDTH, + HEIGHT, + context.descriptor(), + copied.texture(), + copied.target(), + ) + ) + } + + frame.close() + assertTrue(frame.isClosed) + // Released twice is a no-op, and the backend handles are gone with it. + frame.close() + assertFailsWith { frame.frame() } + // The frame values report through the frame's own borrow, which the release ended. + assertFailsWith { copied.texture() } + + // The session is usable again, and a second frame is a handle of its own. The first + // one stays closed even though the storage behind it has been reused. + assertTrue(renderOneFrame(runtime, session)) + val next = session.acquireOpenGLOwnedTextureFrame() + try { + assertFailsWith { frame.frame() } + assertNotEquals(0, next.frame().texture()) + } finally { + next.close() + } + } + } + } + + /** + * A release native refuses leaves the frame exactly as it was, so the caller can ask again. + * + * The alternative is worse than the failure: a frame marked closed by a release that did not + * happen is one native still holds, and the session it was borrowed from refuses to render, + * resize, detach, or close for as long as that borrow stands — with nothing left that could give + * it back. So the assertions here are about the state rather than the error. The frame is still + * open, its backend handles still read, the session still refuses to render, and the retry both + * succeeds and gives the session back. + * + * The refusal is injected, and injected in place of the call rather than after it: native still + * holds the frame afterwards, which is what makes the retry below a real release rather than a + * second attempt at one that already happened. + */ + // Spec coverage: BND-169. + @Test + fun aFrameReleaseNativeRefusesLeavesTheFrameOpenForAnotherAttempt() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { _, session -> + map.setStyleJson(BACKGROUND_STYLE_JSON) + assertTrue(renderOneFrame(runtime, session)) + + val frame = session.acquireOpenGLOwnedTextureFrame() + val texture = frame.frame().texture() + try { + InjectedFaults.failNextCall( + "mln_opengl_owned_texture_release_frame", + MaplibreStatus.INVALID_STATE, + "render session has no frame acquired", + ) + val error = assertFailsWith { frame.close() } + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + assertEquals("render session has no frame acquired", error.diagnostic) + } finally { + InjectedFaults.reset() + } + + // Nothing was retired: the handle is open, its frame still reads, and the session still + // counts the borrow the release did not end. + assertFalse(frame.isClosed, "the frame was retired by a release that did not happen") + assertEquals(texture, frame.frame().texture()) + assertFailsWith { session.renderUpdate() } + + // The retry is the release that never happened, so it closes the frame and hands the + // session back. + frame.close() + assertTrue(frame.isClosed) + assertFailsWith { frame.frame() } + assertTrue(renderOneFrame(runtime, session)) + } + } + } + + /** + * A frame native handed over and the page could not wrap goes back, rather than being stranded. + * + * The window is the one between a successful acquire and the handle the caller is given: the + * descriptor is copied into a Kotlin value and that value wrapped, both of which are object + * construction and so both of which fail when there is no memory to construct into. A page that + * only ended its own borrow there would leave native holding a frame with nothing left that could + * release it — and a session with a frame acquired refuses to render, resize, detach, and close, + * so the map would be lost for the life of the page. + * + * So the assertions are about what the session can do afterwards rather than about the error. The + * failure is injected because a page cannot be made to run out of Kotlin heap on request, and it + * is injected before the wrap rather than in place of the acquire, which is the point: native + * really has a frame at that moment, so the release that follows is a real one. + */ + // Spec coverage: BND-172. + @Test + fun aFrameTheWrapperCouldNotBeBuiltForIsGivenBackToNative() { + withMap(WIDTH, HEIGHT) { runtime, map -> + withSession(map) { _, session -> + map.setStyleJson(BACKGROUND_STYLE_JSON) + assertTrue(renderOneFrame(runtime, session)) + + try { + InjectedFaults.failNextFrameWrap() + val error = + assertFailsWith { session.acquireOpenGLOwnedTextureFrame() } + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + } finally { + InjectedFaults.reset() + } + + // Each of these is refused while a frame is acquired, so all three together say the + // frame went back: the binding's own borrow ended, and native's did too. The resize + // comes first because it retires whatever generation was rendered, which is what the + // acquire below is asking for. + session.resize(WIDTH, HEIGHT, 1.0) + assertTrue(renderOneFrame(runtime, session)) + val frame = session.acquireOpenGLOwnedTextureFrame() + try { + assertNotEquals(0, frame.frame().texture()) + } finally { + frame.close() + } + } + } + } + + @Test + fun closingAMapWithASessionAttachedIsRefusedUntilTheSessionGoes() { + org.maplibre.nativeffi.withRuntime { runtime -> + val map = + MapHandle.create( + runtime, + org.maplibre.nativeffi.map.MapOptions().apply { + width = WIDTH + height = HEIGHT + }, + ) + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + ) + ) + val error = assertFailsWith { map.close() } + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + assertEquals("MapHandle has 1 live child handle(s): RenderSessionHandle", error.diagnostic) + assertFalse(map.isClosed) + + session.close() + map.close() + assertTrue(map.isClosed) + } finally { + context.close() + if (!map.isClosed) map.close() + } + } + } + + /** Runs [body] with a session on a context sized for it, closing both afterwards. */ + private fun withSession(map: MapHandle, body: (WebglContext, RenderSessionHandle) -> T): T { + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), context.descriptor()) + ) + try { + return body(context, session) + } finally { + session.close() + } + } finally { + context.close() + } + } + + /** + * Renders until the session reports a frame. + * + * The first render has nothing to draw yet: the style is still parsing on a MapLibre worker, and + * the map only becomes renderable once the update it produced has been pumped through. + */ + private fun renderOneFrame(runtime: RuntimeHandle, session: RenderSessionHandle): Boolean { + repeat(ATTEMPTS) { + if (session.renderUpdate()) return true + runtime.pump(PUMP_MILLIS) + while (runtime.pollEvent() != null) {} + } + return false + } + + private companion object { + const val WIDTH = 64 + const val HEIGHT = 32 + const val HALF_WIDTH = 32 + const val HALF_HEIGHT = 16 + const val ATTEMPTS = 200 + const val PUMP_MILLIS = 2L + const val ZERO: Byte = 0 + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/WebglContextBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/WebglContextBrowserTest.kt new file mode 100644 index 000000000..4124fe696 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/render/WebglContextBrowserTest.kt @@ -0,0 +1,349 @@ +package org.maplibre.nativeffi.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlin.test.fail +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.withMap + +/** + * Contexts to open while waiting for the module's allocator to hand a freed handle back. + * + * Bounded because a page holds only so many WebGL contexts, and the test asserts the refusal either + * way -- reuse sharpens it into the ABA case rather than being what it rests on. + */ +private const val REUSE_ATTEMPTS = 8 + +/** + * The lifetime of a WebGL context, which on this target the binding owns rather than the host. + * + * Everywhere else a render target's graphics context belongs to a host that made it with EGL, + * Metal, or Vulkan, and keeping it valid for the target's borrow window is the host's job. Here the + * context comes from this module, so keeping it valid is this binding's job — and the Emscripten + * handle a target attaches by stays a positive integer long after the context behind it is gone, + * and is handed to the next context created. So the checks below are the binding's own, and they + * are what stands between a host and a render target working in a context that was destroyed + * underneath it, or in one it never named. + */ +class WebglContextBrowserTest { + // Spec coverage: BND-041, BND-042. + + @Test + fun closingAContextUnderALiveRenderTargetIsRefusedUntilTheTargetGoes() { + withMap(WIDTH, HEIGHT) { _, map -> + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val session = map.attachOpenGLOwnedTexture(descriptorFor(context)) + try { + // The backend makes this context current on every frame and again while it releases + // the GL objects the session built, so destroying it here would leave native working + // in a context that is gone — and the destructor that would find out swallows it. + val error = assertFailsWith { context.close() } + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + assertEquals( + "WebglContext has 1 live child handle(s): RenderSessionHandle", + error.diagnostic, + ) + assertFalse(context.isClosed) + + // A refused close leaves a working context rather than a half-released one: this + // does real GL work in the context the session renders in. + val texture = context.createTexture(WIDTH, HEIGHT) + assertNotEquals(0, texture) + context.destroyTexture(texture) + + // Detaching is what releases the backend, so the context becomes closeable there + // rather than only at close. That is the order a host tearing a map down takes: the + // detached session is live for its own destroy and nothing else, so releasing the + // context it no longer touches must not wait for that destroy. + session.detach() + context.close() + assertTrue(context.isClosed) + } finally { + // Closed here rather than at the end, so an assertion that fails above still leaves + // the map closeable and the failure this test reports is its own. + session.close() + } + } finally { + if (!context.isClosed) context.close() + } + } + } + + @Test + fun attachingWithADescriptorFromAClosedContextIsRefused() { + withMap(WIDTH, HEIGHT) { _, map -> + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + val stale = context.descriptor() + context.close() + + // The handle in it is still positive, which is every check native makes and every check a + // descriptor can make on its own. Only the binding knows the context it named is gone. + assertTrue(stale.context > 0) + val error = + assertFailsWith { + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), stale) + ) + } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertTrue( + error.diagnostic.contains("has been closed"), + "the refusal reported ${error.diagnostic}", + ) + + // The map is where it was, so a target attaches with a context that is open. + val replacement = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + map.attachOpenGLOwnedTexture(descriptorFor(replacement)).close() + } finally { + replacement.close() + } + } + } + + /** + * The same refusal once another context has been given the closed one's handle. + * + * Emscripten allocates a context handle with `malloc` and frees it on destroy, so the number is + * not an identity: destroy a context and the next one created takes the same number back. A + * binding that resolved a descriptor by number would find the *new* context, retain it, and + * render the map through a context the host never named — silently, because every check native + * can make still passes. + * + * So this closes a context and opens others until the number comes back. The allocator is under + * no obligation to return it, and what it returns depends on everything else this page has + * allocated, so the refusal is asserted either way; reuse is what sharpens it into the case the + * test above cannot reach. + * + * Retargeting is asked the same question, at both entry points that take a context. It is the + * half with no second line of defence: an attach at least reaches a session that has no context + * yet, while `set_target` reaches one that does, and all native compares there is the handle — so + * a stale descriptor whose number has come back matches the context the session is really + * rendering in and is accepted. + */ + @Test + fun attachingWithAStaleDescriptorIsRefusedAfterItsHandleIsReused() { + withMap(WIDTH, HEIGHT) { _, map -> + val original = WebglContext.createOffscreen(WIDTH, HEIGHT) + val stale = original.descriptor() + original.close() + + // Reuse is what makes this the ABA case rather than merely a closed-context case, but the + // module's allocator is under no obligation to hand the number straight back: what it + // returns depends on whatever else has been allocated in this page. So the number is + // hunted for rather than assumed, and every context opened on the way is kept open, since + // closing one would free the very number being waited for. + val opened = mutableListOf() + var replacement = WebglContext.createOffscreen(WIDTH, HEIGHT) + opened.add(replacement) + while (replacement.descriptor().context != stale.context && opened.size < REUSE_ATTEMPTS) { + replacement = WebglContext.createOffscreen(WIDTH, HEIGHT) + opened.add(replacement) + } + val reused = replacement.descriptor().context == stale.context + try { + + // Caught rather than left to assertFailsWith. A session that attached holds the map + // open, so it would fail the map's own close on the way out, and that cleanup failure + // is what the report would show instead of this one. Released first, reported second. + val leaked = + try { + map.attachOpenGLOwnedTexture( + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), stale) + ) + } catch (error: InvalidArgumentException) { + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertTrue( + error.diagnostic.contains("has been closed"), + "the refusal reported ${error.diagnostic}", + ) + null + } + if (leaked != null) { + leaked.close() + fail( + "attaching with a descriptor from a closed context succeeded" + + if (reused) { + "; the handle ${stale.context} it carries now belongs to a different context, " + + "so the session would have rendered through one the host never named" + } else { + ", so a descriptor outliving its context is not refused at all" + } + ) + } + + // And the context that really holds that number still attaches, so what was refused was + // the stale descriptor rather than the handle it happens to carry. Only meaningful once + // the number has actually been reused; otherwise this is an ordinary live context. + if (reused) map.attachOpenGLOwnedTexture(descriptorFor(replacement)).close() + + // The other way in. Each session below is attached with the live context and then + // offered the stale descriptor for the same target kind, so the only thing wrong with + // the retarget is the context it names. + val texture = replacement.createTexture(WIDTH, HEIGHT) + try { + val borrowed = + map.attachOpenGLBorrowedTexture(borrowedDescriptorFor(replacement, texture)) + try { + assertStaleContextRefused(reused, stale.context, "a borrowed texture target") { + borrowed.setOpenGLBorrowedTextureTarget( + OpenGLBorrowedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + WIDTH, + HEIGHT, + stale, + texture, + TEXTURE_2D, + ) + ) + } + + // The same retarget with a descriptor from the context that is open goes through, + // so what was refused was the descriptor and not the call. + borrowed.setOpenGLBorrowedTextureTarget(borrowedDescriptorFor(replacement, texture)) + } finally { + borrowed.close() + } + } finally { + replacement.destroyTexture(texture) + } + + val surface = map.attachOpenGLSurface(surfaceDescriptorFor(replacement)) + try { + assertStaleContextRefused(reused, stale.context, "a surface target") { + surface.setOpenGLSurfaceTarget( + OpenGLSurfaceDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + stale, + NativePointer.NULL, + ) + ) + } + + surface.setOpenGLSurfaceTarget(surfaceDescriptorFor(replacement)) + } finally { + surface.close() + } + } finally { + opened.forEach { it.close() } + } + } + } + + /** + * A readback whose pixel count no 32-bit pointer could address. + * + * The scratch a readback stages into is sized from two extents the caller chose, and the product + * of two positive `Int`s is not one. Twenty-five by 42,949,673 is a gibibyte of pixels, and it + * wraps to four bytes: the module would hand back a four-byte block, native would be told the + * real extents, and what stopped it from reading past that block would be native's own extent cap + * rather than anything this binding did. Refused here instead, before the allocator is asked. + */ + @Test + fun aReadbackTooLargeToAddressIsRefusedBeforeTheModuleIsAsked() { + val context = WebglContext.createOffscreen(WIDTH, HEIGHT) + try { + val error = + assertFailsWith { + context.readPixels(DEFAULT_FRAMEBUFFER, WRAPPING_WIDTH, WRAPPING_HEIGHT) + } + assertTrue( + error.diagnostic.contains("pixels"), + "the refusal did not name the pixel count: ${error.diagnostic}", + ) + // And the context is untouched by the refusal, so a host that got the extent wrong once + // still has the context it was reading from. + assertFalse(context.isClosed) + context.readPixels(DEFAULT_FRAMEBUFFER, WIDTH, HEIGHT) + } finally { + context.close() + } + } + + /** + * Asserts that [retarget] refuses a descriptor whose context is gone, and says what it cost. + * + * The refusal has to be the binding's own. Native refuses a *mismatched* handle by itself, so a + * retarget with a stale descriptor whose number was never reused already fails there — with a + * message about the context this session attached with, which says nothing about the descriptor + * having outlived what it named. Only the diagnostic tells the two apart, which is why this reads + * it rather than settling for the exception type. + */ + private fun assertStaleContextRefused( + reused: Boolean, + handle: Int, + what: String, + retarget: () -> Unit, + ) { + val refusal = + try { + retarget() + null + } catch (error: InvalidArgumentException) { + error + } + if (refusal == null) { + fail( + "retargeting to $what with a descriptor from a closed context succeeded" + + if (reused) { + "; the handle $handle it carries now belongs to a different context, so the session " + + "would have rendered through one the host never named" + } else { + ", so a descriptor outliving its context is not refused at retarget at all" + } + ) + } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, refusal.status) + assertTrue( + refusal.diagnostic.contains("has been closed"), + "retargeting to $what reported ${refusal.diagnostic}, which is not the binding's own refusal", + ) + } + + private fun descriptorFor(context: WebglContext) = + OpenGLOwnedTextureDescriptor(RenderTargetExtent(WIDTH, HEIGHT, 1.0), context.descriptor()) + + private fun borrowedDescriptorFor(context: WebglContext, texture: Int) = + OpenGLBorrowedTextureDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + WIDTH, + HEIGHT, + context.descriptor(), + texture, + TEXTURE_2D, + ) + + private fun surfaceDescriptorFor(context: WebglContext) = + OpenGLSurfaceDescriptor( + RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context.descriptor(), + // A WebGL context is already bound to its canvas, so there is no drawable to name where every + // other OpenGL provider names one, and native refuses anything else here. + NativePointer.NULL, + ) + + private companion object { + const val WIDTH = 64 + const val HEIGHT = 32 + + // GL_TEXTURE_2D. The C API takes the GL enum unchanged, and this is the only target a render + // target can be attached to. + const val TEXTURE_2D = 3553 + + /** Framebuffer zero, which is the canvas's own. */ + const val DEFAULT_FRAMEBUFFER = 0 + + // 25 * 42_949_673 is 2^30 + 1 pixels, so four bytes each wraps an Int product to exactly four. + // Both extents are positive and each fits an Int on its own, which is what makes the product + // the only place this can be caught. + const val WRAPPING_WIDTH = 25 + const val WRAPPING_HEIGHT = 42_949_673 + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/resource/ResourceProviderBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/resource/ResourceProviderBrowserTest.kt new file mode 100644 index 000000000..6780a4d01 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/resource/ResourceProviderBrowserTest.kt @@ -0,0 +1,614 @@ +package org.maplibre.nativeffi.resource + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.drain +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.internal.callback.QueuedResourceProviders +import org.maplibre.nativeffi.internal.callback.ResourceRewriteRules +import org.maplibre.nativeffi.internal.wasm.InjectedFaults +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.pageOrigin +import org.maplibre.nativeffi.pumpTurns +import org.maplibre.nativeffi.pumpUntil +import org.maplibre.nativeffi.runtime.RuntimeEventType +import org.maplibre.nativeffi.runtime.RuntimeHandle +import org.maplibre.nativeffi.waitForMapEvent +import org.maplibre.nativeffi.withMap + +/** + * The resource provider, which reaches host code through the module's record ring. + * + * MapLibre raises a provider callback on whichever thread wants the resource, and none of those may + * enter this WebAssembly instance. So this binding registers `mln_adapter_queued_resource_provider` + * rather than a callback of its own: the routes it claims are declared at registration, native + * decides ownership by matching them, and a claimed request is copied into the ring and handed to + * host code on the next pump. + * + * What follows is the shape of every test here. A request the host is meant to see is one a route + * claims; a request no route claims never arrives at all, and goes on through native loading. And + * because the body runs after the pump's own C call has returned, host code inside it is on an + * ordinary stack and may call the map and the runtime freely. + */ +class ResourceProviderBrowserTest { + // Spec coverage: BND-121, BND-122, BND-140, BND-142, BND-143, BND-144, BND-146, BND-147, + // BND-148, BND-149, BND-151, BND-152, BND-154, BND-155, BND-156, BND-157. + + @Test + fun aClaimedRequestCompletedInsideTheCallbackLoadsTheStyle() { + withMap { runtime, map -> + var calls = 0 + var copiedRequest: ResourceRequest? = null + var callbackFailure: Throwable? = null + + runtime.setResourceProvider(listOf(route(STYLE_URL))) { request, handle -> + try { + calls++ + copiedRequest = request + assertEquals(ResourceKind.STYLE, request.kind) + // The pump's own C call has already returned by the time this runs, so this is an + // ordinary stack: reaching the map from here is a same-thread call like any other. The + // style it is waiting for is the one this callback has still to answer, so it reads as + // not yet loaded. + assertFalse(map.isFullyLoaded) + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + // Completing consumed the request's one answer. + assertFailsWith { + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + assertFailsWith { handle.isCancelled() } + } catch (failure: Throwable) { + callbackFailure = failure + } + } + + map.setStyleUrl(STYLE_URL) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + + assertNull(callbackFailure) + assertEquals(1, calls) + // The request the callback was handed is a copied value, so it still reads after the native + // record it was decoded from has been released. + val copied = assertNotNull(copiedRequest) + assertEquals(STYLE_URL, copied.requestedUrl) + assertEquals(ResourceKind.STYLE, copied.kind) + assertEquals(0, copied.priorData.size) + } + } + + @Test + fun aClaimedRequestCompletedAfterTheCallbackReturnsLoadsTheStyle() { + withMap { runtime, map -> + var handled: ResourceRequestHandle? = null + runtime.setResourceProvider(listOf(route(STYLE_URL))) { _, handle -> handled = handle } + + map.setStyleUrl(STYLE_URL) + pumpUntil(runtime) { handled != null } + val handle = assertNotNull(handled, "the provider was never asked for the style") + + // Outstanding, and native has not cancelled it. + assertFalse(handle.isCancelled()) + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + + // Completion is terminal: a second one is the binding's already-completed error, raised + // before anything crosses into the module. + val second = + assertFailsWith { + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + assertEquals(MaplibreStatus.INVALID_STATE, second.status) + assertFailsWith { handle.isCancelled() } + + handle.close() + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + } + } + + /** + * A glob route claims the URLs its pattern matches, and only those. + * + * This is where a queued provider parts company with a callback one. A callback binding decides + * per request and can always fall back to pass-through; here the decision is native's, made + * against a table declared before any request existed. So both halves have to be shown: a URL the + * pattern matches arrives, and one it does not never reaches host code at all — it goes on + * through native loading, which fails because nothing serves this scheme. + * + * The unmatched URL is one a careless pattern would claim. A `*` stops at a path separator, which + * is what keeps a route for one directory from claiming everything below it, so a URL that + * differs only by a further segment is the case worth spending a load on. + */ + // Spec coverage: BND-142, BND-156. + @Test + fun aGlobRouteClaimsWhatItMatchesAndLeavesTheRestToNativeLoading() { + withMap { runtime, map -> + val claimed = mutableListOf() + runtime.setResourceProvider(listOf(route(HANDLED_PREFIX + "*", matchGlob = true))) { + request, + handle -> + claimed += request.requestedUrl + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + } + + map.setStyleUrl(HANDLED_PREFIX + "style.json") + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + assertEquals(listOf(HANDLED_PREFIX + "style.json"), claimed) + + // One segment deeper, so the pattern leaves it alone and native loading reports the failure. + loadUnservedStyle(runtime, map, HANDLED_PREFIX + "deep/style.json") + assertEquals(1, claimed.size, "a URL past the pattern's segment reached the provider") + + // And a URL that shares nothing with the pattern. + loadUnservedStyle(runtime, map, UNCLAIMED_URL) + assertEquals(1, claimed.size, "an unmatched request reached the provider anyway") + } + } + + /** + * Which of a request's two URLs a route compares. + * + * A configured URI-scheme alias makes them differ: the requested URL keeps the alias the host + * asked for, and the resolved URL is what the tile server normalizes it to. A route names one or + * the other, and both have to claim the same request. + */ + // Spec coverage: BND-155, BND-157. + @Test + fun aRouteClaimsAnAliasedRequestByEitherOfItsUrls() { + val byRequested = claimAlias(route(ALIAS_URL, useRequestedUrl = true)) + assertEquals(ALIAS_URL, byRequested.requestedUrl) + assertEquals(RESOLVED_ALIAS_URL, byRequested.resolvedUrl) + + // The same request, claimed by the URL the tile server normalized it to. A route comparing the + // resolved URL would never match the alias, and one comparing the requested URL would never + // match this, so the two together say the binding hands each flag to the right field. + val byResolved = claimAlias(route(RESOLVED_ALIAS_URL)) + assertEquals(ALIAS_URL, byResolved.requestedUrl) + assertEquals(RESOLVED_ALIAS_URL, byResolved.resolvedUrl) + } + + /** + * Loads the aliased style through a provider claiming it with [route], and reports the request. + */ + private fun claimAlias(route: ResourceProviderRoute): ResourceRequest = withMap { runtime, map -> + var claimed: ResourceRequest? = null + runtime.setResourceProvider(listOf(route)) { request, handle -> + claimed = request + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + } + map.setStyleUrl(ALIAS_URL) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + assertNotNull(claimed, "the route never claimed the aliased request") + } + + // Spec coverage: BND-147, BND-151. + @Test + fun aReleasedHandleAnswersNothingAndCannotReachALaterRequest() { + withMap { runtime, map -> + val handles = mutableListOf() + runtime.setResourceProvider(listOf(route(HANDLED_PREFIX + "*", matchGlob = true))) { _, handle + -> + handles += handle + } + + map.setStyleUrl(HANDLED_PREFIX + "first.json") + pumpUntil(runtime) { handles.size == 1 } + val first = handles.first() + + // Released without an answer: every later operation reports the handle as closed rather than + // reaching whatever native request now occupies that storage. + first.close() + first.close() + assertFailsWith { first.isCancelled() } + assertFailsWith { + first.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + + // A second request comes in and is answered normally, so the stale handle above could not + // have interfered with it. + map.setStyleUrl(HANDLED_PREFIX + "second.json") + pumpUntil(runtime) { handles.size == 2 } + val second = handles[1] + assertFailsWith { first.isCancelled() } + second.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + second.close() + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + } + } + + // Spec coverage: BND-146, BND-148, BND-152. + @Test + fun cancellationIsVisibleBeforeALateCompletionIsRefused() { + withMap { runtime, map -> + var handled: ResourceRequestHandle? = null + runtime.setResourceProvider(listOf(route(STYLE_URL))) { _, handle -> handled = handle } + + map.setStyleUrl(STYLE_URL) + pumpUntil(runtime) { handled != null } + val handle = assertNotNull(handled) + + // Loading another style abandons the request in flight, which is what native cancels. + map.setStyleJson(EMPTY_STYLE_JSON) + assertTrue( + pumpUntil(runtime) { handle.isCancelled() }, + "the abandoned request was never reported as cancelled", + ) + + // A completion that arrives after cancellation still reaches native, and native's refusal is + // what the caller sees. + val late = + assertFailsWith { + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + } + assertEquals(MaplibreStatus.INVALID_STATE, late.status) + + // And that completion was terminal even though native refused it, so the handle is spent. + assertFailsWith { handle.isCancelled() } + handle.close() + } + } + + // Spec coverage: BND-149. + @Test + fun anErrorResponseBecomesACopiedLoadingFailureEvent() { + withMap { runtime, map -> + runtime.setResourceProvider(listOf(route(STYLE_URL))) { _, handle -> + handle.complete( + ResourceResponse(ResourceResponseStatus.ERROR).apply { + errorReason = ResourceErrorReason.NOT_FOUND + errorMessage = "custom style failed" + } + ) + } + + map.setStyleUrl(STYLE_URL) + val failure = waitForMapEvent(runtime, map, RuntimeEventType.MAP_LOADING_FAILED) + val copiedMessage = failure.message + + assertEquals(map, failure.mapSource) + assertTrue(copiedMessage.contains("custom style failed"), copiedMessage) + // The message came out of storage the next poll reuses. + runtime.pollEvent() + assertEquals(copiedMessage, failure.message) + } + } + + // Spec coverage: BND-154. + @Test + fun aProviderIsConsultedUntilItIsReplacedAndThenUntilItIsCleared() { + withMap { runtime, map -> + var first = 0 + var second = 0 + + runtime.setResourceProvider(listOf(route(HANDLED_PREFIX + "*", matchGlob = true))) { _, handle + -> + first++ + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + loadHandledStyle(runtime, map, HANDLED_PREFIX + "first.json") + assertTrue(first > 0) + + // Replacing while a map is live is part of the C API's contract, and the routes go with the + // callback: the replacement claims a prefix of its own. + runtime.setResourceProvider(listOf(route(REPLACEMENT_PREFIX + "*", matchGlob = true))) { + _, + handle -> + second++ + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + val firstAfterReplace = first + loadHandledStyle(runtime, map, REPLACEMENT_PREFIX + "second.json") + assertTrue(second > 0) + assertEquals(firstAfterReplace, first, "the replaced provider was consulted again") + + // The replacement took the previous routes away with it, so the prefix the first one claimed + // is nobody's now and passes through to native loading. + loadUnservedStyle(runtime, map, HANDLED_PREFIX + "third.json") + assertEquals(firstAfterReplace, first) + + runtime.clearResourceProvider() + val secondAfterClear = second + loadUnservedStyle(runtime, map, REPLACEMENT_PREFIX + "fourth.json") + assertEquals(secondAfterClear, second, "a cleared provider was consulted again") + + // Clearing an already cleared provider stays a successful no-op. + runtime.clearResourceProvider() + } + } + + /** + * A replacement native refuses leaves the provider that was already there serving requests. + * + * The order the binding installs in is what this rests on. The replacement's routes and listener + * state go into the module's heap before native is told about them, because the provider struct + * native is given points at them. So at the moment native refuses, the binding holds state for a + * provider native has never heard of; it has to release that and keep the previous one. + * + * Native has no refusal of its own to offer here — setting a provider validates the runtime and + * the descriptor, both of which the binding has already made valid — so the refusal is injected. + */ + // Spec coverage: BND-122. + @Test + fun aProviderReplacementNativeRefusesKeepsThePreviousProvider() { + withMap { runtime, map -> + var previous = 0 + var replacement = 0 + runtime.setResourceProvider(listOf(route(HANDLED_PREFIX + "*", matchGlob = true))) { _, handle + -> + previous++ + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + loadHandledStyle(runtime, map, HANDLED_PREFIX + "installed.json") + assertTrue(previous > 0, "the provider that was installed was never consulted") + + val registered = QueuedResourceProviders.liveRegistrations + try { + InjectedFaults.failNextCall( + "mln_runtime_set_resource_provider", + MaplibreStatus.INVALID_ARGUMENT, + "provider callback must not be null", + ) + val error = + assertFailsWith { + runtime.setResourceProvider( + listOf(route(REPLACEMENT_PREFIX + "*", matchGlob = true)) + ) { _, handle -> + replacement++ + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + } + assertEquals(MaplibreStatus.INVALID_ARGUMENT, error.status) + assertEquals("provider callback must not be null", error.diagnostic) + } finally { + InjectedFaults.reset() + } + + // The replacement's state went back, so the module is not holding routes for a provider + // native was never given. + assertEquals( + registered, + QueuedResourceProviders.liveRegistrations, + "the refusal did not leave exactly the previous registration standing", + ) + + // And native still reaches the provider it already had, which is the half the count cannot + // show: state that stayed and state that is still wired to native look the same. + val beforeLoad = previous + loadHandledStyle(runtime, map, HANDLED_PREFIX + "refused.json") + assertTrue(previous > beforeLoad, "the previous provider stopped being consulted") + assertEquals(0, replacement, "the provider native refused was consulted anyway") + + // A later replacement is accepted, so the refusal left the runtime able to take one. + runtime.setResourceProvider(listOf(route(REPLACEMENT_PREFIX + "*", matchGlob = true))) { + _, + handle -> + replacement++ + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + loadHandledStyle(runtime, map, REPLACEMENT_PREFIX + "accepted.json") + assertTrue(replacement > 0) + runtime.clearResourceProvider() + } + } + + /** + * URL rewriting, which this binding does with a native rule table rather than a callback. + * + * MapLibre consults a resource transform on the thread that is about to fetch, so there is no + * host callback to consult and nothing to observe on the way through. What a rule changes is + * where the request goes, so that is what is asserted: the same style URL fails one way with the + * rules installed and another way without them. A 404 says the request went to the URL the map + * was given; anything else says it went somewhere else. + */ + // Spec coverage: BND-140. + @Test + fun aRewriteRuleSendsARequestElsewhereUntilTheRulesAreCleared() { + withMap { runtime, map -> + val source = pageOrigin() + "/mln-test/rewrite-source.json" + + runtime.setResourceUrlRewriteRules( + listOf(ResourceUrlRewriteRule(url = source, replacementUrl = REWRITE_TARGET_URL)) + ) + val rewritten = failedLoadMessage(runtime, map, source) + assertFalse( + rewritten.contains("404"), + "the request was not rewritten: native loading reported $rewritten", + ) + + // Cleared, so the same URL is fetched unchanged and the origin answers for it. + runtime.clearResourceTransform() + val direct = failedLoadMessage(runtime, map, source) + assertTrue( + direct.contains("404"), + "a cleared rule table still rewrote the request: native loading reported $direct", + ) + + // Clearing one that is already cleared stays a successful no-op. + runtime.clearResourceTransform() + } + } + + /** The rule table is the other family installed this way, and it is refused the same way. */ + // Spec coverage: BND-122. + @Test + fun aRewriteRuleReplacementNativeRefusesKeepsThePreviousRules() { + withMap { runtime, map -> + val source = pageOrigin() + "/mln-test/refused-rewrite.json" + runtime.setResourceUrlRewriteRules( + listOf(ResourceUrlRewriteRule(url = source, replacementUrl = REWRITE_TARGET_URL)) + ) + + val registered = ResourceRewriteRules.liveRegistrations + try { + InjectedFaults.failNextCall( + "mln_runtime_set_resource_transform", + MaplibreStatus.INVALID_ARGUMENT, + "transform callback must not be null", + ) + assertFailsWith { + runtime.setResourceUrlRewriteRules( + listOf(ResourceUrlRewriteRule(url = source, replacementUrl = null)) + ) + } + } finally { + InjectedFaults.reset() + } + + assertEquals( + registered, + ResourceRewriteRules.liveRegistrations, + "the refusal did not leave exactly the previous rule table standing", + ) + + // The rules native already had are the ones still in force. + val message = failedLoadMessage(runtime, map, source) + assertFalse(message.contains("404"), "the previous rules stopped rewriting: $message") + runtime.clearResourceTransform() + } + } + + // Spec coverage: BND-121. + @Test + fun aFailingCallbackDoesNotEscapeIntoNativeAndTheRuntimeKeepsWorking() { + withMap { runtime, map -> + var calls = 0 + val stranded = mutableListOf() + runtime.setResourceProvider(listOf(route(HANDLED_PREFIX + "*", matchGlob = true))) { _, handle + -> + calls++ + stranded += handle + throw IllegalStateException("contained") + } + + // Nothing unwinds through the drain, and the drain goes on running. The request the body + // never answered stays the host's, which is why it is released below. + map.setStyleUrl(HANDLED_PREFIX + "failing.json") + assertTrue(pumpUntil(runtime) { calls > 0 }, "the provider was never consulted") + stranded.forEach { it.close() } + + // The runtime is unharmed, and a provider that answers still works afterwards. + runtime.setResourceProvider(listOf(route(STYLE_URL))) { _, handle -> + handle.complete( + ResourceResponse(ResourceResponseStatus.OK).apply { + bytes = EMPTY_STYLE_JSON.encodeToByteArray() + } + ) + } + map.setStyleUrl(STYLE_URL) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + } + } + + /** + * Replacing or clearing the provider from inside a delivered callback. + * + * The body runs inside the drain that delivered it, so retiring its registration would be a close + * waiting on the frame below it. There is one thread and one stack here, so that wait can never + * finish and is refused instead. + */ + @Test + fun aProviderCannotBeReplacedOrClearedFromInsideItsOwnCallback() { + withMap { runtime, map -> + var replaceError: Throwable? = null + var clearError: Throwable? = null + + runtime.setResourceProvider(listOf(route(HANDLED_PREFIX + "*", matchGlob = true))) { _, handle + -> + replaceError = + runCatching { runtime.setResourceProvider(listOf(route(STYLE_URL))) { _, _ -> } } + .exceptionOrNull() + clearError = runCatching { runtime.clearResourceProvider() }.exceptionOrNull() + handle.complete(ResourceResponse(ResourceResponseStatus.NO_CONTENT)) + } + + map.setStyleUrl(HANDLED_PREFIX + "reentrant.json") + assertTrue(pumpUntil(runtime) { replaceError != null }, "the provider was never consulted") + + assertTrue(replaceError is InvalidStateException, "replace reported $replaceError") + assertTrue(clearError is InvalidStateException, "clear reported $clearError") + } + } + + private fun route( + url: String, + matchGlob: Boolean = false, + useRequestedUrl: Boolean = false, + ): ResourceProviderRoute = + ResourceProviderRoute(url = url, matchGlob = matchGlob, useRequestedUrl = useRequestedUrl) + + /** Loads a style the provider answers with no content, so the load fails after it was claimed. */ + private fun loadHandledStyle(runtime: RuntimeHandle, map: MapHandle, styleUrl: String) { + drain(runtime) + map.setStyleUrl(styleUrl) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_LOADING_FAILED) + } + + /** Loads a style whose scheme no file source serves, so native loading reports the failure. */ + private fun loadUnservedStyle(runtime: RuntimeHandle, map: MapHandle, styleUrl: String) { + drain(runtime) + map.setStyleUrl(styleUrl) + waitForMapEvent(runtime, map, RuntimeEventType.MAP_LOADING_FAILED) + // Long enough that a provider still claiming this prefix would have been consulted. + pumpTurns(runtime, QUIET_PUMPS) + } + + /** Loads [styleUrl] and reports the message of the failure it produces. */ + private fun failedLoadMessage(runtime: RuntimeHandle, map: MapHandle, styleUrl: String): String { + drain(runtime) + map.setStyleUrl(styleUrl) + return waitForMapEvent(runtime, map, RuntimeEventType.MAP_LOADING_FAILED).message + } + + private companion object { + /** A scheme no file source serves, so a request for it reaches native loading and fails. */ + const val UNCLAIMED_URL = "jar:file:/packaged/style.json" + const val STYLE_URL = "custom://style.json" + const val HANDLED_PREFIX = "custom://handled/" + const val REPLACEMENT_PREFIX = "custom://replacement/" + + /** The default tile server's alias, and what it normalizes to. */ + const val ALIAS_URL = "maplibre://maps/style" + const val RESOLVED_ALIAS_URL = "https://demotiles.maplibre.org/style.json" + + /** + * A host no name server resolves. + * + * A rewritten request reaches the network and fails to connect, which is a different failure + * from the 404 the unrewritten URL gets — and it is the difference that says the rule fired. + */ + const val REWRITE_TARGET_URL = "https://rewritten.invalid/style.json" + + /** Long enough that a provider still installed would have been consulted at least once. */ + const val QUIET_PUMPS = 200 + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeEventBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeEventBrowserTest.kt new file mode 100644 index 000000000..508d774d2 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeEventBrowserTest.kt @@ -0,0 +1,251 @@ +package org.maplibre.nativeffi.runtime + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer +import org.maplibre.nativeffi.internal.wasm.RuntimeEventMarshal +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEvent +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventCameraTransitionFinished +import org.maplibre.nativeffi.internal.wasm.generated.MlnRuntimeEventPayloadType +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.map.MapOptions +import org.maplibre.nativeffi.pumpUntil +import org.maplibre.nativeffi.waitForMapEvent +import org.maplibre.nativeffi.withMap +import org.maplibre.nativeffi.withRuntime + +/** + * The events a runtime hands back, and how much of them survives the next poll. + * + * A poll writes into one runtime-owned block that the next poll overwrites, so everything a public + * event carries has to be copied out before the frame that read it returns. The two events built by + * hand are the cases no module can be made to produce: a domain from a later revision of the C API, + * and a map-originated event whose map the host has already closed. + */ +class RuntimeEventBrowserTest { + // Spec coverage: BND-081, BND-082, BND-083, BND-086, BND-087. + + @Test + fun aStyleLoadReportsItsOwnMapAndKeepsItsMessageAcrossTheNextPoll() { + withMap { runtime, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + val event = waitForMapEvent(runtime, map, RuntimeEventType.MAP_STYLE_LOADED) + val copiedMessage = event.message + + assertEquals(RuntimeEventSourceType.MAP, event.sourceType) + assertEquals(map, event.mapSource) + assertNull(event.runtimeSource) + assertEquals(RuntimeEventPayload.None, event.payload) + + // The next poll reuses the storage the message was read out of, so a view rather than a + // copy would change here. + runtime.pollEvent() + assertEquals(copiedMessage, event.message) + + // And polling reaches an empty queue rather than repeating the last event forever. + var polls = 0 + while (runtime.pollEvent() != null && polls < POLL_LIMIT) { + polls++ + } + assertTrue(polls < POLL_LIMIT, "the event queue never emptied") + assertNull(runtime.pollEvent()) + } + } + + @Test + fun twoMapsAreEachNamedByTheirOwnStyleLoadEvent() { + withRuntime { runtime -> + val first = MapHandle.create(runtime, mapOptions()) + val second = MapHandle.create(runtime, mapOptions()) + try { + first.setStyleJson(EMPTY_STYLE_JSON) + second.setStyleJson(EMPTY_STYLE_JSON) + + val loaded = mutableMapOf() + pumpUntil( + runtime, + onEvent = { + if (it.type == RuntimeEventType.MAP_STYLE_LOADED) { + val source = it.mapSource + if (source != null) loaded[source] = (loaded[source] ?: 0) + 1 + } + }, + ) { + loaded.containsKey(first) && loaded.containsKey(second) + } + + // Both maps loaded, and each event named the map that raised it rather than whichever + // map happened to be looked up first. + assertEquals(setOf(first, second), loaded.keys) + } finally { + first.close() + second.close() + } + } + } + + @Test + fun anEventFromAFutureDomainKeepsItsRawValuesAndCopiedPayload() { + withRuntime { runtime -> + val message = "future event" + val messageBytes = Heap.utf8Size(message) + val copied = + Heap.withScratch(MlnRuntimeEvent.SIZEOF + PAYLOAD_BYTES + messageBytes) { base -> + val payload = base + MlnRuntimeEvent.SIZEOF + val text = payload + PAYLOAD_BYTES + Heap.storeByte(payload, 1) + Heap.storeByte(payload + 1, 2) + Heap.storeByte(payload + 2, 3) + Heap.storeUtf8(text, message) + + MlnRuntimeEvent.setSize(base, MlnRuntimeEvent.SIZEOF) + MlnRuntimeEvent.setType(base, FUTURE_TYPE) + MlnRuntimeEvent.setSourceType(base, FUTURE_SOURCE_TYPE) + MlnRuntimeEvent.setSource(base, 0L) + MlnRuntimeEvent.setCode(base, FUTURE_CODE) + MlnRuntimeEvent.setPayloadType(base, FUTURE_PAYLOAD_TYPE) + MlnRuntimeEvent.setPayload(base, payload) + MlnRuntimeEvent.setPayloadSize(base, PAYLOAD_BYTES) + MlnRuntimeEvent.setMessage(base, text) + MlnRuntimeEvent.setMessageSize(base, message.length) + + val event = RuntimeEventMarshal.readEvent(base, runtime) + // Overwritten after the read, so a payload that was a view rather than a copy would + // show it below. + Heap.storeByte(payload, 9) + event + } + + assertEquals(RuntimeEventType(FUTURE_TYPE), copied.type) + assertEquals(FUTURE_TYPE, copied.type.nativeValue) + assertEquals(RuntimeEventSourceType(FUTURE_SOURCE_TYPE), copied.sourceType) + assertEquals(FUTURE_SOURCE_TYPE, copied.sourceType.nativeValue) + assertNull(copied.runtimeSource) + assertNull(copied.mapSource) + assertEquals(FUTURE_CODE, copied.code) + assertEquals(message, copied.message) + + val payload = assertIs(copied.payload) + assertEquals(FUTURE_PAYLOAD_TYPE, payload.rawPayloadType) + assertEquals(PAYLOAD_BYTES.toLong(), payload.payloadSize) + assertContentEquals(byteArrayOf(1, 2, 3), payload.payloadBytes) + } + } + + @Test + fun aKnownPayloadShorterThanItsStructIsReadAsUnknown() { + withRuntime { runtime -> + val full = + readSyntheticCameraTransition(runtime, MlnRuntimeEventCameraTransitionFinished.SIZEOF) + // A camera transition really does carry its id, so the full-size case is the control. + assertEquals( + TRANSITION_ID, + assertIs(full).transitionId, + ) + + // A module built from other headers can report a shorter payload. The fields past the size + // it declares belong to that module, so they are not read as this binding's struct. + val truncated = + readSyntheticCameraTransition(runtime, MlnRuntimeEventCameraTransitionFinished.SIZEOF - 1) + val unknown = assertIs(truncated) + assertEquals( + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_CAMERA_TRANSITION_FINISHED, + unknown.rawPayloadType, + ) + assertEquals( + (MlnRuntimeEventCameraTransitionFinished.SIZEOF - 1).toLong(), + unknown.payloadSize, + ) + } + } + + @Test + fun aMapEventWhoseMapHasBeenClosedNamesNoMap() { + withRuntime { runtime -> + val map = MapHandle.create(runtime, mapOptions()) + val closedMapHandle = map.nativeHandle().raw + map.close() + + val copied = + Heap.withScratch(MlnRuntimeEvent.SIZEOF) { base -> + MlnRuntimeEvent.setSize(base, MlnRuntimeEvent.SIZEOF) + MlnRuntimeEvent.setType(base, RuntimeEventType.MAP_STYLE_LOADED.nativeValue) + MlnRuntimeEvent.setSourceType(base, RuntimeEventSourceType.MAP.nativeValue) + MlnRuntimeEvent.setSource(base, closedMapHandle) + RuntimeEventMarshal.readEvent(base, runtime) + } + + // The event still says a map raised it; there is simply no live public map to name. + assertEquals(RuntimeEventType.MAP_STYLE_LOADED, copied.type) + assertEquals(RuntimeEventSourceType.MAP, copied.sourceType) + assertNull(copied.mapSource) + assertNull(copied.runtimeSource) + assertEquals(RuntimeEventPayload.None, copied.payload) + + // A live map is still resolved, so the lookup missed rather than being switched off. + val live = MapHandle.create(runtime, mapOptions()) + try { + val resolved = + Heap.withScratch(MlnRuntimeEvent.SIZEOF) { base -> + MlnRuntimeEvent.setSize(base, MlnRuntimeEvent.SIZEOF) + MlnRuntimeEvent.setType(base, RuntimeEventType.MAP_STYLE_LOADED.nativeValue) + MlnRuntimeEvent.setSourceType(base, RuntimeEventSourceType.MAP.nativeValue) + MlnRuntimeEvent.setSource(base, live.nativeHandle().raw) + RuntimeEventMarshal.readEvent(base, runtime) + } + assertEquals(live, assertNotNull(resolved.mapSource)) + } finally { + live.close() + } + } + } + + private fun readSyntheticCameraTransition( + runtime: RuntimeHandle, + declaredPayloadSize: Int, + ): RuntimeEventPayload = + Heap.withScratch(MlnRuntimeEvent.SIZEOF + MlnRuntimeEventCameraTransitionFinished.SIZEOF) { base + -> + val payload: HeapPointer = base + MlnRuntimeEvent.SIZEOF + MlnRuntimeEventCameraTransitionFinished.setSize( + payload, + MlnRuntimeEventCameraTransitionFinished.SIZEOF, + ) + MlnRuntimeEventCameraTransitionFinished.setTransitionId(payload, TRANSITION_ID) + + MlnRuntimeEvent.setSize(base, MlnRuntimeEvent.SIZEOF) + MlnRuntimeEvent.setType(base, RuntimeEventType.MAP_CAMERA_TRANSITION_FINISHED.nativeValue) + MlnRuntimeEvent.setSourceType(base, RuntimeEventSourceType.MAP.nativeValue) + MlnRuntimeEvent.setPayloadType( + base, + MlnRuntimeEventPayloadType.MLN_RUNTIME_EVENT_PAYLOAD_CAMERA_TRANSITION_FINISHED, + ) + MlnRuntimeEvent.setPayload(base, payload) + MlnRuntimeEvent.setPayloadSize(base, declaredPayloadSize) + RuntimeEventMarshal.readEvent(base, runtime).payload + } + + private fun mapOptions() = + MapOptions().apply { + width = 64 + height = 64 + } + + private companion object { + const val POLL_LIMIT = 4_096 + const val PAYLOAD_BYTES = 3 + const val FUTURE_TYPE = 900 + const val FUTURE_SOURCE_TYPE = 901 + const val FUTURE_CODE = 902 + const val FUTURE_PAYLOAD_TYPE = 903 + const val TRANSITION_ID = 21L + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeHandleBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeHandleBrowserTest.kt new file mode 100644 index 000000000..b74524f2b --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeHandleBrowserTest.kt @@ -0,0 +1,197 @@ +package org.maplibre.nativeffi.runtime + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.EMPTY_STYLE_JSON +import org.maplibre.nativeffi.elapsedMillis +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.error.UnsupportedFeatureException +import org.maplibre.nativeffi.map.MapHandle +import org.maplibre.nativeffi.map.MapOptions +import org.maplibre.nativeffi.resource.ResourceProviderDecision +import org.maplibre.nativeffi.withMap + +/** + * A runtime, and the thread this binding runs it on. + * + * Kotlin/Wasm runs on the pthread the module gives `main()`, where blocking is legal, so a runtime + * is created, pumped, polled, and closed from the thread the tests themselves run on. That thread + * is the runtime's owner thread as far as the C API is concerned, and it is the only one this + * binding has. + * + * The callback families MapLibre raises on its own worker threads are also here, because what this + * target reports for them is part of the runtime's public shape: a worker is a separate JavaScript + * agent and cannot enter this module, so a host callback that has to answer one is refused and a + * native rule table takes its place. + */ +class RuntimeHandleBrowserTest { + // Spec coverage: BND-023, BND-040, BND-042, BND-080, BND-088, BND-089, BND-192. + + @Test + fun aRuntimeIsCreatedAndClosedOnTheOwnerThread() { + val runtime = RuntimeHandle.create(RuntimeOptions()) + // A second reference to the same handle, so release is observed through every alias rather + // than only through the one that closed it. + val alias = runtime + + assertFalse(runtime.isClosed) + runtime.pump(0) + assertNull(runtime.pollEvent()) + + runtime.close() + // The second release is a no-op rather than a second native destroy. + runtime.close() + + assertTrue(runtime.isClosed) + assertTrue(alias.isClosed) + assertFailsWith { runtime.pump(0) } + assertFailsWith { alias.pollEvent() } + } + + @Test + fun aRuntimeWillNotCloseWhileOneOfItsMapsIsLive() { + val runtime = RuntimeHandle.create(RuntimeOptions()) + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = 64 + height = 64 + }, + ) + try { + val error = assertFailsWith { runtime.close() } + + assertEquals(MaplibreStatus.INVALID_STATE, error.status) + assertEquals("RuntimeHandle has 1 live child handle(s): MapHandle", error.diagnostic) + assertFalse(runtime.isClosed) + + // The refused close left the runtime usable, so a host can close the child and retry. + runtime.pump(0) + } finally { + map.close() + } + + runtime.close() + assertTrue(runtime.isClosed) + } + + @Test + fun aWakeSourceStaysUsableAfterTheRuntimeItCameFromIsGone() { + // Spec coverage: BND-089. + val runtime = RuntimeHandle.create(RuntimeOptions()) + val wake = runtime.acquireWakeSource() + + // A pump takes the flag a signal raised, so the next park is not released by it. The pump + // below would otherwise return on a flag this test never cleared. + wake.signal() + runtime.pump(0) + + val idle = elapsedMillis { runtime.pump(IDLE_PUMP_MILLIS) } + assertTrue( + idle >= IDLE_PUMP_MILLIS / 2, + "the pump returned after $idle ms, so the wake flag outlived the pump that took it", + ) + + // Hosts tear the two down in either order, so a source outlives its runtime. + runtime.close() + wake.signal() + wake.close() + + assertTrue(wake.isClosed) + assertFailsWith { wake.signal() } + } + + /** + * A pump that parks is released by native work rather than running to its timeout. + * + * This is what makes a blocking pump usable at all on this target. Kotlin holds the only thread + * it has while `mln_runtime_pump` waits, so a wait that ended only on the timeout would cost a + * host the whole timeout on every idle frame. MapLibre's own threads are what end it: the style + * parse below finishes on a worker and posts back, and the parked wait returns as soon as it + * lands. + * + * The timeout is far longer than any style this suite loads takes, so a pump that spent it is + * unmistakable in the elapsed time. + */ + @Test + fun aParkedPumpIsReleasedByNativeWorkRatherThanByItsTimeout() { + // Spec coverage: BND-088. + withMap { runtime, map -> + map.setStyleJson(EMPTY_STYLE_JSON) + + val waited = elapsedMillis { runtime.pump(PUMP_TIMEOUT_MILLIS) } + + assertTrue(waited < PUMP_TIMEOUT_MILLIS, "the pump waited $waited ms for its timeout") + assertNotNull(runtime.pollEvent(), "the pump returned early with nothing to report") + } + } + + @Test + fun anOfflineOperationHoldsItsRuntimeOpen() { + // Spec coverage: BND-042. + val runtime = RuntimeHandle.create(RuntimeOptions().apply { cachePath = ":memory:" }) + val operation = runtime.startAmbientCacheOperation(AmbientCacheOperation.INVALIDATE) + + assertEquals(OfflineOperationKind.AMBIENT_CACHE, operation.kind) + assertFailsWith { runtime.close() } + + operation.close() + runtime.close() + + assertTrue(runtime.isClosed) + } + + @Test + fun anOutgoingHeaderTransformIsRefusedWhileClearingIsServed() { + // Spec coverage: BND-158, BND-159 recorded as inapplicable; see the note below. + RuntimeHandle.create(RuntimeOptions()).use { runtime -> + // The browser's fetch transport follows redirects itself, so it cannot keep a transformed + // header out of a cross-origin hop. The C API reports the same status for the same reason. + assertFailsWith { + runtime.setHttpHeaderTransform { emptyList() } + } + // Clearing is served anyway, so a host that tears down unconditionally does not have to + // know that installation was refused. + runtime.clearHttpHeaderTransform() + } + } + + /** + * The two callback families whose common form this target cannot answer. + * + * Both are raised on whichever MapLibre thread wants the resource, and each of those is a + * separate JavaScript agent that cannot enter this module. A binding that accepted the callback + * and answered it from somewhere else would be answering a question MapLibre has already moved + * past, so the registration is refused and the queued provider and the rewrite rule table take + * its place. `ResourceProviderBrowserTest` covers both of those. + */ + @Test + fun theSynchronousProviderAndTransformFormsAreRefusedWhileClearingIsServed() { + RuntimeHandle.create(RuntimeOptions()).use { runtime -> + assertFailsWith { + runtime.setResourceProvider { _, _ -> ResourceProviderDecision.PASS_THROUGH } + } + assertFailsWith { runtime.setResourceTransform { null } } + + // Clearing serves whatever is installed, including nothing, so a host tearing down does not + // have to know which form its provider took. + runtime.clearResourceProvider() + runtime.clearResourceTransform() + } + } + + private companion object { + /** Long enough that a pump which ran to its timeout is unmistakable in the elapsed time. */ + const val PUMP_TIMEOUT_MILLIS = 5000L + + /** Short enough not to slow the suite, long enough to distinguish from an immediate return. */ + const val IDLE_PUMP_MILLIS = 200L + } +} diff --git a/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeOfflineBrowserTest.kt b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeOfflineBrowserTest.kt new file mode 100644 index 000000000..894153e97 --- /dev/null +++ b/bindings/kotlin/src/wasmJsTest/kotlin/org/maplibre/nativeffi/runtime/RuntimeOfflineBrowserTest.kt @@ -0,0 +1,287 @@ +package org.maplibre.nativeffi.runtime + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.maplibre.nativeffi.error.InvalidArgumentException +import org.maplibre.nativeffi.error.InvalidStateException +import org.maplibre.nativeffi.error.MaplibreException +import org.maplibre.nativeffi.error.MaplibreStatus +import org.maplibre.nativeffi.geo.LatLng +import org.maplibre.nativeffi.geo.LatLngBounds +import org.maplibre.nativeffi.offline.OfflineRegionDefinition +import org.maplibre.nativeffi.offline.OfflineRegionDownloadState +import org.maplibre.nativeffi.offline.OfflineRegionInfo +import org.maplibre.nativeffi.offline.OfflineRegionStatus +import org.maplibre.nativeffi.pumpUntil +import org.maplibre.nativeffi.withRuntime + +/** + * The offline database, driven through the runtime's operation handles. + * + * Every operation completes through the event queue, so an operation is two calls with a pump + * between them. The database is in memory, which is what a browser has: the module's default file + * system does not survive the page. + */ +class RuntimeOfflineBrowserTest { + // Spec coverage: BND-041, BND-060, BND-061, BND-069, BND-084, BND-085. + + private val deferred = ArrayDeque() + + @Test + fun anOfflineRegionIsCreatedObservedAndDeletedThroughCopiedResults() { + withRuntime(RuntimeOptions().apply { cachePath = ":memory:" }) { runtime -> + val definition = tileDefinition() + + // The metadata is caller-owned storage, so the descriptor has to snapshot it: the array + // is mutated the instant the call returns. + val createMetadata = byteArrayOf(1, 2, 3) + val createOperation = runtime.startCreateOfflineRegion(definition, createMetadata) + createMetadata[0] = 9 + + waitForOperation(runtime, createOperation) + val created = runtime.takeCreateOfflineRegionResult(createOperation) + + assertTrue(created.id > 0) + assertEquals(definition, created.definition) + assertContentEquals(byteArrayOf(1, 2, 3), created.metadata) + // And the accessor hands back a copy rather than the stored array. + created.metadata[0] = 9 + assertContentEquals(byteArrayOf(1, 2, 3), created.metadata) + + assertEquals(created, offlineRegion(runtime, created.id)) + assertTrue(offlineRegions(runtime).contains(created)) + + val updateMetadata = byteArrayOf(4, 5) + val updateOperation = runtime.startUpdateOfflineRegionMetadata(created.id, updateMetadata) + updateMetadata[0] = 9 + waitForOperation(runtime, updateOperation) + val updated = runtime.takeUpdateOfflineRegionMetadataResult(updateOperation) + assertEquals(created.id, updated.id) + assertContentEquals(byteArrayOf(4, 5), updated.metadata) + + val status = offlineRegionStatus(runtime, created.id) + assertEquals(OfflineRegionDownloadState.INACTIVE, status.downloadState) + + // Observation is what turns database progress into runtime events. + completeVoid(runtime, runtime.startSetOfflineRegionObserved(created.id, true)) + completeVoid( + runtime, + runtime.startSetOfflineRegionDownloadState(created.id, OfflineRegionDownloadState.ACTIVE), + ) + val observed = waitForObservation(runtime, created.id) + val copiedMessage = observed.message + assertEquals(RuntimeEventSourceType.RUNTIME, observed.sourceType) + assertEquals(runtime, observed.runtimeSource) + assertNull(observed.mapSource) + assertObservationPayload(created.id, observed.payload) + runtime.pollEvent() + assertEquals(copiedMessage, observed.message) + + completeVoid(runtime, runtime.startSetOfflineRegionObserved(created.id, false)) + completeVoid( + runtime, + runtime.startSetOfflineRegionDownloadState(created.id, OfflineRegionDownloadState.INACTIVE), + ) + completeVoid(runtime, runtime.startInvalidateOfflineRegion(created.id)) + completeVoid(runtime, runtime.startDeleteOfflineRegion(created.id)) + assertNull(offlineRegion(runtime, created.id)) + } + } + + @Test + fun aTakeThatFailsBeforeOwnershipMovesLeavesTheOperationRetryable() { + withRuntime(RuntimeOptions().apply { cachePath = ":memory:" }) { runtime -> + // A region id no database row has. The operation completes, and the take reports that + // nothing was found without consuming the handle. + val operation = runtime.startOfflineRegionStatus(MISSING_REGION_ID) + waitForOperationStatus(runtime, operation) + + val first = + assertFailsWith { runtime.takeOfflineRegionStatusResult(operation) } + assertFalse(operation.isClosed) + + // Retryable, and reporting the same thing each time. + val second = + assertFailsWith { runtime.takeOfflineRegionStatusResult(operation) } + assertEquals(first.status, second.status) + assertFalse(operation.isClosed) + + operation.close() + assertTrue(operation.isClosed) + } + } + + @Test + fun aTakeOfTheWrongResultKindIsRefusedBeforeCrossingIntoTheModule() { + withRuntime(RuntimeOptions().apply { cachePath = ":memory:" }) { runtime -> + // An ambient cache operation carries no result, so asking it for a region is a + // binding-owned invariant rather than something native has to be asked about. The kinds + // are checked against the wrapper's own record, which is why this is built rather than + // started: the public API cannot produce a handle whose kinds disagree with its call. + val operation = + OfflineOperationHandle( + runtime, + 1L, + OfflineOperationKind.AMBIENT_CACHE, + OfflineOperationResultKind.NONE, + ) + try { + assertFailsWith { runtime.takeCreateOfflineRegionResult(operation) } + assertFalse(operation.isClosed) + } finally { + operation.markConsumed() + } + + // Binding-owned validation on inputs, too. + assertFailsWith { runtime.startSetMaximumAmbientCacheSize(-1L) } + assertFailsWith { + runtime.startSetOfflineRegionDownloadState(1, OfflineRegionDownloadState(900)) + } + } + } + + @Test + fun aReleaseNativeRefusesLeavesTheHandleLiveForALaterOne() { + withRuntime(RuntimeOptions().apply { cachePath = ":memory:" }) { runtime -> + // An operation id this runtime never issued. Discarding it is a native call that fails, and + // a failed release must leave the wrapper live rather than consuming it: the wrapper still + // holds its runtime open, and something has to be able to try again. + val stale = + OfflineOperationHandle( + runtime, + UNISSUED_OPERATION_ID, + OfflineOperationKind.AMBIENT_CACHE, + OfflineOperationResultKind.NONE, + ) + assertFailsWith { stale.close() } + assertFalse(stale.isClosed) + // Still holding the runtime open, which is the state a consumed wrapper would have left. + assertFailsWith { runtime.close() } + stale.markConsumed() + + // A release native accepts destroys the handle, and a second one is a no-op. + val real = runtime.startAmbientCacheOperation(AmbientCacheOperation.INVALIDATE) + real.close() + assertTrue(real.isClosed) + real.close() + assertTrue(real.isClosed) + } + } + + private fun waitForOperation( + runtime: RuntimeHandle, + operation: OfflineOperationHandle<*>, + ): RuntimeEventPayload.OfflineOperationCompleted { + val completed = waitForOperationStatus(runtime, operation) + if (completed.resultStatus != MaplibreStatus.OK.nativeCode) { + throw MaplibreException.forStatus( + MaplibreStatus.fromNative(completed.resultStatus), + completed.resultStatus, + "offline operation ${operation.id} failed", + ) + } + return completed + } + + /** Waits for the completion event, whatever status it carries. */ + private fun waitForOperationStatus( + runtime: RuntimeHandle, + operation: OfflineOperationHandle<*>, + ): RuntimeEventPayload.OfflineOperationCompleted { + var completed: RuntimeEventPayload.OfflineOperationCompleted? = null + pumpUntil( + runtime, + onEvent = { event -> + val payload = event.payload as? RuntimeEventPayload.OfflineOperationCompleted + if (payload != null && payload.operationId == operation.id) { + assertEquals(operation.kind, payload.operationKind) + assertEquals(operation.resultKind, payload.resultKind) + completed = payload + } else { + deferred.addLast(event) + } + }, + ) { + completed != null + } + return completed ?: error("offline operation ${operation.id} did not complete") + } + + private fun completeVoid(runtime: RuntimeHandle, operation: OfflineOperationHandle) { + waitForOperation(runtime, operation) + operation.close() + } + + private fun offlineRegion(runtime: RuntimeHandle, id: Long): OfflineRegionInfo? { + val operation = runtime.startOfflineRegion(id) + waitForOperation(runtime, operation) + return runtime.takeOfflineRegionResult(operation) + } + + private fun offlineRegions(runtime: RuntimeHandle): List { + val operation = runtime.startOfflineRegions() + waitForOperation(runtime, operation) + return runtime.takeOfflineRegionsResult(operation) + } + + private fun offlineRegionStatus(runtime: RuntimeHandle, id: Long): OfflineRegionStatus { + val operation = runtime.startOfflineRegionStatus(id) + waitForOperation(runtime, operation) + return runtime.takeOfflineRegionStatusResult(operation) + } + + private fun waitForObservation(runtime: RuntimeHandle, regionId: Long): RuntimeEvent { + var observed: RuntimeEvent? = null + // Events deferred by an earlier wait may already hold the observation this one wants. + while (observed == null && deferred.isNotEmpty()) { + val event = deferred.removeFirst() + if (namesRegion(event.payload, regionId)) observed = event + } + if (observed == null) { + pumpUntil(runtime, onEvent = { if (namesRegion(it.payload, regionId)) observed = it }) { + observed != null + } + } + return observed ?: error("no observation event arrived for offline region $regionId") + } + + private fun namesRegion(payload: RuntimeEventPayload, regionId: Long): Boolean = + when (payload) { + is RuntimeEventPayload.OfflineRegionStatusChanged -> payload.regionId == regionId + is RuntimeEventPayload.OfflineRegionResponseError -> payload.regionId == regionId + is RuntimeEventPayload.OfflineRegionTileCountLimit -> payload.regionId == regionId + else -> false + } + + private fun assertObservationPayload(regionId: Long, payload: RuntimeEventPayload) { + val changed = assertIs(payload) + assertEquals(regionId, changed.regionId) + assertTrue(changed.status.completedResourceCount >= 0) + assertTrue(changed.status.completedTileCount >= 0) + assertTrue(changed.status.requiredTileCount >= 0) + } + + private fun tileDefinition(): OfflineRegionDefinition.TilePyramid = + OfflineRegionDefinition.TilePyramid( + "custom://offline-style.json", + LatLngBounds(LatLng(0.0, 0.0), LatLng(1.0, 1.0)), + 0.0, + 1.0, + 1.0f, + true, + ) + + private companion object { + /** No row is created with this id, so every operation naming it reports not found. */ + const val MISSING_REGION_ID = 987_654L + + /** An operation id no runtime here issued, so discarding it is a native call that fails. */ + const val UNISSUED_OPERATION_ID = 424_242L + } +} diff --git a/ci/snapshots.toml b/ci/snapshots.toml index 0e03855ca..c81102f92 100644 --- a/ci/snapshots.toml +++ b/ci/snapshots.toml @@ -51,6 +51,7 @@ shared = [ "!gradle.properties", "!gradlew", "!gradlew.bat", + "!kotlin-js-store/**", "!package.json", "!pnpm-lock.yaml", "!pnpm-workspace.yaml", @@ -106,6 +107,10 @@ paths = [ "gradle.properties", "gradlew", "gradlew.bat", + # The Kotlin Gradle plugin pins the npm tooling its wasmJs browser tests run + # under, and writes the lock file to this directory at the repository root + # rather than beside the module it belongs to. + "kotlin-js-store/**", ] [components.python] diff --git a/ci/workflow.py b/ci/workflow.py index ee1240d51..848510074 100644 --- a/ci/workflow.py +++ b/ci/workflow.py @@ -5,6 +5,12 @@ import tomllib DESKTOP = {"linux", "macos", "windows"} +# Platforms whose jobs run the binding suites declared in ci/workflow.toml. A +# desktop job hands its build tree to every binding that targets the host. +# Emscripten joins them because the browser bindings load the module that job +# just linked, so their suites belong in the same job rather than in one of +# their own. +SUITE_PLATFORMS = DESKTOP | {"emscripten"} # Targets whose suite runs on an emulator instead of through ctest, so CMake # registers no test preset for them. @@ -167,7 +173,7 @@ def consumer_commands(source: dict[str, object], preset: str) -> list[str]: f"mise run //bindings/dart:build:mobile {preset}", ] ) - elif target_platform in DESKTOP or target_platform == "emscripten": + elif target_platform in SUITE_PLATFORMS: commands.extend(suite_commands(source, preset)) return commands diff --git a/ci/workflow.toml b/ci/workflow.toml index 10f9c58bf..f5f53f43b 100644 --- a/ci/workflow.toml +++ b/ci/workflow.toml @@ -65,6 +65,26 @@ commands = [{ task = "//examples/c-map:build" }] platforms = ["linux", "macos", "windows"] commands = [{ task = "//bindings/python:test" }] +# The Kotlin browser binding drives the prelinked Emscripten module rather than +# a library it loads through a foreign-function interface, so its suite runs in +# the job that linked that module. The tasks name no preset because they read +# the module out of the browser install prefix, which the packaged artifact +# restores in that job. WebGPU is left out because the binding renders through +# WebGL. +# +# The two checks run first, and between them they are what makes a generated +# external a checked call: one regenerates the Kotlin from the headers and +# reports any difference, the other compares it against the module the suite is +# about to load. Neither runs in the hygiene job, because both need the emsdk +# clang that lays the module out. +[[suites]] +platforms = ["emscripten"] +commands = [ + { task = "//bindings/kotlin:check-wasm-generated", include = ["emscripten-wasm32-webgl"], preset = false }, + { task = "//bindings/kotlin:check-wasm-externs", include = ["emscripten-wasm32-webgl"], preset = false }, + { task = "//bindings/kotlin:wasmJsTest", include = ["emscripten-wasm32-webgl"], preset = false }, +] + # TODO(#412): Re-enable //bindings/dart:test once thread-affine ownership is # safe across isolate migration; test processes otherwise wedge until timeout. [[suites]] diff --git a/cmake/mln_ffi_browser_module.cmake b/cmake/mln_ffi_browser_module.cmake new file mode 100644 index 000000000..30e6e18fa --- /dev/null +++ b/cmake/mln_ffi_browser_module.cmake @@ -0,0 +1,131 @@ +# The prelinked browser module. +# +# Every other platform ships a library a host loads and calls through its own +# foreign-function interface. A browser host cannot do that: an Emscripten +# archive is only linkable by the emsdk version that produced it, and a host +# written in Kotlin has no link step to run. So the browser's distributable +# artifact is the linked module itself -- an ES module and its wasm. +# +# The module carries the Kotlin binding's Emscripten shim and boots the binding +# on the pthread -sPROXY_TO_PTHREAD gives main(), where blocking is legal. + +function(mln_ffi_add_browser_module target api_target) + if(NOT EMSCRIPTEN) + return() + endif() + # The binding this module boots renders through WebGL. + if(NOT MLN_FFI_RENDER_BACKEND STREQUAL "opengl") + return() + endif() + + set(shim_dir "${PROJECT_SOURCE_DIR}/bindings/kotlin/emscripten") + set(host_library "${shim_dir}/mln_kotlin_host.js") + set(pre_library "${shim_dir}/mln_kotlin_pre.js") + + set(export_list "${CMAKE_CURRENT_BINARY_DIR}/${target}-exports.txt") + # CMAKE_NM is the emsdk's llvm-nm, which reads the wasm archive the toolchain + # just wrote; a host nm would not. + # + # `_main` is not decoration: naming EXPORTED_FUNCTIONS at all makes emcc treat + # a module without it as a reactor and skip main entirely, which is where the + # binding is booted from (emsdk tools/link.py:918-929). The shim's own entry + # points arrive through EMSCRIPTEN_KEEPALIVE, which emcc appends to this list + # after the link (tools/emscripten.py:567-595). + add_custom_command( + OUTPUT "${export_list}" + COMMAND + "${CMAKE_COMMAND}" + "-DMLN_FFI_NM=${CMAKE_NM}" + "-DMLN_FFI_ARCHIVE=$" + "-DMLN_FFI_OUTPUT=${export_list}" + "-DMLN_FFI_EXTRA_EXPORTS=_main$_malloc$_free" + -P + "${PROJECT_SOURCE_DIR}/cmake/scripts/mln_ffi_browser_exports.cmake" + DEPENDS + "$" + "${PROJECT_SOURCE_DIR}/cmake/scripts/mln_ffi_browser_exports.cmake" + COMMENT "Collecting browser module exports" + VERBATIM) + add_custom_target(${target}_exports DEPENDS "${export_list}") + + add_executable( + ${target} "${shim_dir}/mln_kotlin_main.c" + "${shim_dir}/mln_kotlin_callbacks.c" "${shim_dir}/mln_kotlin_webgl.c") + add_dependencies(${target} ${target}_exports) + # The same two dependency targets mln_ffi_install_emscripten_options() writes + # into share/maplibre-native-c/emscripten-link-flags.txt, so a module linked + # here and a module linked out of the install prefix carry one set of options. + target_link_libraries( + ${target} + PRIVATE ${api_target} MLN_FFI::RenderDependencies) + + # The public headers use C23 fixed-underlying-type enums, and linking the C + # API does not carry its language mode across. + set_target_properties( + ${target} + PROPERTIES + C_STANDARD + 23 + C_STANDARD_REQUIRED + YES + C_EXTENSIONS + OFF + OUTPUT_NAME + maplibre_native_c + SUFFIX + .mjs + RUNTIME_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/browser") + + # A host imports the module from a page or a worker, and pthreads reach it + # from their own workers, so all three environments stay in it. + # + # EXPORT_ES6 is also what makes pthread workers type:'module' (emsdk + # libpthread.js:37-39), which is what makes import() available inside one -- + # and the binding is imported into a pthread. PROXY_TO_PTHREAD is what gives + # main() that pthread, so the binding may block and the host's event loop + # stays free. + # + # Nothing is transferred to it at startup: the host has no canvas by the time + # the proxied main thread is created, and the default selector would fail the + # create (emsdk libpthread.js:712-716). Canvases are registered later. + # + # WASM_BIGINT keeps a 64-bit handle a BigInt rather than a pair of i32s, so a + # host cannot silently truncate one. + # + # Linking the exports library is emcc's supported way to keep the wasm export + # names, which an optimized link otherwise minifies to one and two letters + # (emsdk tools/link.py:1501-1522). That is invisible to a host calling through + # the module object and fatal to scripts/check-browser-exports.py, which reads + # each entry point's lowered signature out of the shipped module. + target_link_options( + ${target} + PRIVATE + "-sENVIRONMENT=web,worker" + -sMODULARIZE=1 + -sEXPORT_ES6=1 + -sEXPORT_NAME=createMaplibreNativeC + -sPROXY_TO_PTHREAD + # The thread this binding runs on is created during instantiation, which + # is the only moment a canvas can be transferred to it. The pre-js + # registers one under this name either way, so a host with no on-screen + # map does not fail thread creation on a selector matching nothing. + "-sOFFSCREENCANVASES_TO_PTHREAD=maplibre" + -sOFFSCREENCANVAS_SUPPORT=1 + -sWASM_BIGINT=1 + "-sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAPU16,HEAPU32,HEAPF32,HEAPF64,GL,UTF8ToString,stringToUTF8,lengthBytesUTF8" + "-sEXPORTED_FUNCTIONS=@${export_list}" + "--js-library=${host_library}" + "--pre-js=${pre_library}" + -lexports.js) + set_property( + TARGET ${target} + APPEND + PROPERTY LINK_DEPENDS "${export_list}" "${host_library}" "${pre_library}") + + install( + FILES "${CMAKE_CURRENT_BINARY_DIR}/browser/maplibre_native_c.mjs" + "${CMAKE_CURRENT_BINARY_DIR}/browser/maplibre_native_c.wasm" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/browser" + COMPONENT "${MLN_FFI_NATIVE_COMPONENT}") +endfunction() diff --git a/cmake/platform/emscripten.cmake b/cmake/platform/emscripten.cmake index 8cd04f01b..80ae3d1d8 100644 --- a/cmake/platform/emscripten.cmake +++ b/cmake/platform/emscripten.cmake @@ -13,7 +13,32 @@ function(mln_ffi_configure_platform_dependencies target) "-sDEFAULT_TO_CXX=1" -fwasm-exceptions "-sFETCH=1" - "-sUSE_ZLIB=1") + "-sUSE_ZLIB=1" + # The heap is fixed, so running out of it is a thing a module has to be + # able to say rather than a thing that cannot happen. Emscripten's default + # says it by aborting: `emscripten_resize_heap` calls + # `abortOnCannotGrowMemory` the first time an allocation needs a byte past + # the initial memory, which takes the whole module down and leaves a host + # holding handles it can no longer call and no error it could have caught. + # Every null check above a `malloc` in this repository is dead code under + # that default. + # + # With it off, the two allocation paths report instead. `malloc` returns + # null, which the C code and the bindings check; `operator new` throws + # `std::bad_alloc`, because the module links the exception-enabled + # libc++abi that `-fwasm-exceptions` selects, and + # `mln::c_api::status_boundary` catches it and returns + # MLN_STATUS_NATIVE_ERROR with its message. What is left unreportable is + # an allocation deep on a MapLibre worker thread, where there is no C API + # call to return to and an escaping `std::bad_alloc` terminates. That is + # what the default did to every allocation anyway. + # + # Growth is a separate, capacity-shaped decision that this one does not + # make. It moves the wall to MAXIMUM_MEMORY rather than removing it, and + # it replaces every JavaScript view of the heap on each grow, so a binding + # holding one would have to re-read it per access. A host that needs more + # memory raises MLN_FFI_EMSCRIPTEN_INITIAL_MEMORY. + "-sABORTING_MALLOC=0") # TODO: Use SIDE_MODULE when pthread dynamic linking is stable. set_target_properties( ${target} diff --git a/cmake/scripts/mln_ffi_browser_exports.cmake b/cmake/scripts/mln_ffi_browser_exports.cmake new file mode 100644 index 000000000..968f63859 --- /dev/null +++ b/cmake/scripts/mln_ffi_browser_exports.cmake @@ -0,0 +1,65 @@ +# Writes the browser module's exported-function list. +# +# The list comes from the built archive rather than from parsing `include/`, +# because it is the only source that cannot drift: every MLN_API function is a +# defined `mln_` symbol there, and a declaration this project adds, renames, or +# removes reaches the archive before it reaches anything that reads it. Fifty of +# them also span two lines in the headers, which is enough to defeat a reader +# that matches a declaration to a line. +# +# Invoked as a script: +# cmake -DMLN_FFI_NM= -DMLN_FFI_ARCHIVE= -DMLN_FFI_OUTPUT= +# -DMLN_FFI_EXTRA_EXPORTS= -P mln_ffi_browser_exports.cmake + +foreach(required MLN_FFI_NM MLN_FFI_ARCHIVE MLN_FFI_OUTPUT) + if(NOT DEFINED ${required}) + message(FATAL_ERROR "${required} is required") + endif() +endforeach() + +execute_process( + COMMAND "${MLN_FFI_NM}" --defined-only --format=posix "${MLN_FFI_ARCHIVE}" + OUTPUT_VARIABLE nm_output + RESULT_VARIABLE nm_status + ERROR_VARIABLE nm_error) +if(NOT nm_status EQUAL 0) + message(FATAL_ERROR "nm failed on ${MLN_FFI_ARCHIVE}: ${nm_error}") +endif() + +# POSIX format is " " per line, so the symbol is the +# first field. Only the public prefix is exported; everything else in the +# archive is an implementation detail a host must not reach. +set(exports) +string(REPLACE "\n" ";" nm_lines "${nm_output}") +foreach(line IN LISTS nm_lines) + if(line MATCHES "^(mln_[A-Za-z0-9_]+) ") + list(APPEND exports "_${CMAKE_MATCH_1}") + endif() +endforeach() + +if(NOT exports) + message(FATAL_ERROR "no mln_ symbols found in ${MLN_FFI_ARCHIVE}") +endif() + +# Names the module exports that are not MLN_API. The caller says why each is +# here. +if(DEFINED MLN_FFI_EXTRA_EXPORTS) + foreach(extra IN LISTS MLN_FFI_EXTRA_EXPORTS) + list(APPEND exports "${extra}") + endforeach() +endif() + +list(REMOVE_DUPLICATES exports) +list(SORT exports) +string(JOIN "\n" export_lines ${exports}) + +# Written through a temporary so a failed run leaves no half-written list, and +# compared first so an unchanged list does not relink the module. +set(previous "") +if(EXISTS "${MLN_FFI_OUTPUT}") + file(READ "${MLN_FFI_OUTPUT}" previous) +endif() +if(NOT previous STREQUAL "${export_lines}\n") + file(WRITE "${MLN_FFI_OUTPUT}.tmp" "${export_lines}\n") + file(RENAME "${MLN_FFI_OUTPUT}.tmp" "${MLN_FFI_OUTPUT}") +endif() diff --git a/docs/scripts/generate-support-matrix.py b/docs/scripts/generate-support-matrix.py index c9a8054f6..e254d640d 100755 --- a/docs/scripts/generate-support-matrix.py +++ b/docs/scripts/generate-support-matrix.py @@ -59,6 +59,7 @@ "bindings-kotlin-android": "Kotlin/Android", "bindings-kotlin-jvm": "Kotlin/JVM", "bindings-kotlin-native": "Kotlin/Native", + "bindings-kotlin-wasm": "Kotlin/Wasm", } @@ -106,6 +107,7 @@ def command_support(command: str) -> tuple[str, str] | None: "androidBuild": "bindings-kotlin-android", "jvmTest": "bindings-kotlin-jvm", "nativeTest": "bindings-kotlin-native", + "wasmJsTest": "bindings-kotlin-wasm", }.get(action) if project_id is None: return None @@ -119,6 +121,7 @@ def command_support(command: str) -> tuple[str, str] | None: "test", "jvmTest", "nativeTest", + "wasmJsTest", "run", "test:android-emulator", "test:ios-simulator", diff --git a/docs/src/content/docs/development/binding-specification.md b/docs/src/content/docs/development/binding-specification.md index 7e34c098d..499086a46 100644 --- a/docs/src/content/docs/development/binding-specification.md +++ b/docs/src/content/docs/development/binding-specification.md @@ -528,6 +528,15 @@ Resource transform invocation follows this operation: callback boundary. If the public handler returns a recoverable host failure, convert the failure to the C callback's documented behavior. +#### The browser + +Resource transform registration reports unsupported in the browser, where +MapLibre raises the callback on worker threads. Each worker is a separate +JavaScript agent, and a host callback belongs to the single agent that defined +it. The browser binding exposes native rewrite rules instead: a rule matches a +resource kind and a URL, exactly or as a glob, and the first matching rule +supplies the replacement URL. + ### HTTP header transforms Direct-callback bindings copy the resource kind and transformed URL into a @@ -595,6 +604,10 @@ Resource provider invocation follows this operation: 6. Allow deferred or cross-thread completion when the C API allows it, without changing one-shot or release behavior. +A binding that cannot answer the callback synchronously registers +`mln_adapter_queued_resource_provider` instead, declaring at registration the +routes it claims and completing each claimed request later. + Provider registration is replaceable for a runtime's whole life. A binding keeps the registered callback state reachable until the C call that replaces or clears the provider returns, and releases it after that call returns. @@ -767,6 +780,14 @@ For host-owned backend resources, the binding does not release or synchronize those resources. The caller keeps them valid for the C API's documented borrow window. +Where the host graphics API is the binding's own module, the binding supplies +those resources instead. A browser host cannot create a WebGL context that a +render target accepts: the handle in a WebGL context descriptor indexes the +module's own table, and a context belongs to the agent that created it. The +browser binding therefore exposes context creation, resize, texture creation and +destruction, presentation, and readback as public API. Every other target leaves +those to the host, which owns EGL, Metal, or Vulkan itself. + The public handle exposes: - `resize` for session kinds that support resize; @@ -988,6 +1009,10 @@ When the binding routes provider requests through | BND-156 | A glob route claims every request URL its pattern matches, and a request URL the pattern leaves unmatched passes through to native loading. | | BND-157 | A route comparing the requested URL claims a request for a configured URI-scheme alias, and a route comparing the resolved URL claims that same request by its tile-server-normalized URL. | +BND-150 does not apply to that binding, because the queued provider decides +handled ownership by route before the request reaches host code, leaving no +callback return path to override. + ### Rendering | ID | Test | diff --git a/docs/src/content/docs/development/kotlin-publishing.md b/docs/src/content/docs/development/kotlin-publishing.md index 66fe0f208..58821ba98 100644 --- a/docs/src/content/docs/development/kotlin-publishing.md +++ b/docs/src/content/docs/development/kotlin-publishing.md @@ -163,13 +163,28 @@ set to a versioned directory and loads packaged dependencies before the C API library. Linux hosts still need the selected graphics loader and driver. Explicit native-library path configuration remains available as an override. +### Browser + +The wasmJs target calls a prelinked WebAssembly module that the page fetches, so +it loads no library and takes no runtime publication. The +`maplibre-native-ffi-wasm-js` module carries that module as a `browser-module` +archive holding `maplibre_native_c.mjs` and its wasm. The +[install page](/maplibre-native-ffi/install/) covers how a host resolves and +unpacks the archive. + +The Emscripten CMake preset links that module and installs it under +`lib/browser`, and the browser native package carries the same prefix. The +publication copies those two files out of that package, so a browser host and a +Kotlin host receive the bytes that one CI job produced. + ## Snapshot publication Snapshot versions end in `-SNAPSHOT` and publish from the exact commit that -passed the main CI workflow. A Linux x64 runner builds the Android publications, -reusing the matrix's CMake install archives to build only the JNI bridge and -final AARs, while macOS runners build the JVM, macOS, and iOS publications. Each -consumes native build artifacts produced by the platform and backend CI matrix. +passed the main CI workflow. A Linux x64 runner builds the Android and browser +publications, reusing the matrix's CMake install archives to build only the JNI +bridge and final AARs, while macOS runners build the JVM, macOS, and iOS +publications. Each consumes native build artifacts produced by the platform and +backend CI matrix. A daily schedule drives publication rather than each push to `main`: the workflow picks the latest successful CI run on `main` and publishes from its @@ -208,6 +223,7 @@ The initial snapshot workflow validates: - JNI library placement and Rustls helper presence in Android AARs; - Android runtime publications resolving without the Kotlin binding; - native resource presence in JVM classifier JARs; +- prelinked module presence in the browser archive of the wasmJs publication; - Dokka-generated API pages in every API-bearing javadoc JAR; - published JVM consumption through the Compose and LWJGL examples; - published Android consumption through the Android map example. diff --git a/docs/src/content/docs/guides/render-a-map-in-a-browser.mdx b/docs/src/content/docs/guides/render-a-map-in-a-browser.mdx new file mode 100644 index 000000000..9d2c31e6c --- /dev/null +++ b/docs/src/content/docs/guides/render-a-map-in-a-browser.mdx @@ -0,0 +1,158 @@ +--- +title: Render a map in a browser +description: Start the WebAssembly module from a page, run the binding on the thread the module owns, and put frames on a canvas. +sidebar: + order: 18 +--- + +The Kotlin wasmJs target runs MapLibre Native inside a WebAssembly module that a +page fetches. The page instantiates the module, and the module starts your +Kotlin program on a thread of its own. That thread may block, and it is the +owner thread for everything the binding creates there, so the runtime, map, and +render session are the ones every other Kotlin target presents: ordinary +handles and synchronous calls. + +[Install](/maplibre-native-ffi/install/#in-a-browser) covers the module files and +the response headers that serve them. This page starts where that one ends. + +## Start the module + +Build your program as a Kotlin wasmJs library distribution named +`maplibre-native-kotlin.mjs`, served beside the module, and export +`mlnKotlinMain`. The module imports that file into the thread it started and +calls that function. + +```html title="index.html" + + +``` + +Page code and binding code share no stack, so a page callback records what it +needs and your program reads that state on its next turn. + +Transfer the canvas before you instantiate, and pass it as `mlnPageCanvas`. A +browser gives a canvas to a thread only as that thread is created, and the +module creates its thread while it instantiates. A program that only renders to +textures passes nothing. + +One canvas can be displayed. It belongs to the thread it was transferred to, +and there is one such thread; offscreen canvases have no such limit. + +## Choose where a frame goes + +`WebglContext.createForCanvas` builds a context whose default framebuffer is a +`` element that the page displays. Use it for an on-screen map. +`WebglContext.createOffscreen` builds a context against a private +`OffscreenCanvas` that nothing displays. Use it for readback, where the host +consumes the pixels itself. + +The context is the binding's to create on this target, because a WebGL context +belongs to the agent that created it and the agent that renders is the module's +thread. Every other platform takes a context that the host created with its own +graphics API. + +A surface target renders into the context's canvas directly. A session-owned +texture target and a caller-owned texture target render into a framebuffer of +their own, and `presentTexture` blits the texture onto the canvas. Both paths +keep the pixels on the GPU, and `readPixels` copies a frame into the module's +heap for a host that encodes or compares images itself. +[Attach a render target](/maplibre-native-ffi/guides/attach-a-render-target/) +covers the three families in full. + +## Put a map on the page + +```kotlin title="MapApp.kt" +private const val WIDTH = 800 +private const val HEIGHT = 600 +private const val POLL_MILLIS = 8L + +@JsExport +fun mlnKotlinMain() { + Maplibre.loadNativeLibrary() + val runtime = RuntimeHandle.create(RuntimeOptions()) + val map = + MapHandle.create( + runtime, + MapOptions().apply { + width = WIDTH + height = HEIGHT + }, + ) + val context = WebglContext.createForCanvas("map", WIDTH, HEIGHT) + val session = + map.attachOpenGLSurface( + OpenGLSurfaceDescriptor( + extent = RenderTargetExtent(WIDTH, HEIGHT, 1.0), + context = context.descriptor(), + // A WebGL context is bound to its canvas already, so this target names + // no drawable of its own. + surface = NativePointer.NULL, + ) + ) + map.setStyleUrl("https://demotiles.maplibre.org/style.json") +``` + +Each pass of the render loop pumps the runtime, drains its events, and asks the +session to render. + +```kotlin title="MapApp.kt" + runtime.pump(POLL_MILLIS) + while (runtime.pollEvent() != null) {} + session.renderUpdate() +``` + +Schedule each pass on the thread's event loop, and return from the pass that +drew. A browser composites a canvas when the task that drew into it ends, and +that task is the turn of the event loop your program runs in, so a program that +loops without returning draws frames that the page never shows. A host that +reads frames back rather than presenting them is free to loop and block, +because compositing stays out of its path. +[Run the render loop](/maplibre-native-ffi/guides/run-the-render-loop/) covers +when a frame is due, and +[Handle events](/maplibre-native-ffi/guides/handle-events/) covers reading +events. + +Sizing a surface target takes two calls, because the canvas's drawing buffer is +what a frame lands in and the session's extent is what MapLibre lays a frame out +for. Call `context.resizeCanvas(width, height)` and then +`session.resize(width, height, scaleFactor)`. + +## Present a texture target + +A texture target renders into a framebuffer of its own, so the host moves the +result onto the canvas. Attach one against the same context in place of the +surface target above, and present each frame from inside the loop: acquire the +frame, present the texture it names, and release the frame handle. + +```kotlin title="MapApp.kt" + if (session.renderUpdate()) { + session.acquireOpenGLOwnedTextureFrame().use { acquired -> + val frame = acquired.frame() + context.presentTexture(frame.texture(), frame.width(), frame.height()) + } + } +``` + +A caller-owned texture target works the same way, with a texture that the host +allocates. Create it with `context.createTexture`, name it in an +`OpenGLBorrowedTextureDescriptor`, present it after each render, and release it +with `context.destroyTexture` once no target uses it. Create that texture in +this context: WebGL shares no objects between contexts, so a texture that the +page made through `canvas.getContext("webgl2")` names nothing the session can +attach. + +## Close in order + +Close the session, then the context, then the map, then the runtime. A context +that a live render target borrows stays in use, and a runtime that still owns a +map reports an invalid-state failure. Close on every path your program can take, +with `use` or a `finally` block. + +Close every handle that a host opens, because this target has no finalization of +any kind and a handle left open holds its native memory. The module itself lives +as long as the agent that instantiated it, so leaving the page, or terminating a +worker that hosts it, releases the rest. diff --git a/docs/src/content/docs/install.mdx b/docs/src/content/docs/install.mdx index ba5ebb360..8e1232ce9 100644 --- a/docs/src/content/docs/install.mdx +++ b/docs/src/content/docs/install.mdx @@ -42,6 +42,52 @@ no additional TLS package. The other runtimes are `maplibre-native-ffi-runtime-opengl` and `maplibre-native-ffi-runtime-metal`. +### In a browser + +The wasmJs target calls a prelinked WebAssembly module that the page fetches, +rather than a library that a host loads through a foreign-function interface. +That module is linked against WebGL already, so this target takes no runtime +dependency for a backend. Depend on the binding, and take the module from the +`browser-module` archive that the wasmJs artifact carries. + +```kotlin title="build.gradle.kts" +val browserModule: Configuration by configurations.creating + +dependencies { + implementation("org.maplibre.nativeffi:maplibre-native-ffi:0.1.0-SNAPSHOT") + browserModule( + "org.maplibre.nativeffi:maplibre-native-ffi-wasm-js:0.1.0-SNAPSHOT:browser-module@zip" + ) +} +``` + +Resolve that configuration and unpack the archive into the directory that serves +your page. It holds `maplibre_native_c.mjs` and `maplibre_native_c.wasm`. Your +page imports the first, and that file resolves the second against its own URL, +so both belong in one directory. + +Your own program belongs there too, built as a Kotlin wasmJs library +distribution named `maplibre-native-kotlin.mjs`. The module resolves that name +against its own URL when it starts your program, so a distribution served +somewhere else is not found. + +Take the archive at the version you depend on. The binding checks the module's C +ABI version as it loads, and a module built from different headers than the +binding was generated from fails that check. + +Serve every response cross-origin isolated, with +`Cross-Origin-Opener-Policy: same-origin` and +`Cross-Origin-Embedder-Policy: require-corp`. The module runs MapLibre on +threads of its own. Those threads need `SharedArrayBuffer`, which a browser +exposes only to a cross-origin isolated page, and the workers they run in +inherit that state from the page that started them. + +Your page starts the module, and the module starts your Kotlin program on the +thread it owns. [Render a map in a +browser](/maplibre-native-ffi/guides/render-a-map-in-a-browser/) covers that +boot, the WebGL context the binding creates there, and the canvas a page hands +over. + ## Rust Depend on the `maplibre-native-ffi` crate from Git and enable one backend feature. diff --git a/gradle.properties b/gradle.properties index d28df2cc6..3a5ee6819 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,3 +24,9 @@ POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/maplibre/maplibre-native-ffi POM_DEVELOPER_ID=maplibre POM_DEVELOPER_NAME=MapLibre contributors POM_DEVELOPER_URL=https://maplibre.org + +# Kotlin's wasmJs browser tooling defaults to Yarn 1, which walks up to the repository's own +# package.json and reads its `packageManager` field -- pnpm, which Yarn 1 cannot parse. npm reads +# the workspace the same way the rest of the repository does, so the browser test task resolves its +# Karma tooling rather than failing before it runs. +kotlin.js.yarn=false diff --git a/include/maplibre_native_c/surface.h b/include/maplibre_native_c/surface.h index c1e200f21..1a4873990 100644 --- a/include/maplibre_native_c/surface.h +++ b/include/maplibre_native_c/surface.h @@ -167,9 +167,13 @@ MLN_API mln_status mln_vulkan_surface_attach( * on this thread. The session renders to descriptor->surface and presents * through the selected context provider. WGL * surfaces present with SwapBuffers(HDC), and EGL surfaces present with - * eglSwapBuffers(EGLDisplay, EGLSurface). OpenGL context handles are borrowed - * and must remain valid until detach or destroy. On success, *out_session - * receives a handle the caller destroys with mln_render_session_destroy(). + * eglSwapBuffers(EGLDisplay, EGLSurface). WebGL presents nothing: the session + * renders into the default framebuffer of the canvas its context is bound to, + * and the browser composites that canvas once the task that rendered ends, so + * a host that renders on a thread which never returns to its event loop draws + * frames nothing displays. OpenGL context handles are borrowed and must remain + * valid until detach or destroy. On success, *out_session receives a handle the + * caller destroys with mln_render_session_destroy(). * * Returns: * - MLN_STATUS_OK on success. @@ -312,6 +316,10 @@ MLN_API mln_status mln_vulkan_surface_set_target( * next mln_render_session_render_update() as MLN_STATUS_NATIVE_ERROR rather * than by this function. The session stays destroyable in that state. * + * A WebGL session has no surface to replace: descriptor->surface stays null + * and only the extent changes. Resizing the canvas drawing buffer is the + * host's, on the thread that owns the canvas. + * * A lost OpenGL context requires destroying the session and attaching again. * * Returns: diff --git a/kotlin-js-store/wasm/package-lock.json b/kotlin-js-store/wasm/package-lock.json new file mode 100644 index 000000000..cbe3cb7ca --- /dev/null +++ b/kotlin-js-store/wasm/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "maplibre-native-ffi", + "version": "unspecified", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maplibre-native-ffi", + "version": "unspecified", + "workspaces": [ + "packages/maplibre-native-ffi-bindings-kotlin", + "packages/maplibre-native-ffi-bindings-kotlin-test" + ], + "devDependencies": {} + }, + "node_modules/maplibre-native-ffi-bindings-kotlin": { + "resolved": "packages/maplibre-native-ffi-bindings-kotlin", + "link": true + }, + "node_modules/maplibre-native-ffi-bindings-kotlin-test": { + "resolved": "packages/maplibre-native-ffi-bindings-kotlin-test", + "link": true + }, + "packages/maplibre-native-ffi-bindings-kotlin": { + "version": "0.0.0-unspecified", + "devDependencies": {} + }, + "packages/maplibre-native-ffi-bindings-kotlin-test": { + "version": "0.0.0-unspecified", + "devDependencies": {} + } + } +} diff --git a/scripts/check-browser-exports.py b/scripts/check-browser-exports.py new file mode 100755 index 000000000..070c08289 --- /dev/null +++ b/scripts/check-browser-exports.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Checks the generated Kotlin externals against the linked browser module. + +The Kotlin/Wasm binding calls the module through JavaScript, so nothing at +either compile step compares the two: Kotlin type-checks a call against a +declaration it was handed, and the module exports whatever it was linked with. A +declaration that survived a header change reaches native as a plausible wrong +call — a pointer where a handle belongs, or one argument short of the hidden +out-pointer a struct return needs. + +This reads the export signatures out of the module emcc wrote and compares each +one against the declaration the binding compiles against. Both readings describe +the shipped artifact, so a mismatch is a real defect rather than a modelling +disagreement. + +The module keeps its export names because it is linked with `-lexports.js`; an +optimized link otherwise renames them to one- and two-letter names. +""" + +from __future__ import annotations + +import argparse +import os +import pathlib +import re +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + +DEFAULT_EXTERNS = ( + REPO_ROOT + / "bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi" + / "internal/wasm/generated/EntryPoints.kt" +) +DEFAULT_MODULE = ( + REPO_ROOT + / "build/emscripten-wasm32-webgl/install/lib/browser/maplibre_native_c.wasm" +) + +#: The Emscripten allocator the binding's heap arena calls directly. It is +#: hand-written rather than generated, so its presence is asserted here. +REQUIRED_EXPORTS = ("malloc", "free") + +#: How a Kotlin type crosses into wasm. `Long` is 64-bit and reaches the module +#: as a BigInt; everything else the generator emits is 32-bit or floating point. +WASM_TYPES = {"Int": "i32", "Long": "i64", "Float": "f32", "Double": "f64"} + +DECLARATION = re.compile(r"internal external fun (mln_\w+)\(([^)]*)\)(?:\s*:\s*(\w+))?") +PARAMETER = re.compile(r":\s*(\w+)") + + +def declared_signatures(externs: pathlib.Path) -> dict[str, tuple[list, list]]: + text = externs.read_text() + signatures = {} + for name, parameters, returned in DECLARATION.findall(text): + signatures[name] = ( + [WASM_TYPES[kotlin] for kotlin in PARAMETER.findall(parameters)], + [WASM_TYPES[returned]] if returned else [], + ) + if not signatures: + raise SystemExit(f"{externs} declares no entry points") + return signatures + + +def exported_signatures(module_path: pathlib.Path) -> dict[str, tuple[list, list]]: + """Reads every exported function's signature from the linked module. + + The parser is emsdk's own, so it reads what the pinned toolchain wrote. + """ + emsdk = os.environ.get("EMSDK") + if not emsdk: + raise SystemExit( + "EMSDK is unset. Run this under `mise exec`, which puts the pinned " + "emsdk in the environment." + ) + sys.path.insert(0, str(pathlib.Path(emsdk) / "upstream" / "emscripten")) + from tools import webassembly + from tools.webassembly import ExternType, SecType, Type + + names = {Type.I32: "i32", Type.I64: "i64", Type.F32: "f32", Type.F64: "f64"} + with webassembly.Module(str(module_path)) as module: + types = module.get_types() + imported = sum( + 1 for entry in module.get_imports() if entry.kind == ExternType.FUNC + ) + # get_functions() reports code bodies rather than types, so the function + # section is read directly. + section = module.get_section(SecType.FUNCTION) + if section is None: + raise SystemExit(f"{module_path} has no function section") + module.seek(section.offset) + function_types = [module.read_uleb() for _ in range(module.read_uleb())] + + exports = {} + for export in module.get_exports(): + if export.kind != ExternType.FUNC: + continue + defined = export.index - imported + if not 0 <= defined < len(function_types): + raise SystemExit( + f"{export.name} resolves outside the defined functions; " + "the module exports an import, which this API never does" + ) + signature = types[function_types[defined]] + exports[export.name] = ( + [names[Type(parameter)] for parameter in signature.params], + [names[Type(result)] for result in signature.returns], + ) + return exports + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("module", type=pathlib.Path, nargs="?", default=DEFAULT_MODULE) + parser.add_argument("--externs", type=pathlib.Path, default=DEFAULT_EXTERNS) + arguments = parser.parse_args(argv) + + if not arguments.module.exists(): + raise SystemExit( + f"{arguments.module} does not exist; build the browser module first" + ) + declared = declared_signatures(arguments.externs) + exported = exported_signatures(arguments.module) + + if not any(name.startswith("mln_") for name in exported): + raise SystemExit( + f"{arguments.module} exports no mln_* name. It was linked without " + "-lexports.js, so its export names are minified and nothing here " + "can be attributed to an entry point." + ) + + failures = [] + for name in REQUIRED_EXPORTS: + if name not in exported: + failures.append(f"{name} is not exported; the binding's heap calls it") + for name, signature in sorted(declared.items()): + if name not in exported: + failures.append(f"{name} is declared but the module does not export it") + elif exported[name] != signature: + failures.append( + f"{name} is declared {_render(signature)} and exported " + f"{_render(exported[name])}" + ) + if failures: + print( + f"{arguments.externs.name} disagrees with {arguments.module.name}:", + file=sys.stderr, + ) + for failure in failures: + print(f" {failure}", file=sys.stderr) + print( + "Regenerate with scripts/generate-wasm-externs.py, and rebuild the " + "browser module if the headers moved.", + file=sys.stderr, + ) + return 1 + print(f"{len(declared)} entry points match {arguments.module.name}") + return 0 + + +def _render(signature: tuple[list, list]) -> str: + parameters, results = signature + return f"({', '.join(parameters)}) -> {', '.join(results) or '()'}" + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/generate-wasm-externs.py b/scripts/generate-wasm-externs.py new file mode 100755 index 000000000..c1ab4ff31 --- /dev/null +++ b/scripts/generate-wasm-externs.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Emits Kotlin external declarations for the C entry points the binding calls. + +Kotlin/Wasm reaches the Emscripten module through JavaScript, so every call is a +`@JsFun` import over `Module._mln_*`. Declaring each one by name is what makes +the compiler check the call: an entry point's *declared* types decide whether an +argument crosses as a JavaScript number or as a BigInt, and the lowered wasm +signature cannot say, because it spells a pointer, an enum, and a handle alike +as `i32`. + +Two lowerings apply on top of the declared types, both of them clang's: +a struct passed by value crosses as the address of a copy, and a function +returning a struct takes a hidden out-pointer and returns nothing. +`scripts/check-browser-exports.py` proves both against the linked module. +""" + +from __future__ import annotations + +import argparse +import pathlib +import sys + +import wasm_c_api + +PROLOGUE = """// Generated by scripts/generate-wasm-externs.py. Edit the generator. +// +// One declaration per C entry point this binding calls, lowered for +// wasm32-unknown-emscripten. Pointers are `Int` because the target is 32-bit; +// handles are `Long` because they are 64-bit and cross as BigInt. A function +// that returns a struct by value takes `out_return` and returns nothing. + +package org.maplibre.nativeffi.internal.wasm.generated""" + +#: The module the host installs on the pthread Kotlin runs on. A `@JsFun` body +#: is an arrow function, so the module object has to reach it through the global. +MODULE = "globalThis.__maplibreNativeC" + +#: Resolved C types that cross as a 64-bit value. Everything else scalar is +#: 32-bit on wasm32, `long` and `size_t` included. +WIDE = ("long long", "unsigned long long") + +FLOAT_TYPES = {"float": "Float", "double": "Double"} + +#: Kotlin and JavaScript both reject some C parameter names as identifiers. +#: `arguments` and `eval` are not keywords but cannot be bound in strict mode, +#: which an ES module always is -- and a binding for one is a syntax error that +#: fails the whole module rather than the one call it appears in. +RESERVED = { + "arguments", "eval", "implements", "private", "protected", "public", + "static", + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", + "if", "in", "interface", "is", "null", "object", "package", "return", + "super", "this", "throw", "true", "try", "typealias", "typeof", "val", + "var", "when", "while", "case", "catch", "const", "default", "delete", + "enum", "export", "extends", "function", "import", "let", "new", "switch", + "void", "with", "yield", +} # fmt: skip + + +def kotlin_type(declarations: wasm_c_api.Declarations, c_type: str) -> str: + """Reports how one declared C type crosses, or `Unit` for an aggregate.""" + resolved = declarations.resolve(c_type) + if resolved.endswith("*") or "(*)" in resolved: + return "Int" + if resolved in WIDE: + return "Long" + if resolved in FLOAT_TYPES: + return FLOAT_TYPES[resolved] + if resolved == "void": + return "Unit" + if resolved.startswith(("struct ", "union ")): + return "Unit" + if resolved.startswith( + ("int", "unsigned", "signed", "short", "long", "char", "bool", "_Bool") + ): + return "Int" + raise SystemExit(f"no wasm lowering for the C type {c_type!r} ({resolved!r})") + + +def declaration(declarations: wasm_c_api.Declarations, name: str) -> str: + entry = declarations.functions[name] + returned = kotlin_type(declarations, entry["return"]) + aggregate = declarations.resolve(entry["return"]).startswith(("struct ", "union ")) + + parameters: list[tuple[str, str]] = [] + if aggregate: + parameters.append(("out_return", "Int")) + for index, (parameter, c_type) in enumerate(entry["parameters"]): + crossed = kotlin_type(declarations, c_type) + if crossed == "Unit": + # `(void)` carries no argument; a by-value struct crosses as the + # address of the copy clang passes. + if declarations.resolve(c_type) == "void": + continue + crossed = "Int" + argument = parameter or f"argument{index}" + parameters.append( + (argument + "_" if argument in RESERVED else argument, crossed) + ) + + arguments = ", ".join(argument for argument, _ in parameters) + call = f"{MODULE}._{name}({arguments})" + body = f"({arguments}) => " + ( + f"{{ {call} }}" if aggregate or returned == "Unit" else call + ) + signature = ", ".join(f"{argument}: {crossed}" for argument, crossed in parameters) + result = "" if aggregate or returned == "Unit" else f": {returned}" + return f'@JsFun("{body}")\ninternal external fun {name}({signature}){result}' + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + wasm_c_api.add_clang_arguments(parser) + parser.add_argument( + "--output", + type=pathlib.Path, + default=wasm_c_api.DEFAULT_GENERATED / "EntryPoints.kt", + ) + arguments = parser.parse_args(argv) + sources = arguments.sources or list(wasm_c_api.DEFAULT_SOURCES) + + clang, sysroot = wasm_c_api.resolve_toolchain(arguments) + declarations = wasm_c_api.read_declarations(clang, sysroot, arguments.include) + referenced = wasm_c_api.referenced_identifiers(sources) + + called = sorted(set(declarations.functions) & referenced) + if not called: + raise SystemExit( + f"no {wasm_c_api.PUBLIC_PREFIX}* entry point is named under " + f"{', '.join(str(source) for source in sources)}" + ) + lines = [PROLOGUE] + for name in called: + lines.append("") + lines.append(declaration(declarations, name)) + + wasm_c_api.write_if_changed(arguments.output, "\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/generate-wasm-struct-layouts.py b/scripts/generate-wasm-struct-layouts.py new file mode 100755 index 000000000..ae3fb4541 --- /dev/null +++ b/scripts/generate-wasm-struct-layouts.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Emits Kotlin field accessors for the C API's descriptor structs. + +The Kotlin/Wasm binding places every descriptor into the Emscripten heap by +writing fields at byte offsets, because the two modules cannot share memory. +Those offsets are not guessable: `mln_render_target_extent` opens with a `size` +field and its `double` forces a four-byte tail pad, so its fields sit at 0, 4, 8, +and 16 rather than the 0, 4, 8 a reader of the field list would assume. Getting +one wrong produces a descriptor native misreads, not a compile error. + +So clang measures them for wasm32-unknown-emscripten, and the result is checked +in the way the JVM binding checks in its jextract output. +""" + +from __future__ import annotations + +import argparse +import pathlib +import sys + +import wasm_c_api + +PROLOGUE = """// Generated by scripts/generate-wasm-struct-layouts.py. Edit the generator. +// +// Offsets and sizes are measured for wasm32-unknown-emscripten by the pinned +// Emscripten clang, and cover the descriptors this binding names. +// +// The accessors exist so that no hand-written code names a *field* offset. An +// offset alone says where four bytes are, not whether they hold an integer, an +// enum, or a pointer, and reading a descriptor at the wrong width is the failure +// this generated layer exists to prevent. Hand-written code still positions its +// own scratch -- an out-parameter placed after a descriptor, say -- and those +// offsets belong to the caller rather than to any C struct. + +package org.maplibre.nativeffi.internal.wasm.generated + +import org.maplibre.nativeffi.internal.wasm.Heap +import org.maplibre.nativeffi.internal.wasm.HeapPointer""" + +#: How each resolved C type is read and written through the Emscripten heap. +#: A type absent from this map gets no accessor: nested structs, arrays, and +#: function pointers are placed by their own object or by the caller, and +#: emitting a wrong-width accessor for one would be worse than emitting none. +#: +#: `long` and `unsigned long` are four bytes on wasm32, which is why they share +#: the 32-bit reader with `int`. +SCALAR_ACCESSORS = { + # C23 spells it `bool`, and a header included from C++ spells it `_Bool`. + "bool": ( + "Boolean", + "Heap.loadByte({p}) != 0.toByte()", + "Heap.storeByte({p}, if ({v}) 1 else 0)", + ), + "_Bool": ( + "Boolean", + "Heap.loadByte({p}) != 0.toByte()", + "Heap.storeByte({p}, if ({v}) 1 else 0)", + ), + "char": ("Int", "Heap.loadByte({p}).toInt()", "Heap.storeByte({p}, {v}.toByte())"), + "signed char": ( + "Int", + "Heap.loadByte({p}).toInt()", + "Heap.storeByte({p}, {v}.toByte())", + ), + "unsigned char": ( + "Int", + "Heap.loadByte({p}).toInt() and 0xFF", + "Heap.storeByte({p}, {v}.toByte())", + ), + "short": ("Int", "Heap.loadShort({p})", "Heap.storeShort({p}, {v})"), + "unsigned short": ("Int", "Heap.loadUShort({p})", "Heap.storeShort({p}, {v})"), + "int": ("Int", "Heap.loadInt({p})", "Heap.storeInt({p}, {v})"), + "unsigned int": ("Int", "Heap.loadInt({p})", "Heap.storeInt({p}, {v})"), + "long": ("Int", "Heap.loadInt({p})", "Heap.storeInt({p}, {v})"), + "unsigned long": ("Int", "Heap.loadInt({p})", "Heap.storeInt({p}, {v})"), + "long long": ("Long", "Heap.loadLong({p})", "Heap.storeLong({p}, {v})"), + "unsigned long long": ("Long", "Heap.loadLong({p})", "Heap.storeLong({p}, {v})"), + "float": ("Float", "Heap.loadFloat({p})", "Heap.storeFloat({p}, {v})"), + "double": ("Double", "Heap.loadDouble({p})", "Heap.storeDouble({p}, {v})"), +} + + +def accessor(field: str, resolved: str, offset: int) -> str: + """Emits one field's getter and setter, or nothing for a type it cannot place.""" + name = wasm_c_api.member_name(field) + capitalized = name[0].upper() + name[1:] + if resolved.endswith("*"): + return ( + f" fun {name}(base: HeapPointer): HeapPointer = " + f"HeapPointer(Heap.loadInt(base + {offset}))\n" + f" fun set{capitalized}(base: HeapPointer, value: HeapPointer) {{ " + f"Heap.storeInt(base + {offset}, value.address) }}" + ) + if resolved not in SCALAR_ACCESSORS: + return "" + kotlin_type, load, store = SCALAR_ACCESSORS[resolved] + getter = load.format(p=f"base + {offset}") + setter = store.format(p=f"base + {offset}", v="value") + return ( + f" fun {name}(base: HeapPointer): {kotlin_type} = {getter}\n" + f" fun set{capitalized}(base: HeapPointer, value: {kotlin_type}) {{ " + f"{setter} }}" + ) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + wasm_c_api.add_clang_arguments(parser) + parser.add_argument( + "--output", + type=pathlib.Path, + default=wasm_c_api.DEFAULT_GENERATED / "StructLayouts.kt", + ) + arguments = parser.parse_args(argv) + sources = arguments.sources or list(wasm_c_api.DEFAULT_SOURCES) + + clang, sysroot = wasm_c_api.resolve_toolchain(arguments) + declarations = wasm_c_api.read_declarations(clang, sysroot, arguments.include) + layouts = wasm_c_api.read_layouts(clang, sysroot, arguments.include, declarations) + referenced = wasm_c_api.referenced_identifiers(sources) + + lines = [PROLOGUE] + + # The header's own enum values, so a binding never writes `1 shl 5` beside a + # field name. A bit that moves in the headers moves here. + for name in sorted(declarations.enums): + constants = declarations.enums[name] + kotlin = wasm_c_api.object_name(name) + if not constants or kotlin not in referenced: + continue + lines.append("") + lines.append(f"/** Values of `enum {name}`. */") + lines.append(f"internal object {kotlin} {{") + for constant, value in sorted(constants.items(), key=lambda entry: entry[1]): + # Kotlin has no unsigned Int literal, so a flag on bit 31 arrives as + # its two's-complement value. The bits are what native compares. + signed = value - (1 << 32) if value >= (1 << 31) else value + lines.append(f" const val {constant}: Int = {signed}") + lines.append("}") + + # One accessor object per struct, so a caller writes a named field rather + # than an offset and a width. + for name in sorted(layouts): + kotlin = wasm_c_api.object_name(name) + if kotlin not in referenced: + continue + members = [] + for field, declared, offset in layouts[name]["fields"]: + if not field: + continue + emitted = accessor(field, declarations.resolve(declared), offset) + # A field with an accessor publishes no offset. Publishing one would + # let a caller reach past the width-safe setter and store the wrong + # size at the right place. A nested struct, union, array, or function + # pointer has no accessor, and the offset is how a caller reaches it + # and hands it to that struct's own object. + members.append( + emitted or f" const val OFFSET_{field.upper()}: Int = {offset}" + ) + if not members: + continue + lines.append("") + tag = declarations.records[name]["tag"] + lines.append(f"/** Fields of `{tag} {name}`. */") + lines.append(f"internal object {kotlin} {{") + lines.append(f" const val SIZEOF: Int = {layouts[name]['size']}") + lines.extend(members) + lines.append("}") + + wasm_c_api.write_if_changed(arguments.output, "\n".join(lines) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/run-browser-test.mjs b/scripts/run-browser-test.mjs index adaeaa641..c77494b49 100644 --- a/scripts/run-browser-test.mjs +++ b/scripts/run-browser-test.mjs @@ -1,10 +1,12 @@ // Runs an emcc HTML page or JavaScript module in isolated headless Chromium. // -// Usage: node scripts/run-browser-test.mjs +// Usage: node scripts/run-browser-test.mjs // [--timeout-seconds N] [--render-backend NAME] [--browser-arg FLAG]... -// [--module-arg ARG]... +// [--module-arg ARG]... [--page-canvas] // -// Backend-specific browser flags live here for both C and Rust suites. +// A `.js` module is hosted in a worker; a `.mjs` module is an ES module the +// page imports and instantiates itself. Backend-specific browser flags live +// here for the C, Rust, and Kotlin suites. import { spawn } from "node:child_process"; import { @@ -99,17 +101,19 @@ function findBrowser() { const [targetPath, ...rest] = process.argv.slice(2); if (!targetPath) fail( - "usage: run-browser-test.mjs [--timeout-seconds N]", + "usage: run-browser-test.mjs [--timeout-seconds N]", ); if (!existsSync(targetPath)) fail(`test target does not exist: ${targetPath}`); let timeoutSeconds = 600; +let pageCanvas = false; const extraBrowserArgs = []; const moduleArgs = []; for (let i = 0; i < rest.length; i += 1) { if (rest[i] === "--timeout-seconds") timeoutSeconds = Number(rest[i + 1]); if (rest[i] === "--browser-arg") extraBrowserArgs.push(rest[i + 1]); if (rest[i] === "--module-arg") moduleArgs.push(rest[i + 1]); + if (rest[i] === "--page-canvas") pageCanvas = true; if (rest[i] === "--render-backend") { const backend = rest[i + 1]; if (!(backend in BACKEND_BROWSER_ARGS)) @@ -119,10 +123,43 @@ for (let i = 0; i < rest.length; i += 1) { } const root = path.dirname(path.resolve(targetPath)); -const pageName = - path.extname(targetPath) === ".html" - ? path.basename(targetPath) - : generateModulePage(path.basename(targetPath)); + +// The HTML parser ends an inline script at the first ` JSON.stringify(value).replaceAll(" + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +// The page half of the result relay. Every generated page reports one payload, +// and a page that fails before reaching the module reports the failure itself +// rather than leaving the run to time out. +const REPORTER_SCRIPT = `const report = (payload) => + void fetch("/__result", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +window.addEventListener("error", (event) => + report({ status: 70, output: "uncaught error: " + event.message })); +window.addEventListener("unhandledrejection", (event) => + report({ status: 70, output: "unhandled rejection: " + event.reason }));`; + +const pageName = selectPage(path.basename(targetPath)); + +function selectPage(name) { + switch (path.extname(name)) { + case ".html": + return name; + case ".mjs": + return generateEsModulePage(name); + default: + return generateModulePage(name); + } +} // Writes the page and worker a cargo test binary needs, which emcc does not // produce for a `.js` output. @@ -132,15 +169,6 @@ const pageName = // a further pthread is the module's own business; see bindings/rust/mise.toml. function generateModulePage(moduleName) { const stem = moduleName.replace(/\.js$/, ""); - // The HTML parser ends an inline script at the first ` JSON.stringify(value).replaceAll(" - value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">"); const workerName = `${stem}.runner-worker.js`; const generatedPageName = `${stem}.runner.html`; @@ -184,16 +212,7 @@ function generateModulePage(moduleName) { ${escapeText(stem)} + + +`, + ); + return generatedPageName; +} + let settle; const finished = new Promise((resolve) => { settle = resolve; diff --git a/scripts/wasm_c_api.py b/scripts/wasm_c_api.py new file mode 100644 index 000000000..a27b5429a --- /dev/null +++ b/scripts/wasm_c_api.py @@ -0,0 +1,383 @@ +"""Reads the public C API as the pinned Emscripten clang sees it. + +The Kotlin/Wasm binding has no jextract and no ffigen, so the two generators +beside this file take their input from clang directly. Clang has already applied +the wasm32 ABI by the time it answers, which is what makes the offsets and the +lowered signatures measured rather than modelled. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import subprocess + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + +#: Only this prefix is public. Everything else a header pulls in belongs to the +#: sysroot or to MapLibre Native itself. +PUBLIC_PREFIX = "mln_" + +#: The adapter header sits outside the umbrella deliberately, so it is named. +#: The shim header is not part of the C API at all: it declares what the browser +#: module adds for this binding, and it is read here so those entry points are +#: generated and export-checked like every other one rather than declared twice +#: by hand with nothing comparing the two. +UMBRELLA = ( + '#include "maplibre_native_c.h"\n' + '#include "maplibre_native_c/callback_adapter.h"\n' + '#include "mln_kotlin.h"\n' +) + +#: Where that shim header lives, added to the include path for the read above. +SHIM_INCLUDE = REPO_ROOT / "bindings/kotlin/emscripten" + +DEFAULT_INCLUDE = REPO_ROOT / "include" +DEFAULT_SOURCES = ( + REPO_ROOT / "bindings/kotlin/src/wasmJsMain/kotlin", + REPO_ROOT / "bindings/kotlin/src/wasmJsTest/kotlin", +) +DEFAULT_GENERATED = ( + REPO_ROOT + / "bindings/kotlin/src/wasmJsMain/generated/org/maplibre/nativeffi" + / "internal/wasm/generated" +) + + +def add_clang_arguments(parser: argparse.ArgumentParser) -> None: + """Adds the toolchain and input arguments both generators take.""" + parser.add_argument( + "--clang", + type=pathlib.Path, + help="defaults to the clang inside $EMSDK", + ) + parser.add_argument( + "--sysroot", + type=pathlib.Path, + help="defaults to the sysroot inside $EMSDK", + ) + parser.add_argument("--include", type=pathlib.Path, default=DEFAULT_INCLUDE) + parser.add_argument( + "--source", + dest="sources", + type=pathlib.Path, + action="append", + help="Kotlin source root to read references from; repeatable", + ) + + +def resolve_toolchain( + arguments: argparse.Namespace, +) -> tuple[pathlib.Path, pathlib.Path]: + """Locates the pinned Emscripten clang and its sysroot. + + Both are read from the emsdk this repository pins, because a host clang lays + records out for the host target and would report offsets no shipped module + uses. + """ + if arguments.clang and arguments.sysroot: + return arguments.clang, arguments.sysroot + emsdk = os.environ.get("EMSDK") + if not emsdk: + raise SystemExit( + "EMSDK is unset and --clang/--sysroot were not both given. Run this " + "under `mise exec`, which puts the pinned emsdk in the environment." + ) + root = pathlib.Path(emsdk) / "upstream" + clang = arguments.clang or root / "bin" / "clang" + sysroot = arguments.sysroot or root / "emscripten" / "cache" / "sysroot" + return clang, sysroot + + +def run_clang( + clang: pathlib.Path, + sysroot: pathlib.Path, + include: pathlib.Path, + source: str, + *flags: str, +) -> str: + result = subprocess.run( + [ + str(clang), + "-target", + "wasm32-unknown-emscripten", + f"--sysroot={sysroot}", + "-I", + str(include), + "-I", + str(SHIM_INCLUDE), + "-fsyntax-only", + "-std=c23", + "-x", + "c", + *flags, + "-", + ], + input=source, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise SystemExit(f"clang failed:\n{result.stderr}") + return result.stdout + + +class Declarations: + """The public C API's functions, records, and enums, with typedefs resolved. + + A typedef's name says nothing about its width or its shape: `mln_map` is a + 64-bit handle and `mln_string_view` is a two-field struct passed by address, + and only the resolved form says so. + """ + + def __init__(self, tree: dict) -> None: + self.typedefs: dict[str, str] = {} + self.enum_underlying: dict[str, str] = {} + self.enums: dict[str, dict[str, int]] = {} + self.records: dict[str, dict] = {} + self.functions: dict[str, dict] = {} + self._collect(tree) + for node in tree.get("inner") or []: + if node.get("kind") == "FunctionDecl" and self._public(node): + self.functions[node["name"]] = { + "return": node["type"]["qualType"].split("(")[0].strip(), + "parameters": [ + (child.get("name") or "", child["type"]["qualType"]) + for child in node.get("inner") or [] + if child.get("kind") == "ParmVarDecl" + ], + } + + @staticmethod + def _public(node: dict) -> bool: + return node.get("name", "").startswith(PUBLIC_PREFIX) + + def _collect(self, node: dict) -> None: + kind = node.get("kind") + if kind == "TypedefDecl" and node.get("name"): + declared = node["type"] + self.typedefs[node["name"]] = declared.get( + "desugaredQualType", declared["qualType"] + ) + elif kind == "EnumDecl" and node.get("name"): + fixed = node.get("fixedUnderlyingType", {}).get("qualType") + if fixed: + self.enum_underlying[f"enum {node['name']}"] = fixed + if self._public(node): + self.enums[node["name"]] = enum_values(node) + elif ( + kind == "RecordDecl" + and self._public(node) + and node.get("completeDefinition") + ): + self.records[node["name"]] = { + "tag": node.get("tagUsed", "struct"), + # Unnamed members keep their slot, because the layout dump + # reports offsets positionally. + "fields": [ + (child.get("name") or "", child["type"]["qualType"]) + for child in node.get("inner") or [] + if child.get("kind") == "FieldDecl" + ], + } + for child in node.get("inner") or []: + self._collect(child) + + def resolve(self, c_type: str) -> str: + """Reduces a declared type to the spelling its ABI treatment follows. + + Enums resolve to their C23 fixed underlying type, so a mode field and a + `uint32_t` field are placed the same way. + """ + resolved = c_type.strip() + for _ in range(16): + if resolved in self.typedefs and self.typedefs[resolved] != resolved: + resolved = self.typedefs[resolved].strip() + elif resolved in self.enum_underlying: + resolved = self.enum_underlying[resolved].strip() + else: + return resolved + raise SystemExit(f"typedef chain for {c_type} does not terminate") + + +def enum_values(node: dict) -> dict[str, int]: + """Reads one enum's constants, so no binding writes `1 shl 5` and hopes.""" + constants: dict[str, int] = {} + value = 0 + for child in node.get("inner") or []: + if child.get("kind") != "EnumConstantDecl": + continue + # An enumerator's children include attributes as well as its + # initializer, and an attribute is not a value. + initializers = [ + inner + for inner in child.get("inner") or [] + if inner.get("kind", "").endswith(("Expr", "Literal", "Operator")) + ] + if initializers: + folded = next( + ( + result + for result in (evaluate(inner) for inner in initializers) + if result is not None + ), + None, + ) + if folded is None: + # Falling through to the sequential value would emit a plausible + # wrong constant that nothing downstream would notice. + raise SystemExit( + f"cannot evaluate {node['name']}.{child['name']}; the AST " + "carries an initializer shape this does not fold" + ) + value = folded + constants[child["name"]] = value + value += 1 + return constants + + +def evaluate(node: dict) -> int | None: + """Folds the constant expressions these headers use: literals and shifts.""" + kind = node.get("kind") + if kind == "IntegerLiteral": + return int(node["value"]) + if kind in ("ConstantExpr", "ImplicitCastExpr", "ParenExpr"): + for child in node.get("inner") or []: + folded = evaluate(child) + if folded is not None: + return folded + return int(node["value"]) if "value" in node else None + if kind == "BinaryOperator" and node.get("opcode") in ("<<", "|", "+"): + operands = [evaluate(child) for child in node.get("inner") or []] + if len(operands) == 2 and all(operand is not None for operand in operands): + left, right = operands + if node["opcode"] == "<<": + return left << right + if node["opcode"] == "|": + return left | right + return left + right + return None + + +def read_declarations( + clang: pathlib.Path, sysroot: pathlib.Path, include: pathlib.Path +) -> Declarations: + dump = run_clang(clang, sysroot, include, UMBRELLA, "-Xclang", "-ast-dump=json") + declarations = Declarations(json.loads(dump)) + if not declarations.functions: + raise SystemExit(f"no {PUBLIC_PREFIX}* declarations under {include}") + return declarations + + +#: `-fdump-record-layouts-simple` names each record, then reports its size in +#: bits and its field offsets in declaration order. +_LAYOUT_TYPE = re.compile(r"^Type: (?:struct|union) (\w+)$", re.MULTILINE) +_LAYOUT_SIZE = re.compile(r"^ Size:(\d+)$", re.MULTILINE) +_LAYOUT_OFFSETS = re.compile(r"^ FieldOffsets: \[([\d, ]*)\]>$", re.MULTILINE) + + +def read_layouts( + clang: pathlib.Path, + sysroot: pathlib.Path, + include: pathlib.Path, + declarations: Declarations, +) -> dict[str, dict]: + """Measures every public record, in bytes. + + Clang lays out only the records a translation unit uses, so naming each one + in a `sizeof` is what forces all of them into the dump. + """ + probe = [UMBRELLA] + probe += [ + f'_Static_assert(sizeof({record["tag"]} {name}) > 0, "{name}");' + for name, record in sorted(declarations.records.items()) + ] + dump = run_clang( + clang, + sysroot, + include, + "\n".join(probe) + "\n", + "-Xclang", + "-fdump-record-layouts-simple", + ) + + layouts: dict[str, dict] = {} + blocks = _LAYOUT_TYPE.split(dump) + for name, block in zip(blocks[1::2], blocks[2::2], strict=True): + if name not in declarations.records: + continue + size = _LAYOUT_SIZE.search(block) + offsets = _LAYOUT_OFFSETS.search(block) + if not size or not offsets: + raise SystemExit( + f"clang's layout for {name} has a shape this does not read; " + "the dump format changed and the parser needs updating" + ) + listed = [ + int(offset) // 8 for offset in offsets.group(1).split(",") if offset.strip() + ] + fields = declarations.records[name]["fields"] + if len(listed) != len(fields): + raise SystemExit( + f"clang reported {len(listed)} offsets for {name} and " + f"{len(fields)} fields; the two readings disagree" + ) + layouts[name] = { + "size": int(size.group(1)) // 8, + "fields": [ + (field, declared, offset) + for (field, declared), offset in zip(fields, listed, strict=True) + ], + } + + # Fail closed. A record that clang laid out under a name this did not expect + # would otherwise be dropped in silence, and the binding would write that + # descriptor at offsets nothing measured. + missing = sorted(set(declarations.records) - set(layouts)) + if missing: + raise SystemExit("clang reported no layout for: " + ", ".join(missing)) + return layouts + + +_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def referenced_identifiers(sources: list[pathlib.Path]) -> set[str]: + """Collects every identifier the hand-written Kotlin names. + + Both generators emit only what the binding names, so an entry point or a + descriptor the browser binding never touches costs nothing. Generated + sources are excluded, or a declaration would keep itself alive. + """ + found: set[str] = set() + for root in sources: + for path in sorted(root.rglob("*.kt")): + if "generated" in path.parts: + continue + found.update(_IDENTIFIER.findall(path.read_text())) + if not found: + raise SystemExit(f"no Kotlin sources under {', '.join(map(str, sources))}") + return found + + +def object_name(c_name: str) -> str: + """`mln_render_target_extent` becomes `MlnRenderTargetExtent`.""" + return "".join(part.capitalize() for part in c_name.split("_")) + + +def member_name(c_name: str) -> str: + """`scale_factor` becomes `scaleFactor`.""" + head, *rest = c_name.split("_") + return head + "".join(part.capitalize() for part in rest) + + +def write_if_changed(path: pathlib.Path, text: str) -> None: + """Leaves an unchanged file alone, so nothing downstream rebuilds.""" + if path.exists() and path.read_text() == text: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) diff --git a/src/c_api/tests/abi_tests.h b/src/c_api/tests/abi_tests.h index 0b79cb722..4cd27af5d 100644 --- a/src/c_api/tests/abi_tests.h +++ b/src/c_api/tests/abi_tests.h @@ -10,6 +10,7 @@ // be declared here and called from `main.c`. void run_browser_http_abi_tests(void); +void run_browser_render_target_abi_tests(void); void run_callback_adapter_abi_tests(void); void run_core_abi_tests(void); void run_handles_abi_tests(void); diff --git a/src/c_api/tests/browser_render_target_abi.c b/src/c_api/tests/browser_render_target_abi.c new file mode 100644 index 000000000..b68aed474 --- /dev/null +++ b/src/c_api/tests/browser_render_target_abi.c @@ -0,0 +1,388 @@ +// Raw C ABI coverage: the render target families a browser build carries, drawn +// for real. +// +// The shared fixture attaches a session-owned texture, so that family is +// covered wherever the suite runs. The other two are not: a surface session +// renders into the default framebuffer of the canvas its context is bound to, +// and a caller-owned texture session renders into a texture the caller made in +// that same context, and neither is reachable from a fixture that hands out one +// descriptor shape. +// +// The context here is on a private OffscreenCanvas this file constructs, which +// is what src/c_api/tests/test_support.c and the Kotlin binding's own shim both +// do and for the same reason: a WebGL2 context cannot exist without a canvas, +// and nothing about these families needs one the page displays. A surface +// session presents by having the browser composite its canvas, and a canvas +// nobody sees is composited by nobody -- so what these tests assert is the +// half that is this project's: that the frame reached the default framebuffer, +// or the caller's texture, read back through the context they own. +// +// That read is also what a browser host does with either family, because +// mln_texture_read_premultiplied_rgba8() covers session-owned textures alone. +// See bindings/kotlin/emscripten/mln_kotlin_webgl.c, which is the same code +// placed on a binding's own render thread. + +#include "abi_tests.h" +#include "test_support.h" +#include "unity.h" + +#if defined(MLN_FFI_TEST_BACKEND_OPENGL) && defined(MLN_FFI_TEST_OPENGL_WEBGL) + +#include +#include +#include +#include +#include +#include + +// The canvas registry is GL.offscreenCanvases rather than specialHTMLTargets, +// because findCanvasEventTarget() -- what resolves the selector under +// -sOFFSCREENCANVAS_SUPPORT -- searches the former and never consults the +// latter. +EM_JS( + void, register_offscreen_canvas, (const char* name, int width, int height), { + const id = UTF8ToString(name); + Module["GL"].offscreenCanvases[id] = { + canvas : new OffscreenCanvas(width, height), + id : id, + }; + } +); + +EM_JS(void, unregister_offscreen_canvas, (const char* name), { + delete Module["GL"].offscreenCanvases[UTF8ToString(name)]; +}); + +// Sizes the canvas behind a registration, which is what a surface session's +// drawing buffer is. Written directly rather than through +// emscripten_set_canvas_element_size(), which resolves this registry but then +// assigns to the entry rather than to the OffscreenCanvas inside it. +EM_JS(void, size_offscreen_canvas, (const char* name, int width, int height), { + const entry = Module["GL"].offscreenCanvases[UTF8ToString(name)]; + entry.canvas.width = width; + entry.canvas.height = height; +}); + +#define CANVAS_ID "mln-render-target-abi" +#define CANVAS_SELECTOR "#" CANVAS_ID + +// No sources, so nothing is fetched and a render is the background alone. Red +// is chosen because it is neither the clear color nor MapLibre's default +// background, so a pixel that reads back red was painted by this style. +static const char background_style_json[] = + "{\"version\":8,\"sources\":{},\"layers\":[{\"id\":\"background\",\"type\":" + "\"background\",\"paint\":{\"background-color\":\"#ff0000\"}}]}"; + +// Creates the context these tests render through, on a canvas of their own. The +// context is left current on this thread, which is where the sessions below are +// attached and where every GL call in this file is issued. +static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE create_context( + uint32_t width, uint32_t height +) { + register_offscreen_canvas(CANVAS_ID, (int)width, (int)height); + + EmscriptenWebGLContextAttributes attributes; + emscripten_webgl_init_context_attributes(&attributes); + attributes.majorVersion = 2; + attributes.minorVersion = 0; + attributes.depth = EM_TRUE; + attributes.stencil = EM_TRUE; + attributes.antialias = EM_FALSE; + // A surface session's frame is read out of this buffer after the render call + // returned, so it has to still be there. + attributes.preserveDrawingBuffer = EM_TRUE; + attributes.explicitSwapControl = EM_FALSE; + attributes.proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW; + + const EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = + emscripten_webgl_create_context(CANVAS_SELECTOR, &attributes); + TEST_ASSERT_TRUE_MESSAGE( + context > 0, + "No WebGL2 context on a private OffscreenCanvas. The build needs " + "-sOFFSCREENCANVAS_SUPPORT for the selector to resolve against " + "GL.offscreenCanvases." + ); + TEST_ASSERT_EQUAL_INT( + EMSCRIPTEN_RESULT_SUCCESS, emscripten_webgl_make_context_current(context) + ); + return context; +} + +static void destroy_context(EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context) { + emscripten_webgl_destroy_context(context); + unregister_offscreen_canvas(CANVAS_ID); +} + +static void fill_webgl_context( + mln_opengl_context_descriptor* out, EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context +) { + out->platform = MLN_OPENGL_CONTEXT_PLATFORM_WEBGL; + out->data.webgl = (mln_webgl_context_descriptor){ + .size = sizeof(mln_webgl_context_descriptor), + .context = (int32_t)context, + }; +} + +static mln_opengl_surface_descriptor surface_descriptor( + EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context, uint32_t width, uint32_t height +) { + mln_opengl_surface_descriptor descriptor = + mln_opengl_surface_descriptor_default(); + descriptor.extent.width = width; + descriptor.extent.height = height; + fill_webgl_context(&descriptor.context, context); + // The settlement for WebGL: the context already names the canvas, so there is + // no surface object to pass and a handle here is rejected. + descriptor.surface = NULL; + return descriptor; +} + +// Creates an RGBA8 texture in the current context for a session to draw into, +// which is what a caller-owned target means: the texture is the caller's, and +// the session only attaches it to a framebuffer of its own. +static uint32_t create_caller_texture(uint32_t width, uint32_t height) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D( + GL_TEXTURE_2D, 0, GL_RGBA8, (GLsizei)width, (GLsizei)height, 0, GL_RGBA, + GL_UNSIGNED_BYTE, NULL + ); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glBindTexture(GL_TEXTURE_2D, 0); + return texture; +} + +// Reads the center pixel of whatever a session drew into and reports whether it +// is the style's background. +// +// `texture` names a caller-owned texture, or is zero for the canvas's default +// framebuffer, which is what a surface session renders into. A caller-owned +// target exposes no frame to acquire -- handing the texture over was the whole +// handover -- so this is what a host does with the result either way, and it is +// what says the session drew into the right place. +static bool center_is_red(uint32_t texture, uint32_t width, uint32_t height) { + GLuint framebuffer = 0; + bool complete = true; + if (texture != 0) { + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glFramebufferTexture2D( + GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0 + ); + complete = + glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + } else { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + uint8_t pixel[4] = {0}; + if (complete) { + glReadPixels( + (GLint)(width / 2), (GLint)(height / 2), 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, + pixel + ); + } + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (framebuffer != 0) { + glDeleteFramebuffers(1, &framebuffer); + } + return complete && pixel[0] > 200 && pixel[1] < 60 && pixel[2] < 60; +} + +// Renders until whatever the session draws into reads back as the style's +// background, and reports whether it got there. +// +// The context is made current again before each read: a session restores +// whatever was current when its render ended. +// +// The first render has nothing to draw yet -- the style is still parsing on a +// MapLibre worker -- so this pumps the runtime between attempts, which is what +// gives that worker a chance to run. +static bool render_until_red( + mln_runtime runtime, mln_render_session session, + EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context, uint32_t texture, uint32_t width, + uint32_t height +) { + for (unsigned int attempt = 0; attempt < 600; attempt += 1) { + bool rendered = false; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_render_session_render_update(session, &rendered) + ); + TEST_ASSERT_EQUAL_INT(MLN_STATUS_OK, mln_runtime_pump(runtime, 0)); + if (rendered) { + TEST_ASSERT_EQUAL_INT( + EMSCRIPTEN_RESULT_SUCCESS, + emscripten_webgl_make_context_current(context) + ); + if (center_is_red(texture, width, height)) { + return true; + } + } + mln_test_sleep_millisecond(); + } + return false; +} + +// This verifies an OpenGL surface session attaches to a browser WebGL context +// and renders into the default framebuffer of the canvas that context is bound +// to. +static void opengl_surface_session_renders_into_its_canvas(void) { + const EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = create_context(64, 64); + + mln_runtime runtime = mln_test_create_runtime(); + mln_map_options options = mln_map_options_default(); + options.width = 64; + options.height = 64; + mln_map map = mln_test_create_map_with_options(runtime, &options); + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_map_set_style_json(map, background_style_json) + ); + + const mln_opengl_surface_descriptor descriptor = + surface_descriptor(context, 64, 64); + mln_render_session session = MLN_HANDLE_NULL; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_opengl_surface_attach(map, &descriptor, &session) + ); + TEST_ASSERT_NOT_EQUAL_UINT64(MLN_HANDLE_NULL, session); + + TEST_ASSERT_TRUE_MESSAGE( + render_until_red(runtime, session, context, 0, 64, 64), + "The surface session never painted the style's background into the " + "canvas's default framebuffer." + ); + + TEST_ASSERT_EQUAL_INT(MLN_STATUS_OK, mln_render_session_destroy(session)); + mln_test_destroy_map(map); + mln_test_destroy_runtime(runtime); + destroy_context(context); +} + +// This verifies a WebGL surface session takes a new extent through set-target, +// which is the only thing that call can change here, and that a surface handle +// is refused rather than ignored. +static void opengl_surface_set_target_takes_a_new_extent(void) { + const EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = create_context(64, 64); + + mln_runtime runtime = mln_test_create_runtime(); + mln_map_options options = mln_map_options_default(); + options.width = 64; + options.height = 64; + mln_map map = mln_test_create_map_with_options(runtime, &options); + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_map_set_style_json(map, background_style_json) + ); + + mln_opengl_surface_descriptor descriptor = + surface_descriptor(context, 64, 64); + mln_render_session session = MLN_HANDLE_NULL; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_opengl_surface_attach(map, &descriptor, &session) + ); + TEST_ASSERT_TRUE(render_until_red(runtime, session, context, 0, 64, 64)); + + // The check that rejects a surface handle runs before the extent is taken, so + // the session is left rendering into what it had. + mln_opengl_surface_descriptor with_surface = descriptor; + with_surface.surface = (void*)(uintptr_t)1; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_INVALID_ARGUMENT, + mln_opengl_surface_set_target(session, &with_surface) + ); + + // The drawing buffer is the canvas's, and only the thread that owns the + // canvas can size it -- which is this one. + size_offscreen_canvas(CANVAS_ID, 32, 32); + descriptor.extent.width = 32; + descriptor.extent.height = 32; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_opengl_surface_set_target(session, &descriptor) + ); + TEST_ASSERT_TRUE_MESSAGE( + render_until_red(runtime, session, context, 0, 32, 32), + "The surface session stopped painting its canvas after the extent changed." + ); + + TEST_ASSERT_EQUAL_INT(MLN_STATUS_OK, mln_render_session_destroy(session)); + mln_test_destroy_map(map); + mln_test_destroy_runtime(runtime); + destroy_context(context); +} + +// This verifies a caller-owned OpenGL texture session renders into the texture +// the host made in the session's context, and goes on doing so after the target +// is replaced with a second one. +static void opengl_borrowed_texture_session_renders_into_the_callers_texture( + void +) { + const EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = create_context(64, 64); + const uint32_t first = create_caller_texture(64, 64); + const uint32_t second = create_caller_texture(32, 32); + + mln_runtime runtime = mln_test_create_runtime(); + mln_map_options options = mln_map_options_default(); + options.width = 64; + options.height = 64; + mln_map map = mln_test_create_map_with_options(runtime, &options); + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_map_set_style_json(map, background_style_json) + ); + + mln_opengl_borrowed_texture_descriptor descriptor = + mln_opengl_borrowed_texture_descriptor_default(); + descriptor.extent.width = 64; + descriptor.extent.height = 64; + descriptor.physical_width = 64; + descriptor.physical_height = 64; + fill_webgl_context(&descriptor.context, context); + descriptor.texture = first; + descriptor.target = GL_TEXTURE_2D; + + mln_render_session session = MLN_HANDLE_NULL; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, + mln_opengl_borrowed_texture_attach(map, &descriptor, &session) + ); + TEST_ASSERT_TRUE_MESSAGE( + render_until_red(runtime, session, context, first, 64, 64), + "The caller-owned texture session never painted the style's background " + "into the texture it was given." + ); + + descriptor.texture = second; + descriptor.extent.width = 32; + descriptor.extent.height = 32; + descriptor.physical_width = 32; + descriptor.physical_height = 32; + TEST_ASSERT_EQUAL_INT( + MLN_STATUS_OK, mln_opengl_borrowed_texture_set_target(session, &descriptor) + ); + TEST_ASSERT_TRUE_MESSAGE( + render_until_red(runtime, session, context, second, 32, 32), + "The caller-owned texture session did not follow its target to the second " + "texture." + ); + + TEST_ASSERT_EQUAL_INT(MLN_STATUS_OK, mln_render_session_destroy(session)); + mln_test_destroy_map(map); + mln_test_destroy_runtime(runtime); + TEST_ASSERT_EQUAL_INT( + EMSCRIPTEN_RESULT_SUCCESS, emscripten_webgl_make_context_current(context) + ); + GLuint textures[] = {first, second}; + glDeleteTextures(2, textures); + destroy_context(context); +} + +#endif + +void run_browser_render_target_abi_tests(void) { + UnitySetTestFile(__FILE__); +#if defined(MLN_FFI_TEST_BACKEND_OPENGL) && defined(MLN_FFI_TEST_OPENGL_WEBGL) + RUN_TEST(opengl_surface_session_renders_into_its_canvas); + RUN_TEST(opengl_surface_set_target_takes_a_new_extent); + RUN_TEST(opengl_borrowed_texture_session_renders_into_the_callers_texture); +#endif +} diff --git a/src/c_api/tests/main.c b/src/c_api/tests/main.c index 90a59342e..14175cdb9 100644 --- a/src/c_api/tests/main.c +++ b/src/c_api/tests/main.c @@ -22,6 +22,7 @@ void tearDown(void) { int main(void) { UNITY_BEGIN(); run_browser_http_abi_tests(); + run_browser_render_target_abi_tests(); run_callback_adapter_abi_tests(); run_core_abi_tests(); run_handles_abi_tests(); diff --git a/src/render/opengl/opengl_surface_session.cpp b/src/render/opengl/opengl_surface_session.cpp index 0ba28164a..1fde2694f 100644 --- a/src/render/opengl/opengl_surface_session.cpp +++ b/src/render/opengl/opengl_surface_session.cpp @@ -166,7 +166,20 @@ class OpenGLSurfaceBackend final : public mbgl::gl::RendererBackend, } void swap_surface() { -#if defined(MLN_FFI_OPENGL_PROVIDER_WGL) +#if defined(MLN_FFI_OPENGL_PROVIDER_WEBGL) + // Nothing to swap. A WebGL drawing buffer is presented by the browser, not + // by the program that drew into it: the canvas this context is bound to is + // composited once the task that rendered returns to the event loop, which + // is what makes the frame above visible. Emscripten's + // emscripten_webgl_commit_frame() exists for the same moment, but it wants + // a context created with explicitSwapControl and is a no-op even then, + // because the .commit() it was written against was removed from browsers. + // + // The consequence belongs to whoever owns the render thread rather than to + // this file: a thread that renders and then parks without ending its task + // has drawn a frame the browser never composites, so a render thread that + // presents has to return to its event loop between frames. +#elif defined(MLN_FFI_OPENGL_PROVIDER_WGL) if (SwapBuffers(static_cast(descriptor_.surface)) == 0) { throw std::runtime_error("Swapping OpenGL WGL surface buffers failed"); } diff --git a/src/render/unsupported_sessions.cpp b/src/render/unsupported_sessions.cpp index b59a9e220..f3a9e219c 100644 --- a/src/render/unsupported_sessions.cpp +++ b/src/render/unsupported_sessions.cpp @@ -399,10 +399,6 @@ auto opengl_surface_set_target( return MLN_STATUS_UNSUPPORTED; } -#endif - -#if !defined(MLN_RENDER_BACKEND_OPENGL) - auto opengl_owned_texture_attach( mln_map map, const mln_opengl_owned_texture_descriptor* descriptor, mln_render_session* out_session