+
+## Running on ARM64
+
+All three scripts detect the host architecture and adapt. Nothing needs a flag —
+run `setup-windows.bat` exactly as on x64.
+
+**The two architectures deliberately install different things.** x64 is the full
+workstation; ARM64 is provisioned as a lean build box with a single C/C++
+compiler. See
+[ARM64 installs less, on purpose](#arm64-installs-less-on-purpose) below for the
+list and the reasoning.
+
+### Detecting the architecture
+
+All three scripts read the **machine-level registry value**, not the environment
+and not `RuntimeInformation.OSArchitecture`. Both of those report the *emulated*
+architecture inside an emulated x64 process — which is what you get by launching
+`powershell` or `cmd` from an x64 shell (Git Bash, for one) on an ARM64 box.
+Measured on this machine, from that emulated shell:
+
+| source | reports |
+| --- | --- |
+| `[RuntimeInformation]::OSArchitecture` | `X64` ✗ |
+| `%PROCESSOR_ARCHITECTURE%` | `AMD64` ✗ |
+| `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` | `ARM64` ✓ |
+
+`OSArchitecture` is documented as the *OS's* architecture, and on .NET Core it
+is; under .NET Framework on Prism it is not. It is kept only as a fallback. This
+used to be a cosmetic mislabel in a log line — now that the architecture decides
+which packages are installed at all, reading it wrong provisions the wrong
+machine.
+
+> The batch file calls `reg.exe` by full path and parses it **without** piping to
+> `find`/`findstr`. Both are shadowable by anything earlier on the `PATH`, and a
+> POSIX `find` turns the whole detection into a silent no-op that leaves the
+> emulated answer standing. That is not hypothetical; it is how this was found.
+
+**The build toolchain is fully native.** Visual Studio's ARM64 installer and MSVC
+are ARM64 binaries, and MSVC cross-compiles every target from an ARM64 host with
+no emulation in the compiler:
+
+```bat
+cmake -B build -A ARM64 && cmake --build build --config Release :: native
+cmake -B build -A x64 && cmake --build build --config Release :: cross
+cmake -B build -A Win32 && cmake --build build --config Release :: cross
+```
+
+**ARM64 uses Visual Studio 2026** (`$VsChannel = 18`) while x64 stays on VS 2022.
+Nothing forces that split — the Arm box simply has no reason to stay on the older
+generation, since the `v141`/XP toolset that pins x64 there cannot run on Arm at
+all — so Arm takes the newer MSVC. Note this is a *default*, not a limitation:
+VS 2026 does still offer `v141` and `WinXP` as components on x64.
+
+The base component group now names `VC.Tools.x86.x64` **and** `VC.Tools.ARM64`
+explicitly instead of relying on `--includeRecommended`, which resolves
+differently per host: on an ARM64 machine the workload's recommended set is the
+ARM64-targeting toolchain, and the x64 cross-compiler is *not* implied. Spectre
+runtimes and ATL are likewise requested for both target families
+(`VC.ATL.ARM64.Spectre` is a separate component from `VC.ATL.Spectre`).
+
+### ARM64 installs less, on purpose
+
+The ARM64 machine here is a constrained VM, and nearly everything it was
+installing without a native build was going to run under Prism emulation anyway.
+The exceptions were individually defensible and collectively a VM full of x64
+binaries. So the split is now explicit: **ARM64 gets MSVC and nothing that
+duplicates it or that exists only as an x64 binary.**
+
+| dropped on ARM64 | where | why |
+| --- | --- | --- |
+| `Brave.Brave` | `setup-windows.bat` | no ARM64 build; an emulated browser is the slowest thing on the box, and Edge is in-box and native |
+| `WinMerge.WinMerge` | `setup-windows.bat` | ARM64 ships as a **machine-scope** installer only, so an unelevated `winget` can install nothing but the emulated x64 build |
+| `LLVM.LLVM` | `setup-windows.bat` | second Clang, dropped along with VS's `clang-cl` |
+| `NASM.NASM` | `setup-windows.bat` | x86/x86-64 assembler with no ARM64 target; MSVC's `armasm64.exe` is the native one |
+| `Google.AndroidGPUInspector` | `setup-windows.bat` | x64-only, and it profiles Android devices rather than anything compiled here |
+| `OpenCppCoverage` | `setup-windows.bat` | collects coverage by stepping x86/x64 instructions, so it cannot instrument an ARM64 binary at all |
+| VS Clang/LLVM component | `setup-windows-with-uac.ps1` | see below |
+| Windows Driver Kit | `setup-windows-with-uac.ps1` | see below |
+| BinSkim | `setup-windows-no-uac.ps1` | the NuGet package publishes `win-x64` only |
+
+x64 keeps every one of them. The decision is made in one place,
+`setup-windows-steps.ps1` (`-X64Only`, with the reason): the plan shows each as
+`[-]` with that reason, and the run log repeats it where the step would have
+run, rather than leaving a silent gap.
+
+**`clang-cl` is dropped by choice, not by limitation.** A native ARM64 `clang-cl`
+*is* offered on Arm and did work here before the split. Two things establish
+that: the `VC.Llvm.Clang` VSIX is `productArch=neutral` with no `chip` or
+`machineArch` restriction, so the Arm installer offers it; and MSVC's
+`VC\Tools\Llvm` tree is partitioned by **host** architecture, with real ARM64
+binaries in `ARM64\bin`, which is where `clang-cl.exe` lands:
+
+| directory | host |
+| --- | --- |
+| `VC\Tools\Llvm\bin` | x86 |
+| `VC\Tools\Llvm\x64\bin` | x64 |
+| `VC\Tools\Llvm\ARM64\bin` | ARM64 |
+
+> Watch for a false positive when checking by hand: `VC\Tools\Llvm\*\bin` holds
+> `clang-format.exe` and `clang-tidy.exe` on **any** host, installed or not —
+> those ship with the NativeDesktop workload. Their presence does not mean the
+> compiler is there; look for `clang-cl.exe`.
+
+Note that the VS installer never uninstalls a component you simply stopped
+asking for, so a box provisioned before this split keeps its `clang-cl` until it
+is explicitly removed. The verification step says so and prints the
+`--remove` line.
+
+**The WDK is dropped for two reasons, and the second one bites on x64 too.** The
+ARM64 box builds user-mode software, so it does not need a driver kit — but this
+particular WDK *cannot* install here regardless. The kit requires the **matching
+Windows SDK revision**, and revisions are not side-by-side: `Include\10.0.26100.0`
+is one directory, whichever revision wrote it. VS 2026 installs SDK
+**26100.7705**; `linkid=2335869` is WDK **26100.6584**, the kit paired with VS
+2022. `wdksetup.exe /quiet` therefore aborts:
+
+```
+WDK installer exit code: 15605
+WER: WDK / 10.1.26100.6584 / Install / 0x80070642
+```
+
+`0x642` is `1602`, `ERROR_INSTALL_USEREXIT` — a silent install cancelling itself
+on a failed prerequisite check. Nothing to do with Arm or emulation; the same
+pairing fails on x64. If a driver ever does need building against VS 2026, use
+the kit Microsoft's [supported WDK downloads](https://learn.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads)
+table pairs with it — the 28000.x one — not this link. x64 with VS 2022 has the
+matched pair, so it installs there.
+
+**Compiler diversity now lives on the x64 box**, which has room for three
+toolchains: MSVC, VS's `clang-cl`, and **upstream LLVM** (`winget install
+LLVM.LLVM`). Upstream runs on its own schedule, is usually several major versions
+ahead of the Clang VS bundles, and installs to `C:\Program Files\LLVM` rather
+than inside the VS tree, so the two are genuinely independent. Two Clang majors
+plus MSVC over the same sources is what catches the bugs a single toolchain
+agrees with itself about. Watch the `PATH` there: a bare `clang-cl` may resolve
+to upstream while CMake's `-T ClangCL` keeps using VS's, so pass a full
+`-DCMAKE_CXX_COMPILER` when it matters.
+
+**Rust is on both boxes, and native on ARM64.** It is a different language
+rather than a second C/C++ compiler, so it does not bend the one-compiler rule,
+and its `-msvc` toolchain links through MSVC's own `link.exe`. The ARM64 box
+gets `stable-aarch64-pc-windows-msvc`; to build x64 Rust code from there, add
+the x64 *target* (`rustup target add x86_64-pc-windows-msvc`) rather than an x64
+host toolchain, which would run emulated.
+
+What you end up with on ARM64:
+
+| | On ARM64 |
+| --- | --- |
+| **Native ARM64** | Git, Python, .NET SDK, PowerShell, VS Code, Windows Terminal, Sysinternals, Claude Code, CMake, Ninja, the VS installer, **MSVC**, MSBuild, the SDK tools (`rc`, `signtool`), rustup and **Rust** (`stable-aarch64-pc-windows-msvc`), the in-box OpenSSH client, and the Windows Performance Toolkit. |
+| **Emulated** | `iperf3` (x64; network-bound), `adb` and `fastboot` (x86 — Google ships no other Windows build; bound by the USB/TCP link to the device), and the ADK *installer* (x64; the toolkit it lays down is native). |
+| **Emulated, minor** | `py.exe` (python.org ships the launcher shim as x86; it execs the native `python.exe`) and `vswhere.exe` (Microsoft ships x86 only; runs once). |
+| **Not installed** | Brave, WinMerge, upstream LLVM, NASM, Android GPU Inspector, OpenCppCoverage, BinSkim, VS `clang-cl`, and the WDK — see the table above. |
+| **Unavailable** | VirtualBox, the Windows 7 x86 test VM, Intel VTune, and the `v141` / Windows XP targeting toolset. |
+
+### The architecture audit
+
+`setup-windows-no-uac.ps1` ends with an **`ArchAudit`** step that reads the PE
+COFF header of every tool the box provisions and reports what you would actually
+run, resolved PATH-first. It reads the file itself rather than trusting package
+metadata — a multi-architecture package (Sysinternals) ships every build in one
+zip under different names, and an installer's metadata says nothing about which
+binary landed. Purely informational; it never fails the run. Skip it with
+`-Skip ArchAudit`.
+
+Anything emulated with a listed reason prints as expected. Anything emulated
+*without* one is called out, with a remedy where a native build exists — which is
+how the `ninja` problem below was found.
+
+### Elevation, and the case where there is no prompt
+
+`setup-windows.bat` works out **once, at the top**, whether this account can
+elevate, and reports it as `Elevation: ALREADY | PROMPT | NOLUA`:
+
+| | |
+| --- | --- |
+| `ALREADY` | Already elevated. The elevated half runs **directly** — no point asking to elevate what already is. |
+| `PROMPT` | Not elevated, UAC on. Elevation is requested: an administrator gets a consent prompt, a standard user gets an over-the-shoulder **credential** prompt. |
+| `NOLUA` | UAC is off machine-wide (`EnableLUA = 0`) **and** this is not an administrator. Elevation is *impossible*, not merely declined. |
+
+`IsInRole(Administrator)` is false for an admin running unelevated under UAC,
+which is what makes `ALREADY` mean *actually elevated* rather than *could be*.
+
+**Why `NOLUA` needs detecting rather than attempting.** `Start-Process -Verb
+RunAs` is not honest about it. With UAC disabled the `RunAs` verb is **silently
+ignored**: no prompt appears, the child starts with the caller's own unelevated
+token, and `Start-Process` reports **exit code 0** as though it had worked. The
+elevated script then refuses itself on its `#Requires -RunAsAdministrator` line,
+exits 1, and never reaches its `Start-Transcript` — so there is no log either.
+Read naively that is indistinguishable from a cancelled UAC prompt, which is
+exactly what this script used to report, sending you to look for a prompt that
+could never have appeared.
+
+Measured on such a box: `Start-Process -Verb RunAs` returned exit 0 while the
+child reported itself as the *unelevated* calling user.
+
+**What is skipped when elevation is impossible.** The elevated half, plus four
+packages that sit in the "per-user" section but are machine-wide installers.
+They used to fail on every run with opaque codes — `exit 5`
+(`ERROR_ACCESS_DENIED`) for the .NET SDK, MSI `1603` for CMake and Android GPU
+Inspector, `exit 1` for OpenCppCoverage, whose installer self-elevates. They are
+now shown as unavailable in the plan, with the reason, and skipped with one
+clear line each. None blocks a build: the .NET SDK is
+only there for the WiX MSI tooling, CMake also ships inside Visual Studio, AGI
+profiles Android devices, and OpenCppCoverage cannot instrument ARM64 binaries
+anyway.
+
+A `NOLUA` run is a **success** (exit 0), not a failure — the per-user half did
+its job. To finish the box, run it from an administrator account, then re-run
+`setup-windows-no-uac.ps1` as yourself so the per-user `PATH` and `.gitconfig`
+land in *your* profile rather than the administrator's.
+
+Two consequences worth knowing rather than rediscovering:
+
+- **LLVM does not fail — it relocates.** Its NSIS installer targets
+ `%ProgramFiles%\LLVM`, and when it cannot write there it silently falls back to
+ a per-user directory (observed: `%USERPROFILE%\Documents\LLVM`) while winget
+ still reports *Successfully installed*. The compiler is genuinely there and
+ native; nothing can find it. The `LlvmPath` step looks in that fallback, puts
+ the directory on the user `PATH`, and says so.
+- **WinMerge silently downgrades to x64.** Upstream publishes ARM64 as a
+ **machine-scope** installer and x64 as a **per-user** one, so an unelevated
+ winget lets its scope preference beat its architecture preference. The batch
+ now asks for `--architecture arm64` first and falls back, so an admin run gets
+ the native build and a standard-user run still gets a working one.
+
+### Two gotchas the audit surfaces
+
+- **Visual Studio bundles an x64 `ninja.exe` even on ARM64.** It matters more
+ than a one-off tool would, because ninja is re-invoked for every edge in the
+ build graph — it is the one place emulation is paid over and over rather than
+ once. `winget install Ninja-build.Ninja` gets the native `ninja-winarm64`
+ build, and the `NinjaPath` step pins its directory to the **front** of the user
+ `PATH`. VS's bundled `cmake.exe` *is* ARM64, so only ninja has this problem.
+
+ On which copy wins inside a Developer Command Prompt: the native one. VS adds
+ its ninja from `Common7\Tools\vsdevcmd\ext\cmake.bat`, which does
+ `set "PATH=%PATH%;...\CMake\bin;...\CMake\Ninja"` — an **append**, so the VS
+ directories land at the very end of the composed `PATH`, behind every machine
+ and user entry. A native ninja anywhere on the user `PATH` already beats it,
+ measured on an ARM64 box. The `NinjaPath` step is therefore insurance rather
+ than the load-bearing fix: winget *appends* its package directory to the user
+ `PATH`, so without it the margin rests on two append orders staying as they
+ are, one of them in a file Microsoft owns and revises.
+- **Sysinternals ships every architecture in one zip**, and the ARM64 build is
+ not the default-named exe. `ProcessExplorer.zip` contains `procexp.exe` (x86),
+ `procexp64.exe` (x64) and `procexp64a.exe` (**ARM64**) — so run the `*64a.exe`
+ variants; plain `procexp.exe` runs emulated. (`Microsoft.Sysinternals.Suite`
+ has a dedicated `SysinternalsSuite-ARM64.zip` if you prefer the whole set.)
+
+- **x64 emulation is checked, not assumed.** Everything in the "emulated" row
+ needs Prism, which is present on Windows 11 on Arm but absent on Windows 10 on
+ Arm (x86-only there) and on some Server images. The elevated half tests for
+ `System32\xtajit64.dll` up front and warns, rather than letting the failure
+ surface much later as a binary that will not start.
+- **VirtualBox is skipped.** There is no ARM64 Windows host build, and the x64
+ one cannot be rescued by emulation: a hypervisor is kernel-mode, and its x64
+ driver will not load on an ARM64 kernel. This is also what makes
+ `setup-windows-7-test-env.bat` unreachable from an ARM64 host — it prepares a
+ Windows 7 *x86* guest, which neither VirtualBox nor Hyper-V on ARM64 can run.
+ (That script itself is unchanged; it runs inside the guest, so it never sees
+ the host architecture.)
+- **The `v141` / Windows XP toolset is skipped**, and the verification step says
+ `n/a` instead of warning about it. The components are still *listed* in the
+ catalog on Arm, so this is a deliberate skip rather than a hard unavailability
+ — but the 14.16 toolset predates Windows on Arm as a host and ships
+ `HostX86`/`HostX64` compilers only, so the best you could get is an
+ x86-emulated compiler, and XP targeting is not supported from an Arm host.
+ Nothing is lost that this box could have used:
+ Windows XP never ran on ARM64, so the only point of an XP-targeting build here
+ would be producing x86 binaries, which the current toolset does natively via
+ `-A Win32`.
+- **OpenCppCoverage is no longer installed on ARM64.** Unlike the rest of the
+ emulated row this was a real ceiling rather than a slowdown: it collects
+ coverage by debugging the process under test and stepping x86/x64
+ instructions, so it can cover cross-compiled x86/x64 binaries but **not** an
+ ARM64 one. A coverage tool that cannot cover this machine's own output belongs
+ on the x64 box, which is where it still installs. Run coverage there.
+- **BinSkim is no longer installed on ARM64 either**, though the reason is
+ weaker: the NuGet package publishes `win-x64` only (its other RIDs are
+ `linux-x64`, `linux-arm64`, `osx-x64`), but BinSkim *reads* PE headers and load
+ configs, so its own architecture is independent of the binaries it analyses —
+ an emulated x64 BinSkim checks ARM64 binaries perfectly well. It went with the
+ rest of the x64-only set to keep the rule simple, not because it was broken.
+ The RID list is still ordered preference on x64, so a future `win-arm64` build
+ would be picked up with no further change.
+- **`rsync` is native on ARM64 now**, and its `ssh.exe` is still verified by
+ running it. The release publishes an `arm64` asset as of `v3.5.0-gdeeda96f`;
+ both binaries in it are `0xAA64`, and the `ssh.exe` reports
+ `OpenSSH_for_Windows_10.0p2 ... LibreSSL 3.8.2` on this box.
+
+ Before that asset existed, ARM64 took the x64 zip. The `rsync.exe` was fine —
+ a transfer is bounded by the socket, not by emulated CPU — but its `ssh.exe`
+ links against `System32\libcrypto.dll`, which on ARM64 is an **ARM64** binary
+ an x64 process cannot load. It died with `0xC0000135`
+ (`STATUS_DLL_NOT_FOUND`) and had to be deleted on every run. That matters more
+ than it sounds: `rsync.exe` *prefers* an `ssh.exe` in its own directory, so a
+ present-but-broken one does not degrade to the in-box client — it breaks rsync
+ outright, with an error naming a dead remote shell rather than `ssh.exe`.
+
+ The elevated half still runs `ssh -V` after unpacking and **deletes** the
+ binary if it will not start. The check is kept because it is what found that
+ problem, and it still covers a missing or too-old `libcrypto.dll` on any
+ architecture. An x64 `rsync.exe` surviving on an ARM64 box now shows up in the
+ audit as emulation *without* a listed reason, with a remedy pointing at a
+ re-run. The `core.sshCommand` step in the non-elevated half already picked its
+ candidate by running it, and is unchanged.
+- **Intel VTune reports `n/a`** rather than printing a download link: there is no
+ Windows-on-Arm build, and its value is reading Intel PMU counters. Use the
+ Windows Performance Toolkit (`wpr`/`xperf` to collect, `wpa` to analyse), or
+ Arm Performance Studio / Streamline for Arm PMU sampling.
+- **NASM is not installed on ARM64.** It assembles x86/x86-64 only — there is no
+ ARM64 target in it and no ARM64 build of it — so it went into the x64-only set
+ with the rest. The ARM64 assembler is `armasm64.exe`, which ships with MSVC.
+ Its installer is also one of the two here that fall back to a per-user
+ directory it never registers (`%LOCALAPPDATA%\bin\NASM`), so `winget uninstall`
+ cannot find it afterwards; the `Uninstall.exe` in that directory is the way out.
+- **`vswhere` stays under the 32-bit Program Files** on ARM64 too
+ (`%ProgramFiles(x86)%\Microsoft Visual Studio\Installer`), so that path is
+ unchanged. The VS Installer is x86-registered there by contract even though the
+ installer binaries themselves are native.
+- **The WDK registry probe reads both hives.** `wdksetup.exe` is 32-bit and
+ writes under `WOW6432Node` on x64, but which hive a kit lands in has varied
+ across versions and architectures, and reading only one made an installed WDK
+ look absent — costing a needless multi-GB reinstall on every run.