using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
namespace ServiceSim.EditorTools
{
///
/// 画面なしの実行ファイル(macOS の普通の player)を作る
/// (2026-10-26・依頼 v12 の 1 章)。
///
/// メニューからも、コマンドからも作れる:
/// メニュー ServiceSim → 画面なしの実行ファイルを作る
/// コマンド /Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity \
/// -batchmode -quit -nographics \
/// -projectPath /Users/shimazueiichi/Robotics_UX/ServiceSim \
/// -executeMethod ServiceSim.EditorTools.BuildHeadless.Build \
/// -logFile /dev/stdout
///
/// 出力(VCS に入れない。ignore.conf に Builds/ を入れてある)
/// Builds/headless/ServiceSim.app
/// Builds/headless/build_info.json
///
/// 回し方は Dedicated Server ではなく **普通の macOS の player** にした。
/// ・-batchmode -nographics で画面を作らずに回せるので、これで足りる
/// ・Dedicated Server は別のモジュールが要り、入れる手間が増える
/// ・いまのコードは「画面なしでも同じ数字が出る」ことを確かめる段(5 章 B)なので、
/// 段階Bと同じ player のまま回すほうが、違いの元が増えない
///
/// シーンはここで直接指定する(ビルド設定に何が入っていても、
/// 作られる実行ファイルの中身が変わらないようにするため。1 章)。
///
public static class BuildHeadless
{
/// 出力の置き場所(プロジェクトの中。VCS に入れない)
public const string OutDir = "Builds/headless";
/// 実行ファイルの名前。tools/night_runner.py の DEFAULT_BUILD と合わせる
public const string AppName = "ServiceSim.app";
/// そばに置く覚え書き
public const string InfoName = "build_info.json";
[MenuItem("ServiceSim/画面なしの実行ファイルを作る", false, 60)]
public static void Build()
{
string err;
if (!BuildTo(OutDir, out err))
{
Debug.LogError("[ServiceSim] 画面なしの実行ファイルを作れませんでした\n " + err);
if (Application.isBatchMode) EditorApplication.Exit(1);
return;
}
if (Application.isBatchMode) EditorApplication.Exit(0);
}
///
/// 作る本体。できたら true。
/// 中で ProjectSettings やビルド設定(EditorBuildSettings)は書き換えない
///
public static bool BuildTo(string outDir, out string err)
{
err = "";
if (!File.Exists(ServiceSimSetup.ScenePath))
{
err = "シーンがありません: " + ServiceSimSetup.ScenePath +
"\n 先に ServiceSim/Setup を実行してください";
return false;
}
// 版と保留の数(台帳の unity_cs に入る字。段階Bと同じ関数を使う)
string cs, pendingNote;
ServiceSimStageB.ReadChangeset(out cs, out pendingNote);
if (pendingNote.Length > 0)
Debug.LogWarning("[ServiceSim] 保留中の変更があるまま作ります: " + pendingNote +
"\n この実行ファイルの中身は " + cs +
" と同じではありません(台帳の unity_cs は " + cs + " になります)。" +
"\n 夜の回に使うなら、先に check-in して保留 0 にしてから作り直してください");
string root = Directory.GetCurrentDirectory();
string dir = Path.IsPathRooted(outDir) ? outDir : Path.Combine(root, outDir);
string appPath = Path.Combine(dir, AppName);
try
{
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
// 前の版が残っていると、古い実行ファイルで夜を回してしまう。消して作り直す
if (Directory.Exists(appPath)) Directory.Delete(appPath, true);
}
catch (Exception e)
{
err = "置き場所を用意できません: " + dir + "\n " + e.Message;
return false;
}
// Apple Silicon で動く形にする(Rosetta で動くと遅くなり、
// 何本同時に回せるか(5 章 C)の実測が変わってしまう)。
// 設定は PlaybackEngines の DLL の中にあり、Unity 抜きの組み立てでは
// 参照できないので、反射で入れる(入らなくてもビルドは続ける)
string archNote = SetAppleSiliconArchitecture();
BuildPlayerOptions opt = new BuildPlayerOptions();
opt.scenes = new string[] { ServiceSimSetup.ScenePath }; // シーンはここで直接指定(1 章)
opt.locationPathName = appPath;
opt.target = BuildTarget.StandaloneOSX;
opt.targetGroup = BuildTargetGroup.Standalone;
opt.options = BuildOptions.None;
BuildReport report = BuildPipeline.BuildPlayer(opt);
if (report == null)
{
err = "BuildPipeline が何も返しませんでした";
return false;
}
BuildSummary sum = report.summary;
if (sum.result != BuildResult.Succeeded)
{
err = "ビルドが " + sum.result + " で終わりました(エラー " + sum.totalErrors + " 件)";
return false;
}
string exe = Path.Combine(appPath, "Contents", "MacOS");
string exeNote = Directory.Exists(exe) ? FirstFile(exe) : "";
string buildId = BuildId(cs);
string infoPath = Path.Combine(dir, InfoName);
if (!WriteInfo(infoPath, buildId, cs, pendingNote, appPath, exeNote, sum, archNote, out err))
return false;
Debug.Log("[ServiceSim] 画面なしの実行ファイルを作りました" +
"\n " + appPath +
"\n 実行ファイル: " + (exeNote.Length > 0 ? Path.Combine(exe, exeNote) : "(見つかりません)") +
"\n 版: " + cs + (pendingNote.Length > 0 ? "(" + pendingNote + ")" : "(保留 0)") +
"\n build_id: " + buildId +
"\n 覚え書き: " + infoPath +
"\n 大きさ: " + (sum.totalSize / (1024f * 1024f)).ToString("0.0") + " MB" +
" / かかった時間: " + sum.totalTime.TotalSeconds.ToString("0") + " 秒" +
"\n 作り方: " + archNote +
"\n 回し方: " + (exeNote.Length > 0 ? Path.Combine(exe, exeNote) : "<実行ファイル>") +
" -batchmode -nographics -ssCondition fam_p1_s4_r1_a42 -ssSeed 1");
return true;
}
///
/// build_info.json(依頼 v12 の 1 章)。
/// unity_cs と build_id は、夜の実行係(night_runner.py)と
/// 実行ファイルの中(HeadlessRun)の両方が読む。
/// 名前を変えるときは、tools/night_runner.py の read_unity_cs も直すこと
///
static bool WriteInfo(string path, string buildId, string cs, string pendingNote,
string appPath, string exeName, BuildSummary sum,
string archNote, out string err)
{
err = "";
StringBuilder sb = new StringBuilder();
sb.Append("{\n");
sb.Append(" \"schema\": \"servicesim-build-info/0.1\",\n");
sb.Append(" \"build_id\": ").Append(Str(buildId)).Append(",\n");
sb.Append(" \"unity_cs\": ").Append(Str(cs)).Append(",\n");
sb.Append(" \"pending_changes\": ").Append(Str(pendingNote)).Append(",\n");
sb.Append(" \"built_at\": ")
.Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture))).Append(",\n");
sb.Append(" \"editor_version\": ").Append(Str(Application.unityVersion)).Append(",\n");
sb.Append(" \"target\": ").Append(Str(BuildTarget.StandaloneOSX.ToString())).Append(",\n");
sb.Append(" \"architecture\": ").Append(Str(archNote)).Append(",\n");
sb.Append(" \"scene\": ").Append(Str(ServiceSimSetup.ScenePath)).Append(",\n");
sb.Append(" \"app\": ").Append(Str(Rel(appPath))).Append(",\n");
sb.Append(" \"exe\": ").Append(Str(exeName)).Append(",\n");
sb.Append(" \"ledger_schema_version\": ").Append(ServiceSimLedger.SchemaVersion).Append(",\n");
sb.Append(" \"total_size_bytes\": ").Append(sum.totalSize).Append(",\n");
sb.Append(" \"build_seconds\": ")
.Append(sum.totalTime.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)).Append("\n");
sb.Append("}\n");
try
{
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
}
catch (Exception e)
{
err = "build_info.json を書けません: " + path + "\n " + e.Message;
return false;
}
return true;
}
///
/// この実行ファイルの名札。版と作った時刻から作る。
/// 例 cs12_20261026_1305(保留があれば頭に dirty_ を付ける)
///
static string BuildId(string cs)
{
string n = "";
int at = (cs ?? "").IndexOf("cs:", StringComparison.Ordinal);
if (at >= 0)
for (int i = at + 3; i < cs.Length && cs[i] >= '0' && cs[i] <= '9'; i++) n += cs[i];
if (n.Length == 0) n = "unknown";
return "cs" + n + "_" + DateTime.Now.ToString("yyyyMMdd_HHmm", CultureInfo.InvariantCulture);
}
///
/// Apple Silicon 向けにする。
/// UnityEditor.OSXStandalone.UserBuildSettings.architecture を反射で入れる
/// (この型は PlaybackEngines の DLL にあり、Unity 抜きの組み立てでは参照できない)。
/// 入れられなければ、そのことを書いて既定のまま作る
///
static string SetAppleSiliconArchitecture()
{
try
{
Type t = null;
System.Reflection.Assembly[] all = AppDomain.CurrentDomain.GetAssemblies();
for (int i = 0; i < all.Length && t == null; i++)
t = all[i].GetType("UnityEditor.OSXStandalone.UserBuildSettings", false);
if (t == null) return "既定(UserBuildSettings が見つかりません)";
System.Reflection.PropertyInfo prop = t.GetProperty("architecture",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (prop == null) return "既定(architecture が見つかりません)";
object arm = Enum.Parse(prop.PropertyType, "ARM64");
prop.SetValue(null, arm, null);
return "ARM64(Apple Silicon)";
}
catch (Exception e)
{
return "既定(入れられませんでした: " + e.Message + ")";
}
}
static string FirstFile(string dir)
{
try
{
string[] files = Directory.GetFiles(dir);
for (int i = 0; i < files.Length; i++)
{
string name = Path.GetFileName(files[i]);
if (name.StartsWith(".", StringComparison.Ordinal)) continue;
return name;
}
}
catch (Exception) { }
return "";
}
static string Rel(string full)
{
string root = Directory.GetCurrentDirectory();
return full.StartsWith(root, StringComparison.Ordinal)
? full.Substring(root.Length).TrimStart('/') : full;
}
static string Str(string s)
{
if (s == null) s = "";
StringBuilder sb = new StringBuilder("\"");
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (c == '"' || c == '\\') sb.Append('\\').Append(c);
else if (c == '\n') sb.Append("\\n");
else if (c == '\r') sb.Append("\\r");
else if (c == '\t') sb.Append("\\t");
else if (c < ' ') sb.Append(' ');
else sb.Append(c);
}
return sb.Append('"').ToString();
}
}
}