kaedeasset記事 · Notes霧走町 流氷に眠る

ヘッダだけの C++ の核を、UE・CLI・Linux のサーバが写さずにコンパイルする

A header-only C++ core compiled, uncopied, by UE, a CLI and a Linux server

 D14-3-T27-one-cpp-core-three-hosts・技術・日本語入力でプレイする仕組み

画面と入力header-only libraryports and adaptershexagonal architectureclaude自然言語処理

日本語の入力をどの証言に当てるかを決める C++ は、ヘッダ 20 本・9,766 行です。
UE のゲーム、Windows の検査用の CLI、ブラウザ版の裏の Linux のサーバが、このヘッダを写さずに、三つのコンパイラの設定でそれぞれコンパイルしています。

連載の 1 回目(T01)で、全体の形としてこれを書きました。
今日はその中身です。
三つの外側でヘッダを共有するときに何を決め、どこで踏み、何を機械に見張らせているか。

この回の位置と、前後の回#

区切り: T25〜T29 画面と入力(5 回のうち 3 回目)
先に読むとよい回: T01(全体の構造と、使っている技術と言語)・T23(同じ入力に同じ出力)
次に読む回: T28(画面を JSON で書く)

名前 — header-only library#

正式名
header-only library(ヘッダだけのライブラリ)。外側との境目の作りは ports and adapters(hexagonal architecture)
略称
なし(hexagonal architecture を「ヘキサゴナル」と呼ぶことはある)
日本語
ヘッダオンリーライブラリ、ポートとアダプター、ヘキサゴナルアーキテクチャ
初出・代表
header-only library という語の初出は確かめられませんでした。ports and adapters は、Alistair Cockburn が hexagonal architecture を 2005 年に名付け直したものとされます(Wikipedia と解説記事で確かめ、本人の原文は見ていません)

header-only library とは#

header-only library(ヘッダだけのライブラリ)とは、C++ のライブラリを、.cpp を持たずヘッダだけで配る形です。
関数は inline、変数は C++17 からの inline 変数、あとはテンプレートで書き、使う側は #include するだけで済みます。

ふつうの使われ方と、知られた例です。

  • nlohmann/json(このゲームも使っている。1 ファイルの単一ヘッダ版がある)
  • Eigen や、Boost の多くのライブラリ
  • stb のような「1 ファイルを置くだけ」のライブラリ(実装は一か所でマクロを立てて展開する流儀)

得るものは、ビルドの仕組みに組み込む手間が無いこと、ABI(コンパイラや設定の違いによるバイナリの非互換)の問題が起きないこと、呼び出しが展開されて最適化が効きやすいこと。

よく知られた弱点もあります。

  • ビルドが遅い。ヘッダを include した翻訳単位ごとに、全部をコンパイルし直す
  • ODR(One Definition Rule)の落とし穴。inline を付け忘れた関数をヘッダに書くと、複数の翻訳単位で定義が重なってリンクで落ちる
  • ヘッダの中のマクロや using が、使う側に漏れる
  • 使う側のコンパイラ設定の違い(警告の扱い・例外・RTTI)が、そのままライブラリに当たる

最後の一つが、三つの外側で共有するうちでは、いちばん効きました。

ports and adapters とは#

アプリの中心(ドメインの規則)を、外の世界(データベース・UI・ファイル・ネットワーク)から切り離す作りです。
中心は「こういう口が要る」というポート(インタフェース)だけを宣言し、外の世界ごとにアダプター(実装)を差し込む。
解説でよく挙げられる狙いは、人・プログラム・自動テストのどれからでも同じように中心を動かせること、データベースなどの実行時の装置と切り離して開発とテストができることです。

ゲームの核に当てはめると、中心は「入力された日本語をどの意図に当てるか、進行をどう変えるか」で、外の世界は「ファイルをどう読むか」「埋め込みをどう計算するか」「画面にどう描くか」です。

核の約束: 標準ライブラリと nlohmann/json しか知らない#

core/ の 20 本の .hpp が include しているものを数えると、vector と string が各 19 本、algorithm 10、set 9、map 8、functional 7、nlohmann/json.hpp 6、cmath 6……と続き、全部が標準ライブラリと nlohmann/json でした。
UE の型(FString・TArray・UObject)は一つもありません。
作品のことも知りません。人物名・場所・台詞・閾値の数は、全部 Game/adv/ の JSON です(アーキテクチャの文書 §6 の境界表)。

ポートは三つ#

核が外の世界に触る口は三つです。

  1. ファイルを読む口(AdvSessionLoader.hpp)

using Loader = std::function<bool(const std::string& /relPath/, std::string& /outText/)>;

作品フォルダからの相対パスを受け、中身を文字列で返す。
共通の言語資源(Game/common/adv/)を読む口も別に差し込めます(setCommonLoader)。渡さなければ、作品の "../common/adv/" を同じ口で読みます。
核はディレクトリを列挙しません。adv.json が指すファイルだけを、この口から読みます。

  1. 埋め込みを計算する口(AdvMatcher.hpp)

using EmbedFn = std::function<std::vector<float>(const std::string&)>;

文を受け、384 次元のベクトルを返す。
渡されなければ、焼いたベクトルにある文だけを意味で照合し、それ以外は文字の照合だけで動きます。

  1. 入力を解析する口(AdvInput.hpp)

class IInputAnalyzer
{
public:
virtual ~IInputAnalyzer() = default;
virtual InputAnalysisResult Analyze(const InputAnalysisRequest& Request) const = 0;
};

日付・時刻・節・否定・疑問を取り出す解析器です(T11〜T13)。
いまの実装は規則による RuleBasedInputAnalyzer 一つで、セッションが既定で持ちます。
解析器に渡す材料には注記があります。「セッションの更新 API・正解経路・未公開台詞は渡さない」。
差し替えた解析器が進行に触れないよう、読み取り専用の材料だけを構造体で渡しています。

二つは std::function、一つは抽象クラスです。
関数一つで済む口は std::function にし、版や id を名乗る必要がある解析器だけクラスにしました。

アダプター: 外側ごとの差し込み#

UE のゲーム(KaedeAdvSubsystem.cpp)は、ファイルを FFileHelper で読みます。

const bool bLoaded = Runner->Session.load(
[WorkDir](const std::string& Rel, std::string& Out) -> bool
{
const FString Path = WorkDir / ToFStr(Rel);
// .bin はバイナリ。文字列ローダで読むと壊れる(vectors.bin が空になり Stage B が無効化された)
if (Path.EndsWith(TEXT(".bin")))
{
TArray<uint8> Bytes;
if (!FFileHelper::LoadFileToArray(Bytes, Path)) { return false; }
Out.assign((const char
)Bytes.GetData(), Bytes.Num());
return true;
}
FString Text;
if (!FFileHelper::LoadFileToString(Text, *Path)) { return false; }
Out = ToStd(Text);
return true;
},
Err);

埋め込みは UE の NNE(NNERuntimeORTCpu)で、前処理と後処理は核の AdvEmbedMath.hpp をそのまま使います。
CLI は、ファイルを標準ライブラリで読み、埋め込みを ONNX Runtime の C API で計算します。
ORT は動的に読み込み、OS の差は LoadLibrary/dlopen と GetProcAddress/dlsym だけです。

踏んだ所 ①: 捕まえ方#

KaedeAdvSubsystem.cpp のローダの [WorkDir] には、10 月 26 日の修正が入っています(commit e03b029e)。
それまでは参照で捕まえていました。

核はこのローダを持ち続け、遊んでいる最中にも台詞の台本(scripts/*.json)を読みます。
参照で捕まえると、ゲームを始める関数を出たあとで、消えた変数を読みに行く。
台本が見つからず、台詞だけが黙って消えました。
8 月 4 日からあった use-after-scope で、10 月 18 日に核のファイルを分けたとき、メモリの並びが変わって初めて表に出ました。

std::function のポートは、型の上では寿命を何も言いません。
CLI ではセッションも、捕まえた変数も同じ関数の中で生きているので、同じ [&] でも壊れなかった。
同じ核でも、外側ごとに寿命の前提が違う、という踏み方でした。

踏んだ所 ②: 警告の扱い#

三つのコンパイラの設定は、こうなっています。

  • UE(UBT・MSVC): KaedeAdv.Build.cs で PCHUsage = NoPCHs・bUseUnity = false・bEnableExceptions = true。UE は変数の隠蔽(C4458 など)をエラーにする
  • Windows の CLI(CMake・MSVC): /utf-8 /EHsc /W3 /we4456 /we4457 /we4458 /we4459
  • Linux の CLI(CMake・GCC): -Wall -Werror=shadow=local -fexceptions

最初、CLI は /W3 のままでした。
9 月 6 日、core のヘッダで、メンバーと同じ名前のローカル変数を書いた変更が CLI では緑のまま通り、UE のビルドで落ちました。
CLI と UE を別の作業の流れで並べて回していると、CLI 側は UE をビルドしないので、自分では気づけません。
CMakeLists.txt の注記です。

/we445x(変数の隠蔽をエラー扱い)_why: core/*.hpp は adv-sim と UE の両方が compile する

唯一の共有面。UE は隠蔽を error にするので、CLI が /W3 のままだと「adv-sim は緑なのに

UE が落ちる」が起きる(2026-09-06 実測)。

GCC で同じことをしようとして、もう一度踏みました。
無印の -Wshadow は、ローカル変数が入れ子の型の名前と同じでも警告します。MSVC の C4458 では通る core(AdvTokenizer.hpp の Piece)が、GCC だけで落ちた。
GCC は -Werror=shadow=local(ローカルと引数の隠蔽だけ)にし、「core の正は MSVC 側のビルドが守る。Linux ビルドはその写しであり、core を触る理由にしない」と書いて止めました。

header-only の弱点の「使う側の設定がライブラリに当たる」を、三つの外側のうち一番厳しい UE に合わせることで潰しています。

一致を確かめる#

同じヘッダを三つでコンパイルしても、結果が同じとは限りません。
今日確かめたことです。

  • core の単体テスト 5 本(入力と期待値の表で、正規化・規則・主張・入力解析・照合を直接見る): 35・55・90・84・50 項目、全部 PASS
  • Windows(MSVC)と Linux(GCC 13.3.0)の adv-sim に同じ 190 行の台本を流すと、出力 2,367 行が改行コードを除いて一致(T23)
  • 焼いたベクトルと実行時の計算は、全 2,845 文でコサイン 1.000000(T01・T07)

UE の上での一致は、起動のたびの埋め込みの自己照合(1 文)と、UE の自動テストで見ています。今日は UE を動かしていません。

データ境界の検査(DB01〜DB08)#

核が作品のことを知らない、は約束だけでは守れません。
誰かが「佐伯なら特別に」と C++ に書けば、それで崩れます。
そこで、CLI をビルドするたびに node のスクリプト Scripts/adv-data-boundary.mjs が、C++ のソースを字句で読んで検査し、違反があればビルドを不合格にします(Scripts/adv-build-sim.ps1)。

規則は 8 つです。

  • DB01 作品の ID・名前・台詞が C++ の文字列にある(完全一致。固有名と台詞は部分一致も)
  • DB02 プレイヤーに見える出口(台詞・画面の文言・通知)へ、C++ の文字列を直接渡している
  • DB03 作品の ID を文字列で比べている(作品や人物で分岐している)
  • DB04 3 語以上の文字列の表・生の JSON・正規表現を、台帳に無いまま新しく置いている
  • DB05 作品データをコンパイル時に取り込んでいる(Game/ の #include・incbin・生成ヘッダ)
  • DB06 契約の欄を使わず、その既定値の文字列で判断している
  • DB07 テストと製品の境界(テストが実作品の入力や期待値を直書き/製品がテストに依存)
  • DB08 方針ファイルそのものの健全性

対象と例外は Tools/adv-sim/contracts/data-boundary-policy.json の一か所に書き、スクリプトにファイル名を書きません。
例外の書き方にも縛りがあります。DB08 の一部です。

for (const a of policy.allow ?? []) {
if (!USES.has(a.use)) V('DB08', rel(POLICY_PATH), 0, 未知の用途: ${a.use});
if (!a.file || /[*?]/.test(a.file) || !a.function || !a.tokenHash || !a.reason || !a.spec) V('DB08', rel(POLICY_PATH), 0, 許可にはファイル・関数・token hash・理由・根拠仕様が要る(全体許可は不可): ${JSON.stringify(a).slice(0, 120)});
}
for (const m of policy.migration ?? []) {
if (!m.until || m.until < today) V('DB08', rel(POLICY_PATH), 0, 期限切れ(または期限なし)の移行中の例外: ${m.id ?? '?'});
if (m.category === 'workData') V('DB08', rel(POLICY_PATH), 0, 作品データ・台詞の暫定除外は置けない: ${m.id});
}

許可は「ファイル・関数・用途・正規化した語の hash・理由・根拠の仕様」の単位でしか書けず、ファイル全体や * の許可は置けません。
移行中の例外には期限が要り、作品データと台詞は暫定でも除外できません。
許可に語そのものではなく hash を書くのは、方針ファイルに作品の語を書かないためです。照合した語は、運営向けの結果ファイルにだけ書きます。

今日の結果です。

DATA-BOUNDARY PASS product=48 test=4 violations=0 {} comment-leaks=323 tree=581e94fb29fa policy=90a577593de3

製品のファイル 48・テスト 4・ビルドの設定 4 を見て、違反 0。3.3 秒でした。
方針ファイルは 617 行、許可は 43 件、移行中の例外は 0 件です。

名前どおりでない所 — header-only library#

header-only library としては、名前どおりです。.cpp は一つもありません。
ただ、ライブラリとして配っているわけではありません。
核は UE プラグインの Private フォルダの中にあり、CLI はそのフォルダを include パスに直接入れています。

target_include_directories(adv-sim PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../Plugins/KaedeAdv/Source/KaedeAdv/Private
${CMAKE_CURRENT_SOURCE_DIR}/../../Plugins/MomijiUI/Source/MomijiUICore/ThirdParty
${KAEDE_ORT_INCLUDE}
)

ふつうは公開用のフォルダ(Public)を切るところを、写しを作らないことを優先しました。

ports and adapters としては、ポートの数が少ない変形です。
画面への出力はポートにしていません。核は返事を AdvTurn という値で返し、描くのは外側の仕事、という分け方です。
ポートとアダプターという言葉も、コードの中では使っていません。

弱点と、採らなかった案 — header-only library#

弱点です。

  • 境界の検査は最後の網で、主防御ではありません。スクリプトの注記は「主防御は C++ の型(ResolvedPlayerText・private の生成口)」と書いていますが、ResolvedPlayerText という型は、今日のソースのどこにもありませんでした。設計の文書にだけある型です。いま効いているのは字句の検査だけで、文字列を組み立てて逃れるコードまでは見つけられません。
  • コメントは違反にしていません。今日の comment-leaks=323 は、C++ のコメントに作品の語が出てくる数です。ビルドは落としません。
  • header-only なので、core を変えると、include している UE の翻訳単位がまとめてコンパイルし直しになります。
  • 寿命の前提は、ポートの型に出ません。上の use-after-scope は、型では防げませんでした。
  • UE のローダは FString を通して UTF-8 に戻すので、CLI と完全に同じバイト列になるとは限りません(T24)。

採らなかった案です。

  • 核を静的ライブラリや DLL にする。UE のモジュール、Windows の MSVC、Linux の GCC で、別々のバイナリと CRT の組み合わせを持つことになります。header-only なら、それぞれのコンパイラがそれぞれ作るだけです。
  • CLI に核の写しを置く。写した瞬間に、正が二つになります。片方だけ直して検査が緑のまま、が起きます(不変条件 7「分類・正規化・閾値読み込みを2か所に書かない」)。
  • ブラウザ版のために核を JavaScript に移す。照合の規則が二つになります。ブラウザは描くだけにして、判定は Linux の adv-sim に任せました。
  • 作品ごとの分岐を C++ に許す。別の作品が同じバイナリで最後まで動くこと(Game/adv-demo)を回帰テストに入れて、許さない側で固めています。今日も同じ adv-sim で、その作品の台本が章の解決まで通りました。

同じヘッダを、三つのコンパイラが別々に読む。
写さないことの代わりに、警告・寿命・境界を、一番厳しい外側に合わせています。

検索語#

header-only library C++
ports and adapters hexagonal architecture
One Definition Rule inline
-Wshadow=local

ゲームの中の判定と同じ C++ が、ブラウザ版の裏でも動いています。『霧走町 流氷に眠る』の第一章はプロフィールのリンクから、ブラウザで入力して試せます。

#claude #自然言語処理