이 트랙에 나온 건 전부 SwiftPM 패키지 하나, AriandelKit 안에 살아. 화살표는 한쪽으로만 가. CJSONC를 CMyPaint가 쓰고, 그걸 CAriandelSurface가 쓰고, 그걸 AriandelEngine이 쓰고, 그걸 실행 파일 ariandel-icon이랑 테스트가 써. (AriandelEngine은 mypaint_brush_*를 직접 부르니까 CMyPaint에도 바로 기대고, 가족 공용 키트의 CwkKitCore에도 기대.) 패키지가 내보내는 product는 딱 하나, AriandelEngine 라이브러리야. XcodeGen이 만드는 앱은 이 패키지에선 그 product 하나만 가져가. 나머지 링크는 가족 공용 키트의 CwkKitCore, CwkKitMedia, CwkKitWeb야. 앱의 Swift 파일 중 C 모듈을 import하는 것도 하나도 없고. C는 엔진이 가진 거지, 앱이 보는 게 아니야.
플랫폼은 iOS 18이랑 macOS 15야. macOS 최소 버전은 장식이 아니야. Mac에서 돌리는 swift test가 빠른 엔진 스위트고, 브러시, 타일, 실행 취소, 문서, 렌더러가 시뮬레이터 없이 돌아. Swift 타깃의 Swift 6 언어 모드랑 C 타깃의 C11은 매니페스트 맨 아래에서 한 번에 정해. 그래서 어떤 타깃도 몰래 다른 모드로 새지 못해.
include/가 공개된 얼굴이야
SwiftPM C 타깃은 .c 파일 디렉터리에 include/ 하위 디렉터리가 붙은 거야. include/에 있는 건 뭐든 그 타깃의 공개 인터페이스야. 거기 module.modulemap이 없으면(Ariandel엔 없어) SwiftPM이 대신 써 줘. 디렉터리 통째에 거는 umbrella 하나. 두 번째 블록에 있는 생성된 모듈 맵이 그거야. 그래서 Swift에서 import CAriandelSurface 한 줄이면 공개 헤더 넷이 다 보여. 경계를 넘으면 이렇게 바뀌어.
함수는 C 타입을 단 Swift 함수로 들어와. int는 Int32, uint16_t *는 UnsafeMutablePointer<UInt16>.
nullability는 헤더가 정해. ariandel_surface.h는 자기를 #pragma clang assume_nonnull로 감싸. 그래서 ArdTileRelease의 버퍼는 옵셔널 아닌 채로 들어오고, _Nullable을 붙인 ArdTileAcquire의 반환값은 옵셔널로 들어와. 레슨 2의 Swift 콜백이 그 시그니처랑 정확히 맞아.
단순한 매크로는 상수로 들어와. Tile.size가 Int(ARD_TILE_SIZE)야.
선언만 하고 정의는 안 한 구조체는 OpaquePointer로 들어와. 그게 BrushSurface.handle이고.
함수 포인터 typedef는 @convention(c) 함수 타입으로 들어와. 캡처 없는 func을 바로 넘길 수 있는 이유가 그거야.
공개 헤더는 의존하는 타깃의 공개 헤더를 include해도 돼. ariandel_surface.h가 mypaint-surface.h를 include하는데, CMyPaint가 의존성이라 찾아져. 그리고 순전히 배치 때문에 있는 헤더도 하나 있어. ariandel_shader_types.h는 Metal 버퍼 구조체를 C로 선언해서 Swift가 C 배치를 그대로 받게 해. 셰이더 소스는 그 필드를 하나하나 똑같이 되풀이하고. 패키지 스위트는 MemoryLayout으로 세 구조체의 C 쪽 stride(40, 112, 144바이트)랑 주요 필드 오프셋을 셰이더가 기대하는 숫자에 고정해 둬. 그걸로 잡히는 건 C 쪽 어긋남뿐이야. 테스트는 셰이더 소스를 안 읽어. 그건 실행할 때 컴파일하는 Swift 문자열이거든. 그래서 Metal 사본만 고치면 그대로 빠져나가고, 그 사본을 맞추는 건 고치는 사람 몫으로 남아.
cSettings, 그리고 비공개로 남아야 하는 헤더
벤더링한 두 라이브러리는 둘 다 config.h를 무조건 include해. libmypaint 쪽은 Ariandel이 직접 쓴 세 줄(glib 없음, 패키지 이름)이고, json-c 쪽은 업스트림 CMake 출력에 가드 하나 더한 거야. 각자 자기 타깃 루트, include/ 바깥에 앉아 있고, .headerSearchPath(".")로 닿아. 이 설정은 그 타깃 자기 파일을 컴파일할 때만 먹혀. 이 배치가 왜 규칙인지는 집 규칙의 함정 항목이 말해 줘. 둘은 다른 타깃의 다른 파일이고, 어느 쪽이든 공개 include/에 넣으면 모듈의 일부가 돼서 Swift가 엉뚱한 매크로를 import해. include/에 든 건 전부, import하는 모든 쪽에 거는 약속이야. 빌드 설정은 그런 약속이 아니고.
CMyPaint는 .define("HAVE_CONFIG_H")도 걸어. autotools 관례야. 그래도 믿기 전에 벤더 코드를 읽어 봐. 이 업스트림 커밋에선 두 라이브러리 어느 파일도 그 매크로를 검사 안 하고, config.h는 상관없이 include돼. 일하는 건 검색 경로고, 그 define은 해 없는 습관이야. (아래 스케치는 자기 include를 그걸로 감싸 뒀어. 소스가 진짜로 검사할 때 관례가 어떻게 도는지 보라고.)
exclude 목록은 소스 아닌 걸 타깃 밖에 둬. COPYING이랑 PROVENANCE.md는 자기가 설명하는 코드 옆에 남아. 업스트림의 fastapprox/Makefile.am.local은 Ariandel이 안 쓰는 빌드 시스템 몫이야. glib/mypaint-brush.c는 mypaint-brush.c가 #include로 끌어오는 조각이라 따로 컴파일하면 절대 안 되고.
생성된 것, 손으로 고치지 않는 것
벤더 타깃의 헤더 몇 개는 복사한 게 아니라 생성한 거야. libmypaint의 brushsettings-gen.h(비공개)랑 include/mypaint-brush-settings-gen.h(공개)는 같은 커밋의 brushsettings.json에 업스트림 generate.py를 돌려서 나와. json-c의 json.h랑 json_config.h는 iOS용으로 설정한 업스트림 CMake에서 나오고. 각 타깃의 PROVENANCE.md에 정확한 명령이 적혀 있어. 이것들도 자기가 설명하는 벤더 코드처럼 다뤄. 기록된 커밋에서 다시 생성하고, 손으로는 절대 안 고쳐. 벤더링 얘기 전체는 1트랙에 있어.
자기 아이콘을 직접 칠하는 실행 파일
ariandel-icon은 작은 명령줄 타깃이야. swift run ariandel-icon <brushes-dir> <out.png>. 넘겨받은 디렉터리(앱이 번들하는 CC0 세트)에서 MyPaint 브러시를 읽어. 그리고 앱이 쓰는 바로 그 Brush, BrushSurface, CanvasRenderer.flatten으로, 헤더 주석 표현대로라면 "A night of the Painted World, one copper-red stroke (Pippa's hair) and falling snow"를 칠해. 눈송이 자리는 시드 고정 생성기가 정하니까 아이콘은 돌릴 때마다 똑같아. 마지막엔 알파 채널 없이 다시 그려. App Store 아이콘엔 알파가 있으면 안 되거든(ITMS-90717).
누가 한 번 그린 에셋 대신 왜 타깃으로 만들었냐고? Mac에서 엔진 전체를 처음부터 끝까지 돌리니까, 브러시 경로가 망가지면 아이콘이 망가진 걸로 드러나. 아이콘을 소스에서 재현할 수 있게 되고. 그리고 자기가 만든 걸 자기가 정직하게 써 보는 일이기도 해. 획마다 첫 deltaTime을 PencilMapping.strokeStartInterval로 시작하는데, 진짜 펜이 닿을 때 앱이 libmypaint에 먹이는 그 상수야(4트랙). 매니페스트엔 라이브러리만 product로 올라 있는데도 swift run이 돼. SwiftPM은 루트 패키지의 실행 타깃이면 뭐든 돌릴 수 있거든.
C 타깃은 공개된 얼굴이 달린 디렉터리야. 인터페이스는 include/에 두고, 나머지(설정, 비공개 생성 헤더, 조각, 라이선스)는 전부 바깥에 둬. 그리고 C랑 나머지 모두 사이엔 Swift 타깃 딱 하나만 세워. 그러면 나머지 코드는 Swift만 import하고, 밑에 C가 있었다는 것도 몰라.
Code
AriandelKit의 Package.swift(원문 그대로)·swift
// swift-tools-version:6.0
//
// Ariandel's engine: libmypaint (vendored, unmodified) behind a Swift engine
// that owns tiles, layers, undo, documents and Metal rendering.
// `swift test` here is the fast engine suite and runs on macOS.
import PackageDescription
let package = Package(
name: "AriandelKit",
platforms: [
.iOS(.v18),
.macOS(.v15),
],
products: [
.library(name: "AriandelEngine", targets: ["AriandelEngine"]),
],
dependencies: [
// The family kit: the vendored-file drift check runs in this package's suite.
.package(name: "CwkKit", path: "../../../cwkSiblingKit/CwkKit"),
],
targets: [
// json-c, vendored byte-for-byte (see Sources/CJSONC/PROVENANCE.md).
.target(
name: "CJSONC",
path: "Sources/CJSONC",
exclude: ["COPYING", "PROVENANCE.md"],
cSettings: [.headerSearchPath(".")]
),
// libmypaint, vendored byte-for-byte (see Sources/CMyPaint/PROVENANCE.md).
.target(
name: "CMyPaint",
dependencies: ["CJSONC"],
path: "Sources/CMyPaint",
exclude: [
"COPYING",
"PROVENANCE.md",
// Compiled only through mypaint-brush.c's #include.
"glib/mypaint-brush.c",
"fastapprox/Makefile.am.local",
],
cSettings: [
.define("HAVE_CONFIG_H"),
.headerSearchPath("."),
]
),
// Ariandel's own C: the tiled surface libmypaint paints into, pixel kernels.
.target(
name: "CAriandelSurface",
dependencies: ["CMyPaint"],
path: "Sources/CAriandelSurface"
),
.target(
name: "AriandelEngine",
dependencies: ["CAriandelSurface", "CMyPaint", .product(name: "CwkKitCore", package: "CwkKit")],
path: "Sources/AriandelEngine"
),
// Paints the app icon with the engine itself (`swift run ariandel-icon`).
.executableTarget(
name: "ariandel-icon",
dependencies: ["AriandelEngine"],
path: "Sources/ariandel-icon"
),
.testTarget(
name: "AriandelEngineTests",
dependencies: [
"AriandelEngine",
.product(name: "CwkKitTesting", package: "CwkKit"),
],
path: "Tests/AriandelEngineTests",
resources: [.copy("Fixtures")]
),
],
swiftLanguageModes: [.v6],
cLanguageStandard: .c11
)
패키지 배치(일부 생략, 주석 추가)와 SwiftPM이 스케치의 C 타깃에 생성한 모듈 맵·text
AriandelKit's layout (trimmed; comments added)
Packages/AriandelKit/
Package.swift
Sources/
CJSONC/ json-c, vendored byte for byte
config.h private: CMake output, reached by .headerSearchPath(".")
include/ public: json.h and json_config.h (generated by CMake), json_object.h, ...
arraylist.c ... 12 sources
CMyPaint/ libmypaint, vendored byte for byte
config.h private: three defines
brushsettings-gen.h private, generated by upstream generate.py
include/ public: mypaint-surface.h, mypaint-tiled-surface.h,
mypaint-brush-settings-gen.h (generated), ...
glib/mypaint-brush.c excluded: compiled only through mypaint-brush.c's #include
fastapprox/ headers (its Makefile.am.local is excluded)
mypaint-brush.c ... 16 sources
CAriandelSurface/ Ariandel's own C
include/ public: ariandel_surface.h, ariandel_kernels.h,
ariandel_pixels.h, ariandel_shader_types.h
ariandel_surface.c ariandel_kernels.c ariandel_pixels.c
AriandelEngine/ Swift: brushes, tiles, layers, documents, undo, files, Metal
ariandel-icon/main.swift paints the app icon with the engine itself
Tests/AriandelEngineTests/
The module map SwiftPM generated for the sketch's C target (no module.modulemap
in the source tree, so it writes one: the whole include/ is the module):
module CEngine {
umbrella "<package>/Sources/CEngine/include"
export *
}
직접 재현하는 최소 스케치: 공개 include/를 단 C 타깃, 비공개 config.h, Swift 래퍼, 실행 파일(빈 디렉터리에서 실행, Swift 6.4로 빌드하고 실행함)·bash
# A minimal sketch of AriandelKit's shape: a C target whose include/ is its
# public face, a private config.h, a Swift target over it, and an executable.
# Run in an empty directory.
mkdir -p Sources/CEngine/include Sources/Engine Sources/mini-icon
cat > Package.swift <<'EOF'
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "MiniKit",
platforms: [.macOS(.v15)],
products: [.library(name: "Engine", targets: ["Engine"])],
targets: [
.target(
name: "CEngine",
path: "Sources/CEngine",
exclude: ["NOTES.md"],
cSettings: [
.define("HAVE_CONFIG_H"),
.headerSearchPath("."), // reaches config.h; it is never public
]
),
.target(name: "Engine", dependencies: ["CEngine"], path: "Sources/Engine"),
.executableTarget(name: "mini-icon", dependencies: ["Engine"], path: "Sources/mini-icon"),
],
swiftLanguageModes: [.v6],
cLanguageStandard: .c11
)
EOF
echo "Not a source file; excluded." > Sources/CEngine/NOTES.md
echo '#define ENGINE_NAME "mini"' > Sources/CEngine/config.h
cat > Sources/CEngine/include/ce_tiles.h <<'EOF'
#ifndef CE_TILES_H
#define CE_TILES_H
#include <stdint.h>
#define CE_TILE_SIZE 64
int ce_fill(uint16_t *values, int count, uint16_t value);
const char *ce_name(void);
#endif
EOF
cat > Sources/CEngine/ce_tiles.c <<'EOF'
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "ce_tiles.h"
int ce_fill(uint16_t *values, int count, uint16_t value) {
for (int i = 0; i < count; i++) values[i] = value;
return count;
}
const char *ce_name(void) { return ENGINE_NAME; }
EOF
cat > Sources/Engine/Engine.swift <<'EOF'
import CEngine
/// The Swift face of the C target; callers import Engine, never CEngine.
public enum Engine {
public static let tileSize = Int(CE_TILE_SIZE) // a C macro, imported as a constant
public static var name: String { String(cString: ce_name()) }
public static func filledTile(_ value: UInt16) -> [UInt16] {
var tile = [UInt16](repeating: 0, count: tileSize * tileSize)
let written = tile.withUnsafeMutableBufferPointer { ce_fill($0.baseAddress!, Int32($0.count), value) }
precondition(written == tile.count)
return tile
}
}
EOF
cat > Sources/mini-icon/main.swift <<'EOF'
import Engine
let tile = Engine.filledTile(1 << 15)
print("\(Engine.name): \(Engine.tileSize)x\(Engine.tileSize) tile, first value \(tile[0])")
EOF
swift run mini-icon
네가 쓰는 작은 C 라이브러리 하나, 아니면 네 프로젝트의 C 파일 하나를 골라서 AriandelKit 모양으로 만들어 봐. 공개 헤더는 include/에 두고, 설정 헤더는 타깃 루트에 둬서 .headerSearchPath(".")로 닿게 해. 소스 아닌 파일은 전부 exclude하고, 그걸 감싸는 Swift 타깃을 하나 둬서 아무도 C 모듈을 직접 import 안 하게 해. 빌드한 다음 SwiftPM이 생성한 모듈 맵을 찾아서, 그게 공개로 만든 헤더를 전부 적어 봐.
Hint
목록을 보고 놀랐다면 include/에 비공개여야 할 게 앉아 있는 거야. 타깃 루트로 옮기고 검색 경로를 더해. 그리고 HAVE_CONFIG_H 같은 define을 어느 빌드 시스템에서 베껴 오기 전에 소스를 grep해 봐. 검사하는 데가 없으면 아무 일도 안 하는 거야.
Progress
Progress is local-only — sign in to sync across devices.