diff --git a/changelog.d/7352-windows-groundwork.md b/changelog.d/7352-windows-groundwork.md
new file mode 100644
index 0000000000..787074a0be
--- /dev/null
+++ b/changelog.d/7352-windows-groundwork.md
@@ -0,0 +1,32 @@
+### Windows: COFF section and PE lookup land; the walker is what is still missing
+
+Two of the three pieces Windows needs are in, and the third is named rather
+than glossed.
+
+**The section.** COFF joins Mach-O and ELF as a first-class object format in
+the map emitter. Its name is `.pgcmap`, not `.perry_gcmap`, and that is
+load-bearing: a **PE image section header has an 8-byte name field**, and long
+names survive only in object files as a string-table offset the linker does not
+carry into the image. A 12-byte name would be truncated on the way in and the
+runtime could never match it.
+
+**The lookup.** The runtime can find that section in a running PE image —
+`GetModuleHandleW(NULL)` gives the image base, which is also the
+`IMAGE_DOS_HEADER`; the section table follows the optional header, whose size
+the file header records rather than being fixed.
+
+**The walker is missing, so Windows stays refused.** `_Unwind_*` does not exist
+there, and the walker module is gated to Apple and Linux — on Windows it falls
+to the stub, no frame is ever visited, and the collector would free live
+objects. Emitting a map anyway would produce exactly the silent-lost-roots
+failure this backend exists to make impossible, so the compiler refuses the
+target with a message that says which piece is absent.
+
+A walker there means either `RtlVirtualUnwind`, or an fp-chain walk (Perry
+forces frame pointers, so RBP does chain) — and it wants a Windows host to
+develop against, which is why this lands staged rather than half-enabled.
+
+Verified: the PE lookup compiles for `x86_64-pc-windows-msvc` in isolation. The
+full crate cannot be cross-checked from macOS because `psm`/`stacker` build
+scripts need a C cross-compiler — the same blocker that stops local watchOS and
+visionOS checks, unrelated to this code. CI's `windows-build` job covers it.
diff --git a/changelog.d/7353-apple-cpu-baseline.md b/changelog.d/7353-apple-cpu-baseline.md
new file mode 100644
index 0000000000..9fcb79b0cb
--- /dev/null
+++ b/changelog.d/7353-apple-cpu-baseline.md
@@ -0,0 +1,28 @@
+### Pin `apple-m1` instead of `-mcpu=native` on Apple aarch64
+
+`gc-native-roots` has never gone green — **0 successes in 40 runs** — and the
+current cause is not a GC bug at all:
+
+```
+fatal error: error in backend: Cannot select: intrinsic %llvm.aarch64.fjcvtzs
+```
+
+`inprocess.rs` already documents the invariant this breaks. Codegen decides
+whether to emit `llvm.aarch64.fjcvtzs` (FEAT_JSCVT, the single-instruction
+ECMAScript `ToInt32`) **from the triple alone**, because clang's default CPU for
+`arm64-apple-*` is `apple-m1`. Anything that then compiles that IR for a CPU
+without the feature aborts. The doc calls out the generic-TargetMachine half of
+that pair; `-mcpu=native` is the other half, and it fails the same way wherever
+CPU detection disagrees with the triple assumption — which is exactly what a
+virtualised macOS CI runner does. The identical command works on a physical Mac,
+which is why this only ever failed in CI.
+
+Apple aarch64 hosts now pass an explicit `-mcpu=apple-m1` rather than `native`,
+making what Perry emits and what it targets the same decision instead of two
+that happen to agree on developer hardware. Every other host keeps native
+tuning.
+
+Verified on hardware: `native_tuning_arg = -mcpu=apple-m1` in the recorded
+compile plan, and all ten gc-ratchet probes still byte-match the pinned Node
+oracle under `PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1
+PERRY_GC_VERIFY_EVACUATION=1`.
diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs
index 0cdfe69b4c..1a0778c30d 100644
--- a/crates/perry-codegen/src/gc_map.rs
+++ b/crates/perry-codegen/src/gc_map.rs
@@ -74,6 +74,15 @@ const MACHO_SECTION: &str = "__PERRY_GCMAP,__perry_gcmap";
/// at all. Measured: the section is present in the object (PROGBITS, SHF_ALLOC,
/// with relocations) and absent from the linked binary.
const ELF_SECTION: &str = ".perry_gcmap,\"awR\",@progbits";
+/// COFF/PE. The name is SHORT on purpose: a PE image section header has an
+/// 8-byte name field, and long names survive only in object files (as a `/nnn`
+/// string-table offset) — the linker cannot put `.perry_gcmap` in the image, so
+/// the runtime would never find it by name. `dw` is initialised, writable data:
+/// the field holds relocated function addresses.
+const COFF_SECTION: &str = ".pgcmap,\"dw\"";
+/// What the runtime looks for in a PE image. Must match `COFF_SECTION`'s name
+/// and stay within eight bytes.
+pub(crate) const COFF_SECTION_NAME: &str = ".pgcmap";
/// LLVM stack-map v3 location kinds. Only these two describe a frame slot;
/// `Constant`/`ConstIndex` carry the statepoint preamble and `Register` cannot
@@ -703,17 +712,37 @@ fn verify_roundtrip(functions: &[FunctionMap], stream: &[u8]) -> Result<(), Stri
/// produce, and the runtime would be reading two pointers as one. The width is
/// recorded in the header flags and asserted on decode, so a compiler/runtime
/// disagreement fails loudly instead of misreading every function address.
-fn emit_asm(functions: &[FunctionMap], stream: &[u8], elf: bool, ptr64: bool) -> String {
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ObjectFormat {
+ MachO,
+ Elf,
+ Coff,
+}
+
+fn format_for(target: &str) -> ObjectFormat {
+ if target.contains("apple") || target.contains("darwin") {
+ ObjectFormat::MachO
+ } else if target.contains("windows") || target.contains("msvc") {
+ ObjectFormat::Coff
+ } else {
+ ObjectFormat::Elf
+ }
+}
+
+fn emit_asm(functions: &[FunctionMap], stream: &[u8], format: ObjectFormat, ptr64: bool) -> String {
let record_total: usize = functions.iter().map(|f| f.records.len()).sum();
let addr_bytes = if ptr64 { 8 } else { 4 };
let entry_bytes = addr_bytes + 8; // address + u32 stack_size + u32 records
let total_len = 16 + functions.len() * entry_bytes + record_total * 4 + stream.len();
let mut out = String::new();
- if elf {
- out.push_str(&format!("\t.section\t{ELF_SECTION}\n"));
- } else {
- out.push_str(&format!("\t.section\t{MACHO_SECTION}\n"));
- }
+ out.push_str(&format!(
+ "\t.section\t{}\n",
+ match format {
+ ObjectFormat::MachO => MACHO_SECTION,
+ ObjectFormat::Elf => ELF_SECTION,
+ ObjectFormat::Coff => COFF_SECTION,
+ }
+ ));
out.push_str("\t.p2align\t3\n");
out.push_str(&format!("{GC_MAP_LABEL}:\n"));
out.push_str(&format!(
@@ -767,11 +796,7 @@ struct GcMapStats {
/// that fails to parse is a hard error in `compact_and_assemble`. Keeping
/// LLVM's section in that case would look conservative and would in fact lose
/// the module's roots, because the runtime reads only the compact section.
-fn compact_stack_map_asm(
- asm: &str,
- elf: bool,
- target: &str,
-) -> Result