Skip to content

CMake parity for a framework and its ecosystem: per-row linkage, dependency identity, shared-library staging, multi-source tests, DMG / Setup.exe — each with a CI check #634

Description

@Sunrisepeak

中文摘要

HuxerUI 的 mcpp 构建链在六个平台上已默认对齐 CMake 的链接、加载与资源布局约定(fork PR 见文末)。逐项对照 CMake 链之后,仍需要 mcpp 生态补齐的共 21 条,按归属分为引擎(A)、mcpp:plugins(B)、xim-pkgindex(C)和 mcpp-index(D)四类。每条都写明实测现象、需求原因、建议形状,以及可在 GitHub 托管 runner 上跑的 CI 验收方法;不需要真机、签名证书或 secret。

  • P1(每个应用和生态库都会碰到):
    • A1:按平台切换依赖的 linkage,第一种直觉写法会被静默忽略;
    • A2:依赖名与目标清单身份不一致时,报错是"模块重复提供";
    • A3 / B1:图内构建的共享库没有被 stage(Android 清单仍写 closure = walked),Mach-O 闭包未遍历,所以 macOS 的 .app 带不上 dylib。
  • P2:
    • 测试:A5 多源文件测试程序与标签;
    • payload 与 runner:A7 payload 依赖闭包对 build program 不可见,C2 模拟器控制台输出偶发丢失,C4 未合并 recipe 无法在消费方 CI 中测试;
    • macOS 分发:B2 DMG,B3 / C1 mcpp run --format app 拿不到程序退出码。
  • P3:
    • 引擎:A4 默认 SONAME,A6 模拟器/模拟机上跑测试,A8 build program 枚举依赖图与元数据,A9 依赖声明平台下限,A10 --toolchain;
    • 插件与 payload:B4 / C3 Setup.exe(Burn),B5 AAB,B6 metal / esbuild 规则;
    • 索引:D1 发布六平台版本并改用版本范围。

Context

#622 asked for what a UI framework needs on Android, iOS and the Web; #630 listed what the first six-platform build still hit. Both are answered: 2026.9.13.2, 2026.9.14.1 (#633) and mcpp:plugins 0.9.3 are what HuxerUI now builds on. On every row its mcpp path takes the CMake path's conventions by default:

  • the framework is statically linked, except on Android, where it is its own libhuxerui.so loaded by the Java host;
  • resources land where the CMake build puts them;
  • CMake's own windowless UI smoke runs under mcpp test.

The work is on three fork PRs, each green on every row: Sunrisepeak/HuxerUI#7 (framework, rule packages, CLI), Sunrisepeak/Lib-Live2D#2 (an ecosystem library) and Sunrisepeak/HuxerUI#8 (templates).

This issue is what remains when that mcpp path is compared with the CMake path item by item: linking, loading, packaging, tests and distributables. Each item gives:

  • Measured: the fact, with the version it was measured on;
  • Why: why it matters beyond HuxerUI;
  • Proposed: a shape;
  • CI check: a check that runs on a GitHub-hosted runner.

Android checks use the emulator (KVM on ubuntu-24.04) and iOS checks the simulator (macos-15). No check needs a device, a signing identity or a secret.

Measured with mcpp 2026.9.14.1, mcpp:plugins 0.9.3 and xlings 2026.9.12.1, on a Linux x86_64 host (GCC 16.1.0, xim:android-ndk 30.0.16248370) and in CI, unless an item says otherwise. CMake references point at unchanged upstream files, through the fork at 64264cb.

The only open item of #622 (B4) is carried here as B6, so #622 can be closed in favour of this issue.

# Where What Priority
A1 engine One identity, a different linkage per row: the first spelling is dropped silently P1
A2 engine A dependency key that names a different identity than its manifest: two packages from one directory P1
A3 engine Shared libraries the graph built are not staged; Mach-O closures are not walked P1
A4 engine No SONAME / install name for a dependency made shared by linkage P3
A5 engine A test program from several sources; test roots; labels P2
A6 engine Windowless tests on the Android emulator and the iOS simulator P3
A7 engine A payload's recipe-dependency closure is invisible to build programs P2
A8 engine Enumerating the resolved graph, and package metadata, from a build program P3
A9 engine A platform floor a dependency needs (min_api_level, iOS deployment target) P3
A10 engine --toolchain on run / test / pack P3
B1 plugins dist-apple carries graph-built dylibs (the plugin-side A3, as dist-apk 0.9.3 did) P1 if before A3
B2 plugins dist-apple: DMG P2
B3 plugins dist-apple: a default macOS --format app runner that returns the program's status P2
B4 plugins dist-wix: a Burn bundle (Setup.exe) P3
B5 plugins dist-apk: an Android App Bundle P3
B6 plugins rules-metal, tools-esbuild (carried from #622 B4) P3
C1 xim-pkgindex A macOS app-bundle runner program P2
C2 xim-pkgindex simctl-run: output lost under --console-pty; exit status unmeasured P2
C3 xim-pkgindex WiX Burn native SDK payloads P3
C4 xim-pkgindex / xlings Resolving an unmerged recipe from a consumer's CI P2
D1 mcpp-index Six-row huxerui.huxerui and huxerui.live2d, so libraries declare ranges P3

A. Engine (mcpp)

A1. One identity, a different linkage per row — P1

Measured. HuxerUI's framework must be a shared library on Android and static elsewhere. The Java host loads libhuxerui.so by name, and CMake's _huxerui_select_framework_target refuses a static framework on Android (cmake/HuxerUILibraries.cmake#L14-L28). The application manifest can say this three ways:

Application manifest Result on x86_64-linux-android
[dependencies] huxerui.huxerui = { path = "../../.." } and [target.'cfg(env = "android")'.dependencies] huxerui.huxerui = { path = "../../..", linkage = "shared" } Builds, and the Android linkage is dropped silently. There is no libhuxerui.so; the application's .so has no NEEDED libhuxerui.so and defines all 54 Java_org_huxerui_* symbols itself. No diagnostic.
the same [dependencies] line and [target.'cfg(env = "android")'.dependencies] huxerui.huxerui = { linkage = "shared" } Refused, with a message about something else (below).
no [dependencies] line; cfg(env = "android") with linkage = "shared" plus cfg(not(env = "android")) without it Works: bin/libhuxerui.so, the application has NEEDED libhuxerui.so and RUNPATH $ORIGIN. This is what HuxerUI ships.

The second spelling is rejected like this:

warning: [dependencies] huxerui.huxerui.linkage = 'shared' is not a version RANGE (version: not a number ('shared')), so it is used as an exact index key: …
error: dependency 'huxerui.huxerui.linkage': no package found for exact selector
  tried: (huxerui.huxerui, linkage)

Why.

  • The spelling that works needs two complementary predicates for one dependency, in every application and every template.
  • The spelling people try first succeeds, and it produces the wrong artefact on exactly the row where linkage matters. An APK built from it carries no libhuxerui.so for the Java host's System.loadLibrary("huxerui").
  • Any library with a JNI or dlopen contract on one platform is in the same position.

Proposed.

  1. Merge conditional linkage. When a conditional declaration names an identity that is also declared unconditionally with the same reference, merge them: the conditional linkage applies on the rows its predicate selects. If merging is unwanted, refuse and name both tables. Never drop it silently.
  2. Accept a modifier-only table. In a conditional dependency table, read { linkage = "shared" } (no version, path or git) as "the declaration above, with this linkage on these rows". If that shape stays refused, say so, instead of parsing linkage as a package name.
  3. Let a dependency state the need. Follow the requires_abi shape (docs/22): [target.'cfg(env = "android")'] requires_linkage = "shared". The root still decides. A build whose resolved linkage differs is refused before compiling, naming the package and the selector.

CI check (ubuntu-24.04, xim:android-ndk).

  • Fixture: an application plus one lib dependency.
  • Spellings 1 and 2: after mcpp build --target x86_64-linux-android, bin/lib<dep>.so exists and llvm-readelf -d of the application's .so shows NEEDED lib<dep>.so. The host build has no lib<dep>.so.
  • Proposal 3: the dependency states requires_linkage and the root does not; the build exits non-zero with the named refusal.

A2. A dependency key that names a different identity than its manifest — P1

Measured. In mcpp/examples/03-library, both edges point at the same directory, whose manifest declares namespace = "huxerui", name = "huxerui":

  • the application declares huxerui.huxerui = { path = "../../.." } (per row, as in A1);
  • its component library declared huxerui = { path = "../../../.." }, which normalises to (mcpplibs, huxerui) (docs/05).

The graph therefore holds two packages built from one directory, and the scanner fails:

error: scanner errors:
  …/HuxerUI/modules/huxerui.cppm: module 'huxerui' already provided by …/HuxerUI/modules/huxerui.cppm

This happened on the macOS and Windows rows in CI (run 34772646907) and reproduces on Linux. The message names neither dependency edge; the fix (spell the library's key huxerui.huxerui) was found by reading every manifest in the graph.

Why. Every ecosystem library declares the framework it builds on. One key written without the namespace, in any library of the graph, breaks every application that also names the framework, and the diagnostic names a module rather than a manifest line.

Proposed.

  • Check the key against the manifest. Read a path or git dependency's identity from the manifest it resolves to. A key that normalises to a different identity is reported, for example as dependency/identity-mismatch: the key, what it normalises to, and what the manifest declares. Then either refuse, or take the manifest's identity; pick one rule and state it.
  • Report duplicates before scanning. Two identities that resolve to one canonical directory, or to one git URL and commit, are reported before scanning, naming both requesters.

CI check (ubuntu-24.04).

  • Mismatch fixture: the application declares ns.dep = { path }; its library declares dep = { path } for the same directory, whose manifest is ns.dep. The build shows the named diagnostic, not a scanner error.
  • Matching keys: the same fixture builds.

A3. Shared libraries the graph built are not staged; Mach-O closures are not walked — P1

Measured.

What the CMake path does. _huxerui_deploy_runtime (cmake/HuxerUIRuntimeDependencies.cmake#L640) runs at install time:

  1. file(GET_RUNTIME_DEPENDENCIES) finds the dependencies.
  2. Dylibs outside /System/Library/ and /usr/lib/ are copied into Contents/Frameworks.
  3. install_name_tool rewrites names to @rpath/…, deletes the old rpaths, and adds @executable_path/../Frameworks to the executable and @loader_path/… to libraries.
  4. codesign --force --sign - runs on each file and on the bundle.
  5. codesign --verify --deep --strict checks the result.

The same function copies DLLs beside the executable on Windows and fills lib/ with patchelf on Linux.

Proposed.

  1. Stage what the graph built. Stage every shared library the graph built for the artefact, on every row, however the closure is discovered: those libraries are declared, not discovered.
  2. Walk with the static readers. Use needed_names (ELF, PE and Mach-O) instead of running the program. Cross rows then get a real walk, and closure = walked means complete.
  3. Mach-O specifics.
    • Shared targets get the install name @rpath/lib<name>.dylib by default.
    • A program packed into a bundle gets LC_RPATH @executable_path/../Frameworks. Setting this at link time, as the §4 note suggests, may make a load-command editor unnecessary.
    • Any rewrite of a Mach-O file is followed by an ad-hoc re-sign, because arm64 refuses an invalidated signature.

CI check.

  • ubuntu-24.04: pack A1's fixture for x86_64-linux-android; the stage manifest lists lib/lib<dep>.so.
  • macos-15: an application with a linkage = "shared" dependency, packed with mcpp pack --format app:
    • Contents/Frameworks/lib<dep>.dylib exists;
    • otool -L Contents/MacOS/<exe> names @rpath/lib<dep>.dylib, and otool -l shows LC_RPATH @executable_path/../Frameworks;
    • codesign --verify --deep --strict passes, and Contents/MacOS/<exe> exits 0.

A4. No SONAME / install name for a dependency made shared by linkage — P3

Measured. libhuxerui.so, produced by linkage = "shared" for x86_64-linux-android, has no DT_SONAME. The application records NEEDED libhuxerui.so from the file name, plus RUNPATH $ORIGIN. It loads on Android. [targets.<n>] soname exists, but a dependency made shared by its consumer's linkage has nowhere to state one.

Proposed. A target resolved Shared through linkage defaults to the SONAME lib<target>.so, and to the Mach-O install name from A3. [targets.<n>] soname still overrides it.

CI check.

  • ubuntu-24.04: llvm-readelf -d bin/lib<dep>.so shows SONAME lib<dep>.so.
  • macos-15: otool -D prints @rpath/lib<dep>.dylib.

A5. A test program from several sources; test roots; labels — P2

Measured.

  • mcpp's model. In docs/08, a test is tests/**/*.cpp, and one .cpp makes one program. [build] sources link into every test program.

  • Why HuxerUI needs a separate package. Its tests live in mcpp/huxerui-tests, because the repository's tests/ belongs to CMake: 111 files, some driven by CMake or Python, some linking the CLI, some per platform.

  • CMake's shape. CMake builds one executable per suite from a list of sources, with labels:

    • HuxerUIUnitTests (common unit, 19 files);
    • HuxerUIRuntimeTests (common runtime, 41 files);
    • HuxerUIUiTests and HuxerUILibraryTests;
    • per-platform suites labelled platform plus native or portable.

    The release workflow selects them with ctest -LE platform, -L portable and -L native.

  • Measured today. The 41 runtime files compile and pass unmodified in the mcpp package:

    All tests passed (41014 assertions in 1091 test cases)
    test result ok. 2 passed; 0 failed; finished in 82.14s (build 72.52s + run 3.38s)
    

    They only fit by being added to [build] sources, which puts them into the same program as the unit suite, and into the smoke program too.

Why.

  • One process per suite, as CMake has, so a suite's global state cannot leak into another suite.
  • Selection by label in CI.
  • Not linking every test source into every test program.
  • Any project whose tests/ directory already belongs to another build system has the same problem.

Proposed.

[test]
roots = ["tests-mcpp"]          # or: discover = false

[[test]]                        # a named program built from several sources
name    = "runtime"
sources = ["../../tests/runtime/*.cpp", "../../tests/support/builtin_resources.cpp"]
defines = ["…"]
labels  = ["common", "runtime"]
timeout = 120

Add mcpp test --label <l> and --exclude-label <l>, and put labels into --message-format json.

CI check (ubuntu-24.04). A fixture with two [[test]] programs whose sources do not overlap:

  • each defines a marker symbol, and nm finds each marker only in its own binary;
  • mcpp test --label a runs one program;
  • the JSON records carry the labels.

A6. Windowless tests on the Android emulator and the iOS simulator — P3, verification first

Measured. Both runners already accept a bare executable:

  • adb-run pushes it to /data/local/tmp, runs it with one adb shell, and parses the status back;
  • simctl-run runs it with simctl spawn.

mcpp test --target x86_64-linux-android and --target aarch64-ios-sim should therefore run windowless test programs, but this is unmeasured. The likely gap is the files a test reads: HuxerUI's tests compile a resource package's host-absolute path in (HUXERUI_TEST_BUILTIN_RESOURCE_PACKAGE), and no runner transfers it. CMake has platform-specific carriers for the smoke (an instrumentation .so on Android, an XCTest bundle on iOS). mcpp could cover more of the suite with bare executables.

Proposed. Measure it and document the result. If files are the gap, let a test's deployed files (mcpp::deploy) travel with it, found through a relative path (the working directory, or MCPP_TEST_DATA_DIR).

CI check. A fixture test reads a deployed file and exits 0 or 1 on its content:

  • ubuntu-24.04: on an x86_64 API 34 emulator (KVM), mcpp test --target x86_64-linux-android passes.
  • macos-15: mcpp test --target aarch64-ios-sim passes.

A7. A payload's recipe-dependency closure is invisible to build programs — P2

Measured.

  • What HuxerUI asks for. Its Linux build asks pkg-config for gtk4, epoxy, gio-2.0 and libsoup-3.0.

  • What happens with only those declared. Declaring just the four direct payloads (xim:gtk4, xim:libepoxy, xim:libsoup, xim:glib) is not enough. The 33 payloads that make up their .pc closure are invisible to the build program, even though they are installed on the machine (xpkg_dir answers ""), and pkg-config fails:

    Package 'pango', required by 'gtk4', not found
    Package 'pangocairo', required by 'gtk4', not found
    Package 'gdk-pixbuf-2.0', required by 'gtk4', not found
    Package 'cairo', required by 'gtk4', not found
    
  • The workaround. HuxerUI declares and exactly pins all 37 payloads in mcpp/huxerui-build-rules-gtk/mcpp.toml, and updates that closure by hand whenever a recipe changes.

Why. Any package that consumes a pkg-config stack through xim payloads (GTK, GStreamer, FFmpeg) has to copy its closure into its own manifest.

Proposed.

  • Answer for the closure. xpkg_dir answers for the recipe-dependency closure of a declared payload, at the versions actually installed. Those versions are recorded in mcpp.lock, so two machines still build the same binary.
  • Expose pkg-config directories. The build program receives the closure's lib/pkgconfig and share/pkgconfig directories, as mcpp::pkg_config_path() or MCPP_PKG_CONFIG_PATH.
  • Related: feat/RFC: [system_deps] —— 经 pkg-config 集成宿主系统库 #493 is the host-system counterpart.

CI check (ubuntu-24.04). A fixture declares only xim:gtk4:

  • its build program runs pkg-config --cflags --libs gtk4 against the provided path and exits 0;
  • xpkg_dir("xim", "pango") is non-empty;
  • mcpp.lock records pango's version.

A8. Enumerating the resolved graph, and package metadata, from a build program — P3

Measured.

  • mcpp::dep_dir / MCPP_DEP_<NAME>_DIR answer by name, for each declared dependency.
  • A build program cannot enumerate the resolved graph, transitive dependencies included, and cannot read anything a dependency states about itself.
  • CMake's huxerui_use_library merges each HuxerUI library's resource package into the application's. On the mcpp path, an application cannot find which of its transitive dependencies carry resources, so a library's resources stay unmerged. Lib-Live2D's string catalogue is today's case.

Proposed.

  • Enumerate the graph. mcpp::dependencies() returns, for each package of the resolved graph: identity, version, kind, resolved linkage, manifest directory and requesters.
  • Read package metadata. A [package.metadata.<tool>] table is readable from consumers' build programs (for example mcpp::dep_metadata(identity, "<tool>.<key>")), and editing it re-runs those build programs.

CI check (ubuntu-24.04). Fixture: application → lib A → lib B, where B declares [package.metadata.demo] resources = "res".

  • The application's build program prints the graph and B's value, and the check asserts both.
  • Editing B's metadata re-runs the application's build program.

A9. A platform floor a dependency needs — P3

Measured. min_api_level ([target.<triple>]) and ios_deployment_target ([build]) belong to the artefact, so only the root states them. HuxerUI's manifests state API 23 and iOS 15.0. Every application and template restates min_api_level = 23 on both Android rows and ios_deployment_target = "15.0", because a dependency has no way to say what it needs.

Proposed. Follow the requires_abi shape:

[target.'cfg(env = "android")']
requires_platform = { api_level = 23 }

[target.'cfg(os = "ios")']
requires_platform = { deployment_target = "15.0" }

A root whose resolved floor is lower is refused before compiling, naming the package. An unset iOS floor resolves to the SDK version (#633) and is compared as that.

CI check.

  • ubuntu-24.04: the dependency requires 23 and the root states 21, so the build is refused by name. With the root at 24, the output shows Target x86_64-linux-android → x86_64-unknown-linux-android24.
  • macos-15: the same for aarch64-ios-sim.

A10. --toolchain on run / test / pack — P3

Measured. mcpp build accepts --toolchain; mcpp run, mcpp test and mcpp pack answer error: unknown option: --toolchain. HuxerUI's clang CI leg therefore switches the whole job with mcpp toolchain default llvm@22.1.8.

Proposed. Accept --toolchain on run, test and pack, meaning what it means for build.

CI check (ubuntu-24.04). mcpp test --toolchain llvm@22.1.8 and mcpp pack --toolchain llvm@22.1.8 compile with clang, as mcpp why toolchain or the compile lines show, while the default toolchain is gcc.


B. mcpp:plugins

B1. dist-apple carries graph-built dylibs — P1 if it lands before A3

The plugin-side counterpart of A3, as dist-apk 0.9.3 did for Android:

  1. Read LC_LOAD_DYLIB with llvm-otool -L or llvm-objdump --macho --dylibs-used.
  2. Copy the dylibs the graph built from bin/ into Contents/Frameworks/ (on iOS, Frameworks/).
  3. Add @executable_path/../Frameworks (on iOS, @executable_path/Frameworks) with llvm-install-name-tool.
  4. Re-sign ad hoc, because arm64 refuses an invalidated signature.

CI check. The macos-15 check of A3, and the same for aarch64-ios-sim with simctl-run launching the bundle.

B2. dist-apple: DMG — P2

What the CMake path does. huxerui package macos (tools/huxerui_cli/platform/macos.cpp#L86-L101):

  1. installs the component into a staging directory;
  2. adds an Applications → /Applications symlink beside the bundle;
  3. runs hdiutil create -volname <name> -srcfolder <staging> -format UDZO -ov <target>-<version>.dmg.

On the mcpp path dist-apple stops at the .app.

Proposed. mcpp pack --format dmg on the macOS row: the .app the member already produces, plus the symlink, through hdiutil from the base macOS install (as ditto and codesign are today). Options for the volume name, and later a background image.

CI check (macos-15).

  • hdiutil verify <dmg> passes.
  • After hdiutil attach -nobrowse -readonly -mountpoint "$RUNNER_TEMP/m" <dmg>, <Name>.app/Contents/MacOS/<exe> exists and Applications is a symlink to /Applications.
  • hdiutil detach succeeds.

B3. dist-apple: a default macOS --format app runner that returns the program's status — P2

Measured.

  • Why HuxerUI declares a runner. It resolves resources through NSBundle, so mcpp run --format app must run the bundle; HuxerUI's manifests declare [target.aarch64-macos] runner = ["open", "-W"].
  • Confirmed in CI. mcpp hands the .app path to that argv: a stub open placed first on PATH received -W and /Users/runner/…/huxerui_example_import.app (run 34775426211, step "Hand the bundle to the manifest's runner").
  • The problem. open returns its own status, not the program's, and the program's stdout and stderr do not reach the terminal. mcpp run --format app cannot fail when the application does.

Proposed. dist-apple supplies the default runner for --format app on *-macos through mcpp::runner, which reaches the consumer (docs/41). It names a program (C1) that runs the bundle's CFBundleExecutable in the foreground, with stdio attached, and returns its status. Applications then drop the table; an override stays possible.

CI check (macos-15). A fixture .app whose program prints 1-2-3 and exits 7, with no runner in its manifest: mcpp run --format app prints 1-2-3 and exits 7.

B4. dist-wix: a Burn bundle (Setup.exe) — P3

What the CMake path does. huxerui_add_windows_installer() (cmake/HuxerUIWindowsInstaller.cmake#L116):

  • builds platform/windows/windows_installer.cpp, a WiX Burn bootstrapper application whose interface is written with HuxerUI;
  • links it against the WiX native SDK, which it restores from NuGet with pinned sha256 (C3);
  • runs wix build on a bundle that chains the MSI, producing <target>-Setup-<version>.exe.

wix.exe needs .NET; CMake probes for Microsoft.NETCore.App 6.0 or newer.

Measured on the mcpp side. dist-wix produces the MSI. xim:wix 5.0.2 already carries x64/burn.exe beside wix.exe (dist/wix.cppm, wix_payload_exe).

Proposed. mcpp pack --format setup (or bundle) builds a Burn bundle that chains the MSI dist-wix builds:

  • by default with the standard bootstrapper (WixToolset.BootstrapperApplications.wixext);
  • optionally with a custom bootstrapper program the package itself builds (options.bootstrapper = "<target>", reached as ${mcpp.target_file:<target>}).

CI check (windows-2022).

  • Setup.exe /quiet /norestart /log setup.log exits 0, and the installed executable is present.
  • Setup.exe /uninstall /quiet exits 0, and the executable is removed.
  • The same passes with a minimal custom bootstrapper fixture (C3).

B5. dist-apk: an Android App Bundle — P3

Measured. dist-apk builds and signs an APK: the published debug key by default, or a release keystore given as a package with an alias and a password environment variable. Google Play takes an Android App Bundle for new applications. An application's Gradle shell produces one with bundleRelease; mcpp pack has no aab.

Proposed. mcpp pack --format aab:

  • aapt2 link --proto-format builds the resources;
  • bundletool build-bundle packs the module zip (manifest/, dex/, lib/<abi>/, assets/, res/, resources.pb);
  • jarsigner signs it with the keystore options the APK already takes;
  • several --target values make one bundle, as they make one universal APK today.

This needs a bundletool payload; it is a jar, run by the xim:jdk-* payload the member already uses.

CI check (ubuntu-24.04).

  • bundletool validate --bundle app.aab passes.
  • bundletool build-apks --mode=universal produces universal.apk, which contains lib/x86_64/lib<app>.so.
  • bundletool dump manifest shows the application id.

B6. rules-metal, tools-esbuild (carried from #622 B4) — P3

Measured. Lib-Live2D's mcpp/cubism package writes its Metal shader edges by hand: .metal → .air → .metallib through xcrun, 477 outputs checked in its CI. Its build program also bundles the TypeScript Web bridge with esbuild from a payload. Both are the same kind of rule as rules-spirv.

Proposed.

  • rules-metal: actions with depfiles, choosing the macOS, iOS or iOS-simulator SDK from the target.
  • tools-esbuild: one action with glob inputs and a declared output.

CI check.

  • macos-15: two .metal files produce one .metallib each for aarch64-macos and for aarch64-ios-sim; touching one .metal rebuilds only its output.
  • ubuntu-24.04: a TypeScript entry produces a bundled .js that exports the entry's symbol.

C. xim-pkgindex (payloads and runner programs)

C1. A macOS app-bundle runner program — P2

For B3: macapp-run <App.app> [args…].

  • It reads CFBundleExecutable from Contents/Info.plist.
  • It runs <App.app>/Contents/MacOS/<exe> in the foreground with stdio attached; NSBundle still resolves the bundle.
  • It returns the program's status.

It lives in apple-simulator-tools or in a new apple-app-tools.

CI check (macos-15). The B3 check, plus a missing or non-bundle operand producing a named error and exit code 2.

C2. simctl-run: output lost under --console-pty; exit status unmeasured — P2

Measured.

Proposed.

  • Measure --console (pipes) and --stdout=<file> --stderr=<file> followed by waiting for the process to exit, against --console-pty.
  • Keep the one that never loses output, print the output, and return the application's own status.
  • Record the measurement in the recipe.

CI check (macos-15).

  • The plugins' iOS fixture runs 20 times in a matrix, and every run prints 1-2-3.
  • A fixture that exits 7 makes mcpp run --target aarch64-ios-sim --format app exit 7.

C3. WiX Burn native SDK payloads — P3

For B4 with a custom bootstrapper, two packages as HuxerUI's CMake path restores them (cmake/HuxerUIWindowsInstaller.cmake#L40-L112):

  • WixToolset.BootstrapperApplicationApi: BootstrapperApplication.h, build/native/v14/x64/balutil.lib, runtimes/win-x64/native/mbanative.dll;
  • WixToolset.DUtil: dutil.h, build/native/v14/x64/dutil.lib.

Also state whether xim:wix carries the .NET runtime wix.exe needs, or which payload does.

Open question for the check. Can mcpp's Windows toolchain (clang, lld-link) link the MSVC-built v14 static libraries? If not, windows = "msvc@system" is the path (#604 is closed).

CI check (windows-2022). A minimal bootstrapper program that calls one balutil and one dutil function compiles and links under mcpp with the payloads, and B4's bundle builds with it.

C4. Resolving an unmerged recipe from a consumer's CI — P2

Measured.

  • The documented limitation. docs/32 says: "A payload's own CI cannot verify that a consumer resolves it".
  • The workaround used. HuxerUI's and Lib-Live2D's CI needed recipes that were still in review (android-platform 36-r2, glew, esbuild). They bridged them by copying the .lua into ~/.mcpp/registry/data/xim-pkgindex/pkgs/<x>/ and adding an entry to .xlings-index-cache.json, which mcpp index update wipes.
  • --add-xpkg did not help. xlings config --add-xpkg registered the recipe under local:, which does not satisfy an xim: address (measured on Sept 12). xlings 2026.9.12.1 also has config --index-repo <NS:URL>, and MCPP_HOME keeps xim-index-repos/ and xim-pkgindex-local/; whether any of them can serve an xim: address from a PR branch was not evaluated.

Proposed. One documented and CI-tested way for an mcpp build to resolve xim: recipes from an unmerged xim-pkgindex branch:

  • either an index overlay under MCPP_HOME, or xlings' --index-repo honoured by mcpp;
  • it survives mcpp index update;
  • mcpp why deps reports it as coming from the overlay.

CI check (xim-pkgindex PR workflow, ubuntu-24.04).

  • A recipe added on the PR branch resolves through the overlay, and a consumer fixture that declares it builds.
  • mcpp index update followed by mcpp build still resolves it.
  • Without the overlay, the build refuses and names the index revision.

D. mcpp-index

D1. Six-row huxerui.huxerui and huxerui.live2d, so libraries declare ranges — P3, after the upstream release

State.

Proposed. Once HuxerUI releases its six-row manifest:

  • publish the next huxerui.huxerui with six platforms, and publish huxerui.live2d;
  • libraries declare huxerui.huxerui = "^0.3", and templates declare published versions.

CI check (mcpp-index PR workflow).

  • mcpp xpkg parse passes on both descriptors.
  • A consumer fixture has huxerui.live2d's ^0.3 requirement and an application pin of 0.3.x. It builds for the host, for x86_64-linux-android and for wasm32-emscripten, and mcpp.lock records exactly one huxerui.huxerui.

E. Not asked of mcpp (HuxerUI's side, recorded for the boundary)

  • Android static framework. HuxerUI needs a Java-side switch, because HuxerUIView's static initialiser loads libhuxerui.so unconditionally. Measured: with the framework static, all 54 Java_org_huxerui_* JNI functions are defined and exported by the application's .so. Beyond A1, nothing is needed from the engine.
  • Runner tables. huxerui.rules can supply the Android and iOS-simulator runners through mcpp::runner (docs/41), which removes the [target.*] runner tables from application manifests. macOS waits for B3 and C1.
  • Test suites.
    • tests/runtime moves into mcpp/huxerui-tests; measured to pass, and cleaner with A5.
    • tests/runtime/ui_testing.cpp needs a resource-compiler fixture.
    • tests/runtime/libraries.cpp needs tests/libraries/root_service as an mcpp package.
  • Leftover workaround. The c++23 workaround in HuxerUI's examples was for clang on Windows hardcodes importStdMinLevel = 23 instead of probing the MSVC STL #603, which is closed, and can be removed.
  • Installer UI. The installer interface program for B4 is HuxerUI's own to build.

F. CI checks at a glance

Item Runner Fixture Passes when
A1 ubuntu-24.04 + android-ndk app + lib, three spellings NEEDED lib<dep>.so on Android for spellings 1 and 2; named refusal for requires_linkage
A2 ubuntu-24.04 app + lib, mismatched keys for one directory named identity diagnostic before scanning
A3 ubuntu-24.04, macos-15 app + shared lib stage manifest lists lib<dep>.so; .app has Frameworks/lib<dep>.dylib, rpath, valid signature, runs
A4 ubuntu-24.04, macos-15 shared lib via linkage SONAME / @rpath install name
A5 ubuntu-24.04 two [[test]] programs disjoint symbols; --label selects; labels in JSON
A6 ubuntu-24.04 (emulator), macos-15 (simulator) test reading a deployed file passes on both
A7 ubuntu-24.04 only xim:gtk4 declared pkg-config gtk4 succeeds; lock lists the closure
A8 ubuntu-24.04 app → A → B with metadata graph and metadata printed; metadata edit re-runs
A9 ubuntu-24.04, macos-15 lib with requires_platform refusal below the floor; triple at or above it
A10 ubuntu-24.04 any package test and pack honour --toolchain
B1 macos-15 as A3 as A3, plus iOS simulator launch
B2 macos-15 any app hdiutil verify; mounted layout
B3 + C1 macos-15 app exits 7, prints 1-2-3 mcpp run --format app exits 7 and prints
B4 + C3 windows-2022 app + MSI (+ minimal bootstrapper) silent install and uninstall
B5 ubuntu-24.04 any app bundletool validate; universal APK contents
B6 macos-15, ubuntu-24.04 .metal sources; TypeScript entry outputs exist; incremental rebuild
C2 macos-15 plugins' iOS fixture ×20 output every time; exit status propagated
C4 ubuntu-24.04 recipe on a PR branch resolves through the overlay, survives index update
D1 ubuntu-24.04 huxerui.live2d on ^0.3 one huxerui.huxerui in mcpp.lock on three rows

G. Suggested order

  1. A1, A2. Every application and ecosystem library hits them. The engine changes are small, and the fixtures run on ubuntu.
  2. A3, or B1 first. The staged tree becomes truthful on Android, and macOS gains a shared mode.
  3. B3 + C1, C2. mcpp run reports the program's own status on macOS and on the iOS simulator, which makes CI results trustworthy.
  4. A5, A7, C4. Test parity with CMake, the 37-line GTK table, and testing recipe PRs.
  5. B2, then C3 + B4, then B5. The distributables the CMake path still owns alone.
  6. A4, A6, A8, A9, A10, B6, D1.

H. Evidence

Activity

  1. Sunrisepeak commented on Sep 14, 2026

    @Sunrisepeak
    MemberAuthor

    中文摘要

    二十一项已按归属全部处理完毕并发布:引擎 mcpp 2026.9.14.2(mcpp#637)及补丁 2026.9.14.3(mcpp#638)、官方插件 mcpp:plugins 0.10.0(mcpp-plugins#24)、载荷 xim-pkgindex#838、索引 mcpplibs/mcpp-index#426。分类与决定见设计记录 .agents/docs/2026-09-14-634-cmake-parity-items-by-home.md,计划与台账见 .agents/docs/2026-09-14-634-implementation-plan.md。

    • 与 issue 的主要不同:A1 用「条件声明替换无条件声明」加按行 [target.<sel>.targets.<n>] kind,不新增 requires_linkage;A5 用 [test] discover 加「多源套件即包」,不做 [[test]]/labels;A7 实测全新 home 的 SubOS 视图已能解析 gtk4(只缺 libxml2 的 .pc,已补),37 条 pin 不需要;A8 不改引擎;A9 由引擎陈述平台下限事实、依赖用 version-floor 拒绝,不抬高下限;C4 不改 xlings。
    • issue 之外实测并修复的缺陷:Android 测试程序因找不到 NDK 静态 libc++ 而全部依赖 libc++_shared.so(模拟器上无一能加载);rpath 中 @executable_path 被锚定到包目录;simctl launch 永远返回 0;dist-apk 第二次打包丢依赖库、多 ABI 失败;嵌套测试加载不到图构建的共享库;依赖与根构建程序同时提供同名 runner 时拼成一条 argv;在执行过 xlings subos use 的 shell 中 mcpp 的 registry 把工具与载荷装进另一个 subos(沙箱验证中发现,2026.9.14.3 修复)。
    • HuxerUI 侧可以做的变更列在文末;本次未修改 HuxerUI。

    What landed, per item

    Each item was first located in the code and its premise measured (local probes, and the temporary measurement PR mcpp#635 on macos-15, an API 34 emulator and windows-2022); the decisions and every reading are in .agents/docs/2026-09-14-634-cmake-parity-items-by-home.md, and the task ledger with the test that closes each task is .agents/docs/2026-09-14-634-implementation-plan.md. Routing followed one rule: the engine takes general capabilities, the official plugins take distribution formats and build rules, payloads take runner programs.

    A. Engine (mcpp 2026.9.14.2, mcpp#637; patch 2026.9.14.3, mcpp#638)

    Item What landed What HuxerUI can write CI check
    A1 A matching [target.<sel>.dependencies] declaration replaces the unconditional declaration of the same identity on its rows (the rule every conditional scalar already follows; no manifest among 509 scanned declared a key in both tables). A package states its form per row with [target.<sel>.targets.<n>] kind = "shared"; a consumer's linkage = "static" against it is warned naming that line, and --strict refuses. The modifier-only spelling huxerui.huxerui = { linkage = "shared" } is reported with the dependency restated with its source. A misspelled [target.<sel>] sub-table is reported. Not requires_linkage: every other requires_* key refuses, and kind = "shared" already selects the shared form. The framework writes [target.'cfg(env = "android")'.targets.huxerui] kind = "shared" once; applications keep one unconditional dependency line, or write the conditional table with the source when an application rather than the framework decides. e2e 677, 678
    A2 A path/git dependency takes the identity its manifest declares, with one warning per declaring edge naming the requester, the key and both identities; one directory reached as fw and huxdemo.fw is one package compiled once. Two identities over one source (a manifest without a namespace) are refused before scanning; the scanner's duplicate-provider message names both packages. The three keys in huxerui-build-rules, huxerui-tools, huxerui-tests can state huxerui.*; until then they build with the warning. e2e 679
    A3 mcpp pack reads the Android and Mach-O closures from the files. Android stages lib/ (per lib/<abi>/ with several --target), graph libraries and libc++_shared.so included; Mach-O stages dylibs beside the program, with no load-command edit. <tree>.stage-manifest gains `needs<path platform unresolved>lines;walkedis written only for a complete closure, anddir/tarrefuse an incomplete one naming the names. rpath entries starting with@executable_path, @loader_pathor@rpath` are no longer anchored to the package directory (a defect found while measuring B1).
    A4 An ELF shared library without a declared soname records its file name as SONAME (bionic enforces one from API 23); a -soname in ldflags still wins. The Mach-O half was already @rpath/<file>. Nothing. e2e 667, 669
    A5 [test] discover = [globs] (the [build] sources vocabulary, ! exclusions, [] disables); names stay path-relative. Not [[test]] with exclusive sources and labels: the target model compiles objects once and links them into every target, and sends a divergent program to a workspace member, so a suite built from several sources is a package selected with mcpp test -p. discover = ["checks/**/*.cpp"] for a tests/ another build owns; one package per multi-source suite. e2e 673
    A6 On the emulator no test could load at all: the engine did not find the NDK's static libc++, degraded to libc++_shared.so, and the device has none. The static runtime is now located by asking the driver for the effective target (the NDK's libc++.a linker script's archives also reach --exclude-libs: 161 exported dynamic symbols became 4). Every runner of mcpp run and mcpp test receives MCPP_RUNTIME_FILES, a TAB-separated list of the deployed files and linked shared libraries, and adb-run pushes them beside the program. The iOS simulator reads the host filesystem and needed nothing. Drop -static-libstdc++ from the Android test rows. ci-linux-e2e job android (API 34 emulator: runs and reads_relative pass)
    A7 Measured on a fresh home: the SubOS pkg-config view already resolves gtk4 completely; only libxml-2.0.pc was missing (the recipe had no pkg-config handling, fixed in xim-pkgindex#838). The 37 pins are unnecessary. Build programs get mcpp::pkg_config_libdir(). The sandbox verification found that inside a shell that ran xlings subos use, mcpp's registry installed payloads into a SubOS of the shell's name, so the view the accessor names stayed empty; 2026.9.14.3 fixes it (mcpp#638). Declare the direct payloads and run PKG_CONFIG_LIBDIR=<mcpp::pkg_config_libdir()> pkg-config gtk4. e2e 683; sandbox
    A8 No engine change. A library's mcpp::deploy resources already reach the application from two levels down, and mcpp why deps now prints the resolved graph (below). A graph API for build programs waits for a need transitive deploy cannot answer. Each library deploys its resource package under a common directory. -
    A9 The engine states android.api-level, ios.deployment-target and macos.deployment-target as facts; a dependency writes the ordinary [[runtime.requirements]] kind = "version-floor" against one and is refused before compiling, naming the key that set the value ("this build targets 21", set by: [target.x86_64-linux-android] min_api_level). Not raising the floor: it is already in the compiler's --target when the graph is walked, and an application's floor is its own decision. value = "android.api-level >= 23" in the framework. e2e 680
    A10 run, test and pack accept --toolchain; a refused value is named as the command line's. --toolchain llvm@22.1.8 instead of MCPP_TOOLCHAIN. e2e 671
    X (new) resolution.json records graph (each package, each request's key and declaring table, a library's link form and why) and mcpp why deps prints it; path dependencies, which mcpp.lock does not record, are listed. - e2e 682

    B. mcpp:plugins 0.10.0 (mcpp-plugins#24)

    Item What landed What HuxerUI can write CI check
    B1 dist-apple places the dylibs the stage manifest names in Contents/Frameworks/ (Frameworks/ on iOS), links the program with @executable_path/../Frameworks through mcpp::link_flag, so no file is edited after the link, and signs a bundle without an identity ad hoc, frameworks first. Measured: codesign --verify --deep --strict is valid; the program exits 7 through the framework and 134 with "Library not loaded" without it. Nothing beyond the dependency. macOS job, app-framework-consumer
    B2 --format dmg: the bundle beside an Applications link in a UDZO image (hdiutil verify valid); refused on iOS. mcpp pack --format dmg macOS job
    B3 Engine: mcpp run --format <f> uses the runner named <f> (e2e 674). Plugin: on macOS dist-apple supplies the runner named app, which is xim:macapp-run (C1), installed for mcpp run only; mcpp run --format app returns the program's status (exit 7 measured). A manifest runner of that name wins. Remove the open -W default runner. macOS job
    B4 dist-wix --format setup: a Burn bundle chaining the MSI, with WiX's stock bootstrapper application from xim:wix 5.0.2-1; the MSI wix burn extract takes out of the bundle is byte-identical to the built one. options::bundle_output = "setup.exe" is refused before wix runs, since wix refuses that name (WIX0388). mcpp pack --format setup Windows job, msi-consumer
    B5 dist-apk reads the closure the engine stages (lib/ for one --target, lib/<abi>/ for several) in place of its own NEEDED walk and stamp: a second pack keeps the dependency's library, and one APK carries every ABI (aapt2 reports native-code: 'arm64-v8a' 'x86_64'; apksigner verifies). A stage without needs lines comes from an older engine and is refused naming 2026.9.14.2; every refusal is also a mcpp::warning, because the engine discards a build program's output when it exits 0. --format aab builds an App Bundle with xim:bundletool (bundletool validate passes; a universal APK carries the dependency's library). mcpp pack --target aarch64-linux-android --target x86_64-linux-android --format apk, or --format aab Linux job, apk-consumer-shared
    B6 New member rules-metal (.metal): one xcrun metal action per shader with -MMD, one xcrun metallib action per library, deployed under metallib/, which dist-apple maps into the bundle's resources; a missing SDK and a missing compiler are refused naming the command. A header edit recompiled only the shaders that include it. tools-esbuild stays in the project. features = ["rules-metal"] in place of the project's Metal rule macOS job, metal-consumer
    C2 (CI) The iOS fixture exits 7, and the simulator step asserts that status through simctl-run 0.3.0. - macOS job

    C. xim-pkgindex (xim-pkgindex#838, merged)

    Item What landed CI check
    C1 xim:macapp-run 0.1.0: macapp-run <X.app> [args] requires Contents/Info.plist, reads CFBundleExecutable and execs the executable, so output and status are the program's (exit 7 returned; abort 134). apple runners / macapp-run
    C2 apple-simulator-tools 0.3.0: a bundle whose executable does not load UIKit is installed and its installed executable spawned (measured: status returned, output kept 20 of 20; simctl launch returned 0 in 140 of 140 launches for an app exiting 7, and 0 for abort()); a UIKit bundle keeps simctl launch and says the status is simctl's. apple runners / simctl-run
    C3 wix 5.0.2-1: mbanative.dll anchored, WixToolset.BootstrapperApplications.wixext added, an install-verify job, and the recipe states the MSVC-ABI link with the Windows import libraries (both of mcpp's MSVC-ABI toolchains link and run DutilInitialize with them). wix build -o setup.exe is refused by WiX itself (WIX0388). wix bundle
    C4 Usage today: a fresh MCPP_HOME with [index.repos.xim] url = "<checkout>" in config.toml resolves an unmerged recipe and follows commits on the branch. Engine: the same table now reaches an existing home (and its removal restores the entry), and installations from it print the source. xim-pkgindex's own PR workflow builds consumer fixtures through the override. No xlings change was needed. engine e2e 681; consumer-through-index-override
    B5 support xim:bundletool 1.18.3 on jdk-temurin; android-platform-tools 37.0.1-4 (adb-run runtime files). bundletool, android runner / adb-run

    D. mcpp-index

    D1 follows HuxerUI's release (the descriptors are the project's to publish). mcpp.plugins 0.10.0 is indexed in mcpplibs/mcpp-index#426, and latest points at it.

    Found beyond the issue, all fixed

    1. Android dir/tar archives omitted their closure and said walked.
    2. Android test programs could not load on a device (the static libc++ lookup, above).
    3. dist-apk 0.9.3 dropped the dependency's library on a second pack, and refused two ABIs without showing why.
    4. The link-form degradation did not name the package's kind = "shared" statement.
    5. The version-floor refusal said "this machine has" for any fact.
    6. config.toml [index.repos.<name>] did nothing on an existing home.
    7. @executable_path/@loader_path/@rpath rpath entries were anchored to the package directory.
    8. A runner a dependency's build program supplies and the project's build program also emits became one argv (run-A.sh run-B.sh <artifact>); refused now, naming both.
    9. A test built from a subdirectory of tests/ could not load a graph-built shared library.
    10. simctl launch never reported an application's status; libxml2 published no .pc.
    11. Inside a shell that ran xlings subos use <name>, mcpp passed XLINGS_ACTIVE_SUBOS to its own xlings, which installed mcpp's tools and a project's payloads into registry/subos/<name> while mcpp reads subos/default (found in the sandbox; fixed in 2026.9.14.3, e2e 686).

    What HuxerUI can change now (not done here)

    • A1: the framework's per-row kind, or the conditional table with its source; remove the two complementary predicates.
    • A2: state huxerui.* in the three rule/tool/test manifests.
    • A6: remove -static-libstdc++ from the Android rows.
    • A7: remove the 37 GTK pins; declare the direct payloads.
    • A10 / B3: --toolchain instead of MCPP_TOOLCHAIN; drop the open -W default runner (it hands a plain mcpp run's program to open, which returns 0); dist-apple now supplies app.
    • A5: [test] discover and one package per multi-source suite.

    Verification

    • CI: mcpp#637 green on all 41 checks, including the new Android emulator job and the iOS simulator job; mcpp#638 green on all 40; mcpp-plugins#24 green on its three jobs against the released 2026.9.14.2 (run 34831041382); xim-pkgindex#838 and mcpp:plugins 0.10.0: dist-apk and dist-apple read the staged closure; dmg, aab and setup formats; rules-metal mcpplibs/mcpp-index#426 green.
    • Release: the eight mirrored engine assets are byte-identical on GitHub and GitCode, and the published xim index artifact names the release as latest; the plugins tag archive and its GitCode copy are byte-identical (one sha256), and the published mcpp-index artifact names 0.10.0 as latest on both hosts.
    • Sandbox: in xlings subos use m634-eco --sandbox, with the xlings and mcpp mirrors set to CN, the released 2026.9.14.3 installed through xlings install and mcpp:plugins 0.10.0 resolved from the index pass 22 of 22 checks (A1, A2, A3 and A6 on x86_64-linux-android, A4, A5, A7 with pkg-config gtk4, A9, A10, B3, X, and dist-apk through the index: two packs carry lib/x86_64/libfw.so, and one APK carries it for both ABIs), and C4 on that existing home passes 4 of 4. The same script on 2026.9.14.1 fails 18 checks. The first run, on 2026.9.14.2, failed the two pkg-config view checks, which is how item 11 above was found.
  2. speak-agent commented on Sep 20, 2026

    @speak-agent
    Member

    Closing: all twenty-one items were delivered and released, per the triage comment above, and the follow-up work has since shipped on top of them.

    Records: .agents/docs/2026-09-14-634-cmake-parity-items-by-home.md for the classification and the decisions, .agents/docs/2026-09-14-634-implementation-plan.md for the plan and the ledger.

    Where the delivery differs from the issue text is stated in that comment and is worth repeating here, because a reader coming back to this issue will otherwise look for shapes that were not built: A1 became "a conditional declaration replaces an unconditional one" plus per-row [target.<sel>.targets.<n>] kind, with no new requires_linkage; A5 became [test] discover plus "a multi-source suite is a package", with no [[test]] and no labels; A7 turned out not to need the 37 pins, because a fresh home's SubOS view already resolves gtk4 once libxml2's .pc was added; A8 needed no engine change; A9 has the engine state the platform floor as a fact and the dependency refuse through version-floor, rather than raising the floor; C4 left xlings untouched.

    Five defects found while implementing, none of them reported here, also landed: Android test programs depending on libc++_shared.so because the NDK static libc++ was not found (none of them loadable on the emulator); @executable_path in an rpath anchored to the package directory; simctl launch always returning 0; dist-apk losing dependency libraries on a second pack and failing on multiple ABIs; nested tests unable to load a graph-built shared library.

    This issue also carried #622's last open item (B4, as B6 here), so closing it closes that line too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions