From 4136e23e1972e077b28d624fb863a783d6db0263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 13 Jun 2026 07:49:23 +0200 Subject: [PATCH 1/2] feat(watchos): arm64_32 device support (Series 4-8 / SE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real pre-S9 Apple Watches use the arm64_32 architecture (64-bit ISA, 32-bit pointers). Perry never produced a working binary for them, and an old note claimed it was architecturally impossible (NaN-boxing needs 64-bit usize). That's overstated: a 32-bit pointer fits trivially in the 48-bit NaN payload — only a few heap-range guard heuristics and literal constants assumed a 64-bit address space. Runtime (32-bit literal fixes; also benefit wasm32): - array/from_concat: (1usize << 53) saturates via try_from on 32-bit - box.rs / formatting.rs / value/dynamic_object.rs: 2^47/2^48 pointer- guard upper bounds compared in u64 so the literal isn't out-of-range for 32-bit usize (no-op on 32-bit — no addresses reach that high) The heap-min *value* guards already pick the low (0x1000) floor on target_os=watchos, so no value changes were needed there. Compile driver (opt-in via PERRY_WATCHOS_ARM64_32, so the default watchos=arm64/S9+ path is untouched): - codegen emits the C entry via PERRY_ENTRY_SYMBOL (e.g. _perry_user_main) instead of renaming _main with rust-objcopy afterwards — objcopy's MachOWriter segfaults on arm64_32 Mach-O. The link-time objcopy rename is skipped on arm64_32 accordingly. - helpers.rs / app_metadata.rs / link/mod.rs / platform_cmd.rs / bundle_apple.rs resolve the arm64_32-apple-watchos triple (codegen, auto-optimize runtime rebuild, native-lib build, swiftc, link) and a low MinimumOSVersion (PERRY_WATCHOS_MIN, default 11.0). Validated end to end: built a real game (Bloom Jump) — perry-runtime + perry-ui-watchos + engine native lib all compile for arm64_32-apple- watchos; the game links to a Mach-O 'architecture: arm64_32, platform WATCHOS, minos 11.0' with __perry_user_main and _main both defined and no undefined refs. (Testable only on real pre-S9 hardware — the simulator is arm64.) Co-authored from a parked WIP exploring this target. --- crates/perry-codegen/src/codegen/entry.rs | 10 +++++++++- crates/perry-codegen/src/codegen/helpers.rs | 6 ++++++ crates/perry-runtime/src/array/from_concat.rs | 4 +++- crates/perry-runtime/src/box.rs | 4 ++-- crates/perry-runtime/src/builtins/formatting.rs | 5 ++++- .../perry-runtime/src/value/dynamic_object.rs | 2 +- .../perry/src/commands/compile/app_metadata.rs | 7 +++++++ .../perry/src/commands/compile/bundle_apple.rs | 11 +++++++++-- crates/perry/src/commands/compile/link/mod.rs | 13 +++++++++++-- .../src/commands/compile/link/platform_cmd.rs | 17 ++++++++++++++--- 10 files changed, 66 insertions(+), 13 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index d1bc00f1c1..6f08956d9d 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -132,7 +132,15 @@ pub(super) fn compile_module_entry( let main = if is_dylib { llmod.define_function("perry_module_init", VOID, vec![]) } else { - llmod.define_function("main", I32, vec![]) + // Allow the host build to override the C entry symbol. On arm64_32 + // watchOS we can't rename `_main → __perry_user_main` after the + // fact (rust-objcopy's MachOWriter crashes on arm64_32 objects), so + // we emit the final symbol directly. Pass e.g. `_perry_user_main` + // (the leading underscore yields Mach-O `__perry_user_main`, which + // the Swift `@main` shell references via @_silgen_name). + let entry_name = std::env::var("PERRY_ENTRY_SYMBOL") + .unwrap_or_else(|_| "main".to_string()); + llmod.define_function(&entry_name, I32, vec![]) }; main.add_pre_return_void_call("js_typed_feedback_maybe_dump_trace"); let _ = main.create_block("entry"); diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 680a018858..eed8183e15 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -397,6 +397,12 @@ pub fn resolve_target_triple(name: &str) -> Option { "ios-simulator" => Some("arm64-apple-ios17.0-simulator".to_string()), "visionos" => Some("arm64-apple-xros1.0".to_string()), "visionos-simulator" => Some("arm64-apple-xros1.0-simulator".to_string()), + // arm64_32 (Series 4-8 / SE) when opted in via PERRY_WATCHOS_ARM64_32; + // otherwise arm64 (S9+). Sets the arch of the emitted TS object files, + // which must match the runtime/native-lib/link triples. + "watchos" if std::env::var("PERRY_WATCHOS_ARM64_32").is_ok() => { + Some("arm64_32-apple-watchos".to_string()) + } "watchos" => Some("aarch64-apple-watchos".to_string()), "watchos-simulator" => Some("arm64-apple-watchos10.0-simulator".to_string()), "tvos" => Some("aarch64-apple-tvos".to_string()), diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 84eb49d8c6..59471d18c2 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -413,7 +413,9 @@ fn array_like_length(items: f64) -> usize { let n = n.floor(); let max = (1u64 << 53) as f64 - 1.0; if n > max { - return (1usize << 53) - 1; + // 2^53 - 1 (JS max safe integer). usize can't represent this on 32-bit + // targets (arm64_32 watchOS, wasm32), so saturate to usize::MAX there. + return usize::try_from((1u64 << 53) - 1).unwrap_or(usize::MAX); } n as usize } diff --git a/crates/perry-runtime/src/box.rs b/crates/perry-runtime/src/box.rs index 97c5dbd269..8304c9b36e 100644 --- a/crates/perry-runtime/src/box.rs +++ b/crates/perry-runtime/src/box.rs @@ -87,7 +87,7 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { // address (alloc gives 8-aligned pointers in user space) // matches `is_plausible_box_ptr` to keep this a no-op for // any pathological entry. - if addr >= 0x1000 && addr < 0x0001_0000_0000_0000 && addr % 8 == 0 { + if addr >= 0x1000 && (addr as u64) < 0x0001_0000_0000_0000 && addr % 8 == 0 { unsafe { visitor.visit_nanbox_f64_raw_slot(&raw mut (*ptr).value); } @@ -200,7 +200,7 @@ fn is_plausible_box_ptr(ptr: *mut Box) -> bool { if addr < 0x1000 { return false; } - if addr >= 0x0001_0000_0000_0000 { + if (addr as u64) >= 0x0001_0000_0000_0000 { return false; } if addr % std::mem::align_of::() != 0 { diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index 88dbb0b5c3..c769ba7074 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -1694,7 +1694,10 @@ fn looks_like_raw_heap_pointer(value: f64) -> bool { return false; } let addr = bits as usize; - (0x1000..0x8000_0000_0000usize).contains(&addr) && addr >= crate::gc::GC_HEADER_SIZE + 0x1000 + // Compare in u64 so the 2^47 upper bound stays in range on 32-bit targets + // (arm64_32 watchOS, wasm32), where it's a no-op — no addresses that high. + (0x1000..0x8000_0000_0000u64).contains(&(addr as u64)) + && addr >= crate::gc::GC_HEADER_SIZE + 0x1000 } fn formatted_deep_equal(left: f64, right: f64, skip_prototype: bool) -> bool { diff --git a/crates/perry-runtime/src/value/dynamic_object.rs b/crates/perry-runtime/src/value/dynamic_object.rs index b3caa128d7..d8df1018a2 100644 --- a/crates/perry-runtime/src/value/dynamic_object.rs +++ b/crates/perry-runtime/src/value/dynamic_object.rs @@ -87,7 +87,7 @@ pub extern "C" fn js_value_length_f64(value: f64) -> f64 { target_os = "visionos", )))] let heap_min: usize = 0x200_0000_0000; - if handle < heap_min || handle >= 0x8000_0000_0000 { + if handle < heap_min || (handle as u64) >= 0x8000_0000_0000 { return 0.0; } if let Some(value) = unsafe { diff --git a/crates/perry/src/commands/compile/app_metadata.rs b/crates/perry/src/commands/compile/app_metadata.rs index d4cc9634dd..3a7adcdebe 100644 --- a/crates/perry/src/commands/compile/app_metadata.rs +++ b/crates/perry/src/commands/compile/app_metadata.rs @@ -158,6 +158,13 @@ pub(super) fn rust_target_triple(target: Option<&str>) -> Option<&'static str> { Some("visionos-simulator") => Some("aarch64-apple-visionos-sim"), Some("visionos") => Some("aarch64-apple-visionos"), Some("watchos-simulator") => Some("aarch64-apple-watchos-sim"), + // arm64_32 watchOS (Series 4-8 / SE) when opted in; otherwise arm64 + // (S9+). Governs the rust target used for the auto-optimize runtime + // rebuild and for building/resolving native libraries, so all three + // must agree with the link triple in platform_cmd.rs / link/mod.rs. + Some("watchos") if std::env::var("PERRY_WATCHOS_ARM64_32").is_ok() => { + Some("arm64_32-apple-watchos") + } Some("watchos") => Some("aarch64-apple-watchos"), Some("tvos-simulator") => Some("aarch64-apple-tvos-sim"), Some("tvos") => Some("aarch64-apple-tvos"), diff --git a/crates/perry/src/commands/compile/bundle_apple.rs b/crates/perry/src/commands/compile/bundle_apple.rs index a2f215c146..dd6564ce72 100644 --- a/crates/perry/src/commands/compile/bundle_apple.rs +++ b/crates/perry/src/commands/compile/bundle_apple.rs @@ -173,10 +173,17 @@ pub(super) fn bundle_for_watchos( // #4849: read version/build_number from perry.toml (was hardcoded "1.0"). let (app_version, app_build_number) = read_apple_app_version(input); - // Device builds are arm64-only, which requires watchOS 26 (S9+); the - // simulator target keeps the lower floor. + // Device builds default to arm64-only, which requires watchOS 26 (S9+). + // arm64_32 device builds (PERRY_WATCHOS_ARM64_32) reach pre-S9 watches and + // use a low floor (overridable via PERRY_WATCHOS_MIN); the simulator target + // keeps the lower floor too. + let arm64_32 = target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); + let min_os_owned; let min_os = if target == Some("watchos-simulator") { "10.0" + } else if arm64_32 { + min_os_owned = std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); + min_os_owned.as_str() } else { "26.0" }; diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index ce5ad94cb4..4315e61118 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -1762,11 +1762,20 @@ pub(super) fn build_and_run_link( } else { "watchos" }; + // arm64_32 watchOS (Series 4-8 / SE): opt-in, matches the app + // binary's triple in platform_cmd.rs so the native @main lib + // links against the same arch. + let swift_arm64_32 = target == Some("watchos") + && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); + let swift_watchos_min = + std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); + let swift_triple_owned; let swift_triple = if target == Some("watchos-simulator") { "arm64-apple-watchos10.0-simulator" + } else if swift_arm64_32 { + swift_triple_owned = format!("arm64_32-apple-watchos{}", swift_watchos_min); + swift_triple_owned.as_str() } else { - // Device builds are arm64-only (S9+ / watchOS 26): Perry's - // NaN-boxed values need 64-bit pointers, which arm64_32 lacks. "arm64-apple-watchos26.0" }; let swift_sysroot = String::from_utf8( diff --git a/crates/perry/src/commands/compile/link/platform_cmd.rs b/crates/perry/src/commands/compile/link/platform_cmd.rs index 545dbdee40..b1db7096c2 100644 --- a/crates/perry/src/commands/compile/link/platform_cmd.rs +++ b/crates/perry/src/commands/compile/link/platform_cmd.rs @@ -53,11 +53,19 @@ pub fn select_linker_command( )? .trim() .to_string(); + // arm64_32 watchOS (Series 4-8 / SE): opt-in via PERRY_WATCHOS_ARM64_32. + // Lets the device target reach pre-S9 watches; deployment min defaults + // low (these watches run watchOS 9-11) but is overridable. + let arm64_32 = target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); + let watchos_min = std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); + let triple_owned; let triple = if target == Some("watchos-simulator") { "arm64-apple-watchos10.0-simulator" + } else if arm64_32 { + triple_owned = format!("arm64_32-apple-watchos{}", watchos_min); + triple_owned.as_str() } else { - // Device builds are arm64-only (S9+ / watchOS 26): Perry's - // NaN-boxed values need 64-bit pointers, which arm64_32 lacks. + // Device builds default to arm64-only (S9+ / watchOS 26). "arm64-apple-watchos26.0" }; @@ -108,7 +116,10 @@ pub fn select_linker_command( .unwrap_or(false) }) }); - if let Some(entry_obj) = entry_obj { + // arm64_32: rust-objcopy crashes on these Mach-O objects, so the entry + // symbol was emitted directly by codegen (PERRY_ENTRY_SYMBOL) instead of + // renamed here. Skip the objcopy pass entirely. + if let Some(entry_obj) = entry_obj.filter(|_| !arm64_32) { let objcopy = std::env::var("HOME").ok() .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/rust-objcopy")) .filter(|p| p.exists()) From edd3b4c0a9fc891a984d83ecc8dc40cc1378e7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 13 Jun 2026 07:57:24 +0200 Subject: [PATCH 2/2] style: rustfmt the arm64_32 entry-symbol changes --- crates/perry-codegen/src/codegen/entry.rs | 4 ++-- crates/perry/src/commands/compile/link/mod.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 6f08956d9d..cc6cc64850 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -138,8 +138,8 @@ pub(super) fn compile_module_entry( // we emit the final symbol directly. Pass e.g. `_perry_user_main` // (the leading underscore yields Mach-O `__perry_user_main`, which // the Swift `@main` shell references via @_silgen_name). - let entry_name = std::env::var("PERRY_ENTRY_SYMBOL") - .unwrap_or_else(|_| "main".to_string()); + let entry_name = + std::env::var("PERRY_ENTRY_SYMBOL").unwrap_or_else(|_| "main".to_string()); llmod.define_function(&entry_name, I32, vec![]) }; main.add_pre_return_void_call("js_typed_feedback_maybe_dump_trace"); diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 4315e61118..b0416a2a71 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -1765,8 +1765,8 @@ pub(super) fn build_and_run_link( // arm64_32 watchOS (Series 4-8 / SE): opt-in, matches the app // binary's triple in platform_cmd.rs so the native @main lib // links against the same arch. - let swift_arm64_32 = target == Some("watchos") - && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); + let swift_arm64_32 = + target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); let swift_watchos_min = std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); let swift_triple_owned;