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(); } } }