]> vilimpoc.org git repositories - dotfiles/blobdiff - README.md
dotfiles: make the Windows setup work natively on ARM64
[dotfiles] / README.md
index e10d872a599b87930611d76d599e4a8fb69e255b..349a693f72dd9ffe2ce86652001cdd3363268172 100644 (file)
--- a/README.md
+++ b/README.md
@@ -3,20 +3,24 @@
 Windows development-box provisioning scripts.
 
 `setup-windows.bat` takes a fresh Windows install to a working C++ / native
-development environment: editors and shells, Python, the Visual Studio 2022
+development environment: editors and shells, Python, the Visual Studio
 toolchain (including the Clang and Windows XP targeting toolsets), the Windows
 Driver Kit, and a handful of analysis tools (Sysinternals, OpenCppCoverage,
 BinSkim, the Windows Performance Toolkit). It also sets the box up to be driven
 remotely: OpenSSH Server plus an rsync build for Windows, which is what makes a
 throwaway VM reachable from a Linux host.
 
+Runs on **x64 and on ARM64** (Windows 11 on Arm). See
+[Running on ARM64](#running-on-arm64) for what differs.
+
 ## Files
 
 | File | Purpose |
 | --- | --- |
-| `setup-windows.bat` | Entry point. Runs the winget installs, then the non-elevated script, then launches the elevated half and prints its log. |
+| `setup-windows.bat` | Entry point. Runs the winget installs, then launches the elevated half and prints its log, then runs the non-elevated script. |
 | `setup-windows-no-uac.ps1` | The non-elevated, per-user half: WinMerge and BinSkim on the user `PATH`, and the global git config (identity, plus `core.sshCommand`). Can also be run directly from an ordinary prompt. |
-| `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs and starts OpenSSH Server, installs `rsync.exe` to the machine `PATH`, then installs Visual Studio 2022 Community with the required components, the WDK, and the Windows Performance Toolkit. Can also be run directly from an Administrator prompt. |
+| `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs the OpenSSH Client and Server capabilities and starts `sshd`, unpacks the `rsync-windows` release zip for this architecture (`rsync.exe` plus the `ssh.exe` it runs) into `C:\Tools\rsync` on the machine `PATH`, then installs Visual Studio Community with the required components, the WDK, and the Windows Performance Toolkit, and reports whether Intel VTune Profiler is present. Can also be run directly from an Administrator prompt. |
+| `setup-windows-7-test-env.bat` | Prepares a **Windows 7 VM** as a test target driven from the host by `VBoxManage guestcontrol`. Copy it into the guest and run it there; it is idempotent, so re-run it after any snapshot restore. The per-user half needs no UAC (crash-dialog suppression, no screen blanking, a staging directory, the shared folder on `Z:`); the machine-wide half is skipped with a notice unless run elevated inside the guest. It then reports what the box can actually test: DWM composition, printers, audio capture devices. |
 
 ## Usage
 
@@ -50,15 +54,27 @@ throwaway VM reachable from a Linux host.
 - The elevated half writes a transcript to `setup-windows-uac.log` next to the
   script; the batch file prints it when the elevated window closes. The log is
   gitignored, as it contains local paths.
-- **git uses the Windows SSH client.** `setup-windows-no-uac.ps1` sets
-  `core.sshCommand` to `%WINDIR%/System32/OpenSSH/ssh.exe`. Git for Windows
-  otherwise prefers its own bundled MSYS2 `ssh.exe`, which cannot reach the
-  Windows `ssh-agent` service that the elevated half enables - Win32-OpenSSH
-  publishes the agent on a named pipe the MSYS2 build does not speak. Without
-  this, keys loaded with `ssh-add` from PowerShell are invisible to `git`, and a
-  push falls back to hunting for a key file and prompting for its passphrase.
-  The value uses forward slashes on purpose: git parses `core.sshCommand` with
-  shell quoting rules, in which a backslash is an escape character.
+- **git uses a Win32-OpenSSH client.** `setup-windows-no-uac.ps1` sets
+  `core.sshCommand`. Git for Windows otherwise prefers its own bundled MSYS2
+  `ssh.exe`, which cannot reach the Windows `ssh-agent` service that the elevated
+  half enables - Win32-OpenSSH publishes the agent on a named pipe the MSYS2
+  build does not speak. Without this, keys loaded with `ssh-add` from PowerShell
+  are invisible to `git`, and a push falls back to hunting for a key file and
+  prompting for its passphrase. The value uses forward slashes on purpose: git
+  parses `core.sshCommand` with shell quoting rules, in which a backslash is an
+  escape character.
+- **Which `ssh.exe` git gets.** `C:\Tools\rsync\ssh.exe` — the fast build the
+  elevated half unpacks — if it is there, `%WINDIR%/System32/OpenSSH/ssh.exe`
+  otherwise. It is the same client with the same `~/.ssh`, agent and
+  `known_hosts`; the difference is the stdin pump, and without it anything git
+  *pushes* is capped at ~17 MB/s. Candidates are tried by **running** them
+  (`ssh -V`), not by `Test-Path`: the fast build needs a `libcrypto.dll` that an
+  image without the OpenSSH Client capability does not have, and a client that
+  will not start should be found here rather than on the next `git push`.
+  This is also why `setup-windows.bat` now runs the elevated half **first** — the
+  fast `ssh.exe` has to exist before the git config step can prefer it. The
+  non-elevated half still runs even when the elevated one failed; it just falls
+  back. Re-run `setup-windows-no-uac.ps1` on its own at any time to re-pick.
 - All three scripts are idempotent — re-running skips anything already installed.
   BinSkim in particular checks NuGet for the newest stable version *before*
   downloading: the package is a self-contained .NET build well over 100 MB, and
@@ -83,9 +99,306 @@ throwaway VM reachable from a Linux host.
   a profile. Key auth needs `~/.ssh/authorized_keys` ACL'd to just you and
   `SYSTEM`; accounts in the Administrators group use
   `C:\ProgramData\ssh\administrators_authorized_keys` instead.
+- **rsync brings its own `ssh.exe`.** That release ships as one zip per
+  architecture — `rsync-windows-x64.zip` / `rsync-windows-x86.zip`, each holding
+  `rsync.exe`, an `ssh.exe`, `COPYING.txt` and `NOTICE-ssh.txt` — and the
+  elevated half picks the zip for the host architecture (ARM64 takes the x64 one;
+  there is no ARM64 asset), verifies it against the published `.sha256`, unpacks
+  the pair together, then **runs** the unpacked `ssh.exe` and removes it if it
+  will not start. Together is the point:
+  `rsync.exe` prefers an `ssh.exe` sitting in its own directory, and the release
+  builds one because the client Windows ships reads its stdin 3 KB at a time,
+  which holds a transfer *from* the box at ~17 MB/s however fast the link is.
+  Nothing else about it differs — same `~/.ssh`, same `ssh-agent`, same
+  `known_hosts` — and a bare `ssh` still resolves to the in-box client, which
+  sits ahead of `C:\Tools\rsync` on the `PATH`. It links against the
+  `libcrypto.dll` the **OpenSSH Client** capability puts in `System32` (Windows'
+  own LibreSSL, which uses AES-NI) and ships no copy of its own, so the elevated
+  half installs that capability first and falls back to `rsync.exe` alone,
+  warning, on an image that will not offer it.
+- The `rsync` download follows the `releases/latest/download/` redirect rather
+  than the GitHub API: unauthenticated API calls are rate-limited to 60/hour per
+  IP, which a provisioning run behind a shared NAT can genuinely exhaust. To hold
+  a box on a known build, pin the tag in `$RsyncUrl`
+  (`.../releases/download/<tag>/<asset>`) instead.
 - Visual Studio is installed in three labelled passes (base workload, Clang/LLVM,
-  XP toolset) so a failure identifies which component group is responsible.
+  XP toolset) so a failure identifies which component group is responsible. The
+  last two are optional passes: a failure there warns and provisioning continues,
+  because neither is needed to build with MSVC and losing the whole toolchain
+  over a component you can add later from the installer UI is the worse outcome.
+- **Which Visual Studio generation.** `$VsChannel` near the VS step picks it —
+  `17` for VS 2022, `18` for VS 2026 — and `$VsEdition` picks Community /
+  Professional / Enterprise. It is **architecture-split by default**: 17 on x64,
+  where the `v141`/XP toolset in the component list is the reason to pin a
+  generation, and 18 on ARM64, where that toolset cannot exist anyway (no
+  ARM64-hosted 14.16 compiler), so nothing holds Arm back to the older one.
+- **The aka.ms path differs per generation** — VS 2022 is published under
+  `/release/`, VS 2026 under `/stable/`, which `$VsChannelPath` derives. This is
+  not cosmetic: `https://aka.ms/vs/18/release/vs_community.exe` is *not* a 404,
+  it silently redirects to Bing and returns **200 with an HTML body**. A wrong
+  guess there downloads a web page, names it `vs_community.exe`, and fails later
+  at `Start-Process` with something that looks nothing like a bad URL. The step
+  therefore checks the downloaded file starts with an `MZ` header and throws with
+  the URL if it does not.
+- `Get-VsInstallPath` scopes its `vswhere` query to that generation
+  (`-version "[17.0,18.0)"`). That scoping is load-bearing: a bare
+  `vswhere -products *` returns **every** Visual Studio on the box, newest
+  first, and on a machine that already has VS 2026 the old unscoped lookup handed
+  an 18.x install path to a 17.x bootstrapper as `modify --installPath`, so every
+  pass targeted the wrong product. The step now also lists any other generations
+  it found and states that it left them alone — when none of them matches
+  `$VsChannel` the script is about to fetch a second, largely redundant
+  toolchain, and that should be a visible decision. On ARM64, where `$VsChannel`
+  is 18, an existing VS 2026 **is** the match and gets modified in place rather
+  than duplicated.
+- **Windows Performance Analyzer is not part of Visual Studio.** VS has its own
+  Performance Profiler, which is a different, `.diagsession`-based tool and
+  cannot open an `.etl`. WPA ships with `xperf` and `wpr` in the Windows
+  Performance Toolkit, which exists in exactly two places: as an optional
+  *feature* of the Windows SDK (`OptionId.WindowsPerformanceToolkit`) and inside
+  the Windows ADK, which bundles the same toolkit. Whether the SDK install that
+  Visual Studio performs selects that feature varies by version, so the elevated
+  half **detects first** — `%ProgramFiles(x86)%\Windows Kits\10\Windows
+  Performance Toolkit`, its 64-bit twin, and the ADK location — and only falls
+  back to `winget install Microsoft.WindowsADK` when nothing is there. It then
+  re-asserts that directory on the machine `PATH` (the toolkit's own installer
+  usually does this, and the Start Menu gets *Windows Kits > Windows Performance
+  Toolkit* shortcuts for WPA and WPR). To install just the toolkit instead of the
+  whole ADK, run the standalone SDK setup with
+  `winsdksetup.exe /features OptionId.WindowsPerformanceToolkit /q`. A newer WPA
+  also exists in the Microsoft Store (`winget install --id 9N0W1B2BXGNZ --source
+  msstore`); it is not installed here because the Store source needs an
+  interactive, signed-in session, which the unattended elevated half does not
+  have.
+- **Intel VTune Profiler is reported, not installed.** The elevated half prints
+  whether it is on the box, its version, and the path to `vtune.exe`; if it is
+  missing it prints the download page instead (and says so if the CPU is not
+  Intel). Automating the install is not worth it here: the offline installer is a
+  ~750 MB download from a URL carrying a per-release GUID with no "latest"
+  redirect behind it, so every new build would mean editing a hard-coded link,
+  and it is only worth having on Intel silicon since hardware event-based
+  sampling reads Intel PMU counters. It does install unattended if you want it
+  scripted elsewhere:
+
+  ```powershell
+  intel-vtune-<version>_offline.exe -a --silent --cli --eula accept
+  ```
+
+  **Run it from an administrator account, elevated.** Hardware event-based
+  sampling (`uarch-exploration`, `memory-access`, `hotspots -knob
+  sampling-mode=hw`) requires it, and VTune warns about that at the top of every
+  unelevated run. Worth knowing that the failure it gives there is *"cannot
+  recognize the processor"*, which reads like a hardware problem and is not one:
+  the drivers (`sepdrv5`, `sepdal`, `vtss`) are installed and running, and VTune
+  identifies the PMU through them. There is no group to join to get around it —
+  the Linux driver can be handed to a `vtune` group, but on Windows the
+  documented answer is to run as administrator.
+
+- **Collect traces from an elevated Administrator session. Non-elevated
+  collection was tried here and abandoned.** The attempt was to put one ordinary
+  account into `BUILTIN\Performance Log Users`, which appears in the default
+  security descriptors ETW keeps per provider GUID under
+  `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`, and collect without a UAC
+  prompt. It does not survive contact with the real workflow: `xperf -on base`
+  and `wpr -start` drive the *NT Kernel Logger*, reserved for Administrators and
+  LocalSystem, and granting the group `SeSystemProfilePrivilege` ("Profile system
+  performance") plus an explicit ACE for `TRACELOG_ACCESS_KERNEL_LOGGER` on
+  `SystemTraceControlGuid` — all three in place, across a reboot — still answered
+
+  ```text
+  xperf: error: NT Kernel Logger: Access is denied. (0x5).
+  ```
+
+  It is not a check an ACE overrides, and the same wall turned up often enough
+  elsewhere that the whole approach was dropped rather than carried as a
+  half-working path. **Sign in to an administrator account and run `xperf`, `wpr`
+  and VTune from an elevated prompt.** Analysis is the exception and never needed
+  any of this: `wpa.exe` opens an existing `.etl` as a plain user.
+
+  If an earlier revision of these scripts ran on a box, it left that account in
+  the group. Take it back out with:
+
+  ```powershell
+  net localgroup "Performance Log Users" DOMAIN\user /delete
+  ```
+
+  Two revisions also granted the privilege and the ACE. Undo the privilege in
+  `secpol.msc` > Local Policies > User Rights Assignment > "Profile system
+  performance" by removing Performance Log Users. The ACEs sit in the
+  `{9e814aad-3204-11d2-9a82-006008a86939}` value under
+  `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`: strip the `LU` entries
+  from that descriptor rather than deleting the value, which also carries entries
+  for SYSTEM, Administrators and two service accounts.
+
 - The scripts were extracted from a native Windows project, so the component
   selection is tuned for that: Spectre-mitigated runtimes, the v141/XP toolset,
   and driver-kit headers. Trim the component lists in the `.ps1` if you don't
   need them — each group is a plain array near the top.
+
+## Running on ARM64
+
+All three scripts detect the host architecture and adapt. Nothing needs a flag —
+run `setup-windows.bat` exactly as on x64. Both PowerShell halves use
+`RuntimeInformation.OSArchitecture` rather than `%PROCESSOR_ARCHITECTURE%`,
+because an emulated PowerShell reports the *emulated* architecture in the
+environment variable and the real one through the API.
+
+**The build toolchain is fully native.** Visual Studio's ARM64 installer, MSVC
+and clang-cl are all 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`).
+
+**Three native compilers, on purpose.** MSVC and `clang-cl` from Visual Studio,
+plus **upstream LLVM** (`winget install LLVM.LLVM`, which resolves to
+`LLVM-<ver>-woa64.exe` on ARM64 — "Windows on Arm 64"). Upstream runs on its own
+schedule and is usually several major versions ahead of the Clang that VS
+bundles, and it 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` if you use it: 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.
+
+**Clang/LLVM is installed on ARM64 too, and it is native there** — MSVC and
+`clang-cl` over the same sources is the compiler diversity this box is after.
+Two things establish that it is genuinely native rather than an emulated x64
+compiler: 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 already 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 |
+
+Unlike the `v141`/XP group, nothing technical is in the way. Upstream LLVM ships
+a Windows-on-Arm build independently too — `winget install LLVM.LLVM` resolves to
+`LLVM-<ver>-woa64.exe` on ARM64 — if you want a Clang outside Visual Studio.
+
+> 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`. The verification step does exactly
+> that, and warns separately if only a non-native `clang-cl` landed on Arm.
+
+What changes, and why:
+
+| | On ARM64 |
+| --- | --- |
+| **Native ARM64** | Git, Python, .NET SDK, PowerShell, VS Code, Windows Terminal, WinMerge, Brave, Sysinternals, Claude Code, CMake, Ninja, the VS installer, **MSVC, VS clang-cl and upstream LLVM**, MSBuild, the SDK tools (`rc`, `signtool`), the in-box OpenSSH client, and the Windows Performance Toolkit. |
+| **Emulated x64** | `iperf3`, NASM, OpenCppCoverage, BinSkim, `rsync.exe`, Android GPU Inspector, and the WDK/ADK *installers* (the kits they lay down are 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). |
+| **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.
+
+### Two gotchas the audit surfaces
+
+- **Visual Studio bundles an x64 `ninja.exe` even on ARM64**, and `VsDevCmd.bat`
+  **prepends** the VS directories to `PATH`. So inside a Developer Command Prompt
+  — exactly where C++ builds happen — the emulated ninja wins over the native one
+  `winget install Ninja-build.Ninja` provides. It matters more than a one-off
+  tool would, because ninja is re-invoked for every edge in the build graph. Pass
+  `-DCMAKE_MAKE_PROGRAM=` the native one, or put its directory ahead of the VS
+  one. VS's bundled `cmake.exe` *is* ARM64, so only ninja has this problem.
+- **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 installed but limited.** Unlike the rest of the emulated
+  row, this is a real ceiling rather than a slowdown: it collects coverage by
+  debugging the process under test and stepping x86/x64 instructions, so it
+  covers the x86/x64 binaries this box cross-compiles but **not** an ARM64 one.
+  Run coverage against the x64 build. The script prints this rather than leaving
+  you to discover it.
+- **BinSkim is emulated and that is fine.** The NuGet package publishes `win-x64`
+  only (its other RIDs are `linux-x64`, `linux-arm64`, `osx-x64`). 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. The RID list is ordered preference, so a future `win-arm64` build is
+  picked up with no further change.
+- **`rsync` gets the x64 zip, and its `ssh.exe` is verified by running it.** The
+  release publishes x64 and x86 assets and no ARM64 one. x64 is still the right
+  pick — a transfer is bounded by the socket, not by emulated CPU, so lifting the
+  in-box client's 3 KB stdin cap wins by far more than emulation costs. But that
+  `ssh.exe` links against a `System32\libcrypto.dll` which on ARM64 is an **ARM64**
+  binary, so it may not load at all. 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 that names a dead remote shell rather than `ssh.exe`. The elevated half
+  therefore runs `ssh -V` after unpacking and **deletes** the binary if it will
+  not start, so `rsync` falls back to the native in-box client. 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 still installed** (as emulated x64) because this box cross-compiles
+  x86/x64, where NASM is the assembler that builds them. If you only target
+  ARM64, nothing uses it — the ARM64 assembler is `armasm64.exe`, which ships
+  with MSVC.
+- **`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.