# Windows Runtime-DLL Deployment & `compat.openblas` Windows Support (Design)

Date: 2026-06-29
Status: **Implemented & shipped.**
- **Phase A** (mcpp runtime-DLL deployment) — merged in mcpp **#185**, released as
  **v0.0.73**, mirrored to `xlings-res/mcpp` (GitHub + GitCode), indexed via
  `openxlings/xim-pkgindex` **#326** (`latest → 0.0.73`), `xlings install
  mcpp@0.0.73` verified, workspace bootstrap pin bumped.
- **Phase C** (`compat.openblas` Windows recipe) + **Phase D** (Windows CI
  build-and-run) — mcpp-index **#55**; the `OpenBLAS-0.3.33-x64.zip` CN mirror is
  live on gitcode `mcpp-res/openblas` (byte-verified).

Scope: `src/build/{plan,ninja_backend}.cppm` (mcpp); `pkgs/c/compat.openblas.lua`,
`tests/smoke_compat_portable.sh`, `.github/workflows/validate.yml` (mcpp-index).

### Implementation note (deviation from the original design)

The implemented mechanism is **gated by the `*.dll` file extension, not by
`if constexpr(is_windows)` and not by a schema change.** During build planning,
each `*.dll` found in a linked dependency's `[runtime] library_dirs` is staged
into `bin/` beside the produced executable via a ninja `cp_bmi` copy edge that
the executable target takes as an implicit dependency. Consequences:

- **No `manifest.cppm` schema change** (the original Phase-A-step-1). A recipe
  declares `[runtime] library_dirs` *globally*; on Linux/macOS the dependency
  ships `.so`/`.dylib` (never `.dll`), so the glob matches nothing and the build
  is byte-for-byte unchanged. This is exactly the §7 "declare it globally —
  harmless on Linux" option, made safe by the extension filter. Per-OS scoping
  under `mcpp.<os>` is therefore unnecessary (and already supported for free by
  the existing per-OS textual merge if a recipe ever wants it).
- **The deploy path is exercised on a Linux host** (test
  `tests/e2e/84_runtime_dll_deploy.sh`) by a dummy dependency shipping a stub
  `libdummy.dll` — the same code that runs on Windows, validated without a
  Windows runner. The Windows link/run half is Phase D (mcpp-index CI).
- **`mcpp pack`** needs no change here: Windows PE packaging is separately
  stubbed (`src/pack/pack.cppm`, see `2026-05-19-pack-windows-design.md`); when
  implemented it will pick up the staged `bin/*.dll`. On Linux `mcpp pack` uses
  `ldd`, which never sees a `.dll`, so the deploy is invisible to it.

## 1. Problem

`compat.openblas` is a first-class BLAS **library**, not merely an Eigen backend:
a consumer can depend on it directly, `#include <cblas.h>`, and call
`cblas_dgemm` (proven — a standalone consumer links and runs, producing the
correct `[19 22; 43 50]`). It therefore deserves cross-platform support.

It currently ships **linux/macosx only**. The two platforms build OpenBLAS from
source through its GNU Make system, driven by the xpkg `install()` hook
(build-dep `xim:make`, `CC=gcc`), producing a fully static `libopenblas.a` that
links with no runtime artifact. Windows has no equivalent path:

- mcpp links Windows with **MSVC-ABI Clang** (msvcrt / msvc-stl / MSVC C++ ABI).
- OpenBLAS's prebuilt `OpenBLAS-<ver>-x64.zip` contains a **mingw** static
  `libopenblas.a` (Itanium ABI + libgcc/mingw-libc) that does not link cleanly
  into an MSVC-ABI binary, plus an MSVC import library `libopenblas.lib` and a
  runtime `libopenblas.dll`. The import-lib path links, but then the produced
  executable needs `libopenblas.dll` present at launch.
- Building OpenBLAS statically with clang-cl/CMake inside the xim sandbox is not
  available (no MSVC toolchain there) and would be a heavy CMake build.

So the only viable Windows route is **import-lib + runtime DLL**, and that is
blocked by a genuine mcpp capability gap: **mcpp does not deploy a dependency's
runtime DLL alongside the produced executable.** A Windows `compat.openblas`
would link but fail to launch (missing DLL).

## 2. Background — mcpp's existing runtime model

mcpp already models "what a built binary needs at launch" via the `[runtime]`
section (`manifest::RuntimeConfig`):

- `library_dirs` — directories (relative to the package root) holding the
  package's runtime shared libraries. These flow into
  `BuildPlan::depRuntimeLibraryDirs`.
- `dlopen_libs`, `capabilities`, `provides`, provider overrides.

On platforms where `mcpp::platform::supports_rpath` is true (ELF / Mach-O —
Linux, macOS), `src/build/flags.cppm` turns each runtime library dir into
`-Wl,-rpath,<dir>` so the loader finds the `.so`/`.dylib` at runtime. `mcpp run`
additionally exports the platform runtime path variable
(`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) from the same list.

On Windows (PE), `supports_rpath` is **false**. There is no general RPATH
mechanism, so `depRuntimeLibraryDirs` is currently a no-op at link time: a
directly-launched `.exe` cannot find a dependency's DLL.

## 3. Design decision

**Reuse `[runtime].library_dirs`; add a Windows deployment backend. No new schema
key, no top-level per-OS block.**

The recipe schema already carries platform structure at three layers, each with a
distinct responsibility:

| Layer | Responsibility | Platform split |
|---|---|---|
| `xpm.<os>` | download source (url / sha256) | `xpm.{linux,macosx,windows}` |
| `mcpp.<os>` | compile/link keys (`cflags`/`sources`/`ldflags`) | `mcpp.{linux,macosx,windows}` (cf. compat.glfw) |
| `[runtime]` | launch-time requirements (`library_dirs`, `dlopen_libs`, …) | global today |

A dependency's runtime DLL is a **launch-time requirement**, so it belongs to
`[runtime].library_dirs` (pointing at the directory that holds the DLL, e.g.
`bin/`). The abstraction *already exists*; Windows is simply the platform on
which its deployment backend is unimplemented.

The implementation mirrors the existing RPATH branch. Where `flags.cppm` today
does, in effect:

```
if constexpr (supports_rpath)        // ELF / Mach-O
    for dir in depRuntimeLibraryDirs: ldflags += "-Wl,-rpath," + dir
```

it gains the symmetric PE branch:

```
else if constexpr (is_windows)       // PE
    for dir in depRuntimeLibraryDirs: deploy *.dll from dir  →  <output>/bin/
```

The same declaration ("this dependency's runtime libraries live in dir X") is
dispatched, at compile time, to the platform-appropriate mechanism: RPATH on
ELF/Mach-O, copy-beside-executable on PE. Rejected alternatives:

- **A top-level `windows = {}` recipe block** — redundant; `mcpp.<os>` and
  `[runtime]` already provide the platform and runtime axes.
- **A new `runtime_libs` key** — redundant with `[runtime].library_dirs`; adds a
  second way to say the same thing.

## 4. Architecture evaluation

| Dimension | Rating | Rationale |
|---|---|---|
| Soundness | High | Completes an abstraction already designed but unimplemented on PE; RPATH and copy are both "make the runtime library locatable" — semantically unified. |
| Compatibility | High | Purely additive, dispatched by `if constexpr`; non-Windows behavior is byte-for-byte unchanged; no new schema key, so existing recipes are untouched. |
| Generality | High | Benefits every Windows compat library that ships a DLL (future fftw / hdf5 / any prebuilt-DLL package), not an OpenBLAS-specific patch. |
| Complexity | Low–Medium | One ninja `copy` edge plus exposing the DLLs of `depRuntimeLibraryDirs` to the backend. No new concept, no new key. ninja handles incrementality/timestamps. |
| Elegance | High | Symmetric with the RPATH path; reuses `[runtime]`; zero new abstraction. |
| Stability | High (deploy side) | The link side is unchanged (still import-lib); the addition is an order-only file copy at the end of the build — small failure surface. |

The residual risk is **not** in this feature's deploy mechanism but in the
Windows-specific link/run that only a Windows host exercises (see §7).

## 5. Implementation phases

### Phase A — mcpp: Windows runtime-DLL deployment (the core feature)

1. **Schema** (`manifest.cppm`): allow `[runtime].library_dirs` to be declared
   per-OS under `mcpp.<os>` (today `mcpp.<os>` parses `cflags`/`sources`/`ldflags`;
   extend it to also read a runtime library-dir list), so a recipe can scope the
   directory to Windows. No new key is introduced.
2. **Propagation** (`prepare.cppm` / `plan.cppm`): the runtime library dirs of a
   dependency in an executable's link closure already reach
   `BuildPlan::depRuntimeLibraryDirs`; ensure they are resolved to absolute
   source paths (reuse the `-L<rel>` → `<depRoot>/<rel>` normalization) and
   de-duplicated.
3. **Backend** (`flags.cppm` + `ninja_backend.cppm`): add the PE branch — for
   each runtime library dir of a linked dependency, emit
   `build <output>/bin/<dll> : copy <abs-src>` and make the executable target
   take an order-only dependency on it. Guard by `is_windows` so other platforms
   are unaffected.
4. **Packaging** (`mcpp pack`): include the deployed `bin/*.dll` so a packaged
   artifact is self-contained off the build machine.
5. **Local test** (achievable without Windows): an e2e on Linux using a *dummy*
   shared library — a dependency declares a runtime library dir; assert
   `mcpp build` deploys the file beside the executable. This validates the deploy
   mechanism mechanically. The Windows-specific link/run is verified in Phase D.

### Phase B — release mcpp 0.0.73

The feature must ship in a released mcpp before the recipe can rely on it (an
older mcpp would link the import lib but never deploy the DLL → runtime failure).
Run the established pipeline: version bump (`mcpp.toml` + `fingerprint.cppm`),
design-doc + tests, PR → CI (5 suites) → squash-merge → `release.yml` (4
platforms) → mirror `xlings-res/mcpp` (GitHub + GitCode) → bump
`xim-pkgindex/pkgs/m/mcpp.lua` → `xlings install mcpp@0.0.73` verification → bump
the workspace bootstrap pin.

### Phase C — `compat.openblas` Windows recipe

1. `xpm.windows`: `url` → `OpenBLAS-0.3.33-x64.zip` (GLOBAL = OpenMathLib GitHub;
   CN = gitcode `mcpp-res/openblas`); `sha256`. The zip unpacks to
   `bin/ lib/ include/` (no wrapper directory).
2. `mcpp.windows`:
   - `include_dirs` resolves the shipped `include/` (carries `cblas.h`).
   - `ldflags` link the import library `libopenblas.lib` (clang-cl form to be
     finalized via CI).
   - `[runtime] library_dirs = ["bin"]` (the existing key) so Phase A deploys
     `libopenblas.dll` beside the consumer's executable.
   - anchor strategy: Windows has no build step, so the anchor TU is supplied via
     `generated_files` (mcpp writes it; `install()` does not run). Linux/macOS keep
     the "`install()` produces the anchor → triggers the Make build" trigger.
3. `install()` branches on `os.host()`: Windows returns immediately (prebuilt — no
   Make); linux/macosx run the existing source build.
4. CN mirror: upload `OpenBLAS-0.3.33-x64.zip` (~40 MB) to the gitcode
   `mcpp-res/openblas` 0.3.33 release; verify byte-equality via a GET download.

The linux/windows asymmetry is intentional and semantically natural: Linux/macOS
link a fully static `libopenblas.a` (no runtime artifact, so no runtime dir is
declared); Windows uses the prebuilt import-lib + DLL (MSVC-ABI Clang has no
static option from the upstream zip), so only Windows declares a runtime library
directory.

### Phase D — verification via CI (the only Windows-host validation)

The Windows runtime half (clang-cl linking `libopenblas.lib`, the DLL loading at
launch, `cblas_dgemm` producing the correct result) can be verified **only** on a
real Windows runner. The current mcpp-index CI does **not** cover it as-is:

- `smoke-examples` is **ubuntu-only** and does build **and** run
  (`tests/run_example.sh` = `mcpp build` + `mcpp run`), but never on Windows.
- `smoke-windows` runs on `windows-latest` but is **build-only**
  (`smoke_compat_*.sh` invoke `mcpp build`, not `mcpp run`) and is **pinned to
  mcpp 0.0.68**.

So Phase D must, in `validate.yml`:

1. Bump the Windows smoke job's mcpp pin to ≥ 0.0.73 (the release carrying the
   deployment feature).
2. Add a Windows **build-and-run** step for an OpenBLAS example that asserts the
   computed result — only `mcpp run` exercises DLL loading; a build-only check
   would pass even if the DLL were never deployed.

Iterate against the runner logs until green (no interactive debugging is possible
on the runner; correction is log-driven).

## 6. Verification boundary

| Item | Local (Linux host) | CI (`windows-latest`) |
|---|---|---|
| recipe parse / schema / Linux deploy mechanism | yes (dummy-lib e2e `84_runtime_dll_deploy.sh`) | — |
| clang linking `libopenblas.lib` | no (no MSVC env) | **yes — confirmed** (`-Llib -llibopenblas`) |
| DLL deployed beside the exe + loaded + `cblas_dgemm` runs | no | **yes — confirmed** (mcpp-index #55 smoke-windows: `mcpp run` + direct-exe launch from a neutral CWD both green) |

## 7. Open questions / risks — resolved by CI

- **Whole-directory `*.dll` vs an explicit list.** OpenBLAS's `bin/` holds a
  single DLL, so copying `*.dll` from each runtime library dir is sufficient and
  simple (preferred — YAGNI). If a future package's `bin/` mixes unrelated DLLs,
  an optional explicit list can narrow it later. *(Shipped as whole-directory.)*
- **clang link form and symbol decoration.** *Resolved:* mcpp links Windows with
  the clang MSVC driver, and `-Llib -llibopenblas` resolves to `lib/libopenblas.lib`;
  the cdecl `cblas_*` symbols matched and `cblas_dgemm` produced `[19 22; 43 50]`
  on the runner.
- **`mcpp.<os>` runtime parsing.** *Resolved without a parser change:* the deploy
  is filtered by the `*.dll` extension (§"Implementation note"), so `[runtime]
  library_dirs` is declared per-OS under `mcpp.windows` via the existing per-OS
  textual merge — no new schema key. On Linux/macOS the static archive ships no
  DLL, so the declaration is inert.

## 8. Validation footnote — Phase D test bug (caught & fixed by CI)

The first mcpp-index #55 run's `smoke-windows` failed *not* in the feature but in
the test: the new openblas smoke ran the produced `.exe` from a path `find target`
returned **relative**, after `cd /` to a neutral CWD — so the launch resolved to a
nonexistent path. The build had already linked the import lib, deployed
`bin/libopenblas.dll`, and `mcpp run` had exited 0 (correct GEMM). Absolutising the
exe path (pwd + basename) fixed the assertion; the re-run is green. The lesson is
the value of the direct-exe launch from a neutral CWD: it is the only check that
proves the DLL loads *beside the exe* rather than via `mcpp run`'s `PATH` prepend.
