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