forked from stride3d/stride
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTemplatePreprocessor.cs
More file actions
1426 lines (1303 loc) · 71.7 KB
/
Copy pathTemplatePreprocessor.cs
File metadata and controls
1426 lines (1303 loc) · 71.7 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
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Stride.Assets.Templates;
using Stride.Core;
using Stride.Core.Diagnostics;
namespace Stride.TemplateGenerator;
/// <summary>
/// Transforms a raw sample dir into a staged tree that the dotnet new template engine can pack.
/// See <see cref="Run"/> for the inline-documented step sequence.
/// </summary>
internal class TemplatePreprocessor
{
/// <summary>Standard dashed GUID format: 8-4-4-4-12 hex digits.</summary>
private static readonly Regex GuidRegex = new(
@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
RegexOptions.Compiled);
/// <summary>
/// Well-known constant GUIDs that look like instance identifiers but are actually fixed type
/// markers — replacing them with per-instantiation values would break tooling. Skipped by both
/// the scan and the substitution passes.
/// </summary>
private static readonly HashSet<Guid> ReservedGuids = new()
{
// .sln project type GUIDs (Microsoft well-knowns).
new("9A19103F-16F7-4668-BE54-9A1E7A4F7556"), // SDK-style C# project
new("FAE04EC0-301F-11D3-BF4B-00C04F79EFBC"), // Legacy C# project
new("2150E333-8FDC-42A3-9474-1A3956D46DE8"), // Solution folder
};
/// <summary>
/// Non-Stride file extensions treated as text for the GUID placeholder + line-ending passes.
/// Stride assets (any <c>.sd*</c> extension) are picked up via <see cref="IsTextFile"/> so
/// plugin-defined asset types stay covered without explicit listing.
/// </summary>
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".cs", ".csproj", ".sln", ".slnx", ".targets", ".props", ".config",
".json", ".yaml", ".yml",
".xml", ".html", ".htm",
".md", ".txt", ".gitignore", ".gitattributes", ".editorconfig",
};
/// <summary>
/// Stride asset extensions that store binary payload (rather than the typical YAML).
/// Anything <c>.sd*</c> not in this set is treated as text.
/// </summary>
private static readonly HashSet<string> BinaryStrideExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".sdimg",
};
/// <summary>
/// True for files whose content the preprocessor may safely read + rewrite (GUID substitution,
/// line-ending normalization). Covers non-Stride text extensions plus all <c>.sd*</c> asset
/// extensions except the known-binary ones.
/// </summary>
private static bool IsTextFile(string path)
{
var ext = Path.GetExtension(path);
if (TextExtensions.Contains(ext))
return true;
if (ext.StartsWith(".sd", StringComparison.OrdinalIgnoreCase))
return !BinaryStrideExtensions.Contains(ext);
return false;
}
public string? InputPath { get; set; }
public string? OutputDirectory { get; set; }
public string? TemplateName { get; set; }
/// <summary>
/// When set, staged <c>.csproj</c> files get this engine version stamped in: every literal
/// <c>$EngineVersion$</c> (NewGame starter) and every concrete engine-family <c>Stride.*</c>
/// <c>PackageReference</c> version (samples commit the samples-authority version, which may
/// not match — or even restore against — the engine being packed). Stamping at pack time
/// makes the content instantiable as-is by every consumer, including plain <c>dotnet new</c>,
/// which has no Stride-side rewrite hook; instantiation-side upgrading remains only for
/// running a template against a newer engine than it was packed with.
/// </summary>
public string? EngineVersion { get; set; }
/// <summary>
/// Parsed from the input's <c>.sdtpl</c> file (if present). Drives template.json metadata
/// (Name, Description, identity, etc.) and per-template parameter opt-in via the
/// <see cref="SdtplMetadata.Parameters"/> list. Null when the input dir has no .sdtpl —
/// preprocessor falls back to default behavior in that case.
/// </summary>
public SdtplMetadata? Sdtpl { get; private set; }
/// <summary>
/// Original literal name to rename to <c>MyTemplate</c> across all staged content (file
/// contents, file names, dir names). When null, auto-detected from the first <c>.sdpkg</c>'s
/// <c>Name</c> field with any <c>.Game</c> / <c>.Windows</c> / etc. suffix stripped. Set
/// explicitly via <c>--source-name=X</c> to override.
///
/// No-op when the detected name is already <c>MyTemplate</c> (e.g. NewGame scaffold case).
/// Required for sample templates: their content is full of literal <c>CSharpBeginner</c>
/// (or whatever) refs in .cs namespaces, .sd* script type refs, .csproj RootNamespace, etc.,
/// none of which the dotnet new template engine's <c>sourceName</c> mechanism can substitute
/// because they don't match the sourceName literal.
/// </summary>
public string? SourceName { get; set; }
/// <summary>
/// When true, skip the asset prune step. Templates ship larger (unreachable assets included);
/// escape hatch for the "minimal" pack mode and for diagnostics.
/// </summary>
public bool SkipPrune { get; set; }
/// <summary>
/// Reference template (NewGame) whose <c>MyTemplate.{Platform}/</c> exec-project folders are
/// copied verbatim into the staged tree for any platform the input sample doesn't already ship.
/// Null disables injection.
/// </summary>
public string? PlatformTemplatePath { get; set; }
/// <summary>
/// Map of original GUID → placeholder index (1-based). Populated by the GUID scan pass,
/// consumed by the placeholder substitution pass and template.json emission.
/// </summary>
public Dictionary<Guid, int> GuidMap { get; } = new();
public bool Run(ILogger logger)
{
if (string.IsNullOrEmpty(InputPath))
{
logger.Error("--preprocess-template requires --input-path=<dir>");
return false;
}
if (string.IsNullOrEmpty(OutputDirectory))
{
logger.Error("--preprocess-template requires --output-path=<dir>");
return false;
}
if (!Directory.Exists(InputPath))
{
logger.Error($"Input path does not exist: {InputPath}");
return false;
}
// Parse the sample's .sdtpl if present. Drives template.json metadata + per-template
// parameter opt-in. Absent .sdtpl is fine — defaults apply.
var sdtplPath = Directory.EnumerateFiles(InputPath, "*.sdtpl", SearchOption.TopDirectoryOnly).FirstOrDefault();
if (sdtplPath != null)
{
Sdtpl = SdtplMetadata.Parse(sdtplPath);
logger.Info($"Loaded metadata from {Path.GetFileName(sdtplPath)} (Name='{Sdtpl.Name}', Parameters=[{string.Join(", ", Sdtpl.Parameters)}])");
}
// Stage: recursively mirror input → output. Clean output first to avoid leftover files
// from prior runs polluting the staged tree.
if (Directory.Exists(OutputDirectory))
Directory.Delete(OutputDirectory, recursive: true);
CopyDirectory(InputPath, OutputDirectory);
logger.Info($"Staged {InputPath} → {OutputDirectory}");
// .sdtpl is metadata for the package, not content for the user. Strip from the staged
// output (the orchestrator's aggregation step reads .sdtpl directly from sample inputs).
foreach (var stagedSdtpl in Directory.EnumerateFiles(OutputDirectory, "*.sdtpl", SearchOption.TopDirectoryOnly))
File.Delete(stagedSdtpl);
// Icon/Screenshot files declared in .sdtpl may point OUTSIDE the sample dir (e.g. the
// genre starters share samples/Templates/.sdtpl/Icon2*.png). The recursive copy above
// only mirrors what's inside the sample dir, so external assets need an explicit copy
// into the staged tree. We deposit them under <output>/.sdtpl/<filename> — sibling to
// sample-local screenshots — and the GameStudio bridge tries that fallback location
// when the as-declared relative path doesn't resolve. Sample-local paths need no work
// (already mirrored by CopyDirectory).
CopyExternalSdtplAssets(sdtplPath, logger);
// Dep collapse: for sample templates whose .csproj references shared external asset packs
// (e.g. Templates/Packs/PrototypingBlocks), inline those packs' Assets/ and Resources/
// content into the staged tree and strip the ProjectReference. Intra-template refs
// (e.g. MyTemplate.Windows → MyTemplate) are preserved. No-op when no .csproj contains
// an external ProjectReference (e.g. the NewGame scaffold).
CollapseProjectReferences(logger);
// Asset pruning: drop any asset under the staged tree that isn't reachable from a root
// (RootAsset, always-mark-as-root type, or transitively depended on by such). For
// dep-collapsed sample templates this typically prunes 80%+ of inlined-pack assets that
// the sample doesn't actually use. Pure-text scan (no PackageSession.Load), so it has no
// engine-assembly dependency and runs in milliseconds.
if (!SkipPrune)
DumbPruneUnreachableAssets(logger);
else
logger.Info("Skipping asset prune");
// Clean up obj/ and bin/ dirs before the rename pass — the obj/project.nuget.cache files
// contain stale sample-name references that would confuse the diff, and walking them is
// slow.
CleanBuildArtifacts(logger);
// Sample-name → MyTemplate rename. Replaces the sample's literal name (CSharpBeginner /
// SpriteStudioDemo / ...) with the sourceName placeholder "MyTemplate" across file
// contents, file names, and directory names. The template engine then sourceName-
// substitutes "MyTemplate" → user's -n value at instantiation. No-op when the detected
// source name is already "MyTemplate" (NewGame scaffold case).
RenameSourceName(logger);
// Copy MyTemplate.{Platform}/ from --platform-template-path for any platform the sample
// didn't author. EmitTemplateJson's per-platform modifiers then gate the result by the
// platforms parameter at instantiation. No-op when the flag isn't set.
InjectMissingPlatforms(logger);
// Generate MyTemplate.sln if absent. Samples typically don't include their sln (input is
// the inner dir, not the sample root); synthesize one referencing every csproj in staging,
// including any platform exec project injected above. Per-platform exec csprojs get wrapped
// in #if (XActive) conditional regions so template.json's SpecialCustomOperations strips
// unselected platforms at instantiation.
GenerateSlnxIfMissing(logger);
// Inject a BasicCameraController placeholder component into the Camera entity, BEFORE
// the GUID scan so the injected component's Id gets caught by the placeholder pass
// (fresh GUID per dotnet new instantiation). The type/assembly prefix is the literal
// "MyTemplate" which the template engine substitutes via sourceName.
InjectCameraScript(logger);
// Rewrite the Android activity ScreenOrientation and the engine GameSettings
// DisplayOrientation into placeholders consumed by the orientation template.json symbols.
// Only when the template opts into the orientation parameter.
if (Sdtpl?.HasParameter("orientation") == true)
InjectOrientationPlaceholders(logger);
// Scan all text files for unique GUIDs, then rewrite with placeholders. A pre-pass
// indexes every Id explicitly declared in the staged .sd* tree (the asset's own Id at
// the top of each file, plus sub-asset Ids like entity / component / render-stage Ids
// declared within); during the GUID rewrite pass, only Ids in that locally-defined set
// are placeholdered. Anything else is a reference to something external (engine
// archetypes like `Archetype: 823a81bf...:DefaultGraphicsCompositorLevel10`, engine
// compositor camera slot Ids referenced bare from `Slot:` fields, etc.) and must survive
// intact through instantiation — otherwise the reference dangles at runtime.
ScanLocallyDefinedIds(logger);
ScanGuids();
ApplyPlaceholders();
logger.Info($"Replaced {GuidMap.Count} unique GUIDs with placeholders");
// Emit .template.config/template.json with one generated/guid symbol per placeholder,
// plus multichoice Platforms, HDR/LDR choice, and per-platform sources/modifiers.
EmitTemplateJson();
logger.Info($"Wrote template.json with {GuidMap.Count} generated/guid symbols");
// Engine-version stamp: $EngineVersion$ literals (NewGame) plus concrete Stride.*
// PackageReference versions (samples), so packed content instantiates against the
// engine it shipped with. See the EngineVersion property doc.
if (!string.IsNullOrEmpty(EngineVersion))
SubstituteEngineVersion(logger);
// Normalize line endings so the nupkg is byte-identical regardless of build host
// (Windows checkout = CRLF, Linux CI = LF) and regardless of which step wrote each
// file. Binary files are excluded via the TextExtensions whitelist.
NormalizeLineEndings(logger);
return true;
}
/// <summary>
/// Final-pass line-ending normalization. <c>.sln</c>/<c>.slnx</c> → CRLF (VS/dotnet write CRLF on
/// edit; matching avoids the "Inconsistent line endings" prompt on first save). Everything
/// else → LF (modern cross-platform convention, matches what <c>dotnet/sdk</c> templates
/// ship). Only files in the <see cref="TextExtensions"/> whitelist are touched — binaries
/// (.dds/.png/.fbx/.wav/...) are left untouched.
/// </summary>
private void NormalizeLineEndings(ILogger logger)
{
var rewritten = 0;
foreach (var path in EnumerateTextFiles(OutputDirectory!))
{
var content = File.ReadAllText(path);
var normalized = content.Replace("\r\n", "\n");
var ext = Path.GetExtension(path);
if (ext.Equals(".sln", StringComparison.OrdinalIgnoreCase) || ext.Equals(".slnx", StringComparison.OrdinalIgnoreCase))
normalized = normalized.Replace("\n", "\r\n");
if (normalized != content)
{
File.WriteAllText(path, normalized);
rewritten++;
}
}
logger.Info($"Normalized line endings in {rewritten} text file(s)");
}
/// <summary>
/// Copies any Icon/Screenshot file referenced by the parsed <see cref="Sdtpl"/> whose
/// canonical location is OUTSIDE <see cref="InputPath"/> into the staged output's
/// <c>.sdtpl/</c> dir. Sample-local references (path resolves inside the sample dir) are
/// already covered by the prior recursive copy and need no further action.
/// </summary>
private void CopyExternalSdtplAssets(string? sdtplPath, ILogger logger)
{
if (Sdtpl == null || sdtplPath == null)
return;
var baseDir = Path.GetFullPath(InputPath!);
var sdtplDir = Path.GetDirectoryName(sdtplPath)!;
var destDir = Path.Combine(OutputDirectory!, ".sdtpl");
void Copy(string? relPath)
{
if (string.IsNullOrEmpty(relPath))
return;
var src = Path.GetFullPath(Path.Combine(sdtplDir, relPath));
if (!File.Exists(src))
{
logger.Warning($"sdtpl asset not found: {relPath} (resolved {src})");
return;
}
// If the asset already lives inside the sample dir, the recursive stage already
// copied it — skip.
if (src.StartsWith(baseDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
src.StartsWith(baseDir + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
return;
Directory.CreateDirectory(destDir);
var dest = Path.Combine(destDir, Path.GetFileName(src));
File.Copy(src, dest, overwrite: true);
logger.Info($"Copied external sdtpl asset {Path.GetFileName(src)} → .sdtpl/");
}
Copy(Sdtpl.Icon);
foreach (var screenshot in Sdtpl.Screenshots)
Copy(screenshot);
}
/// <summary>Matches a <c>Stride*</c> Include + Version attribute pair (PackageReference shape).</summary>
private static readonly Regex StridePackageReferenceRegex = new(
"(Include=\"(Stride[^\"]*)\"\\s+Version=\")([^\"]*)(\")", RegexOptions.Compiled);
/// <summary>
/// Community/third-party <c>Stride.*</c>-prefixed packages that version independently of the
/// engine; their references pass through the engine-version stamp untouched.
/// </summary>
private static readonly string[] NonEnginePackagePrefixes =
{
"Stride.Awesome.Shaders", "Stride.Community", "Stride.Dependencies.", "Stride.GNU.",
"Stride.GraphX", "Stride.Metrics", "Stride.Mono.", "Stride.OpenTK", "Stride.QuickGraph",
};
private static bool IsNonEnginePackage(string packageId)
=> NonEnginePackagePrefixes.Any(prefix => packageId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
private void SubstituteEngineVersion(ILogger logger)
{
var files = 0;
var references = 0;
foreach (var csproj in Directory.EnumerateFiles(OutputDirectory!, "*.csproj", SearchOption.AllDirectories))
{
var content = File.ReadAllText(csproj);
var updated = content.Replace("$EngineVersion$", EngineVersion);
updated = StridePackageReferenceRegex.Replace(updated, match =>
{
var version = match.Groups[3].Value;
// A remaining $placeholder$ is someone else's substitution point; community
// packages keep their committed version.
if (version.IndexOf('$') >= 0 || version == EngineVersion || IsNonEnginePackage(match.Groups[2].Value))
return match.Value;
references++;
return match.Groups[1].Value + EngineVersion + match.Groups[4].Value;
});
if (updated == content)
continue;
File.WriteAllText(csproj, updated);
files++;
}
if (files > 0)
logger.Info($"Stamped engine version {EngineVersion} into {files} .csproj file(s) ({references} Stride.* reference(s) rewritten)");
}
/// <summary>
/// Every Guid the staged tree declares via an <c>Id:</c> line — the asset's own Id at
/// the top of each .sd* file plus every sub-asset Id nested inside (entity, component,
/// render-stage, etc.). Populated by <see cref="ScanLocallyDefinedIds"/>; consulted by
/// <see cref="ScanGuids"/> to distinguish sample-internal Ids (which get rotated per
/// instantiation via template.json's generated/guid symbols) from external references
/// (engine archetypes, engine camera-slot Ids, anything else this sample doesn't own —
/// kept intact so the reference still resolves at runtime).
/// </summary>
private readonly HashSet<Guid> LocallyDefinedIds = new();
/// <summary>
/// Walks every .sd* file in the staged output and records every Guid declared via an
/// <c>Id:</c> line (any indent). The top-level <c>Id:</c> declares the asset itself; the
/// nested ones declare sub-assets that are sample-internal but addressable by Id from
/// other parts of the file (or other files in the same sample). Both flavors need
/// placeholdering at instantiation time; everything else (engine refs, cross-pack refs)
/// stays intact.
/// </summary>
private void ScanLocallyDefinedIds(ILogger logger)
{
foreach (var path in EnumerateTextFiles(OutputDirectory!))
{
// Only .sd* asset files declare assets / sub-assets via "Id:". Other text files
// (.csproj, .sln, .json) carry GUIDs but they're not declarations — anything
// there is treated as an external reference and preserved.
if (!Path.GetExtension(path).StartsWith(".sd", StringComparison.OrdinalIgnoreCase))
continue;
foreach (var line in File.ReadLines(path))
{
var trimmed = line.TrimStart();
if (!trimmed.StartsWith("Id:", StringComparison.Ordinal))
continue;
var idStr = trimmed.Substring(3).Trim();
if (Guid.TryParse(idStr, out var g))
LocallyDefinedIds.Add(g);
}
}
logger.Info($"Indexed {LocallyDefinedIds.Count} locally-defined Id(s)");
}
private void ScanGuids()
{
// Sort for determinism: same input layout produces the same placeholder assignment.
// Only Ids declared locally (asset's own + sub-asset Ids) get placeholdered. GUIDs
// not in LocallyDefinedIds are external references (engine archetypes / slots / etc.)
// and stay intact through preprocessing.
foreach (var path in EnumerateTextFiles(OutputDirectory!))
{
var content = File.ReadAllText(path);
foreach (Match m in GuidRegex.Matches(content))
{
if (Guid.TryParse(m.Value, out var g)
&& !ReservedGuids.Contains(g)
&& LocallyDefinedIds.Contains(g)
&& !GuidMap.ContainsKey(g))
GuidMap[g] = GuidMap.Count + 1;
}
}
}
private void ApplyPlaceholders()
{
foreach (var path in EnumerateTextFiles(OutputDirectory!))
{
var content = File.ReadAllText(path);
var rewritten = GuidRegex.Replace(content, m =>
Guid.TryParse(m.Value, out var g) && GuidMap.TryGetValue(g, out var idx)
? MakePlaceholder(idx)
: m.Value);
if (!ReferenceEquals(rewritten, content))
File.WriteAllText(path, rewritten);
}
}
/// <summary>
/// Walks all <c>.csproj</c> files under the staged output, removes any
/// <c><ProjectReference></c> whose target resolves outside the staging dir, and copies
/// the referenced project's <c>Assets/</c> and <c>Resources/</c> content into the staged
/// root's top-level <c>Assets/</c> / <c>Resources/</c>. Intra-template references (e.g. exec
/// → game library, same staging tree) are left untouched.
/// </summary>
private void CollapseProjectReferences(ILogger logger)
{
var stagedRoot = new DirectoryInfo(OutputDirectory!).FullName;
var inputRoot = new DirectoryInfo(InputPath!).FullName;
var totalCopied = 0;
var totalRefsRemoved = 0;
foreach (var csprojPath in Directory.EnumerateFiles(OutputDirectory!, "*.csproj", SearchOption.AllDirectories))
{
XDocument doc;
try
{
doc = XDocument.Load(csprojPath, LoadOptions.PreserveWhitespace);
}
catch (Exception ex)
{
logger.Warning($"Could not parse {csprojPath} as XML; skipping dep-collapse: {ex.Message}");
continue;
}
// ProjectReference is always under the default namespace (i.e. no xmlns prefix on SDK
// csprojs). Match by local name to be robust against either form.
var refsToRemove = new List<XElement>();
foreach (var refElem in doc.Descendants().Where(e => e.Name.LocalName == "ProjectReference"))
{
var include = refElem.Attribute("Include")?.Value;
if (string.IsNullOrEmpty(include))
continue;
// .csproj files conventionally use backslash separators (Windows convention) even
// when authored cross-platform. Normalize to the current platform so Path.Combine
// resolves them correctly on Linux/macOS.
var normalizedInclude = include.Replace('\\', Path.DirectorySeparatorChar);
// Resolve relative to the ORIGINAL csproj location, not the staged one. The
// staged copy strips parent dirs, so a sample's "..\..\..\..\Packs\Foo" reference
// can't resolve from staging. Map the staged path back to its input counterpart.
var relCsproj = Path.GetRelativePath(stagedRoot, csprojPath);
var originalCsproj = Path.Combine(inputRoot, relCsproj);
var refDir = Path.GetDirectoryName(originalCsproj)!;
var resolved = Path.GetFullPath(Path.Combine(refDir, normalizedInclude));
var resolvedDir = Path.GetDirectoryName(resolved)!;
if (resolvedDir.StartsWith(inputRoot, StringComparison.OrdinalIgnoreCase))
{
// Intra-template reference — keep. The pre-collapse input tree may contain
// multiple csprojs that ref each other (e.g. Foo.Windows → Foo.Game); those
// are part of the template, not external packs.
continue;
}
if (!File.Exists(resolved))
{
logger.Warning($"ProjectReference target does not exist on disk: {resolved} (in {csprojPath})");
refsToRemove.Add(refElem);
continue;
}
// Copy the referenced project dir's Assets/ and Resources/ subdirs into the staged
// root's top-level Assets/ and Resources/.
var copiedCount = CopyContentSubdir(resolvedDir, stagedRoot, "Assets", logger)
+ CopyContentSubdir(resolvedDir, stagedRoot, "Resources", logger);
totalCopied += copiedCount;
logger.Info($"Inlined ProjectReference '{include}' → {copiedCount} files");
refsToRemove.Add(refElem);
}
if (refsToRemove.Count == 0)
continue;
// Remove the ProjectReference nodes (and their immediate trailing whitespace, if any,
// to keep the .csproj XML tidy).
foreach (var elem in refsToRemove)
{
if (elem.NextNode is XText whitespace && string.IsNullOrWhiteSpace(whitespace.Value))
whitespace.Remove();
elem.Remove();
totalRefsRemoved++;
}
doc.Save(csprojPath);
}
if (totalRefsRemoved > 0)
logger.Info($"Dep-collapse: removed {totalRefsRemoved} external ProjectReference(s), inlined {totalCopied} files");
}
/// <summary>
/// Maps a per-platform exec project's suffix to the template parameter name guarding its
/// inclusion. Mirrors the *Active computed bools emitted by <see cref="EmitParameterSymbols"/>;
/// the <c>iOS</c> entry uses <c>iOsActive</c> (mixed case) to dodge the customOperations
/// C++-evaluator clash with the <c>iOS</c> quoteless choice literal.
/// </summary>
private static readonly Dictionary<string, string> PlatformActiveSymbol = new(StringComparer.Ordinal)
{
{ "Windows", "WindowsActive" },
{ "Linux", "LinuxActive" },
{ "macOS", "MacOSActive" },
{ "iOS", "iOsActive" },
{ "Android", "AndroidActive" },
};
// Default-startup priority: desktop platforms first, then mobile. The highest-priority platform the
// user keeps gets the .slnx DefaultStartup. PlatformType (not strings) so the names stay
// compile-checked; covers every PlatformActiveSymbol platform.
private static readonly PlatformType[] StartupPlatformPriority =
[PlatformType.Windows, PlatformType.Linux, PlatformType.macOS, PlatformType.Android, PlatformType.iOS];
/// <summary>
/// Synthesizes a <c>MyTemplate.slnx</c> at the staged root when no solution is already present.
/// Walks <c>*.csproj</c> under the tree and emits a <c><Project></c> for each, wrapping
/// per-platform exec projects (those whose dir name ends in a known platform suffix) in
/// <c>#if (XActive)</c> markers so the template engine's SpecialCustomOperations strips
/// unselected platforms at instantiation. Exactly one exec — the highest-priority platform the
/// user keeps (<see cref="StartupPlatformPriority"/>) — is marked <c>DefaultStartup</c>, since
/// .slnx, unlike classic .sln, doesn't pick the startup project from file order.
/// </summary>
private void GenerateSlnxIfMissing(ILogger logger)
{
var existing = Directory.EnumerateFiles(OutputDirectory!, "*.sln", SearchOption.TopDirectoryOnly)
.Concat(Directory.EnumerateFiles(OutputDirectory!, "*.slnx", SearchOption.TopDirectoryOnly))
.ToList();
if (existing.Count > 0)
return;
// Discover csprojs and classify by per-platform suffix (null = the shared game library).
var projects = new List<(string Csproj, string DirName, string? Platform)>();
foreach (var csproj in Directory.EnumerateFiles(OutputDirectory!, "*.csproj", SearchOption.AllDirectories))
{
var dirName = Path.GetFileName(Path.GetDirectoryName(csproj)!);
string? platform = null;
foreach (var p in PlatformActiveSymbol.Keys)
{
if (dirName.EndsWith("." + p, StringComparison.Ordinal))
{
platform = p;
break;
}
}
projects.Add((csproj, dirName, platform));
}
if (projects.Count == 0)
return;
string Rel(string csproj) => Path.GetRelativePath(OutputDirectory!, csproj).Replace('\\', '/');
var sb = new StringBuilder();
sb.AppendLine("<Solution>");
// Game library (and any other non-platform project) first; .slnx project order is irrelevant —
// the startup project is the DefaultStartup below, not the first entry.
foreach (var p in projects.Where(p => p.Platform == null))
sb.AppendLine($" <Project Path=\"{Rel(p.Csproj)}\" />");
// Per-platform execs in startup priority order. DefaultStartup goes on the first active one: each
// lower-priority exec claims it only when no higher-priority platform is active, so exactly one
// DefaultStartup survives whatever set of platforms the user selects.
var higherPriority = new List<string>();
foreach (var p in projects.Where(p => p.Platform != null)
.OrderBy(p => Enum.TryParse<PlatformType>(p.Platform, out var pt) ? Array.IndexOf(StartupPlatformPriority, pt) : int.MaxValue))
{
var rel = Rel(p.Csproj);
var symbol = PlatformActiveSymbol[p.Platform!];
if (higherPriority.Count == 0)
{
sb.AppendLine($"#if ({symbol})");
sb.AppendLine($" <Project Path=\"{rel}\" DefaultStartup=\"true\" />");
sb.AppendLine("#endif");
}
else
{
var noneHigher = string.Join(" && ", higherPriority.Select(s => $"!{s}"));
sb.AppendLine($"#if ({symbol} && {noneHigher})");
sb.AppendLine($" <Project Path=\"{rel}\" DefaultStartup=\"true\" />");
sb.AppendLine($"#elseif ({symbol})");
sb.AppendLine($" <Project Path=\"{rel}\" />");
sb.AppendLine("#endif");
}
higherPriority.Add(symbol);
}
sb.AppendLine("</Solution>");
var slnxPath = Path.Combine(OutputDirectory!, "MyTemplate.slnx");
File.WriteAllText(slnxPath, sb.ToString());
logger.Info($"Generated MyTemplate.slnx with {projects.Count} project(s)");
}
/// <summary>
/// Copies any platform exec-project folder (<c>MyTemplate.{Linux,macOS,iOS,Android,Windows}</c>)
/// from <see cref="PlatformTemplatePath"/> into the staged output when the sample didn't ship
/// its own. Verbatim copy — the reference template (NewGame) and samples both use
/// <c>MyTemplate.Game/</c> as the shared-library dir name, so injected csprojs reference the
/// correct sibling unchanged.
/// </summary>
private void InjectMissingPlatforms(ILogger logger)
{
if (string.IsNullOrEmpty(PlatformTemplatePath))
return;
if (!Directory.Exists(PlatformTemplatePath))
{
logger.Warning($"--platform-template-path does not exist: {PlatformTemplatePath}");
return;
}
// Only inject into templates that look like runnable games (have a MyTemplate.Game/
// shared-library dir). Library-only templates (stride-library) use bare MyTemplate/
// and shouldn't gain platform exec projects.
if (!Directory.Exists(Path.Combine(OutputDirectory!, "MyTemplate.Game")))
{
logger.Info("No MyTemplate.Game/ shared-library dir in staged tree; skipping platform injection (looks like a library, not a game).");
return;
}
var injected = 0;
foreach (var srcDir in Directory.EnumerateDirectories(PlatformTemplatePath, "MyTemplate.*", SearchOption.TopDirectoryOnly))
{
var dirName = Path.GetFileName(srcDir);
// Only inject directories that match a known platform suffix (Linux/macOS/iOS/Android/Windows).
if (!dirName.StartsWith("MyTemplate.", StringComparison.Ordinal))
continue;
var suffix = dirName["MyTemplate.".Length..];
if (!PlatformActiveSymbol.ContainsKey(suffix))
continue;
var destDir = Path.Combine(OutputDirectory!, dirName);
if (Directory.Exists(destDir))
continue;
CopyDirectory(srcDir, destDir);
injected++;
logger.Info($"Injected platform folder: {dirName}");
}
if (injected > 0)
logger.Info($"Injected {injected} platform folder(s) from {PlatformTemplatePath}");
}
/// <summary>
/// Removes <c>obj/</c> and <c>bin/</c> dirs anywhere under the staged tree so stale build
/// artifacts don't get walked by the rename pass and don't ship in the final template.
/// </summary>
private void CleanBuildArtifacts(ILogger logger)
{
var removed = 0;
foreach (var dir in Directory.EnumerateDirectories(OutputDirectory!, "*", SearchOption.AllDirectories).ToList())
{
var name = Path.GetFileName(dir);
if ((name.Equals("obj", StringComparison.OrdinalIgnoreCase) || name.Equals("bin", StringComparison.OrdinalIgnoreCase))
&& Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
removed++;
}
}
if (removed > 0)
logger.Info($"Removed {removed} obj/bin build artifact dir(s) from staged tree");
}
/// <summary>
/// Replaces the sample's original literal name with <c>MyTemplate</c> across the staged
/// tree: text file contents (word-boundary match so prefixed identifiers aren't mangled),
/// file names, and directory names. Auto-detected from the first <c>.sdpkg</c>'s <c>Name</c>
/// field if <see cref="SourceName"/> is unset.
/// </summary>
private void RenameSourceName(ILogger logger)
{
var sourceName = SourceName ?? DetectSourceNameFromSdpkg(logger);
if (string.IsNullOrEmpty(sourceName))
{
logger.Info("No source name detected; skipping rename pass");
return;
}
if (sourceName == "MyTemplate")
{
logger.Info("Detected source name is already 'MyTemplate'; skipping rename pass");
return;
}
// Word-boundary regex with optional Stride exec-project suffix lookahead. Matches:
// - SampleName (followed by non-word — covered by \b in lookahead)
// - SampleNameApp (App suffix preserved)
// - SampleNameAppDelegate (iOS pattern)
// - SampleNameActivity (Android pattern)
// The lookahead means only "SampleName" is consumed; the suffix stays intact, so
// CSharpBeginnerApp.cs → MyTemplateApp.cs (then sourceName substitutes at instantiation).
// CSharpBeginnerExtraFoo stays untouched because no boundary follows "SampleName" there.
// Escape sourceName in case it contains regex-special chars.
var pattern = new Regex($@"\b{Regex.Escape(sourceName)}(?=(App|AppDelegate|Activity)?\b)", RegexOptions.Compiled);
// Rewrite file contents first (paths still valid during this pass).
var filesRewritten = 0;
foreach (var path in EnumerateTextFiles(OutputDirectory!))
{
var content = File.ReadAllText(path);
var rewritten = pattern.Replace(content, "MyTemplate");
if (!ReferenceEquals(rewritten, content) && rewritten != content)
{
File.WriteAllText(path, rewritten);
filesRewritten++;
}
}
// Rename files. Collect first, mutate after — modifying a dir while enumerating it is
// implementation-defined.
var fileRenames = new List<(string from, string to)>();
foreach (var path in Directory.EnumerateFiles(OutputDirectory!, "*", SearchOption.AllDirectories))
{
var basename = Path.GetFileName(path);
var newBasename = pattern.Replace(basename, "MyTemplate");
if (newBasename != basename)
fileRenames.Add((path, Path.Combine(Path.GetDirectoryName(path)!, newBasename)));
}
foreach (var (from, to) in fileRenames)
File.Move(from, to, overwrite: true);
// Rename directories bottom-up so parent renames don't invalidate child paths mid-walk.
var dirRenames = new List<(string from, string to)>();
foreach (var dir in Directory.EnumerateDirectories(OutputDirectory!, "*", SearchOption.AllDirectories))
{
var basename = Path.GetFileName(dir);
var newBasename = pattern.Replace(basename, "MyTemplate");
if (newBasename != basename)
dirRenames.Add((dir, Path.Combine(Path.GetDirectoryName(dir)!, newBasename)));
}
// Sort by descending path depth so deepest dirs rename first.
dirRenames.Sort((a, b) => b.from.Length.CompareTo(a.from.Length));
foreach (var (from, to) in dirRenames)
Directory.Move(from, to);
logger.Info($"Renamed '{sourceName}' → 'MyTemplate' in {filesRewritten} file(s), {fileRenames.Count} file name(s), {dirRenames.Count} dir name(s)");
}
/// <summary>
/// Reads the first <c>.sdpkg</c>'s <c>Name:</c> field and strips a trailing <c>.Game</c> /
/// <c>.Windows</c> / <c>.Linux</c> / <c>.macOS</c> / <c>.iOS</c> / <c>.Android</c> suffix.
/// Stride convention is <c>SampleName.Game</c> for the library package; the base name is
/// what gets referenced in code namespaces.
/// </summary>
private string? DetectSourceNameFromSdpkg(ILogger logger)
{
var sdpkg = Directory.EnumerateFiles(OutputDirectory!, "*.sdpkg", SearchOption.AllDirectories).FirstOrDefault();
if (sdpkg == null)
return null;
var nameLine = File.ReadLines(sdpkg)
.FirstOrDefault(l => l.TrimStart().StartsWith("Name:", StringComparison.Ordinal));
if (nameLine == null)
return null;
var name = nameLine.Substring(nameLine.IndexOf(':') + 1).Trim();
foreach (var suffix in new[] { ".Game", ".Windows", ".Linux", ".macOS", ".iOS", ".Android" })
{
if (name.EndsWith(suffix, StringComparison.Ordinal))
return name.Substring(0, name.Length - suffix.Length);
}
return name;
}
/// <summary>
/// Asset type tags (the YAML <c>!Foo</c> first-line marker) that are always treated as roots,
/// mirroring the engine's <c>[AssetDescription(AlwaysMarkAsRoot = true)]</c>-decorated types
/// plus the canonical scene/prefab/compositor entry points that usually anchor a sample.
/// Hardcoded so the dumb-scan pruner doesn't need an engine ProjectReference to query
/// <see cref="AssetRegistry"/> at runtime.
/// </summary>
private static readonly HashSet<string> AlwaysRootAssetTypeTags = new(StringComparer.Ordinal)
{
"GameSettingsAsset", // [AssetDescription(AlwaysMarkAsRoot = true)]
"EffectShader", // [AssetDescription(AlwaysMarkAsRoot = true)]
"EffectLibrary", // [AssetDescription(AlwaysMarkAsRoot = true)] (EffectLogAsset)
"EffectCompositorAsset", // [AssetDescription(AlwaysMarkAsRoot = true)]
"ScriptSourceFileAsset", // [AssetDescription(AlwaysMarkAsRoot = true)] (not normally a .sd file)
};
/// <summary>Matches a top-level <c>Id: <dashed-guid></c> field (no indent).</summary>
private static readonly Regex TopLevelIdRegex = new(
@"^Id:\s+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s*$",
RegexOptions.Compiled | RegexOptions.Multiline);
/// <summary>
/// Matches a Stride asset reference in <c><guid>:<location></c> form. Location can
/// contain letters, digits, <c>/</c>, <c>.</c>, <c>_</c>, <c>-</c>, and spaces; we stop at
/// whitespace, comma, or YAML container closers so we don't accidentally swallow trailing
/// punctuation. The <c>ref!! <guid></c> intra-asset object reference form has no
/// <c>:location</c> suffix and therefore can't match.
/// </summary>
private static readonly Regex AssetRefRegex = new(
@"\b([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}):[^\s,}\]]+",
RegexOptions.Compiled);
/// <summary>
/// Pure-text asset pruner: walks every <c>*.sd*</c> file under the staged tree (anything with
/// a leading <c>!Type</c> YAML tag and top-level <c>Id:</c>), parses outgoing
/// <c><guid>:<location></c> references with a regex, BFS-marks reachable from
/// RootAssets + always-root types, deletes the unmarked. No engine assemblies, no MSBuild
/// project resolution. False negatives (pruning a still-referenced asset) are bounded because
/// Stride YAML uses exactly two cross-asset reference forms (the <c><guid>:<location></c>
/// asset ref and the intra-file <c>ref!! <guid></c>); only the first is cross-asset and
/// both <c>BasePartAsset:</c>-style derived refs use the same syntax.
/// </summary>
private void DumbPruneUnreachableAssets(ILogger logger)
{
var sdpkgPaths = Directory.EnumerateFiles(OutputDirectory!, "*.sdpkg", SearchOption.AllDirectories).ToList();
var rootIds = new HashSet<Guid>();
foreach (var sdpkg in sdpkgPaths)
CollectRootAssets(sdpkg, rootIds);
// Discover every asset file: any *.sd* file other than .sdpkg that opens with a !Type YAML
// tag and has a top-level Id field. Filter on extension prefix to avoid reading binaries
// (and ignore .sdpkg itself, which is the package manifest, not an asset).
var assetFiles = new Dictionary<Guid, string>();
var assetTypes = new Dictionary<Guid, string>();
var outgoing = new Dictionary<Guid, HashSet<Guid>>();
foreach (var path in Directory.EnumerateFiles(OutputDirectory!, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(path);
if (ext.Length < 3 || !ext.StartsWith(".sd", StringComparison.OrdinalIgnoreCase))
continue;
if (ext.Equals(".sdpkg", StringComparison.OrdinalIgnoreCase))
continue;
if (ext.Equals(".sdtpl", StringComparison.OrdinalIgnoreCase))
continue;
string content;
try { content = File.ReadAllText(path); }
catch { continue; }
// Type tag: very first line, must start with !.
var newlineIdx = content.IndexOf('\n');
if (newlineIdx <= 1 || content[0] != '!')
continue;
var typeTag = content.Substring(1, newlineIdx - 1).Trim();
var idMatch = TopLevelIdRegex.Match(content);
if (!idMatch.Success)
continue;
var selfId = Guid.Parse(idMatch.Groups[1].Value);
assetFiles[selfId] = path;
assetTypes[selfId] = typeTag;
if (AlwaysRootAssetTypeTags.Contains(typeTag))
rootIds.Add(selfId);
var refs = new HashSet<Guid>();
foreach (Match m in AssetRefRegex.Matches(content))
{
if (Guid.TryParse(m.Groups[1].Value, out var refId) && refId != selfId)
refs.Add(refId);
}
outgoing[selfId] = refs;
}
// BFS from roots, restricted to ids we actually have files for (refs may point to engine
// assets that aren't in the staged tree — that's expected).
var reachable = new HashSet<Guid>();
var queue = new Queue<Guid>();
foreach (var id in rootIds)
{
if (assetFiles.ContainsKey(id) && reachable.Add(id))
queue.Enqueue(id);
}
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (!outgoing.TryGetValue(id, out var refs))
continue;
foreach (var refId in refs)
{
if (assetFiles.ContainsKey(refId) && reachable.Add(refId))
queue.Enqueue(refId);
}
}
var deleted = 0;
foreach (var (id, path) in assetFiles)
{
if (reachable.Contains(id))
continue;
File.Delete(path);
deleted++;
}
if (deleted > 0)
logger.Info($"Pruned {deleted} unreachable asset file(s) from staged tree");
}
/// <summary>
/// Parses the <c>RootAssets:</c> section of a <c>.sdpkg</c> and adds each entry's leading GUID
/// to <paramref name="rootIds"/>. Entries are YAML list items of form
/// <c>- <guid>:<location></c>; we stop on the next top-level key.
/// </summary>
private static void CollectRootAssets(string sdpkgPath, HashSet<Guid> rootIds)
{
var inSection = false;
foreach (var rawLine in File.ReadLines(sdpkgPath))
{
if (!inSection)
{
if (rawLine.StartsWith("RootAssets:", StringComparison.Ordinal))
inSection = true;
continue;
}
// End of section: any non-blank line that's not a list-item entry (indented dash).
if (rawLine.Length > 0 && !char.IsWhiteSpace(rawLine[0]) && !rawLine.StartsWith("- ", StringComparison.Ordinal))
break;
var m = AssetRefRegex.Match(rawLine);
if (m.Success && Guid.TryParse(m.Groups[1].Value, out var id))
rootIds.Add(id);
}
}
private static int CopyContentSubdir(string sourceProjectDir, string stagedRoot, string subdirName, ILogger logger)
{
var sourceSubdir = Path.Combine(sourceProjectDir, subdirName);
if (!Directory.Exists(sourceSubdir))
return 0;
var destSubdir = Path.Combine(stagedRoot, subdirName);
Directory.CreateDirectory(destSubdir);
var copied = 0;
foreach (var srcFile in Directory.EnumerateFiles(sourceSubdir, "*", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(sourceSubdir, srcFile);
var dest = Path.Combine(destSubdir, rel);
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
File.Copy(srcFile, dest, overwrite: true);
copied++;
}
return copied;
}
/// <summary>
/// Injects a BasicCameraController script component into the Camera entity's Components dict
/// of the staged MainScene.sdscene. The type/assembly is written as
/// <c>!MyTemplate.BasicCameraController,MyTemplate</c>; <c>MyTemplate</c> is the
/// <c>sourceName</c> declared in template.json, which the template engine substitutes with the
/// user's <c>-n</c> value at instantiation. The component's <c>Id</c> uses a hardcoded GUID
/// that will be caught by the subsequent GUID-placeholder pass and replaced per-instance; the
/// 32-hex Components dict key stays literal (file-scoped uniqueness is all that's required).
/// Skipped silently if no <c>MainScene.sdscene</c> exists (e.g. when preprocessing a sample
/// that doesn't have a Camera entity).
/// </summary>
private void InjectCameraScript(ILogger logger)
{