-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDiscWright.ps1
More file actions
3678 lines (3438 loc) · 207 KB
/
Copy pathDiscWright.ps1
File metadata and controls
3678 lines (3438 loc) · 207 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
<#
DiscWright
Turns a GOG offline-installer folder into a burnable "retro game disc" image:
custom drive icon + label, and an optional autorun splash menu (background, music,
Play/Install/Manual/Extras/Exit buttons).
Runs on Smart App Control-locked PCs: launched via signed powershell.exe, uses
in-process Add-Type (allowed) and the signed mshta.exe to run the generated menu.
Reuses the same build pipeline proven on the Dead Space disc.
NOTE: keep this file pure ASCII - PS 5.1 mis-parses em-dashes / ellipses.
#>
# =================== STARTUP ENVIRONMENT GUARD ===================
# Runs before Add-Type on purpose: the things it checks are what make Add-Type and
# the window itself fail, and both failures look like "the app is just broken".
#
# PS 5.1 Historically because New-Iso compiled its helper with /unsafe, which
# needed "Add-Type -CompilerParameters" - dropped in PowerShell 6, so the
# app ran fine until BUILD ISO and died at the last step. That is no
# longer true: the helper compiles with a plain Add-Type now. The check
# stays because nothing has been tested on 7, not because it is known to
# fail. Somebody should try it and either lift this or write down why not.
# STA WinForms needs a single-threaded apartment. powershell.exe -File is STA
# by default, but -MTA and several hosting paths are not, and there the
# dialogs and file pickers misbehave rather than fail outright.
# No WinForms yet, so MessageBox is not available. WScript.Shell is present in every
# PowerShell on Windows and still gives a real modal window, which is what the rule
# about errors getting a window actually asks for.
function Show-StartupError([string]$msg) {
try { [void](New-Object -ComObject WScript.Shell).Popup($msg,0,'DiscWright',0x10) }
catch { Write-Host $msg }
}
if ($PSVersionTable.PSVersion.Major -ne 5) {
Show-StartupError (
"DiscWright needs Windows PowerShell 5.1, but this is PowerShell $($PSVersionTable.PSVersion).`r`n`r`n" +
"Building the ISO uses a compiler option that PowerShell 6 and later removed, so the build would " +
"fail at the very last step.`r`n`r`n" +
"Start DiscWright with its shortcut, or with 'Run DiscWright.cmd'.")
exit 1
}
if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
Show-StartupError (
"DiscWright has to run in single-threaded apartment (STA) mode, and this session is " +
"$([Threading.Thread]::CurrentThread.GetApartmentState()).`r`n`r`n" +
"The file pickers and dialogs do not work reliably otherwise.`r`n`r`n" +
"Start DiscWright with its shortcut, or with 'Run DiscWright.cmd' - both pass the -STA switch.")
exit 1
}
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
$PROJECT_FILE = 'discproject.json'
# Kept in step with the git tag by a CI check that runs when a tag is pushed, so
# this cannot quietly drift a release behind. Shown in the title bar and the log,
# and written into every project file - a bug report that comes with a project
# file then says for itself which version built the disc.
$APP_VERSION = '0.4.1'
# =================== SMALL HELPERS ===================
function Test-SamePath([string]$a,[string]$b) {
if ([string]::IsNullOrWhiteSpace($a) -or [string]::IsNullOrWhiteSpace($b)) { return $false }
try { return ([IO.Path]::GetFullPath($a).TrimEnd('\') -ieq [IO.Path]::GetFullPath($b).TrimEnd('\')) } catch { return $false }
}
# $child is $parent itself, or lives underneath it
function Test-SubPath([string]$child,[string]$parent) {
if ([string]::IsNullOrWhiteSpace($child) -or [string]::IsNullOrWhiteSpace($parent)) { return $false }
try {
$c=[IO.Path]::GetFullPath($child).TrimEnd('\'); $p=[IO.Path]::GetFullPath($parent).TrimEnd('\')
return ($c -ieq $p) -or $c.StartsWith($p+'\',[StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
# Files copied off a mounted ISO or any read-only media keep the ReadOnly attribute.
# Overwriting one later fails - and GDI+ reports that as "a generic error occurred
# in GDI+" rather than access denied, which is impossible to diagnose from the message.
function Clear-ReadOnly([string]$path) {
if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path $path)) { return }
try {
$item = Get-Item -LiteralPath $path -Force
if ($item.PSIsContainer) {
Get-ChildItem -Recurse -Force -File $path -EA SilentlyContinue |
Where-Object { $_.IsReadOnly } | ForEach-Object { $_.IsReadOnly = $false }
} elseif ($item.IsReadOnly) { $item.IsReadOnly = $false }
} catch {}
}
# Write to temp then move: never leaves a half-written image if encoding fails, and
# sidesteps a read-only or transiently locked destination.
function Save-PngAtomic($bmp,[string]$outPng) {
$tmp = [IO.Path]::Combine([IO.Path]::GetTempPath(), ([Guid]::NewGuid().ToString('N')+'.png'))
$bmp.Save($tmp,[System.Drawing.Imaging.ImageFormat]::Png)
Clear-ReadOnly $outPng
Move-Item -LiteralPath $tmp -Destination $outPng -Force
}
# Sub-gigabyte payloads read badly in GB - a CD-sized game becomes "0.39 GB", and a
# few megabytes of extras rounds to "0.00 GB", which looks like nothing at all.
# Switch units instead of printing a meaningless zero.
# Minutes and seconds until it runs past an hour, which a 100 GB BD-R XL can.
#
# Floor, not [int]: casting a double to [int] in PowerShell ROUNDS rather than
# truncating, so [int]1.58 is 2 and 95 seconds displayed as "02:35" - a clock that
# reads a minute ahead of itself. A stopwatch counts up, it does not round.
function Format-Elapsed([TimeSpan]$t) {
if ($t.TotalHours -ge 1) { return '{0:00}:{1:00}:{2:00}' -f [int][math]::Floor($t.TotalHours), $t.Minutes, $t.Seconds }
return '{0:00}:{1:00}' -f [int][math]::Floor($t.TotalMinutes), $t.Seconds
}
function Format-Size([double]$bytes) {
if ($bytes -ge 1GB) { return ('{0:N2} GB' -f ($bytes/1GB)) }
if ($bytes -ge 1MB) { return ('{0:N0} MB' -f ($bytes/1MB)) }
if ($bytes -ge 1KB) { return ('{0:N0} KB' -f ($bytes/1KB)) }
return ('{0:N0} bytes' -f $bytes)
}
# For text that lands in HTML markup (title, button captions).
function ConvertTo-HtmlText([string]$s) {
if ([string]::IsNullOrEmpty($s)) { return '' }
# Harmless in HTML, where a newline is just whitespace - stripped anyway so that
# every writer in the build treats them the same way. A rule with an exception
# is a rule somebody has to remember.
$s = Remove-ControlChars $s
if ([string]::IsNullOrEmpty($s)) { return '' }
return ($s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"')
}
# For text that lands inside a JS string literal. HTML entities are NOT decoded
# inside <script>, so these must be backslash-escaped, not entity-escaped.
function ConvertTo-JsString([string]$s) {
if ([string]::IsNullOrEmpty($s)) { return '' }
# Control characters first, and by removal rather than by escaping. A newline
# inside a JS string literal is not a character to encode, it is the end of the
# literal - JScript treats it as an unterminated string and refuses the whole
# file, so one bad name takes the entire menu with it. Nothing in a game name
# needs them.
$s = Remove-ControlChars $s
if ([string]::IsNullOrEmpty($s)) { return '' }
return ($s -replace '\\','\\' -replace '"','\"' -replace '<','\x3c' -replace '>','\x3e')
}
# =================== BUILD PIPELINE ===================
function Get-GameInfo([string]$folder) {
# Kind and ParentIndex describe an entry's place on the disc rather than
# anything read out of the folder, so they start as "a game of its own" and
# the interface changes them afterwards. ParentIndex is an index into the
# disc's own list of entries, -1 meaning none - names are not stable enough
# to point with, since two GOG folders can yield the same ProductName.
# ManualPath and ExtrasPath are this entry's OWN. A disc can also carry a
# manual and an Extras folder for the whole disc; an entry that has none of
# its own falls back to those, which is what keeps a single-game disc, and
# every project written before this, behaving exactly as it did.
$info = @{ Ok=$false; SetupExe=$null; Files=@(); GameName=$null; Msg=''; TotalBytes=0; Folder=$null
Warning=''; MissingParts=@(); Kind='Game'; ParentIndex=-1
ManualPath=$null; ExtrasPath=$null }
if (-not (Test-Path $folder)) { $info.Msg='Folder not found.'; return $info }
$exes = @(Get-ChildItem $folder -Filter 'setup_*.exe' -File -ErrorAction SilentlyContinue |
Sort-Object Length -Descending)
if ($exes.Count -eq 0) {
# Two folders in a project look alike and sit next to each other: the GOG
# download, and the output folder DiscWright writes to. The second holds a
# disc\ subfolder with the installer one level down, so picking it lands
# here - and the generic message sends people looking for a problem with
# their download instead of at which folder they picked. Name it instead.
$inner = Join-Path $folder 'disc'
$innerExe = @()
if (Test-Path $inner) {
$innerExe = @(Get-ChildItem $inner -Filter 'setup_*.exe' -File -ErrorAction SilentlyContinue)
}
if ($innerExe.Count -gt 0) {
$info.Msg = 'This looks like a disc DiscWright built, not a GOG download. Step 1 wants the folder you downloaded from GOG; this one is where the ISO gets written.'
} else {
$info.Msg = 'No GOG "setup_*.exe" found in this folder.'
}
return $info
}
$exe = $exes[0]
Set-InstallerFacts $info $exe
if ($info.MissingParts.Count -eq 0 -and $exes.Count -gt 1) {
# Several installers in one folder is normal (base game plus DLC). Say which
# one was picked, so a wrong guess is visible before the disc is burned.
$info.Warning = "$($exes.Count) installers in this folder - using the largest, $($exe.Name)."
}
return $info
}
# Everything true of an installer once one has been chosen: its parts, whether the
# download finished, its name and its size. Shared, because an add-on is described
# exactly the same way - the only difference is how the .exe was picked.
function Set-InstallerFacts([hashtable]$info,[System.IO.FileInfo]$exe) {
# Parts belong to ONE installer and are named "<installer>-1.bin", "-2.bin"...
# Taking every setup_*.bin in the folder swept in the parts of a DLC or of a
# second game stored alongside, and wrote them to the disc as if they belonged
# to this installer. Match on the chosen exe's own name instead - compared as
# plain strings, never wildcards, because GOG names are full of brackets and
# dots that -like and -match would read as syntax.
$stem = $exe.BaseName + '-'
$bins = @(Get-ChildItem $exe.DirectoryName -Filter '*.bin' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name.StartsWith($stem,[StringComparison]::OrdinalIgnoreCase) } |
Sort-Object Name)
# A download that stopped early leaves a gap in the numbering. Building it
# produces a clean-looking ISO that only fails when someone runs the installer
# off the burned disc - the worst possible place to discover it, because the
# disc is already spent. Games with no parts at all are normal; only a gap in
# an existing sequence is suspicious.
$nums = @()
foreach ($b in $bins) { if ($b.BaseName -match '-(\d+)$') { $nums += [int]$Matches[1] } }
if ($nums.Count -gt 0) {
$nums = @($nums | Sort-Object -Unique)
$info.MissingParts = @(1..($nums[-1]) | Where-Object { $nums -notcontains $_ })
}
if ($info.MissingParts.Count -gt 0) {
$info.Warning = 'INCOMPLETE: installer part(s) ' + (($info.MissingParts | ForEach-Object { "-$_" }) -join ', ') +
' are missing - the download looks unfinished.'
}
if ($info.Kind -eq 'AddOn') {
$name = Get-AddOnName $exe.Name
} else {
$name = $exe.VersionInfo.ProductName
if ([string]::IsNullOrWhiteSpace($name)) { $name = ($exe.BaseName -replace '^setup_','' -replace '_',' ' -replace '\s*\d.*$','') }
# Inno pads VersionInfo strings with trailing spaces - trim or they leak into
# folder names ("...Edition Disc").
$name = $name.Trim()
}
$info.Ok=$true; $info.SetupExe=$exe; $info.Files=@($exe)+@($bins); $info.GameName=$name
$info.Folder = $exe.DirectoryName
$info.TotalBytes = ($info.Files | Measure-Object Length -Sum).Sum
$plural = if ($info.Files.Count -eq 1) { 'file' } else { 'files' }
$info.Msg = "Detected: $name ($($info.Files.Count) $plural, $(Format-Size $info.TotalBytes))"
}
# An add-on is named from its filename, never from the installer's ProductName.
# Every GOG patch reports the ProductName of the game it patches - all four
# Hollow Knight patches call themselves "Hollow Knight" - so a disc built that way
# would show four identical buttons and no way to tell which was which. The
# filename is the only thing that distinguishes them.
function Get-AddOnName([string]$fileName) {
$raw = [IO.Path]::GetFileNameWithoutExtension($fileName)
$n = $raw
# patch_<game>_<from>_to_<to>: the version being moved TO is what tells two
# patches apart, and it belongs at the front where the button will not clip it.
if ($n -match '^patch_.+_to_(.+)$') { $n = 'Update ' + $Matches[1] }
elseif ($n -match '^setup_(.+)$') { $n = $Matches[1] }
$n = (($n -replace '_',' ') -replace '\s+',' ').Trim()
# A filename that is nothing but underscores leaves an empty label, and a
# button with no text is a button nobody can identify. Fall back rather than
# produce one.
if ([string]::IsNullOrWhiteSpace($n)) { $n = $raw.Trim() }
if ([string]::IsNullOrWhiteSpace($n)) { $n = 'Add-on' }
return $n
}
# An add-on is picked as a single file rather than a folder: a patch or a DLC
# usually sits in the same folder as the game it belongs to, so pointing at the
# folder would just find the game again. Any .exe is accepted here - the
# setup_*.exe rule exists to identify a GOG game folder, and an add-on's identity
# comes from the list it is added to, not from its filename. That is what lets a
# mod or an overhaul, which is never named setup_*, go on the disc at all.
function Get-AddOnInfo([string]$exePath) {
$info = @{ Ok=$false; SetupExe=$null; Files=@(); GameName=$null; Msg=''; TotalBytes=0; Folder=$null
Warning=''; MissingParts=@(); Kind='AddOn'; ParentIndex=-1 }
if (-not (Test-Path $exePath -PathType Leaf)) { $info.Msg='File not found.'; return $info }
$exe = Get-Item -LiteralPath $exePath
if ($exe.Extension -ne '.exe') {
$info.Msg = "An add-on has to be an installer (.exe). '$($exe.Name)' is not one - loose files belong in step 5, Extra content."
return $info
}
Set-InstallerFacts $info $exe
return $info
}
# Every disc size DiscWright knows about, smallest first (usable capacities, GiB).
# One table rather than a ladder of magic numbers, because two features now read
# the same figures: the advice line under the installer list, and the splitter
# that packs a set.
#
# An 80-minute CD-R is 360,000 sectors of 2048 bytes = 0.686 GiB; 0.68 leaves a
# little room for the filesystem. Worth having as its own tier rather than
# rounding up to DVD: the sub-700 MB back catalogue is exactly the audience for
# a tool about authentic period discs, and telling someone to put a 1998 game
# on a DVD gets the whole premise wrong.
function Get-MediaTiers {
return @(
@{ Key='CD'; Gib=0.68; Name='CD-R 700 MB'; Short='CD-R 700 MB'; RecText='fits CD-R 700 MB' },
@{ Key='DVD5'; Gib=4.37; Name='DVD5 4.7 GB'; Short='DVD5 4.7 GB'; RecText='fits DVD5 4.7 GB (single layer)' },
@{ Key='DVD9'; Gib=7.95; Name='DVD9 8.5 GB (dual layer)'; Short='DVD9 8.5 GB'; RecText='needs DVD9 8.5 GB (dual layer)' },
@{ Key='BD25'; Gib=23.3; Name='BD-R 25 GB'; Short='BD-R 25 GB'; RecText='too big for DVD - needs BD-R 25 GB' },
@{ Key='BD50'; Gib=46.6; Name='BD-R DL 50 GB (dual layer)'; Short='BD-R DL 50 GB'; RecText='needs BD-R DL 50 GB (dual layer)' },
@{ Key='BDXL'; Gib=93.0; Name='BD-R XL 100 GB'; Short='BD-R XL 100 GB'; RecText='needs BD-R XL 100 GB' }
)
}
# The first entry in the target-disc list: carry on doing what DiscWright has
# always done, which is recommend a size and build exactly one disc.
function Get-MediaAutoText { return 'Fit on one disc (recommended)' }
# What one row of the dropdown reads, given what the planner made of that tier.
# The list is where the question "which disc should I use" gets asked, so it is
# where the answer belongs. No games on the form means no plan and no annotation.
function Get-MediaOptionText([hashtable]$tier, $plan) {
if (-not $plan) { return [string]$tier.Name }
if (-not $plan.Ok) { return ("{0} - will not fit" -f $tier.Name) }
$n = @($plan.Discs).Count
return ("{0} - {1} disc{2}" -f $tier.Name, $n, $(if ($n -eq 1) { '' } else { 's' }))
}
# The dropdown shows names; everything else works in keys. Rows carry an
# annotation once there is something to plan, so a row is matched on the tier
# name it starts with rather than on the whole string.
function Get-MediaKeyFromName([string]$name) {
$n = "$name".Trim()
foreach ($t in Get-MediaTiers) {
if ($n -eq $t.Name -or $n.StartsWith($t.Name + ' ')) { return $t.Key }
}
return ''
}
function Get-MediaNameFromKey([string]$key) {
foreach ($t in Get-MediaTiers) { if ($t.Key -ieq $key) { return $t.Name } }
return (Get-MediaAutoText)
}
# The same medium, minus the "(dual layer)" note. The dropdown has a row to
# itself and can afford the full name; a sentence sharing one line with the
# payload total and a game's title cannot.
function Get-MediaShortFromKey([string]$key) {
foreach ($t in Get-MediaTiers) { if ($t.Key -ieq $key) { return $t.Short } }
return (Get-MediaAutoText)
}
# Usable bytes for one disc of the named medium, or 0 for a key nothing recognises.
function Get-MediaCapacity([string]$key) {
foreach ($t in Get-MediaTiers) { if ($t.Key -ieq $key) { return [double]$t.Gib * 1GB } }
return [double]0
}
# Recommend the smallest media that fits the payload.
function Get-MediaRec([double]$bytes) {
$gib = $bytes/1GB
foreach ($t in Get-MediaTiers) {
if ($gib -le $t.Gib) { return @{ Fit=$true; Key=$t.Key; Text=$t.RecText } }
}
return @{ Fit=$false; Key=''; Text=("too big for one disc ({0:N1} GB) - split it across a set" -f $gib) }
}
# Total bytes of a mixed list of files and folders.
function Get-ItemsSize($items) {
$sum = [double]0
foreach ($i in @($items)) {
if ([string]::IsNullOrWhiteSpace($i) -or -not (Test-Path $i)) { continue }
if (Test-Path $i -PathType Container) {
$s = (Get-ChildItem -Recurse -File -Force $i -EA SilentlyContinue | Measure-Object Length -Sum).Sum
if ($s) { $sum += $s }
} else { $sum += (Get-Item -LiteralPath $i).Length }
}
return $sum
}
# Packs a set in the order the entries already sit in on the form.
#
# Deliberately next-fit rather than any cleverer packing: the list on the form is
# the order the user arranged, and the menu numbers follow it. A packer that
# reordered games to save a disc would produce a set whose disc 2 holds the game
# they put first. Saving one disc is worth less than a set that matches what is
# on screen.
#
# $sizes is one figure per installer, in list order. $overhead is what every disc
# carries no matter what else is on it - menu, icon, background, music.
# $firstDiscExtra is the disc-wide manual and extras, which ride on disc 1 alone
# rather than being copied onto every disc in the set.
#
# Refuses, rather than guessing, when a single installer is larger than a whole
# disc. Spreading one game's parts across several discs is a different problem
# with a different answer, and quietly producing a set that cannot install the
# game would be worse than saying so.
function Split-DiscSet([double[]]$sizes, [double]$capacity, [double]$overhead, [double]$firstDiscExtra) {
$usable = $capacity - $overhead
if ($usable -le 0) { return @{ Ok=$false; Reason='capacity'; TooBig=-1; Room=[double]0 } }
if ($firstDiscExtra -gt $usable) { return @{ Ok=$false; Reason='extras'; TooBig=-1; Room=$usable } }
$discs = @()
$cur = @()
$used = [double]0
$room = $usable - $firstDiscExtra
for ($i = 0; $i -lt @($sizes).Count; $i++) {
$sz = [double]$sizes[$i]
if ($sz -gt $usable) { return @{ Ok=$false; Reason='entry'; TooBig=$i; Room=$usable } }
# Disc 1 gives up room to the disc-wide extras, so the first entry can be
# too big for disc 1 and still fit every disc after it. Let disc 1 carry
# the extras on their own rather than refuse a set that packs fine.
if ($cur.Count -eq 0 -and $sz -gt $room) { $discs += ,@(); $room = $usable }
if ($cur.Count -gt 0 -and ($used + $sz) -gt $room) {
$discs += ,@($cur)
$cur = @(); $used = [double]0; $room = $usable
}
$cur += $i
$used += $sz
}
if ($cur.Count -gt 0 -or $discs.Count -eq 0) { $discs += ,@($cur) }
return @{ Ok=$true; Reason=''; TooBig=-1; Room=$usable; Discs=$discs }
}
# What every disc in a set carries no matter which games land on it: the icon at
# the root and again inside AUTORUN, the composed background, the menu itself and
# autorun.inf. Counted twice for the icon and the background because each ships
# in two places, or is recomposed into a PNG that can come out larger than the
# JPG it was made from. Erring high costs a little headroom; erring low produces
# a disc that will not burn.
function Get-DiscOverheadBytes([hashtable]$s) {
$b = [double]2MB
$b += 2 * (Get-ItemsSize @($s.IconPath))
$b += 2 * (Get-ItemsSize @($s.BgPath))
$b += (Get-ItemsSize @($s.MusicFile))
return $b
}
# Works out how many discs the set needs and what goes on each, in entry indices.
# Packs by group rather than by entry so a game and its add-ons stay together.
function Get-DiscPlan([array]$entries, [string]$mediaKey, [double]$overhead, [double]$firstDiscExtra) {
$cap = Get-MediaCapacity $mediaKey
if ($cap -le 0) { return @{ Ok=$false; Reason='media'; Discs=@(); Capacity=[double]0; TooBigName=''; Room=[double]0 } }
$groups = Get-EntryGroups $entries
$sizes = @()
foreach ($g in $groups) {
$t = [double]0
foreach ($i in $g) { $t += [double]$entries[$i].TotalBytes }
$sizes += $t
}
$r = Split-DiscSet ([double[]]$sizes) $cap $overhead $firstDiscExtra
if (-not $r.Ok) {
# Name the game rather than the group index. "Group 2 is too big" is a
# sentence about the packer; the user needs the sentence about the disc.
$nm = ''; $nb = [double]0
if ($r.Reason -eq 'entry' -and $r.TooBig -ge 0) {
$nm = [string]$entries[$groups[$r.TooBig][0]].GameName
$nb = [double]$sizes[$r.TooBig]
}
return @{ Ok=$false; Reason=$r.Reason; Discs=@(); Capacity=$cap; TooBigName=$nm; TooBigBytes=$nb; Room=$r.Room }
}
$discs = @()
foreach ($d in $r.Discs) {
$idx = @()
foreach ($gi in $d) { foreach ($e in $groups[$gi]) { $idx += $e } }
$discs += ,@($idx)
}
return @{ Ok=$true; Reason=''; Discs=$discs; Capacity=$cap; TooBigName=''; TooBigBytes=[double]0; Room=$r.Room }
}
# How the plan reads on the line under the installer list, and in the dialog that
# refuses a build. One sentence, no jargon: the number of discs is the answer to
# the only question being asked.
function Get-DiscPlanText([hashtable]$plan, [string]$mediaKey) {
$name = Get-MediaShortFromKey $mediaKey
if (-not $plan.Ok) {
switch ($plan.Reason) {
# The game's OWN size, not the total on the form. "Alan Wake alone is
# bigger than a DVD5" sat on a line beginning "2 games (8.94 GB)", and
# read as though the 8.94 was what would not fit. Naming the figure
# that is actually too big settles it in the same breath.
'entry' { return ("{0} is {1:N2} GB, too big for a {2}" -f $plan.TooBigName, ([double]$plan.TooBigBytes/1GB), $name) }
'extras' { return ("the extra content alone is bigger than a $name") }
'media' { return ("no such disc") }
default { return ("cannot be split onto $name") }
}
}
$n = @($plan.Discs).Count
if ($n -le 1) { return "1 disc, $name" }
return "$n discs of $name"
}
# Every label a build of $discs discs will use, in order. One disc gives one
# plain label, which is what a single-disc build has always written.
function Get-SetLabels([string]$label, [int]$discs) {
$n = $(if ($discs -lt 1) { 1 } else { $discs })
return ,@(1..$n | ForEach-Object { Get-DiscSetLabel $label $_ $n })
}
# What a build is about to write, and how much of it is already there. The BUILD
# button and the confirmation dialog both ask this - separately, they drifted: the
# dialog listed "RETRO NIGHT D1.iso" and "D2.iso" while the button was still
# asking whether "RETRO NIGHT.iso" existed, a name a set never writes, so it read
# BUILD ISO over a folder holding the whole set.
function Get-BuildTargets([string]$outDir, [string]$label, [int]$discs) {
$labels = Get-SetLabels $label $discs
$isos = @($labels | ForEach-Object { Get-IsoPath $outDir $_ })
$have = @($labels | Where-Object { Test-AlreadyBuilt $outDir $_ })
return @{ Labels=@($labels); Isos=$isos; Existing=$have.Count; Count=@($labels).Count }
}
# What This PC calls each disc in a set. A set of one keeps the plain label:
# a disc with no set around it should not announce itself as "D1".
function Get-DiscSetLabel([string]$label, [int]$n, [int]$of) {
$base = "$label".Trim()
if ($of -le 1) { return $base }
return ("{0} D{1}" -f $base, $n)
}
# The ISO9660 volume identifier is 16 characters, and New-Iso folds anything that
# is not alphanumeric to an underscore. Truncating the finished label at 16 would
# cut the disc number off the end of a long name and hand every disc in the set
# the same volume id, so the number is reserved first and the name takes what is
# left over.
function Get-VolumeLabel([string]$label, [int]$n, [int]$of) {
$sfx = if ($of -le 1) { '' } else { "_D$n" }
$base = (("$label" -replace '[^A-Za-z0-9_]','_')).Trim('_')
$max = 16 - $sfx.Length
if ($max -lt 1) { $max = 1 }
if ($base.Length -gt $max) { $base = $base.Substring(0, $max) }
if ([string]::IsNullOrEmpty($base)) { $base = 'DISC' }
return ($base + $sfx)
}
# Every disc used to ship its icon as "disc.ico". Explorer caches icon bitmaps
# keyed by path, so "E:\disc.ico" was the SAME cache key for every disc that ever
# passed through that drive letter - insert a new disc and Explorer would happily
# redraw the previous disc's icon without re-reading the file. Naming the icon
# after the disc gives each one its own key.
# Strips accents down to the plain letter underneath: a Polish z-acute becomes a
# plain z, a German U-umlaut becomes a plain U. Works by splitting each character
# into its base letter plus its combining marks, then dropping the marks.
# Alphabets with no Latin equivalent - Cyrillic, Greek, CJK - come back untouched,
# which is exactly what the callers test for.
function ConvertTo-AsciiFold([string]$s) {
if ([string]::IsNullOrEmpty($s)) { return '' }
$out = New-Object System.Text.StringBuilder
foreach ($c in $s.Normalize([Text.NormalizationForm]::FormD).ToCharArray()) {
if ([Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne [Globalization.UnicodeCategory]::NonSpacingMark) {
[void]$out.Append($c)
}
}
return $out.ToString().Normalize([Text.NormalizationForm]::FormC)
}
function Get-DiscIconName([string]$label) {
if ([string]::IsNullOrWhiteSpace($label)) { return 'disc.ico' }
# Fold before stripping, or an accented letter is deleted outright rather than
# reduced: an accented letter used to vanish outright, so the Polish spelling of
# "Wiedzmin" produced "Wiedmin". Folding first gives back "Wiedzmin".
$n = ((ConvertTo-AsciiFold $label) -replace '[^A-Za-z0-9]','')
if ($n.Length -gt 40) { $n = $n.Substring(0,40) }
if ([string]::IsNullOrWhiteSpace($n)) {
# A label with no Latin letters or digits at all fell back to the constant
# "disc.ico" - which is exactly the shared per-path cache key this naming
# rule exists to break. Hash the label instead, so two Cyrillic or CJK discs
# in the same drive letter still get different icon filenames.
$md5 = [System.Security.Cryptography.MD5]::Create()
try { $h = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($label)) } finally { $md5.Dispose() }
$n = 'disc' + ((($h[0..3]) | ForEach-Object { $_.ToString('x2') }) -join '')
}
return ($n + '.ico')
}
# Names the build pipeline owns at the disc root - extra content may not use them.
# 'disc.ico' stays reserved even when unused, so extra content cannot collide with
# discs built before the icon was named after the game.
function Test-ReservedDiscName([string]$name,[string]$iconName='disc.ico') {
if ($name -like 'setup_*') { return $true }
return (@('autorun.inf','disc.ico',$iconName,'AUTORUN','Extras','Games',$PROJECT_FILE) -contains $name)
}
# Folder name for one game on a multi-game disc. Numbered, so the order in the
# menu and the order in the disc's own listing agree when someone browses it by
# hand. Folded to plain ASCII for the same reason the disc label is - a disc that
# is legible in every file manager is worth more than an exact title.
function Get-GameFolderName([int]$index,[string]$name) {
$n = (ConvertTo-AsciiFold ([string]$name)) -replace '[^A-Za-z0-9 _\-\.]',' '
$n = ($n -replace '\s+',' ').Trim(' ','.')
if ($n.Length -gt 48) { $n = $n.Substring(0,48).Trim() }
if ([string]::IsNullOrWhiteSpace($n)) { $n = 'Game' }
return ('{0:D2} - {1}' -f $index, $n)
}
# Where an entry's installer sits on the finished disc, relative to the disc root.
# Empty string means the disc root itself.
#
# The staging copy and the menu both need this and they MUST agree. They used to
# work it out separately, a few hundred lines apart, from the same rule written
# twice - and the failure mode is silent: the files land in one place, the menu
# points at another, and the disc burns with an Install button greyed out for a
# reason nothing on screen explains. One function now, called by both.
#
# A disc with a single entry keeps the original flat layout, installer at the
# root, exactly as every disc built before multi-game existed. Two or more and
# every entry moves into Games\, add-ons included: their .bin parts are named
# after their own installer, but a flat root full of a dozen setup files is
# unreadable, and the numbering is what makes the disc browsable by hand.
function Get-DiscEntryFolder([array]$entries,[int]$index) {
if ($entries.Count -le 1) { return '' }
return (Join-Path 'Games' (Get-GameFolderName ($index+1) $entries[$index].GameName))
}
function Get-DiscEntrySetup([array]$entries,[int]$index) {
$rel = Get-DiscEntryFolder $entries $index
$name = $entries[$index].SetupExe.Name
if ([string]::IsNullOrEmpty($rel)) { return $name }
return (Join-Path $rel $name)
}
# Where an entry's own manual and extras live on the disc. Beside its installer,
# so a game and everything belonging to it sit together and can be found by hand
# without the menu. A disc holding one entry keeps the flat Extras\ at the root,
# which is where every disc built before this put them.
function Get-DiscEntryExtras([array]$entries,[int]$index) {
$rel = Get-DiscEntryFolder $entries $index
if ([string]::IsNullOrEmpty($rel)) { return 'Extras' }
return (Join-Path $rel 'Extras')
}
# The menu's view of the disc: games in order, each carrying the add-ons that
# point at it. An add-on whose parent is missing, or which points at another
# add-on, is promoted to a game of its own rather than dropped - a disc that
# shows an unexpected entry is recoverable, one that silently omits an installer
# the user paid for and burned is not.
function Get-MenuGames([array]$entries) {
$out = @()
for ($i = 0; $i -lt $entries.Count; $i++) {
$e = $entries[$i]
$p = [int]$e.ParentIndex
$isAddOn = ($e.Kind -eq 'AddOn') -and $p -ge 0 -and $p -lt $entries.Count -and
$p -ne $i -and $entries[$p].Kind -ne 'AddOn'
if ($isAddOn) { continue }
$addOns = @()
for ($j = 0; $j -lt $entries.Count; $j++) {
$a = $entries[$j]
if ($j -eq $i -or $a.Kind -ne 'AddOn') { continue }
if ([int]$a.ParentIndex -ne $i) { continue }
$addOns += @{ Name=$a.GameName; Setup=(Get-DiscEntrySetup $entries $j) }
}
# An entry's own manual and extras, as paths on the finished disc. Empty
# means it has none of its own and the menu should fall back to the
# disc-wide ones.
$man = ''; $ext = ''
if ($e.ManualPath -or $e.ExtrasPath) {
$ext = Get-DiscEntryExtras $entries $i
if ($e.ManualPath) { $man = Join-Path $ext ([IO.Path]::GetFileName([string]$e.ManualPath)) }
}
$out += @{ Name=$e.GameName; MatchName=$e.GameName
Setup=(Get-DiscEntrySetup $entries $i); AddOns=@($addOns)
Manual=$man; Extras=$ext }
}
return ,@($out)
}
# A game and everything filed under it travel together when a set is packed.
# Splitting them would strand an add-on on a disc whose ParentIndex points at a
# game that is not on it, and the menu would show the patch as a game of its own.
# Uses the same test for "is this really an add-on" as the menu does, so an
# orphan - an add-on whose parent was removed - forms its own group, exactly as
# it becomes its own entry in the menu.
function Get-EntryGroups([array]$entries) {
$groups = @()
for ($i = 0; $i -lt $entries.Count; $i++) {
$e = $entries[$i]
$p = [int]$e.ParentIndex
$isAddOn = ($e.Kind -eq 'AddOn') -and $p -ge 0 -and $p -lt $entries.Count -and
$p -ne $i -and $entries[$p].Kind -ne 'AddOn'
if ($isAddOn) { continue }
$g = @($i)
for ($j = 0; $j -lt $entries.Count; $j++) {
$a = $entries[$j]
if ($j -eq $i -or $a.Kind -ne 'AddOn') { continue }
if ([int]$a.ParentIndex -ne $i) { continue }
$g += $j
}
$groups += ,@($g)
}
return ,@($groups)
}
# One disc's slice of the list, renumbered so the disc stands on its own. Both
# the folder names and the menu key off an entry's position, and ParentIndex has
# to point inside the slice - left pointing at the full list it would either miss
# or, worse, land on a different game.
function Get-DiscEntries([array]$entries, [int[]]$indices) {
$map = @{}
for ($k = 0; $k -lt @($indices).Count; $k++) { $map[[int]$indices[$k]] = $k }
$out = @()
foreach ($i in @($indices)) {
$src = $entries[[int]$i]
$copy = @{}
foreach ($key in $src.Keys) { $copy[$key] = $src[$key] }
$p = [int]$src.ParentIndex
$copy.ParentIndex = $(if ($map.ContainsKey($p)) { $map[$p] } else { -1 })
$out += $copy
}
return ,@($out)
}
# Taking an entry out renumbers every entry after it, and parents are stored as
# positions. Removing the second of four therefore silently re-points an add-on
# that belonged to the fourth at the third - a disc that builds cleanly with the
# DLC filed under the wrong game. So the shift is applied here, in one place,
# rather than left to whoever calls Remove.
#
# An add-on whose own parent is the entry being removed becomes a game of its
# own. The alternative is deleting it too, which throws away an installer the
# user chose, without asking.
function Remove-GameEntry([array]$entries,[int]$index) {
if ($index -lt 0 -or $index -ge $entries.Count) { return ,@($entries) }
$kept = @()
for ($i = 0; $i -lt $entries.Count; $i++) {
if ($i -eq $index) { continue }
$e = $entries[$i]
$p = [int]$e.ParentIndex
if ($p -eq $index) { $e.Kind = 'Game'; $e.ParentIndex = -1 }
elseif ($p -gt $index) { $e.ParentIndex = $p - 1 }
$kept += $e
}
return ,@($kept)
}
# Reordering entries is deliberately not here. The order decides the numbering
# of the Games\ folders and the order of the chooser, so it does matter - but
# nobody has asked for it, and the two buttons it needs do not fit beside the
# list without pushing the window past a 1080p screen. Remove and re-add is the
# workaround until it earns the space.
function Test-IconInput([string]$path) {
$r = @{ Ok=$false; IsIco=$false; W=0; H=0; Msg='' }
if (-not (Test-Path $path)) { $r.Msg='File not found.'; return $r }
$ext = [IO.Path]::GetExtension($path).ToLower()
try {
if ($ext -eq '.ico') {
$ic = New-Object System.Drawing.Icon($path)
$r.IsIco=$true; $r.W=$ic.Width; $r.H=$ic.Height; $ic.Dispose()
$r.Ok=$true; $r.Msg="Valid .ico ($($r.W)x$($r.H) default frame). Will be used as-is."
} else {
$img=[System.Drawing.Image]::FromFile($path); $r.W=$img.Width; $r.H=$img.Height; $img.Dispose()
if ($r.W -lt 64 -or $r.H -lt 64) { $r.Msg="Image is only $($r.W)x$($r.H) - too small (min 64, 256+ recommended)."; return $r }
$r.Ok=$true
$sq = ($r.W -eq $r.H)
$r.Msg = "Image $($r.W)x$($r.H)" + $(if(-not $sq){" (not square - will be cropped to a square icon)"}else{" - good."}) + $(if($r.W -lt 256){" [under 256px: may look soft]"}else{""})
}
} catch { $r.Msg="Not a readable image: $($_.Exception.Message)" }
return $r
}
# The icon is validated the moment it is picked; the background never was, so a
# corrupt or unsupported image sailed through the whole form and only failed at
# build time as "A generic error occurred in GDI+" - a message that tells the user
# nothing about which file is at fault.
function Test-BgInput([string]$path) {
$r = @{ Ok=$false; W=0; H=0; Msg='' }
if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path $path)) { $r.Msg='File not found.'; return $r }
try {
$img=[System.Drawing.Image]::FromFile($path); $r.W=$img.Width; $r.H=$img.Height; $img.Dispose()
if ($r.W -lt 200 -or $r.H -lt 150) {
$r.Msg="The image is only $($r.W)x$($r.H). The menu is 760x480, so this would be stretched past recognition."
return $r
}
$r.Ok=$true
} catch {
# Deliberately NOT surfacing the GDI+ text: it reports "Out of memory" for
# any file it cannot decode, which sends people hunting a memory problem
# they do not have. Say what is actually wrong instead.
$r.Msg = "Windows cannot read that file as an image. It may be corrupt, still downloading, " +
"or a format GDI+ does not support (WebP and HEIC are the usual culprits). " +
"PNG, JPG and BMP always work."
}
return $r
}
function Get-DibBytes([System.Drawing.Bitmap]$bmp) {
$w=$bmp.Width;$h=$bmp.Height
$rect=New-Object System.Drawing.Rectangle(0,0,$w,$h)
$data=$bmp.LockBits($rect,[System.Drawing.Imaging.ImageLockMode]::ReadOnly,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$stride=$data.Stride; $buf=New-Object byte[] ($stride*$h)
[System.Runtime.InteropServices.Marshal]::Copy($data.Scan0,$buf,0,$buf.Length); $bmp.UnlockBits($data)
$ms=New-Object System.IO.MemoryStream; $bw=New-Object System.IO.BinaryWriter($ms)
$bw.Write([int]40);$bw.Write([int]$w);$bw.Write([int]($h*2));$bw.Write([int16]1);$bw.Write([int16]32)
$bw.Write([int]0);$bw.Write([int]0);$bw.Write([int]0);$bw.Write([int]0);$bw.Write([int]0);$bw.Write([int]0)
for($y=$h-1;$y -ge 0;$y--){ $bw.Write($buf,$y*$stride,$w*4) }
$maskRow=[int]([math]::Floor(($w+31)/32))*4; $bw.Write((New-Object byte[] ($maskRow*$h)),0,($maskRow*$h))
$bw.Flush(); return $ms.ToArray()
}
function Convert-ToIco([string]$imgPath, [string]$outIco) {
$src=[System.Drawing.Image]::FromFile($imgPath)
# square master (center-crop)
$side=[math]::Min($src.Width,$src.Height)
$master=New-Object System.Drawing.Bitmap($side,$side,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g=[System.Drawing.Graphics]::FromImage($master); $g.InterpolationMode='HighQualityBicubic'
$g.DrawImage($src,(New-Object System.Drawing.Rectangle(0,0,$side,$side)),
(New-Object System.Drawing.Rectangle([int](($src.Width-$side)/2),[int](($src.Height-$side)/2),$side,$side)),[System.Drawing.GraphicsUnit]::Pixel)
$g.Dispose(); $src.Dispose()
$sizes=@(16,24,32,48,64,128,256); $entries=@()
foreach($s in $sizes){
$b=New-Object System.Drawing.Bitmap($s,$s,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g2=[System.Drawing.Graphics]::FromImage($b); $g2.InterpolationMode='HighQualityBicubic'; $g2.PixelOffsetMode='HighQuality'
$g2.DrawImage($master,0,0,$s,$s); $g2.Dispose()
if ($s -ge 256) {
# Vista onwards, the 256px frame of an .ico is stored as a PNG file
# rather than a raw DIB. A 32-bit DIB at that size is a quarter of a
# megabyte on its own and some shells render it poorly. The directory
# entry already writes 0 for a 256px dimension, which is the same
# convention, so only the payload changes.
$msPng = New-Object System.IO.MemoryStream
$b.Save($msPng,[System.Drawing.Imaging.ImageFormat]::Png)
$entries += @{ w=$s; data=$msPng.ToArray() }
$msPng.Dispose()
} else {
$entries += @{ w=$s; data=(Get-DibBytes $b) }
}
$b.Dispose()
}
$master.Dispose()
$fs=New-Object System.IO.MemoryStream; $w=New-Object System.IO.BinaryWriter($fs)
$w.Write([int16]0);$w.Write([int16]1);$w.Write([int16]$entries.Count); $off=6+16*$entries.Count
foreach($e in $entries){ $wb=if($e.w -ge 256){0}else{$e.w}
$w.Write([byte]$wb);$w.Write([byte]$wb);$w.Write([byte]0);$w.Write([byte]0)
$w.Write([int16]1);$w.Write([int16]32);$w.Write([int]$e.data.Length);$w.Write([int]$off); $off+=$e.data.Length }
foreach($e in $entries){ $w.Write($e.data,0,$e.data.Length) }
$w.Flush(); Clear-ReadOnly $outIco; [System.IO.File]::WriteAllBytes($outIco,$fs.ToArray())
}
# $unit matters: Font sizes in POINTS by default, so on a 300 dpi bitmap a "size"
# meant as pixels comes out ~4x too big. Callers working in pixels must say so.
function New-TitleFont([single]$size,[System.Drawing.GraphicsUnit]$unit=[System.Drawing.GraphicsUnit]::Point) {
try { return New-Object System.Drawing.Font("Bahnschrift SemiBold",$size,[System.Drawing.FontStyle]::Bold,$unit) }
catch { return New-Object System.Drawing.Font("Segoe UI",$size,[System.Drawing.FontStyle]::Bold,$unit) }
}
# $panelSide = 'Right' (default) or 'Left' - which edge the button column sits on.
# Pick the side OPPOSITE the focal point of the artwork, or the buttons cover it.
function New-Background([string]$imgPath,[string]$title,[string]$outPng,[string]$panelSide='Right',[bool]$divider=$false,[bool]$showTitle=$false) {
$W=760;$H=480;$PW=290
$left = ($panelSide -ieq 'Left')
$px = if($left){0}else{$W-$PW} # panel x
$dx = if($left){$PW}else{$W-$PW} # divider x
$img=[System.Drawing.Image]::FromFile($imgPath)
$bmp=New-Object System.Drawing.Bitmap($W,$H)
$g=[System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode='HighQualityBicubic';$g.SmoothingMode='AntiAlias';$g.TextRenderingHint='ClearTypeGridFit'
$scale=[math]::Max($W/$img.Width,$H/$img.Height); $sw=[int]($img.Width*$scale);$sh=[int]($img.Height*$scale)
$g.DrawImage($img,[int](($W-$sw)/2),[int](($H-$sh)/2),$sw,$sh); $img.Dispose()
$g.FillRectangle((New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(70,0,0,0))),0,0,$W,$H)
# darkest under the buttons, fading toward the divider
$rect=New-Object System.Drawing.Rectangle($px,0,$PW,$H)
$cNear=[System.Drawing.Color]::FromArgb(225,2,5,7); $cFar=[System.Drawing.Color]::FromArgb(120,3,8,10)
$c1 = if($left){$cNear}else{$cFar}; $c2 = if($left){$cFar}else{$cNear}
$grad=New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect,$c1,$c2,0.0)
$g.FillRectangle($grad,$rect)
# The divider reads as a hard line drawn across the artwork; off by default.
if ($divider) { $g.DrawLine((New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(200,0,190,200),2)),$dx,0,$dx,$H) }
# Title on the artwork is OFF by default: cover art usually carries the game's
# own logo already, and a second title drawn over it just fights the artwork.
if ($showTitle -and -not [string]::IsNullOrWhiteSpace($title)) {
# title goes on the artwork side, shrunk to fit
$tx = if($left){$PW+40}else{27}
$maxW = $W-$PW-54
$size=30.0; $f=New-TitleFont $size
while ($size -gt 12 -and $g.MeasureString($title,$f).Width -gt $maxW) {
$f.Dispose(); $size -= 1.5; $f=New-TitleFont $size
}
$g.DrawString($title,$f,(New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(180,0,0,0))),($tx+2),29)
$g.DrawString($title,$f,(New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(235,235,245,248))),$tx,27)
$f.Dispose()
}
$g.Dispose(); Save-PngAtomic $bmp $outPng; $bmp.Dispose()
}
# Control characters, gone. Everything a build writes goes into a line-based or a
# quoted format - autorun.inf is one directive per line, the menu's JScript puts
# names inside string literals - so a stray CR or LF does not corrupt the text, it
# ends the line and starts a new one.
#
# In autorun.inf that means a label carrying a newline writes further directives:
#
# label=My Game
# open=Extras\payload.exe <- came from inside the "label"
#
# Windows 7 and later will not silently run an open= from optical media; it offers
# it in the AutoPlay prompt. But the wording of that prompt and what it points at
# would both be chosen by whoever wrote the project file, on a disc the person
# burning it believed was theirs. Project files do get passed around - this repo's
# own README suggests attaching one to a bug report.
#
# In the menu's JScript a newline is an unterminated string literal, so the whole
# HTA fails to parse and the disc opens to nothing.
#
# This is not a restriction on what may be typed. The label box is single line and
# the UI cannot produce these. It is a filter on what a FILE may carry: assigning
# to a single-line TextBox does not strip them, which is exactly the path a loaded
# project takes to reach the build.
function Remove-ControlChars([string]$s) {
if ([string]::IsNullOrEmpty($s)) { return $s }
return ($s -replace '[\x00-\x1F\x7F]','')
}
# What a label will actually look like once it has been through autorun.inf.
# AutoRun reads the file in the system ANSI codepage - it has no Unicode mode at
# all - so this is a property of Windows, not something the tool can fix. Returns
# the surviving text plus whether anything was lost, so callers can warn rather
# than silently ship a drive called "???????".
function Get-AutorunLabelPreview([string]$label) {
$enc = [System.Text.Encoding]::Default
$seen = $enc.GetString($enc.GetBytes($label))
return @{ Text=$seen; Lossy=($seen -ne $label); Codepage=$enc.WebName }
}
function New-AutorunInf([string]$label,[string]$iconName,[bool]$menu,[string]$out) {
# Stripped here rather than only at the call site: this function is what turns
# text into directives, so it is the last place that can be sure.
$label = Remove-ControlChars $label
$iconName = Remove-ControlChars $iconName
$lines = @('[autorun]')
if ($menu) { $lines += 'shellexecute=AUTORUN\menu.hta' }
$lines += "icon=$iconName"
$lines += "label=$label"
if ($menu) { $lines += 'action=Run ' + $label }
$lines += @('','[Content]','MusicFiles=false','PictureFiles=false','VideoFiles=false')
# ANSI, not ASCII. ASCII turned every accented character into a literal "?", so
# "Uber Alles" reached Explorer as "?ber Alles". The ANSI codepage carries the
# Latin-1 accents and the typographic dashes GOG titles are full of, and best-fit
# maps most of what it cannot hold - a Polish z-acute arrives as a plain z
# rather than as a question mark.
# Not UTF8: PowerShell 5.1 writes a BOM, which AutoRun does not understand.
Clear-ReadOnly $out; Set-Content -LiteralPath $out -Value ($lines -join "`r`n") -Encoding Default
}
function New-MenuHta([hashtable]$cfg,[string]$out) {
# cfg: GameName (the disc's label), Games (array), Buttons (ordered array),
# MusicFile, ManualFile, PanelSide, IconName, WindowBorder, ButtonStyle
#
# Each entry of Games is @{ Name; MatchName; Setup; AddOns=@(@{Name;Setup}) },
# built by Get-MenuGames. Setup paths are relative to the disc root.
#
# The panel used to be baked here as fixed HTML, one button per ticked option.
# It cannot be any more: the button list now depends on which screen the menu
# is showing, and that is only known while it runs. So the panel is rendered
# from data by the menu itself, and what this function emits is the data.
$panelLeft = if($cfg.PanelSide -ieq 'Left'){20}else{490}
$menuGames = @($cfg.Games)
# A caller that still speaks the old single-game contract keeps working, so
# Preview and anything else calling this directly did not all have to change
# in the same commit.
if ($menuGames.Count -eq 0 -and $cfg.SetupExe) {
$mn = if ($cfg.MatchName) { $cfg.MatchName } else { $cfg.GameName }
$menuGames = @(@{ Name=$cfg.GameName; MatchName=$mn; Setup=$cfg.SetupExe; AddOns=@() })
}
$gamesJs = '[' + (($menuGames | ForEach-Object {
$addJs = '[' + (($_.AddOns | ForEach-Object {
'{n:"' + (ConvertTo-JsString $_.Name) + '",s:"' + (ConvertTo-JsString $_.Setup) + '"}'
}) -join ',') + ']'
$mn = if ($_.MatchName) { $_.MatchName } else { $_.Name }
'{n:"' + (ConvertTo-JsString $_.Name) + '",m:"' + (ConvertTo-JsString $mn) +
'",s:"' + (ConvertTo-JsString $_.Setup) +
'",man:"' + (ConvertTo-JsString ([string]$_.Manual)) +
'",ext:"' + (ConvertTo-JsString ([string]$_.Extras)) + '",a:' + $addJs + '}'
}) -join ',') + ']'
$btnsJs = '[' + ((@($cfg.Buttons) | ForEach-Object { '"' + (ConvertTo-JsString $_) + '"' }) -join ',') + ']'
# <bgsound> no longer plays MP3 in current MSHTML - it silently does nothing.
# The menu renders in quirks mode (no doctype), so switching to a standards
# document mode for <audio> would break the button box model. Use the Windows
# Media Player control instead, which works in this document mode.
$musicJs = ''
if ($cfg.MusicFile) { $musicJs = ConvertTo-JsString $cfg.MusicFile }
$tpl = @'
<html>
<head>
<hta:application id="app" applicationname="%%APPNAME%%" border="none" caption="no"
showintaskbar="yes" singleinstance="yes" sysmenu="no" scroll="no" selection="no"
contextmenu="no" innerborder="no" maximizebutton="no" minimizebutton="no"
icon="%%ICONFILE%%" />
<title>%%TITLE%%</title>
<style>
html,body{margin:0;padding:0;width:760px;height:480px;overflow:hidden;background:#04080a;font-family:'Bahnschrift','Segoe UI',Arial,sans-serif;}
#stage{position:absolute;left:0;top:0;width:760px;height:480px;background:#04080a url('bg.png') no-repeat 0 0;%%STAGEBORDER%%}
.panel{position:absolute;left:%%PANELLEFT%%px;top:20px;width:250px;}
/* Game names are user data and some are long. nowrap+hidden keeps one that got
past the length clip from growing the 46px button and throwing the panel's
vertical centering out. */
.btn{display:block;width:250px;height:46px;margin:0 0 12px 0;line-height:46px;color:#e6ebef;text-decoration:none;
font-size:15px;font-weight:600;letter-spacing:2px;text-transform:uppercase;cursor:pointer;background:#0a1519;
white-space:nowrap;overflow:hidden;
%%BTNBORDER%%padding-left:16px;}
.btn:hover{background:#12242b;border-color:#00bec8;color:#fff;}
.btn.play{border-left-color:#35c46a;} .btn.play:hover{border-color:#66e090;}
.btn.install{border-left-color:#ff781e;} .btn.install:hover{border-color:#ff9a4d;}
.btn.exit{border-left-color:#a03434;} .btn.exit:hover{border-color:#c86464;}
#x{position:absolute;right:8px;top:8px;width:28px;height:26px;line-height:26px;text-align:center;color:#e6ebef;
font-family:'Segoe UI',Arial;font-weight:bold;background:#0a1519;border:1px solid #7a2c2c;cursor:pointer;}