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:
Yaya48 committed 2026-09-23 13:30:17 +02:00
1 parent b931671fde
commit c230971f07
64 files changed
+6488 -238

No files matched your search

+157
View File
@@ -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"
}
+13
View File
@@ -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' }
+48
View File
@@ -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"
+144
View File
@@ -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.'
+204
View File
@@ -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;
}
+42
View File
@@ -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."
+87
View File
@@ -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;
};
+195
View File
@@ -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.
+190
View File
@@ -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;
}
}
+256
View File
@@ -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))
+98
View File
@@ -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))
+102
View File
@@ -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")
+195
View File
@@ -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()
+45
View File
@@ -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()
+134
View File
@@ -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))
+11
View File
@@ -0,0 +1,11 @@
build/
__pycache__/
*.elf
*.map
/build/
/apps/
/captures/
/__pycache__/
/wii-conformance.dol
/wii-conformance.elf
/wii-conformance.map
+45
View File
@@ -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
+59
View File
@@ -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.
+79
View File
@@ -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())
+8
View File
@@ -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>
+172
View File
@@ -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;
}
+48
View File
@@ -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()