IOS LLE: checkpoint NAND conformance and diagnostic tooling
Preserve formatted NAND boot and Wii Remote pairing, add DMA/SRAM conformance tests and a Wii comparison probe. Keep P1 profiling tools and native update A/B evidence; reject the unhelpful targeted SRAM shortcut.
This commit is contained in:
1 parent
b931671fde
commit
c230971f07
64 files changed
+6488
-238
No files matched your search
@@ -0,0 +1,157 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$CaptureDirectory,
|
||||
[Parameter(Mandatory)][string]$OutputDirectory,
|
||||
[string]$PythonExecutable,
|
||||
[ValidateRange(0,2147483647)][int]$NativePID=0,
|
||||
[string]$NativePDB,
|
||||
[string]$XperfRangeStart,
|
||||
[string]$XperfRangeEnd
|
||||
)
|
||||
# Read-only input: qualify a synthetic capture, or inspect one native Dolphin PID.
|
||||
# Output is a new directory; no emulator, NAND or WPR session is touched.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (($NativePID -eq 0) -ne (-not $NativePDB)) {
|
||||
throw 'For native analysis pass both -NativePID and -NativePDB.'
|
||||
}
|
||||
if ([bool]$XperfRangeStart -ne [bool]$XperfRangeEnd) {
|
||||
throw 'Pass both -XperfRangeStart and -XperfRangeEnd, or neither.'
|
||||
}
|
||||
if ($XperfRangeStart -and -not $NativePID) {
|
||||
throw 'Time-window export is only supported for exploratory native analysis.'
|
||||
}
|
||||
if ($PythonExecutable) {
|
||||
$p1Python = (Resolve-Path -LiteralPath $PythonExecutable).Path
|
||||
} else {
|
||||
$p1Candidates = @(Get-Command python.exe -All -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Source -notlike '*\WindowsApps\*' } |
|
||||
Select-Object -ExpandProperty Source)
|
||||
$p1Candidates += 'C:\msys64\mingw64\bin\python.exe'
|
||||
foreach ($p1Candidate in $p1Candidates) {
|
||||
if ($p1Candidate -and (Test-Path -LiteralPath $p1Candidate)) {
|
||||
$p1Python = $p1Candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $p1Python) {
|
||||
throw 'Python was not found before analysis. Pass -PythonExecutable with the path to a working python.exe.'
|
||||
}
|
||||
& $p1Python --version | Out-Null
|
||||
if ($LASTEXITCODE) { throw "Python could not start: $p1Python" }
|
||||
function ConvertTo-P1WindowsArgument([string]$Value) {
|
||||
$p1Quoted = '"'
|
||||
$p1Slashes = 0
|
||||
foreach ($p1Char in $Value.ToCharArray()) {
|
||||
if ($p1Char -eq '\') { ++$p1Slashes; continue }
|
||||
if ($p1Char -eq '"') {
|
||||
$p1Quoted += ('\' * (2 * $p1Slashes + 1)) + '"'
|
||||
$p1Slashes = 0
|
||||
continue
|
||||
}
|
||||
$p1Quoted += ('\' * $p1Slashes) + $p1Char
|
||||
$p1Slashes = 0
|
||||
}
|
||||
return $p1Quoted + ('\' * (2 * $p1Slashes)) + '"'
|
||||
}
|
||||
$p1Repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$p1Input = [IO.Path]::GetFullPath($(if ([IO.Path]::IsPathRooted($CaptureDirectory)) {
|
||||
$CaptureDirectory
|
||||
} else {
|
||||
Join-Path (Get-Location).ProviderPath $CaptureDirectory
|
||||
}))
|
||||
$p1Output = [IO.Path]::GetFullPath($(if ([IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory
|
||||
} else {
|
||||
Join-Path $p1Repo $OutputDirectory
|
||||
}))
|
||||
if (Test-Path -LiteralPath $p1Output) { throw 'Use a NEW analysis directory; no results are overwritten.' }
|
||||
$p1ETL = Join-Path $p1Input 'capture.etl'
|
||||
$p1ManifestPath = Join-Path $p1Input 'manifest.json'
|
||||
$p1StdoutPath = Join-Path $p1Input 'probe.stdout.log'
|
||||
$p1Decoder = Join-Path $p1Repo '.starlet-profile/P1-tools/DecodeP1.exe'
|
||||
$p1RequiredInputs = @($p1ETL, $p1ManifestPath, $p1Decoder)
|
||||
if ($NativePID) {
|
||||
$NativePDB = (Resolve-Path -LiteralPath $NativePDB).Path
|
||||
$p1RequiredInputs += $NativePDB
|
||||
} else {
|
||||
$p1RequiredInputs += $p1StdoutPath
|
||||
}
|
||||
foreach ($p1Required in $p1RequiredInputs) {
|
||||
if (-not (Test-Path -LiteralPath $p1Required)) { throw "Missing analysis input: $p1Required" }
|
||||
}
|
||||
$p1Drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($p1Output))
|
||||
$p1RequiredFree = if ($NativePID) { 20GB } else { 7GB }
|
||||
if ($p1Drive.AvailableFreeSpace -lt $p1RequiredFree) {
|
||||
throw "Need at least $([math]::Round($p1RequiredFree / 1GB)) GiB free for offline analysis."
|
||||
}
|
||||
New-Item -ItemType Directory -Path $p1Output | Out-Null
|
||||
Copy-Item -LiteralPath $p1ManifestPath -Destination $p1Output
|
||||
if (-not $NativePID) { Copy-Item -LiteralPath $p1StdoutPath -Destination $p1Output }
|
||||
$p1Manifest = Get-Content -LiteralPath $p1ManifestPath -Raw | ConvertFrom-Json
|
||||
$p1TargetPID = if ($NativePID) { $NativePID } else { $p1Manifest.ProbePID }
|
||||
if (-not $p1TargetPID) { throw 'Target PID missing from capture manifest.' }
|
||||
$p1Native = Join-Path $p1Output 'native-events.jsonl'
|
||||
$p1DecodeText = & $p1Decoder $p1ETL $p1Native
|
||||
$p1DecodeExit = $LASTEXITCODE
|
||||
$p1DecodeText | Out-File -LiteralPath (Join-Path $p1Output 'decoder-summary.json') -Encoding utf8
|
||||
if ($p1DecodeExit) { throw 'TDH native decode or ETW loss check failed; see decoder-summary.json.' }
|
||||
|
||||
$p1Csv = Join-Path $p1Output 'xperf-symbols.csv'
|
||||
$p1PriorSymbols = [Environment]::GetEnvironmentVariable('_NT_SYMBOL_PATH', 'Process')
|
||||
$p1PriorCache = [Environment]::GetEnvironmentVariable('_NT_SYMCACHE_PATH', 'Process')
|
||||
try {
|
||||
$env:_NT_SYMBOL_PATH = if ($NativePID) { Split-Path $NativePDB -Parent } else {
|
||||
Split-Path $p1Manifest.ProbePDB -Parent
|
||||
}
|
||||
$env:_NT_SYMCACHE_PATH = Join-Path $p1Output 'symcache'
|
||||
$p1Info = [Diagnostics.ProcessStartInfo]::new((Get-Command xperf.exe).Source)
|
||||
$p1Info.UseShellExecute = $false
|
||||
$p1Info.CreateNoWindow = $true
|
||||
$p1Info.RedirectStandardOutput = $true
|
||||
$p1Info.RedirectStandardError = $true
|
||||
$p1Info.WorkingDirectory = $p1Repo
|
||||
$p1XperfArgs = @('-i', $p1ETL, '-o', $p1Csv, '-symbols', '-target',
|
||||
'machine', '-quiet', '-a', 'dumper')
|
||||
if ($XperfRangeStart) {
|
||||
$p1XperfArgs += @('-range', $XperfRangeStart, $XperfRangeEnd)
|
||||
[pscustomobject]@{ Start=$XperfRangeStart; End=$XperfRangeEnd;
|
||||
Note='Xperf CPU samples are limited to this window; native scopes and counters span the full ETL.' } |
|
||||
ConvertTo-Json | Out-File -LiteralPath (Join-Path $p1Output 'analysis-range.json') -Encoding utf8
|
||||
}
|
||||
$p1Info.Arguments = ($p1XperfArgs | ForEach-Object {
|
||||
ConvertTo-P1WindowsArgument $_
|
||||
}) -join ' '
|
||||
$p1Process = [Diagnostics.Process]::Start($p1Info)
|
||||
$p1Stdout = $p1Process.StandardOutput.ReadToEndAsync()
|
||||
$p1Stderr = $p1Process.StandardError.ReadToEndAsync()
|
||||
$p1TimeoutMs = if ($NativePID) { 900000 } else { 180000 }
|
||||
if (-not $p1Process.WaitForExit($p1TimeoutMs)) {
|
||||
$p1Process.Kill() # Only the xperf process created by this analysis script.
|
||||
$p1Process.WaitForExit()
|
||||
throw "Owned Xperf decoder exceeded $($p1TimeoutMs / 1000) seconds; no input ETL was modified."
|
||||
}
|
||||
$p1Stdout.GetAwaiter().GetResult() | Out-File `
|
||||
-LiteralPath (Join-Path $p1Output 'xperf-stdout.log') -Encoding utf8
|
||||
$p1Stderr.GetAwaiter().GetResult() | Out-File `
|
||||
-LiteralPath (Join-Path $p1Output 'xperf-warnings.log') -Encoding utf8
|
||||
if ($p1Process.ExitCode) { throw 'Xperf kernel decode failed; see xperf-warnings.log.' }
|
||||
} finally {
|
||||
[Environment]::SetEnvironmentVariable('_NT_SYMBOL_PATH', $p1PriorSymbols, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('_NT_SYMCACHE_PATH', $p1PriorCache, 'Process')
|
||||
}
|
||||
$p1Combined = Join-Path $p1Output 'combined.jsonl'
|
||||
$p1XperfSummary = Join-Path $p1Output 'xperf-summary.json'
|
||||
& $p1Python (Join-Path $PSScriptRoot 'xperf_to_p1.py') $p1Csv $p1Native `
|
||||
--pid $p1TargetPID --output $p1Combined --summary $p1XperfSummary
|
||||
if ($LASTEXITCODE) { throw 'Xperf/native clock alignment failed.' }
|
||||
if ($NativePID) {
|
||||
& $p1Python (Join-Path $PSScriptRoot 'analyze_p1.py') $p1Combined `
|
||||
--pid $p1TargetPID --output (Join-Path $p1Output 'native-report.json')
|
||||
if ($LASTEXITCODE) { throw 'Native offline analysis failed.' }
|
||||
Write-Output "Exploratory native report: $p1Output\native-report.json (NOT qualified as a complete Wii-update bottleneck report)."
|
||||
} else {
|
||||
& $p1Python (Join-Path $PSScriptRoot 'qualify_p1.py') $p1Output `
|
||||
--events $p1Combined --xperf-summary $p1XperfSummary
|
||||
if ($LASTEXITCODE) { throw 'Local WPR qualification failed; see qualification.json.' }
|
||||
Write-Output "Local WPR qualification finished: $p1Output\qualification.json"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
param([Parameter(Mandatory)][string]$OutputDirectory)
|
||||
$ErrorActionPreference='Stop'
|
||||
$decoderOut=[IO.Path]::GetFullPath($(if([IO.Path]::IsPathRooted($OutputDirectory)){
|
||||
$OutputDirectory
|
||||
}else{
|
||||
Join-Path (Get-Location).ProviderPath $OutputDirectory
|
||||
}))
|
||||
New-Item -ItemType Directory -Force -Path $decoderOut | Out-Null
|
||||
$decoderSource=Join-Path $PSScriptRoot 'DecodeP1.cpp'
|
||||
$decoderSetup='call "C:\Program Files\Microsoft Visual Studio\18\Community\Common7\Tools\VsDevCmd.bat" -arch=x64 -host_arch=x64 >nul'
|
||||
$decoderCommand=$decoderSetup+' && cl /nologo /EHsc /O2 /std:c++20 /DUNICODE /D_UNICODE "'+$decoderSource+'" /Fo"'+(Join-Path $decoderOut 'DecodeP1.obj')+'" /Fe"'+(Join-Path $decoderOut 'DecodeP1.exe')+'" /link tdh.lib advapi32.lib'
|
||||
cmd.exe /d /s /c $decoderCommand
|
||||
if($LASTEXITCODE) { throw 'Decoder compilation failed' }
|
||||
@@ -0,0 +1,48 @@
|
||||
param([switch]$ConfigureOnly, [switch]$CleanBeforeBuild)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$p1Repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$p1Build = Join-Path $p1Repo '.starlet_diag_p1'
|
||||
$p1VS = 'C:\Program Files\Microsoft Visual Studio\18\Community'
|
||||
$p1CMake = Join-Path $p1VS 'Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe'
|
||||
$p1Ninja = Join-Path $p1VS 'Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe'
|
||||
$p1Setup = 'set VSLANG=1033&& call "' + (Join-Path $p1VS 'Common7\Tools\VsDevCmd.bat') + '" -arch=x64 -host_arch=x64 >nul'
|
||||
$p1Configure = $p1Setup + ' && "' + $p1CMake + '" -S "' + $p1Repo + '" -B "' + $p1Build + '" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_MAKE_PROGRAM="' + $p1Ninja + '" -DENABLE_QT=OFF -DENABLE_NOGUI=ON -DENABLE_TESTS=ON -DENABLE_VULKAN=OFF -DENABLE_LTO=OFF -DENABLE_LLVM=OFF -DUSE_SYSTEM_LIBS=OFF'
|
||||
cmd.exe /d /s /c $p1Configure
|
||||
if ($LASTEXITCODE) { throw 'P1 configuration failed.' }
|
||||
|
||||
# CMake can decode French MSVC /showIncludes through the OEM code page even though
|
||||
# cl prints UTF-8 nonbreaking spaces. Then Ninja silently records no header deps.
|
||||
$p1PrefixFixed = $false
|
||||
foreach ($p1Info in Get-ChildItem -LiteralPath (Join-Path $p1Build 'CMakeFiles') -Recurse -File -Filter 'CMake*Compiler.cmake') {
|
||||
$p1Text = [IO.File]::ReadAllText($p1Info.FullName)
|
||||
$p1Corrected = $p1Text.Replace('Remarque : inclusion du fichier : ',
|
||||
'Remarque' + [char]0xa0 + ': inclusion du fichier' + [char]0xa0 + ': ')
|
||||
if ($p1Corrected -cne $p1Text) {
|
||||
[IO.File]::WriteAllText($p1Info.FullName, $p1Corrected, [Text.UTF8Encoding]::new($false))
|
||||
$p1PrefixFixed = $true
|
||||
}
|
||||
}
|
||||
if ($p1PrefixFixed) {
|
||||
cmd.exe /d /s /c $p1Configure
|
||||
if ($LASTEXITCODE) { throw 'P1 dependency prefix repair failed.' }
|
||||
}
|
||||
if ($ConfigureOnly) { return }
|
||||
if ($CleanBeforeBuild -and [IO.Path]::GetFullPath($p1Build) -cne
|
||||
[IO.Path]::GetFullPath((Join-Path $p1Repo '.starlet_diag_p1'))) {
|
||||
throw 'Unexpected build target: refusing clean.'
|
||||
}
|
||||
$p1Clean = if ($CleanBeforeBuild) { ' --clean-first' } else { '' }
|
||||
$p1Compile = $p1Setup + ' && "' + $p1CMake + '" --build "' + $p1Build + '"' + $p1Clean + ' --target dolphin-nogui tests TraceProbeP1 -j 6'
|
||||
cmd.exe /d /s /c $p1Compile
|
||||
if ($LASTEXITCODE) { throw 'P1 build failed.' }
|
||||
$p1NetworkTarget = Join-Path $p1Build 'Binaries/Network'
|
||||
if (-not (Test-Path -LiteralPath $p1NetworkTarget)) {
|
||||
$p1NetworkSource = Join-Path $p1Repo '.starlet_msvc2/Binaries/Network'
|
||||
if (-not (Test-Path -LiteralPath $p1NetworkSource)) {
|
||||
throw 'Local libslirp runtime absent; the network probe cannot run.'
|
||||
}
|
||||
New-Item -ItemType Directory -Path $p1NetworkTarget | Out-Null
|
||||
Get-ChildItem -LiteralPath $p1NetworkSource -File | Copy-Item -Destination $p1NetworkTarget
|
||||
}
|
||||
& $p1Ninja -C $p1Build -t deps 'Source/Core/Common/CMakeFiles/common.dir/PerformanceTrace.cpp.obj' |
|
||||
Select-Object -First 8
|
||||
@@ -0,0 +1,17 @@
|
||||
param([Parameter(Mandatory)][string]$OutputDirectory)
|
||||
# One-command, firmware-free WPR qualification. Run in an elevated PowerShell.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$p1Repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$p1Out = [IO.Path]::GetFullPath($(if ([IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory
|
||||
} else {
|
||||
Join-Path $p1Repo $OutputDirectory
|
||||
}))
|
||||
$p1Probe = Join-Path $p1Repo '.starlet_diag_p1/Binaries/Tests/TraceProbeP1.exe'
|
||||
$p1Network = Join-Path $p1Repo '.starlet_diag_p1/Binaries/Network'
|
||||
foreach ($p1Input in @($p1Probe, $p1Network)) {
|
||||
if (-not (Test-Path -LiteralPath $p1Input)) { throw "Missing local probe dependency: $p1Input" }
|
||||
}
|
||||
& (Join-Path $PSScriptRoot 'Capture-P1.ps1') `
|
||||
-OutputDirectory $p1Out -Seconds 60 -ProbeExecutable $p1Probe `
|
||||
-ProbeArguments @((Join-Path $p1Out 'synthetic.journal'), $p1Network)
|
||||
@@ -0,0 +1,58 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][ValidateSet('P1-trace-005','P1-trace-006')][string]$Name,
|
||||
[Parameter(Mandatory=$true)][int]$DolphinProcessId,
|
||||
[ValidateRange(2,60)][int]$IntervalSeconds=10,
|
||||
[ValidateRange(1,120)][int]$MaxMinutes=60
|
||||
)
|
||||
|
||||
# Low-overhead, read-only host progress samples for the paired native update.
|
||||
# No debugger, ETW session, guest-memory reads, or emulator input is involved.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$p1Repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$p1Root = Join-Path $p1Repo '.starlet_update_ab_20260913'
|
||||
$p1Profile = Join-Path $p1Root $Name
|
||||
$p1ExpectedExe = Join-Path $p1Root "build-$Name/Binaries/DolphinNoGUI.exe"
|
||||
$p1Journal = Join-Path $p1Profile 'Starlet/0238188DE97894AE3AF93A92C4F238C0572731A7.nand-journal'
|
||||
$p1Process = Get-Process -Id $DolphinProcessId -ErrorAction Stop
|
||||
if ($p1Process.ProcessName -ne 'DolphinNoGUI') {
|
||||
throw "Process $DolphinProcessId is not DolphinNoGUI."
|
||||
}
|
||||
# The restricted sampler cannot always read another process's executable path.
|
||||
# Check it when available, and require the isolated journal to be live as well.
|
||||
if ($p1Process.Path -and
|
||||
-not $p1Process.Path.Equals($p1ExpectedExe, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Process $DolphinProcessId is not the frozen build for $Name."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $p1Journal -PathType Leaf)) {
|
||||
throw "Missing isolated NAND journal: $p1Journal"
|
||||
}
|
||||
$p1Started = $p1Process.StartTime
|
||||
if ((Get-Item -LiteralPath $p1Journal).LastWriteTime -lt $p1Started) {
|
||||
throw "The isolated journal has not been written by the current launch."
|
||||
}
|
||||
$p1Capture = Join-Path $p1Profile ('Capture/host-progress-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.jsonl')
|
||||
$p1Stream = [IO.File]::Open($p1Capture, [IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write, [IO.FileShare]::Read)
|
||||
$p1Writer = [IO.StreamWriter]::new($p1Stream, [Text.UTF8Encoding]::new($false))
|
||||
$p1Writer.AutoFlush = $true
|
||||
$p1Clock = [Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Output "Capturing $Name (PID $DolphinProcessId) to $p1Capture"
|
||||
try {
|
||||
while ($p1Clock.Elapsed.TotalMinutes -lt $MaxMinutes) {
|
||||
$p1Process = Get-Process -Id $DolphinProcessId -ErrorAction SilentlyContinue
|
||||
if (-not $p1Process -or $p1Process.StartTime -ne $p1Started) { break }
|
||||
$p1Process.Refresh()
|
||||
[ordered]@{
|
||||
UTC=[datetime]::UtcNow.ToString('o')
|
||||
ElapsedSeconds=[math]::Round($p1Clock.Elapsed.TotalSeconds,3)
|
||||
ProcessCPUSeconds=[math]::Round($p1Process.TotalProcessorTime.TotalSeconds,3)
|
||||
JournalBytes=(Get-Item -LiteralPath $p1Journal).Length
|
||||
PrivateMemoryBytes=$p1Process.PrivateMemorySize64
|
||||
} | ConvertTo-Json -Compress | ForEach-Object { $p1Writer.WriteLine($_) }
|
||||
Start-Sleep -Seconds $IntervalSeconds
|
||||
}
|
||||
$p1Writer.WriteLine(('CAPTURE_END_UTC=' + [datetime]::UtcNow.ToString('o')))
|
||||
} finally {
|
||||
$p1Writer.Dispose()
|
||||
}
|
||||
Write-Output "Saved $p1Capture"
|
||||
@@ -0,0 +1,144 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$OutputDirectory,
|
||||
[ValidateRange(5,900)][int]$Seconds=60,
|
||||
[string]$ProbeExecutable,
|
||||
[string[]]$ProbeArguments=@(),
|
||||
[ValidateRange(0,9223372036854775807)][long]$ProbeAffinityMask=0
|
||||
)
|
||||
# Runs an owned, uniquely named ETW session. Does not launch or stop Dolphin.
|
||||
# Native use requires a separately launched, frozen diagnostic build and manifest.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function ConvertTo-P1WindowsArgument([string]$Value) {
|
||||
# Windows PowerShell 5.1 uses .NET Framework, where ProcessStartInfo has no
|
||||
# ArgumentList. Quote paths using the standard Windows argv backslash rules.
|
||||
$p1Quoted = '"'
|
||||
$p1Slashes = 0
|
||||
foreach ($p1Char in $Value.ToCharArray()) {
|
||||
if ($p1Char -eq '\') { ++$p1Slashes; continue }
|
||||
if ($p1Char -eq '"') {
|
||||
$p1Quoted += ('\' * (2 * $p1Slashes + 1)) + '"'
|
||||
$p1Slashes = 0
|
||||
continue
|
||||
}
|
||||
$p1Quoted += ('\' * $p1Slashes) + $p1Char
|
||||
$p1Slashes = 0
|
||||
}
|
||||
return $p1Quoted + ('\' * (2 * $p1Slashes)) + '"'
|
||||
}
|
||||
$p1Out = [IO.Path]::GetFullPath($(if ([IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory
|
||||
} else {
|
||||
Join-Path (Get-Location).ProviderPath $OutputDirectory
|
||||
}))
|
||||
if (Test-Path -LiteralPath $p1Out) { throw 'Use a NEW capture directory; results are never overwritten.' }
|
||||
if ($ProbeExecutable) {
|
||||
$ProbeExecutable = (Resolve-Path -LiteralPath $ProbeExecutable).Path
|
||||
if ([IO.Path]::GetFileName($ProbeExecutable) -ne 'TraceProbeP1.exe') {
|
||||
throw 'Automatic launch is restricted to the local qualification probe, not Dolphin.'
|
||||
}
|
||||
}
|
||||
if ($ProbeAffinityMask -ne 0) {
|
||||
$p1Allowed = [Diagnostics.Process]::GetCurrentProcess().ProcessorAffinity.ToInt64()
|
||||
if (($ProbeAffinityMask -band $p1Allowed) -ne $ProbeAffinityMask) {
|
||||
throw "Probe affinity mask $ProbeAffinityMask is not available to this process."
|
||||
}
|
||||
}
|
||||
$p1Drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($p1Out))
|
||||
if ($p1Drive.AvailableFreeSpace -lt 9GB) { throw 'Need 4 GiB trace budget plus 5 GiB reserve.' }
|
||||
New-Item -ItemType Directory -Path $p1Out | Out-Null
|
||||
$p1Name = 'DolphinP1-' + [guid]::NewGuid().ToString('N')
|
||||
$p1Temp = Join-Path (Split-Path $p1Out -Parent) ($p1Name + '-temporary-etl')
|
||||
New-Item -ItemType Directory -Path $p1Temp | Out-Null
|
||||
$p1Profile = Join-Path $PSScriptRoot 'DolphinP1.wprp'
|
||||
$p1ETL = Join-Path $p1Out 'capture.etl'
|
||||
$p1PDB = if ($ProbeExecutable) {
|
||||
Join-Path (Split-Path (Split-Path (Split-Path $ProbeExecutable -Parent) -Parent) -Parent) 'pdb/TraceProbeP1.pdb'
|
||||
} else { $null }
|
||||
$p1Started = $false
|
||||
$p1Reason = 'duration'
|
||||
$p1Probe = $null
|
||||
$p1Stdout = $null
|
||||
$p1Stderr = $null
|
||||
$p1Manifest = [ordered]@{
|
||||
Schema=1; Collection='WPRSystemAndNative'; Session=$p1Name
|
||||
StartUTC=[datetime]::UtcNow.ToString('o')
|
||||
Seconds=$Seconds; TemporaryTraceDirectory=$p1Temp
|
||||
Profile=$p1Profile; ProfileSHA256=(Get-FileHash -LiteralPath $p1Profile).Hash
|
||||
Probe=$ProbeExecutable; ProbeSHA256=$(if ($ProbeExecutable) { (Get-FileHash -LiteralPath $ProbeExecutable).Hash } else { $null })
|
||||
ProbeAffinityMask=$ProbeAffinityMask
|
||||
ProbePDB=$p1PDB; ProbePDBSHA256=$(if ($p1PDB -and (Test-Path -LiteralPath $p1PDB)) { (Get-FileHash -LiteralPath $p1PDB).Hash } else { $null })
|
||||
NativeQualificationPassed=$false
|
||||
}
|
||||
$p1Manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $p1Out 'manifest.json') -Encoding utf8
|
||||
try {
|
||||
& wpr.exe -start ($p1Profile + '!DolphinP1') -filemode -recordtempto $p1Temp -instancename $p1Name *> (Join-Path $p1Out 'start.log')
|
||||
if ($LASTEXITCODE) { throw "ETW start failed; see $p1Out\start.log. No other session was stopped." }
|
||||
$p1Started = $true
|
||||
if (-not $ProbeExecutable) {
|
||||
Write-Output "Native collector armed for $Seconds seconds: $p1Name. Launch Dolphin separately with -PerformanceTrace now."
|
||||
}
|
||||
if ($ProbeExecutable) {
|
||||
# Environment of this child only; never persistently modify the user's environment.
|
||||
$p1Info = [Diagnostics.ProcessStartInfo]::new($ProbeExecutable)
|
||||
$p1Info.UseShellExecute = $false
|
||||
$p1Info.CreateNoWindow = $true
|
||||
$p1Info.RedirectStandardOutput = $true
|
||||
$p1Info.RedirectStandardError = $true
|
||||
$p1Info.Environment['DOLPHIN_PERF_TRACE'] = '1'
|
||||
if ($p1Info.PSObject.Properties['ArgumentList'] -and $p1Info.ArgumentList) {
|
||||
foreach ($p1Arg in $ProbeArguments) { $p1Info.ArgumentList.Add($p1Arg) }
|
||||
} else {
|
||||
$p1Info.Arguments = ($ProbeArguments | ForEach-Object {
|
||||
ConvertTo-P1WindowsArgument $_
|
||||
}) -join ' '
|
||||
}
|
||||
$p1Probe = [Diagnostics.Process]::Start($p1Info)
|
||||
if ($ProbeAffinityMask -ne 0) {
|
||||
try {
|
||||
$p1Probe.ProcessorAffinity = [IntPtr]$ProbeAffinityMask
|
||||
} catch {
|
||||
$p1Probe.Kill() # Only the synthetic probe started by this script.
|
||||
$p1Probe.WaitForExit()
|
||||
throw "Could not pin owned synthetic probe to mask ${ProbeAffinityMask}: $_"
|
||||
}
|
||||
}
|
||||
$p1Manifest.ProbeObservedAffinityMask = $p1Probe.ProcessorAffinity.ToInt64()
|
||||
$p1Stdout = $p1Probe.StandardOutput.ReadToEndAsync()
|
||||
$p1Stderr = $p1Probe.StandardError.ReadToEndAsync()
|
||||
$p1Manifest.ProbePID = $p1Probe.Id
|
||||
$p1Manifest.ProbeStartUTC = $p1Probe.StartTime.ToUniversalTime().ToString('o')
|
||||
}
|
||||
$p1Clock = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($p1Clock.Elapsed.TotalSeconds -lt $Seconds) {
|
||||
Start-Sleep -Milliseconds 1000
|
||||
$p1Bytes = (Get-ChildItem -LiteralPath $p1Temp -File -Recurse | Measure-Object Length -Sum).Sum
|
||||
if ($p1Drive.AvailableFreeSpace -lt 5GB -or $p1Bytes -gt 4GB) { $p1Reason='storage-budget'; break }
|
||||
if ($p1Probe -and $p1Probe.HasExited) { $p1Reason='probe-exit'; break }
|
||||
}
|
||||
} finally {
|
||||
if ($p1Started) {
|
||||
& wpr.exe -status collectors -details -instancename $p1Name *> (Join-Path $p1Out 'collector-status.log')
|
||||
& wpr.exe -stop $p1ETL -instancename $p1Name *> (Join-Path $p1Out 'stop.log')
|
||||
$p1StopExit = $LASTEXITCODE
|
||||
$p1Manifest.EndUTC = [datetime]::UtcNow.ToString('o')
|
||||
$p1Manifest.StopReason = $p1Reason
|
||||
$p1Manifest.StopExitCode = $p1StopExit
|
||||
$p1Manifest.ProbeExitCode = if ($p1Probe -and $p1Probe.HasExited) { $p1Probe.ExitCode } else { $null }
|
||||
if ($p1Probe -and $p1Probe.HasExited) {
|
||||
$p1Stdout.GetAwaiter().GetResult() | Out-File -LiteralPath (Join-Path $p1Out 'probe.stdout.log') -Encoding utf8
|
||||
$p1Stderr.GetAwaiter().GetResult() | Out-File -LiteralPath (Join-Path $p1Out 'probe.stderr.log') -Encoding utf8
|
||||
$p1Manifest.ProbeCPUSeconds = $p1Probe.TotalProcessorTime.TotalSeconds
|
||||
$p1Manifest.ProbeWallSeconds = ($p1Probe.ExitTime - $p1Probe.StartTime).TotalSeconds
|
||||
}
|
||||
$p1Manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $p1Out 'manifest.json') -Encoding utf8
|
||||
if ($p1StopExit) { throw 'Owned ETW session could not be saved. See stop.log; no broad cancel attempted.' }
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $p1ETL)) { throw 'WPR reported success but produced no capture.etl.' }
|
||||
if ($p1Probe -and -not $p1Probe.HasExited) {
|
||||
throw "Capture stopped at $p1Reason, but the owned local probe is still running (PID $($p1Probe.Id)). No process was forcibly stopped."
|
||||
}
|
||||
if ($p1Probe -and $p1Probe.ExitCode -ne 0) {
|
||||
throw "Local probe failed with exit code $($p1Probe.ExitCode); see probe.stderr.log. The ETL was kept."
|
||||
}
|
||||
Write-Output "Saved $p1ETL. Qualification is NOT automatically passed; inspect losses, attribution and overhead."
|
||||
@@ -0,0 +1,106 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$OutputDirectory,
|
||||
[ValidateRange(30,300)][int]$TimeoutSeconds=120,
|
||||
[ValidateRange(0,9223372036854775807)][long]$ProbeAffinityMask=0,
|
||||
[switch]$NoTrace
|
||||
)
|
||||
# Synthetic user-mode ETW qualification. This does not sample CPU or scheduling.
|
||||
$ErrorActionPreference='Stop'
|
||||
function ConvertTo-P1WindowsArgument([string]$Value) {
|
||||
$p1Quoted = '"'
|
||||
$p1Slashes = 0
|
||||
foreach ($p1Char in $Value.ToCharArray()) {
|
||||
if ($p1Char -eq '\') { ++$p1Slashes; continue }
|
||||
if ($p1Char -eq '"') {
|
||||
$p1Quoted += ('\' * (2 * $p1Slashes + 1)) + '"'
|
||||
$p1Slashes = 0
|
||||
continue
|
||||
}
|
||||
$p1Quoted += ('\' * $p1Slashes) + $p1Char
|
||||
$p1Slashes = 0
|
||||
}
|
||||
return $p1Quoted + ('\' * (2 * $p1Slashes)) + '"'
|
||||
}
|
||||
$p1Repo=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
if ($ProbeAffinityMask -ne 0) {
|
||||
$p1Allowed = [Diagnostics.Process]::GetCurrentProcess().ProcessorAffinity.ToInt64()
|
||||
if (($ProbeAffinityMask -band $p1Allowed) -ne $ProbeAffinityMask) {
|
||||
throw "Probe affinity mask $ProbeAffinityMask is not available to this process."
|
||||
}
|
||||
}
|
||||
$p1Output=[IO.Path]::GetFullPath($(if([IO.Path]::IsPathRooted($OutputDirectory)){
|
||||
$OutputDirectory
|
||||
}else{
|
||||
Join-Path (Get-Location).ProviderPath $OutputDirectory
|
||||
}))
|
||||
$p1Probe=Join-Path $p1Repo '.starlet_diag_p1/Binaries/Tests/TraceProbeP1.exe'
|
||||
$p1PDB=Join-Path $p1Repo '.starlet_diag_p1/pdb/TraceProbeP1.pdb'
|
||||
$p1Network=Join-Path $p1Repo '.starlet_diag_p1/Binaries/Network'
|
||||
$p1Decoder=Join-Path $p1Repo '.starlet-profile/P1-tools/DecodeP1.exe'
|
||||
foreach($p1Input in @($p1Probe,$p1PDB,$p1Network,$p1Decoder)) {
|
||||
if(-not (Test-Path -LiteralPath $p1Input)) { throw "Missing local qualification dependency: $p1Input" }
|
||||
}
|
||||
if(Test-Path -LiteralPath $p1Output) { throw 'Use a NEW directory; trace evidence is never overwritten.' }
|
||||
New-Item -ItemType Directory -Path $p1Output | Out-Null
|
||||
$p1Journal=Join-Path $p1Output 'synthetic.journal'
|
||||
$p1ETL=Join-Path $p1Output 'native.etl'
|
||||
$p1Events=Join-Path $p1Output 'events.jsonl'
|
||||
$p1Info=[Diagnostics.ProcessStartInfo]::new($p1Probe)
|
||||
$p1Info.UseShellExecute=$false
|
||||
$p1Info.CreateNoWindow=$true
|
||||
$p1Info.RedirectStandardOutput=$true
|
||||
$p1Info.RedirectStandardError=$true
|
||||
$p1Info.WorkingDirectory=$p1Repo
|
||||
if($NoTrace) { $p1Info.Environment.Remove('DOLPHIN_PERF_TRACE') | Out-Null }
|
||||
else { $p1Info.Environment['DOLPHIN_PERF_TRACE']='1' }
|
||||
$p1Arguments=if($NoTrace) { @($p1Journal,$p1Network) } else { @($p1Journal,$p1Network,$p1ETL) }
|
||||
if($p1Info.PSObject.Properties['ArgumentList'] -and $p1Info.ArgumentList) {
|
||||
foreach($p1Arg in $p1Arguments) { $p1Info.ArgumentList.Add($p1Arg) }
|
||||
} else {
|
||||
$p1Info.Arguments=($p1Arguments | ForEach-Object {
|
||||
ConvertTo-P1WindowsArgument $_
|
||||
}) -join ' '
|
||||
}
|
||||
$p1Process=[Diagnostics.Process]::Start($p1Info)
|
||||
if ($ProbeAffinityMask -ne 0) {
|
||||
try {
|
||||
$p1Process.ProcessorAffinity = [IntPtr]$ProbeAffinityMask
|
||||
} catch {
|
||||
$p1Process.Kill() # Only the synthetic probe started by this script.
|
||||
$p1Process.WaitForExit()
|
||||
throw "Could not pin owned synthetic probe to mask ${ProbeAffinityMask}: $_"
|
||||
}
|
||||
}
|
||||
$p1ObservedAffinity = $p1Process.ProcessorAffinity.ToInt64()
|
||||
$p1Stdout=$p1Process.StandardOutput.ReadToEndAsync()
|
||||
$p1Stderr=$p1Process.StandardError.ReadToEndAsync()
|
||||
if(-not $p1Process.WaitForExit($TimeoutSeconds*1000)) {
|
||||
throw "Local probe exceeded $TimeoutSeconds seconds. Its PID is $($p1Process.Id); no process was forcibly stopped."
|
||||
}
|
||||
$p1Stdout.GetAwaiter().GetResult() | Out-File -LiteralPath (Join-Path $p1Output 'probe.stdout.log') -Encoding utf8
|
||||
$p1Stderr.GetAwaiter().GetResult() | Out-File -LiteralPath (Join-Path $p1Output 'probe.stderr.log') -Encoding utf8
|
||||
$p1Manifest=[ordered]@{
|
||||
Schema=1; Collection=$(if($NoTrace){'NoTrace'}else{'PrivateUserMode'}); Probe=$p1Probe
|
||||
ProbeSHA256=(Get-FileHash -LiteralPath $p1Probe).Hash
|
||||
ProbePDB=$p1PDB; ProbePDBSHA256=(Get-FileHash -LiteralPath $p1PDB).Hash
|
||||
ProbePID=$p1Process.Id; ProbeExitCode=$p1Process.ExitCode
|
||||
ProbeAffinityMask=$ProbeAffinityMask; ProbeObservedAffinityMask=$p1ObservedAffinity
|
||||
ProbeStartUTC=$p1Process.StartTime.ToUniversalTime().ToString('o')
|
||||
ProbeEndUTC=$p1Process.ExitTime.ToUniversalTime().ToString('o')
|
||||
ProbeWallSeconds=($p1Process.ExitTime-$p1Process.StartTime).TotalSeconds
|
||||
ProbeCPUSeconds=$p1Process.TotalProcessorTime.TotalSeconds
|
||||
NativeQualificationPassed=$false
|
||||
}
|
||||
$p1Manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $p1Output 'manifest.json') -Encoding utf8
|
||||
if($p1Process.ExitCode -ne 0) { throw 'Local probe failed; see probe.stderr.log.' }
|
||||
if($NoTrace) {
|
||||
Write-Output "No-trace fixed-work baseline completed: $p1Output"
|
||||
return
|
||||
}
|
||||
if(-not (Test-Path -LiteralPath $p1ETL)) { throw 'Local probe produced no ETL.' }
|
||||
$p1DecodeText=& $p1Decoder $p1ETL $p1Events
|
||||
$p1DecodeExit=$LASTEXITCODE
|
||||
$p1DecodeText | Out-File -LiteralPath (Join-Path $p1Output 'decoder-summary.json') -Encoding utf8
|
||||
if($p1DecodeExit) { throw 'ETL decode/loss check failed; see decoder-summary.json.' }
|
||||
Write-Output "Private user-mode trace decoded: $p1Output"
|
||||
Write-Output 'This capture has no CPU samples, context switches or disk wait events.'
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Offline ETL decoder. No process attach, memory read or thread suspension.
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cwchar>
|
||||
#include <evntrace.h>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <tdh.h>
|
||||
#include <vector>
|
||||
|
||||
static constexpr GUID NATIVE = {
|
||||
0x54ee9231, 0x28be, 0x4ab0, {0x98, 0x1e, 0xb2, 0x6f, 0x97, 0x9c, 0x56, 0xa4}};
|
||||
static FILE* output;
|
||||
static unsigned long long decoded = 0, errors = 0, total = 0;
|
||||
static std::map<std::string, std::vector<unsigned char>> schemas;
|
||||
static bool inspect_system = false;
|
||||
static unsigned inspected_schemas = 0, failed_schemas = 0;
|
||||
static std::string UTF8(const wchar_t* value)
|
||||
{
|
||||
if (!value || !*value)
|
||||
return {};
|
||||
int n = WideCharToMultiByte(CP_UTF8, 0, value, -1, nullptr, 0, nullptr, nullptr);
|
||||
std::string text(n, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, value, -1, text.data(), n, nullptr, nullptr);
|
||||
text.pop_back();
|
||||
return text;
|
||||
}
|
||||
static std::string Quote(const std::string& value)
|
||||
{
|
||||
std::string result = "\"";
|
||||
for (unsigned char c : value)
|
||||
{
|
||||
if (c == '"' || c == '\\')
|
||||
{
|
||||
result += '\\';
|
||||
result += c;
|
||||
}
|
||||
else if (c < 32)
|
||||
{
|
||||
char escaped[7];
|
||||
std::snprintf(escaped, sizeof(escaped), "\\u%04x", c);
|
||||
result += escaped;
|
||||
}
|
||||
else
|
||||
result += c;
|
||||
}
|
||||
return result + '"';
|
||||
}
|
||||
static void WINAPI Event(PEVENT_RECORD record)
|
||||
{
|
||||
++total;
|
||||
const bool native = IsEqualGUID(record->EventHeader.ProviderId, NATIVE);
|
||||
std::string key(reinterpret_cast<const char*>(&record->EventHeader.ProviderId), sizeof(GUID));
|
||||
key.append(reinterpret_cast<const char*>(&record->EventHeader.EventDescriptor),
|
||||
sizeof(EVENT_DESCRIPTOR));
|
||||
auto& buffer = schemas[key];
|
||||
const bool first_schema = buffer.empty();
|
||||
if (native || buffer.empty())
|
||||
{
|
||||
ULONG bytes = 0;
|
||||
const ULONG status = TdhGetEventInformation(record, 0, nullptr, nullptr, &bytes);
|
||||
if (status != ERROR_INSUFFICIENT_BUFFER)
|
||||
{
|
||||
if (native)
|
||||
++errors;
|
||||
else if (inspect_system && failed_schemas++ < 20)
|
||||
std::fprintf(stderr, "TDH missing: event=%u opcode=%u status=%lu\n",
|
||||
record->EventHeader.EventDescriptor.Id,
|
||||
record->EventHeader.EventDescriptor.Opcode, status);
|
||||
return;
|
||||
}
|
||||
buffer.resize(bytes);
|
||||
if (TdhGetEventInformation(record, 0, nullptr,
|
||||
reinterpret_cast<PTRACE_EVENT_INFO>(buffer.data()),
|
||||
&bytes) != ERROR_SUCCESS)
|
||||
{
|
||||
if (native)
|
||||
++errors;
|
||||
buffer.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto* info = reinterpret_cast<PTRACE_EVENT_INFO>(buffer.data());
|
||||
const auto text = [&](ULONG offset) -> std::string {
|
||||
return offset ? UTF8(reinterpret_cast<wchar_t*>(buffer.data() + offset)) : "";
|
||||
};
|
||||
std::string name = text(info->EventNameOffset);
|
||||
if (name.empty())
|
||||
name = text(info->TaskNameOffset) + "/" + text(info->OpcodeNameOffset);
|
||||
if (inspect_system && !native && first_schema && inspected_schemas++ < 300)
|
||||
{
|
||||
std::fprintf(stderr, "SCHEMA event=%u opcode=%u name=%s fields=",
|
||||
record->EventHeader.EventDescriptor.Id,
|
||||
record->EventHeader.EventDescriptor.Opcode, name.c_str());
|
||||
for (ULONG i = 0; i < info->TopLevelPropertyCount && i < 30; ++i)
|
||||
std::fprintf(stderr, "%s%s", i ? "," : "", text(info->EventPropertyInfoArray[i].NameOffset).c_str());
|
||||
std::fprintf(stderr, "\n");
|
||||
}
|
||||
bool interesting = native;
|
||||
// Keep scheduling and instruction pointer records for independent qualification.
|
||||
for (ULONG i = 0; i < info->TopLevelPropertyCount; ++i)
|
||||
{
|
||||
const auto field = text(info->EventPropertyInfoArray[i].NameOffset);
|
||||
if (field == "InstructionPointer" || field == "OldThreadId" || field == "TThreadId")
|
||||
interesting = true;
|
||||
}
|
||||
if (!interesting)
|
||||
return;
|
||||
std::string fields;
|
||||
for (ULONG i = 0; i < info->TopLevelPropertyCount; ++i)
|
||||
{
|
||||
const auto& property = info->EventPropertyInfoArray[i];
|
||||
if (property.Flags & PropertyStruct)
|
||||
continue;
|
||||
const auto field = text(property.NameOffset);
|
||||
PROPERTY_DATA_DESCRIPTOR descriptor{};
|
||||
descriptor.PropertyName = reinterpret_cast<ULONGLONG>(buffer.data() + property.NameOffset);
|
||||
descriptor.ArrayIndex = ULONG_MAX;
|
||||
ULONG size = 0;
|
||||
if (TdhGetPropertySize(record, 0, nullptr, 1, &descriptor, &size) != ERROR_SUCCESS ||
|
||||
size > 65536)
|
||||
{
|
||||
if (native)
|
||||
++errors;
|
||||
continue;
|
||||
}
|
||||
std::vector<unsigned char> bytes(size + 2, 0);
|
||||
if (TdhGetProperty(record, 0, nullptr, 1, &descriptor, size, bytes.data()) != ERROR_SUCCESS)
|
||||
{
|
||||
if (native)
|
||||
++errors;
|
||||
continue;
|
||||
}
|
||||
std::string value;
|
||||
const auto type = property.nonStructType.InType;
|
||||
if (type == TDH_INTYPE_UNICODESTRING)
|
||||
value = Quote(UTF8(reinterpret_cast<wchar_t*>(bytes.data())));
|
||||
else if (type == TDH_INTYPE_ANSISTRING)
|
||||
value = Quote(reinterpret_cast<char*>(bytes.data()));
|
||||
else if (size == 1 || size == 2 || size == 4 || size == 8)
|
||||
{
|
||||
unsigned long long number = 0;
|
||||
std::memcpy(&number, bytes.data(), size);
|
||||
value = std::to_string(number);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (native)
|
||||
++errors;
|
||||
continue;
|
||||
}
|
||||
if (!fields.empty())
|
||||
fields += ',';
|
||||
fields += Quote(field) + ':' + value;
|
||||
}
|
||||
std::fprintf(output,
|
||||
"{\"native\":%s,\"event\":%s,\"qpc\":%lld,\"pid\":%lu,\"tid\":%lu,\"cpu\":%u,"
|
||||
"\"opcode\":%u,\"data\":{%s}}\n",
|
||||
native ? "true" : "false", Quote(name).c_str(),
|
||||
record->EventHeader.TimeStamp.QuadPart, record->EventHeader.ProcessId,
|
||||
record->EventHeader.ThreadId, record->BufferContext.ProcessorNumber,
|
||||
record->EventHeader.EventDescriptor.Opcode, fields.c_str());
|
||||
++decoded;
|
||||
}
|
||||
int wmain(int argc, wchar_t** argv)
|
||||
{
|
||||
if (argc != 3 && (argc != 4 || std::wcscmp(argv[3], L"--inspect-system") != 0))
|
||||
{
|
||||
std::fprintf(stderr, "DecodeP1 input.etl NEW-output.jsonl [--inspect-system]\n");
|
||||
return 2;
|
||||
}
|
||||
inspect_system = argc == 4;
|
||||
if (GetFileAttributesW(argv[2]) != INVALID_FILE_ATTRIBUTES)
|
||||
{
|
||||
std::fprintf(stderr, "Refusing overwrite\n");
|
||||
return 2;
|
||||
}
|
||||
if (_wfopen_s(&output, argv[2], L"wb"))
|
||||
return 2;
|
||||
EVENT_TRACE_LOGFILEW logfile{};
|
||||
logfile.LogFileName = argv[1];
|
||||
logfile.ProcessTraceMode = PROCESS_TRACE_MODE_EVENT_RECORD | PROCESS_TRACE_MODE_RAW_TIMESTAMP;
|
||||
logfile.EventRecordCallback = Event;
|
||||
TRACEHANDLE trace = OpenTraceW(&logfile);
|
||||
if (trace == INVALID_PROCESSTRACE_HANDLE)
|
||||
{
|
||||
std::fclose(output);
|
||||
return 3;
|
||||
}
|
||||
const ULONG result = ProcessTrace(&trace, 1, nullptr, nullptr);
|
||||
CloseTrace(trace);
|
||||
std::fclose(output);
|
||||
std::printf("{\"status\":%lu,\"total\":%llu,\"decoded\":%llu,\"native_decode_errors\":%llu,"
|
||||
"\"events_lost\":%lu,\"buffers_lost\":%lu,\"qpc_frequency\":%lld}\n",
|
||||
result, total, decoded, errors, logfile.LogfileHeader.EventsLost,
|
||||
logfile.LogfileHeader.BuffersLost, logfile.LogfileHeader.PerfFreq.QuadPart);
|
||||
return result != ERROR_SUCCESS || errors != 0 || logfile.LogfileHeader.EventsLost != 0 ||
|
||||
logfile.LogfileHeader.BuffersLost != 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<WindowsPerformanceRecorder Version="1.0" Author="Dolphin" Company="Dolphin">
|
||||
<Profiles>
|
||||
<SystemCollector Id="P1Kernel" Name="DolphinP1Kernel">
|
||||
<BufferSize Value="256"/>
|
||||
<Buffers Value="128"/>
|
||||
</SystemCollector>
|
||||
<EventCollector Id="P1Events" Name="DolphinP1Events">
|
||||
<BufferSize Value="128"/>
|
||||
<Buffers Value="64"/>
|
||||
</EventCollector>
|
||||
<SystemProvider Id="P1System">
|
||||
<Keywords>
|
||||
<Keyword Value="ProcessThread"/>
|
||||
<Keyword Value="Loader"/>
|
||||
<Keyword Value="SampledProfile"/>
|
||||
<Keyword Value="CSwitch"/>
|
||||
<Keyword Value="ReadyThread"/>
|
||||
<Keyword Value="DiskIO"/>
|
||||
<Keyword Value="DiskIOInit"/>
|
||||
<Keyword Value="FileIO"/>
|
||||
<Keyword Value="FileIOInit"/>
|
||||
<Keyword Value="Filename"/>
|
||||
<Keyword Value="HardFaults"/>
|
||||
<Keyword Value="CpuConfig"/>
|
||||
<Keyword Value="Power"/>
|
||||
<Keyword Value="NetworkTrace"/>
|
||||
</Keywords>
|
||||
<Stacks>
|
||||
<Stack Value="SampledProfile"/>
|
||||
<Stack Value="CSwitch"/>
|
||||
</Stacks>
|
||||
</SystemProvider>
|
||||
<EventProvider Id="P1Native" Name="54ee9231-28be-4ab0-981e-b26f979c56a4" Level="5"/>
|
||||
<Profile Id="DolphinP1.Verbose.File" Name="DolphinP1" Description="CPU, scheduling, file IO and versioned Dolphin diagnostic events" LoggingMode="File" DetailLevel="Verbose">
|
||||
<Collectors>
|
||||
<SystemCollectorId Value="P1Kernel"><SystemProviderId Value="P1System"/></SystemCollectorId>
|
||||
<EventCollectorId Value="P1Events"><EventProviders><EventProviderId Value="P1Native"/></EventProviders></EventCollectorId>
|
||||
</Collectors>
|
||||
</Profile>
|
||||
</Profiles>
|
||||
</WindowsPerformanceRecorder>
|
||||
@@ -0,0 +1,79 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$OutputDirectory,
|
||||
[ValidateRange(4,8)][int]$Pairs = 4,
|
||||
[string]$PythonExecutable,
|
||||
[ValidateRange(0,9223372036854775807)][long]$ProbeAffinityMask = 0,
|
||||
[switch]$AnalyzeOnly
|
||||
)
|
||||
# One controlled batch of OFF/WPR-ON fixed-work synthetic runs; no Wii/NAND.
|
||||
# WPR requires an elevated PowerShell. Every run gets a unique directory.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$p1Repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$p1Root = [IO.Path]::GetFullPath($(if ([IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory
|
||||
} else {
|
||||
Join-Path $p1Repo $OutputDirectory
|
||||
}))
|
||||
if ($PythonExecutable) {
|
||||
$p1Python = (Resolve-Path -LiteralPath $PythonExecutable).Path
|
||||
} else {
|
||||
$p1Candidates = @(Get-Command python.exe -All -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Source -notlike '*\WindowsApps\*' } |
|
||||
Select-Object -ExpandProperty Source)
|
||||
$p1Candidates += 'C:\msys64\mingw64\bin\python.exe'
|
||||
foreach ($p1Candidate in $p1Candidates) {
|
||||
if ($p1Candidate -and (Test-Path -LiteralPath $p1Candidate)) {
|
||||
$p1Python = $p1Candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $p1Python) {
|
||||
throw 'Python was not found before capture. Pass -PythonExecutable with the path to a working python.exe.'
|
||||
}
|
||||
& $p1Python --version | Out-Null
|
||||
if ($LASTEXITCODE) { throw "Python could not start: $p1Python" }
|
||||
$p1AlreadyExists = Test-Path -LiteralPath $p1Root
|
||||
if ($AnalyzeOnly -and -not $p1AlreadyExists) { throw "Existing batch not found: $p1Root" }
|
||||
if (-not $AnalyzeOnly -and $p1AlreadyExists) {
|
||||
throw 'Use a NEW batch directory for capture. Pass -AnalyzeOnly to compare existing captures.'
|
||||
}
|
||||
$p1Probe = Join-Path $p1Repo '.starlet_diag_p1/Binaries/Tests/TraceProbeP1.exe'
|
||||
$p1Network = Join-Path $p1Repo '.starlet_diag_p1/Binaries/Network'
|
||||
$p1Decoder = Join-Path $p1Repo '.starlet-profile/P1-tools/DecodeP1.exe'
|
||||
if (-not $AnalyzeOnly) {
|
||||
foreach ($p1Required in @($p1Probe, $p1Network, $p1Decoder)) {
|
||||
if (-not (Test-Path -LiteralPath $p1Required)) { throw "Missing dependency: $p1Required" }
|
||||
}
|
||||
$p1Drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($p1Root))
|
||||
if ($p1Drive.AvailableFreeSpace -lt 9GB) { throw 'Need 4 GiB batch budget plus 5 GiB reserve.' }
|
||||
New-Item -ItemType Directory -Path $p1Root | Out-Null
|
||||
}
|
||||
$p1Runs = @()
|
||||
for ($p1Index = 1; $p1Index -le $Pairs; ++$p1Index) {
|
||||
$p1Off = Join-Path $p1Root "off-$p1Index"
|
||||
$p1On = Join-Path $p1Root "wpr-on-$p1Index"
|
||||
if (-not $AnalyzeOnly) {
|
||||
Write-Output "Pair $p1Index/${Pairs}: fixed-work OFF"
|
||||
& (Join-Path $PSScriptRoot 'Capture-Private-P1.ps1') -OutputDirectory $p1Off -NoTrace `
|
||||
-ProbeAffinityMask $ProbeAffinityMask
|
||||
Write-Output "Pair $p1Index/${Pairs}: fixed-work WPR ON"
|
||||
& (Join-Path $PSScriptRoot 'Capture-P1.ps1') -OutputDirectory $p1On -Seconds 60 `
|
||||
-ProbeExecutable $p1Probe `
|
||||
-ProbeArguments @((Join-Path $p1On 'synthetic.journal'), $p1Network) `
|
||||
-ProbeAffinityMask $ProbeAffinityMask
|
||||
$p1Decoded = & $p1Decoder (Join-Path $p1On 'capture.etl') `
|
||||
(Join-Path $p1On 'native-events.jsonl')
|
||||
$p1DecodeExit = $LASTEXITCODE
|
||||
$p1Decoded | Out-File -LiteralPath (Join-Path $p1On 'decoder-summary.json') -Encoding utf8
|
||||
if ($p1DecodeExit) { throw "Pair $p1Index has ETW loss or native decode errors." }
|
||||
}
|
||||
$p1Runs += $p1Off, $p1On
|
||||
}
|
||||
$p1Comparison = Join-Path $p1Root 'comparison.json'
|
||||
if (Test-Path -LiteralPath $p1Comparison) {
|
||||
throw "Comparison already exists and will not be overwritten: $p1Comparison"
|
||||
}
|
||||
& $p1Python (Join-Path $PSScriptRoot 'compare_p1.py') @p1Runs --output $p1Comparison
|
||||
if ($LASTEXITCODE) { throw 'Fixed-work comparison failed; no Wii run was started.' }
|
||||
Write-Output "Overhead batch finished: $p1Comparison"
|
||||
@@ -0,0 +1,110 @@
|
||||
param(
|
||||
[ValidatePattern('^P1-trace-[0-9]{3}$')][string]$Name='P1-trace-001',
|
||||
[string]$BuildDirectory='.starlet_diag_p1',
|
||||
[string]$RuntimeDirectory='.starlet_msvc2',
|
||||
[Parameter(Mandatory=$true)][string]$PythonExecutable
|
||||
)
|
||||
# Create an independent, verified pre-update NAND and frozen diagnostic build.
|
||||
# Never write to the user's normal NAND, the seed, or an existing experiment.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$p1Repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$p1Root = Join-Path $p1Repo '.starlet_update_ab_20260913'
|
||||
$p1Profile = Join-Path $p1Root $Name
|
||||
$p1Build = Join-Path $p1Root "build-$Name"
|
||||
$p1SourceBuild = [IO.Path]::GetFullPath((Join-Path $p1Repo $BuildDirectory))
|
||||
$p1RuntimeBuild = [IO.Path]::GetFullPath((Join-Path $p1Repo $RuntimeDirectory))
|
||||
$p1SourceExe = Join-Path $p1SourceBuild 'Binaries/DolphinNoGUI.exe'
|
||||
$p1SourcePDB = Join-Path $p1SourceBuild 'pdb/DolphinNoGUI.pdb'
|
||||
$p1Seed = Join-Path $p1Root 'pre-update.nand-journal'
|
||||
$p1JournalRelative = 'Starlet\0238188DE97894AE3AF93A92C4F238C0572731A7.nand-journal'
|
||||
$p1Manifest = Get-Content -LiteralPath (Join-Path $p1Root 'manifest.json') -Raw | ConvertFrom-Json
|
||||
$p1Expected = Get-Content -LiteralPath (Join-Path $p1Root 'initial-files-B.json') -Raw | ConvertFrom-Json
|
||||
$p1Python = (Resolve-Path -LiteralPath $PythonExecutable).Path
|
||||
if (Get-Process DolphinNoGUI,Dolphin -ErrorAction SilentlyContinue) {
|
||||
throw 'Close the existing Dolphin instance before preparing a native-update run.'
|
||||
}
|
||||
foreach ($p1Target in @($p1Profile, $p1Build)) {
|
||||
if (Test-Path -LiteralPath $p1Target) { throw "Refusing to overwrite $p1Target" }
|
||||
}
|
||||
foreach ($p1Required in @($p1Seed, $p1SourceExe, $p1SourcePDB,
|
||||
(Join-Path $p1RuntimeBuild 'Binaries/Sys'),
|
||||
(Join-Path $p1SourceBuild 'Binaries/Network'))) {
|
||||
if (-not (Test-Path -LiteralPath $p1Required)) { throw "Missing input: $p1Required" }
|
||||
}
|
||||
if ((Get-FileHash -LiteralPath $p1Seed -Algorithm SHA256).Hash -ne $p1Manifest.SeedSHA256) {
|
||||
throw 'The preserved pre-update NAND journal differs from its manifest.'
|
||||
}
|
||||
$p1InputBytes = ($p1Expected | Measure-Object -Property Bytes -Sum).Sum
|
||||
$p1InputBytes += (Get-Item -LiteralPath $p1SourceExe).Length + (Get-Item -LiteralPath $p1SourcePDB).Length
|
||||
foreach ($p1Folder in @('Sys','Network')) {
|
||||
$p1FolderSource = if ($p1Folder -eq 'Sys') { $p1RuntimeBuild } else { $p1SourceBuild }
|
||||
$p1InputBytes += (Get-ChildItem -LiteralPath (Join-Path $p1FolderSource "Binaries/$p1Folder") `
|
||||
-File -Recurse | Measure-Object -Property Length -Sum).Sum
|
||||
}
|
||||
if ((Get-PSDrive C).Free -lt ($p1InputBytes + 5GB)) {
|
||||
throw 'Insufficient free space for copies, update growth, and a 5 GiB reserve.'
|
||||
}
|
||||
$p1Copies = foreach ($p1File in $p1Expected) {
|
||||
$p1Target = [IO.Path]::GetFullPath((Join-Path $p1Profile $p1File.Relative))
|
||||
if (-not $p1Target.StartsWith($p1Profile + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Invalid relative path in the initial-file manifest.'
|
||||
}
|
||||
$p1Sources = if ($p1File.Relative -eq $p1JournalRelative) { @($p1Seed) } else {
|
||||
@((Join-Path (Join-Path $p1Root 'B') $p1File.Relative),
|
||||
(Join-Path (Join-Path $p1Repo '.starlet_formatter_test_20260913') $p1File.Relative))
|
||||
}
|
||||
$p1Selected = $null
|
||||
foreach ($p1Source in $p1Sources) {
|
||||
if ((Test-Path -LiteralPath $p1Source -PathType Leaf) -and
|
||||
(Get-FileHash -LiteralPath $p1Source -Algorithm SHA256).Hash -eq $p1File.SHA256) {
|
||||
$p1Selected = $p1Source
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $p1Selected) { throw "No byte-identical initial source for $($p1File.Relative)" }
|
||||
[pscustomobject]@{ Relative=$p1File.Relative; Source=$p1Selected; Target=$p1Target; SHA256=$p1File.SHA256 }
|
||||
}
|
||||
New-Item -ItemType Directory -Path $p1Profile,(Join-Path $p1Build 'Binaries'),
|
||||
(Join-Path $p1Build 'pdb') | Out-Null
|
||||
foreach ($p1Copy in $p1Copies) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $p1Copy.Target -Parent) | Out-Null
|
||||
Copy-Item -LiteralPath $p1Copy.Source -Destination $p1Copy.Target
|
||||
if ((Get-FileHash -LiteralPath $p1Copy.Target -Algorithm SHA256).Hash -ne $p1Copy.SHA256) {
|
||||
throw "Copied input verification failed: $($p1Copy.Relative)"
|
||||
}
|
||||
}
|
||||
foreach ($p1Folder in @('Sys','Network')) {
|
||||
$p1FolderSource = if ($p1Folder -eq 'Sys') { $p1RuntimeBuild } else { $p1SourceBuild }
|
||||
Copy-Item -LiteralPath (Join-Path $p1FolderSource "Binaries/$p1Folder") `
|
||||
-Destination (Join-Path $p1Build 'Binaries') -Recurse
|
||||
}
|
||||
Copy-Item -LiteralPath $p1SourceExe -Destination (Join-Path $p1Build 'Binaries/DolphinNoGUI.exe')
|
||||
Copy-Item -LiteralPath $p1SourcePDB -Destination (Join-Path $p1Build 'pdb/DolphinNoGUI.pdb')
|
||||
foreach ($p1Folder in @('Logs','Perf','Capture')) {
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $p1Profile $p1Folder) | Out-Null
|
||||
}
|
||||
$p1ConfigPath = Join-Path $p1Profile 'Config/Dolphin.ini'
|
||||
$p1OriginalConfig = [IO.File]::ReadAllText($p1ConfigPath)
|
||||
$p1OldPrefix = (Join-Path $p1Root 'B').Replace('\','/') + '/'
|
||||
$p1NewPrefix = $p1Profile.Replace('\','/') + '/'
|
||||
if (-not $p1OriginalConfig.Contains($p1OldPrefix)) {
|
||||
throw 'The source configuration has no expected baseline profile path.'
|
||||
}
|
||||
$p1NewConfig = $p1OriginalConfig.Replace($p1OldPrefix, $p1NewPrefix)
|
||||
if ($p1NewConfig.Replace($p1NewPrefix, $p1OldPrefix) -cne $p1OriginalConfig) {
|
||||
throw 'The isolated configuration differs beyond its profile paths.'
|
||||
}
|
||||
[IO.File]::WriteAllText($p1ConfigPath, $p1NewConfig, [Text.UTF8Encoding]::new($false))
|
||||
& $p1Python -B (Join-Path $p1Root '../.starlet-profile/VerifyJournalCheckpoint.py') `
|
||||
(Join-Path $p1Profile $p1JournalRelative) $p1Manifest.PrefixBytes `
|
||||
> (Join-Path $p1Profile 'Capture/initial-journal-verification.txt') 2>&1
|
||||
if ($LASTEXITCODE) { throw 'Preserved NAND checkpoint verification failed; no emulator was launched.' }
|
||||
[pscustomobject]@{
|
||||
CreatedUTC=[datetime]::UtcNow.ToString('o'); Profile=$p1Profile; Build=$p1Build
|
||||
InitialFiles=$p1Copies.Count; SeedSHA256=$p1Manifest.SeedSHA256
|
||||
ExeSHA256=(Get-FileHash -LiteralPath (Join-Path $p1Build 'Binaries/DolphinNoGUI.exe')).Hash
|
||||
PdbSHA256=(Get-FileHash -LiteralPath (Join-Path $p1Build 'pdb/DolphinNoGUI.pdb')).Hash
|
||||
SourceBuild=$p1SourceBuild; RuntimeBuild=$p1RuntimeBuild
|
||||
PerformanceTraceRequestedAtLaunch=$false
|
||||
} | ConvertTo-Json -Depth 4 | Out-File -LiteralPath (Join-Path $p1Profile 'Capture/prepare-manifest.json') -Encoding utf8
|
||||
Write-Output "Prepared isolated profile $p1Profile and frozen build $p1Build. No Dolphin instance was launched."
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// A local, user-mode-only trace for validating the provider without WPR rights.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
#include <evntrace.h>
|
||||
|
||||
#include "Common/PerformanceTrace.h"
|
||||
|
||||
class PrivateETW
|
||||
{
|
||||
public:
|
||||
explicit PrivateETW(const char* output)
|
||||
{
|
||||
if (!output)
|
||||
return;
|
||||
const auto path = std::filesystem::absolute(output);
|
||||
if (std::filesystem::exists(path))
|
||||
throw std::runtime_error("Private ETL target already exists");
|
||||
if (!Common::PerformanceTrace::Requested())
|
||||
throw std::runtime_error("Set DOLPHIN_PERF_TRACE=1 for a private trace");
|
||||
Common::PerformanceTrace::Enabled(); // Register before starting the session.
|
||||
constexpr wchar_t name[] = L"DolphinP1LocalProbe";
|
||||
const std::wstring filename = path.wstring();
|
||||
const size_t name_size = sizeof(name);
|
||||
const size_t filename_size = (filename.size() + 1) * sizeof(wchar_t);
|
||||
m_storage.resize(sizeof(EVENT_TRACE_PROPERTIES) + name_size + filename_size);
|
||||
m_properties = reinterpret_cast<EVENT_TRACE_PROPERTIES*>(m_storage.data());
|
||||
std::memset(m_properties, 0, m_storage.size());
|
||||
m_properties->Wnode.BufferSize = static_cast<ULONG>(m_storage.size());
|
||||
m_properties->Wnode.Guid = PERF_PROVIDER;
|
||||
m_properties->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
|
||||
m_properties->Wnode.ClientContext = 1; // QPC
|
||||
m_properties->BufferSize = 64;
|
||||
m_properties->MinimumBuffers = 2;
|
||||
m_properties->MaximumBuffers = 16;
|
||||
m_properties->LogFileMode = EVENT_TRACE_FILE_MODE_SEQUENTIAL | EVENT_TRACE_PRIVATE_LOGGER_MODE |
|
||||
EVENT_TRACE_PRIVATE_IN_PROC;
|
||||
m_properties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);
|
||||
m_properties->LogFileNameOffset =
|
||||
static_cast<ULONG>(sizeof(EVENT_TRACE_PROPERTIES) + name_size);
|
||||
std::memcpy(m_storage.data() + m_properties->LoggerNameOffset, name, name_size);
|
||||
std::memcpy(m_storage.data() + m_properties->LogFileNameOffset, filename.c_str(),
|
||||
filename_size);
|
||||
const ULONG started = StartTraceW(&m_handle, name, m_properties);
|
||||
if (started != ERROR_SUCCESS)
|
||||
throw std::runtime_error("Private ETW StartTrace error " + std::to_string(started));
|
||||
m_started = true;
|
||||
const ULONG enabled =
|
||||
EnableTraceEx2(m_handle, &PERF_PROVIDER, EVENT_CONTROL_CODE_ENABLE_PROVIDER,
|
||||
TRACE_LEVEL_VERBOSE, 0, 0, 0, nullptr);
|
||||
if (enabled != ERROR_SUCCESS || !Common::PerformanceTrace::Enabled())
|
||||
{
|
||||
Close();
|
||||
throw std::runtime_error("Private ETW enable error " + std::to_string(enabled));
|
||||
}
|
||||
}
|
||||
|
||||
~PrivateETW() { Close(); }
|
||||
PrivateETW(const PrivateETW&) = delete;
|
||||
PrivateETW& operator=(const PrivateETW&) = delete;
|
||||
|
||||
bool Close()
|
||||
{
|
||||
if (!m_started)
|
||||
return true;
|
||||
m_started = false;
|
||||
return ControlTraceW(m_handle, L"DolphinP1LocalProbe", m_properties,
|
||||
EVENT_TRACE_CONTROL_STOP) == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr GUID PERF_PROVIDER = {
|
||||
0x54ee9231, 0x28be, 0x4ab0, {0x98, 0x1e, 0xb2, 0x6f, 0x97, 0x9c, 0x56, 0xa4}};
|
||||
TRACEHANDLE m_handle = 0;
|
||||
bool m_started = false;
|
||||
EVENT_TRACE_PROPERTIES* m_properties = nullptr;
|
||||
std::vector<u8> m_storage;
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
# Diagnostic P1 : statut et exécution locale
|
||||
|
||||
Le protocole et les critères de décision sont dans
|
||||
[`docs/Wii_LLE_Performance_Diagnostic_Protocol.md`](../../docs/Wii_LLE_Performance_Diagnostic_Protocol.md).
|
||||
Tous les artefacts volumineux vont dans `.starlet-profile` (ignoré par Git), jamais
|
||||
dans la NAND source ni dans le profil utilisateur courant.
|
||||
|
||||
1. Construire le profil isolé avec `./Tools/PerformanceTrace/Build-P1.ps1`.
|
||||
Après modification d'une option de compilation, `-CleanBeforeBuild` vérifie tous
|
||||
les objets. Le script répare le préfixe français `/showIncludes` si cette
|
||||
installation MSVC l'a mal décodé ; un objet avec `#deps 0` invalide la build.
|
||||
2. Vérifier les tests ciblés de `Binaries/Tests/tests.exe` et le décodeur avec
|
||||
`./Tools/PerformanceTrace/Build-Decoder.ps1 -OutputDirectory .starlet-profile/P1-tools`.
|
||||
3. Sur une session Windows sans droit de profilage noyau, valider d'abord le
|
||||
fournisseur dans une session ETW privée :
|
||||
|
||||
```powershell
|
||||
./Tools/PerformanceTrace/Capture-Private-P1.ps1 -OutputDirectory .starlet-profile/P1-private-NEW
|
||||
python Tools/PerformanceTrace/qualify_p1.py .starlet-profile/P1-private-NEW
|
||||
```
|
||||
|
||||
Le résultat attendu est `local_provider_passed=true` et
|
||||
`full_collector_passed=false`. Cette session ne contient aucun échantillon
|
||||
CPU, changement de thread ou attente disque. Le mode `-NoTrace` exécute
|
||||
exactement la même charge pour une comparaison locale alternée. Exemple
|
||||
avec quatre répertoires neufs, à exécuter dans cet ordre :
|
||||
|
||||
```powershell
|
||||
./Tools/PerformanceTrace/Capture-Private-P1.ps1 -OutputDirectory .starlet-profile/P1-off-1 -NoTrace
|
||||
./Tools/PerformanceTrace/Capture-Private-P1.ps1 -OutputDirectory .starlet-profile/P1-on-1
|
||||
./Tools/PerformanceTrace/Capture-Private-P1.ps1 -OutputDirectory .starlet-profile/P1-off-2 -NoTrace
|
||||
./Tools/PerformanceTrace/Capture-Private-P1.ps1 -OutputDirectory .starlet-profile/P1-on-2
|
||||
python Tools/PerformanceTrace/compare_p1.py .starlet-profile/P1-off-1 .starlet-profile/P1-on-1 .starlet-profile/P1-off-2 .starlet-profile/P1-on-2 --output .starlet-profile/P1-private-overhead-NEW.json
|
||||
```
|
||||
|
||||
Ce comparateur conserve les quatre mesures et la dispersion ; il ne peut
|
||||
**pas** valider le budget de 2 % de la capture WPR complète.
|
||||
4. Capture synthétique WPR complète **dans une session administrateur**, avec
|
||||
un **nouveau** chemin de sortie :
|
||||
|
||||
```powershell
|
||||
./Tools/PerformanceTrace/Capture-Local-P1.ps1 -OutputDirectory .starlet-profile/P1-local-NEW
|
||||
```
|
||||
|
||||
Ce programme exécute une boucle Thumb réelle, un sommeil hôte connu, 128
|
||||
transactions de journal synthétique et 32 échanges ARP avec la passerelle
|
||||
locale libslirp. Le journal synthétique reste dans ce répertoire neuf.
|
||||
Aucun serveur externe n'est contacté par l'échange ARP.
|
||||
5. Après la capture, décoder et qualifier hors ligne (sans droit administrateur)
|
||||
dans un **nouveau** répertoire :
|
||||
|
||||
```powershell
|
||||
./Tools/PerformanceTrace/Analyze-Local-P1.ps1 `
|
||||
-CaptureDirectory .starlet-profile/P1-local-NEW `
|
||||
-OutputDirectory .starlet-profile/P1-local-NEW-analysis
|
||||
```
|
||||
|
||||
Le lecteur TDH extrait les événements natifs ; Xperf décode les événements
|
||||
noyau classiques, résout les PDB locaux et les deux flux sont alignés par
|
||||
les QPC des marqueurs ou des snapshots périodiques. Le CSV, l'ETL et les données de profil peuvent contenir
|
||||
des noms de processus ou chemins : ils restent locaux et ignorés par Git.
|
||||
Le répertoire de sortie doit être neuf à chaque tentative.
|
||||
6. Mesurer le surcoût WPR avec une seule commande administrateur et un nouveau
|
||||
répertoire. Elle automatise quatre paires alternées OFF/ON du **même** travail
|
||||
synthétique (huit lancements locaux, aucun démarrage Wii ni NAND utilisateur) :
|
||||
|
||||
```powershell
|
||||
./Tools/PerformanceTrace/Measure-P1-Overhead.ps1 -OutputDirectory .starlet-profile/P1-overhead-001
|
||||
```
|
||||
|
||||
`comparison.json` conserve les durées brutes, la dispersion des bases OFF,
|
||||
les écarts par paire et une borne supérieure de confiance. Le seuil de 2 %
|
||||
ne passe que si les données restent stables et les trois mesures ARM/mur/CPU
|
||||
satisfont ce seuil. Python est vérifié **avant** toute capture ; si un lot
|
||||
s'est interrompu uniquement au calcul final, `-AnalyzeOnly` relit les huit
|
||||
captures existantes sans en relancer aucune. `-PythonExecutable` permet de
|
||||
préciser le chemin de `python.exe` si le PowerShell administrateur n'a pas
|
||||
le même `PATH`. Un résultat négatif ou bruité n'autorise aucun diagnostic
|
||||
de vitesse Wii.
|
||||
|
||||
`qualification.json` détaille chaque gate. Une sonde locale réussie ne constitue
|
||||
pas encore une analyse de mise à jour Wii : le cas PPC, les piles hôte et le
|
||||
surcoût du collecteur restent à qualifier. La capture native n'est prévue
|
||||
qu'après ces vérifications. Le profil WPR comporte une session au nom unique et
|
||||
le script arrête seulement cette session.
|
||||
|
||||
Le 22 septembre, deux traces privées ont validé les 13 contrôles du fournisseur
|
||||
(`P1-private-004`, `P1-private-006`). Le jeton `CodexSandboxUsers` ne pouvait
|
||||
pas lancer WPR (`0xc5585011`), mais la capture synthétique administrateur
|
||||
`P1-local-004` a réussi. Sous Windows PowerShell 5.1, le premier essai avait
|
||||
révélé `ProcessStartInfo.ArgumentList` nul et une résolution de chemin relatif
|
||||
depuis le répertoire de processus plutôt que celui affiché par `cd` ; les deux
|
||||
cas sont corrigés et testés.
|
||||
|
||||
L'analyse reproductible `P1-local-004-reanalysis2` passe 13 contrôles du
|
||||
fournisseur et 3 contrôles système : 9 427 échantillons CPU, 694 changements
|
||||
de contexte, dix ancres QPC alignées à cinq ticks près, sommeil connu de
|
||||
752 ms avec un switch `Waiting`. Les 112 événements classiques déclarés
|
||||
invalides par Xperf concernent une métadonnée `ImageID` hors du PID de la
|
||||
sonde ; zéro événement cible invalide et zéro perte ETW. Les symboles du PDB
|
||||
local résolvent les fonctions hôte. Cela valide la **sonde**, pas la capture
|
||||
native : coût du collecteur WPR, cas PPC et chemin critique Wii restent ouverts.
|
||||
[Identification du GUID ImageID dans le code Microsoft PerfView](https://github.com/microsoft/perfview/blob/main/src/TraceEvent/BPerf/BPerfEventSource.cs).
|
||||
|
||||
Le lot `P1-overhead-001` du 22 septembre a produit les huit captures complètes,
|
||||
mais son PowerShell administrateur ne trouvait pas Python lors de la comparaison.
|
||||
Les données conservées ont été comparées hors ligne, sans recapture : les bases
|
||||
OFF varient de 34,5 % pour la phase ARM et 26,8 % sur le temps total. Les écarts
|
||||
ARM par paire sont +9,7 %, +25,0 %, -0,5 % et +1,0 %. Le surcoût WPR ne peut
|
||||
donc pas être isolé de la variabilité de cette série et le budget de 2 % reste
|
||||
**non qualifié**. Le rapport brut est
|
||||
`.starlet-profile/P1-overhead-001/comparison.json` ; aucune mesure de vitesse
|
||||
Wii n'en est déduite. Les traces ON montrent `cpptools.exe` consommant environ
|
||||
8–9 s de CPU par essai et des sous-processus Dell intermittents ; ces charges
|
||||
hôte sont une piste de contamination, pas une attribution causale chiffrée des
|
||||
écarts ON/OFF. Dans `wpr-on-2`, 76 768 événements `ReadyThread` système ont été
|
||||
enregistrés. Le profil conserve ces événements et leurs horodatages mais ne
|
||||
collecte plus leur pile, afin de réduire le coût potentiel de cette source
|
||||
système très fréquente ; le profil modifié doit être requalifié séparément.
|
||||
|
||||
Le lot `P1-overhead-002`, après cet allègement, n'a pas perdu d'événements
|
||||
mais garde 36,5 % d'étendue ARM entre bases OFF. Ses quatre écarts ON/OFF sont
|
||||
+4,6 %, -11,0 %, +10,2 % et -3,5 % ; leur moyenne proche de zéro ne qualifie
|
||||
**pas** le budget. L'analyse des échantillons ETW révèle un changement de
|
||||
placement CPU : part sur les processeurs logiques 0–3 de 78,7 %, 45,4 %, 28,9 %,
|
||||
46,5 % pour des phases ARM de 7,59 s, 8,82 s, 10,33 s, 8,65 s. L'ETL identifie
|
||||
un Core i7-1255U et SMT sur 0–3 ; [Intel indique 2 P-cores et 8 E-cores](https://www.intel.com/content/www/us/en/products/sku/226259/intel-core-i71255u-processor-12m-cache-up-to-4-70-ghz/specifications.html).
|
||||
Ce placement est fortement associé à la durée, sans prouver qu'il explique à
|
||||
lui seul tout l'écart. Le lancement Wii existant fixe déjà Dolphin sur ces
|
||||
processeurs ; la **sonde synthétique**, elle, n'était pas fixée.
|
||||
|
||||
Les captures de sonde acceptent maintenant `-ProbeAffinityMask`, enregistrent
|
||||
le masque réellement appliqué, et le comparateur refuse les masques différents.
|
||||
Quatre bases locales sans WPR avec `-ProbeAffinityMask 15` ont fini leur phase
|
||||
ARM en 7,03, 6,65, 7,20 et 6,97 s (masque observé 15 à chaque fois). Le
|
||||
placement est contrôlé, mais une variabilité résiduelle de 8,3 % subsiste :
|
||||
aucun nouveau lot administrateur n'est demandé avant de traiter cette limite
|
||||
du protocole ou de passer à un diagnostic qualitatif explicitement borné.
|
||||
|
||||
## Première capture d'une vraie mise à jour (exploratoire)
|
||||
|
||||
`Prepare-Native-Update-P1.ps1` crée un profil NAND pré-mise-à-jour indépendant,
|
||||
vérifie les 31 fichiers et le journal, puis fige l'exécutable et son PDB. Il
|
||||
refuse tout nom déjà utilisé et ne lance pas Dolphin. L'expérience préparée
|
||||
ici est `P1-trace-002` ; le profil courant `.starlet_user3` et les résultats A–F
|
||||
ne sont pas modifiés. La capture ETW requiert un PowerShell administrateur.
|
||||
La préparation dépend d'un seed NAND local ignoré par Git et demande désormais
|
||||
un `-PythonExecutable` explicite ; elle n'est pas une procédure de création de
|
||||
NAND dans un clone neuf.
|
||||
|
||||
```powershell
|
||||
# Terminal administrateur : démarrer d'abord, puis laisser ouvert.
|
||||
cd C:\Users\tpetkovic\Desktop\dolphin
|
||||
.\Tools\PerformanceTrace\Capture-P1.ps1 -OutputDirectory .starlet-profile/P1-native-update-001 -Seconds 600
|
||||
|
||||
# Dès « Native collector armed », dans un autre PowerShell :
|
||||
cd C:\Users\tpetkovic\Desktop\dolphin
|
||||
.\Run-Wii-IOS-LLE.ps1 `
|
||||
-BuildDirectory .starlet_update_ab_20260913/build-P1-trace-002 `
|
||||
-UserDirectory .starlet_update_ab_20260913/P1-trace-002 `
|
||||
-Ethernet -PerformanceTrace
|
||||
```
|
||||
|
||||
Noter le PID imprimé par le lanceur, lancer la mise à jour dans la Wii, puis
|
||||
laisser terminer la capture. Analyser ensuite l'ETL hors ligne dans un nouveau
|
||||
répertoire :
|
||||
|
||||
```powershell
|
||||
.\Tools\PerformanceTrace\Analyze-Local-P1.ps1 `
|
||||
-CaptureDirectory .starlet-profile/P1-native-update-001 `
|
||||
-OutputDirectory .starlet-profile/P1-native-update-001-analysis `
|
||||
-NativePID <PID_DOLPHIN> `
|
||||
-NativePDB .starlet_update_ab_20260913/build-P1-trace-002/pdb/DolphinNoGUI.pdb
|
||||
```
|
||||
|
||||
Le rapport `native-report.json` reste exploratoire : perte ETW et alignement
|
||||
QPC sont contrôlés, mais piles, attentes critiques, NAT et surcoût de capture
|
||||
doivent encore être qualifiés avant d'attribuer le ralentissement ou d'annoncer
|
||||
un gain de FPS. La mise à jour nécessite une action Wii réelle et un réseau
|
||||
disponible ; ne pas essayer de la rejouer sur le même profil après succès.
|
||||
|
||||
Windows exige un droit de profilage pour le profil noyau ; un logger privé
|
||||
du processus fonctionne avec un compte peu privilégié.
|
||||
[Sessions privées ETW](https://learn.microsoft.com/en-us/windows/win32/etw/configuring-and-starting-a-private-logger-session),
|
||||
[droits de StartTrace](https://learn.microsoft.com/en-us/windows/win32/api/evntrace/nf-evntrace-starttracea).
|
||||
|
||||
## Décision après A/B natif du 23 septembre
|
||||
|
||||
Le [rapport P1](../../docs/Wii_LLE_Update_P1_Trace_2026-09-23.md) contient les
|
||||
deux mises à jour NAND isolées et vérifiées. Le raccourci SRAM ciblé a été
|
||||
rejeté : sur ce passage, le témoin a terminé les mêmes seuils de journal plus
|
||||
vite et a été jugé plus fluide. Le raccourci a été retiré du source ; ses deux
|
||||
binaires et journaux figés restent locaux pour audit. Les outils de ce dossier
|
||||
servent au diagnostic, pas à justifier une modification matérielle sans test
|
||||
de conformité sur Wii réelle.
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Local qualification only: synthetic ARM code/data, synthetic NAND journal, no
|
||||
// firmware/profile.
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <winsock2.h>
|
||||
|
||||
#include "Common/Network.h"
|
||||
#include "Common/PerformanceTrace.h"
|
||||
#include "Core/IOS/Starlet/ARMCore.h"
|
||||
#include "Core/IOS/Starlet/AX88772.h"
|
||||
#include "Core/IOS/Starlet/NANDJournal.h"
|
||||
#include "Core/IOS/Starlet/SlirpNetwork.h"
|
||||
#include "PrivateETW.h"
|
||||
|
||||
namespace Trace = Common::PerformanceTrace;
|
||||
using namespace IOS::LLE;
|
||||
class ProbeBus final : public ARMBus
|
||||
{
|
||||
public:
|
||||
u8 Read8(u32 address) override { return bytes.at(address); }
|
||||
void Write8(u32 address, u8 value) override { bytes.at(address) = value; }
|
||||
const u8* GetDirectMemoryPointer(u32 address, u32 size) const override
|
||||
{
|
||||
return address < bytes.size() && size <= bytes.size() - address ? bytes.data() + address :
|
||||
nullptr;
|
||||
}
|
||||
void Op(u32 offset, u16 op)
|
||||
{
|
||||
bytes[offset] = static_cast<u8>(op >> 8);
|
||||
bytes[offset + 1] = static_cast<u8>(op);
|
||||
}
|
||||
std::array<u8, 8192> bytes{};
|
||||
};
|
||||
void Check(bool value)
|
||||
{
|
||||
if (!value)
|
||||
throw std::runtime_error("P1 local probe validation failed");
|
||||
}
|
||||
void LocalNAT(const std::string& directory)
|
||||
{
|
||||
SlirpNetwork network;
|
||||
AX88772 device([&](std::span<const u8> frame) { network.Input(frame); });
|
||||
std::string error;
|
||||
if (!network.Start(
|
||||
directory, [&](std::span<const u8> frame) { device.ReceiveFrame(frame); }, &error))
|
||||
throw std::runtime_error(error);
|
||||
const auto control = [&](u8 type, u8 request, u16 value) {
|
||||
std::array<u8, 8> setup{
|
||||
type, request, static_cast<u8>(value), static_cast<u8>(value >> 8), 0, 0, 0, 0};
|
||||
size_t actual = 0;
|
||||
Check(device.Transfer(0, 0, setup, &actual) == AX88772::Result::Completed);
|
||||
Check(device.Transfer(0, 2, {}, &actual) == AX88772::Result::Completed);
|
||||
};
|
||||
control(0, 9, 1);
|
||||
control(0x40, 0x10, 0x88);
|
||||
control(0x40, 0x1b, 0x306);
|
||||
device.SetLink(true);
|
||||
// ARP reaches only libslirp's virtual gateway: no DNS, Internet or remote server.
|
||||
Common::MACAddress mac{2, 0x44, 0x4f, 0x4c, 0, 1};
|
||||
Common::ARPPacket request(Common::MACAddress{255, 255, 255, 255, 255, 255}, mac);
|
||||
request.arp_header.opcode = htons(1);
|
||||
request.arp_header.sender_address = mac;
|
||||
request.arp_header.sender_ip = htonl(0x0a00020f);
|
||||
request.arp_header.target_ip = htonl(0x0a000202);
|
||||
const auto frame = request.Build();
|
||||
const u16 length = static_cast<u16>(frame.size());
|
||||
std::vector<u8> framed{static_cast<u8>(length), static_cast<u8>(length >> 8),
|
||||
static_cast<u8>(~length), static_cast<u8>(~length >> 8)};
|
||||
framed.insert(framed.end(), frame.begin(), frame.end());
|
||||
Trace::Marker("LocalNATBegin");
|
||||
for (u32 i = 0; i < 32; ++i)
|
||||
{
|
||||
Trace::SampleRoot root(true);
|
||||
size_t actual = 0;
|
||||
Check(device.Transfer(3, 1, framed, &actual) == AX88772::Result::Completed);
|
||||
network.Poll(243000);
|
||||
device.TraceQueue();
|
||||
std::array<u8, 2048> response{};
|
||||
Check(device.Transfer(2, 2, response, &actual) == AX88772::Result::Completed);
|
||||
Check(actual >= 46);
|
||||
const auto arp =
|
||||
Common::PacketView(response.data() + 4, response[0] | (response[1] << 8)).GetARPPacket();
|
||||
Check(arp && ntohs(arp->arp_header.opcode) == 2 &&
|
||||
arp->arp_header.sender_ip == htonl(0x0a000202));
|
||||
device.TraceQueue();
|
||||
}
|
||||
Trace::Marker("LocalNATEnd");
|
||||
}
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (argc != 3 && argc != 4)
|
||||
throw std::runtime_error("Usage: TraceProbeP1.exe NEW-synthetic-journal-path "
|
||||
"libslirp-runtime-directory [NEW-private-ETL]");
|
||||
const std::string journal_path = argv[1];
|
||||
Check(!std::filesystem::exists(journal_path));
|
||||
PrivateETW private_trace(argc == 4 ? argv[3] : nullptr);
|
||||
Trace::Marker("ProbeBegin");
|
||||
ProbeBus bus;
|
||||
bus.Op(0, 0x6810); // ldr r0,[r2]
|
||||
bus.Op(2, 0x6851); // ldr r1,[r2,#4]
|
||||
bus.Op(4, 0x46c0); // nop
|
||||
bus.Op(6, 0xe7fb); // b 0
|
||||
bus.Write32(4096, 0x12345678);
|
||||
bus.Write32(4100, 0x87654321);
|
||||
ARMCore cpu(bus);
|
||||
cpu.SetCPSR(static_cast<u32>(ARMCore::Mode::Supervisor) | ARMCore::CPSR_T);
|
||||
cpu.SetRegister(2, 4096);
|
||||
cpu.SetJitEnabled(true);
|
||||
Trace::Marker("ARMWorkBegin");
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
// Fixed work, not a duration-based loop: suitable for ON/OFF cost
|
||||
// comparisons.
|
||||
constexpr u64 slices = 160000;
|
||||
for (u64 i = 0; i < slices; ++i)
|
||||
{
|
||||
Trace::SampleRoot root;
|
||||
Check(cpu.RunCycles(4096) == 4096);
|
||||
if ((i & 1023) == 0)
|
||||
Trace::Snapshot(cpu.GetTraceGeneration(), cpu.GetExecutedInstructions(),
|
||||
cpu.GetJitNativeExecutedInstructions(), cpu.GetJitSlowReadCount(),
|
||||
cpu.GetJitSlowWriteCount(), cpu.GetJitLifetimeCompiledBlockCount(),
|
||||
cpu.GetJitFallbackInstructionCount(), cpu.GetRegister(15), false);
|
||||
}
|
||||
const double ms =
|
||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
||||
Check(cpu.GetRegister(0) == 0x12345678 && cpu.GetRegister(1) == 0x87654321);
|
||||
Check(cpu.GetExecutedInstructions() == slices * 4096);
|
||||
Trace::Marker("ARMWorkEnd");
|
||||
Trace::Marker("KnownSleepBegin");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(750));
|
||||
Trace::Marker("KnownSleepEnd");
|
||||
{
|
||||
Trace::Marker("SyntheticJournalBegin");
|
||||
NANDJournal journal;
|
||||
NANDJournal::Pages pages;
|
||||
Common::SHA1::Digest synthetic_identity{};
|
||||
std::string error;
|
||||
Check(journal.Open(journal_path, synthetic_identity, &pages, &error));
|
||||
for (u32 i = 0; i < 128; ++i)
|
||||
{
|
||||
Trace::SampleRoot root(true);
|
||||
NANDJournal::Pages update;
|
||||
update[i].fill(static_cast<u8>(i));
|
||||
Check(journal.Append(update));
|
||||
}
|
||||
Trace::Marker("SyntheticJournalEnd");
|
||||
}
|
||||
{
|
||||
NANDJournal verifier;
|
||||
NANDJournal::Pages pages;
|
||||
Common::SHA1::Digest synthetic_identity{};
|
||||
std::string error;
|
||||
Check(verifier.Open(journal_path, synthetic_identity, &pages, &error));
|
||||
Check(pages.size() == 128);
|
||||
for (u32 i = 0; i < 128; ++i)
|
||||
for (u8 value : pages.at(i))
|
||||
Check(value == static_cast<u8>(i));
|
||||
}
|
||||
LocalNAT(std::filesystem::absolute(argv[2]).string());
|
||||
cpu.Reset();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
|
||||
Trace::Snapshot(cpu.GetTraceGeneration(), cpu.GetExecutedInstructions(),
|
||||
cpu.GetJitNativeExecutedInstructions(), cpu.GetJitSlowReadCount(),
|
||||
cpu.GetJitSlowWriteCount(), cpu.GetJitLifetimeCompiledBlockCount(),
|
||||
cpu.GetJitFallbackInstructionCount(), cpu.GetRegister(15), false);
|
||||
Trace::Marker("ProbeEnd");
|
||||
const bool trace_enabled = Trace::Enabled();
|
||||
Check(private_trace.Close());
|
||||
std::printf("P1Probe exact=OK trace-requested=%d trace-enabled=%d "
|
||||
"arm-ms=%.3f instructions=%llu journal-pages=128 nat-arp-replies=32\n",
|
||||
Trace::Requested(), trace_enabled, ms, slices * 4096);
|
||||
return 0;
|
||||
}
|
||||
catch (const std::exception& error)
|
||||
{
|
||||
std::fprintf(stderr, "%s\n", error.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Offline P1 event analysis. Unknowns fail qualification rather than becoming zero cost."""
|
||||
import argparse
|
||||
from bisect import bisect_left
|
||||
from collections import Counter, defaultdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
|
||||
REGIONS = ("Slice", "ARM", "Jit", "Compile", "Devices", "NAND", "Journal",
|
||||
"JournalBuild", "JournalWrite", "JournalFlush", "NAT", "DMARead", "DMAWrite", "AES", "SHA")
|
||||
|
||||
|
||||
class CodeMap:
|
||||
def __init__(self):
|
||||
self.ranges = []
|
||||
|
||||
def event(self, data):
|
||||
begin, end = int(data["Begin"]), int(data["End"])
|
||||
if not begin or end <= begin:
|
||||
raise ValueError("Invalid generated-code interval")
|
||||
# Reuse replaces overlapping mappings at this timestamp. Never use the final
|
||||
# mapping to interpret samples collected before an invalidation/recompilation.
|
||||
kept = []
|
||||
for lo, hi, domain, pc in self.ranges:
|
||||
if hi <= begin or lo >= end:
|
||||
kept.append((lo, hi, domain, pc))
|
||||
else:
|
||||
if lo < begin:
|
||||
kept.append((lo, begin, domain, pc))
|
||||
if hi > end:
|
||||
kept.append((end, hi, domain, pc))
|
||||
if data["Kind"] != "invalidate":
|
||||
kept.append((begin, end, data["Domain"], data["GuestPC"]))
|
||||
self.ranges = kept
|
||||
|
||||
def lookup(self, ip):
|
||||
for lo, hi, domain, pc in reversed(self.ranges):
|
||||
if lo <= ip < hi:
|
||||
return domain, pc
|
||||
return "UnresolvedNative", None
|
||||
|
||||
|
||||
class SampledCodeMap:
|
||||
"""Time-ordered mappings only for host PCs actually sampled by ETW.
|
||||
|
||||
Native updates can emit hundreds of thousands of JIT map events. Keeping
|
||||
split intervals and scanning them for every sample makes offline analysis
|
||||
quadratic. The report only needs the finite set of sampled host addresses.
|
||||
"""
|
||||
|
||||
def __init__(self, sampled_addresses):
|
||||
self.addresses = sorted(set(sampled_addresses))
|
||||
self.mappings = {}
|
||||
|
||||
def event(self, data):
|
||||
begin, end = int(data["Begin"]), int(data["End"])
|
||||
if not begin or end <= begin:
|
||||
raise ValueError("Invalid generated-code interval")
|
||||
lo = bisect_left(self.addresses, begin)
|
||||
hi = bisect_left(self.addresses, end)
|
||||
if data["Kind"] == "invalidate":
|
||||
for address in self.addresses[lo:hi]:
|
||||
self.mappings.pop(address, None)
|
||||
else:
|
||||
value = (data["Domain"], data["GuestPC"])
|
||||
for address in self.addresses[lo:hi]:
|
||||
self.mappings[address] = value
|
||||
|
||||
def lookup(self, ip):
|
||||
return self.mappings.get(ip, ("UnresolvedNative", None))
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
if not values:
|
||||
return None
|
||||
values = sorted(values)
|
||||
position = (len(values) - 1) * fraction
|
||||
low = int(position)
|
||||
high = min(low + 1, len(values) - 1)
|
||||
return values[low] + (values[high] - values[low]) * (position - low)
|
||||
|
||||
|
||||
def read_relevant_events(path, pid):
|
||||
# ETW is host-wide. Keep this process's telemetry and all its CPU samples;
|
||||
# rendering/network threads can be hot without emitting native telemetry.
|
||||
native = []
|
||||
with path.open(encoding="utf-8") as source:
|
||||
for line in source:
|
||||
e = json.loads(line)
|
||||
if e["native"] and e["pid"] == pid:
|
||||
native.append(e)
|
||||
if not native:
|
||||
raise ValueError("No native events for requested process")
|
||||
native_tids = {e["tid"] for e in native}
|
||||
begin, end = min(e["qpc"] for e in native), max(e["qpc"] for e in native)
|
||||
rows = native
|
||||
with path.open(encoding="utf-8") as source:
|
||||
for line in source:
|
||||
e = json.loads(line)
|
||||
if e["native"] or not begin <= e["qpc"] <= end:
|
||||
continue
|
||||
if e["event"] == "SampledProfile" and e["pid"] == pid:
|
||||
rows.append(e)
|
||||
sampled_tids = {e["data"].get("ThreadId", e["tid"]) for e in rows
|
||||
if not e["native"] and e["event"] == "SampledProfile"}
|
||||
process_tids = native_tids | sampled_tids
|
||||
with path.open(encoding="utf-8") as source:
|
||||
for line in source:
|
||||
e = json.loads(line)
|
||||
if e["native"] or e["event"] != "CSwitch" or not begin <= e["qpc"] <= end:
|
||||
continue
|
||||
d = e["data"]
|
||||
if d.get("OldThreadId") in process_tids or d.get("NewThreadId") in process_tids:
|
||||
rows.append(e)
|
||||
return rows
|
||||
|
||||
|
||||
def analyze(rows, pid):
|
||||
rows = sorted(rows, key=lambda event: event["qpc"])
|
||||
native = [e for e in rows if e["native"] and e["pid"] == pid]
|
||||
if not native:
|
||||
raise ValueError("No native events for requested process")
|
||||
if any(e["data"].get("Schema") != 1 for e in native):
|
||||
raise ValueError("Unknown or missing native schema")
|
||||
native_tids = {e["tid"] for e in native}
|
||||
sampled_tids = {e["data"].get("ThreadId", e["tid"]) for e in rows
|
||||
if not e["native"] and e["event"] == "SampledProfile" and e["pid"] == pid}
|
||||
tids = native_tids | sampled_tids
|
||||
frequencies = {e["data"]["QPCFrequency"] for e in native if "QPCFrequency" in e["data"]}
|
||||
if len(frequencies) != 1 or not next(iter(frequencies)):
|
||||
raise ValueError("Missing or inconsistent QPC frequency")
|
||||
frequency = next(iter(frequencies))
|
||||
mappings = SampledCodeMap(e["data"]["InstructionPointer"] for e in rows
|
||||
if not e["native"] and "InstructionPointer" in e["data"])
|
||||
samples = Counter()
|
||||
hot_pc = Counter()
|
||||
hot_static = Counter()
|
||||
phases = {}
|
||||
scope_samples = defaultdict(list)
|
||||
frames = defaultdict(list)
|
||||
counter_last = {}
|
||||
counter_errors = []
|
||||
snapshots = []
|
||||
cswitches = []
|
||||
queues = []
|
||||
accounting_errors = 0
|
||||
for e in rows:
|
||||
data = e["data"]
|
||||
if e["native"] and e["pid"] == pid:
|
||||
if e["event"] == "CodeMap":
|
||||
mappings.event(data)
|
||||
elif e["event"] == "Marker":
|
||||
phases[data["Name"]] = e["qpc"]
|
||||
if data["Name"] == "PerformanceMetricsReset":
|
||||
# Keep intervals per generation, never bridge a reset/reboot.
|
||||
for events in frames.values():
|
||||
events.append(None)
|
||||
elif e["event"] == "ScopeSample":
|
||||
accounting_errors += data["AccountingErrors"]
|
||||
scope_samples[(e["tid"], data["Sample"])].append(data)
|
||||
elif e["event"] == "Frame":
|
||||
frames[data["Kind"]].append((data["QPC"], data))
|
||||
elif e["event"] == "Counter":
|
||||
key = (e["tid"], data["Counter"])
|
||||
if data["Value"] < counter_last.get(key, 0):
|
||||
counter_errors.append({"tid": e["tid"], "counter": data["Counter"], "qpc": e["qpc"]})
|
||||
counter_last[key] = data["Value"]
|
||||
elif e["event"] == "Snapshot":
|
||||
snapshots.append(data)
|
||||
elif e["event"] == "USBQueue":
|
||||
queues.append(data)
|
||||
elif not e["native"]:
|
||||
tid = data.get("ThreadId", data.get("ThreadID", e["tid"]))
|
||||
if "InstructionPointer" in data and tid in tids:
|
||||
domain, pc = mappings.lookup(data["InstructionPointer"])
|
||||
weight = data.get("Count", 1)
|
||||
if domain == "UnresolvedNative" and "!" in data.get("Symbol", ""):
|
||||
symbol = data["Symbol"]
|
||||
module = symbol.split("!", 1)[0].strip('"').lower()
|
||||
if module in ("traceprobep1.exe", "dolphinnogui.exe", "dolphin.exe"):
|
||||
domain = "HostC++"
|
||||
elif module and module != "unknown":
|
||||
domain = "SystemOrLibrary"
|
||||
if domain != "UnresolvedNative":
|
||||
hot_static[symbol] += weight
|
||||
samples[domain] += weight
|
||||
if pc is not None:
|
||||
hot_pc[(domain, pc)] += weight
|
||||
if "OldThreadId" in data and (data["OldThreadId"] in tids or data.get("NewThreadId") in tids):
|
||||
cswitches.append(e)
|
||||
timing = defaultdict(lambda: {"calls": 0, "inclusive_ms": 0., "exclusive_ms": 0.})
|
||||
for group in scope_samples.values():
|
||||
roots = [d for d in group if d["Region"] == 0]
|
||||
if len(roots) != 1 or sum(d["ExclusiveQPC"] for d in group) != roots[0]["InclusiveQPC"]:
|
||||
accounting_errors += 1
|
||||
for data in group:
|
||||
region = REGIONS[data["Region"]] + "/sample-1-in-" + str(data["SamplingDenominator"])
|
||||
# Actual sampled time only. Do not present extrapolated duration as exact CPU.
|
||||
stat = timing[region]
|
||||
stat["calls"] += data["Calls"]
|
||||
stat["inclusive_ms"] += 1000 * data["InclusiveQPC"] / frequency
|
||||
stat["exclusive_ms"] += 1000 * data["ExclusiveQPC"] / frequency
|
||||
snapshot_errors = []
|
||||
previous = {}
|
||||
for data in snapshots:
|
||||
key = data["CoreId"]
|
||||
if key in previous:
|
||||
for field in ("Instructions", "NativeInstructions", "SlowReads", "SlowWrites", "Compiles", "Fallbacks"):
|
||||
if data[field] < previous[key][field]:
|
||||
snapshot_errors.append({"generation": key, "field": field})
|
||||
previous[key] = data
|
||||
durations = {}
|
||||
for kind, events in frames.items():
|
||||
deltas = [(b[0] - a[0]) * 1000 / frequency for a, b in zip(events, events[1:]) if a and b]
|
||||
durations[kind] = {"intervals": len(deltas), "p50_ms": percentile(deltas, .5),
|
||||
"p95_ms": percentile(deltas, .95), "p99_ms": percentile(deltas, .99)}
|
||||
sleep = None
|
||||
if "KnownSleepBegin" in phases and "KnownSleepEnd" in phases:
|
||||
begin, end = phases["KnownSleepBegin"], phases["KnownSleepEnd"]
|
||||
changes = [e for e in cswitches if begin <= e["qpc"] <= end]
|
||||
waits = [e for e in changes if e["data"]["OldThreadId"] in tids and e["data"].get("OldThreadState") == 5]
|
||||
sleep = {"wall_ms": (end - begin) * 1000 / frequency,
|
||||
"cswitch_events": len(changes), "waiting_switches": len(waits),
|
||||
"wait_evidence_present": bool(waits)}
|
||||
return {"schema": 1, "pid": pid, "qpc_frequency": frequency,
|
||||
"native_events": len(native), "native_threads": sorted(native_tids),
|
||||
"sampled_process_threads": sorted(sampled_tids),
|
||||
"cpu_leaf_samples": dict(samples),
|
||||
"hot_generated_pcs": [{"domain": domain, "pc": hex(pc), "samples": count}
|
||||
for (domain, pc), count in hot_pc.most_common(20)],
|
||||
"hot_static_symbols": [{"symbol": symbol, "samples": count}
|
||||
for symbol, count in hot_static.most_common(20)],
|
||||
"sampled_scope_wall_times_not_cpu": dict(timing), "scope_samples": len(scope_samples),
|
||||
"accounting_errors": accounting_errors, "counter_regressions": counter_errors,
|
||||
"snapshot_regressions": snapshot_errors, "core_generations": sorted(previous),
|
||||
"last_counters_by_thread": [{"tid": tid, "counter": counter, "value": value}
|
||||
for (tid, counter), value in sorted(counter_last.items())],
|
||||
"usb_queue_events": len(queues),
|
||||
"usb_queue_peak": max((q["PeakFrames"] for q in queues), default=None),
|
||||
"frame_intervals": durations, "known_sleep": sleep, "markers": phases,
|
||||
"native_update_qualified": False,
|
||||
"remaining_gates": ["Resolve static C++/system stacks and critical-path waits in ETL",
|
||||
"Qualify PPC and check local NAT counters", "Check ETW losses and full collector overhead"],
|
||||
"note": "Partial offline qualification, not a native update bottleneck report. CPU leaf samples include all sampled threads of the target process within native telemetry bounds. Leaf sample counts are not exact CPU durations. Scopes are sampled wall times, not CPU. Never compare differently sampled totals."}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("events", type=Path)
|
||||
parser.add_argument("--pid", required=True, type=int)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
result = analyze(read_relevant_events(args.events, args.pid), args.pid)
|
||||
with args.output.open("x", encoding="utf-8") as output:
|
||||
json.dump(result, output, indent=2)
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Compare alternating fixed-work local runs without claiming a native gain."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
|
||||
|
||||
def load(directory):
|
||||
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8-sig"))
|
||||
log = (directory / "probe.stdout.log").read_text(encoding="utf-8-sig")
|
||||
match = re.search(r"arm-ms=([0-9.]+) instructions=(\d+) journal-pages=128 nat-arp-replies=32", log)
|
||||
if not match or manifest["ProbeExitCode"] != 0 or int(match.group(2)) != 655360000:
|
||||
raise ValueError(f"Probe did not finish the exact work: {directory}")
|
||||
collection = manifest.get("Collection", "WPRSystemAndNative" if "Profile" in manifest else None)
|
||||
requested = collection != "NoTrace"
|
||||
if f"trace-requested={int(requested)} trace-enabled={int(requested)}" not in log:
|
||||
raise ValueError(f"Probe trace state does not match collection: {directory}")
|
||||
if collection == "WPRSystemAndNative":
|
||||
decoder = json.loads((directory / "decoder-summary.json").read_text(encoding="utf-8-sig"))
|
||||
if any(decoder.get(key) != 0 for key in
|
||||
("status", "native_decode_errors", "events_lost", "buffers_lost")):
|
||||
raise ValueError(f"WPR trace has decode errors or lost events: {directory}")
|
||||
return {"directory": str(directory), "collection": collection,
|
||||
"hash": manifest["ProbeSHA256"], "pdb_hash": manifest["ProbePDBSHA256"],
|
||||
"affinity_mask": manifest.get("ProbeAffinityMask", 0),
|
||||
"observed_affinity_mask": manifest.get("ProbeObservedAffinityMask"),
|
||||
"arm_ms": float(match.group(1)), "wall_ms": 1000 * manifest["ProbeWallSeconds"],
|
||||
"cpu_ms": 1000 * manifest["ProbeCPUSeconds"]}
|
||||
|
||||
|
||||
def upper_95(values):
|
||||
# Two-sided Student 95% upper bound; report but never equate with a
|
||||
# guarantee for a different (native Wii) workload.
|
||||
critical = {4: 3.182, 5: 2.776, 6: 2.571, 7: 2.447, 8: 2.365}
|
||||
return statistics.mean(values) + critical[len(values)] * statistics.stdev(values) / math.sqrt(len(values))
|
||||
|
||||
|
||||
def compare(directories):
|
||||
runs = [load(path) for path in directories]
|
||||
on_mode = runs[1]["collection"] if len(runs) >= 2 else None
|
||||
if (on_mode not in ("PrivateUserMode", "WPRSystemAndNative") or
|
||||
len(runs) < 4 or len(runs) > 16 or len(runs) % 2 or
|
||||
any(r["collection"] != kind for r, kind in
|
||||
zip(runs, ("NoTrace", on_mode) * (len(runs) // 2)))):
|
||||
raise ValueError("Provide at least two OFF/ON pairs in alternating order")
|
||||
if len({(r["hash"], r["pdb_hash"]) for r in runs}) != 1:
|
||||
raise ValueError("The compared executables or PDBs differ")
|
||||
if len({r["affinity_mask"] for r in runs}) != 1 or any(
|
||||
r["affinity_mask"] and r["observed_affinity_mask"] != r["affinity_mask"]
|
||||
for r in runs):
|
||||
raise ValueError("Probe CPU affinity differs across compared runs")
|
||||
pairs = []
|
||||
for off, on in zip(runs[::2], runs[1::2]):
|
||||
pairs.append({"off": off["directory"], "on": on["directory"],
|
||||
"arm_phase_percent": 100 * (on["arm_ms"] / off["arm_ms"] - 1),
|
||||
"whole_probe_percent": 100 * (on["wall_ms"] / off["wall_ms"] - 1),
|
||||
"cpu_percent": 100 * (on["cpu_ms"] / off["cpu_ms"] - 1),
|
||||
"non_arm_wall_delta_ms": (on["wall_ms"] - on["arm_ms"]) -
|
||||
(off["wall_ms"] - off["arm_ms"])})
|
||||
arm_differences = [pair["arm_phase_percent"] for pair in pairs]
|
||||
wall_differences = [pair["whole_probe_percent"] for pair in pairs]
|
||||
cpu_differences = [pair["cpu_percent"] for pair in pairs]
|
||||
baseline_range = 100 * (max(r["arm_ms"] for r in runs[::2]) /
|
||||
min(r["arm_ms"] for r in runs[::2]) - 1)
|
||||
wall_baseline_range = 100 * (max(r["wall_ms"] for r in runs[::2]) /
|
||||
min(r["wall_ms"] for r in runs[::2]) - 1)
|
||||
bounds = (dict(arm_phase_percent=upper_95(arm_differences),
|
||||
whole_probe_percent=upper_95(wall_differences),
|
||||
cpu_percent=upper_95(cpu_differences)) if len(pairs) >= 4 else None)
|
||||
qualified = (on_mode == "WPRSystemAndNative" and bounds is not None and
|
||||
baseline_range <= 2 and wall_baseline_range <= 2 and
|
||||
all(value <= 2 for value in bounds.values()))
|
||||
return {"schema": 1, "on_collection": on_mode, "runs": runs, "pairs": pairs,
|
||||
"mean_arm_phase_percent": statistics.mean(arm_differences),
|
||||
"off_arm_phase_range_percent": baseline_range,
|
||||
"off_wall_range_percent": wall_baseline_range,
|
||||
"upper_95_percent": bounds,
|
||||
"mean_non_arm_wall_delta_ms": statistics.mean(p["non_arm_wall_delta_ms"] for p in pairs),
|
||||
"two_percent_budget_qualified": qualified,
|
||||
"note": "Fixed-work local collector cost only, not a native Wii speed estimate. Four or more OFF/ON pairs, low baseline spread, and upper bounds below 2% are required for this conservative local gate."}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("captures", nargs="+", type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
result = compare(args.captures)
|
||||
with args.output.open("x", encoding="utf-8") as target:
|
||||
json.dump(result, target, indent=2)
|
||||
print(json.dumps({key: result[key] for key in
|
||||
("on_collection", "pairs", "mean_arm_phase_percent",
|
||||
"off_arm_phase_range_percent", "off_wall_range_percent",
|
||||
"upper_95_percent", "mean_non_arm_wall_delta_ms",
|
||||
"two_percent_budget_qualified")}, indent=2))
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Check a local P1 trace against the probe's known work; never qualify a Wii run."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from analyze_p1 import analyze, read_relevant_events
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest().upper()
|
||||
|
||||
|
||||
def qualify(directory, events_path=None, xperf_summary_path=None):
|
||||
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8-sig"))
|
||||
decoder = json.loads((directory / "decoder-summary.json").read_text(encoding="utf-8-sig"))
|
||||
source = events_path or directory / "events.jsonl"
|
||||
report = analyze(read_relevant_events(source, manifest["ProbePID"]),
|
||||
manifest["ProbePID"])
|
||||
log = (directory / "probe.stdout.log").read_text(encoding="utf-8-sig")
|
||||
events = read_relevant_events(source, manifest["ProbePID"])
|
||||
xperf_path = xperf_summary_path or directory / "xperf-summary.json"
|
||||
xperf = json.loads(xperf_path.read_text(encoding="utf-8-sig")) if xperf_path.exists() else {}
|
||||
counters = {(c["tid"], c["counter"]): c["value"]
|
||||
for c in report["last_counters_by_thread"]}
|
||||
# The local probe has one worker thread. Values are cumulative and exact.
|
||||
worker = next(e["tid"] for e in events if e["native"] and e["event"] == "Marker"
|
||||
and e["data"].get("Name") == "ProbeBegin")
|
||||
checks = {
|
||||
"probe_exit_success": manifest.get("ProbeExitCode") == 0,
|
||||
"probe_self_checks": "P1Probe exact=OK" in log and "journal-pages=128 nat-arp-replies=32" in log,
|
||||
"trace_requested_and_enabled": "trace-requested=1 trace-enabled=1" in log,
|
||||
"probe_hash_unchanged": sha256(Path(manifest["Probe"])) == manifest["ProbeSHA256"],
|
||||
"probe_pdb_present_and_unchanged": bool(manifest.get("ProbePDBSHA256")) and
|
||||
sha256(Path(manifest["ProbePDB"])) == manifest["ProbePDBSHA256"],
|
||||
"etl_decoded_without_loss": decoder.get("status") == 0
|
||||
and decoder.get("events_lost") == 0 and decoder.get("buffers_lost") == 0
|
||||
and decoder.get("native_decode_errors") == 0,
|
||||
"qpc_frequency_agrees": decoder.get("qpc_frequency") == report["qpc_frequency"],
|
||||
"arm_jit_mapping": any(e["native"] and e["event"] == "CodeMap"
|
||||
and e["data"].get("Domain") == "ARM"
|
||||
and e["data"].get("Kind") == "thumb" for e in events),
|
||||
"nested_scope_consistency": report["scope_samples"] >= 128 and
|
||||
report["accounting_errors"] == 0,
|
||||
"no_counter_or_snapshot_regression": not report["counter_regressions"] and
|
||||
not report["snapshot_regressions"],
|
||||
"reset_distinct_generation": len(report["core_generations"]) >= 2,
|
||||
"journal_exact": counters.get((worker, 0)) == 128 and
|
||||
counters.get((worker, 1)) == 128 * (12 + 4 + 0x840 + 20) and
|
||||
counters.get((worker, 2)) == 0,
|
||||
"local_nat_exact": counters.get((worker, 3)) == 32 and
|
||||
counters.get((worker, 5)) == 32 and
|
||||
counters.get((worker, 7)) == 0 and
|
||||
counters.get((worker, 8)) == 32 and
|
||||
counters.get((worker, 10)) == 0 and
|
||||
report["usb_queue_events"] >= 64,
|
||||
}
|
||||
system_checks = {
|
||||
"arm_cpu_sample": report["cpu_leaf_samples"].get("ARM", 0) > 0,
|
||||
"known_sleep_classified_waiting": bool(report["known_sleep"] and
|
||||
report["known_sleep"]["wait_evidence_present"]),
|
||||
"xperf_clock_and_target_events": xperf.get("anchors", 0) >= 2 and
|
||||
xperf.get("max_anchor_error_qpc", float("inf")) <=
|
||||
report["qpc_frequency"] / 100_000 and
|
||||
xperf.get("xperf_invalid_target_events") == 0,
|
||||
}
|
||||
return {
|
||||
"schema": 1, "local_provider_passed": all(checks.values()),
|
||||
"full_collector_passed": all(checks.values()) and all(system_checks.values()),
|
||||
"native_update_ready": False, "provider_checks": checks,
|
||||
"system_checks": system_checks,
|
||||
"remaining": ["Qualify real PPC JIT workload and native C++ stacks",
|
||||
"Quantify full collector overhead on alternating fixed-work runs",
|
||||
"Validate CPU domain coverage and critical path on a native trace"],
|
||||
"analysis": report,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("capture", type=Path)
|
||||
parser.add_argument("--events", type=Path,
|
||||
help="Combined native and xperf JSONL; defaults to capture/events.jsonl")
|
||||
parser.add_argument("--xperf-summary", type=Path,
|
||||
help="Summary from xperf_to_p1.py for the same combined events")
|
||||
parser.add_argument("--output", type=Path,
|
||||
help="New qualification JSON path; defaults to capture/qualification.json")
|
||||
args = parser.parse_args()
|
||||
result = qualify(args.capture, args.events, args.xperf_summary)
|
||||
output = args.output or args.capture / "qualification.json"
|
||||
with output.open("x", encoding="utf-8") as target:
|
||||
json.dump(result, target, indent=2)
|
||||
print(json.dumps({"local_provider_passed": result["local_provider_passed"],
|
||||
"full_collector_passed": result["full_collector_passed"],
|
||||
"provider_checks": result["provider_checks"],
|
||||
"system_checks": result["system_checks"], "output": str(output)}, indent=2))
|
||||
raise SystemExit(0 if result["local_provider_passed"] else 1)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Summarize CPU leaf samples per thread from an already-aligned P1 capture.
|
||||
|
||||
These are sample counts, not exact CPU times or a critical-path attribution.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from analyze_p1 import percentile
|
||||
|
||||
|
||||
def summarize(path, pid):
|
||||
by_thread = defaultdict(Counter)
|
||||
processors = defaultdict(Counter)
|
||||
sample_begin = None
|
||||
sample_end = None
|
||||
with path.open(encoding="utf-8") as source:
|
||||
for line in source:
|
||||
event = json.loads(line)
|
||||
if event["native"] or event["pid"] != pid or event["event"] != "SampledProfile":
|
||||
continue
|
||||
data = event["data"]
|
||||
tid = int(data["ThreadId"])
|
||||
weight = int(data.get("Count", 1))
|
||||
by_thread[tid][data.get("Symbol", "") or "<unresolved>"] += weight
|
||||
processors[tid][int(data["Processor"])] += weight
|
||||
sample_begin = event["qpc"] if sample_begin is None else min(sample_begin, event["qpc"])
|
||||
sample_end = event["qpc"] if sample_end is None else max(sample_end, event["qpc"])
|
||||
if sample_begin is None:
|
||||
raise ValueError("No target CPU samples in the selected window")
|
||||
frequency = None
|
||||
previous_frame = {}
|
||||
frame_deltas = defaultdict(list)
|
||||
snapshots = {}
|
||||
with path.open(encoding="utf-8") as source:
|
||||
for line in source:
|
||||
event = json.loads(line)
|
||||
if not event["native"] or event["pid"] != pid:
|
||||
continue
|
||||
qpc = event["qpc"]
|
||||
if not sample_begin <= qpc <= sample_end:
|
||||
continue
|
||||
data = event["data"]
|
||||
if event["event"] == "Marker" and data["Name"] == "PerformanceMetricsReset":
|
||||
previous_frame.clear()
|
||||
elif event["event"] == "Frame":
|
||||
if frequency is None:
|
||||
frequency = int(data["QPCFrequency"])
|
||||
elif frequency != int(data["QPCFrequency"]):
|
||||
raise ValueError("Inconsistent frame QPC frequency")
|
||||
kind = data["Kind"]
|
||||
if kind in previous_frame:
|
||||
frame_deltas[kind].append(1000 * (qpc - previous_frame[kind]) / frequency)
|
||||
previous_frame[kind] = qpc
|
||||
elif event["event"] == "Snapshot":
|
||||
core = int(data["CoreId"])
|
||||
if core not in snapshots:
|
||||
snapshots[core] = [data, data]
|
||||
else:
|
||||
snapshots[core][1] = data
|
||||
frames = {kind: {"intervals": len(deltas), "p50_ms": percentile(deltas, .5),
|
||||
"p95_ms": percentile(deltas, .95), "p99_ms": percentile(deltas, .99)}
|
||||
for kind, deltas in frame_deltas.items()}
|
||||
return {
|
||||
"pid": pid,
|
||||
"sample_count": sum(sum(symbols.values()) for symbols in by_thread.values()),
|
||||
"sample_window_qpc": {"begin": sample_begin, "end": sample_end,
|
||||
"duration_seconds": (sample_end - sample_begin) / frequency
|
||||
if frequency else None},
|
||||
"frame_intervals_in_sample_window": frames,
|
||||
"snapshot_deltas_in_sample_window": {
|
||||
str(core): {field: last[field] - first[field]
|
||||
for field in ("Instructions", "NativeInstructions", "SlowReads",
|
||||
"SlowWrites", "Compiles", "Fallbacks")}
|
||||
for core, (first, last) in snapshots.items()
|
||||
},
|
||||
"note": "Leaf sample counts and host frame intervals within the same selected window; no exact CPU duration or critical-path claim.",
|
||||
"threads": [
|
||||
{"tid": tid, "samples": sum(symbols.values()),
|
||||
"processors": dict(sorted(processors[tid].items())),
|
||||
"top_symbols": [{"symbol": symbol, "samples": count}
|
||||
for symbol, count in symbols.most_common(12)]}
|
||||
for tid, symbols in sorted(by_thread.items(),
|
||||
key=lambda item: -sum(item[1].values()))
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("events", type=Path)
|
||||
parser.add_argument("--pid", required=True, type=int)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
result = summarize(args.events, args.pid)
|
||||
with args.output.open("x", encoding="utf-8") as target:
|
||||
json.dump(result, target, indent=2)
|
||||
print(f"{result['sample_count']} CPU samples from {len(result['threads'])} threads")
|
||||
@@ -0,0 +1,195 @@
|
||||
import unittest
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from analyze_p1 import CodeMap, SampledCodeMap, analyze, percentile, read_relevant_events
|
||||
from xperf_to_p1 import convert, parse_pid
|
||||
|
||||
|
||||
class AnalysisTests(unittest.TestCase):
|
||||
def test_temporal_address_reuse(self):
|
||||
m = CodeMap()
|
||||
def event(begin, end, kind, pc=0):
|
||||
m.event(dict(Begin=begin, End=end, Kind=kind, GuestPC=pc, Domain="ARM"))
|
||||
event(100, 200, "thumb", 4)
|
||||
self.assertEqual(m.lookup(125), ("ARM", 4))
|
||||
event(120, 180, "invalidate")
|
||||
self.assertEqual(m.lookup(125)[0], "UnresolvedNative")
|
||||
self.assertEqual(m.lookup(190), ("ARM", 4))
|
||||
event(120, 180, "thumb", 8)
|
||||
self.assertEqual(m.lookup(125), ("ARM", 8))
|
||||
m.event(dict(Begin=100, End=200, Kind="block-near", GuestPC=0x8000, Domain="PPC"))
|
||||
self.assertEqual(m.lookup(125), ("PPC", 0x8000))
|
||||
self.assertEqual(m.lookup(190), ("PPC", 0x8000))
|
||||
|
||||
def test_invalid_range(self):
|
||||
with self.assertRaises(ValueError):
|
||||
CodeMap().event(dict(Begin=10, End=9))
|
||||
|
||||
def test_sampled_map_preserves_temporal_overlap_semantics(self):
|
||||
sampled = SampledCodeMap([125, 190, 250])
|
||||
sampled.event(dict(Begin=100, End=200, Kind="thumb", Domain="ARM", GuestPC=4))
|
||||
self.assertEqual(sampled.lookup(125), ("ARM", 4))
|
||||
sampled.event(dict(Begin=120, End=180, Kind="invalidate", Domain="ARM", GuestPC=0))
|
||||
self.assertEqual(sampled.lookup(125), ("UnresolvedNative", None))
|
||||
self.assertEqual(sampled.lookup(190), ("ARM", 4))
|
||||
sampled.event(dict(Begin=120, End=180, Kind="thumb", Domain="ARM", GuestPC=8))
|
||||
self.assertEqual(sampled.lookup(125), ("ARM", 8))
|
||||
self.assertEqual(sampled.lookup(250), ("UnresolvedNative", None))
|
||||
|
||||
def test_percentiles_are_frame_intervals(self):
|
||||
self.assertEqual(percentile([10, 20, 30], .5), 20)
|
||||
self.assertIsNone(percentile([], .5))
|
||||
|
||||
def test_missing_telemetry_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
analyze([], 12)
|
||||
|
||||
def test_unknown_schema_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
analyze([dict(native=True, pid=12, tid=1, qpc=0, data=dict(Schema=99))], 12)
|
||||
|
||||
@staticmethod
|
||||
def native(event, qpc, **data):
|
||||
return dict(native=True, pid=12, tid=1, qpc=qpc, event=event, data=dict(Schema=1, **data))
|
||||
|
||||
def test_exclusive_accounting_does_not_sum_inclusive_regions(self):
|
||||
rows = [self.native("Marker", 0, QPCFrequency=1000, Name="ProbeBegin")]
|
||||
for region, inclusive, exclusive in ((0, 100, 20), (1, 80, 50), (6, 30, 30)):
|
||||
rows.append(self.native("ScopeSample", 100, Region=region, Sample=1,
|
||||
SamplingDenominator=1, Calls=1, AccountingErrors=0,
|
||||
InclusiveQPC=inclusive, ExclusiveQPC=exclusive))
|
||||
result = analyze(rows, 12)
|
||||
self.assertEqual(result["accounting_errors"], 0)
|
||||
rows[-1]["data"]["ExclusiveQPC"] = 31
|
||||
self.assertEqual(analyze(rows, 12)["accounting_errors"], 1)
|
||||
|
||||
def test_frames_do_not_bridge_reset(self):
|
||||
rows = [self.native("Marker", 0, QPCFrequency=1000, Name="ProbeBegin")]
|
||||
for t in (10, 20):
|
||||
rows.append(self.native("Frame", t, QPC=t, Kind="produced"))
|
||||
rows.append(self.native("Marker", 100, Name="PerformanceMetricsReset"))
|
||||
for t in (1000, 1010):
|
||||
rows.append(self.native("Frame", t, QPC=t, Kind="produced"))
|
||||
self.assertEqual(analyze(rows, 12)["frame_intervals"]["produced"],
|
||||
dict(intervals=2, p50_ms=10, p95_ms=10, p99_ms=10))
|
||||
|
||||
def test_counter_regression_fails_even_with_good_scopes(self):
|
||||
rows = [self.native("Marker", 0, QPCFrequency=1000, Name="ProbeBegin"),
|
||||
self.native("Counter", 1, Counter=0, Value=5),
|
||||
self.native("Counter", 2, Counter=0, Value=4)]
|
||||
self.assertEqual(len(analyze(rows, 12)["counter_regressions"]), 1)
|
||||
|
||||
def test_kernel_sample_and_known_sleep_are_separate_evidence(self):
|
||||
rows = [self.native("Marker", 0, QPCFrequency=1000, Name="ProbeBegin"),
|
||||
self.native("CodeMap", 1, Begin=100, End=200, Domain="ARM",
|
||||
GuestPC=4, Kind="thumb"),
|
||||
dict(native=False, pid=0, tid=0, qpc=10, event="SampledProfile",
|
||||
data=dict(ThreadId=1, InstructionPointer=125)),
|
||||
self.native("Marker", 20, Name="KnownSleepBegin"),
|
||||
dict(native=False, pid=0, tid=0, qpc=30, event="CSwitch",
|
||||
data=dict(OldThreadId=1, NewThreadId=2, OldThreadState=5)),
|
||||
self.native("Marker", 800, Name="KnownSleepEnd")]
|
||||
result = analyze(rows, 12)
|
||||
self.assertEqual(result["cpu_leaf_samples"], {"ARM": 1})
|
||||
self.assertEqual(result["hot_generated_pcs"][0]["pc"], "0x4")
|
||||
self.assertTrue(result["known_sleep"]["wait_evidence_present"])
|
||||
self.assertEqual(result["known_sleep"]["wall_ms"], 780)
|
||||
|
||||
def test_jit_sample_after_invalidation_is_not_attributed_to_old_block(self):
|
||||
rows = [self.native("Marker", 0, QPCFrequency=1000, Name="ProbeBegin"),
|
||||
self.native("CodeMap", 1, Begin=100, End=200, Domain="ARM",
|
||||
GuestPC=4, Kind="thumb"),
|
||||
dict(native=False, pid=0, tid=1, qpc=2, event="SampledProfile",
|
||||
data=dict(InstructionPointer=125)),
|
||||
self.native("CodeMap", 3, Begin=100, End=200, Domain="ARM",
|
||||
GuestPC=0, Kind="invalidate"),
|
||||
dict(native=False, pid=0, tid=1, qpc=4, event="SampledProfile",
|
||||
data=dict(InstructionPointer=125))]
|
||||
self.assertEqual(analyze(rows, 12)["cpu_leaf_samples"],
|
||||
{"ARM": 1, "UnresolvedNative": 1})
|
||||
|
||||
def test_static_module_is_not_mistaken_for_unmapped_jit(self):
|
||||
rows = [self.native("Marker", 0, QPCFrequency=1000, Name="ProbeBegin"),
|
||||
dict(native=False, pid=12, tid=1, qpc=1, event="SampledProfile",
|
||||
data=dict(ThreadId=1, InstructionPointer=999, Count=3,
|
||||
Symbol="TraceProbeP1.exe!IOS::LLE::ARMCore::Read32"))]
|
||||
report = analyze(rows, 12)
|
||||
self.assertEqual(report["cpu_leaf_samples"], {"HostC++": 3})
|
||||
self.assertEqual(report["hot_static_symbols"][0]["samples"], 3)
|
||||
|
||||
def test_xperf_classic_events_align_to_native_qpc(self):
|
||||
native = [self.native("Marker", 1001000, QPCFrequency=10000000, Name="ProbeBegin"),
|
||||
self.native("CodeMap", 1001500, Begin=100, End=200, Domain="ARM",
|
||||
GuestPC=4, Kind="thumb"),
|
||||
self.native("Marker", 1011000, Name="KnownSleepBegin"),
|
||||
self.native("Marker", 1111000, Name="KnownSleepEnd")]
|
||||
rows = [["Dolphin.Performance/Marker/", "100000", "TraceProbeP1.exe (12)",
|
||||
"1"] + [""] * 8 + ["ProbeBegin"],
|
||||
["Dolphin.Performance/Marker/", "101000", "TraceProbeP1.exe (12)",
|
||||
"1"] + [""] * 8 + ["KnownSleepBegin"],
|
||||
["Dolphin.Performance/Marker/", "111000", "TraceProbeP1.exe (12)",
|
||||
"1"] + [""] * 8 + ["KnownSleepEnd"],
|
||||
["SampledProfile", "100100", "TraceProbeP1.exe (12)", "1",
|
||||
"0x7d", "0", "", "", "2", "Unbatched"],
|
||||
["CSwitch", "101100", "Idle (0)", "2", "", "", "", "",
|
||||
"TraceProbeP1.exe (12)", "1", "", "", "Waiting"]]
|
||||
merged, summary = convert(rows, native, 12)
|
||||
report = analyze(merged, 12)
|
||||
self.assertEqual(summary["max_anchor_error_qpc"], 0)
|
||||
self.assertEqual(summary["cpu_sample_count"], 2)
|
||||
self.assertEqual(summary["cpu_samples_by_processor"], {0: 2})
|
||||
self.assertEqual(report["cpu_leaf_samples"], {"ARM": 2})
|
||||
self.assertTrue(report["known_sleep"]["wait_evidence_present"])
|
||||
self.assertEqual(parse_pid("Idle ( 0)"), 0)
|
||||
|
||||
def test_xperf_snapshots_align_without_named_markers(self):
|
||||
native = [self.native("Snapshot", 1001000, QPC=1000999,
|
||||
QPCFrequency=10000000, CoreId=1),
|
||||
self.native("Snapshot", 2001000, QPC=2000999,
|
||||
QPCFrequency=10000000, CoreId=1)]
|
||||
def snapshot(us, payload_qpc):
|
||||
row = ["Dolphin.Performance/Snapshot/", str(us), "DolphinNoGUI.exe (12)",
|
||||
"1"] + [""] * 8
|
||||
row[10] = str(payload_qpc)
|
||||
return row
|
||||
rows = [snapshot(100000, 1000999), snapshot(200000, 2000999),
|
||||
["SampledProfile", "150000", "DolphinNoGUI.exe (12)", "1",
|
||||
"0x7d", "0", "", "", "2", "DolphinNoGUI.exe!ARMCore::Run"]]
|
||||
merged, summary = convert(rows, native, 12)
|
||||
self.assertEqual(summary["anchors"], 2)
|
||||
self.assertEqual(summary["max_anchor_error_qpc"], 0)
|
||||
self.assertEqual(summary["cpu_sample_count"], 2)
|
||||
self.assertEqual(merged[1]["qpc"], 1501000)
|
||||
|
||||
def test_uninstrumented_process_thread_is_counted(self):
|
||||
native = [self.native("Snapshot", 1001000, QPC=1000999,
|
||||
QPCFrequency=10000000, CoreId=1, Instructions=1,
|
||||
NativeInstructions=1, SlowReads=0, SlowWrites=0,
|
||||
Compiles=0, Fallbacks=0),
|
||||
self.native("Snapshot", 2001000, QPC=2000999,
|
||||
QPCFrequency=10000000, CoreId=1, Instructions=2,
|
||||
NativeInstructions=2, SlowReads=0, SlowWrites=0,
|
||||
Compiles=0, Fallbacks=0)]
|
||||
rows = []
|
||||
for us, payload in ((100000, 1000999), (200000, 2000999)):
|
||||
row = ["Dolphin.Performance/Snapshot/", str(us), "DolphinNoGUI.exe (12)",
|
||||
"1"] + [""] * 8
|
||||
row[10] = str(payload)
|
||||
rows.append(row)
|
||||
rows.append(["SampledProfile", "150000", "DolphinNoGUI.exe (12)", "9",
|
||||
"0x7d", "0", "", "DolphinNoGUI.exe!Render", "3", "Unbatched"])
|
||||
merged, summary = convert(rows, native, 12)
|
||||
self.assertEqual(summary["cpu_sample_threads"], 1)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "events.jsonl"
|
||||
path.write_text("".join(json.dumps(event) + "\n" for event in merged),
|
||||
encoding="utf-8")
|
||||
report = analyze(read_relevant_events(path, 12), 12)
|
||||
self.assertEqual(report["native_threads"], [1])
|
||||
self.assertEqual(report["sampled_process_threads"], [9])
|
||||
self.assertEqual(report["hot_static_symbols"][0]["samples"], 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from compare_p1 import compare
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def runs(on_mode, on_multiplier=1.01, off_values=(100, 100, 100, 100)):
|
||||
result = []
|
||||
for index, off in enumerate(off_values):
|
||||
for mode, value in (("NoTrace", off), (on_mode, off * on_multiplier)):
|
||||
result.append(dict(directory=f"run-{index}-{mode}", collection=mode,
|
||||
hash="same-exe", pdb_hash="same-pdb",
|
||||
affinity_mask=15, observed_affinity_mask=15, arm_ms=value,
|
||||
wall_ms=value * 1.2, cpu_ms=value * .95))
|
||||
return result
|
||||
|
||||
def test_repeated_low_noise_wpr_pairs_can_pass_local_budget(self):
|
||||
runs = self.runs("WPRSystemAndNative")
|
||||
with patch("compare_p1.load", side_effect=runs):
|
||||
report = compare(list(range(len(runs))))
|
||||
self.assertTrue(report["two_percent_budget_qualified"])
|
||||
self.assertAlmostEqual(report["upper_95_percent"]["arm_phase_percent"], 1)
|
||||
|
||||
def test_private_logger_never_qualifies_full_wpr_budget(self):
|
||||
runs = self.runs("PrivateUserMode")
|
||||
with patch("compare_p1.load", side_effect=runs):
|
||||
self.assertFalse(compare(list(range(len(runs))))["two_percent_budget_qualified"])
|
||||
|
||||
def test_large_baseline_spread_keeps_budget_unqualified(self):
|
||||
runs = self.runs("WPRSystemAndNative", off_values=(100, 104, 100, 104))
|
||||
with patch("compare_p1.load", side_effect=runs):
|
||||
self.assertFalse(compare(list(range(len(runs))))["two_percent_budget_qualified"])
|
||||
|
||||
def test_mismatched_affinity_rejected(self):
|
||||
runs = self.runs("WPRSystemAndNative")
|
||||
runs[3]["observed_affinity_mask"] = 255
|
||||
with patch("compare_p1.load", side_effect=runs):
|
||||
with self.assertRaisesRegex(ValueError, "affinity"):
|
||||
compare(list(range(len(runs))))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Merge xperf's classic kernel events with native TDH events on their QPC clock.
|
||||
|
||||
TDH may return ERROR_NOT_FOUND for classic kernel schemas on the analysis host.
|
||||
Xperf can still decode those MOF events from the same ETL. This is an offline
|
||||
adapter only: it never qualifies a native Wii update on its own.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import statistics
|
||||
|
||||
|
||||
def parse_pid(label):
|
||||
match = re.search(r"\(\s*(\d+)\s*\)$", label.strip())
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def convert(rows, native, pid):
|
||||
native_markers = {e["data"]["Name"]: e["qpc"] for e in native
|
||||
if e["native"] and e["pid"] == pid and e["event"] == "Marker"}
|
||||
native_payload_qpc = {
|
||||
(e["event"], int(e["data"]["QPC"])): e["qpc"] for e in native
|
||||
if e["native"] and e["pid"] == pid and e["event"] in ("Marker", "Snapshot", "Frame")
|
||||
and "QPC" in e["data"]
|
||||
}
|
||||
frequencies = {e["data"]["QPCFrequency"] for e in native
|
||||
if e["native"] and e["pid"] == pid and "QPCFrequency" in e["data"]}
|
||||
if len(frequencies) != 1:
|
||||
raise ValueError("Missing or inconsistent native QPC frequency")
|
||||
frequency = frequencies.pop()
|
||||
native_tids = {e["tid"] for e in native if e["native"] and e["pid"] == pid}
|
||||
if not native_tids:
|
||||
raise ValueError("No native threads for requested PID")
|
||||
|
||||
# Xperf timestamps are trace-relative microseconds. Match the QPC payload
|
||||
# of periodic native events to their TDH event-header QPC. A real Dolphin
|
||||
# run need not emit two named markers, but normally emits many snapshots.
|
||||
anchors = []
|
||||
raw_samples = []
|
||||
raw_switches = []
|
||||
invalid_events = 0
|
||||
invalid_target_events = 0
|
||||
for row in rows:
|
||||
if len(row) < 2:
|
||||
continue
|
||||
kind = row[0].strip()
|
||||
if kind.startswith("InvalidEvent/"):
|
||||
invalid_events += 1
|
||||
if len(row) > 2 and parse_pid(row[2]) == pid:
|
||||
invalid_target_events += 1
|
||||
if not row[1].strip().isdigit(): # xperf's per-event header line
|
||||
continue
|
||||
if kind in ("Dolphin.Performance/Marker/", "Dolphin.Performance/Snapshot/",
|
||||
"Dolphin.Performance/Frame/") and len(row) >= 12 and parse_pid(row[2]) == pid:
|
||||
event = kind.split("/")[1]
|
||||
payload_qpc = row[10].strip()
|
||||
if payload_qpc.isdigit() and (event, int(payload_qpc)) in native_payload_qpc:
|
||||
anchors.append((int(row[1]), native_payload_qpc[(event, int(payload_qpc))],
|
||||
f"{event}:{payload_qpc}"))
|
||||
elif event == "Marker" and len(row) >= 13:
|
||||
# Older synthetic captures also support named-marker matching.
|
||||
name = row[-1].strip()
|
||||
if name in native_markers:
|
||||
anchors.append((int(row[1]), native_markers[name], name))
|
||||
elif kind == "SampledProfile" and len(row) >= 9 and parse_pid(row[2]) == pid:
|
||||
tid = int(row[3])
|
||||
# Xperf does not CSV-quote C++ template commas in symbol names.
|
||||
# Count/type are the final two columns; rejoin the leaf symbol.
|
||||
# All threads of this PID matter: GPU/network threads may be hot
|
||||
# without ever emitting a native Dolphin.Performance event.
|
||||
raw_samples.append((int(row[1]), tid, int(row[4], 16), int(row[5]),
|
||||
int(row[-2]), ",".join(row[7:-2]).strip()))
|
||||
elif kind == "CSwitch" and len(row) >= 13:
|
||||
old_tid, new_tid = int(row[9]), int(row[3])
|
||||
if (old_tid in native_tids or new_tid in native_tids or
|
||||
parse_pid(row[2]) == pid or parse_pid(row[8]) == pid):
|
||||
raw_switches.append((int(row[1]), old_tid, new_tid, row[12].strip()))
|
||||
|
||||
if len(anchors) < 2:
|
||||
raise ValueError("Need at least two matching native/xperf QPC anchors")
|
||||
scale = frequency / 1_000_000
|
||||
offset = statistics.median(qpc - us * scale for us, qpc, _ in anchors)
|
||||
max_anchor_error = max(abs(round(us * scale + offset) - qpc) for us, qpc, _ in anchors)
|
||||
if max_anchor_error > frequency / 100_000: # More than 10 microseconds.
|
||||
raise ValueError(f"Native/xperf clocks do not agree: {max_anchor_error} QPC ticks")
|
||||
|
||||
def qpc(us):
|
||||
return round(us * scale + offset)
|
||||
|
||||
system = [dict(native=False, event="SampledProfile", qpc=qpc(us), pid=pid, tid=tid,
|
||||
data=dict(ThreadId=tid, InstructionPointer=ip, Processor=processor,
|
||||
Count=count, Symbol=symbol))
|
||||
for us, tid, ip, processor, count, symbol in raw_samples]
|
||||
system.extend(dict(native=False, event="CSwitch", qpc=qpc(us), pid=0, tid=0,
|
||||
data=dict(OldThreadId=old_tid, NewThreadId=new_tid,
|
||||
OldThreadState=5 if state == "Waiting" else -1,
|
||||
OldState=state))
|
||||
for us, old_tid, new_tid, state in raw_switches)
|
||||
samples_by_processor = Counter()
|
||||
for _, _, _, processor, count, _ in raw_samples:
|
||||
samples_by_processor[processor] += count
|
||||
summary = dict(schema=1, pid=pid, qpc_frequency=frequency, anchors=len(anchors),
|
||||
max_anchor_error_qpc=max_anchor_error, cpu_sample_records=len(raw_samples),
|
||||
cpu_sample_count=sum(count for _, _, _, _, count, _ in raw_samples),
|
||||
cpu_sample_threads=len({tid for _, tid, _, _, _, _ in raw_samples}),
|
||||
cpu_samples_by_processor=dict(sorted(samples_by_processor.items())),
|
||||
context_switches=len(raw_switches), xperf_invalid_events=invalid_events,
|
||||
xperf_invalid_target_events=invalid_target_events,
|
||||
note="Classic kernel events decoded by xperf; invalid events and missing symbols remain separate quality gates.")
|
||||
return sorted(native + system, key=lambda e: e["qpc"]), summary
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("xperf_csv", type=Path)
|
||||
parser.add_argument("native_jsonl", type=Path)
|
||||
parser.add_argument("--pid", required=True, type=int)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--summary", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
with args.native_jsonl.open(encoding="utf-8") as source:
|
||||
native_events = [json.loads(line) for line in source]
|
||||
with args.xperf_csv.open(encoding="utf-8", errors="replace", newline="") as source:
|
||||
events, result = convert(csv.reader(source, skipinitialspace=True), native_events, args.pid)
|
||||
with args.output.open("x", encoding="utf-8") as target:
|
||||
for event in events:
|
||||
target.write(json.dumps(event, separators=(",", ":")) + "\n")
|
||||
with args.summary.open("x", encoding="utf-8") as target:
|
||||
json.dump(result, target, indent=2)
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -0,0 +1,11 @@
|
||||
build/
|
||||
__pycache__/
|
||||
*.elf
|
||||
*.map
|
||||
/build/
|
||||
/apps/
|
||||
/captures/
|
||||
/__pycache__/
|
||||
/wii-conformance.dol
|
||||
/wii-conformance.elf
|
||||
/wii-conformance.map
|
||||
@@ -0,0 +1,45 @@
|
||||
.SUFFIXES:
|
||||
|
||||
ifeq ($(strip $(DEVKITPPC)),)
|
||||
$(error Set DEVKITPPC to your devkitPPC installation)
|
||||
endif
|
||||
|
||||
include $(DEVKITPPC)/wii_rules
|
||||
|
||||
TARGET := wii-conformance
|
||||
BUILD := build
|
||||
SOURCES := source
|
||||
|
||||
CFLAGS := -O2 -g -Wall -Wextra $(MACHDEP) $(INCLUDE)
|
||||
LDFLAGS = -g $(MACHDEP) -Wl,-Map,$(OUTPUT).map
|
||||
LIBS := -lwiiuse -lbte -lfat -logc -lm
|
||||
|
||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
||||
export VPATH := $(CURDIR)/source
|
||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
||||
export OFILES := main.o
|
||||
export INCLUDE := -I$(LIBOGC_INC)
|
||||
export LIBPATHS := -L$(LIBOGC_LIB)
|
||||
export LD := $(CC)
|
||||
|
||||
.PHONY: all clean
|
||||
all: | $(BUILD)
|
||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
||||
@mkdir -p apps/wii-conformance
|
||||
@cp $(TARGET).dol apps/wii-conformance/boot.dol
|
||||
@cp meta.xml apps/wii-conformance/meta.xml
|
||||
|
||||
$(BUILD):
|
||||
@mkdir -p $@
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILD) $(OUTPUT).elf $(OUTPUT).dol $(OUTPUT).elf.map
|
||||
else
|
||||
DEPENDS := $(OFILES:.o=.d)
|
||||
|
||||
$(OUTPUT).dol: $(OUTPUT).elf
|
||||
$(OUTPUT).elf: $(OFILES)
|
||||
|
||||
-include $(DEPENDS)
|
||||
endif
|
||||
@@ -0,0 +1,59 @@
|
||||
# Wii IOS conformance probe
|
||||
|
||||
This is the first black-box comparison probe for the original IOS running on a
|
||||
real Wii and under this fork's Starlet LLE mode. Build **one** DOL and run that
|
||||
same file in both places. It records 64 rounds of IPC-backed resource-manager
|
||||
open/close calls in all four open modes, missing-device errors, root directory
|
||||
counts, installed-title counts, and an SD write/read/hash/cleanup round trip.
|
||||
The output contains 1,860 numbered observations. The probe requests no NAND
|
||||
writes, installs no IOS, and does not blindly scan MMIO registers. It creates and
|
||||
removes only `sd:/wii-conformance-data.bin` on the SD card.
|
||||
|
||||
## Build
|
||||
|
||||
Install devkitPPC and libogc/libfat using devkitPro, set `DEVKITPRO` and
|
||||
`DEVKITPPC` in the build shell, then run `make` in this directory. The output is
|
||||
`wii-conformance.dol`. On this Windows machine, the following command was used
|
||||
successfully from this directory in PowerShell:
|
||||
|
||||
```powershell
|
||||
& 'C:\devkitPro\msys2\usr\bin\bash.exe' -c 'export DEVKITPRO=/c/devkitPro DEVKITPPC=/c/devkitPro/devkitPPC; export PATH=/c/devkitPro/devkitPPC/bin:/c/devkitPro/tools/bin:/usr/bin:/bin; make'
|
||||
```
|
||||
|
||||
The ready-to-copy Homebrew Channel folder is `apps/wii-conformance/`, containing
|
||||
`boot.dol` and `meta.xml`. This build has not yet been run on a physical Wii.
|
||||
|
||||
## Capture
|
||||
|
||||
1. Use a Wii and a Dolphin LLE profile with the same IOS version and revision.
|
||||
For exact directory/title counts, use the same NAND snapshot, starting state,
|
||||
and loaded title. The SD cards need enough free space for a 4 KiB test file
|
||||
and the CSV output.
|
||||
2. Copy `apps/wii-conformance/` to the `apps/` folder on each SD card and launch
|
||||
it through the Homebrew Channel. Use the exact same `boot.dol` on both runs.
|
||||
Dolphin LLE requires a configured writable virtual SD image.
|
||||
3. After the program returns, copy `sd:/wii-conformance.csv` from each SD card
|
||||
to separate files such as `wii.csv` and `dolphin.csv`.
|
||||
4. Compare them with `python compare.py wii.csv dolphin.csv`. Exit code 0 means
|
||||
all recorded observations agree, 1 reports differences, and 2 means the
|
||||
captures are invalid or use different IOS versions.
|
||||
|
||||
The CSV contains no keys or NAND contents. An allocated IOS file descriptor is
|
||||
normalized to success/failure because its numeric value depends on unrelated
|
||||
open handles. Call return codes and output counts remain exact. Time is not
|
||||
compared in this first suite because scheduling and output I/O introduce noise.
|
||||
`captures/` is kept local and ignored by Git until each capture's console or
|
||||
emulator provenance, IOS revision, probe binary hash, and initial NAND state
|
||||
are documented. A filename alone is not evidence that a capture came from a
|
||||
physical Wii.
|
||||
|
||||
## Interpretation and next suites
|
||||
|
||||
A mismatch identifies a reproducible behavior to investigate; it does not by
|
||||
itself locate a bad register. Trace the first failing request inside Dolphin's
|
||||
Wii IPC, Starlet memory bus, and the relevant device. The next test suites
|
||||
should use explicit stimulus/response cases for IPC flag acknowledgements,
|
||||
shared-memory cache visibility, DMA, IRQ ordering, and individual devices.
|
||||
PPC homebrew cannot inspect every Starlet register while Nintendo IOS is
|
||||
running. A separate Starlet-side probe under MINI/BootMii can test raw
|
||||
hardware registers, but its results must be labeled as MINI-mode tests.
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare two runs of the identical Wii conformance DOL."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
METADATA = ("schema", "rounds", "observations", "ios_version", "ios_revision")
|
||||
HEADER = ["seq", "test", "round", "value", "extra"]
|
||||
|
||||
|
||||
def read_capture(path: Path):
|
||||
with path.open(newline="", encoding="utf-8") as stream:
|
||||
rows = csv.reader(stream)
|
||||
metadata = {}
|
||||
for key in METADATA:
|
||||
row = next(rows, None)
|
||||
if row is None or len(row) != 2 or row[0] != key:
|
||||
raise ValueError(f"{path}: expected metadata row {key!r}")
|
||||
metadata[key] = int(row[1])
|
||||
if next(rows, None) != HEADER:
|
||||
raise ValueError(f"{path}: invalid observation header")
|
||||
observations = []
|
||||
for line, row in enumerate(rows, start=7):
|
||||
if len(row) != 5:
|
||||
raise ValueError(f"{path}:{line}: expected 5 columns")
|
||||
seq, test, round_number, value, extra = row
|
||||
if int(seq) != len(observations):
|
||||
raise ValueError(f"{path}:{line}: sequence is not contiguous")
|
||||
observations.append((test, int(round_number), int(value), int(extra)))
|
||||
if len(observations) != metadata["observations"]:
|
||||
raise ValueError(
|
||||
f"{path}: expected {metadata['observations']} observations, "
|
||||
f"found {len(observations)}"
|
||||
)
|
||||
return metadata, observations
|
||||
|
||||
|
||||
def compare(reference, candidate, limit=20):
|
||||
reference_meta, reference_rows = reference
|
||||
candidate_meta, candidate_rows = candidate
|
||||
for key in METADATA:
|
||||
if reference_meta[key] != candidate_meta[key]:
|
||||
print(f"Incomparable runs: {key}: Wii={reference_meta[key]}, Dolphin={candidate_meta[key]}")
|
||||
return 2
|
||||
|
||||
differences = 0
|
||||
for index in range(max(len(reference_rows), len(candidate_rows))):
|
||||
expected = reference_rows[index] if index < len(reference_rows) else None
|
||||
actual = candidate_rows[index] if index < len(candidate_rows) else None
|
||||
if expected == actual:
|
||||
continue
|
||||
differences += 1
|
||||
if differences <= limit:
|
||||
print(f"#{index} Wii={expected} Dolphin={actual}")
|
||||
|
||||
if differences:
|
||||
print(f"{differences} differences across {max(len(reference_rows), len(candidate_rows))} observations")
|
||||
return 1
|
||||
print(f"Identical: {len(reference_rows)} observations")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("wii", type=Path, help="CSV from the real Wii")
|
||||
parser.add_argument("dolphin", type=Path, help="CSV from Dolphin")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return compare(read_capture(args.wii), read_capture(args.dolphin))
|
||||
except (OSError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<app version="1">
|
||||
<name>Wii IOS Conformance Probe</name>
|
||||
<coder>Dolphin Emulator Project</coder>
|
||||
<version>0.1</version>
|
||||
<short_description>Compare IOS behavior on Wii and Dolphin</short_description>
|
||||
<long_description>Runs repeatable IPC, IOS, and SD tests and saves wii-conformance.csv to the SD card.</long_description>
|
||||
</app>
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <fat.h>
|
||||
#include <gccore.h>
|
||||
#include <ogc/es.h>
|
||||
#include <ogc/ios.h>
|
||||
#include <ogc/ipc.h>
|
||||
#include <ogc/isfs.h>
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// The same DOL must be run on the console and in Dolphin. Do not depend on
|
||||
// timings or on allocated IOS file descriptor numbers in the comparison.
|
||||
#define SCHEMA_VERSION 1
|
||||
#define ROUNDS 64
|
||||
#define RECORDS_PER_ROUND 29
|
||||
#define SKIPPED (-32768)
|
||||
#define SD_TEST_FILE "sd:/wii-conformance-data.bin"
|
||||
|
||||
static FILE* output;
|
||||
static unsigned int sequence;
|
||||
|
||||
static void record(const char* test, unsigned int round, int value, int extra)
|
||||
{
|
||||
if (output)
|
||||
fprintf(output, "%u,%s,%u,%d,%d\n", sequence, test, round, value, extra);
|
||||
++sequence;
|
||||
}
|
||||
|
||||
static void probe_open(const char* name, const char* path, unsigned int round, unsigned int mode)
|
||||
{
|
||||
char test[48];
|
||||
const s32 fd = IOS_Open(path, mode);
|
||||
snprintf(test, sizeof(test), "open_%s_mode%u", name, mode);
|
||||
// File descriptor allocation can legitimately differ between runs.
|
||||
record(test, round, fd >= 0 ? 0 : fd, 0);
|
||||
snprintf(test, sizeof(test), "close_%s_mode%u", name, mode);
|
||||
record(test, round, fd >= 0 ? IOS_Close(fd) : SKIPPED, 0);
|
||||
}
|
||||
|
||||
static void probe_sd(unsigned int round)
|
||||
{
|
||||
static u8 expected[4096] __attribute__((aligned(32)));
|
||||
static u8 actual[4096] __attribute__((aligned(32)));
|
||||
const size_t length = 1 + ((round * 67) % sizeof(expected));
|
||||
for (size_t i = 0; i < length; ++i)
|
||||
expected[i] = (u8)(round * 131 + i * 17);
|
||||
|
||||
FILE* file = fopen(SD_TEST_FILE, "wb");
|
||||
record("sd_open_write", round, file ? 0 : -1, (int)length);
|
||||
record("sd_write_bytes", round, file ? (int)fwrite(expected, 1, length, file) : SKIPPED,
|
||||
(int)length);
|
||||
record("sd_close_write", round, file ? fclose(file) : SKIPPED, 0);
|
||||
|
||||
file = fopen(SD_TEST_FILE, "rb");
|
||||
record("sd_open_read", round, file ? 0 : -1, 0);
|
||||
const size_t read_count = file ? fread(actual, 1, length, file) : 0;
|
||||
record("sd_read_bytes", round, file ? (int)read_count : SKIPPED, (int)length);
|
||||
u32 hash = 2166136261u;
|
||||
for (size_t i = 0; i < read_count; ++i)
|
||||
hash = (hash ^ actual[i]) * 16777619u;
|
||||
record("sd_read_hash", round, file ? (int)hash : SKIPPED, 0);
|
||||
record("sd_data_equal", round,
|
||||
file ? (read_count == length && memcmp(expected, actual, length) == 0) : SKIPPED, 0);
|
||||
record("sd_close_read", round, file ? fclose(file) : SKIPPED, 0);
|
||||
record("sd_remove", round, remove(SD_TEST_FILE), 0);
|
||||
}
|
||||
|
||||
static void init_display(void)
|
||||
{
|
||||
VIDEO_Init();
|
||||
GXRModeObj* mode = VIDEO_GetPreferredMode(NULL);
|
||||
void* framebuffer = MEM_K0_TO_K1(SYS_AllocateFramebuffer(mode));
|
||||
console_init(framebuffer, 20, 20, mode->fbWidth - 40, mode->xfbHeight - 40,
|
||||
mode->fbWidth * VI_DISPLAY_PIX_SZ);
|
||||
VIDEO_Configure(mode);
|
||||
VIDEO_SetNextFramebuffer(framebuffer);
|
||||
VIDEO_SetBlack(FALSE);
|
||||
VIDEO_Flush();
|
||||
VIDEO_WaitVSync();
|
||||
if (mode->viTVMode & VI_NON_INTERLACE)
|
||||
VIDEO_WaitVSync();
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
init_display();
|
||||
printf("Wii IOS conformance probe v%d\n", SCHEMA_VERSION);
|
||||
|
||||
if (fatInitDefault())
|
||||
output = fopen("sd:/wii-conformance.csv", "wb");
|
||||
if (!output)
|
||||
{
|
||||
printf("Cannot write sd:/wii-conformance.csv\n");
|
||||
printf("Insert a writable SD card and restart.\n");
|
||||
for (int i = 0; i < 600; ++i)
|
||||
VIDEO_WaitVSync();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Metadata is checked before comparing individual observations.
|
||||
fprintf(output, "schema,%d\n", SCHEMA_VERSION);
|
||||
fprintf(output, "rounds,%d\n", ROUNDS);
|
||||
fprintf(output, "observations,%d\n", 4 + ROUNDS * RECORDS_PER_ROUND);
|
||||
fprintf(output, "ios_version,%d\n", IOS_GetVersion());
|
||||
fprintf(output, "ios_revision,%d\n", IOS_GetRevision());
|
||||
fprintf(output, "seq,test,round,value,extra\n");
|
||||
|
||||
const s32 fs_init = ISFS_Initialize();
|
||||
record("isfs_init", 0, fs_init, 0);
|
||||
const s32 es_init = __ES_Init();
|
||||
record("es_init", 0, es_init, 0);
|
||||
|
||||
for (unsigned int round = 0; round < ROUNDS; ++round)
|
||||
{
|
||||
// A missing resource is a useful stable IPC error-path test.
|
||||
probe_open("missing", "/dev/wii_conformance_probe_absent", round, IPC_OPEN_NONE);
|
||||
|
||||
for (unsigned int mode = IPC_OPEN_NONE; mode <= IPC_OPEN_RW; ++mode)
|
||||
{
|
||||
probe_open("fs", "/dev/fs", round, mode);
|
||||
probe_open("es", "/dev/es", round, mode);
|
||||
}
|
||||
|
||||
if (fs_init >= 0)
|
||||
{
|
||||
u32 count __attribute__((aligned(32))) = 0;
|
||||
const s32 result = ISFS_ReadDir("/", NULL, &count);
|
||||
record("isfs_root_count", round, result, result >= 0 ? (int)count : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
record("isfs_root_count", round, SKIPPED, 0);
|
||||
}
|
||||
|
||||
if (es_init >= 0)
|
||||
{
|
||||
u32 title_count __attribute__((aligned(32))) = 0;
|
||||
const s32 title_result = ES_GetNumTitles(&title_count);
|
||||
record("es_title_count", round, title_result,
|
||||
title_result >= 0 ? (int)title_count : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
record("es_title_count", round, SKIPPED, 0);
|
||||
}
|
||||
|
||||
probe_sd(round);
|
||||
}
|
||||
|
||||
if (fs_init >= 0)
|
||||
record("isfs_deinit", 0, ISFS_Deinitialize(), 0);
|
||||
else
|
||||
record("isfs_deinit", 0, SKIPPED, 0);
|
||||
if (es_init >= 0)
|
||||
record("es_deinit", 0, __ES_Close(), 0);
|
||||
else
|
||||
record("es_deinit", 0, SKIPPED, 0);
|
||||
|
||||
const int write_error = ferror(output);
|
||||
const int close_error = fclose(output);
|
||||
printf("%u observations. CSV: sd:/wii-conformance.csv\n", sequence);
|
||||
printf("Write status: %d, close status: %d\n", write_error, close_error);
|
||||
printf("Returning to loader in about 10 seconds.\n");
|
||||
for (int i = 0; i < 600; ++i)
|
||||
VIDEO_WaitVSync();
|
||||
return write_error || close_error ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from compare import compare, read_capture
|
||||
|
||||
|
||||
def capture(version=58, value=0):
|
||||
return ("schema,1\nrounds,64\nobservations,1\nios_version,%d\nios_revision,6176\n"
|
||||
"seq,test,round,value,extra\n0,open_fs_mode0,0,%d,0\n" % (version, value))
|
||||
|
||||
|
||||
class CompareTest(unittest.TestCase):
|
||||
def test_identical_and_first_difference(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
wii = Path(directory) / "wii.csv"
|
||||
dolphin = Path(directory) / "dolphin.csv"
|
||||
wii.write_text(capture(), encoding="utf-8")
|
||||
dolphin.write_text(capture(), encoding="utf-8")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(compare(read_capture(wii), read_capture(dolphin)), 0)
|
||||
dolphin.write_text(capture(value=-6), encoding="utf-8")
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(compare(read_capture(wii), read_capture(dolphin)), 1)
|
||||
self.assertIn("#0", output.getvalue())
|
||||
|
||||
def test_wrong_ios_version_is_incomparable(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
wii = Path(directory) / "wii.csv"
|
||||
dolphin = Path(directory) / "dolphin.csv"
|
||||
wii.write_text(capture(), encoding="utf-8")
|
||||
dolphin.write_text(capture(version=80), encoding="utf-8")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(compare(read_capture(wii), read_capture(dolphin)), 2)
|
||||
|
||||
def test_truncated_capture_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "short.csv"
|
||||
path.write_text(capture().replace("observations,1", "observations,2"), encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
read_capture(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user