-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathConsoleCommandSystem.cpp
More file actions
4674 lines (4265 loc) · 224 KB
/
Copy pathConsoleCommandSystem.cpp
File metadata and controls
4674 lines (4265 loc) · 224 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "Commands/CommandSupport.h"
#include "ConsoleCommandSystem.h"
#include "CommandCore/CommandSession.h" // LC1: 결과 누적과 process exit code
#include "CommandCore/CommandParser.h"
#include "CommandCore/CommandRegistry.h" // LC3: descriptor snapshot
#include "Commands/CommandRegistrar.h"
#include "Commandlets/EditorCommandlets.h"
#include "CommandCore/CommandDescriptorSeeds.h"
#include "CommandResultJson.h" // LC9: 배치 JSONL 과 서비스 JSON 의 단일 정본
#include "EditorCommandServiceHost.h" // LC4: 로컬 HTTP/JSON 서비스 // LC2: 토크나이저와 소유형 invocation
#include "EditorCameraRig.h"
#include "EditorSessionState.h"
#include "EngineBootstrap.h"
#include "GameBuilderSystem.h"
#include "EditorAssetDatabase.h"
#include "Interfaces/AssetAuthoringPort.h"
#include "Interfaces/FoliageInstance.h"
#include <mathematics/color.hpp>
#include "SceneManager.h"
// SceneManager.h는 Scene을 전방 선언만 한다. 여기서는 씬의 멤버를 훑으므로
// 완전한 형이 필요하다 — 유니티 빌드에서는 앞선 파일이 공급했다.
#include "Scene.h"
#include "CameraComponent.h"
#include "CameraSystem.h"
#include "ClrHost.h"
#include "ScriptComponent.h"
#include "PrefabUtility.h"
#include "ComponentFactory.h"
#include "ModelSceneInstantiation.h" // MBC9: generation 씬 인스턴스화
#include "ModelConsumptionDiagnostics.h" // MBC10: 읽기 전용 소비 스냅샷
#include "Material.h"
#include "Mesh.h"
#include "Assets/ModelAssetGeneration.h"
#include "Assets/ModelVertexLayout.h" // MBC9: skinbounds typed 정점 디코드
#include "Assets/ModelAnimationSampler.h" // MBC9: editorsurface frame 축(CountUniqueKeyTimes)
#include "Assets/ModelAssetAuthoringTransaction.h" // MBC11: assets.modelbench author 모드
#include "RHI/IRHIDeviceResources.h" // MBC11: VRAM 계측
#include "LifecycleTrace.h"
#include "LifecycleRegistry.h"
#include "Animator.h"
#include "Socket.h" // X7 transform bulk probe
#include "BoneRegion.h" // MAX_BONES
#include "Experiment/Model.h" // I5-D4e-1: experiment.animtick 패리티
#include "RenderScene.h" // I5-D4e-1: GetAnimationJob
#include "AvatarMask.h" // I5-D4e-3: experiment.animmask A/B 대조
#include "FoliageComponent.h" // I5-D5a: experiment.foliage 게이트
#include "Terrain.h" // D4 Terrain YAML authoring round-trip
#include "Experiment/MaterialInstance.h" // I5-D5c1: experiment.matruntime
#include "Experiment/MaterialAuthoringCodec.h" // I5-D5c1: 값 인코딩 대조
#include "ExperimentMaterialMigration.h" // I5-D5c1: legacy 왕복 축
#include "Experiment/Cooked/CookedAssetCatalog.h" // I7-C1
#include "ExperimentMaterialResolveBinding.h" // I7-C1: 제품 resolver
#include "StandardMaterialProperty.h" // I7-C1: probe property
#include "Experiment/MaterialPropertyBlock.h" // I5-D5c2-1: packing 바이트 축
#include "MaterialPropertyPacker.h" // I5-D5c2-1: 합성 layout
#include "PrimitiveRenderProxy.h" // I5-D5c2-2: 프록시 축
#include "MaterialScriptBinding.h" // I5-D5c3: 실물 편집 창구
#include "ProxyCommandQueue.h" // I5-D5c3: 갱신 커맨드 소비
#include "Render/Scene/ExperimentMaterialSealing.h" // I5-D5c3-2: texture 축
#include "PrimitiveRenderProxy.h" // I5-D5a: FoliageRenderProxy 실물 사슬
#include "RHI/IRenderDeviceServices.h" // RHIModelMeshView·BuildRHIModelMeshView
#include "ConditionParameter.h"
#include "UIManager.h"
#include "Canvas.h"
#include "ImageComponent.h"
#include "MeshRenderer.h" // X8 render proxy dirty probe
#include "RectTransformComponent.h"
#include "BoneComponent.h" // E7-b: scene.traversalbench 0 모드의 마커 보유 수 진단
#include "UIButton.h"
#include "TextComponent.h"
#include "SpriteSheetComponent.h"
#include "StateMachineComponent.h"
#include "AIManager.h"
#include "DataSystem.h"
#include "GpuDiagnostics.h"
#include "LogSystem.h"
#include "PathFinder.h"
#include "RuntimeSettings.h"
#include "AuthoringNodeEquality.h" // D3-a-1: 저작 노드 구조 비교
#include "AuthoringNodeViewAccess.h" // D3-a-5b
#include "AuthoringParsedDocument.h"
#include "AuthoringRymlErrorPolicy.h" // D3-b-1: ryml abort → 예외 정책
#include "SerializationProfiler.h" // D0(SerializationPlan): 직렬화 기준선 계측
#include "CoreWindow.h"
#include "Render/Scene/EnhancedSceneRenderer.h"
#include "RHI/DX12/Tests/DX12SelfTest.h"
#include "RHI/Vulkan/VulkanSelfTest.h"
#include "RHI/IImGuiHost.h"
#include "ProfilerSelfTest.h"
#include "ExperimentParity/ExperimentVertexLayoutSelfTest.h"
#include "AssetIdentity/AssetIdentitySelfTest.h"
#include "AssetIdentity/AssetSidecarSchemaSelfTest.h"
#include "AssetIdentity/ModelAssetGenerationSelfTest.h"
#include "AssetIdentity/SceneModelGenerationSelfTest.h"
#include "ExperimentParity/ExperimentSamplerSelfTest.h"
#include "ExperimentParity/ExperimentCookedSelfTest.h"
#include "ExperimentParity/ExperimentWeldSelfTest.h"
#include "ExperimentParity/ExperimentCacheOptSelfTest.h"
#include "ExperimentParity/ExperimentTextureCookSelfTest.h"
#include "ShaderMeta.h"
#include "ExperimentParity/ExperimentShaderMetaCookSelfTest.h"
#include "ExperimentParity/ExperimentMaterialCookSelfTest.h"
#include "ExperimentParity/ExperimentMaterialInstanceSelfTest.h"
#include "ExperimentParity/ExperimentMaterialSealSelfTest.h"
#include "ExperimentParity/ExperimentMaterialCodecSelfTest.h"
#include "ExperimentParity/ExperimentSceneCookSelfTest.h"
#include "ExperimentParity/ExperimentResolverSelfTest.h"
#include "ExperimentParity/ExperimentCatalogSelfTest.h"
#include "RHI/ScreenSizedResource.h"
#include "ReflectionYml.h"
// Undo/선택 프로브(E3-2 게이트)가 쓴다. Reflection 사슬이 ReflectionUndo.h를
// 전이로 물어 주지만 그 사슬에 기대지 않는다 — 바로 아래 StringHelper.h가
// 같은 실수로 비유니티 빌드에서 깨진 적이 있다.
#include "ReflectionUndo.h"
#include "GameObjectCommand.h"
// StringToWstring. 유니티 빌드에서는 같은 청크의 EditorAssetDatabase.cpp가
// 대신 물어 줘서 보이지 않던 누락이라, 비유니티 빌드에서만 드러났다.
#include "StringHelper.h"
#include "BlackBoard.h"
#include "TagManager.h"
#include <Windows.h>
#include <psapi.h> // MBC11: assets.modelbench peak working set
#pragma comment(lib, "Psapi.lib")
#include <crtdbg.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <limits>
#include <random>
#include <optional>
#include <stdexcept>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <DbgHelp.h>
#pragma comment(lib, "dbghelp.lib")
#include <DXProgrammableCapture.h>
#include <chrono>
#include <dxgidebug.h>
#include <wrl/client.h>
#include <algorithm>
#include <cstring>
#include <cstdio>
#include <fstream>
#include <functional>
#include "../../Engine/SceneRuntime/MeshRenderer.h"
#include "../../Engine/RenderEngine/Material.h"
#include <unordered_set>
#include <iostream>
#include <sstream>
#include <stdexcept>
namespace
{
// camera.editor follow 의 상태. 게임 스레드(App 프레임 경계와 CLI Pump)
// 에서만 만지므로 원자성이 필요 없다 — 둘 다 같은 스레드다.
bool g_editorCameraFollowsGame = false;
// 앞뒤 공백 제거
std::string TrimLine(const std::string& s)
{
const auto begin = s.find_first_not_of(" \t\r\n");
if (begin == std::string::npos) return {};
const auto end = s.find_last_not_of(" \t\r\n");
return s.substr(begin, end - begin + 1);
}
// tokenizer 는 LC2 에서 CommandCore/CommandParser 로 옮겼다.
//
// 여기 있던 `Split()` 은 따옴표를 상태 토글로만 다뤄서 escape 가 없었고,
// 닫히지 않은 따옴표를 조용히 통과시켰다. 무엇보다 **핸들러들이 그 결과를
// 버리고 원문을 다시 잘랐다**(§3.2). 문법을 한 곳에 모으지 않으면 그
// 재해석을 막을 자리가 없다.
void EnsureConsole()
{
// 이미 파일이나 파이프로 리다이렉트된 스트림은 건드리지 않는다.
//
// CONOUT$로 무조건 다시 여는 코드가 `Academy_4Q.exe --exec ... > out.txt`를
// 조용히 무력화하고 있었다 — 명령은 돌고 출력은 콘솔 창으로만 가서
// 파일에는 아무것도 남지 않았다. 자동 검증에서는 그 출력이 결과 전부라,
// '통과했는지 알 수 없음'과 '실패'가 구분되지 않는 상태였다.
//
// ★ 판정은 **콘솔을 붙이기 전에** 한다 (2026-09-10 실측).
//
// `AttachConsole(ATTACH_PARENT_PROCESS)`가 성공하면 Windows가 표준 핸들 셋을
// 그 콘솔의 핸들로 **덮어쓴다** — 부모(cmd)가 `> out.txt`로 넘겨 준 파일 핸들
// (FILE_TYPE_DISK)이 콘솔 핸들(FILE_TYPE_CHAR)로 바뀐다. 그 뒤에 판정하면
// "리다이렉트 안 됨"으로 읽혀 `freopen(CONOUT$)`가 stdout을 콘솔로 보내고,
// 파일에는 붙이기 전에 찍힌 세 줄만 남았다. `AllocConsole` 경로(부모에 콘솔이
// 없는 bash·Start-Process)는 핸들을 보존해서 같은 명령이 거기서는 멀쩡했다 —
// 실행 환경에 따라 출력이 있다가 없어지는 형태였다. 그래서 붙이기 전 판정값을
// 쓰고, 덮어써진 핸들은 원래 것으로 되돌린다(CRT의 fd 1은 애초에 원래 핸들을
// 쥐고 있으므로 되돌리는 것은 Win32 표준 핸들 조회를 쓰는 쪽을 위해서다).
struct StandardStream { DWORD id; HANDLE handle; bool redirected; };
StandardStream streams[] = {
{ STD_INPUT_HANDLE, nullptr, false },
{ STD_OUTPUT_HANDLE, nullptr, false },
{ STD_ERROR_HANDLE, nullptr, false },
};
for (StandardStream& stream : streams)
{
stream.handle = ::GetStdHandle(stream.id);
if (nullptr == stream.handle || INVALID_HANDLE_VALUE == stream.handle) continue;
const DWORD type = ::GetFileType(stream.handle);
stream.redirected = FILE_TYPE_DISK == type || FILE_TYPE_PIPE == type;
}
// 콘솔을 확보한다(GUI 앱이라 기본적으로 없다). 터미널에서 실행한 경우에는
// 그 터미널에 그대로 붙고, 부모 콘솔이 없을 때만(탐색기에서 더블클릭 등) 새
// 콘솔을 만든다 — 이때 어떤 터미널이 열리는지는 Windows 11의 "기본 터미널 앱"
// 설정을 따른다.
if (nullptr == ::GetConsoleWindow())
{
if (::AttachConsole(ATTACH_PARENT_PROCESS) || ::AllocConsole())
{
for (const StandardStream& stream : streams)
if (stream.redirected) ::SetStdHandle(stream.id, stream.handle);
}
}
// 콘솔을 끝내 못 얻었으면 CONOUT$ 열기가 실패하고, freopen_s는 실패해도 원래
// 스트림을 닫아 버린다 — 리다이렉트도 콘솔도 없는 스트림만 그대로 둔다.
const bool hasConsole = nullptr != ::GetConsoleWindow();
FILE* dummy = nullptr;
if (hasConsole && !streams[0].redirected) freopen_s(&dummy, "CONIN$", "r", stdin);
if (hasConsole && !streams[1].redirected) freopen_s(&dummy, "CONOUT$", "w", stdout);
if (hasConsole && !streams[2].redirected) freopen_s(&dummy, "CONOUT$", "w", stderr);
std::ios::sync_with_stdio(true);
// ★ 버퍼링을 끈다.
//
// 씬 로드가 멈추는 것을 쫓다가 출력이 0바이트인 실행을 만났다.
// 프로세스를 죽여도 아무것도 안 남아서, 어디까지 갔는지조차 알 수
// 없었다 — 멈춘 자리를 찾는 일에 로그가 없는 것이 가장 나쁘다.
//
// 버퍼링을 끄면 느려지지만, 이 경로는 진단용 CLI라 그 대가가 싸다.
//
// ★★ 다만 그 "느려짐"은 stdout이 **파이프**일 때 심하다 — 무버퍼 printf 한 줄이
// 파이프 write 한 번이 되고, `Start-Process -RedirectStandardOutput` 아래에서는
// 그 한 번이 8~18 ms의 대기로 실측됐다(2026-09-10). 로드 경로 안에서 찍는
// `[scene.document]` 같은 줄이 그대로 SceneParse 계측에 얹혀 909B 씬이 36 ms로
// 읽혔다. 시간을 재는 게이트는 파일 핸들로 리다이렉트해야 한다
// (verify-serialization-baseline.ps1 참조).
setvbuf(stdout, nullptr, _IONBF, 0);
setvbuf(stderr, nullptr, _IONBF, 0);
// 로그와 명령 출력이 한글을 쓰므로 UTF-8로 맞춘다.
// (기본 코드페이지 949에서는 소스의 UTF-8 문자열이 깨진다)
::SetConsoleOutputCP(CP_UTF8);
::SetConsoleCP(CP_UTF8);
}
}
ConsoleCommandSystem& ConsoleCommandSystem::Get()
{
static ConsoleCommandSystem instance;
return instance;
}
void ConsoleCommandSystem::InitializeFromCommandLine()
{
int argc = 0;
LPWSTR* argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
if (!argv) return;
bool wantConsole = false;
// 표준 입력을 읽는 스레드는 --console에서만 띄운다.
//
// --script / --exec는 명령이 파일과 인자에서 오므로 타이핑할 사람이 없다.
// 그런데도 예전에는 세 경우 모두 리더를 띄웠고, 그 스레드는 종료 시점에
// getline에 갇혀 있다가 detach됐다 — 회귀 세트(전부 --script)에서만 나타난
// 종료 구간 힙 손상(0xC0000374)의 구조적 원인이다.
// 콘솔 자체(출력)는 세 경우 모두 필요하므로 wantConsole과는 분리한다.
bool wantStdinReader = false;
bool hasBatchInput = false;
bool wantCommandService = false;
// `--allow-user-code`. **서비스에만 걸린다**(§8 · LC7).
//
// ★ 배치(`--exec`·`--script`)와 stdin 은 이 플래그를 보지 않는다. 그 경로로
// 명령을 넣는 사람은 이미 이 기계에서 이 실행 파일을 인자와 함께 띄운
// 사람이고, 그에게 사용자 코드 호출을 한 번 더 묻는 것은 통제가 아니라
// 의식이다. §8 이 통제를 요구하는 것은 **프로세스 경계 밖**에서 오는
// 호출이고, 그것이 서비스다.
bool wantUserCode = false;
auto toUtf8 = [](const wchar_t* w) -> std::string
{
if (!w) return {};
const int need = ::WideCharToMultiByte(CP_UTF8, 0, w, -1, nullptr, 0, nullptr, nullptr);
std::string s(need > 0 ? need - 1 : 0, '\0');
if (need > 1) ::WideCharToMultiByte(CP_UTF8, 0, w, -1, s.data(), need, nullptr, nullptr);
return s;
};
for (int i = 1; i < argc; ++i)
{
const std::string arg = toUtf8(argv[i]);
if (arg == "--commandlet")
{
if (m_commandletMode) m_commandletError = "Only one --commandlet is allowed";
m_commandletMode = true;
wantConsole = true;
m_resultJsonl = true;
for (++i; i < argc; ++i)
{
std::string token = toUtf8(argv[i]);
if (token == "--") break;
m_commandletArguments.push_back(std::move(token));
}
}
else if (arg == "--commandlet-script")
{
if (m_commandletMode) m_commandletError = "Only one Commandlet input is allowed";
m_commandletMode = true;
if (i + 1 < argc) m_commandletScript = toUtf8(argv[++i]);
else m_commandletError = "--commandlet-script requires a file path";
wantConsole = true;
m_resultJsonl = true;
}
else if (arg == "--exec" && i + 1 < argc)
{
hasBatchInput = true;
Enqueue(toUtf8(argv[++i]));
wantConsole = true;
}
else if (arg == "--script" && i + 1 < argc)
{
hasBatchInput = true;
LoadScriptFile(toUtf8(argv[++i]));
wantConsole = true;
}
else if (arg == "--console")
{
wantConsole = true;
wantStdinReader = true;
}
else if (arg == "--heapcheck")
{
EnableHeapValidation();
}
else if (arg == "--exec-args" && i + 1 < argc)
{
// `--exec-args <명령> [인자]... [--]`
//
// ★ 이것이 오늘 존재하는 **구조화 입력 경로**다. OS 가 이미 갈라 준
// argv 를 라인으로 이어 붙이지 않고 그대로 owned argument 로 쓴다.
// 따옴표 있는 이름을 라인 경로와 구조화 경로 양쪽으로 넣어 같은
// invocation 이 나오는지 단정할 수 있게 됐다(§14.2).
//
// `--` 로 끝낸다. 처음에는 남은 argv 를 전부 먹게 했는데, 그러면
// **뒤에 `--exec quit` 을 붙일 수가 없어** 무인 실행이 종료하지
// 못하고 하네스 타임아웃까지 살아 있었다. 실제로 겪었다.
// `--` 가 없으면 끝까지 먹는 것은 그대로 둔다(대화형 편의).
std::vector<std::string> arguments;
int j = i + 1;
for (; j < argc; ++j)
{
std::string token = toUtf8(argv[j]);
if ("--" == token) { ++j; break; }
arguments.push_back(std::move(token));
}
i = j - 1;
hasBatchInput = true;
EnqueueStructured(std::move(arguments));
wantConsole = true;
}
else if (arg == "--command-service")
{
// ★ 기본은 off 다(§8). 켜는 것은 이 명시 플래그뿐이다.
//
// 서비스는 실행 표면이라, "설정 파일에 켜져 있었다"로 열리면
// 안 된다. 실패해도 에디터는 계속 뜬다 — 서비스가 안 열린 것과
// 에디터가 못 뜨는 것은 다른 사건이다.
wantCommandService = true;
}
else if (arg == "--allow-user-code")
{
// ★ 서비스를 켠 것과 사용자 코드를 연 것은 다른 결정이다(§8 · LC7).
//
// `--command-service` 안에는 씬을 열고 자산을 저작하는 것까지
// 들어 있지만, 그것들은 전부 **엔진이 쓴 코드**가 하는 일이다.
// `script.invoke` 는 엔진이 쓰지 않은 코드를 부른다. 둘을 한
// 플래그에 묶으면 서비스를 켜는 모든 실행이 뒤엣것에 동의한
// 셈이 되므로, 동의를 따로 받는다.
//
// 이 플래그만 주고 서비스를 안 켜면 아무 일도 하지 않는다 —
// 배치·stdin 경로는 애초에 이 플래그를 보지 않는다(아래 주석).
wantUserCode = true;
}
else if (arg == "--result-format" && i + 1 < argc)
{
// LC9 — 배치 결과를 schema v1 JSONL 로 낸다(§18).
//
// ★ 오늘 형식은 `jsonl` 하나다. 그래도 값을 받는 이유는 이름이
// `--result-jsonl` 이 아니라 `--result-format` 이기 때문이다 —
// 계획이 그 이름을 골랐고, 그 이름은 값이 늘어날 자리를 약속한다.
// 모르는 값을 조용히 무시하면 오타가 "형식 없음"으로 지나간다.
const std::string format = toUtf8(argv[++i]);
if ("jsonl" == format) { m_resultJsonl = true; }
else
{
std::fprintf(stderr, "[CLI] 알 수 없는 --result-format: %s (jsonl)\n",
format.c_str());
std::fflush(stderr);
// ★ `SetExitCode` 를 직접 부르지 않는다(§14.1).
//
// 여기는 명령이 아니라 **인자** 오류라 CommandResult 를 낼
// 핸들러가 없다. 그래도 exit code 를 직접 쓰면 "쓰는 곳은
// session 하나" 라는 불변식이 깨지고, 뒤의 성공이 이 실패를
// 지우는 옛 구조로 한 걸음 돌아간다. session 에 기록하면
// 그 한 곳이 §5.4 의 2 를 정한다.
CommandCore::CommandSession::Batch().Record("--result-format",
CommandCore::InvalidArguments(
"알 수 없는 --result-format: " + format, "args.invalid"));
m_quitRequested.store(true, std::memory_order_release);
}
}
else if (arg == "--result-file" && i + 1 < argc)
{
m_resultFilePath = toUtf8(argv[++i]);
}
else if (arg == "--fail-fast")
{
// 기본은 continue + aggregate 다(§3.1). 시나리오 하나가 실패해도
// 뒤의 진단 명령이 돌아야 무엇이 왜 실패했는지 같은 실행에서 본다.
// --fail-fast 는 그 반대를 원하는 호출자(이등분 탐색 등)를 위한 것이다.
CommandCore::CommandSession::Batch().SetFailFast(true);
}
}
::LocalFree(argv);
if (m_commandletMode)
{
if (hasBatchInput || wantStdinReader || wantCommandService)
m_commandletError = "--commandlet cannot be combined with batch, console or HTTP service";
wantCommandService = false;
wantStdinReader = false;
std::lock_guard<std::mutex> guard(m_mutex);
m_pending.clear();
}
// LC4: 서비스를 켠다. 배치 프론트엔드와 독립이라 --console 과 무관하게 뜬다.
if (wantCommandService)
{
// ★ 표를 **열기 전에** 채운다.
//
// `--command-service` 만 준 실행에는 배치 입력이 없어서, 첫 HTTP 요청이
// 올 때까지 `ExecuteParsed` 가 한 번도 안 돈다 = registry 가 비어 있다.
// 그 상태로 수신 스레드를 띄우면 첫 요청의 `cost` 조회가 빗나가 Long
// 명령이 동기로 돌고(LC5 가 존재하는 이유가 사라진다), 동시에 게임
// 스레드가 211 개를 밀어 넣는 중인 vector 를 수신 스레드가 훑는다.
EnsureRegistryPopulated();
std::string error;
if (EditorCommandService::Start(PathFinder::BaseProjectPath().string(), wantUserCode, error))
{
std::printf("[CLI] command service listening 127.0.0.1:%u\n",
static_cast<unsigned>(EditorCommandService::Port()));
// 사용자 코드를 열었다는 것은 **로그에 남아야 한다.** 이 실행의
// 표면이 다른 실행과 다르고, 감사에서 그 사실이 보여야 한다.
if (wantUserCode)
{
std::printf("[CLI] command service: 사용자 코드 실행 허용 "
"(--allow-user-code)\n");
}
}
else
{
std::fprintf(stderr, "[CLI] command service 시작 실패: %s\n", error.c_str());
std::fflush(stderr);
}
}
if (wantConsole)
{
// 스크립트로 돌리는 실행에서는 크래시 때 대화상자를 띄우지 않는다 —
// 답할 사람이 없어 그대로 멈춰 있다가 덤프도 없이 죽는다.
CoreWindow::SetUnattended(true);
SuppressCrtDialogs();
EnsureConsole();
if (wantStdinReader)
{
std::printf("[CLI] 콘솔 명령 사용 가능. 'help' 입력.\n");
StartStdinReader();
}
}
// 스크립트를 못 열었는데 타이핑할 사람도 없다면, 이 실행은 아무것도 하지
// 못한다. 계속 돌게 두면 하네스가 타임아웃으로 죽여야 하고 원인도 안 보인다.
if (m_scriptLoadFailed && !wantStdinReader && !m_commandletMode)
{
std::fputs("[CLI] 실행할 명령이 없어 종료한다.\n", stderr);
std::fflush(stderr);
m_quitRequested.store(true, std::memory_order_release);
}
}
void ConsoleCommandSystem::StartStdinReader()
{
if (m_running.exchange(true)) return;
// 표준 입력은 블로킹이므로 별도 스레드에서 읽고 큐에만 넣는다.
// 실제 실행은 Pump()가 게임 스레드에서 수행한다.
m_stdinDone = std::promise<void>{};
m_stdinDoneFuture = m_stdinDone.get_future();
m_stdinThread = std::thread([this]
{
// 어떤 경로로 빠져나가든 종료 사실을 알린다. Shutdown이 이걸 기다린다.
struct DoneSignal
{
std::promise<void>& promise;
~DoneSignal() { promise.set_value(); }
} signal{ m_stdinDone };
std::string line;
while (m_running.load(std::memory_order_acquire) && std::getline(std::cin, line))
{
Enqueue(line);
}
});
}
void ConsoleCommandSystem::LoadScriptFile(const std::string& path)
{
std::ifstream file(path);
if (!file)
{
// 로그에만 남기면 아무도 못 본다. 실행 인자를 준 쪽은 대개 자동화라
// 콘솔을 보고 있지 않고, 명령이 하나도 없는 에디터는 quit도 받지 못한 채
// 그냥 계속 돈다 — 하네스가 타임아웃으로 죽을 때까지. 실제로 겪었다.
std::fprintf(stderr, "[CLI] 스크립트를 열 수 없습니다: %s\n", path.c_str());
std::fflush(stderr);
Debug->LogError("[CLI] 스크립트를 열 수 없습니다: " + path);
m_scriptLoadFailed = true;
return;
}
std::string line;
while (std::getline(file, line))
{
const std::string trimmed = TrimLine(line);
if (trimmed.empty() || trimmed[0] == '#') continue; // 주석/빈 줄 무시
Enqueue(trimmed);
}
}
void ConsoleCommandSystem::Enqueue(std::string command)
{
PendingCommand pending;
pending.text = std::move(command);
pending.enqueuedAt = std::chrono::steady_clock::now();
pending.enqueuedFrame = m_frameIndex.load(std::memory_order_acquire);
std::lock_guard<std::mutex> guard(m_mutex);
m_pending.push_back(std::move(pending));
}
void ConsoleCommandSystem::EnqueueStructured(std::vector<std::string> arguments)
{
EnqueueStructured(std::move(arguments), CommandCompletion{});
}
ConsoleCommandSystem::ServiceStatus ConsoleCommandSystem::SnapshotStatus() const
{
ServiceStatus status;
status.frame = m_frameIndex.load(std::memory_order_acquire);
status.executing = m_executing.load(std::memory_order_acquire);
{
// ★ 이 락은 실행 중에는 잡혀 있지 않다.
//
// `Pump()` 는 큐에서 꺼낼 때만 잡고 곧 놓은 뒤 명령을 실행한다.
// 그래서 `scene.load` 가 2.4초 도는 동안에도 여기서 즉시 잠긴다 —
// 그것이 §7.3 이 성립하는 이유다.
std::lock_guard<std::mutex> guard(m_mutex);
status.serviceQueueDepth = m_servicePending.size();
status.batchQueueDepth = m_pending.size();
if (!m_servicePending.empty())
{
const std::chrono::duration<double, std::milli> age =
std::chrono::steady_clock::now() - m_servicePending.front().enqueuedAt;
status.oldestQueuedMs = age.count();
}
}
status.sceneLoading = m_sceneLoading.load(std::memory_order_acquire);
status.waitFramesRemaining = m_waitFramesRemaining.load(std::memory_order_acquire);
{
std::lock_guard<std::mutex> guard(m_statusMutex);
status.currentCommand = m_currentCommand;
}
return status;
}
bool ConsoleCommandSystem::EnqueueStructured(std::vector<std::string> arguments,
CommandCompletion completion,
std::size_t serviceQueueCap)
{
if (arguments.empty()) return false;
PendingCommand pending;
pending.completion = std::move(completion);
// 진단용 재구성. **이 문자열은 다시 파싱되지 않는다** — 실행은 arguments 로
// 한다. 로그와 계측이 "무엇을 불렀나"를 사람 눈으로 볼 수 있게만 만든다.
for (const std::string& argument : arguments)
{
if (!pending.text.empty()) pending.text.push_back(' ');
pending.text += argument;
}
pending.arguments = std::move(arguments);
pending.enqueuedAt = std::chrono::steady_clock::now();
pending.enqueuedFrame = m_frameIndex.load(std::memory_order_acquire);
// completion 이 있으면 결과를 기다리는 사람이 있다는 뜻이고, 오늘 그것은
// 서비스뿐이다. `--exec-args` 는 completion 없이 들어와 배치 큐로 간다.
pending.fromService = static_cast<bool>(pending.completion);
std::lock_guard<std::mutex> guard(m_mutex);
if (pending.fromService)
{
// 상한 확인과 적재가 **같은 락 안**이다. 이것이 상한을 불변식으로
// 만드는 유일한 배치다 — 서비스 쪽의 사전 검사는 빠른 길일 뿐이다.
if (0 != serviceQueueCap && m_servicePending.size() >= serviceQueueCap) return false;
m_servicePending.push_back(std::move(pending));
}
else
{
m_pending.push_back(std::move(pending));
}
return true;
}
void ConsoleCommandSystem::Pump()
{
// 생명주기 기록기의 프레임 경계(PHASE 9-0).
//
// 여기에 두는 이유는 이 함수가 이미 "게임 스레드에서 프레임마다 정확히 한 번"이고,
// 그 성질을 가진 자리를 새로 만들면 엔진 루프에 진단용 호출이 하나 더 늘기 때문이다.
// 아래 조기 반환들보다 앞이어야 한다 — wait 중이거나 씬 로딩 중인 프레임도
// 프레임이고, 그 사이에 일어난 Awake/OnDestroy가 어느 프레임 것인지 알아야 한다.
Lifecycle::Trace::BeginFrame();
// Count every editor frame, including frames waiting for scene activation.
const uint64_t frameIndex = m_frameIndex.fetch_add(1, std::memory_order_acq_rel) + 1;
const bool sceneLoading = SceneManagers->IsSceneLoading();
// ★ 조기 반환 사유를 **밖에서 볼 수 있게 남긴다.**
//
// 아래 두 반환은 서비스 큐까지 통째로 멈춘다. 그 구간에는 `RunOne` 에
// 들어가지 않으므로 `m_executing` 도 `m_currentCommand` 도 비어 있고,
// `/health` 는 "idle"을 낸다 — HTTP 클라이언트는 자기 요청이 씬 전환에
// 막혀 있는 동안 한가한 서버를 본다. LC0 실측으로 이 구간은 2.4초까지
// 간다. 멈춘 것을 한가한 것으로 내면 §7.3 이 성립하지 않는다.
m_sceneLoading.store(sceneLoading, std::memory_order_release);
m_waitFramesRemaining.store(static_cast<uint32_t>(m_waitFrames), std::memory_order_release);
if (m_waitResult)
{
std::optional<CommandCore::CommandResult> result;
try { result = m_waitResult(); }
catch (const std::exception& error) { result = CommandCore::InternalError("commandlet.poll_exception", error.what()); }
catch (...) { result = CommandCore::InternalError("commandlet.poll_exception", "Deferred command failed"); }
if (!result) return;
m_waitResult = {};
auto finish = std::move(m_finishWait);
finish(*result);
return;
}
// wait 명령으로 보류 중이면 프레임만 소모한다.
if (m_waitFrames > 0)
{
--m_waitFrames;
return;
}
// 씬 로딩이 끝나기 전에는 다음 명령을 실행하지 않는다.
// (전환 중 측정하면 중간값이 섞인다)
if (sceneLoading) return;
if (m_commandletMode && !m_commandletDone)
{
m_commandletDone = true;
EnsureRegistryPopulated();
if (!m_commandletError.empty())
{
const auto result = CommandCore::InvalidArguments(m_commandletError, "commandlet.mode_conflict");
PublishResult("--commandlet", result);
WriteResultLine("--commandlet", result, 0, 0, 0);
RequestQuit();
return;
}
if (!m_commandletScript.empty())
{
LoadScriptFile(m_commandletScript);
if (m_scriptLoadFailed)
{
const auto result = CommandCore::InvalidArguments("Cannot open commandlet script", "commandlet.script_missing");
PublishResult("--commandlet-script", result); WriteResultLine("--commandlet-script", result, 0, 0, 0);
RequestQuit(); return;
}
}
else if (!m_commandletArguments.empty() && CommandCore::CommandRegistry::Commandlets().Find(m_commandletArguments[0]))
EnqueueStructured(m_commandletArguments);
else
{
const auto result = EditorCommandlets::Run(m_commandletArguments);
const auto name = m_commandletArguments.empty() ? "--commandlet" : m_commandletArguments[0];
PublishResult(name, result);
WriteResultLine(name, result, 0, 0, 0);
RequestQuit();
return;
}
}
// ── 배치 큐: 프레임당 정확히 하나 (§7.2 · 기존 의미 보존) ───────────
//
// 이 수를 바꾸면 프레임 수로 시간을 재는 기존 시나리오의 측정값이 조용히
// 이동한다. `wait N` 이 정확히 N 프레임이라는 전제도 여기 걸려 있다.
{
PendingCommand pending;
bool has = false;
{
std::lock_guard<std::mutex> guard(m_mutex);
if (!m_pending.empty())
{
pending = std::move(m_pending.front());
m_pending.pop_front();
has = true;
}
}
if (has) RunOne(std::move(pending), frameIndex);
else if (m_commandletMode) { RequestQuit(); return; }
}
if (m_waitResult) return;
// ── 서비스 큐: 예산만큼 (§7.2) ──────────────────────────────────────
//
// 명령 N 개가 N 프레임을 기다리지 않게 하는 자리다. 예산은 시간과 개수 둘
// 다이고, `cost=Long` 을 만나면 이번 프레임은 그것 하나만 돈다 — 긴 명령이
// 예산 안에서 다른 명령의 지연을 통째로 먹지 않게.
const double budgetMs = m_drainTimeMs.load(std::memory_order_relaxed);
const std::size_t budgetCount = m_drainCount.load(std::memory_order_relaxed);
const auto drainBegan = std::chrono::steady_clock::now();
for (std::size_t drained = 0; drained < budgetCount; ++drained)
{
if (drained > 0)
{
const std::chrono::duration<double, std::milli> spent =
std::chrono::steady_clock::now() - drainBegan;
if (spent.count() >= budgetMs) break;
}
PendingCommand pending;
bool isLong = false;
bool needsFrame = false;
{
std::lock_guard<std::mutex> guard(m_mutex);
if (m_servicePending.empty()) break;
// 비용을 **꺼내기 전에** 본다. 긴 명령을 만났는데 이미 이 프레임에서
// 뭔가 돌렸다면 다음 프레임으로 미룬다.
const std::string& name = m_servicePending.front().arguments.empty()
? m_servicePending.front().text
: m_servicePending.front().arguments.front();
if (const CommandCore::CommandDescriptor* descriptor =
CommandCore::CommandRegistry::Get().Find(name))
{
isLong = (CommandCore::CommandCost::Long == descriptor->cost);
needsFrame = (CommandCore::CommandCost::Immediate != descriptor->cost);
}
if (isLong && drained > 0) break;
pending = std::move(m_servicePending.front());
m_servicePending.pop_front();
}
RunOne(std::move(pending), frameIndex);
if (needsFrame) break; // Let frame-end lifecycle work finish before the next mutation.
}
}
bool ConsoleCommandSystem::RunOne(PendingCommand pending, uint64_t frameIndex)
{
const std::string line = TrimLine(pending.text);
const auto dequeuedAt = std::chrono::steady_clock::now();
// 실행 중임을 GT 밖에서도 볼 수 있게 찍는다(LC4). 큐 락은 이미 놓았으므로
// 서비스는 이 명령이 오래 돌아도 상태를 읽을 수 있다.
{
const auto nameEnd = line.find_first_of(" \t");
std::lock_guard<std::mutex> guard(m_statusMutex);
m_currentCommand = line.substr(0, (nameEnd == std::string::npos) ? line.size() : nameEnd);
}
m_executing.store(true, std::memory_order_release);
m_executingFromService = pending.fromService;
// 구조화 입력은 라인 문법을 거치지 않는다(LC2). 재구성한 문자열은 진단용
// 으로만 넘긴다 — 그것을 다시 파싱하면 §3.2 의 왕복 손실이 되살아난다.
const CommandCore::CommandResult result = pending.IsStructured()
? ExecuteParsed(pending.arguments, line)
: Execute(line);
auto finish = [this, pending = std::move(pending), frameIndex, dequeuedAt, line](const CommandCore::CommandResult& result)
{
const auto finishedAt = std::chrono::steady_clock::now();
m_executing.store(false, std::memory_order_release);
m_executingFromService = false;
{
std::lock_guard<std::mutex> guard(m_statusMutex);
m_currentCommand.clear();
}
// 이름만 남긴다. 인자는 경로·오브젝트 이름이 섞여 있어 계측 artifact에
// 그대로 실으면 기계마다 다른 문자열이 들어간다.
const std::string_view name = pending.IsStructured()
? std::string_view(pending.arguments[0])
: [&line]
{
const auto nameEnd = line.find_first_of(" \t");
return std::string_view(line).substr(
0, (nameEnd == std::string::npos) ? line.size() : nameEnd);
}();
// ★ 서비스 명령은 배치 session 에 누적하지 않는다(LC5).
//
// LC4 직후에는 둘이 같은 session 을 썼다. 그래서 HTTP 로 부른
// `scene.load` 하나가 실패하면 **에디터 프로세스가 exit 3 으로 끝났다** —
// 배치 시나리오는 아무 잘못이 없는데 그 판정이 뒤집힌다. 배치 session 은
// 배치의 판정이어야 한다. 서비스 요청의 판정은 HTTP 응답으로 간다.
if (!name.empty() && !pending.fromService)
{
PublishResult(std::string(name), result);
}
const std::chrono::duration<double, std::milli> queued = dequeuedAt - pending.enqueuedAt;
const std::chrono::duration<double, std::milli> executed = finishedAt - dequeuedAt;
const uint64_t waitedFrames = (frameIndex > pending.enqueuedFrame)
? (frameIndex - pending.enqueuedFrame) : 0;
// ── LC9: 배치 결과를 기계가 읽는 형태로 낸다 (§18) ──────────────────
//
// ★ 서비스 요청은 제외한다. 그쪽 판정은 HTTP 응답으로 가고, JSONL 은 **배치
// 시나리오의 기록**이다. 둘을 한 파일에 섞으면 `--script` 하나를 돌린
// 소비자가 자기가 넣지 않은 줄을 읽게 된다.
if (!name.empty() && !pending.fromService)
{
WriteResultLine(std::string(name), result,
queued.count(), static_cast<uint32_t>(waitedFrames), executed.count());
}
// 결과를 기다리는 사람(LC4 의 수신 스레드)을 깨운다. **GT 에서 불린다** —
// 여기서 오래 걸리면 다음 프레임이 밀린다. 어댑터는 값만 넘기고 곧 반환한다.
if (pending.completion)
{
CommandTiming timing;
timing.queuedMs = queued.count();
timing.waitedFrames = static_cast<uint32_t>(waitedFrames);
timing.executedMs = executed.count();
pending.completion(result, timing);
}
};
if (m_waitResult && result.IsSuccess())
{
m_finishWait = std::move(finish);
return true;
}
m_waitResult = {};
finish(result);
return true;
}
void ConsoleCommandSystem::WaitForResult(std::function<std::optional<CommandCore::CommandResult>()> poll)
{
if (!m_commandletMode || m_executingFromService || m_waitResult || !poll)
throw std::logic_error("Deferred results require one active commandlet");
m_waitResult = std::move(poll);
}
std::size_t ConsoleCommandSystem::ServiceQueueDepth() const
{
std::lock_guard<std::mutex> guard(m_mutex);
return m_servicePending.size();
}
std::size_t ConsoleCommandSystem::BatchQueueDepth() const
{
std::lock_guard<std::mutex> guard(m_mutex);
return m_pending.size();
}
void ConsoleCommandSystem::SetDrainBudget(DrainBudget budget) noexcept
{
m_drainTimeMs.store(budget.timeMs, std::memory_order_relaxed);
m_drainCount.store(budget.count, std::memory_order_relaxed);
}
ConsoleCommandSystem::DrainBudget ConsoleCommandSystem::GetDrainBudget() const noexcept
{
DrainBudget budget;
budget.timeMs = m_drainTimeMs.load(std::memory_order_relaxed);
budget.count = m_drainCount.load(std::memory_order_relaxed);
return budget;
}
namespace
{
// 리플렉션 골든 덤프(PHASE 18 CT0). 등록된 전 타입을 기본 생성해
// Meta::Serialize 출력을 한 문서로 쓴다 — 컴파일타임 전환(CT4~CT5) 동안
// "직렬화 출력이 한 글자도 안 변했다"를 diff 0으로 증명하는 자다.
// 씬·프리팹 콘텐츠에 기대지 않으므로 게임 데이터가 바뀌어도 흔들리지 않는다.
CommandCore::CommandResult HandleReflectGolden(const std::vector<std::string>& parts)
{
using namespace CommandCore;
if (parts.size() > 2) return InvalidArguments("reflect.golden [path]");
const std::string outPath = (parts.size() > 1) ? parts[1] : std::string("reflect_golden.yaml");
auto names = Meta::Registry::GetInstance()->GetAllTypeNames();
std::sort(names.begin(), names.end()); // unordered_map 순회 순서를 고정한다
Authoring::WriteDocument document;
const Authoring::WriteNode root = document.Root();
root.SetMap();
int serialized = 0;
int noFactory = 0;
int failed = 0;
for (const auto& name : names)
{
// CT11-b: 이름은 정본 Type::name의 view — yaml 키로 쓸 때만 문자열화.
const std::string key(name);
const Meta::Type* type = Meta::Registry::GetInstance()->Find(name);
if (nullptr == type)
{
continue;
}
// CT11: 팩토리 접합 — Type이 생성 함수를 직접 든다.
void* instance = type->create ? type->create() : nullptr;
if (nullptr == instance)
{
// 팩토리 미등록(자동 등록 경로 밖에서 Reflect만 가진 중첩 구조체 등).
// 누락이 아니라 커버리지 한계다 — 목록으로 남겨 diff 대상에 포함한다.
Authoring::WriteNode noFactoryNode = root.Child("__no_factory__");
if (!noFactoryNode.Read().IsSequence()) noFactoryNode.SetSequence();
noFactoryNode.Append().SetScalar(key);
++noFactory;
continue;
}
try
{
Meta::SerializeInto(instance, *type, root.Child(key));
++serialized;
}
catch (const std::exception& e)
{
root.Child("__failed__").Child(key).SetScalar(e.what());
++failed;
}
// instance는 의도적으로 해제하지 않는다 — Type::create에 void*
// 파괴 경로가 없고, 이 명령은 종료 직전 시나리오에서만 쓰인다.
}
std::ofstream out(outPath, std::ios::binary);
if (!out)
{
std::printf("[CLI] reflect.golden: 출력 파일을 열 수 없음: %s\n", outPath.c_str());
return Fail("reflection.write_failed", "Cannot open output: " + outPath);
}
out << "# reflect.golden — 등록 전 타입 default-Serialize 덤프 (PHASE 18 CT0)\n"
<< document.Dump();
out.close();
if (!out) return Fail("reflection.write_failed", "Cannot write output: " + outPath);