Skip to content

Latest commit

 

History

History
239 lines (194 loc) · 10.6 KB

File metadata and controls

239 lines (194 loc) · 10.6 KB

DDS on the JVM — Foreign Function & Memory (FFM) interface

This document explains how to build the DDS native shared library and call the Double Dummy Solver from Java using the Foreign Function & Memory API (Project Panama, java.lang.foreign). No hand-written JNI C/C++ glue is required: the JVM loads the plain C ABI directly.

The bindings live under //jni and mirror how the other language bindings work — see python_interface.md, dotnet_interface.md, and wasm_build.md.

Table of Contents

  1. Prerequisites
  2. Building the shared library
  3. The pure-C ABI shim
  4. Using the FFM bindings
  5. Worked example
  6. Loading and running
  7. Packaging & local install
  8. Threading and lifecycle
  9. Not yet supported

Prerequisites

  • JDK 22 or later. java.lang.foreign is a stable API from JDK 22, so no --enable-preview is needed. The Bazel build is hermetic: rules_java supplies the JDK (currently pinned to remotejdk_25 in .bazelrc); you do not need a system JDK installed.
  • Bazel as pinned in .bazelversion. Use bazelisk, which reads that file and fetches the matching Bazel automatically. Other versions may reject the committed MODULE.bazel.lock. See Bazel version and MODULE.bazel.lock.
  • JVM runtime. These FFM bindings presume a 64-bit JVM

No jextract install is required — the bindings are hand-written (see Using the FFM bindings).

Building the shared library

bazelisk build //jni:dds_shared

This produces one self-contained native library rolling up the entire solver (all internal sub-libraries are linked in statically), named per OS:

OS Artifact Location
Linux libdds.so bazel-bin/jni/
macOS libdds.dylib bazel-bin/jni/
Windows dds.dll bazel-bin/jni/

Only the public C API is exported. Exports are constrained at link time by the generated jni/version_script.lds (Linux) and jni/exported_symbols.lds (macOS); on Windows the existing __declspec(dllexport) markers drive exports, so the DLL's export set is a superset of the Linux/macOS one (see Not yet supported). The lists are generated from the headers by jni/gen_export_lists.py and verified by //jni/tests:export_set_test, which asserts the library exports exactly the public symbols and leaks no internal C++ symbols.

The pure-C ABI shim

The modern context API in library/src/api/dds_api.hpp takes C++ references and C++ types (const Deal&, SolverConfig, TTKind), which are not addressable from a pure-C FFI. The shim library/src/api/dds_c_api.h wraps it with a clean, C-valid, pointer-only, POD-only surface that Java, .NET, and ctypes can all bind to:

  • The solver handle is opaque: typedef void* DDS_C_SOLVER_CTX.
  • Every struct is passed by pointer; no non-POD C++ type crosses the boundary. SolverConfig is decomposed into scalar arguments and TTKind crosses as an int, so nothing is passed by value either.

The shim covers the modern API's full surface: context lifecycle (default and config-based creation, destroy), dds_c_solve_board, dds_c_calc_dd_table and its _pbn twin, dds_c_calc_par, transposition-table configure/resize/clear, both resets, and the logging passthroughs. The Java bindings here use a subset; the .NET binding uses all of it (dotnet_interface.md). The flat legacy C API from dll.h (SolveBoard, CalcDDtable, GetDDSInfo, ErrorMessage, …) is exported unchanged and is also callable from FFM.

Using the FFM bindings

//jni:dds_ffm provides the Java bindings in package org.dds.ffm (Dds.java). They are hand-written rather than generated by jextract: jextract is distributed only as non-hermetic early-access binaries, and the generated output is just plain java.lang.foreign code, so the bindings are checked in directly. Dds exposes:

  • Public MemoryLayout constants matching the C structs: DEAL, FUTURE_TRICKS, DD_TABLE_DEAL, DD_TABLE_RESULTS, PAR_RESULTS, DDS_INFO.
  • Typed downcall wrappers for the shim functions plus getDdsInfo. These return an int DdsStatus RETURN_* code (RETURN_NO_FAULT == 1 on success); compare against the named constants rather than magic numbers, and use DdsStatus.name(rc) for diagnostics.
  • Dds.load(Path) — loads the library via SymbolLookup.libraryLookup over an owned Arena; Dds is AutoCloseable.

Depend on it from your own java_library/java_binary:

java_binary(
    name = "my_app",
    srcs = ["MyApp.java"],
    deps = ["//jni:dds_ffm"],
    data = ["//jni:dds_shared"],
    jvm_flags = ["--enable-native-access=ALL-UNNAMED"],
)

Worked example

Solve a single board (North holds all spades with spades trump — 13 tricks):

import static java.lang.foreign.ValueLayout.JAVA_INT;
import java.lang.foreign.Arena;
import java.lang.foreign.MemoryLayout.PathElement;
import java.lang.foreign.MemorySegment;
import java.nio.file.Path;
import org.dds.ffm.Dds;

try (Dds dds = Dds.load(Path.of(System.getProperty("dds.library.path")));
     Arena arena = Arena.ofConfined()) {

    long remain = Dds.DEAL.byteOffset(PathElement.groupElement("remainCards"));
    MemorySegment deal = arena.allocate(Dds.DEAL);
    deal.fill((byte) 0);   // allocate() is not guaranteed to zero the memory
    // deal.fill(...) zeroes the struct; Deal.trump uses strain index 0=S,1=H,2=D,3=C,4=NT (DDS_NOTRUMP), and first=0 is North.
    deal.set(JAVA_INT, remain + 0L * 4, 0x7FFC);            // North spades
    deal.set(JAVA_INT, remain + 5L * 4, 0x7FFC);            // East hearts
    deal.set(JAVA_INT, remain + 10L * 4, 0x7FFC);           // South diamonds
    deal.set(JAVA_INT, remain + 15L * 4, 0x7FFC);           // West clubs

    MemorySegment ctx = dds.createSolverContext();
    try {
        MemorySegment fut = arena.allocate(Dds.FUTURE_TRICKS);
        int rc = dds.solveBoard(ctx, deal, -1, 1, 1, fut);  // target, solutions, mode
        long score = Dds.FUTURE_TRICKS.byteOffset(PathElement.groupElement("score"));
        System.out.println("tricks = " + fut.get(JAVA_INT, score)); // 13
    } finally {
        dds.destroySolverContext(ctx);
    }
}

remainCards is a [hand][suit] array with 4 suits per hand, so element [hand][suit] is at index hand * 4 + suit. Holdings are 13-bit rank masks (0x7FFC = all ranks 2..A). See DdsSmokeTest.java for a complete, tested program including GetDDSInfo.

Loading and running

There are two ways to load the native library:

  • Dds.loadEmbedded() (default) — extracts the library bundled in the jar for the current OS/arch (from the classpath resource native/<os>-<arch>/<lib>) to a temp file and loads it. Zero configuration; this is what the published jar (below) is for. Host-platform only for now.
  • Dds.load(Path) — loads a library at an explicit filesystem path via SymbolLookup.libraryLookup, with no reliance on System.loadLibrary / java.library.path. Useful when you built //jni:dds_shared yourself; in Bazel, make it a data dependency and pass its runfiles path, e.g. -Ddds.library.path=$(rootpath //jni:dds_shared).

FFM downcalls are restricted native operations. Run with --enable-native-access=ALL-UNNAMED (as a jvm_flag or on the java command line) to grant access and silence the runtime warning.

The end-to-end smoke tests wire all of this together:

bazelisk test //jni:dds_ffm_smoke_test           # explicit-path loading
bazelisk test //jni:dds_ffm_embedded_smoke_test  # loadEmbedded() from the jar

Packaging & local install

//jni:dds_ffm_dist is a java_export producing a Maven-coordinate jar, org.dds:dds-ffm:3.1.0, that embeds the host-platform native library under native/<os>-<arch>/ — so a consumer needs only the jar and Dds.loadEmbedded(), no separate .so/.dylib/.dll to locate.

Install it into your local Maven repository (~/.m2):

bazelisk run //jni:dds_ffm_dist.publish \
    --define maven_repo="file://$HOME/.m2/repository" \
    --define gpg_sign=false

This deposits the main jar plus -sources, -javadoc, and the generated POM under org/dds/dds-ffm/3.1.0/. Another project on the same machine can then depend on it:

implementation("org.dds:dds-ffm:3.1.0")   // Gradle
<dependency><groupId>org.dds</groupId><artifactId>dds-ffm</artifactId><version>3.1.0</version></dependency>

The bundled native library is host-platform only — the jar built on macOS contains the macOS .dylib and so on. A single multi-OS/arch ("fat") jar and publishing to a public/remote repository are not yet available (see below).

Threading and lifecycle

Each DDS_C_SOLVER_CTX owns per-context solver state and its own transposition table. A context is not thread-safe: use one solver context per thread. Always release a context with destroySolverContext (the example above does so in a finally block). Because the modern context API owns its state per-handle, no global InitializeStaticMemory call is required before solving through the shim.

Not yet supported

  • Remote/public publishing — only local ~/.m2 installs are wired today. Publishing to Maven Central (OSSRH + GPG signing) or GitHub Packages is not yet set up.
  • Multi-OS/arch "fat" jar — the jar bundles only the host platform's native library. Producing one jar with every triplet requires a CI matrix building each platform and a final assembly step.
  • A pinned Windows export set — the Linux and macOS libraries are linked against a header-derived export list and verified by //jni/tests:export_set_test, but the Windows DLL exports everything marked __declspec(dllexport), including the modern dds_* context API. Matching the other platforms needs a generated .def file and a dumpbin-based arm of the export test; the //jni tests are excluded on Windows meanwhile.
  • Hand-written JNI convenience API (idiomatic native-method Java classes) — deferred; the FFM path above is the supported route.