]> FriiDump Source - friidump.git/blob - validation/friidump-v0.5.3.16-pf1-candidate21-windows-live-validation.ps1
Record A102 and B101 live profile evidence
[friidump.git] / validation / friidump-v0.5.3.16-pf1-candidate21-windows-live-validation.ps1
1 [CmdletBinding()]
2 param(
3     [ValidateSet(
4         'Build',
5         'NoMedia',
6         'GameCube',
7         'XboxIso',
8         'XboxXiso',
9         'XboxCancel',
10         'PrepareCancellation',
11         'ValidateReports',
12         'Collect'
13     )]
14     [string]$Phase = 'Build',
15
16     [string]$SourceRoot = (Get-Location).Path,
17
18     [string]$Drive,
19
20     [string]$ResultsRoot,
21
22     [string]$OutputName,
23
24     [string]$FirmwareModifiedNote,
25
26     [bool]$UseMethod8 = $true,
27
28     [string[]]$ExtraFriiDumpArgs = @(),
29
30     [string]$PhpValidator
31 )
32
33 Set-StrictMode -Version Latest
34 $ErrorActionPreference = 'Stop'
35
36 $ExpectedVersion = '0.5.3.16-pf1-candidate21'
37 $ExpectedCommit = $null
38 $ExpectedSchemaSha256 = 'd1e97cb3e88e0f5a94ccf9b9edf921554fc15278662e69420b8c1e564c600411'
39
40 $SourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path
41 $CandidateMetadataPath = Join-Path $SourceRoot 'CANDIDATE.json'
42 if (-not (Test-Path -LiteralPath $CandidateMetadataPath -PathType Leaf)) {
43     throw "Candidate metadata is missing: $CandidateMetadataPath"
44 }
45 $CandidateMetadata = Get-Content -Raw -LiteralPath $CandidateMetadataPath | ConvertFrom-Json
46 $ExpectedCommit = [string]$CandidateMetadata.candidate_implementation.commit
47 if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') {
48     throw "Candidate implementation commit is invalid: $ExpectedCommit"
49 }
50 $ExpectedCommit = $ExpectedCommit.ToLowerInvariant()
51
52 if ([string]::IsNullOrWhiteSpace($ResultsRoot)) {
53     $ResultsRoot = Join-Path $SourceRoot '_native-report-live-validation'
54 }
55 $ResultsRoot = [System.IO.Path]::GetFullPath($ResultsRoot)
56
57 $Exe = Join-Path $SourceRoot 'friidump.exe'
58 $BuildScript = Join-Path $SourceRoot 'build_msvc32.cmd'
59 $FixtureBuildScript = Join-Path $SourceRoot 'build_msvc32_native_report_tests.cmd'
60 $Validator = Join-Path $SourceRoot 'tests\validate_native_reports.py'
61 $Schema = Join-Path $SourceRoot 'tests\contracts\friidump-test-result.v1.schema.json'
62 $SourceManifest = Join-Path $SourceRoot 'SOURCE_SHA256SUMS.txt'
63 $MainResponseFile = Join-Path $SourceRoot 'msvc32_friidump.rsp'
64 $FixtureResponseFile = Join-Path $SourceRoot 'msvc32_native_report_tests.rsp'
65
66 function Assert-True {
67     param(
68         [Parameter(Mandatory)]
69         [bool]$Condition,
70         [Parameter(Mandatory)]
71         [string]$Message
72     )
73
74     if (-not $Condition) {
75         throw $Message
76     }
77 }
78
79 function Assert-LastExitCode {
80     param(
81         [Parameter(Mandatory)]
82         [string]$Operation
83     )
84
85     if ($LASTEXITCODE -ne 0) {
86         throw "$Operation failed with exit code $LASTEXITCODE."
87     }
88 }
89
90 function Get-Sha256 {
91     param([Parameter(Mandatory)][string]$Path)
92
93     return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant()
94 }
95
96 function Test-SourceManifest {
97     if (-not (Test-Path -LiteralPath $SourceManifest -PathType Leaf)) {
98         Write-Host '[INFO] SOURCE_SHA256SUMS.txt is not present; package-manifest validation skipped.'
99         return
100     }
101
102     $checked = 0
103     foreach ($line in Get-Content -LiteralPath $SourceManifest) {
104         if ([string]::IsNullOrWhiteSpace($line)) {
105             continue
106         }
107         if ($line -notmatch '^([0-9a-fA-F]{64})  (.+)$') {
108             throw "Malformed source-manifest line: $line"
109         }
110
111         $expected = $Matches[1].ToLowerInvariant()
112         $relative = $Matches[2] -replace '/', '\'
113         $path = Join-Path $SourceRoot $relative
114         Assert-True (Test-Path -LiteralPath $path -PathType Leaf) "Manifest file is missing: $relative"
115         $actual = Get-Sha256 $path
116         Assert-True ($actual -eq $expected) "Manifest SHA-256 mismatch: $relative"
117         $checked++
118     }
119
120     Write-Host "Source manifest: PASS ($checked files)"
121 }
122
123 function Test-MsvcSplitLinkContract {
124     param(
125         [Parameter(Mandatory)][string]$ResponsePath,
126         [Parameter(Mandatory)][string]$BuildScriptPath,
127         [Parameter(Mandatory)][string]$ExpectedInvocation,
128         [Parameter(Mandatory)][string]$Label
129     )
130
131     Assert-True (Test-Path -LiteralPath $ResponsePath -PathType Leaf) "$Label response file is missing: $ResponsePath"
132     Assert-True (Test-Path -LiteralPath $BuildScriptPath -PathType Leaf) "$Label build script is missing: $BuildScriptPath"
133
134     $lines = @(
135         Get-Content -LiteralPath $ResponsePath |
136             ForEach-Object { $_.Trim() } |
137             Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
138     )
139
140     Assert-True (-not ($lines -icontains '/link')) "$Label response file must not contain /link. The linker boundary must be on the cl.exe command line."
141     Assert-True (-not ($lines -icontains 'ole32.lib')) "$Label response file must not contain ole32.lib. The library must follow /link on the cl.exe command line."
142
143     $buildText = Get-Content -Raw -LiteralPath $BuildScriptPath
144     Assert-True ($buildText.Contains($ExpectedInvocation)) "$Label build script is missing the required split compile/link invocation: $ExpectedInvocation"
145
146     Write-Host "$Label MSVC command-line linker boundary: PASS"
147 }
148
149 function Test-PythonValidatorAvailable {
150     & py -3 -c 'import jsonschema' 2>$null
151     if ($LASTEXITCODE -ne 0) {
152         throw 'Python 3 with jsonschema is required. Install it with: py -3 -m pip install jsonschema'
153     }
154 }
155
156 function Invoke-ReportValidation {
157     param([Parameter(Mandatory)][string]$ReportRoot)
158
159     Assert-True (Test-Path -LiteralPath $Validator -PathType Leaf) "Validator is missing: $Validator"
160     Assert-True (Test-Path -LiteralPath $Schema -PathType Leaf) "Schema is missing: $Schema"
161     Assert-True ((Get-Sha256 $Schema) -eq $ExpectedSchemaSha256) 'Bundled schema SHA-256 mismatch.'
162
163     Test-PythonValidatorAvailable
164
165     $arguments = @(
166         '-3',
167         $Validator,
168         '--fixtures', $ReportRoot,
169         '--schema', $Schema,
170         '--allow-arbitrary'
171     )
172
173     if (-not [string]::IsNullOrWhiteSpace($PhpValidator)) {
174         Assert-True (Test-Path -LiteralPath $PhpValidator -PathType Leaf) "PHP validator is missing: $PhpValidator"
175         $arguments += @('--php-validator', $PhpValidator)
176     }
177
178     & py @arguments
179     Assert-LastExitCode 'Strict native-report validation'
180 }
181
182 function Get-NewReport {
183     param(
184         [Parameter(Mandatory)][string]$ReportDirectory,
185         [Parameter(Mandatory)][datetime]$Started
186     )
187
188     $reports = @(
189         Get-ChildItem -LiteralPath $ReportDirectory -Recurse -Filter '*.friidump.json' -File |
190             Where-Object { $_.LastWriteTimeUtc -ge $Started.ToUniversalTime().AddSeconds(-2) } |
191             Sort-Object LastWriteTimeUtc
192     )
193
194     Assert-True ($reports.Count -eq 1) "Expected exactly one new report; found $($reports.Count)."
195     return $reports[0]
196 }
197
198 function Read-Report {
199     param([Parameter(Mandatory)][string]$Path)
200
201     return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json
202 }
203
204 function Test-CommonReportIdentity {
205     param(
206         [Parameter(Mandatory)]$Report,
207         [Parameter(Mandatory)][string]$ReportPath
208     )
209
210     Assert-True ($Report.schema -eq 'friidump-test-result.v1') "${ReportPath}: schema mismatch."
211     Assert-True ($Report.generator.name -eq 'FriiDump') "${ReportPath}: generator name mismatch."
212     Assert-True ($Report.generator.version -eq $ExpectedVersion) "${ReportPath}: generator version mismatch."
213     Assert-True ($Report.generator.build_commit -eq $ExpectedCommit) "${ReportPath}: build commit mismatch."
214     Assert-True (-not [string]::IsNullOrWhiteSpace([string]$Report.run.run_id)) "${ReportPath}: run UUID missing."
215     Assert-True (-not [string]::IsNullOrWhiteSpace([string]$Report.drive.model)) "${ReportPath}: drive model missing."
216     Assert-True (-not [string]::IsNullOrWhiteSpace([string]$Report.drive.firmware_revision)) "${ReportPath}: firmware revision missing."
217
218     if ([string]::IsNullOrWhiteSpace($FirmwareModifiedNote)) {
219         Assert-True ($Report.drive.firmware_modified -eq $false) "${ReportPath}: omitted --firmware-modified must assume stock firmware."
220         Assert-True ($null -eq $Report.drive.modification_note) "${ReportPath}: stock firmware must not have a modification note."
221     }
222     else {
223         Assert-True ($Report.drive.firmware_modified -eq $true) "${ReportPath}: modified-firmware flag was not recorded."
224         Assert-True ([string]$Report.drive.modification_note -eq $FirmwareModifiedNote) "${ReportPath}: modified-firmware explanation mismatch."
225     }
226 }
227
228 function Test-DumpOutputIdentity {
229     param(
230         [Parameter(Mandatory)]$Report,
231         [Parameter(Mandatory)][string]$ExpectedOutput
232     )
233
234     $resolvedOutput = [System.IO.Path]::GetFullPath($ExpectedOutput)
235     Assert-True (Test-Path -LiteralPath $resolvedOutput -PathType Leaf) "Dump output is missing: $resolvedOutput"
236
237     $actualBytes = (Get-Item -LiteralPath $resolvedOutput).Length
238     Assert-True ([int64]$Report.dump.byte_count -eq $actualBytes) 'Report byte count does not match the output file.'
239     Assert-True ([string]$Report.dump.output_path -eq $resolvedOutput) 'Report output path does not match the requested path.'
240
241     $artifacts = @($Report.artifacts | Where-Object { $_.type -eq 'dump_output' })
242     Assert-True ($artifacts.Count -eq 1) 'Exactly one dump_output artifact is required.'
243     Assert-True ([string]$artifacts[0].path -eq $resolvedOutput) 'dump_output artifact path mismatch.'
244     Assert-True ([int64]$artifacts[0].bytes -eq $actualBytes) 'dump_output artifact byte count mismatch.'
245
246     if ($null -ne $Report.hashes.sha256) {
247         $actualSha = Get-Sha256 $resolvedOutput
248         Assert-True ([string]$Report.hashes.sha256 -eq $actualSha) 'Report SHA-256 does not match the output file.'
249         Assert-True ([string]$artifacts[0].sha256 -eq $actualSha) 'Artifact SHA-256 does not match the output file.'
250     }
251 }
252
253 function New-RunDirectory {
254     param([Parameter(Mandatory)][string]$Name)
255
256     $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
257     $path = Join-Path $ResultsRoot "$stamp-$Name"
258     New-Item -ItemType Directory -Force -Path $path | Out-Null
259     New-Item -ItemType Directory -Force -Path (Join-Path $path 'reports') | Out-Null
260     return $path
261 }
262
263 function Invoke-FriiDumpRun {
264     param(
265         [Parameter(Mandatory)][string[]]$Arguments,
266         [Parameter(Mandatory)][string]$RunDirectory,
267         [Parameter(Mandatory)][bool]$ExpectSuccess
268     )
269
270     $started = (Get-Date).ToUniversalTime()
271
272     Write-Host "Working directory: $RunDirectory"
273     Write-Host "Command: $Exe $($Arguments -join ' ')"
274     Write-Host 'Console mode: direct live FriiDump output; FriiDump native .log is the evidence log.'
275
276     # Do not route an interactive FriiDump run through a PowerShell
277     # pipeline. Tee-Object buffers carriage-return progress updates, turns
278     # native stderr into PowerShell error records, and also contaminates a
279     # function's return stream with every line emitted by the child.
280     #
281     # Start FriiDump with inherited console handles instead. This preserves
282     # real-time progress output and leaves this function with exactly one
283     # return object.
284     function ConvertTo-WindowsCommandLineArgument {
285         param([AllowEmptyString()][string]$Value)
286
287         if ($Value.Length -gt 0 -and $Value -notmatch '[\s"]') {
288             return $Value
289         }
290
291         $builder = New-Object System.Text.StringBuilder
292         [void]$builder.Append('"')
293         $backslashes = 0
294
295         foreach ($character in $Value.ToCharArray()) {
296             if ($character -eq '\') {
297                 $backslashes++
298                 continue
299             }
300
301             if ($character -eq '"') {
302                 [void]$builder.Append(('\' * (($backslashes * 2) + 1)))
303                 [void]$builder.Append('"')
304                 $backslashes = 0
305                 continue
306             }
307
308             if ($backslashes -gt 0) {
309                 [void]$builder.Append(('\' * $backslashes))
310                 $backslashes = 0
311             }
312
313             [void]$builder.Append($character)
314         }
315
316         if ($backslashes -gt 0) {
317             [void]$builder.Append(('\' * ($backslashes * 2)))
318         }
319
320         [void]$builder.Append('"')
321         return $builder.ToString()
322     }
323
324     $argumentLine = (
325         $Arguments |
326             ForEach-Object {
327                 ConvertTo-WindowsCommandLineArgument ([string]$_)
328             }
329     ) -join ' '
330
331     $startInfo = New-Object System.Diagnostics.ProcessStartInfo
332     $startInfo.FileName = $Exe
333     $startInfo.Arguments = $argumentLine
334     $startInfo.WorkingDirectory = $RunDirectory
335     $startInfo.UseShellExecute = $false
336     $startInfo.CreateNoWindow = $false
337     $startInfo.RedirectStandardOutput = $false
338     $startInfo.RedirectStandardError = $false
339
340     $process = New-Object System.Diagnostics.Process
341     $process.StartInfo = $startInfo
342
343     try {
344         [void]$process.Start()
345         $process.WaitForExit()
346         $exitCode = $process.ExitCode
347     }
348     finally {
349         $process.Dispose()
350     }
351
352     Set-Content -LiteralPath (Join-Path $RunDirectory 'exit-code.txt') -Value $exitCode -Encoding ASCII
353
354     if ($ExpectSuccess) {
355         Assert-True ($exitCode -eq 0) "FriiDump was expected to succeed but exited with $exitCode."
356     }
357
358     return [pscustomobject]@{
359         Started = $started
360         ExitCode = [int]$exitCode
361         Log = $null
362     }
363 }
364
365 function Add-ModifiedFirmwareArgument {
366     param([string[]]$Arguments)
367
368     if (-not [string]::IsNullOrWhiteSpace($FirmwareModifiedNote)) {
369         return $Arguments + @('--firmware-modified', $FirmwareModifiedNote)
370     }
371     return $Arguments
372 }
373
374 New-Item -ItemType Directory -Force -Path $ResultsRoot | Out-Null
375
376 switch ($Phase) {
377     'Build' {
378         Assert-True (Test-Path -LiteralPath $BuildScript -PathType Leaf) "Build script is missing: $BuildScript"
379         Assert-True (Test-Path -LiteralPath $FixtureBuildScript -PathType Leaf) "Fixture build script is missing: $FixtureBuildScript"
380         Test-SourceManifest
381
382         $XboxRegionHeader = Join-Path $SourceRoot 'libfriidump\xbox_region.h'
383         $XboxLegacyUtilsHeader = Join-Path $SourceRoot 'libfriidump\xbox_ref\utils.h'
384         $XboxRegionHeaderText = Get-Content -Raw -LiteralPath $XboxRegionHeader
385         $XboxLegacyUtilsText = Get-Content -Raw -LiteralPath $XboxLegacyUtilsHeader
386
387         Assert-True (-not $XboxRegionHeaderText.Contains('<stdbool.h>')) `
388             'xbox_region.h must not introduce stdbool.h into the copied Xbox header graph.'
389         Assert-True ($XboxRegionHeaderText.Contains('int xbox_region_format(')) `
390             'xbox_region.h must expose the MSVC-safe int return type.'
391         Assert-True ($XboxLegacyUtilsText.Contains('typedef int bool;')) `
392             'The copied Xbox legacy bool declaration changed unexpectedly.'
393         Write-Host 'Xbox region/MSVC bool isolation: PASS'
394
395         $XboxCancelContract = Join-Path $SourceRoot 'tests\test_xbox_cancel_contract.py'
396         Assert-True (Test-Path -LiteralPath $XboxCancelContract -PathType Leaf) `
397             'Xbox copied-dumper cancellation contract test is missing.'
398         & py -3 $XboxCancelContract --source-root $SourceRoot
399         Assert-LastExitCode 'Xbox copied-dumper cancellation contract'
400
401         $SeedDiagnosticContract = Join-Path $SourceRoot 'tests\test_seed_diagnostics_contract.py'
402         Assert-True (Test-Path -LiteralPath $SeedDiagnosticContract -PathType Leaf) `
403             'Seed diagnostics contract test is missing.'
404         & py -3 $SeedDiagnosticContract
405         Assert-LastExitCode 'Seed diagnostics contract'
406
407         $LinuxRawIoContract = Join-Path $SourceRoot 'tests\test_linux_rawio_contract.py'
408         Assert-True (Test-Path -LiteralPath $LinuxRawIoContract -PathType Leaf) `
409             'Linux raw-I/O source/documentation contract test is missing.'
410         & py -3 $LinuxRawIoContract
411         Assert-LastExitCode 'Linux raw-I/O source/documentation contract'
412
413         $XboxDvdStructureContract = Join-Path $SourceRoot 'tests\test_xbox_dvd_structure_contract.py'
414         Assert-True (Test-Path -LiteralPath $XboxDvdStructureContract -PathType Leaf) `
415             'Xbox READ DVD STRUCTURE CDB contract test is missing.'
416         & py -3 $XboxDvdStructureContract --source-root $SourceRoot
417         Assert-LastExitCode 'Xbox READ DVD STRUCTURE CDB contract'
418
419         Test-MsvcSplitLinkContract `
420             -ResponsePath $MainResponseFile `
421             -BuildScriptPath $BuildScript `
422             -ExpectedInvocation 'cl @"%EFFECTIVE_RSP%" /link ole32.lib' `
423             -Label 'FriiDump executable'
424
425         Test-MsvcSplitLinkContract `
426             -ResponsePath $FixtureResponseFile `
427             -BuildScriptPath $FixtureBuildScript `
428             -ExpectedInvocation 'cl @"%~dp0msvc32_native_report_tests.rsp" /link ole32.lib' `
429             -Label 'Native-report fixtures'
430
431         $env:FRIIDUMP_BUILD_COMMIT = $ExpectedCommit
432
433         & $BuildScript clean
434         Assert-LastExitCode 'MSVC clean'
435
436         & $BuildScript
437         Assert-LastExitCode 'MSVC candidate build'
438
439         & $FixtureBuildScript
440         Assert-LastExitCode 'MSVC native-report fixture validation'
441
442         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'friidump.exe was not produced.'
443
444         # Windows PowerShell 5.1 converts native stderr records into
445         # NativeCommandError objects when $ErrorActionPreference is Stop.
446         # FriiDump intentionally writes part of its startup/help text to
447         # stderr, so capture both streams through ProcessStartInfo instead
448         # of invoking the executable directly through the PowerShell
449         # pipeline.
450         $helpStartInfo = New-Object System.Diagnostics.ProcessStartInfo
451         $helpStartInfo.FileName = $Exe
452         $helpStartInfo.Arguments = '--help'
453         $helpStartInfo.UseShellExecute = $false
454         $helpStartInfo.RedirectStandardOutput = $true
455         $helpStartInfo.RedirectStandardError = $true
456         $helpStartInfo.CreateNoWindow = $true
457
458         $helpProcess = New-Object System.Diagnostics.Process
459         $helpProcess.StartInfo = $helpStartInfo
460
461         try {
462             [void]$helpProcess.Start()
463
464             # Drain stdout and stderr concurrently. Reading either redirected
465             # stream synchronously before the other can deadlock when the
466             # second pipe fills while the process is still running.
467             $helpStdoutTask = $helpProcess.StandardOutput.ReadToEndAsync()
468             $helpStderrTask = $helpProcess.StandardError.ReadToEndAsync()
469
470             $helpProcess.WaitForExit()
471
472             $helpStdout = $helpStdoutTask.GetAwaiter().GetResult()
473             $helpStderr = $helpStderrTask.GetAwaiter().GetResult()
474             $helpExitCode = $helpProcess.ExitCode
475         }
476         finally {
477             $helpProcess.Dispose()
478         }
479
480         $help = @(
481             $helpStdout,
482             $helpStderr
483         ) -join [Environment]::NewLine
484
485         Assert-True ($helpExitCode -eq 1) "Candidate help command returned unexpected exit code $helpExitCode; FriiDump historically returns 1 for --help."
486         Assert-True ($help.Contains("FriiDump $ExpectedVersion")) 'Candidate version is missing from help output.'
487         foreach ($option in @('--report-json', '--report-dir', '--firmware-modified')) {
488             Assert-True ($help.Contains($option)) "Candidate help is missing $option."
489         }
490         Assert-True ($help.Contains('Native .friidump.json reports are created by default')) 'Default native-report behavior is missing from help output.'
491         Assert-True ($help.Contains('beside the final log')) 'Default beside-log report placement is missing from help output.'
492         Assert-True ($help.Contains('(may be combined with --report-json)')) 'Combined --report-json/--report-dir behavior is missing from help output.'
493         Assert-True ($help.Contains('omission assumes stock')) 'Stock-firmware default is missing from help output.'
494
495         $buildRecord = @(
496             "Generated UTC: $((Get-Date).ToUniversalTime().ToString('o'))",
497             "Version: $ExpectedVersion",
498             "Commit: $ExpectedCommit",
499             "Executable: $Exe",
500             "Executable SHA-256: $(Get-Sha256 $Exe)",
501             'MSVC build: PASS',
502             'MSVC native-report fixtures: PASS',
503             'Schema and semantic validation: PASS (13 reports)',
504             'Help/version/options/default-location smoke: PASS',
505             'Xbox copied-dumper cancellation, hashing, and summary contract: PASS',
506             'Seed diagnostics, transport evidence, and seed-failure STOP UNIT contract: PASS',
507             'Linux CAP_SYS_RAWIO preflight, documentation, helper, and build-warning contract: PASS'
508         )
509         $recordPath = Join-Path $ResultsRoot 'build-validation.txt'
510         Set-Content -LiteralPath $recordPath -Value $buildRecord -Encoding UTF8
511
512         Write-Host ''
513         Write-Host 'FRIIDUMP CANDIDATE21 WINDOWS BUILD: PASS'
514         Write-Host "Build record: $recordPath"
515         break
516     }
517
518     'NoMedia' {
519         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'Build the candidate first with -Phase Build.'
520         Assert-True (-not [string]::IsNullOrWhiteSpace($Drive)) '-Drive is required.'
521
522         Read-Host "Remove all media from $Drive, wait for the drive to settle, then press Enter"
523         $runDir = New-RunDirectory 'no-media-default-report'
524         $output = Join-Path $runDir 'no-media-placeholder.iso'
525         $args = @('-d', $Drive, '-i', $output)
526         $args = Add-ModifiedFirmwareArgument $args
527         $args += $ExtraFriiDumpArgs
528
529         $run = Invoke-FriiDumpRun -Arguments $args -RunDirectory $runDir -ExpectSuccess $false
530         $reportFile = Get-NewReport -ReportDirectory $runDir -Started $run.Started
531         Assert-True ($reportFile.Name -eq 'no-media-placeholder.iso.friidump.json') "Default report filename mismatch: $($reportFile.Name)"
532         Assert-True ($reportFile.DirectoryName -eq $runDir) "Default no-media report was not placed beside the final log/output path."
533         Invoke-ReportValidation $runDir
534         $report = Read-Report $reportFile.FullName
535         Test-CommonReportIdentity $report $reportFile.FullName
536
537         Assert-True ($report.run.test_type -eq 'diagnostic_no_media') 'No-media test_type mismatch.'
538         Assert-True ($report.run.result -eq 'not_applicable') 'No-media run result mismatch.'
539         Assert-True ($report.dump.attempted -eq $false) 'No-media report must not claim a dump.'
540         Assert-True (-not (Test-Path -LiteralPath $output)) 'No-media test unexpectedly created an output image.'
541
542         Write-Host ''
543         Write-Host 'NO-MEDIA DEFAULT NATIVE REPORT: PASS'
544         Write-Host "Report: $($reportFile.FullName)"
545         break
546     }
547
548     'GameCube' {
549         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'Build the candidate first with -Phase Build.'
550         Assert-True (-not [string]::IsNullOrWhiteSpace($Drive)) '-Drive is required.'
551
552         $runDir = New-RunDirectory 'gamecube-full-dump'
553         if ([string]::IsNullOrWhiteSpace($OutputName)) {
554             $OutputName = 'Sonic Mega Collection.iso'
555         }
556         $output = [System.IO.Path]::GetFullPath((Join-Path $runDir $OutputName))
557         $args = @('-d', $Drive)
558         if ($UseMethod8) {
559             $args += '-8'
560         }
561         $args += @('-i', $output, '--report-dir', $reportDir)
562         $args = Add-ModifiedFirmwareArgument $args
563         $args += $ExtraFriiDumpArgs
564
565         $run = Invoke-FriiDumpRun -Arguments $args -RunDirectory $runDir -ExpectSuccess $true
566         $reportFile = Get-NewReport -ReportDirectory $reportDir -Started $run.Started
567         Invoke-ReportValidation $reportDir
568         $report = Read-Report $reportFile.FullName
569         Test-CommonReportIdentity $report $reportFile.FullName
570         Test-DumpOutputIdentity $report $output
571
572         Assert-True ($report.media.platform -eq 'gamecube') 'Expected a GameCube report.'
573         Assert-True ($report.run.test_type -eq 'full_dump' -and $report.run.result -eq 'pass') 'GameCube run outcome mismatch.'
574         Assert-True ($report.dump.result -eq 'pass') 'GameCube dump result mismatch.'
575         Assert-True (@($report.notes) -contains 'Measurement scope: full_optical_payload.') 'GameCube measurement scope mismatch.'
576
577         Write-Host ''
578         Write-Host 'GAMECUBE FULL-DUMP NATIVE REPORT: PASS'
579         Write-Host "Report: $($reportFile.FullName)"
580         break
581     }
582
583     'XboxIso' {
584         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'Build the candidate first with -Phase Build.'
585         Assert-True (-not [string]::IsNullOrWhiteSpace($Drive)) '-Drive is required.'
586
587         $runDir = New-RunDirectory 'xbox-redump-iso'
588         $reportDir = Join-Path $runDir 'reports'
589         if ([string]::IsNullOrWhiteSpace($OutputName)) {
590             $OutputName = 'Red Faction II [TQ00501A].iso'
591         }
592         $output = [System.IO.Path]::GetFullPath((Join-Path $runDir $OutputName))
593         $args = @('-d', $Drive, '-T', '4', '-i', $output, '--report-dir', $reportDir)
594         $args = Add-ModifiedFirmwareArgument $args
595         $args += $ExtraFriiDumpArgs
596
597         $run = Invoke-FriiDumpRun -Arguments $args -RunDirectory $runDir -ExpectSuccess $true
598         $reportFile = Get-NewReport -ReportDirectory $reportDir -Started $run.Started
599         Invoke-ReportValidation $reportDir
600         $report = Read-Report $reportFile.FullName
601         Test-CommonReportIdentity $report $reportFile.FullName
602         Test-DumpOutputIdentity $report $output
603
604         Assert-True ($report.media.platform -eq 'xbox') 'Expected an Xbox report.'
605         Assert-True ($report.media.region -eq 'North America') 'Expected the XBE GameRegion to report North America.'
606         Assert-True (@($report.notes) -contains 'Xbox XBE GameRegion mask: 0x00000001.') 'Expected the raw XBE GameRegion mask note.'
607         Assert-True ($report.run.test_type -eq 'full_dump' -and $report.run.result -eq 'pass') 'Xbox ISO run outcome mismatch.'
608         Assert-True (@($report.notes) -contains 'Measurement scope: assembled_output.') 'Xbox ISO measurement scope mismatch.'
609
610         Write-Host ''
611         Write-Host 'XBOX REDUMP-STYLE ISO NATIVE REPORT: PASS'
612         Write-Host "Report: $($reportFile.FullName)"
613         break
614     }
615
616     'XboxXiso' {
617         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'Build the candidate first with -Phase Build.'
618         Assert-True (-not [string]::IsNullOrWhiteSpace($Drive)) '-Drive is required.'
619
620         $runDir = New-RunDirectory 'xbox-xiso'
621         $reportDir = Join-Path $runDir 'reports'
622         if ([string]::IsNullOrWhiteSpace($OutputName)) {
623             $OutputName = 'Red Faction II [TQ00501A].xiso'
624         }
625         $output = [System.IO.Path]::GetFullPath((Join-Path $runDir $OutputName))
626         $args = @('-d', $Drive, '-T', '4', '-X', $output, '--report-dir', $reportDir)
627         $args = Add-ModifiedFirmwareArgument $args
628         $args += $ExtraFriiDumpArgs
629
630         $run = Invoke-FriiDumpRun -Arguments $args -RunDirectory $runDir -ExpectSuccess $true
631         $reportFile = Get-NewReport -ReportDirectory $reportDir -Started $run.Started
632         Invoke-ReportValidation $reportDir
633         $report = Read-Report $reportFile.FullName
634         Test-CommonReportIdentity $report $reportFile.FullName
635         Test-DumpOutputIdentity $report $output
636
637         Assert-True ($report.media.platform -eq 'xbox') 'Expected an Xbox report.'
638         Assert-True (-not [string]::IsNullOrWhiteSpace([string]$report.media.region)) 'Expected an XBE-derived Xbox region.'
639         Assert-True ($report.run.test_type -eq 'full_dump' -and $report.run.result -eq 'pass') 'Xbox XISO run outcome mismatch.'
640         Assert-True (@($report.notes) -contains 'Measurement scope: assembled_output.') 'Xbox XISO measurement scope mismatch.'
641
642         Write-Host ''
643         Write-Host 'XBOX XISO NATIVE REPORT: PASS'
644         Write-Host "Report: $($reportFile.FullName)"
645         break
646     }
647
648     'XboxCancel' {
649         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'Build the candidate first with -Phase Build.'
650         Assert-True (-not [string]::IsNullOrWhiteSpace($Drive)) '-Drive is required.'
651
652         $runDir = New-RunDirectory 'xbox-controlled-cancellation'
653         $reportDir = Join-Path $runDir 'reports'
654         if ([string]::IsNullOrWhiteSpace($OutputName)) {
655             $OutputName = 'Red Faction II [TQ00501A].xiso'
656         }
657         $output = [System.IO.Path]::GetFullPath((Join-Path $runDir $OutputName))
658         $args = @('-d', $Drive, '-T', '4', '-X', $output, '--report-dir', $reportDir)
659         $args = Add-ModifiedFirmwareArgument $args
660         $args += $ExtraFriiDumpArgs
661
662         Write-Host ''
663         Write-Host 'After Regions: North America appears and XISO progress begins, press Ctrl+C once.'
664         Write-Host ''
665
666         $run = Invoke-FriiDumpRun -Arguments $args -RunDirectory $runDir -ExpectSuccess $false
667         $reportFile = Get-NewReport -ReportDirectory $reportDir -Started $run.Started
668         Invoke-ReportValidation $reportDir
669         $report = Read-Report $reportFile.FullName
670         Test-CommonReportIdentity $report $reportFile.FullName
671         Test-DumpOutputIdentity $report $output
672
673         Assert-True ($report.media.platform -eq 'xbox') 'Expected an Xbox report.'
674         Assert-True ($report.media.region -eq 'North America') 'Expected the XBE GameRegion to report North America.'
675         Assert-True (@($report.notes) -contains 'Xbox XBE GameRegion mask: 0x00000001.') 'Expected the raw XBE GameRegion mask note.'
676         Assert-True ($report.run.test_type -eq 'partial_dump' -and $report.run.result -eq 'partial') 'Xbox cancellation run outcome mismatch.'
677         Assert-True ($report.dump.result -eq 'partial') 'Xbox cancellation dump result mismatch.'
678         Assert-True ($report.dump.failure_stage -eq 'user_cancelled') 'Xbox cancellation failure stage mismatch.'
679         Assert-True ([int64]$report.dump.sector_count -gt 32) 'Xbox cancellation did not record meaningful partial progress.'
680         Assert-True ([int64]$report.dump.byte_count -eq ([int64]$report.dump.sector_count * 2048)) 'Xbox cancellation byte/sector accounting mismatch.'
681         Assert-True (-not [string]::IsNullOrWhiteSpace([string]$report.hashes.crc32)) 'Partial Xbox CRC32 is missing.'
682         Assert-True (-not [string]::IsNullOrWhiteSpace([string]$report.hashes.md5)) 'Partial Xbox MD5 is missing.'
683         Assert-True (-not [string]::IsNullOrWhiteSpace([string]$report.hashes.sha1)) 'Partial Xbox SHA-1 is missing.'
684         Assert-True (-not [string]::IsNullOrWhiteSpace([string]$report.hashes.sha256)) 'Partial Xbox SHA-256 is missing.'
685
686         $nativeLog = "$output.log"
687         Assert-True (Test-Path -LiteralPath $nativeLog -PathType Leaf) "Xbox native log is missing: $nativeLog"
688         $logText = Get-Content -Raw -LiteralPath $nativeLog
689         foreach ($expectedText in @(
690             'Game/Media ID.....: TQ00501A',
691             'Title.............: Red Faction',
692             'Region............: North America',
693             'Seed read.........: N/A (Xbox reference auth path)',
694             'Source sectors....: 3431264',
695             'Expected output...: 1913920 sectors',
696             '[OK] Controlled partial Xbox output hashes captured.'
697         )) {
698             Assert-True ($logText.Contains($expectedText)) "Xbox summary/log is missing: $expectedText"
699         }
700
701         Write-Host ''
702         Write-Host 'XBOX REGION, SUMMARY, GEOMETRY, CANCELLATION, AND HASHING: PASS'
703         Write-Host "Report: $($reportFile.FullName)"
704         break
705     }
706
707     'PrepareCancellation' {
708         Assert-True (Test-Path -LiteralPath $Exe -PathType Leaf) 'Build the candidate first with -Phase Build.'
709         Assert-True (-not [string]::IsNullOrWhiteSpace($Drive)) '-Drive is required.'
710
711         $runDir = New-RunDirectory 'controlled-cancellation'
712         if ([string]::IsNullOrWhiteSpace($OutputName)) {
713             $OutputName = 'controlled-cancellation.partial.iso'
714         }
715         $output = [System.IO.Path]::GetFullPath((Join-Path $runDir $OutputName))
716         $wrapper = Join-Path $runDir 'run-controlled-cancellation.ps1'
717
718         $argLines = @("'-d'", "'$($Drive.Replace("'", "''"))'")
719         if ($UseMethod8) {
720             $argLines += "'-8'"
721         }
722         $argLines += @(
723             "'-i'", "'$($output.Replace("'", "''"))'"
724         )
725         if (-not [string]::IsNullOrWhiteSpace($FirmwareModifiedNote)) {
726             $argLines += @("'--firmware-modified'", "'$($FirmwareModifiedNote.Replace("'", "''"))'")
727         }
728         foreach ($arg in $ExtraFriiDumpArgs) {
729             $argLines += "'$($arg.Replace("'", "''"))'"
730         }
731
732         $wrapperText = @"
733 `$ErrorActionPreference = 'Stop'
734 `$exe = '$($Exe.Replace("'", "''"))'
735 `$arguments = @(
736     $($argLines -join ",`r`n    ")
737 )
738 Write-Host 'Allow data to be written, then press Ctrl+C once.'
739 & `$exe @arguments
740 exit `$LASTEXITCODE
741 "@
742         Set-Content -LiteralPath $wrapper -Value $wrapperText -Encoding UTF8
743
744         Write-Host ''
745         Write-Host 'CONTROLLED CANCELLATION TEST PREPARED'
746         Write-Host 'Run this wrapper in a separate PowerShell window:'
747         Write-Host "powershell -NoProfile -ExecutionPolicy Bypass -File `"$wrapper`""
748         Write-Host ''
749         Write-Host 'After the dump has written data, press Ctrl+C once. Then validate all generated reports with:'
750         Write-Host "powershell -NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Phase ValidateReports -SourceRoot `"$SourceRoot`" -ResultsRoot `"$ResultsRoot`""
751         Write-Host ''
752         Write-Host 'Expected cancellation report: beside the final .log, partial_dump / partial, failure_stage=user_cancelled, scope=partial_progress.'
753         Write-Host 'The copied GDR-8050L Windows reference-dumper path is not yet an approved cancellation target.'
754         break
755     }
756
757     'ValidateReports' {
758         $reports = @(Get-ChildItem -LiteralPath $ResultsRoot -Recurse -Filter '*.friidump.json' -File)
759         Assert-True ($reports.Count -gt 0) "No native reports were found under $ResultsRoot."
760         Invoke-ReportValidation $ResultsRoot
761
762         $runIds = @{}
763         foreach ($file in $reports) {
764             $report = Read-Report $file.FullName
765             Test-CommonReportIdentity $report $file.FullName
766             $runId = [string]$report.run.run_id
767             Assert-True (-not $runIds.ContainsKey($runId)) "Duplicate run UUID: $runId"
768             $runIds[$runId] = $true
769
770             if ($report.dump.attempted -and $null -ne $report.dump.output_path) {
771                 Test-DumpOutputIdentity $report ([string]$report.dump.output_path)
772             }
773         }
774
775         $cancelReports = @(
776             $reports | Where-Object {
777                 $document = Read-Report $_.FullName
778                 $document.dump.failure_stage -eq 'user_cancelled'
779             }
780         )
781         foreach ($file in $cancelReports) {
782             $report = Read-Report $file.FullName
783             Assert-True ($report.run.test_type -eq 'partial_dump') "$($file.FullName): cancellation test_type mismatch."
784             Assert-True ($report.run.result -eq 'partial') "$($file.FullName): cancellation run result mismatch."
785             Assert-True ($report.dump.result -eq 'partial') "$($file.FullName): cancellation dump result mismatch."
786             Assert-True (@($report.notes) -contains 'Measurement scope: partial_progress.') "$($file.FullName): cancellation scope mismatch."
787
788             $outputPath = [System.IO.Path]::GetFullPath([string]$report.dump.output_path)
789             $finalLogPath = "$outputPath.log"
790             $reportParent = [System.IO.Path]::GetFullPath($file.DirectoryName).TrimEnd([char[]]"\/")
791             $logParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $finalLogPath)).TrimEnd([char[]]"\/")
792
793             Assert-True ($reportParent -eq $logParent) "$($file.FullName): default report is not beside the final FriiDump log."
794             Assert-True (Test-Path -LiteralPath $finalLogPath -PathType Leaf) "$($file.FullName): final FriiDump log is missing: $finalLogPath"
795
796             $logText = Get-Content -Raw -LiteralPath $finalLogPath
797             Assert-True ($logText.Contains('Dump status.......: CANCELLED (PARTIAL)')) "${finalLogPath}: corrected cancellation status is missing."
798             Assert-True ($logText.Contains("Cancelled at......: sector $($report.dump.failure_sector)")) "${finalLogPath}: corrected cancellation-sector line is missing."
799             Assert-True ($logText.Contains("Completed sectors.: $($report.dump.sector_count)")) "${finalLogPath}: completed-sector line is missing or inconsistent."
800             Assert-True ($logText.Contains("Completed bytes...: $($report.dump.byte_count)")) "${finalLogPath}: completed-byte line is missing or inconsistent."
801             Assert-True (-not $logText.Contains('MiB ISO payload')) "${finalLogPath}: obsolete full-payload throughput wording remains."
802
803             $averageMatch = [regex]::Match(
804                 $logText,
805                 'Observed average\.\.: ([0-9]+(?:\.[0-9]+)?) MiB/h over ([0-9]+(?:\.[0-9]+)?) MiB completed output'
806             )
807             Assert-True ($averageMatch.Success) "${finalLogPath}: corrected completed-output throughput line is missing."
808
809             $culture = [System.Globalization.CultureInfo]::InvariantCulture
810             $observedRate = [double]::Parse($averageMatch.Groups[1].Value, $culture)
811             $observedMiB = [double]::Parse($averageMatch.Groups[2].Value, $culture)
812             $expectedMiB = [double]$report.dump.byte_count / 1MB
813             $expectedRate = $expectedMiB / [double]$report.dump.duration_seconds * 3600.0
814
815             Assert-True ([Math]::Abs($observedMiB - $expectedMiB) -lt 0.01) "${finalLogPath}: completed MiB does not match the report byte count."
816             Assert-True ([Math]::Abs($observedRate - $expectedRate) -lt 0.05) "${finalLogPath}: observed average does not use completed bytes and report duration."
817
818             # Verify that the native report and human-readable summary agree
819             # with the authoritative Nintendo platform magic in sector 0.
820             # This catches HLDS drives that accept out-of-range dummy reads
821             # and therefore defeat the legacy capacity-only classifier.
822             $header = New-Object byte[] 32
823             $stream = [System.IO.File]::OpenRead($outputPath)
824
825             try {
826                 $headerBytes = $stream.Read($header, 0, $header.Length)
827             }
828             finally {
829                 $stream.Dispose()
830             }
831
832             if ($headerBytes -ge 32) {
833                 $wiiMagic = (
834                     ([uint32]$header[0x18] -shl 24) -bor
835                     ([uint32]$header[0x19] -shl 16) -bor
836                     ([uint32]$header[0x1a] -shl 8) -bor
837                     [uint32]$header[0x1b]
838                 )
839
840                 $gameCubeMagic = (
841                     ([uint32]$header[0x1c] -shl 24) -bor
842                     ([uint32]$header[0x1d] -shl 16) -bor
843                     ([uint32]$header[0x1e] -shl 8) -bor
844                     [uint32]$header[0x1f]
845                 )
846
847                 if ($wiiMagic -eq [uint32]0x5d1c9ea3) {
848                     Assert-True (
849                         $report.media.platform -eq 'wii'
850                     ) "${finalLogPath}: Wii header magic does not match the native-report platform."
851                 }
852                 elseif ($gameCubeMagic -eq [uint32]0xc2339f3d) {
853                     Assert-True (
854                         $report.media.platform -eq 'gamecube'
855                     ) "${finalLogPath}: GameCube header magic does not match the native-report platform."
856
857                     Assert-True (
858                         $logText.Contains('Disc type..........: GameCube')
859                     ) "${finalLogPath}: GameCube header magic does not match the human-readable disc type."
860
861                     Assert-True (
862                         $logText.Contains('Expected sectors..: 712880')
863                     ) "${finalLogPath}: GameCube header magic does not match the expected-sector geometry."
864                 }
865             }
866         }
867
868         Write-Host ''
869         Write-Host "LIVE NATIVE REPORT VALIDATION: PASS ($($reports.Count) reports)"
870         Write-Host "Controlled cancellation reports: $($cancelReports.Count)"
871         break
872     }
873
874     'Collect' {
875         Assert-True (Test-Path -LiteralPath $ResultsRoot -PathType Container) "Results directory is missing: $ResultsRoot"
876         $destination = "$ResultsRoot-results.zip"
877         if (Test-Path -LiteralPath $destination) {
878             Remove-Item -LiteralPath $destination -Force
879         }
880         Compress-Archive -Path (Join-Path $ResultsRoot '*') -DestinationPath $destination -CompressionLevel Optimal
881         Write-Host 'VALIDATION RESULT COLLECTION: PASS'
882         Write-Host "Archive: $destination"
883         Write-Host "Bytes: $((Get-Item -LiteralPath $destination).Length)"
884         Write-Host "SHA-256: $(Get-Sha256 $destination)"
885         break
886     }
887 }