]> vilimpoc.org git repositories - dotfiles/blobdiff - setup-windows.bat
dotfiles: split provisioning between x64 and ARM64
[dotfiles] / setup-windows.bat
index c91262cd06ad3bae4d04a23cd409933f39ba0a4d..0a69956da6768ce5b481324bd2dad576a0bf3366 100644 (file)
 @rem ---------------------------------------------------------------------------\r
 @rem Host architecture\r
 @rem\r
-@rem PROCESSOR_ARCHITECTURE alone is not enough: a 32-bit cmd.exe under emulation\r
-@rem reports x86 and stashes the real one in PROCESSOR_ARCHITEW6432. Check both,\r
-@rem so this is right however the script was launched.\r
+@rem The environment variables are not enough, and the failure is silent. A 32-bit\r
+@rem cmd.exe under emulation reports x86 and stashes the real one in\r
+@rem PROCESSOR_ARCHITEW6432 - that pair is handled below - but an EMULATED x64\r
+@rem cmd.exe on an ARM64 box reports AMD64 with PROCESSOR_ARCHITEW6432 unset, and\r
+@rem nothing in the environment contradicts it. Launching this script from an x64\r
+@rem shell (Git Bash, for one) is enough to hit that, and measured here it gives:\r
+@rem     PROCESSOR_ARCHITECTURE                  AMD64\r
+@rem     HKLM\...\Session Manager\Environment    ARM64   <- the real one\r
 @rem\r
-@rem The value drives two things below, and nothing else - winget resolves the\r
+@rem So take the machine-level registry value as the answer and keep the\r
+@rem environment pair only as the fallback. The registry key is per-machine and\r
+@rem not subject to per-process emulation, and it is not one of the redirected\r
+@rem hives. This is load-bearing now: %IS_ARM64% decides which packages are\r
+@rem installed at all, so reading it wrong provisions the wrong machine.\r
+@rem\r
+@rem The value drives three things below, and nothing else - winget resolves the\r
 @rem installer architecture on its own (native first, then whatever the box can\r
 @rem emulate), so the ordinary installs need no help:\r
 @rem   - VirtualBox is skipped on ARM64 (see below)\r
+@rem   - the x64-only package set is skipped on ARM64 (see :x64only)\r
 @rem   - the summary tells you which tools you got as emulated x64\r
 @rem ---------------------------------------------------------------------------\r
 set "HOST_ARCH=%PROCESSOR_ARCHITECTURE%"\r
 if defined PROCESSOR_ARCHITEW6432 set "HOST_ARCH=%PROCESSOR_ARCHITEW6432%"\r
+@rem reg.exe by full path, and NOT through a pipe to find/findstr: both of those\r
+@rem are shadowable by anything earlier on the PATH, and a POSIX `find` on the\r
+@rem PATH turns this into a silent no-op that leaves the emulated answer standing.\r
+for /f "tokens=1,2,*" %%A in ('%SystemRoot%\System32\reg.exe query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v PROCESSOR_ARCHITECTURE 2^>nul') do if /i "%%A"=="PROCESSOR_ARCHITECTURE" set "HOST_ARCH=%%C"\r
 set "IS_ARM64=0"\r
 if /i "%HOST_ARCH%"=="ARM64" set "IS_ARM64=1"\r
 echo [setup-windows] Host architecture: %HOST_ARCH%\r
 \r
+@rem ---------------------------------------------------------------------------\r
+@rem Can this account elevate? Worked out ONCE, here, because it gates two very\r
+@rem different things: the elevated half far below, and a handful of the "per-user"\r
+@rem winget installs just after this, which are per-user in name only.\r
+@rem\r
+@rem   ALREADY - already elevated. IsInRole(Administrator) is false for an admin\r
+@rem             running unelevated under UAC, so this means actually elevated,\r
+@rem             not merely capable of it.\r
+@rem   PROMPT  - not elevated, UAC on, so elevation can be requested.\r
+@rem   NOLUA   - UAC is off machine-wide (EnableLUA = 0) AND this is not an\r
+@rem             administrator. Elevation is impossible, not merely declined:\r
+@rem             Windows has no prompt to offer. Note that with UAC off,\r
+@rem             `Start-Process -Verb RunAs` does not fail - it is silently\r
+@rem             ignored, runs the child with the caller's own token, and reports\r
+@rem             success, which is why this needs detecting rather than trying.\r
+@rem ---------------------------------------------------------------------------\r
+set "ELEV=PROMPT"\r
+for /f "usebackq tokens=*" %%A in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$id=[Security.Principal.WindowsIdentity]::GetCurrent(); if (([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { 'ALREADY' } elseif ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue).EnableLUA -eq 0) { 'NOLUA' } else { 'PROMPT' }"`) do set "ELEV=%%A"\r
+echo [setup-windows] Elevation: %ELEV%\r
+if "%ELEV%"=="NOLUA" (\r
+    echo [setup-windows] This account cannot elevate ^(UAC off, not an administrator^). Packages that\r
+    echo [setup-windows] need a machine-wide install will be SKIPPED rather than left to fail; details\r
+    echo [setup-windows] at each one, and a summary before the elevated half.\r
+)\r
+\r
+@rem ---------------------------------------------------------------------------\r
+@rem ARM64 is a lean build box, x64 is the full workstation\r
+@rem\r
+@rem The ARM64 machine here is a small VM, and everything it was installing\r
+@rem without a native build was going to run under Prism emulation anyway. So the\r
+@rem two architectures now provision different things on purpose: x64 keeps the\r
+@rem full set, ARM64 installs ONE compiler - MSVC from Visual Studio - and\r
+@rem nothing that merely duplicates it or that exists only as an x64 binary.\r
+@rem\r
+@rem Dropped on ARM64, and where each one is dropped:\r
+@rem   Brave.Brave                      here      no ARM64 build; Edge is in-box and native\r
+@rem   WinMerge.WinMerge                here      ARM64 build is machine-scope only\r
+@rem   LLVM.LLVM                        here      second Clang, dropped with VS's clang-cl\r
+@rem   NASM.NASM                        here      x86/x64 assembler; ARM64 uses MSVC's armasm64\r
+@rem   Google.AndroidGPUInspector       here      x64-only, profiles Android devices\r
+@rem   OpenCppCoverage.OpenCppCoverage  here      x86/x64 only; cannot instrument ARM64 binaries\r
+@rem   VS Clang/LLVM component          with-uac  see $ClangComponents\r
+@rem   Windows Driver Kit               with-uac  see the WDK step\r
+@rem   BinSkim                          no-uac    NuGet publishes win-x64 only\r
+@rem\r
+@rem :x64only at the bottom of this file is the one place that decision is\r
+@rem applied, so the skip and its reason land in the log together.\r
+@rem ---------------------------------------------------------------------------\r
+\r
 @rem --- Non-admin (per-user) winget installs ---\r
 @rem\r
 @rem No --architecture anywhere on purpose. winget already picks the best\r
@@ -42,12 +107,23 @@ echo [setup-windows] Host architecture: %HOST_ARCH%
 @rem (ARM64). On ARM64, run the *64a.exe variants - plain procexp.exe is the x86\r
 @rem one and will run emulated.\r
 winget install Anthropic.ClaudeCode\r
-winget install Brave.Brave\r
+call :x64only Brave.Brave "no ARM64 build - the emulated x64 browser is the slowest thing on the box, and Edge is in-box and native"\r
 winget install Git.Git\r
-winget install Microsoft.DotNet.SDK.10\r
 winget install Microsoft.PowerShell Microsoft.Sysinternals.ProcessExplorer Microsoft.Sysinternals.ProcessMonitor Microsoft.Sysinternals.SDelete Microsoft.VisualStudioCode Microsoft.WindowsTerminal\r
 winget install Python.Python.3.13\r
-winget install WinMerge.WinMerge\r
+\r
+@rem WinMerge is the one package here where winget's own preference order picks\r
+@rem the WRONG architecture, and it is worth knowing why: upstream publishes the\r
+@rem ARM64 build as a MACHINE-scope installer (WinMerge-<ver>-ARM64-Setup.exe) and\r
+@rem the x64 build as a PER-USER one (WinMerge-<ver>-x64-PerUser-Setup.exe). Run\r
+@rem unelevated, winget's scope preference beats its architecture preference and\r
+@rem you silently get the emulated x64 build.\r
+@rem\r
+@rem That asymmetry is why ARM64 no longer installs it at all: the only build a\r
+@rem normal user can install there is the emulated one, and asking for --architecture\r
+@rem arm64 just fails unless the run is elevated. VS Code's diff view and\r
+@rem `git difftool` cover the same ground natively.\r
+call :x64only WinMerge.WinMerge "upstream ships ARM64 as a machine-scope installer only, so an unelevated winget can install nothing but the emulated x64 build"\r
 \r
 @rem --- Build drivers, standalone and native ---\r
 @rem\r
@@ -62,44 +138,56 @@ winget install WinMerge.WinMerge
 @rem     graph, which makes it the one tool here where emulation is actually paid\r
 @rem     over and over, and ninja-winarm64.zip is a native build. Worth having.\r
 @rem\r
-@rem Both are on the machine/user PATH after install, so a bare `cmake -G Ninja`\r
-@rem picks these up. Inside a VS Developer prompt the VS copies may come first;\r
-@rem that is fine for cmake (same architecture) and is the case to watch for ninja.\r
-winget install Kitware.CMake\r
+@rem Which ninja wins inside a VS Developer Command Prompt: the native one. VS\r
+@rem adds its copy from Common7\Tools\vsdevcmd\ext\cmake.bat with\r
+@rem     set "PATH=%PATH%;...\CMake\bin;...\CMake\Ninja"\r
+@rem which APPENDS, so the VS directories sit at the very end of the composed\r
+@rem PATH, behind every machine and user entry. setup-windows-no-uac.ps1 then\r
+@rem pins the native ninja's directory to the FRONT of the user PATH so the\r
+@rem margin does not depend on two append orders staying as they are.\r
+@rem\r
+@rem Ninja here, CMake in the machine-wide group below: Ninja is a portable zip\r
+@rem winget unpacks into the user profile, CMake is an MSI that installs for the\r
+@rem whole machine.\r
 winget install Ninja-build.Ninja\r
 \r
-@rem --- Second, independent Clang: compiler diversity ---\r
+@rem --- Second, independent Clang: compiler diversity, on x64 only ---\r
 @rem\r
-@rem The elevated half installs Visual Studio's Clang component (clang-cl, native\r
-@rem ARM64). This is a DIFFERENT LLVM: the upstream release, on its own schedule\r
-@rem and usually several major versions ahead of the one VS bundles, installed to\r
+@rem On x64 the elevated half installs Visual Studio's Clang component (clang-cl).\r
+@rem This is a DIFFERENT LLVM: the upstream release, on its own schedule and\r
+@rem usually several major versions ahead of the one VS bundles, installed to\r
 @rem C:\Program Files\LLVM rather than inside the VS tree. Two Clang majors plus\r
 @rem MSVC over the same sources is the point - it is what catches the bugs a\r
 @rem single toolchain agrees with itself about.\r
 @rem\r
-@rem Native on ARM64: the manifest resolves to LLVM-<ver>-woa64.exe ("Windows on\r
-@rem Arm 64"), not the x64 build.\r
+@rem NOT ON ARM64, and not for lack of a native build - the manifest does resolve\r
+@rem to LLVM-<ver>-woa64.exe there. It is dropped because the ARM64 box carries\r
+@rem MSVC alone: with VS's clang-cl component gone too, a third compiler with\r
+@rem nothing to disagree with is just disk. Do the multi-compiler build on the\r
+@rem x64 machine, which keeps all three.\r
 @rem\r
-@rem PATH ORDER MATTERS if you use it. Should this installer put its bin on the\r
-@rem PATH, a bare `clang-cl` resolves to the upstream one while CMake's\r
+@rem PATH ORDER MATTERS on x64 if you use it. Should this installer put its bin on\r
+@rem the PATH, a bare `clang-cl` resolves to the upstream one while CMake's\r
 @rem `-T ClangCL` toolset keeps using the VS copy - so the two can be selected\r
 @rem independently, but only if you know which one you are asking for. Prefer\r
 @rem -DCMAKE_CXX_COMPILER with a full path when you care.\r
-winget install LLVM.LLVM\r
+call :x64only LLVM.LLVM "ARM64 carries MSVC alone - VS's clang-cl is dropped there too, so a second Clang has nothing to cross-check against"\r
 \r
-@rem x64-only manifests: winget falls back to the x64 installer on ARM64 and these\r
-@rem run under Prism emulation. Harmless for what they do here - iperf3 is a\r
-@rem network benchmark bounded by the link, and AGI is an Android-side GPU\r
-@rem profiler whose work happens on the phone. Neither is CPU-bound on this box.\r
+@rem x64-only manifest: winget falls back to the x64 installer on ARM64 and it runs\r
+@rem under Prism emulation. Harmless for what it does here - iperf3 is a network\r
+@rem benchmark bounded by the link, not by CPU.\r
 winget install ar51an.iPerf3\r
-winget install Google.AndroidGPUInspector\r
 \r
 @rem NASM assembles x86/x86-64 only - there is no ARM64 target in it and no ARM64\r
-@rem build of it. Installed on ARM64 anyway (as emulated x64) because this box\r
-@rem still cross-compiles x86/x64 binaries, where NASM is the assembler that\r
-@rem builds them. If you only ever target ARM64, nothing here uses it: the ARM64\r
-@rem assembler is armasm64.exe, which ships with MSVC.\r
-winget install NASM.NASM\r
+@rem build of it. x64 keeps it; ARM64 does not, because an emulated assembler is\r
+@rem only worth carrying if you cross-compile x86/x64 here, and that work belongs\r
+@rem on the x64 box. The ARM64 assembler is armasm64.exe, which ships with MSVC.\r
+@rem\r
+@rem Its installer is also one of the two here that fall back to a per-user\r
+@rem directory when it cannot write to Program Files - C:\Users\<you>\AppData\\r
+@rem Local\bin\NASM, registered nowhere, so `winget uninstall` cannot find it\r
+@rem afterwards. Uninstall.exe in that directory is the only way back out.\r
+call :x64only NASM.NASM "x86/x86-64 assembler with no ARM64 target and no ARM64 build - MSVC's armasm64.exe is the native one"\r
 \r
 @rem VirtualBox has no ARM64 Windows host build, and the x64 one cannot be made to\r
 @rem work by emulation: a hypervisor is kernel-mode, and its x64 driver will not\r
@@ -116,17 +204,54 @@ if "%IS_ARM64%"=="1" (
     winget install Oracle.VirtualBox\r
 )\r
 \r
+@rem ---------------------------------------------------------------------------\r
+@rem Machine-wide installers, despite sitting in the "per-user" section\r
+@rem\r
+@rem These four are not per-user at all. On an account that cannot elevate they\r
+@rem fail on every run, with installer exit codes that say nothing useful:\r
+@rem   Microsoft.DotNet.SDK.10      exit 5     (ERROR_ACCESS_DENIED)\r
+@rem   Kitware.CMake                exit 1603  (generic MSI failure)\r
+@rem   Google.AndroidGPUInspector   exit 1603\r
+@rem   OpenCppCoverage              exit 1     (its installer self-elevates)\r
+@rem\r
+@rem Grouped and skipped outright where elevation is impossible. Attempting a\r
+@rem guaranteed failure four times per run - and paying the download for it -\r
+@rem teaches nobody anything, and the four opaque error codes bury the one line\r
+@rem that matters. On an account that can elevate they run exactly as before.\r
+@rem\r
+@rem NOTE none of these is a build-blocker: the .NET SDK is only here for the WiX\r
+@rem MSI tooling, CMake also ships inside Visual Studio, AGI profiles Android\r
+@rem devices, and OpenCppCoverage cannot instrument ARM64 binaries anyway.\r
+@rem\r
+@rem On ARM64 the group is just the first two - AGI and OpenCppCoverage are in the\r
+@rem x64-only set, so there they are skipped before elevation is even consulted.\r
+@rem ---------------------------------------------------------------------------\r
+if "%ELEV%"=="NOLUA" (\r
+    echo.\r
+    echo [setup-windows] Skipping the machine-wide installers - this account cannot elevate:\r
+    echo [setup-windows]   Microsoft.DotNet.SDK.10 and Kitware.CMake, plus - on x64 only -\r
+    echo [setup-windows]   Google.AndroidGPUInspector and OpenCppCoverage.\r
+    echo [setup-windows]   None blocks a build. Re-run from an administrator account to get them.\r
+    echo.\r
+    goto :after_admin_pkgs\r
+)\r
+\r
+winget install Microsoft.DotNet.SDK.10\r
+winget install Kitware.CMake\r
+call :x64only Google.AndroidGPUInspector "x64-only build, and it profiles Android devices rather than anything compiled here"\r
+\r
 @rem OpenCppCoverage: native (PE) line coverage for the C++ binaries. run-coverage-occ.py drives the\r
 @rem pytest suite under it to produce an HTML report (the binaries under test build with PDBs,\r
 @rem which it reads). The installer elevates via UAC.\r
 @rem\r
 @rem x86/x64 only, and unlike the other emulated tools that is a real limit rather\r
 @rem than a slowdown: it collects coverage by debugging the process under test and\r
-@rem stepping x86/x64 instructions, so it can cover the x86/x64 binaries this box\r
-@rem cross-compiles but NOT an ARM64 one. For ARM64 coverage, build the ARM64\r
-@rem binaries with /fsanitize-coverage or use the x64 build for the coverage run.\r
-winget install OpenCppCoverage.OpenCppCoverage\r
-if "%IS_ARM64%"=="1" echo [setup-windows] NOTE: OpenCppCoverage is x86/x64-only - it cannot instrument ARM64 binaries. Run coverage against the x64 build.\r
+@rem stepping x86/x64 instructions, so it can cover x86/x64 binaries but NOT an\r
+@rem ARM64 one. That is what puts it in the x64-only set: on ARM64 it could only\r
+@rem ever cover the cross-compiled x64 build, which is a job for the x64 box.\r
+call :x64only OpenCppCoverage.OpenCppCoverage "it collects coverage by stepping x86/x64 instructions, so it cannot instrument an ARM64 binary at all"\r
+\r
+:after_admin_pkgs\r
 \r
 @rem --- WiX 5.0.2, pinned on purpose ---\r
 @rem WiX packages our proprietary software into MSIs, and the version is pinned to keep that\r
@@ -192,10 +317,51 @@ if not exist "%DOTNET_EXE%" goto :after_wix
 set "UAC_LOG=%~dp0setup-windows-uac.log"\r
 if exist "%UAC_LOG%" del "%UAC_LOG%"\r
 \r
+@rem %ELEV% was worked out at the top of this script - see the comment there for\r
+@rem why `Start-Process -Verb RunAs` cannot be trusted to report this itself.\r
+if "%ELEV%"=="NOLUA" goto :elev_impossible\r
+if "%ELEV%"=="ALREADY" goto :elev_direct\r
+\r
+@rem Not elevated, UAC is on: request it. Expect a prompt.\r
+echo [setup-windows] Requesting elevation ^(expect a UAC prompt^)...\r
 powershell -NoProfile -Command "$p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','""%~dp0setup-windows-with-uac.ps1""' -Wait -PassThru; exit $p.ExitCode"\r
 set "UAC_RC=%ERRORLEVEL%"\r
+goto :elev_done\r
+\r
+:elev_direct\r
+echo [setup-windows] Already running elevated; running the elevated half directly.\r
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-with-uac.ps1"\r
+set "UAC_RC=%ERRORLEVEL%"\r
+goto :elev_done\r
+\r
+:elev_impossible\r
+echo.\r
+echo [setup-windows] SKIPPING the elevated half: this account cannot elevate.\r
+echo [setup-windows]   UAC is disabled machine-wide ^(EnableLUA = 0^) and you are not an administrator,\r
+echo [setup-windows]   so Windows offers no way to elevate - there is no prompt to accept. With UAC off,\r
+echo [setup-windows]   'Start-Process -Verb RunAs' is silently ignored and reports success, which is why\r
+echo [setup-windows]   this used to look like a cancelled prompt.\r
+echo [setup-windows]\r
+echo [setup-windows]   Not installed: Visual Studio and its MSVC toolchain, rsync, and the OpenSSH\r
+echo [setup-windows]   server. Everything per-user above is unaffected.\r
+echo [setup-windows]\r
+echo [setup-windows]   To finish the box, either sign in to an administrator account and re-run this\r
+echo [setup-windows]   script, or have an administrator run setup-windows-with-uac.ps1 there. Then\r
+echo [setup-windows]   re-run setup-windows-no-uac.ps1 as yourself, so the per-user PATH and .gitconfig\r
+echo [setup-windows]   land in YOUR profile rather than the administrator's.\r
+echo.\r
+set "UAC_RC=SKIPPED"\r
+\r
+:elev_done\r
 \r
 @rem --- Surface the elevated session's output (its window has already closed) ---\r
+@rem\r
+@rem A missing log is NOT self-explanatory, so do not guess at one cause. The\r
+@rem elevated script writes its transcript as almost its first act, so no log\r
+@rem means it never got as far as running: either the prompt was declined, or it\r
+@rem started unelevated and stopped on its own #Requires line. Which of those it\r
+@rem was is already known from %ELEV%, so report that instead of speculating.\r
+if "%UAC_RC%"=="SKIPPED" goto :after_uac_log\r
 if exist "%UAC_LOG%" (\r
     echo.\r
     echo ===== elevated setup log ^(%UAC_LOG%^) =====\r
@@ -203,8 +369,10 @@ if exist "%UAC_LOG%" (
     echo ===== end of elevated setup log =====\r
 ) else (\r
     echo [setup-windows] WARNING: no elevated log found at "%UAC_LOG%".\r
-    echo [setup-windows] The elevated window may have been cancelled at the UAC prompt.\r
+    if "%ELEV%"=="PROMPT" echo [setup-windows] The elevated window never started - the UAC prompt was most likely declined.\r
+    if "%ELEV%"=="ALREADY" echo [setup-windows] The elevated half exited before writing its transcript; see its output above.\r
 )\r
+:after_uac_log\r
 \r
 @rem --- Non-elevated PowerShell half ---\r
 @rem WinMerge on the user PATH, BinSkim, and the global git config (identity +\r
@@ -227,6 +395,11 @@ if exist "%UAC_LOG%" (
 powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup-windows-no-uac.ps1"\r
 if not "%ERRORLEVEL%"=="0" echo [setup-windows] WARNING: setup-windows-no-uac.ps1 reported a failure ^(see above^); continuing.\r
 \r
+@rem Skipping the elevated half is a reported, understood outcome on a box where\r
+@rem elevation is impossible - not a failure to exit non-zero over. The per-user\r
+@rem provisioning above did run, and re-running from an administrator account is\r
+@rem the documented next step.\r
+if "%UAC_RC%"=="SKIPPED" goto :uac_reported\r
 if not "%UAC_RC%"=="0" (\r
     echo.\r
     echo [setup-windows] ELEVATED SETUP FAILED ^(exit code %UAC_RC%^). See log above.\r
@@ -234,27 +407,96 @@ if not "%UAC_RC%"=="0" (
 )\r
 echo.\r
 echo [setup-windows] Elevated setup completed successfully.\r
+goto :uac_reported\r
+\r
+:uac_reported\r
+if "%UAC_RC%"=="SKIPPED" echo.\r
+if "%UAC_RC%"=="SKIPPED" echo [setup-windows] Per-user setup complete; the elevated half was skipped ^(see above^).\r
 \r
 @rem Removed: this doesn't work as well as I hoped, maybe try again later\r
 @rem -- Install Headroom ---\r
 @rem py -m pip install "headroom-ai[all]"\r
 @rem npm install headroom-ai\r
 \r
-@rem Pillow publishes win_arm64 wheels alongside win_amd64, so `py` - the ARM64\r
-@rem Python winget just installed - gets a native wheel and never falls back to\r
-@rem building from source. Any package WITHOUT an ARM64 wheel does fall back, and\r
-@rem that source build needs the MSVC toolchain the elevated half installs.\r
-py -m pip install Pillow\r
+@rem --- Find an interpreter by path, rather than trusting `py` ---\r
+@rem\r
+@rem `py -m pip install Pillow` used to be this line, and it failed with\r
+@rem     'py' is not recognized as an internal or external command\r
+@rem for two independent reasons, which look identical at the prompt:\r
+@rem\r
+@rem   1. PATH staleness. winget installed Python a few lines above, but this cmd\r
+@rem      session inherited its environment before that, so nothing winget added\r
+@rem      is visible here. Same problem the DOTNET_EXE lookup solves above, and it\r
+@rem      would bite even where the launcher IS installed.\r
+@rem\r
+@rem   2. The launcher may simply not be there. Measured on the ARM64 box: every\r
+@rem      other Python component registered - Core Interpreter, Executables,\r
+@rem      Standard Library, pip Bootstrap, Tcl/Tk, Add to Path - and there is no\r
+@rem      launcher entry and no py.exe anywhere on disk, while the Launcher\r
+@rem      directory it would occupy is on the user PATH but does not exist.\r
+@rem\r
+@rem So resolve an interpreter by full path and prefer the launcher only when it\r
+@rem is real. python.exe is the thing that actually has to exist; `py` is a\r
+@rem convenience shim that picks between several of them, and there is exactly one\r
+@rem here. Reverse name order so the newest Python wins, and require python.exe\r
+@rem inside the directory - a half-removed version leaves the folder behind with\r
+@rem only Doc and Lib in it, which is exactly what this box has.\r
+set "PY_EXE="\r
+if exist "%LOCALAPPDATA%\Programs\Python\Launcher\py.exe" set "PY_EXE=%LOCALAPPDATA%\Programs\Python\Launcher\py.exe"\r
+if not defined PY_EXE if exist "%WINDIR%\py.exe" set "PY_EXE=%WINDIR%\py.exe"\r
+if not defined PY_EXE for /f "delims=" %%P in ('dir /b /ad /o-n "%LOCALAPPDATA%\Programs\Python\Python3*" 2^>nul') do if not defined PY_EXE if exist "%LOCALAPPDATA%\Programs\Python\%%P\python.exe" set "PY_EXE=%LOCALAPPDATA%\Programs\Python\%%P\python.exe"\r
+\r
+@rem Pillow publishes win_arm64 wheels alongside win_amd64, so the ARM64 Python\r
+@rem winget just installed gets a native wheel and never falls back to building\r
+@rem from source. Any package WITHOUT an ARM64 wheel does fall back, and that\r
+@rem source build needs the MSVC toolchain the elevated half installs.\r
+if not defined PY_EXE (\r
+    echo [setup-windows] WARNING: no Python interpreter found; skipping the Pillow install.\r
+    echo [setup-windows]   Looked for the py launcher, then %LOCALAPPDATA%\Programs\Python\Python3*\python.exe.\r
+) else (\r
+    echo [setup-windows] Python: %PY_EXE%\r
+    "%PY_EXE%" -m pip install Pillow\r
+)\r
 \r
 if "%IS_ARM64%"=="1" (\r
     echo.\r
     echo [setup-windows] ARM64 notes:\r
     echo [setup-windows]   native ARM64: Git, Python, .NET SDK, PowerShell, VS Code, Windows Terminal,\r
-    echo [setup-windows]                 WinMerge, Brave, Sysinternals ^(the *64a.exe variants^),\r
-    echo [setup-windows]                 Claude Code, CMake, Ninja, and the SDK/WPT tools.\r
-    echo [setup-windows]   compilers   : MSVC and clang-cl from Visual Studio 2026, plus upstream\r
-    echo [setup-windows]                 LLVM - all three native ARM64, two different Clang majors.\r
-    echo [setup-windows]   emulated x64: iperf3, NASM, OpenCppCoverage, BinSkim, rsync.exe, AGI.\r
+    echo [setup-windows]                 Sysinternals ^(the *64a.exe variants^), Claude Code, CMake,\r
+    echo [setup-windows]                 Ninja, and the SDK/WPT tools.\r
+    echo [setup-windows]   compiler    : MSVC from Visual Studio 2026, native ARM64. One compiler by\r
+    echo [setup-windows]                 design - clang-cl and upstream LLVM are x64-box only.\r
+    echo [setup-windows]   emulated x64: iperf3 and rsync.exe, both network-bound.\r
+    echo [setup-windows]   not here    : Brave, WinMerge, LLVM, NASM, AGI, OpenCppCoverage, BinSkim\r
+    echo [setup-windows]                 and the WDK - dropped on ARM64, see the x64-only set above.\r
     echo [setup-windows]   unavailable : VirtualBox, the Windows 7 x86 test VM, Intel VTune,\r
     echo [setup-windows]                 and the v141 / Windows XP targeting toolset.\r
 )\r
+@rem The list above is what this script PROVIDES on ARM64, not necessarily what\r
+@rem landed on this run - so point at the audit, which reports the actual state.\r
+if "%ELEV%"=="NOLUA" (\r
+    echo [setup-windows]   NOT on this box: everything needing elevation was skipped, including\r
+    echo [setup-windows]                 Visual Studio's MSVC, the .NET SDK and CMake. The\r
+    echo [setup-windows]                 architecture audit above lists what is really installed.\r
+)\r
+\r
+exit /b 0\r
+\r
+@rem ---------------------------------------------------------------------------\r
+@rem :x64only <winget-id> "<why not on ARM64>"\r
+@rem\r
+@rem Install a package on x64 and skip it on ARM64, saying why. One subroutine\r
+@rem rather than a gate at each call site so that the reason is never separated\r
+@rem from the skip: the log line a future reader sees is the same text this file\r
+@rem carries, and there is exactly one place to change the policy.\r
+@rem\r
+@rem The reason is a real argument, not a comment, because the interesting case is\r
+@rem reading the run log six months later and wondering where NASM went.\r
+@rem ---------------------------------------------------------------------------\r
+:x64only\r
+if not "%IS_ARM64%"=="1" (\r
+    winget install %1\r
+    goto :eof\r
+)\r
+echo [setup-windows] Skipping %1 on ARM64: %~2\r
+goto :eof\r