diff --git a/.github/workflows/ci_linux.yml b/.github/workflows/ci_linux.yml index b7d17bcb..c3fd0ae7 100644 --- a/.github/workflows/ci_linux.yml +++ b/.github/workflows/ci_linux.yml @@ -48,7 +48,28 @@ jobs: - name: Run all tests run: bazel test --verbose_failures //... - # 7️⃣ Upload test logs + # 7️⃣ .NET binding — build and test the managed wrapper against the shared + # library just built. Pinned to 8.0.x because that is what DDS_Core + # targets; the test project's RollForward only matters where no 8.0 + # runtime exists, which is not the case here. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # bazel-bin is a configuration-dependent convenience symlink, so ask Bazel + # for it rather than hardcoding a path that a different --config would move. + - name: Test .NET binding + run: | + bazel build --verbose_failures //jni:dds_shared + export DDS_LIBRARY_PATH="$(bazel info bazel-bin)/jni/libdds.so" + test -f "$DDS_LIBRARY_PATH" || { echo "native library not found at $DDS_LIBRARY_PATH"; exit 1; } + # Both configurations: ThrowIfError is unconditional, so a regression to + # [Conditional("DEBUG")] only surfaces in Release. See dotnet-binding.md. + dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + dotnet test dotnet/DDS_Core.Tests/ -c Release --verbosity normal + + # 8️⃣ Upload test logs - name: Upload test logs - Linux if: always() uses: actions/upload-artifact@v6 diff --git a/.github/workflows/ci_macos.yml b/.github/workflows/ci_macos.yml index 50c23e9b..4695eed2 100644 --- a/.github/workflows/ci_macos.yml +++ b/.github/workflows/ci_macos.yml @@ -47,6 +47,28 @@ jobs: - name: Run all tests run: bazelisk test --verbose_failures //... + # .NET binding — this job is the only CI coverage of the AArch64 ABI, which + # is what the managed struct-layout tests exist to guard: a layout mismatch + # there corrupts results silently rather than throwing. Linux covers SysV + # x86-64 and Windows covers Win64, so all three ABIs are exercised. + # Pinned to 8.0.x to match what DDS_Core targets. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # bazel-bin is a configuration-dependent convenience symlink, so ask Bazel + # for it rather than hardcoding a path that a different --config would move. + - name: Test .NET binding + run: | + bazelisk build --verbose_failures //jni:dds_shared + export DDS_LIBRARY_PATH="$(bazelisk info bazel-bin)/jni/libdds.dylib" + test -f "$DDS_LIBRARY_PATH" || { echo "native library not found at $DDS_LIBRARY_PATH"; exit 1; } + # Both configurations: ThrowIfError is unconditional, so a regression to + # [Conditional("DEBUG")] only surfaces in Release. See dotnet-binding.md. + dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + dotnet test dotnet/DDS_Core.Tests/ -c Release --verbosity normal + # Upload test logs - name: Upload test logs - macOS if: always() diff --git a/.github/workflows/ci_windows.yml b/.github/workflows/ci_windows.yml index a69cea9f..ece78c2d 100644 --- a/.github/workflows/ci_windows.yml +++ b/.github/workflows/ci_windows.yml @@ -56,6 +56,37 @@ jobs: - name: Run all tests run: bazel test --verbose_failures //... + # .NET binding — this is the platform the shim retarget can regress. + # Windows was previously the only place the binding worked, via + # dds_native.dll and dds_api.hpp's dds_* symbols; it now binds dds_c_* + # in the Bazel-built dds.dll, a path no other test covers (the JNI smoke + # tests and export_set_test are target_compatible_with-excluded here). + # Pinned to 8.0.x to match what DDS_Core targets. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + # bazel-bin is a configuration-dependent convenience symlink, so ask Bazel + # for it rather than hardcoding a path that a different --config would move. + - name: Test .NET binding + shell: pwsh + run: | + bazel build --verbose_failures //jni:dds_shared + if ($LASTEXITCODE -ne 0) { exit 1 } + $binDir = bazel info bazel-bin + $env:DDS_LIBRARY_PATH = Join-Path $binDir "jni\dds.dll" + if (-not (Test-Path $env:DDS_LIBRARY_PATH)) { + Write-Host "native library not found at $env:DDS_LIBRARY_PATH" + exit 1 + } + # Both configurations: ThrowIfError is unconditional, so a regression to + # [Conditional("DEBUG")] only surfaces in Release. See dotnet-binding.md. + dotnet test dotnet/DDS_Core.Tests/ --verbosity normal + if ($LASTEXITCODE -ne 0) { exit 1 } + dotnet test dotnet/DDS_Core.Tests/ -c Release --verbosity normal + if ($LASTEXITCODE -ne 0) { exit 1 } + # Upload test logs - name: Upload test logs - Windows if: always() diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 17be65cb..b7816577 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -328,7 +328,7 @@ "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { "bzlTransitiveDigest": "CpCK8JdAd4sMkjDZLg/mBRxzNO6d3nEkAW2H4mMike4=", - "usagesDigest": "lqS0hMGr6MqmX63BGZOskMFcqzqO53K0UlYFxuE3QSU=", + "usagesDigest": "wvQRffIp8pEJeKXankBKEQBAOCI8HTtmw9TvnW7/e1c=", "recordedInputs": [ "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", diff --git a/docs/dotnet_interface.md b/docs/dotnet_interface.md index 573f9381..75d24a4a 100644 --- a/docs/dotnet_interface.md +++ b/docs/dotnet_interface.md @@ -9,9 +9,16 @@ The library exposes the full DDS functionality through idiomatic `.Net` structur The goal is to provide a stable, fast, and fully documented .NET interface to the DDS engine. +It runs on **macOS, Linux, and Windows** from the same assembly — see +[Building and loading the native library](#building-and-loading-the-native-library). +The bindings mirror how the other language bindings work; see +[jni_interface.md](jni_interface.md), [python_interface.md](python_interface.md), +and [wasm_build.md](wasm_build.md). + --- ## Table of Contents +0. [Building and loading the native library](#building-and-loading-the-native-library) 1. [Introduction](#introduction) 2. [Legacy vs. Modern DDS API](#legacy-vs-modern-dds-api) 3. [Basic Usage](#basic-usage) @@ -39,6 +46,65 @@ The goal is to provide a stable, fast, and fully documented .NET interface to th --- +# Building and loading the native library + +`DDS_Core` is a managed wrapper; the solver itself lives in one self-contained +native library built by Bazel: + +```bash +bazel build //jni:dds_shared +``` + +| OS | Artifact | Location | +| ------- | -------------- | ---------------- | +| Linux | `libdds.so` | `bazel-bin/jni/` | +| macOS | `libdds.dylib` | `bazel-bin/jni/` | +| Windows | `dds.dll` | `bazel-bin/jni/` | + +Despite living under `//jni`, this is the shared native artifact for every +binding — the JVM one uses it too. + +## Finding the library at runtime + +The binding imports the library under the single name **`dds`**; .NET's probing +supplies the `lib` prefix and the per-OS extension, so one name resolves all +three artifacts above. There are two ways to point it at a library: + +- **Default probing** — place the library where the runtime already looks (next + to the application, or under `runtimes//native/` in a package). No + configuration needed. +- **`DDS_LIBRARY_PATH`** — set this environment variable to the *full path* of a + library file to override probing. This is the counterpart of the JVM binding's + `-Ddds.library.path`, and is how the tests bind against a freshly-built + library: + + ```bash + export DDS_LIBRARY_PATH="$(bazel info bazel-bin)/jni/libdds.dylib" + dotnet test dotnet/DDS_Core.Tests/ + ``` + + Use `bazel info bazel-bin` rather than the `bazel-bin` symlink: that symlink is + configuration-dependent and moves when you build with a different `--config`. + If the variable is set but the library cannot be loaded, the error names the + path you gave rather than reporting a missing entry point later. + +## Which native symbols are used + +The modern context API binds the pure-C shim (`dds_c_*`, from +`library/src/api/dds_c_api.h`); the legacy flat API binds `dll.h` directly. The +reference-taking `dds_*` functions in `dds_api.hpp` are deliberately **not** +used — they are not exported on Linux or macOS, and binding them is what +previously made this wrapper Windows-only. + +## Requirements + +.NET 8.0 or later (`.NET Framework` is not supported). If you build the test +project on a machine that has only a newer major runtime installed, note it sets +`Major` so it still runs; the library itself targets +`net8.0`. + +--- + # Introduction `DDS_Core` provides a managed .NET interface to the DDS engine. diff --git a/docs/jni_interface.md b/docs/jni_interface.md index 0d157248..32045e1d 100644 --- a/docs/jni_interface.md +++ b/docs/jni_interface.md @@ -68,12 +68,16 @@ 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. - -Shim entry points: `dds_c_create_solvercontext_default`, -`dds_c_destroy_solvercontext`, `dds_c_solve_board`, `dds_c_calc_dd_table`, -`dds_c_calc_par`. The flat legacy C API from `dll.h` (`SolveBoard`, -`CalcDDtable`, `GetDDSInfo`, `ErrorMessage`, …) is exported unchanged and is -also callable from FFM. + `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](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 diff --git a/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs new file mode 100644 index 00000000..c4dbd810 --- /dev/null +++ b/dotnet/DDS_Core.Tests/ContextLifecycleTests.cs @@ -0,0 +1,160 @@ +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// Covers the context-management entry points added to the C shim by this work: +/// config-based construction, TT configuration, the resets, logging, and +/// SafeHandle-driven disposal. Between these and , every +/// one of the fourteen retargeted P/Invokes is exercised — so a missing +/// EntryPoint fails here rather than in a consumer. +/// +public class ContextLifecycleTests +{ + [Theory] + [InlineData(TTKind.Small)] + [InlineData(TTKind.Large)] + public void ConstructedFromConfig_SolvesReferenceDeal(TTKind kind) + { + // Exercises dds_c_create_solvercontext, whose SolverConfig is unpacked + // into scalars at the ABI boundary. + using var ctx = new SolverContext(new SolverConfig(kind, 0, 0)); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + /// + /// A successfully created context must hold a valid native handle. The + /// creation guard cannot test the reference for null — the SafeHandle + /// marshaller never returns one — so it tests IsInvalid, and this pins that + /// the successful path actually produces a live pointer rather than + /// IntPtr.Zero silently sailing through. + /// + [Theory] + [InlineData(TTKind.Small)] + [InlineData(TTKind.Large)] + public void Construction_YieldsValidHandle(TTKind kind) + { + using var fromConfig = new SolverContext(new SolverConfig(kind, 0, 0)); + Assert.False(fromConfig.Handle.IsInvalid); + + using var fromDefault = new SolverContext(); + Assert.False(fromDefault.Handle.IsInvalid); + } + + /// + /// Failures must throw at the public surface in every build configuration, + /// not only in DEBUG. `solutions = 4` is out of range and the solver returns + /// RETURN_SOLNS_WRONG_HI (-9); if ThrowIfError is ever made conditional + /// again, this test fails in Release. + /// + [Fact] + public void SolveBoard_WithInvalidSolutions_Throws() + { + using var ctx = new SolverContext(); + + Assert.Throws( + () => ctx.SolveBoard(TestDeals.Reference(), -1, 4, 1, out FutureTricks _)); + } + + [Fact] + public void TtReconfiguration_LeavesContextUsable() + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.ConfigureTT(TTKind.Small, 1, 2); + ctx.ResizeTT(1, 2); + ctx.ClearTT(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void Resets_LeaveContextUsable() + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.ResetForSolve(); + ctx.ResetBestMovesLite(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + /// + /// A Small-TT context survives ClearTT() followed by ResetForSolve() and + /// still solves. ClearTT() disposes the transposition table, so the + /// following solve rebuilds one lazily from the context's configuration. + /// This exercises the managed TT-lifecycle path; the native + /// TransTableS::reset_memory() guard is covered by + /// //library/tests/trans_table:trans_table. + /// + [Fact] + public void SmallTt_ClearThenResetForSolve_StillSolves() + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.ConfigureTT(TTKind.Small, 1, 2); + ctx.ClearTT(); + ctx.ResetForSolve(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void Logging_DoesNotDisturbSolving() + { + using var ctx = new SolverContext(); + + ctx.LogAppend("DDS_Core.Tests"); + ctx.LogAppend(string.Empty); + ctx.LogClear(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + /// + /// Disposal must release the native context through SafeHandle without + /// faulting, and must be safe to repeat. + /// + [Fact] + public void Dispose_ReleasesHandleAndIsIdempotent() + { + var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks _); + + ctx.Dispose(); + ctx.Dispose(); + + Assert.True(ctx.Handle.IsClosed || ctx.Handle.IsInvalid); + } + + /// + /// Contexts are single-threaded, so concurrent use means one context per + /// thread. This is the arrangement the docs prescribe; if it regressed, + /// multi-threaded consumers would corrupt results rather than fail loudly. + /// + [Fact] + public void OneContextPerThread_SolvesConcurrently() + { + const int threads = 4; + var results = new int[threads]; + + Parallel.For(0, threads, i => + { + using var ctx = new SolverContext(); + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + results[i] = fut.Score[0]; + }); + + Assert.All(results, r => Assert.Equal(TestDeals.ExpectedTricks, r)); + } +} diff --git a/dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj b/dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj new file mode 100644 index 00000000..f71123e0 --- /dev/null +++ b/dotnet/DDS_Core.Tests/DDS_Core.Tests.csproj @@ -0,0 +1,41 @@ + + + + + net8.0 + enable + enable + false + AnyCPU + + + Major + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/DDS_Core.Tests/LayoutTests.cs b/dotnet/DDS_Core.Tests/LayoutTests.cs new file mode 100644 index 00000000..4a66f95f --- /dev/null +++ b/dotnet/DDS_Core.Tests/LayoutTests.cs @@ -0,0 +1,83 @@ +using System.Runtime.InteropServices; +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// Pins the managed struct layouts to the C structs in library/src/api/dll.h. +/// +/// +/// +/// These matter more than they look. The managed types were only ever exercised +/// against the MSVC ABI; a layout mismatch on SysV (Linux) or AArch64 (Apple +/// Silicon) corrupts results silently rather than throwing, so the smoke +/// tests alone cannot catch it. +/// +/// +/// The expected values are derived from the C headers — compiled with +/// offsetof/sizeof — not from running the C# side. Asserting what +/// the managed code already does would prove nothing. +/// +/// +public class LayoutTests +{ + // ---- Ground truth from library/src/api/dll.h (offsetof/sizeof, LP64) ---- + + [Fact] + public void Deal_MatchesNativeLayout() + { + Assert.Equal(96, Marshal.SizeOf()); + Assert.Equal(0, (int) Marshal.OffsetOf(nameof(Deal.Trump))); + Assert.Equal(4, (int) Marshal.OffsetOf(nameof(Deal.First))); + Assert.Equal(8, (int) Marshal.OffsetOf(nameof(Deal.CurrentTrickSuit))); + Assert.Equal(20, (int) Marshal.OffsetOf(nameof(Deal.CurrentTrickRank))); + Assert.Equal(32, (int) Marshal.OffsetOf(nameof(Deal.RemainingCards))); + } + + [Fact] + public void FutureTricks_MatchesNativeLayout() + { + Assert.Equal(216, Marshal.SizeOf()); + Assert.Equal(0, (int) Marshal.OffsetOf(nameof(FutureTricks.Nodes))); + Assert.Equal(4, (int) Marshal.OffsetOf(nameof(FutureTricks.NumberOfCards))); + Assert.Equal(8, (int) Marshal.OffsetOf(nameof(FutureTricks.Suit))); + Assert.Equal(60, (int) Marshal.OffsetOf(nameof(FutureTricks.Ranks))); + Assert.Equal(112, (int) Marshal.OffsetOf(nameof(FutureTricks.EqualGroups))); + Assert.Equal(164, (int) Marshal.OffsetOf(nameof(FutureTricks.Score))); + } + + [Fact] + public void DdTableDeal_MatchesNativeLayout() + => Assert.Equal(64, Marshal.SizeOf()); + + [Fact] + public void DdTableResults_MatchesNativeLayout() + => Assert.Equal(80, Marshal.SizeOf()); + + [Fact] + public void ParResults_MatchesNativeLayout() + => Assert.Equal(288, Marshal.SizeOf()); + + [Fact] + public void DdTableDealPBN_MatchesNativeLayout() + => Assert.Equal(80, Marshal.SizeOf()); + + /// + /// remainCards is [hand][suit], row-major with DDS_SUITS = 4 columns, so + /// element [hand][suit] lives at index hand * 4 + suit. The JVM binding + /// relies on the same arithmetic; if FourHands ever disagreed, deals would + /// be silently transposed rather than rejected. + /// + [Fact] + public void FourHands_IsRowMajorByHandThenSuit() + { + var hands = new FourHands(); + for (int hand = 0; hand < 4; hand++) + for (int suit = 0; suit < 4; suit++) + hands[hand, suit] = (uint) (hand * 4 + suit); + + var flat = hands.AsSpan(); + for (int i = 0; i < FourHands.SIZE; i++) + Assert.Equal((uint) i, flat[i]); + } +} diff --git a/dotnet/DDS_Core.Tests/SmokeTests.cs b/dotnet/DDS_Core.Tests/SmokeTests.cs new file mode 100644 index 00000000..42862189 --- /dev/null +++ b/dotnet/DDS_Core.Tests/SmokeTests.cs @@ -0,0 +1,64 @@ +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// End-to-end solving through the retargeted binding — the .NET analogue of +/// DdsSmokeTest.java. These are what prove the dds_c_* entry +/// points actually resolve and marshal correctly on a non-Windows platform. +/// +public class SmokeTests +{ + [Fact] + public void SolveBoard_ReferenceDeal_TakesThirteenTricks() + { + using var ctx = new SolverContext(); + + ctx.SolveBoard(TestDeals.Reference(), -1, 1, 1, out FutureTricks fut); + + Assert.Equal(TestDeals.ExpectedTricks, fut.Score[0]); + } + + [Fact] + public void CalcDdTable_ReferenceDeal_MatchesExpectedTable() + { + using var ctx = new SolverContext(); + var deal = TestDeals.ReferenceTable(); + + ctx.CalcDdTable(deal, out DdTableResults results); + + for (int strain = 0; strain < 5; strain++) + for (int hand = 0; hand < 4; hand++) + Assert.Equal(TestDeals.ExpectedDdTable[strain][hand], results.ResultsTable[strain, hand]); + } + + /// + /// The PBN twin must agree with the binary form. This is the only PBN pair + /// on the modern layer, added to the shim by this work. + /// + [Fact] + public void CalcDdTable_PbnAgreesWithBinary() + { + using var ctx = new SolverContext(); + + ctx.CalcDdTable(TestDeals.ReferenceTable(), out DdTableResults binary); + + var pbnDeal = new DdTableDealPBN { Cards = TestDeals.ReferencePbn }; + ctx.CalcDdTable(pbnDeal, out DdTableResults pbn); + + for (int strain = 0; strain < 5; strain++) + for (int hand = 0; hand < 4; hand++) + Assert.Equal(binary.ResultsTable[strain, hand], pbn.ResultsTable[strain, hand]); + } + + [Fact] + public void CalcPar_ReferenceDeal_ProducesNonEmptyScore() + { + using var ctx = new SolverContext(); + + ctx.CalcPar(TestDeals.ReferenceTable(), 0 /* vulnerable: none */, + out DdTableResults _, out ParResults par); + + Assert.False(string.IsNullOrWhiteSpace(par.ParScores[0])); + } +} diff --git a/dotnet/DDS_Core.Tests/TestDeals.cs b/dotnet/DDS_Core.Tests/TestDeals.cs new file mode 100644 index 00000000..d8f58fd3 --- /dev/null +++ b/dotnet/DDS_Core.Tests/TestDeals.cs @@ -0,0 +1,54 @@ +using DDS_Core; + +namespace DDS_Core.Tests; + +/// +/// The shared reference board, matching DdsSmokeTest.java and +/// dds_c_api_test.cpp so the JVM, C++, and .NET bindings all assert +/// against one fixture. +/// +internal static class TestDeals +{ + /// Full 13-card holding bitmask (ranks 2..A). + internal const uint FullSuit = 0x7FFC; + + /// + /// North holds all spades, East all hearts, South all diamonds, West all + /// clubs. With spades trump and North to lead, North/South take all 13. + /// + internal const int ExpectedTricks = 13; + + /// res_table[strain][hand] for the reference board. + internal static readonly int[][] ExpectedDdTable = + [ + [13, 0, 13, 0], // spades + [0, 13, 0, 13], // hearts + [13, 0, 13, 0], // diamonds + [0, 13, 0, 13], // clubs + [0, 0, 0, 0], // no-trump + ]; + + /// The same board in PBN: spades.hearts.diamonds.clubs per hand. + internal const string ReferencePbn = + "N:AKQJT98765432... .AKQJT98765432.. ..AKQJT98765432. ...AKQJT98765432"; + + internal static Deal Reference() + { + var deal = new Deal { Trump = 0, First = 0, RemainingCards = new FourHands() }; + deal.RemainingCards[0, 0] = FullSuit; // North spades + deal.RemainingCards[1, 1] = FullSuit; // East hearts + deal.RemainingCards[2, 2] = FullSuit; // South diamonds + deal.RemainingCards[3, 3] = FullSuit; // West clubs + return deal; + } + + internal static DdTableDeal ReferenceTable() + { + var deal = new DdTableDeal { Cards = new FourHands() }; + deal.Cards[0, 0] = FullSuit; + deal.Cards[1, 1] = FullSuit; + deal.Cards[2, 2] = FullSuit; + deal.Cards[3, 3] = FullSuit; + return deal; + } +} diff --git a/dotnet/DDS_Core/DDS_Core.csproj b/dotnet/DDS_Core/DDS_Core.csproj index 35151565..72831453 100644 --- a/dotnet/DDS_Core/DDS_Core.csproj +++ b/dotnet/DDS_Core/DDS_Core.csproj @@ -4,7 +4,8 @@ net8.0 enable disable - x64 + + AnyCPU false ..\..\Build\bin\ True diff --git a/dotnet/DDS_Core/DDS_Core.slnx b/dotnet/DDS_Core/DDS_Core.slnx index c30e54c4..49f37b06 100644 --- a/dotnet/DDS_Core/DDS_Core.slnx +++ b/dotnet/DDS_Core/DDS_Core.slnx @@ -14,4 +14,10 @@ + + + + diff --git a/dotnet/DDS_Core/DataModel/SolverContext.cs b/dotnet/DDS_Core/DataModel/SolverContext.cs index bdf29cc7..87d0f81c 100644 --- a/dotnet/DDS_Core/DataModel/SolverContext.cs +++ b/dotnet/DDS_Core/DataModel/SolverContext.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using DDS_Core.Helpers; +using DDS_Core.Helpers; using DDS_Core.Native; namespace DDS_Core; @@ -11,14 +10,35 @@ public sealed class SolverContext : IDisposable #region Constructors and destructors public SolverContext() { - Handle = DdsNative.dds_create_solvercontext_default() - ?? throw new InvalidOperationException("Failed to create SolverContext."); + Handle = Validated(DdsNative.dds_create_solvercontext_default()); } public SolverContext(SolverConfig config) { - Handle = DdsNative.dds_create_solvercontext(config) - ?? throw new InvalidOperationException("Failed to create SolverContext."); + // Unpacked into scalars: the native shim is pointer-only and + // POD-only, so SolverConfig never crosses the ABI boundary. + Handle = Validated(DdsNative.dds_create_solvercontext( (int) config.TTKind + , config.DefaultMemoryMB + , config.MaximumMemoryMB)); + } + + /// + /// Rejects a failed native creation. The shim returns NULL on failure, + /// but a P/Invoke returning a SafeHandle-derived type never yields null: + /// the marshaller constructs an instance and stores whatever pointer came + /// back, so failure surfaces as IsInvalid, not as a null reference. A + /// `?? throw` here would never fire, leaving every later call to pass + /// IntPtr.Zero into the shim and quietly collect RETURN_UNKNOWN_FAULT. + /// + private static SolverContextHandle Validated(SolverContextHandle handle) + { + if (handle is null || handle.IsInvalid) + { + handle?.Dispose(); + throw new InvalidOperationException("Failed to create SolverContext."); + } + + return handle; } public void Dispose() @@ -137,7 +157,11 @@ public int CalcPar( in DdTableDeal table_deal #endregion #region private methods - [Conditional("DEBUG")] + // Deliberately not [Conditional("DEBUG")]: that elided every call site in + // Release, so the configuration consumers actually ship returned the raw + // RETURN_* code and never threw, contradicting the documented contract in + // specs/dotnet-binding.md. The check is one integer compare on a call that + // has just run a search. private static void ThrowIfError(int result, string functionName) { if (result != (int)SolveBoardResult.NoFault) diff --git a/dotnet/DDS_Core/Native/DdsNative.cs b/dotnet/DDS_Core/Native/DdsNative.cs index 8b9b7cf8..d0c260cb 100644 --- a/dotnet/DDS_Core/Native/DdsNative.cs +++ b/dotnet/DDS_Core/Native/DdsNative.cs @@ -5,49 +5,71 @@ namespace DDS_Core.Native; internal static class DdsNative { - private const string DllName = "dds_native"; + // One native library for every platform. .NET's probing supplies the "lib" + // prefix and the per-OS extension, so this single name resolves + // libdds.dylib, libdds.so, and dds.dll. + private const string DllName = "dds"; + + // Runs before this type's first P/Invoke, so the DDS_LIBRARY_PATH override + // is always in place without consumers having to call anything. + static DdsNative() => DdsNativeResolver.Register(); + + // The modern context entry points below bind the pure-C shim (dds_c_*) + // rather than the reference-taking dds_* functions in dds_api.hpp. Only the + // shim is exported by the shared library on Linux and macOS; the managed + // method names are kept as-is so call sites are unaffected. #region ====== Version 3 specific methods ====== #region ===== Solver Context Management ====== - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_create_solvercontext_default", CallingConvention = CallingConvention.Cdecl)] internal static extern SolverContextHandle dds_create_solvercontext_default(); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - internal static extern SolverContextHandle dds_create_solvercontext(SolverConfig cfg); + // SolverConfig is passed as scalars: the shim is pointer-only and + // POD-only by design, so no struct crosses the ABI boundary. + [DllImport(DllName, EntryPoint = "dds_c_create_solvercontext", CallingConvention = CallingConvention.Cdecl)] + internal static extern SolverContextHandle dds_create_solvercontext( int ttKind + , int defMB + , int maxMB); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_destroy_solvercontext", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_destroy_solvercontext(IntPtr ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + // TTKind is `enum TTKind : int`, which marshals as a plain int and + // matches the shim's int tt_kind parameter, so the managed + // signature is unchanged. + [DllImport(DllName, EntryPoint = "dds_c_configure_tt", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_configure_tt( SolverContextHandle ctx , TTKind kind , int defMB , int maxMB); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_resize_tt", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_resize_tt( SolverContextHandle ctx , int defMB , int maxMB); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_clear_tt", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_clear_tt(SolverContextHandle ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_reset_for_solve", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_reset_for_solve(SolverContextHandle ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_reset_best_moves_lite", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_reset_best_moves_lite(SolverContextHandle ctx); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] - internal static extern void dds_log_append( SolverContextHandle ctx, string msg); + // The shim documents msg as NUL-terminated UTF-8; be explicit rather + // than relying on the platform-dependent CharSet.Ansi default. + [DllImport(DllName, EntryPoint = "dds_c_log_append", CallingConvention = CallingConvention.Cdecl)] + internal static extern void dds_log_append( SolverContextHandle ctx + , [MarshalAs(UnmanagedType.LPUTF8Str)] string msg); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_log_clear", CallingConvention = CallingConvention.Cdecl)] internal static extern void dds_log_clear( SolverContextHandle ctx); #endregion #region ====== SolverContext methods ====== - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_solve_board", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_solve_board( SolverContextHandle ctx , in Deal dl , int target @@ -56,40 +78,24 @@ public static extern int dds_solve_board( SolverContextHandle ctx , out FutureTricks fut); #region Call_dd - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int dds_calc_dd_table( in DdTableDeal table_deal - // , out DdTableResults table_results); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_calc_dd_table", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_calc_dd_table( SolverContextHandle ctx , in DdTableDeal table_deal , out DdTableResults table_results); - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int dds_calc_dd_table_pbn( in DdTableDealPBN table_deal_pbn - // , out DdTableResults table_results); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_calc_dd_table_pbn", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_calc_dd_table_pbn( SolverContextHandle ctx , in DdTableDealPBN table_deal_pbn , out DdTableResults table_results); #endregion #region Call_par - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int calc_par( in DdTableDeal table_deal - // , int vulnerable - // , out DdTableResults table_results - // , out ParResults par_results); - [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + [DllImport(DllName, EntryPoint = "dds_c_calc_par", CallingConvention = CallingConvention.Cdecl)] public static extern int dds_calc_par( SolverContextHandle ctx , in DdTableDeal table_deal , int vulnerable , out DdTableResults table_results , out ParResults par_results); - - // [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] - //public static extern int calc_par_from_table( in DdTableResults table_results - // , int vulnerable - // , out ParResults par_results); #endregion #endregion #endregion diff --git a/dotnet/DDS_Core/Native/DdsNativeResolver.cs b/dotnet/DDS_Core/Native/DdsNativeResolver.cs new file mode 100644 index 00000000..e5f342a8 --- /dev/null +++ b/dotnet/DDS_Core/Native/DdsNativeResolver.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace DDS_Core.Native; + +/// +/// Resolves the native DDS library for this assembly's P/Invokes. +/// +/// +/// +/// By default the runtime's own probing applies, which is what a NuGet package +/// laying the library out under runtimes/<rid>/native relies on. +/// Setting the DDS_LIBRARY_PATH environment variable to the full path of +/// a library file overrides that, which is how tests and development builds bind +/// against a freshly-built bazel-bin/jni/libdds.dylib without installing +/// anything. It is the .NET counterpart of the JVM binding's +/// -Ddds.library.path. +/// +/// +/// If DDS_LIBRARY_PATH is set but the library cannot be loaded, the +/// failure is surfaced with the attempted path rather than silently falling back +/// to probing: a typo in a test script should not present later as a missing +/// entry point. +/// +/// +internal static class DdsNativeResolver +{ + /// Environment variable holding an explicit path to the native library. + internal const string LibraryPathVariable = "DDS_LIBRARY_PATH"; + + /// + /// Registers the resolver. Called from 's static + /// constructor, which the runtime guarantees runs before that type's first + /// P/Invoke — so no explicit setup call is needed from consumers. A module + /// initializer would also work but runs eagerly at load time, which is both + /// more surprising in a library and flagged by CA2255. + /// + internal static void Register() + => NativeLibrary.SetDllImportResolver(typeof(DdsNative).Assembly, Resolve); + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + // Never intercept imports belonging to anything but the DDS library. + if (!string.Equals(libraryName, "dds", StringComparison.Ordinal)) + return IntPtr.Zero; + + var explicitPath = Environment.GetEnvironmentVariable(LibraryPathVariable); + if (string.IsNullOrWhiteSpace(explicitPath)) + return IntPtr.Zero; // Fall back to the runtime's default probing. + + try + { + return NativeLibrary.Load(explicitPath); + } + catch (Exception ex) + { + throw new DllNotFoundException( + $"{LibraryPathVariable} is set to '{explicitPath}', but the native DDS " + + "library could not be loaded from there.", ex); + } + } +} diff --git a/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj b/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj index ee83a962..ea2df6ef 100644 --- a/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj +++ b/dotnet/DDS_Core_Demo/DDS_Core_Demo.csproj @@ -5,7 +5,8 @@ net8.0 enable enable - x64 + + AnyCPU false ..\..\Build\bin\ diff --git a/jni/exported_symbols.lds b/jni/exported_symbols.lds index 1e4e0980..da713285 100644 --- a/jni/exported_symbols.lds +++ b/jni/exported_symbols.lds @@ -38,7 +38,16 @@ _SolveAllChunksPBN _SolveBoard _SolveBoardPBN _dds_c_calc_dd_table +_dds_c_calc_dd_table_pbn _dds_c_calc_par +_dds_c_clear_tt +_dds_c_configure_tt +_dds_c_create_solvercontext _dds_c_create_solvercontext_default _dds_c_destroy_solvercontext +_dds_c_log_append +_dds_c_log_clear +_dds_c_reset_best_moves_lite +_dds_c_reset_for_solve +_dds_c_resize_tt _dds_c_solve_board diff --git a/jni/version_script.lds b/jni/version_script.lds index d02f1be6..c35bd936 100644 --- a/jni/version_script.lds +++ b/jni/version_script.lds @@ -40,9 +40,18 @@ SolveBoard; SolveBoardPBN; dds_c_calc_dd_table; + dds_c_calc_dd_table_pbn; dds_c_calc_par; + dds_c_clear_tt; + dds_c_configure_tt; + dds_c_create_solvercontext; dds_c_create_solvercontext_default; dds_c_destroy_solvercontext; + dds_c_log_append; + dds_c_log_clear; + dds_c_reset_best_moves_lite; + dds_c_reset_for_solve; + dds_c_resize_tt; dds_c_solve_board; local: *; diff --git a/library/src/api/dds_c_api.cpp b/library/src/api/dds_c_api.cpp index a66499e6..ff9892e6 100644 --- a/library/src/api/dds_c_api.cpp +++ b/library/src/api/dds_c_api.cpp @@ -92,4 +92,119 @@ DLLEXPORT int dds_c_calc_par(DDS_C_SOLVER_CTX ctx, } } +DLLEXPORT DDS_C_SOLVER_CTX dds_c_create_solvercontext(int tt_kind, + int def_mb, int max_mb) +{ + try { + SolverConfig cfg; + cfg.tt_kind_ = static_cast(tt_kind); + cfg.tt_mem_default_mb_ = def_mb; + cfg.tt_mem_maximum_mb_ = max_mb; + return static_cast(dds_create_solvercontext(cfg)); + } catch (...) { + return nullptr; + } +} + +DLLEXPORT int dds_c_calc_dd_table_pbn(DDS_C_SOLVER_CTX ctx, + const struct DdTableDealPBN* deal, + struct DdTableResults* results) +{ + if (ctx == nullptr || deal == nullptr || results == nullptr) + return RETURN_UNKNOWN_FAULT; + + try { + return dds_calc_dd_table_pbn(static_cast(ctx), + *deal, results); + } catch (...) { + return RETURN_UNKNOWN_FAULT; + } +} + +DLLEXPORT void dds_c_configure_tt(DDS_C_SOLVER_CTX ctx, int tt_kind, + int def_mb, int max_mb) +{ + if (ctx == nullptr) + return; + + try { + dds_configure_tt(static_cast(ctx), + static_cast(tt_kind), def_mb, max_mb); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_resize_tt(DDS_C_SOLVER_CTX ctx, int def_mb, int max_mb) +{ + if (ctx == nullptr) + return; + + try { + dds_resize_tt(static_cast(ctx), def_mb, max_mb); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_clear_tt(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_clear_tt(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_reset_for_solve(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_reset_for_solve(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_reset_best_moves_lite(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_reset_best_moves_lite(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_log_append(DDS_C_SOLVER_CTX ctx, const char* msg) +{ + if (ctx == nullptr || msg == nullptr) + return; + + try { + dds_log_append(static_cast(ctx), msg); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + +DLLEXPORT void dds_c_log_clear(DDS_C_SOLVER_CTX ctx) +{ + if (ctx == nullptr) + return; + + try { + dds_log_clear(static_cast(ctx)); + } catch (...) { + // Must not unwind through the C ABI boundary. + } +} + } // extern "C" diff --git a/library/src/api/dds_c_api.h b/library/src/api/dds_c_api.h index 975b1024..1e11053c 100644 --- a/library/src/api/dds_c_api.h +++ b/library/src/api/dds_c_api.h @@ -54,6 +54,33 @@ DLLEXPORT int dds_c_calc_par(DDS_C_SOLVER_CTX ctx, struct DdTableResults* results, struct ParResults* par); +/* Creation with explicit transposition-table configuration. The C++ SolverConfig + is decomposed into scalars rather than mirrored as a struct: passing a struct + by value is exactly the ABI question this shim exists to avoid, and a mirror + type would be a second definition to keep in sync. tt_kind: 0 = Small, + 1 = Large (matching enum class TTKind). Returns NULL on failure. */ +DLLEXPORT DDS_C_SOLVER_CTX dds_c_create_solvercontext(int tt_kind, + int def_mb, int max_mb); + +/* Compute the double dummy table from a PBN-format deal. */ +DLLEXPORT int dds_c_calc_dd_table_pbn(DDS_C_SOLVER_CTX ctx, + const struct DdTableDealPBN* deal, + struct DdTableResults* results); + +/* Transposition-table configuration. */ +DLLEXPORT void dds_c_configure_tt(DDS_C_SOLVER_CTX ctx, int tt_kind, + int def_mb, int max_mb); +DLLEXPORT void dds_c_resize_tt(DDS_C_SOLVER_CTX ctx, int def_mb, int max_mb); +DLLEXPORT void dds_c_clear_tt(DDS_C_SOLVER_CTX ctx); + +/* Per-solve state resets. */ +DLLEXPORT void dds_c_reset_for_solve(DDS_C_SOLVER_CTX ctx); +DLLEXPORT void dds_c_reset_best_moves_lite(DDS_C_SOLVER_CTX ctx); + +/* Logging passthrough. msg is a NUL-terminated UTF-8 string. */ +DLLEXPORT void dds_c_log_append(DDS_C_SOLVER_CTX ctx, const char* msg); +DLLEXPORT void dds_c_log_clear(DDS_C_SOLVER_CTX ctx); + #ifdef __cplusplus } #endif diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index 8b27ef6d..b64c000b 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -200,8 +200,18 @@ auto SolverContext::clear_tt() const -> void #ifdef DDS_UTILITIES_LOG utilities().log_append("tt:clear"); #endif - if (auto* tt = search_.maybe_trans_table()) - tt->return_all_memory(); + // Dispose the instance rather than calling return_all_memory() on it. Both + // free the pools — the TT destructor returns all memory — but returning the + // memory while keeping the object leaves a husk whose pool pointers dangle, + // and SearchContext::trans_table() hands that husk straight back because it + // only checks whether tt_ is non-null. The next lookup then reads freed + // memory (ASan: heap-use-after-free in TransTable{L,S}::lookup). + // + // Disposing instead makes the documented "recreates lazily on demand" + // behaviour real: tt_ becomes null, so the next trans_table() rebuilds from + // the owner's config. Nothing is lost, because the kind and memory limits + // live in SolverContext::cfg_, not in the TT instance. + dispose_trans_table(); } auto SolverContext::resize_tt(int defMB, int maxMB) const -> void diff --git a/library/src/solver_context/solver_context.hpp b/library/src/solver_context/solver_context.hpp index 7ba130e6..1cdfd531 100644 --- a/library/src/solver_context/solver_context.hpp +++ b/library/src/solver_context/solver_context.hpp @@ -174,8 +174,8 @@ class SolverContext // tt->reset_memory(FreeMemory) when a TT exists; // preserves the TT allocation for reuse. // reset_best_moves_lite() — clears only best-move ranks and updates memUsed. - // clear_tt() — returns all TT memory to the system; preserves - // future config and recreates lazily on demand. + // clear_tt() — disposes the TT instance; preserves future + // config and recreates lazily on demand. // dispose_trans_table() — destroys the owned TT immediately. // - Diagnostics: When built with DDS_UTILITIES_LOG / DDS_UTILITIES_STATS, TT // lifecycle events append compact log entries and bump small counters. @@ -215,9 +215,14 @@ class SolverContext */ auto reset_best_moves_lite() const -> void; /** - * @brief Return all TT memory to the system without destroying the TT. + * @brief Return all TT memory to the system. + * + * Disposes the TT instance; the configured kind and memory limits persist on + * the context, so the next use recreates an empty table from them. Keeping a + * memory-less instance alive instead would leave dangling pool pointers for + * the next lookup to read. */ - auto clear_tt() const -> void; // Calls ReturnAllMemory() + auto clear_tt() const -> void; /** * @brief Resize TT memory defaults and limits in-place if TT exists. */ diff --git a/library/src/trans_table/trans_table_s.cpp b/library/src/trans_table/trans_table_s.cpp index 5cacd1e8..73f92ab2 100644 --- a/library/src/trans_table/trans_table_s.cpp +++ b/library/src/trans_table/trans_table_s.cpp @@ -343,6 +343,18 @@ auto TransTableS::init_tt() -> void auto TransTableS::reset_memory( [[maybe_unused]] const ResetReason reason) -> void { + // Nothing to reset when the pools have been returned: return_all_memory() + // frees pw_/pn_/pl_ and clears tt_in_use_, and make_tt() reallocates lazily + // before the next lookup. Without this guard init_tt() below dereferences + // the freed pools (pw_[0]) and segfaults. TransTableL's reset_memory() + // already guards the equivalent case with `pool_ == nullptr`. + // + // Defensive: SolverContext::clear_tt() disposes the TT instance rather than + // returning its memory, so no public-API sequence reaches this today. It is + // covered directly by TransTableSMemoryTest.ResetAfterReturnAllMemoryIsInert. + if (!tt_in_use_) + return; + wipe(); init_tt(); diff --git a/library/tests/BUILD.bazel b/library/tests/BUILD.bazel index 2509ff72..89e3f696 100644 --- a/library/tests/BUILD.bazel +++ b/library/tests/BUILD.bazel @@ -11,6 +11,7 @@ filegroup( "calc_par_test.cpp", # Uses GoogleTest, compiled separately "test_timer_test.cpp", # Uses GoogleTest, compiled separately "args_test.cpp", # Uses GoogleTest, compiled separately + "dds_c_api_test.cpp", # Uses GoogleTest, compiled separately ], ), ) @@ -58,6 +59,30 @@ cc_test( ], ) +# Exercises the pure-C shim through its own ABI, including the null guards and +# catch-all wrappers that exist only at that boundary and would be bypassed by +# calling the reference-taking dds_* functions directly. +# +# //library/src:dds is listed explicitly for readability; //library/src/api:dds_c_api +# already depends on it. The test needs no static-memory initialization: +# SolverContext(SolverConfig) allocates its own ThreadData, so every entry point +# here is driven from context-owned state rather than InitializeStaticMemory(). +# A test that does need it should call InitializeStaticMemory() in a fixture, as +# library/tests/system/context_tt_facade_test.cpp does. +cc_test( + name = "dds_c_api_test", + srcs = ["dds_c_api_test.cpp"], + size = "small", + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, + deps = [ + "//library/src:dds", + "//library/src/api:dds_c_api", + "@googletest//:gtest_main", + ], +) + cc_test( name = "test_timer_test", srcs = [ diff --git a/library/tests/dds_c_api_test.cpp b/library/tests/dds_c_api_test.cpp new file mode 100644 index 00000000..fb64cc38 --- /dev/null +++ b/library/tests/dds_c_api_test.cpp @@ -0,0 +1,294 @@ +/* + DDS, a bridge double dummy solver. + + Tests the pure-C ABI shim through its own boundary. + + These call dds_c_* rather than the reference-taking dds_* functions on + purpose: the null guards and the catch-all wrappers exist only in the shim, + so exercising the C++ API directly would bypass exactly the code under test. + + The reference board matches jni/java/org/dds/ffm/DdsSmokeTest.java so the + JVM, .NET, and C++ bindings all agree on one fixture. + + See LICENSE and README. +*/ + +#include + +#include + +#include + +namespace { + +// Full 13-card holding bitmask (ranks 2..A), matching the Java/Python fixtures. +constexpr unsigned int kFullSuit = 0x7FFCU; + +// The reference board: North holds all spades, East all hearts, South all +// diamonds, West all clubs. With spades trump and North to lead, North/South +// take all 13 tricks. +constexpr int kExpectedTricks = 13; + +// res_table[strain][hand], flattened 5 strains x 4 hands. Cross-checked against +// the JVM binding's EXPECTED_DD_TABLE. +constexpr int kExpectedDdTable[DDS_STRAINS][DDS_HANDS] = { + {13, 0, 13, 0}, // spades + {0, 13, 0, 13}, // hearts + {13, 0, 13, 0}, // diamonds + {0, 13, 0, 13}, // clubs + {0, 0, 0, 0}, // no-trump +}; + +// The same board in PBN: ... per hand. +constexpr const char* kReferencePbn = + "N:AKQJT98765432... .AKQJT98765432.. ..AKQJT98765432. ...AKQJT98765432"; + +struct Deal MakeReferenceDeal() +{ + struct Deal dl = {}; // value-initialize; the shim does not zero for us + dl.trump = 0; // spades + dl.first = 0; // North leads + dl.remainCards[0][0] = kFullSuit; // North spades + dl.remainCards[1][1] = kFullSuit; // East hearts + dl.remainCards[2][2] = kFullSuit; // South diamonds + dl.remainCards[3][3] = kFullSuit; // West clubs + return dl; +} + +struct DdTableDeal MakeReferenceTableDeal() +{ + struct DdTableDeal deal = {}; + deal.cards[0][0] = kFullSuit; + deal.cards[1][1] = kFullSuit; + deal.cards[2][2] = kFullSuit; + deal.cards[3][3] = kFullSuit; + return deal; +} + +// Solve the reference board on ctx and return the trick count. +int SolveReference(DDS_C_SOLVER_CTX ctx) +{ + const struct Deal dl = MakeReferenceDeal(); + struct FutureTricks fut = {}; + const int rc = dds_c_solve_board(ctx, &dl, -1, 1, 1, &fut); + EXPECT_EQ(rc, RETURN_NO_FAULT); + return fut.score[0]; +} + +// --------------------------------------------------------------------------- +// Null-handle safety. Every entry point must reject a null handle rather than +// dereferencing it: the int-returning ones with RETURN_UNKNOWN_FAULT, the +// void-returning ones by returning quietly. +// --------------------------------------------------------------------------- + +TEST(DdsCApiNullHandle, IntReturningEntryPointsFailFast) +{ + const struct Deal dl = MakeReferenceDeal(); + const struct DdTableDeal table_deal = MakeReferenceTableDeal(); + struct DdTableDealPBN pbn_deal = {}; + struct FutureTricks fut = {}; + struct DdTableResults results = {}; + struct ParResults par = {}; + + EXPECT_EQ(dds_c_solve_board(nullptr, &dl, -1, 1, 1, &fut), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table(nullptr, &table_deal, &results), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table_pbn(nullptr, &pbn_deal, &results), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_par(nullptr, &table_deal, 0, &results, &par), RETURN_UNKNOWN_FAULT); +} + +TEST(DdsCApiNullHandle, VoidReturningEntryPointsAreNoOps) +{ + // Each must return without dereferencing; the test passing is the assertion. + dds_c_destroy_solvercontext(nullptr); + dds_c_configure_tt(nullptr, 1, 0, 0); + dds_c_resize_tt(nullptr, 0, 0); + dds_c_clear_tt(nullptr); + dds_c_reset_for_solve(nullptr); + dds_c_reset_best_moves_lite(nullptr); + dds_c_log_append(nullptr, "ignored"); + dds_c_log_clear(nullptr); + SUCCEED(); +} + +TEST(DdsCApiNullArgument, PointerArgumentsAreValidated) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + struct DdTableResults results = {}; + struct DdTableDealPBN pbn_deal = {}; + const struct DdTableDeal table_deal = MakeReferenceTableDeal(); + struct FutureTricks fut = {}; + + EXPECT_EQ(dds_c_solve_board(ctx, nullptr, -1, 1, 1, &fut), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table_pbn(ctx, nullptr, &results), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_dd_table_pbn(ctx, &pbn_deal, nullptr), RETURN_UNKNOWN_FAULT); + EXPECT_EQ(dds_c_calc_par(ctx, &table_deal, 0, &results, nullptr), RETURN_UNKNOWN_FAULT); + + // A null message must be ignored rather than passed through to strlen. + dds_c_log_append(ctx, nullptr); + + dds_c_destroy_solvercontext(ctx); +} + +// --------------------------------------------------------------------------- +// Functional paths for the newly added entry points. +// --------------------------------------------------------------------------- + +class DdsCApiConfiguredContext : public testing::TestWithParam {}; + +TEST_P(DdsCApiConfiguredContext, SolvesReferenceBoard) +{ + // tt_kind 0 = Small, 1 = Large; both must produce a usable context. + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext(GetParam(), 0, 0); + ASSERT_NE(ctx, nullptr); + + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +INSTANTIATE_TEST_SUITE_P(BothTtKinds, DdsCApiConfiguredContext, + testing::Values(0, 1)); + +TEST(DdsCApiTtConfiguration, ContextRemainsUsableAfterReconfiguration) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + + // Reconfigure, resize, and clear the TT, then confirm the context still + // solves correctly — the point is that these calls do not corrupt state. + dds_c_configure_tt(ctx, 0, 1, 2); + dds_c_resize_tt(ctx, 1, 2); + dds_c_clear_tt(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +// Regression: clear_tt() used to return the TT's memory while keeping the +// instance, so the next lookup read freed pools (ASan: heap-use-after-free in +// TransTableL::lookup_suit). This is the default-configuration path — no TT +// kind switch involved — and is reachable from every binding as +// clear_tt() followed by a solve. +TEST(DdsCApiTtConfiguration, ClearTtThenSolveOnDefaultTt) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + dds_c_clear_tt(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiResets, ResetsLeaveContextUsable) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_reset_for_solve(ctx); + dds_c_reset_best_moves_lite(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +// A Small-TT context survives clear_tt() followed by reset_for_solve() and +// still solves. clear_tt() disposes the TT, so reset_for_solve() finds no +// table and the following solve rebuilds one lazily from the context's config. +// The TransTableS::reset_memory() guard is not on this path — it is covered +// directly by //library/tests/trans_table:trans_table. +TEST(DdsCApiTtConfiguration, SmallTtClearThenResetForSolve) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + dds_c_configure_tt(ctx, 0 /* Small */, 1, 2); + dds_c_clear_tt(ctx); + dds_c_reset_for_solve(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiLogging, AppendAndClearAreCallable) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + dds_c_log_append(ctx, "dds_c_api_test"); + dds_c_log_append(ctx, ""); + dds_c_log_clear(ctx); + + // Logging must not disturb solving. + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiDdTable, BinaryTableMatchesExpected) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + const struct DdTableDeal deal = MakeReferenceTableDeal(); + struct DdTableResults results = {}; + ASSERT_EQ(dds_c_calc_dd_table(ctx, &deal, &results), RETURN_NO_FAULT); + + for (int strain = 0; strain < DDS_STRAINS; ++strain) + for (int hand = 0; hand < DDS_HANDS; ++hand) + EXPECT_EQ(results.res_table[strain][hand], kExpectedDdTable[strain][hand]) + << "res_table[" << strain << "][" << hand << "]"; + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiDdTable, PbnTableMatchesBinaryTable) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + const struct DdTableDeal binary_deal = MakeReferenceTableDeal(); + struct DdTableResults binary_results = {}; + ASSERT_EQ(dds_c_calc_dd_table(ctx, &binary_deal, &binary_results), + RETURN_NO_FAULT); + + struct DdTableDealPBN pbn_deal = {}; + std::snprintf(pbn_deal.cards, sizeof pbn_deal.cards, "%s", kReferencePbn); + struct DdTableResults pbn_results = {}; + ASSERT_EQ(dds_c_calc_dd_table_pbn(ctx, &pbn_deal, &pbn_results), + RETURN_NO_FAULT); + + for (int strain = 0; strain < DDS_STRAINS; ++strain) + for (int hand = 0; hand < DDS_HANDS; ++hand) + EXPECT_EQ(pbn_results.res_table[strain][hand], + binary_results.res_table[strain][hand]) + << "res_table[" << strain << "][" << hand << "]"; + + dds_c_destroy_solvercontext(ctx); +} + +TEST(DdsCApiPar, ProducesNonEmptyScore) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + + const struct DdTableDeal deal = MakeReferenceTableDeal(); + struct DdTableResults results = {}; + struct ParResults par = {}; + ASSERT_EQ(dds_c_calc_par(ctx, &deal, 0 /* vulnerable: none */, &results, &par), + RETURN_NO_FAULT); + + EXPECT_GT(std::strlen(par.par_score[0]), 0U); + + dds_c_destroy_solvercontext(ctx); +} + +} // namespace diff --git a/library/tests/trans_table/trans_table_s_test.cpp b/library/tests/trans_table/trans_table_s_test.cpp index f0e95008..10f7d94e 100644 --- a/library/tests/trans_table/trans_table_s_test.cpp +++ b/library/tests/trans_table/trans_table_s_test.cpp @@ -4,7 +4,7 @@ // Include DDS types first #include -// No TransTable dependencies needed in this file; remove legacy forward declarations. +#include namespace dds_test { @@ -33,6 +33,43 @@ static void CreateTestWinRanks(unsigned short win_ranks[DDS_SUITS]) { win_ranks[3] = 0x8888; // Clubs } +// Regression: reset_memory() after return_all_memory() must be inert. +// return_all_memory() frees pw_/pn_/pl_ and clears tt_in_use_; without the +// guard in TransTableS::reset_memory(), init_tt() dereferences the freed pools +// (pw_[0]) and segfaults. TransTableL::reset_memory() already guards the +// equivalent case with `pool_ == nullptr`. +TEST(TransTableSMemoryTest, ResetAfterReturnAllMemoryIsInert) { + TransTableS tt; + + tt.set_memory_maximum(1); + tt.make_tt(); + tt.return_all_memory(); + + // The guard under test. Without it this call segfaults: init_tt() + // dereferences pw_[0], which return_all_memory() has already freed. + // Reaching this line at all is the regression assertion. + tt.reset_memory(ResetReason::FreeMemory); + + // The table is usable again once the pools are reallocated. + tt.make_tt(); + + int handLookup[15][15]; + CreateBasicHandLookup(handLookup); + tt.init(handLookup); + tt.reset_memory(ResetReason::NewDeal); + + // aggr_target entries index aggp_ (8192 = 2^13 slots), so each must be a + // 13-bit rank mask; a wider value reads past the array. An all-zero + // hand_dist matches the tree root init_tt() rebuilt, so this reaches the + // aggp_ indexing and the pos_search_point_ null check on the rebuilt table. + unsigned short aggrTarget[DDS_SUITS] = {0, 0, 0, 0}; + int hand_dist[4] = {0, 0, 0, 0}; + bool lowerFlag = false; + + // Nothing was added, so the rebuilt table must miss rather than crash. + EXPECT_EQ(tt.lookup(1, 0, aggrTarget, hand_dist, 0, lowerFlag), nullptr); +} + // Test that verifies DDS constants are available TEST(TransTableSBasicTest, DDSConstantsAvailable) { // Verify that basic DDS constants are accessible diff --git a/specs/dds-public-api.md b/specs/dds-public-api.md index da023ff0..325f0157 100644 --- a/specs/dds-public-api.md +++ b/specs/dds-public-api.md @@ -1,7 +1,7 @@ --- capability: dds-public-api owners: [api] -last-updated: 2026-07-18 +last-updated: 2026-07-19 --- # DDS Public API @@ -36,17 +36,21 @@ capability defines what crosses the boundary and promises to stay stable. header for the full set. 3. **Pure-C ABI shim** — `dds_c_api.h` (`dds_c_*`). Pointer-only, POD-only, opaque `void*` handle (`DDS_C_SOLVER_CTX`); no C++ types cross the boundary. - It forwards to layer 2. Today it is a thin subset (`dds_c_solve_board`, - `dds_c_calc_dd_table`, `dds_c_calc_par` plus context lifecycle) — **no - `*PBN` twins and no TT configure** in the shim. -- **Bindings pick different layers.** Java/FFM binds the shim (`dds_c_*` plus - `GetDDSInfo` from `dll.h`) — see [jni-ffm-binding](jni-ffm-binding.md). .NET - P/Invokes the modern `dds_*` symbols from `dds_api.hpp` - (`dotnet/DDS_Core/Native/DdsNative.cs`). Python wraps the C++ API via pybind11 - ([python-binding](python-binding.md)), not the C shim. There is no shipped - ctypes binding. The shim header is C-ABI but not C-includable (it pulls in - `dll.h`, which uses C++ trailing-return syntax) — bind to compiled symbols or - parse in C++ mode (jextract). + It forwards to layer 2 and now covers that layer's full surface: context + lifecycle (including config-based creation), `dds_c_solve_board`, + `dds_c_calc_dd_table` and its `_pbn` twin, `dds_c_calc_par`, TT + configure/resize/clear, both resets, and the logging passthroughs. + `SolverConfig` is decomposed into scalar arguments and `TTKind` crosses as + an `int`, so no struct is passed by value. +- **Bindings pick different layers.** Java/FFM and .NET both bind the shim + (`dds_c_*`) plus the flat `dll.h` API — see + [jni-ffm-binding](jni-ffm-binding.md) and [dotnet-binding](dotnet-binding.md). + .NET reaches the shim through `EntryPoint` on its P/Invokes + (`dotnet/DDS_Core/Native/DdsNative.cs`), keeping its managed method names. + Python wraps the C++ API via pybind11 ([python-binding](python-binding.md)), + not the C shim. There is no shipped ctypes binding. The shim header is C-ABI + but not C-includable (it pulls in `dll.h`, which uses C++ trailing-return + syntax) — bind to compiled symbols or parse in C++ mode (jextract). - **Handles are single-threaded.** One `DDS_SOLVER_CTX` / `DDS_C_SOLVER_CTX` per thread; the handle owns per-context solver state and its transposition table. Create → use → destroy. The legacy flat API manages global/threaded state via @@ -57,13 +61,17 @@ capability defines what crosses the boundary and promises to stay stable. entry points have a `*PBN` twin; both compute identical results from the same deal. On the modern C++ layer only `dds_calc_dd_table` has one (`dds_calc_dd_table_pbn`) — `dds_solve_board` and `dds_calc_par` do not. The - pairing does **not** extend to the C shim. + shim mirrors the modern layer exactly: `dds_c_calc_dd_table_pbn` is its only + PBN twin. Agreement between each pair is asserted by + `//library/tests:dds_c_api_test` and by the .NET smoke tests. - **The pinned binding export set is `dll.h` + `dds_c_api.h`.** On Linux/macOS the JNI shared library exports are constrained by `jni/version_script.lds` / `exported_symbols.lds` and checked by the export-set test. That is the *stable binding ABI*, not every `DLLEXPORT` symbol in the tree: `dds_api.hpp` also marks modern `dds_*` symbols `DLLEXPORT`, and on Windows (no `.lds`) the DLL exports that broader `DLLEXPORT` set. Details in [jni-ffm-binding](jni-ffm-binding.md). + Now that the shim covers the whole modern surface, no shipped binding depends + on that Windows-only surplus: all three platforms bind the same pinned set. ## Key entry points @@ -71,7 +79,8 @@ capability defines what crosses the boundary and promises to stay stable. narrative in `docs/legacy_c_api.md`. - `library/src/api/dds_api.hpp` — modern context C++ API. Narrative in `docs/c++_interface.md`; migration in `docs/api_migration.md`. -- `library/src/api/dds_c_api.h` — pure-C ABI shim (Java/FFM binding surface). +- `library/src/api/dds_c_api.h` — pure-C ABI shim (the Java/FFM and .NET binding + surface). Guarded by `//library/tests:dds_c_api_test`. - `library/src/api/{solve_board,calc_dd_table,calc_par}.hpp`, `PBN.h`, `portab.h`, `dds.h` — supporting public headers (`api_definitions`). - Build targets: `//library/src/api:dds_c_api`, `:api_definitions`, `//:dds` diff --git a/specs/dotnet-binding.md b/specs/dotnet-binding.md new file mode 100644 index 00000000..e51b2006 --- /dev/null +++ b/specs/dotnet-binding.md @@ -0,0 +1,106 @@ +--- +capability: dotnet-binding +owners: [dotnet] +last-updated: 2026-07-19 +--- + +# .NET Binding (DDS_Core) + +> **Specs vs. doxygen / docs.** How to call the wrapper — the type-by-type API, +> legacy-vs-modern guidance, worked examples — is in `docs/dotnet_interface.md` +> and the XML doc comments on the types themselves. This spec records the +> cross-cutting contracts: which ABI layer is bound, how the native library is +> found, and the invariants the tests enforce. + +## Purpose + +This capability lets .NET consumers call the solver through an idiomatic managed +API — blittable structs, `SafeHandle` lifetimes, method overloading — on macOS, +Linux, and Windows from the same assembly. It exists so a .NET application gets +the solver without building the C++ or writing marshalling code. Like the JVM +binding it targets the pure-C shim from [dds-public-api](dds-public-api.md), not +the C++ API. + +## Behaviour & invariants + +> Per-type and per-method detail is in `DDS_Core`'s doc comments and +> `docs/dotnet_interface.md`. These are the whole-binding guarantees. + +- **Two ABI layers, one library.** The modern context entry points bind the + `dds_c_*` shim; the legacy flat API (`SolveBoard`, `CalcDDtable`, `Par`, + `Analyse*`, …) binds `dll.h` directly. Both come from the single native + library built by `//jni:dds_shared`. The binding does **not** use the + reference-taking `dds_*` symbols in `dds_api.hpp`: those are not exported on + Linux or macOS, so binding them made the wrapper Windows-only. +- **Managed names are decoupled from ABI names.** The shim is reached via + `EntryPoint` on each `DllImport`, so `DdsNative`'s method names — and every + call site in `DDS.cs` / `SolverContext.cs` — are independent of the C symbol + names. Renaming a shim export touches one attribute. +- **No struct crosses the boundary by value.** `SolverConfig` is unpacked into + scalar arguments at the P/Invoke and `TTKind` marshals as its underlying + `int`, matching the shim's pointer-only, POD-only contract. +- **One native library name: `dds`.** .NET's probing supplies the `lib` prefix + and per-OS extension, so a single `DllName` resolves `libdds.dylib`, + `libdds.so`, and `dds.dll`. +- **Library resolution is overridable.** A resolver registered from + `DdsNative`'s static constructor — which the runtime guarantees runs before + that type's first P/Invoke — honours the `DDS_LIBRARY_PATH` environment + variable, falling back to default probing when it is unset. When it is set but + unusable the failure names the attempted path rather than silently falling + through. This is the counterpart of the JVM binding's `-Ddds.library.path` and + is intended for tests and development, not deployment. +- **Struct layouts are pinned by tests, not by convention.** The managed structs + must match the C structs in `dll.h` byte for byte; a mismatch on SysV or + AArch64 corrupts results *silently* rather than throwing. `LayoutTests` + asserts sizes and field offsets against values derived from the C headers, and + `FourHands` indexing against the `hand * 4 + suit` row-major layout the other + bindings assume. +- **Solver contexts are single-threaded and deterministically released.** + `SolverContext` owns a `SolverContextHandle` (`SafeHandle`), so the native + context is freed on `Dispose` or finalization; disposal is idempotent. One + context per thread, as with every binding (see + [solver-context](solver-context.md)). +- **Integer status returns.** Entry points return `RETURN_*` codes as elsewhere + in the API; the wrapper converts failures to exceptions at its public surface, + in every build configuration. The check must not be made conditional on + `DEBUG` — that silently downgrades Release consumers to unchecked return + codes, which is the opposite of what this bullet promises. +- **All three ABIs are covered by CI.** The managed tests run on Linux (SysV + x86-64), Windows (Win64), and macOS (AArch64), each against the Bazel-built + shared library located via `bazel info bazel-bin`. Windows matters most: it is + the platform the shim retarget could regress, and the Bazel-built `dds.dll` + path is covered by no other test, since the JNI tests are + `target_compatible_with`-excluded there. + +## Key entry points + +- `dotnet/DDS_Core/Native/DdsNative.cs` — every P/Invoke; `DllName` and the + `EntryPoint` mapping onto `dds_c_*`. +- `dotnet/DDS_Core/Native/DdsNativeResolver.cs` — `DDS_LIBRARY_PATH` resolution. +- `dotnet/DDS_Core/DataModel/SolverContext.cs` — the modern managed API; + `Helpers/SolverContextHandle.cs` — `SafeHandle` ownership. +- `dotnet/DDS_Core/DataModel/`, `Helpers/` — the blittable structs and inline + array helpers whose layouts the tests pin. +- Native artifact: `//jni:dds_shared` (see [jni-ffm-binding](jni-ffm-binding.md)). +- Consumer guide: `docs/dotnet_interface.md`. +- Guarded by `dotnet/DDS_Core.Tests/` (`LayoutTests`, `SmokeTests`, + `ContextLifecycleTests`) and, on the native side, + `//library/tests:dds_c_api_test`. + +## Known gaps / non-goals + +- **No NuGet package yet.** Consumers build the project and supply the native + library themselves. RID-specific packaging (`runtimes//native/…`) and a + multi-platform package are a deliberate follow-up, mirroring how the jar + followed the JVM shared library. +- **`solution/dds_native.vcxproj` is no longer the shipped native artifact** but + is still present, and `DDS_Core.slnx` still build-depends on it. It builds a + differently-named DLL that the binding no longer loads; retiring it is + deferred. As a consequence `DDS_Core.slnx` does not build under `dotnet + build` — it includes a C++ project needing Visual Studio's MSBuild. The + individual projects build fine, which is what CI uses. +- **The test project targets `net8.0` with `RollForward` set to `Major`**, so it + also runs where only a newer major runtime is installed. CI pins an 8.0 SDK, + where the property has no effect. +- Per-type API documentation is intentionally not duplicated here; it lives in + `docs/dotnet_interface.md` and the types' doc comments. diff --git a/specs/jni-ffm-binding.md b/specs/jni-ffm-binding.md index 6b2f5ac0..c98866f3 100644 --- a/specs/jni-ffm-binding.md +++ b/specs/jni-ffm-binding.md @@ -1,7 +1,7 @@ --- capability: jni-ffm-binding owners: [jni] -last-updated: 2026-07-18 +last-updated: 2026-07-19 --- # JVM Binding (Foreign Function & Memory) @@ -30,7 +30,9 @@ shim from [dds-public-api](dds-public-api.md), not the C++ API. links every internal sub-library statically so `System.loadLibrary("dds")` needs exactly one file. Per-OS name: `dds.dll` / `libdds.dylib` / `libdds.so`. On Unix the export set is the stable C ABI (`dll.h` + `dds_c_*`); on Windows - it is broader — see the next bullet. + it is broader — see the next bullet. Despite living under `//jni`, this target + is the shared native artifact for the .NET binding too + ([dotnet-binding](dotnet-binding.md)). - **The exported ABI is pinned by checked-in export lists on Unix.** Linux links with `version_script.lds` (`-Wl,--version-script`), macOS with `exported_symbols.lds` (`-Wl,-exported_symbols_list`). Those `.lds` files are @@ -40,7 +42,10 @@ shim from [dds-public-api](dds-public-api.md), not the C++ API. parser is unit-tested by `gen_export_lists_test`. **Windows has no `.lds` branch:** the DLL exports whatever is marked `DLLEXPORT`, which is a **superset** of the Unix list (it also includes the modern `dds_*` context API - from `dds_api.hpp`). See [dds-public-api](dds-public-api.md). + from `dds_api.hpp`). No shipped binding relies on that surplus any more: the + shim now covers the whole modern surface, so the JVM and .NET bindings bind + the same pinned set on every platform. See + [dds-public-api](dds-public-api.md) and [dotnet-binding](dotnet-binding.md). - **Bindings are hand-written, not jextract-generated.** `//jni:dds_ffm` (`java_library`, `java/org/dds/ffm/Dds.java`) declares the struct `MemoryLayout`s and `Linker` downcall handles for the `dds_c_*` shim plus diff --git a/specs/solver-context.md b/specs/solver-context.md index c2bf5c08..6ab48b08 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -1,7 +1,7 @@ --- capability: solver-context owners: [solver_context] -last-updated: 2026-07-18 +last-updated: 2026-07-20 --- # Solver Context @@ -48,19 +48,19 @@ the opaque handle. See [dds-public-api](dds-public-api.md). `reset_for_solve()` clears a subset of search state and resets TT memory (`ResetReason::FreeMemory`) while preserving the allocation for reuse; `reset_best_moves_lite()` clears only best-move ranks (hot per-iteration path); - `clear_tt()` calls `return_all_memory()` on the existing TT object; - `dispose_trans_table()` destroys the TT immediately. -- **`clear_tt()` does not leave a reusable table.** It keeps the `unique_ptr` - alive but frees the table's storage, and nothing re-runs `make_tt()`: - `SearchContext::trans_table()` returns early whenever `tt_` is non-null, and - `TransTable::init()` only fills aggregate lookup arrays — it does not - reallocate. Reusing the context after `clear_tt()` without - `dispose_trans_table()` (or recreating the context) is **unsafe**: the next - `lookup`/`add` can touch freed memory. Do not treat this as a quiet "dead - cache" — `TransTableS` does not gate `lookup`/`add` on `tt_in_use_`, and - `TransTableL::return_all_memory()` does not put the table into a reliably - inert state. Use `dispose_trans_table()` when the next solve should get a - fresh table. No test guards this today. + `clear_tt()` disposes the TT instance; `dispose_trans_table()` destroys the + TT immediately. +- **`clear_tt()` leaves the context reusable.** It disposes the TT instance + rather than calling `return_all_memory()` on it, so `tt_` becomes null and + the next `SearchContext::trans_table()` rebuilds an empty table lazily. The + configured kind and memory limits survive, because they live in + `SolverContext::cfg_` and not in the TT instance. Calling `return_all_memory()` + while keeping the object was the earlier behaviour and was **unsafe**: it left + a husk whose pool pointers dangled, which `trans_table()` handed straight back + because it only checks whether `tt_` is non-null — the next `lookup`/`add` + read freed memory. `clear_tt()` and `dispose_trans_table()` now differ only in + their log/stats trace. Guarded by `//library/tests:dds_c_api_test` + (`DdsCApiTtConfiguration.ClearTtThenSolveOnDefaultTt`). - **Hot-path facades are value-typed and inline-friendly, with different holds.** `MoveGenContext` holds a raw `ThreadData*` so `move_gen()` can return a value-typed facade without an atomic `shared_ptr` bump on every call.