@tishlang/tish-lsp 3.2.2 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -834,6 +834,7 @@ pub fn compile_with_native_modules_emit_exports(
834
834
  || m.crate_name == "tishlang_ios"
835
835
  });
836
836
  g.emit_program(&program)?;
837
+ g.emit_gba_key_table();
837
838
  Ok(g.output)
838
839
  }
839
840
 
@@ -998,6 +999,10 @@ pub(crate) enum VecRetKind {
998
999
  F64,
999
1000
  Unit,
1000
1001
  VecF64,
1002
+ /// #605. Every `return` yields a string, so the fn can be a native `-> String` instead of a
1003
+ /// boxed closure. OWNED, not `&str`: a returned string outlives the frame that built it, and a
1004
+ /// borrow would need a lifetime the generated free fn has nowhere to name.
1005
+ Str,
1001
1006
  }
1002
1007
 
1003
1008
  #[derive(Debug, Clone)]
@@ -1097,6 +1102,21 @@ pub(crate) struct Codegen {
1097
1102
  /// Top-level `let name = [f64 literals…]` never reassigned — emitted as `const G_name: [f64; N]`
1098
1103
  /// for direct indexing from native fns (fasta `codes`/`probs`).
1099
1104
  module_const_f64_arrays: std::collections::HashMap<String, Vec<f64>>,
1105
+ /// #597: names passed DIRECTLY as a call argument (`fill(reqs)`). A typed array in this set must
1106
+ /// not lower to a native `Vec` — see the note at the `VarDecl` typing site.
1107
+ arrays_passed_to_calls: std::collections::HashSet<String>,
1108
+ /// The STATIC names from `module_const_f64_arrays` whose every element is an exact integer in
1109
+ /// `i32` range, so the hoist emits `[i32; N]` instead of `[f64; N]`.
1110
+ ///
1111
+ /// ARM7TDMI HAS NO FPU. These passes were written for the host, where `f64` is tish's number and
1112
+ /// the representation is free; on GBA every `f64` touch is a soft-float call and every element
1113
+ /// costs 8 bytes instead of 4. Worse, the source usually declares the element type — hoisting
1114
+ /// `let A: i32[] = [1,2,3]` to `[f64; 3]` discards a type the program already stated, then pays
1115
+ /// an `i32 -> f64 -> usize` round trip on each index to get back to where it started.
1116
+ ///
1117
+ /// Keyed by static name, not source name, so every read site (including the `_cum` aliases) can
1118
+ /// ask the question with what it already has in hand.
1119
+ module_const_int_statics: std::collections::HashSet<String>,
1100
1120
  /// Precomputed cumulative arrays for `module_const_f64_arrays` used as `cumulative(p)` input
1101
1121
  /// (`probs` → `G_probs_cum`).
1102
1122
  module_const_f64_cum: std::collections::HashMap<String, String>,
@@ -1120,6 +1140,12 @@ pub(crate) struct Codegen {
1120
1140
  /// free `fn f_native(f64,..)->f64` (all params `: number`, returns `number`, native-safe
1121
1141
  /// body). Direct calls to these route to the native fn, bypassing the boxed `value_call`.
1122
1142
  native_fns: std::collections::HashSet<String>,
1143
+ /// Set while emitting the body of an i32-ABI native fn, so `return` coerces to `i32`.
1144
+ native_fn_abi_i32: bool,
1145
+ /// The subset of `native_fns` emitted with an `fn(i32, ..) -> i32` signature instead of the
1146
+ /// f64 one — every parameter and the return annotated `: i32`. On ARM7TDMI this is the whole
1147
+ /// point: an f64 ABI makes every argument and every return a soft-float conversion.
1148
+ native_fns_i32: std::collections::HashSet<String>,
1123
1149
  /// True while emitting an M5 `fn name_native` body — keeps VarDecl inits on the native path.
1124
1150
  native_fn_body_emit: bool,
1125
1151
  /// M5 fn currently being emitted (`mandel_native`, `fastaRandom_native`, …).
@@ -1209,6 +1235,17 @@ pub(crate) struct Codegen {
1209
1235
  /// `RustType::Value`. This is the rust-AOT analogue of the VM array-JIT bailing to the
1210
1236
  /// interpreter on a non-numeric element. See `collect_demoted_numeric_locals`.
1211
1237
  demoted_numeric_locals: std::collections::HashSet<String>,
1238
+ /// Top-level fns whose EVERY return is provably numeric → the native type a call to one yields.
1239
+ ///
1240
+ /// Without it a call is opaque, so a local assigned from one demotes to a boxed `Value` and
1241
+ /// every arithmetic consumer falls back to the generic `ops::add` plus two soft-float
1242
+ /// conversions — most of the ~92 ticks a user-fn call costs on GBA
1243
+ /// (`docs/issues/user-fn-call-cost-on-gba.md`).
1244
+ ///
1245
+ /// KEYED ON A PROOF, NOT THE ANNOTATION. `function f(): i32 { return "no" }` compiles and
1246
+ /// returns the string — return types are erased — so trusting the declaration would reinterpret
1247
+ /// a `String` as a number.
1248
+ user_fn_ret: std::collections::HashMap<String, RustType>,
1212
1249
  /// Integer-range lattice (#174): names of `f64` locals the analysis proves always hold an
1213
1250
  /// integer within `[min, max]`, both strictly inside `(-2^53, 2^53)` so `as i64` is exact and
1214
1251
  /// `i64` arithmetic is bit-identical to the `f64` the interpreter/VM use. Lets the codegen
@@ -1293,6 +1330,16 @@ pub(crate) struct Codegen {
1293
1330
  /// `mod __scheme_<name>_<j> { … }` blocks and their per-scheme registration. The handle a tish
1294
1331
  /// name binds to is its index AMONG same-scheme entries (= its slot in that scheme's arena).
1295
1332
  scheme_files: Vec<(String, String)>,
1333
+ /// GBA only: the program-wide intern table for OBJECT-LITERAL KEYS, in first-seen order,
1334
+ /// plus the name→index map. `RefCell` so `cached_object_key` can stay `&self`.
1335
+ ///
1336
+ /// ⚠️ This is a HEAP fix, not a tidiness one. Off-GBA every key site gets its own `OnceLock`,
1337
+ /// which dedupes repeated evaluation of ONE site but never across sites — and a data table is
1338
+ /// N sibling object literals, so N separate sites each allocate their own copy of every key
1339
+ /// name. A real ROM measured 1,890 key sites over 474 distinct names: 1,416 duplicate `Arc<str>`
1340
+ /// allocations that exist only to spell "name" and "id" again. One table, indexed, kills all of
1341
+ /// them. See `cached_object_key`.
1342
+ gba_keys: std::cell::RefCell<(Vec<String>, std::collections::HashMap<String, usize>)>,
1296
1343
  /// Program links `tish:macos` / `tish:ios` — skip HeadlessHost install.
1297
1344
  has_native_ui_host: bool,
1298
1345
  /// Program references browser global `document` — inject tish-canvas.
@@ -1351,12 +1398,16 @@ impl Codegen {
1351
1398
  refcell_wrapped_vars: std::collections::HashSet::new(),
1352
1399
  native_numeric_globals: std::collections::HashMap::new(),
1353
1400
  module_const_f64_arrays: std::collections::HashMap::new(),
1401
+ module_const_int_statics: std::collections::HashSet::new(),
1402
+ arrays_passed_to_calls: std::collections::HashSet::new(),
1354
1403
  module_const_f64_cum: std::collections::HashMap::new(),
1355
1404
  module_const_f64_aliases: std::collections::HashMap::new(),
1356
1405
  map_instance_locals: std::collections::HashSet::new(),
1357
1406
  collection_instance_locals: std::collections::HashSet::new(),
1358
1407
  hoisted_string_chars: std::collections::HashMap::new(),
1359
1408
  native_fns: std::collections::HashSet::new(),
1409
+ native_fns_i32: std::collections::HashSet::new(),
1410
+ native_fn_abi_i32: false,
1360
1411
  native_fn_body_emit: false,
1361
1412
  native_fn_emit_name: None,
1362
1413
  native_fn_literal_args: std::collections::HashMap::new(),
@@ -1383,6 +1434,7 @@ impl Codegen {
1383
1434
  aggregate_array_locals: std::collections::HashSet::new(),
1384
1435
  agg_cur_ret: None,
1385
1436
  demoted_numeric_locals: std::collections::HashSet::new(),
1437
+ user_fn_ret: std::collections::HashMap::new(),
1386
1438
  int_range_locals: std::collections::HashMap::new(),
1387
1439
  diag_coord_indices: std::collections::HashSet::new(),
1388
1440
  int_i32_vec_locals: std::collections::HashSet::new(),
@@ -1408,6 +1460,7 @@ impl Codegen {
1408
1460
  emit_mode: crate::NativeEmitMode::DesktopBin,
1409
1461
  entry_exports: Vec::new(),
1410
1462
  scheme_files: Vec::new(),
1463
+ gba_keys: std::cell::RefCell::new((Vec::new(), std::collections::HashMap::new())),
1411
1464
  has_native_ui_host: false,
1412
1465
  program_uses_document: false,
1413
1466
  cse_subst: Vec::new(),
@@ -2393,6 +2446,20 @@ impl Codegen {
2393
2446
  "box", "do", "final", "macro", "override", "priv", "try", "typeof", "unsized",
2394
2447
  "virtual", "yield",
2395
2448
  ];
2449
+ // #549: FOUR KEYWORDS CANNOT BE RAW IDENTIFIERS AT ALL. `r#self`, `r#Self`, `r#super` and
2450
+ // `r#crate` are rejected by rustc outright ("`self` cannot be a raw identifier"), so the
2451
+ // blanket `r#` below emitted a program that could not compile. These are ordinary names in
2452
+ // tish and the interpreter accepts them, which made this a backend divergence that REJECTED
2453
+ // VALID SOURCE rather than merely lowering it differently.
2454
+ //
2455
+ // They are renamed instead. The suffix is deliberately not a bare `_`: `self_` is a name a
2456
+ // program could plausibly use itself, and colliding with it would trade a compile error for
2457
+ // a silent capture of the wrong binding. `__tish_kw_` cannot be written in tish source,
2458
+ // because a leading double underscore is reserved by the compiler for generated temporaries.
2459
+ const NON_RAW_KEYWORDS: &[&str] = &["self", "Self", "super", "crate"];
2460
+ if NON_RAW_KEYWORDS.contains(&name) {
2461
+ return Cow::Owned(format!("__tish_kw_{}", name));
2462
+ }
2396
2463
  if RUST_KEYWORDS.contains(&name) {
2397
2464
  Cow::Owned(format!("r#{}", name))
2398
2465
  } else {
@@ -3112,27 +3179,79 @@ impl Codegen {
3112
3179
  // M5 (default-on via native_opts_enabled; TISH_NATIVE_OPT=0 opts out): emit a parallel
3113
3180
  // native `fn f_native` for each eligible top-level numeric fn at top level; direct calls
3114
3181
  // route to it in emit_typed_expr.
3115
- // Gba mode: these passes emit `thread_local!` and other std-isms; gate them off initially
3116
- // (they are pure perf — correctness is unaffected). Re-audit for no_std later.
3117
- if crate::native_opts_enabled() && self.emit_mode != crate::NativeEmitMode::Gba {
3182
+ if crate::native_opts_enabled() {
3183
+ // MODULE STATE GETS A TOP-LEVEL HOME ON GBA TOO (#594). These passes used to be skipped
3184
+ // there because the numeric-global one emits `thread_local!`, which no_std has not got —
3185
+ // and the const-array pass was collateral, gated only because it shared the block. The
3186
+ // consequence was the bug this issue reports: a lowered free `fn` is emitted at top
3187
+ // level, so every module binding it reads is a local of `run()` that does not exist at
3188
+ // that scope, and the emitted crate fails with E0425.
3189
+ //
3190
+ // A const numeric array is `const G_X: [f64; N] = [..]` — no std in it at all. A mutable
3191
+ // numeric global becomes `SingleCore<Cell<f64>>`, the facade's single-core static whose
3192
+ // `with()` deliberately mirrors `LocalKey::with` (compat.rs), so every read and write the
3193
+ // emitter already produces (`G.with(|c| c.get())`) is unchanged.
3194
+ let gba = self.emit_mode == crate::NativeEmitMode::Gba;
3118
3195
  self.native_numeric_globals =
3119
3196
  Self::collect_native_numeric_globals(&program.statements);
3120
3197
  if !self.native_numeric_globals.is_empty() {
3121
- self.emit_native_numeric_global_tls()?;
3198
+ if gba {
3199
+ self.emit_native_numeric_global_singlecore()?;
3200
+ } else {
3201
+ self.emit_native_numeric_global_tls()?;
3202
+ }
3122
3203
  self.writeln("");
3123
3204
  }
3124
3205
  self.module_const_f64_arrays =
3125
3206
  Self::collect_module_const_f64_arrays(&program.statements);
3126
- self.module_const_f64_cum =
3127
- Self::collect_module_const_cum(&program.statements, &self.module_const_f64_arrays);
3128
- self.module_const_f64_aliases =
3129
- Self::collect_module_const_aliases(&program.statements, &self.module_const_f64_cum);
3207
+ self.module_const_f64_cum = Self::collect_module_const_cum(
3208
+ &program.statements,
3209
+ &self.module_const_f64_arrays,
3210
+ );
3211
+ self.module_const_f64_aliases = Self::collect_module_const_aliases(
3212
+ &program.statements,
3213
+ &self.module_const_f64_cum,
3214
+ );
3215
+ // #597: which annotated arrays escape into a call, and so must stay boxed to preserve
3216
+ // aliasing. Collected once over the whole program — an array declared here can be
3217
+ // passed to a fn declared anywhere.
3218
+ //
3219
+ // GATED ON GBA, and not only because the RESULT is GBA-only: this scan runs
3220
+ // `scan_param_use` over the whole program once per annotated array, so it is
3221
+ // O(arrays x program). Off-target that is pure waste — the set is never read — and it
3222
+ // would be a silent compile-time tax on every host build.
3223
+ if self.emit_mode == crate::NativeEmitMode::Gba {
3224
+ let mut array_names: Vec<String> = Vec::new();
3225
+ Self::collect_annotated_array_names(&program.statements, &mut array_names);
3226
+ self.arrays_passed_to_calls =
3227
+ Self::collect_escaping_array_names(&program.statements, &array_names);
3228
+ }
3229
+ // Pick the integer representation where the values allow it. GBA only: off-target the
3230
+ // `f64` layout is free and eligibility must stay byte-identical to before.
3231
+ //
3232
+ // A `_cum` array is deliberately NOT considered — the fasta ladder that consumes one
3233
+ // compares it against an `r_f64` and is specialised around f64 arms, so leaving cum
3234
+ // alone keeps that path untouched.
3235
+ if self.emit_mode == crate::NativeEmitMode::Gba {
3236
+ for (name, vals) in &self.module_const_f64_arrays {
3237
+ if vals
3238
+ .iter()
3239
+ .all(|v| v.fract() == 0.0 && *v >= i32::MIN as f64 && *v <= i32::MAX as f64)
3240
+ {
3241
+ self.module_const_int_statics
3242
+ .insert(Self::module_const_static(name));
3243
+ }
3244
+ }
3245
+ }
3130
3246
  if !self.module_const_f64_arrays.is_empty() {
3131
3247
  self.emit_module_const_f64_arrays()?;
3132
3248
  self.writeln("");
3133
3249
  }
3134
- let native_vec_names: std::collections::HashSet<String> =
3135
- Self::detect_native_vec_fns(program).keys().cloned().collect();
3250
+ let native_vec_names: std::collections::HashSet<String> = if gba {
3251
+ std::collections::HashSet::new()
3252
+ } else {
3253
+ Self::detect_native_vec_fns(program).keys().cloned().collect()
3254
+ };
3136
3255
  let mut global_names: std::collections::HashSet<String> =
3137
3256
  self.native_numeric_globals.keys().cloned().collect();
3138
3257
  global_names.extend(self.module_const_f64_arrays.keys().cloned());
@@ -3141,6 +3260,21 @@ impl Codegen {
3141
3260
  &global_names,
3142
3261
  &native_vec_names,
3143
3262
  );
3263
+ // Which of the survivors carry the all-`i32` signature. Computed AFTER the fixpoint,
3264
+ // so a function the proof rejected never reaches the i32 ABI either.
3265
+ self.native_fns_i32 = program
3266
+ .statements
3267
+ .iter()
3268
+ .filter_map(|s| match s {
3269
+ Statement::FunDecl { name, params, return_type, .. }
3270
+ if self.native_fns.contains(name.as_ref())
3271
+ && Self::fn_sig_all_i32(params, return_type) =>
3272
+ {
3273
+ Some(name.to_string())
3274
+ }
3275
+ _ => None,
3276
+ })
3277
+ .collect();
3144
3278
  self.native_fn_literal_args =
3145
3279
  Self::collect_native_fn_literal_calls(&program.statements, &self.native_fns);
3146
3280
  self.native_fn_param_names =
@@ -3149,11 +3283,17 @@ impl Codegen {
3149
3283
  Self::collect_native_lcg_fns(&program.statements, &self.native_fns);
3150
3284
  // #381 — fns on a call-graph cycle get guarded rotation copies (default ON;
3151
3285
  // TISH_NATIVE_RECUR_GUARD=0 restores the plain unguarded emission).
3152
- self.native_recur_fns = if crate::native_recur_guard_enabled() {
3286
+ self.native_recur_fns = if crate::native_recur_guard_enabled()
3287
+ && self.emit_mode != crate::NativeEmitMode::Gba
3288
+ {
3153
3289
  Self::compute_native_recur_fns(&program.statements, &self.native_fns)
3154
3290
  } else {
3155
3291
  std::collections::HashMap::new()
3156
3292
  };
3293
+ // A fn on a call-graph cycle keeps the f64 ABI: its stack-guard bail unwinds through a
3294
+ // NaN sentinel (#381), and an `i32` return has no value to spare for that. Recursion is
3295
+ // not what the i32 shape exists for.
3296
+ self.native_fns_i32.retain(|n| !self.native_recur_fns.contains_key(n));
3157
3297
  if !self.native_fns.is_empty() {
3158
3298
  self.emit_native_fns(&program.statements)?;
3159
3299
  self.writeln("");
@@ -3196,6 +3336,9 @@ impl Codegen {
3196
3336
  // `VarDecl` lowers them as boxed `Value` rather than native `f64` (else the store coerces
3197
3337
  // and panics on a JS string-concat result like `s = s + arr[i]`). See
3198
3338
  // `collect_demoted_numeric_locals` / `demoted_numeric_locals`.
3339
+ // BEFORE the demotion pass, which consults it: a local assigned from a provably-numeric
3340
+ // call must stop demoting, which is half of where the win comes from.
3341
+ self.user_fn_ret = self.collect_user_fn_rets(&program.statements);
3199
3342
  self.demoted_numeric_locals = self.collect_demoted_numeric_locals(&program.statements);
3200
3343
  self.int_valued_locals = Self::collect_int_valued_locals(&program.statements);
3201
3344
  self.int_range_locals = self.collect_int_range_locals(&program.statements);
@@ -3594,7 +3737,25 @@ impl Codegen {
3594
3737
  if self.is_async {
3595
3738
  self.async_context_stack.push(true); // run() body is async Rust context
3596
3739
  }
3597
- self.emit_statements_with_folds(&program.statements)?;
3740
+ // #629: A FUNCTION MAY REFERENCE A MODULE BINDING DECLARED BELOW IT. Module bindings are
3741
+ // emitted into `run()` in source order, but a function VALUE is emitted where it appears and
3742
+ // captures by name — so `function readA() { return A[i] }` above `let A = [...]` emitted a
3743
+ // capture of `A` five lines before `let A`, and rustc rejected the program:
3744
+ //
3745
+ // let rangDone = { Value::native(move |args| { … vm_read(&rangLive) … }) }; // use
3746
+ // let rangLive = VmRef::new(vec![(0_f64) as i32]); // decl
3747
+ //
3748
+ // That is legal tish — the function is not CALLED before the binding is initialised — and
3749
+ // only the ORDER differs; moving the `let` above the functions compiles. It took hyrule's
3750
+ // whole ROM down (`rangDone` at weapons.tish:28, `rangLive` at :46).
3751
+ //
3752
+ // Reordered here rather than at the capture site because the capture is not wrong; its
3753
+ // position is. Restricted to LITERAL initialisers on purpose: hoisting `let x = f()` would
3754
+ // move a call, changing when its side effects run and possibly reading state the statements
3755
+ // above it establish. A literal cannot observe or disturb anything, so moving it is not
3756
+ // visible to the program.
3757
+ let reordered = Self::hoist_forward_referenced_module_bindings(&program.statements);
3758
+ self.emit_statements_with_folds(reordered.as_deref().unwrap_or(&program.statements))?;
3598
3759
  if self.is_async {
3599
3760
  self.async_context_stack.pop();
3600
3761
  }
@@ -3862,11 +4023,64 @@ impl Codegen {
3862
4023
  /// unchanged, and `PropMap` dedup + hidden-class shapes key by text, not pointer — so shapes,
3863
4024
  /// insertion order, and `Object.keys` are all unaffected. Same inline-`static` idiom as the
3864
4025
  /// per-site `PropIC` caches.
4026
+ /// Append the GBA object-key intern table. Called ONCE, after the whole program is emitted,
4027
+ /// because the key set is only complete then — Rust does not care that a `fn` is defined below
4028
+ /// its callers.
4029
+ ///
4030
+ /// ⚠️ `static mut` + `&raw mut` rather than a `OnceLock`: the target is `#![no_std]` and the GBA
4031
+ /// is single-core, so there is no thread to race with. Taking a raw pointer avoids the
4032
+ /// `static_mut_refs` lint that a plain `&mut __TISH_KEYS` would trip.
4033
+ fn emit_gba_key_table(&mut self) {
4034
+ if self.emit_mode != crate::NativeEmitMode::Gba {
4035
+ return;
4036
+ }
4037
+ let names: Vec<String> = self.gba_keys.borrow().0.clone();
4038
+ if names.is_empty() {
4039
+ return;
4040
+ }
4041
+ let list = names
4042
+ .iter()
4043
+ .map(|k| format!("{:?}", k))
4044
+ .collect::<Vec<_>>()
4045
+ .join(", ");
4046
+ self.write(&format!(
4047
+ "\n// Object-literal keys, interned program-wide: {} distinct names.\n\
4048
+ static __TISH_KEY_NAMES: [&str; {}] = [{}];\n\
4049
+ static mut __TISH_KEYS: Option<Vec<Arc<str>>> = None;\n\
4050
+ fn __tish_key(i: usize) -> Arc<str> {{\n\
4051
+ \x20 unsafe {{\n\
4052
+ \x20 let t = &raw mut __TISH_KEYS;\n\
4053
+ \x20 if (*t).is_none() {{\n\
4054
+ \x20 *t = Some(__TISH_KEY_NAMES.iter().map(|s| Arc::from(*s)).collect());\n\
4055
+ \x20 }}\n\
4056
+ \x20 match (*t).as_ref() {{\n\
4057
+ \x20 Some(v) => v[i].clone(),\n\
4058
+ \x20 None => Arc::from(__TISH_KEY_NAMES[i]),\n\
4059
+ \x20 }}\n\
4060
+ \x20 }}\n\
4061
+ }}\n",
4062
+ names.len(),
4063
+ names.len(),
4064
+ list
4065
+ ));
4066
+ }
4067
+
3865
4068
  fn cached_object_key(&self, k: &str) -> String {
3866
4069
  if self.emit_mode == crate::NativeEmitMode::Gba {
3867
- // No `OnceLock` on no_std GBA; emit a direct interned key (`Arc` = `Rc`).
3868
- // Object-literal keys in a hot loop are a later perf item on GBA.
3869
- return format!("Arc::from({:?})", k);
4070
+ // No `OnceLock` on no_std GBA — and a per-site cache would be the wrong tool anyway.
4071
+ // Intern PROGRAM-WIDE instead: every distinct key name is allocated once, at first use,
4072
+ // and each site clones an `Arc` (= `Rc`) out of one table. See the `gba_keys` field.
4073
+ let mut t = self.gba_keys.borrow_mut();
4074
+ let idx = match t.1.get(k) {
4075
+ Some(&i) => i,
4076
+ None => {
4077
+ let i = t.0.len();
4078
+ t.0.push(k.to_string());
4079
+ t.1.insert(k.to_string(), i);
4080
+ i
4081
+ }
4082
+ };
4083
+ return format!("__tish_key({})", idx);
3870
4084
  }
3871
4085
  format!(
3872
4086
  "{{ static K: ::std::sync::OnceLock<::std::sync::Arc<str>> = \
@@ -3967,9 +4181,26 @@ impl Codegen {
3967
4181
  // `emit_int32_operand` (a `>>> 0` result is u32 reinterpreted to i32; signed
3968
4182
  // bitwise ops yield i32 directly) — so store the i32 with NO `f64` round-trip.
3969
4183
  if rust_type == RustType::I32 {
4184
+ // #599: A CAPTURED i32 IS A CELL, AND THE WRITE HAS TO SAY SO. Reads of a
4185
+ // wrapped scalar already go through `vm_read(&x)`; every store below used to
4186
+ // emit a bare `x = <i32>`, so the two halves disagreed and the crate failed with
4187
+ // `expected VmRef<i32>, found i32`. Net effect was that a mutable module-level
4188
+ // typed scalar could not be used at all on GBA — every counter had to move into
4189
+ // a function body or hide in a one-element `i32[]`.
4190
+ //
4191
+ // The narrow-int branch below has always done this; the i32 branch is the one
4192
+ // that was missed, across all three of its store shapes.
4193
+ let is_refcell = self.refcell_wrapped_vars.contains(name.as_ref());
4194
+ let store = |escaped: &str, val: String| {
4195
+ if is_refcell {
4196
+ format!("{{ let _t = {}; *{}.borrow_mut() = _t; }}", val, escaped)
4197
+ } else {
4198
+ format!("{} = {}", escaped, val)
4199
+ }
4200
+ };
3970
4201
  if let Some(int_code) = self.emit_int32_operand(value)? {
3971
4202
  let escaped = Self::escape_ident(name.as_ref());
3972
- return Ok(format!("{} = ({}) as i32", escaped, int_code));
4203
+ return Ok(store(&escaped, format!("({}) as i32", int_code)));
3973
4204
  }
3974
4205
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
3975
4206
  // An RHS that is ALREADY an integer stores straight into the register. `d = d + 1`
@@ -3978,7 +4209,7 @@ impl Codegen {
3978
4209
  // out of it was the whole cost of incrementing an annotated counter.
3979
4210
  let escaped_int = Self::escape_ident(name.as_ref());
3980
4211
  if val_ty.is_integer_scalar() {
3981
- return Ok(format!("{} = ({}) as i32", escaped_int, val_code));
4212
+ return Ok(store(&escaped_int, format!("({}) as i32", val_code)));
3982
4213
  }
3983
4214
  // Defensive: gate guarantees `Some`, but if a future RHS shape slips through,
3984
4215
  // fall back to a sound f64-narrowed store rather than miscompiling.
@@ -3988,11 +4219,7 @@ impl Codegen {
3988
4219
  val_code
3989
4220
  };
3990
4221
  let escaped = Self::escape_ident(name.as_ref());
3991
- return Ok(format!(
3992
- "{} = {}",
3993
- escaped,
3994
- RustType::I32.from_value_expr(&v)
3995
- ));
4222
+ return Ok(store(&escaped, RustType::I32.from_value_expr(&v)));
3996
4223
  }
3997
4224
  // String self-append `s = s + rhs` -> in-place push_str (amortized O(1)). The
3998
4225
  // general path boxes via `ops::add(Value::String(s.clone()), ...)` which clones
@@ -5643,6 +5870,34 @@ impl Codegen {
5643
5870
  })
5644
5871
  .unwrap_or(RustType::Value);
5645
5872
 
5873
+ // #597: AN ARRAY THAT ESCAPES INTO A BOXED CALL STAYS BOXED. A native `Vec<T>`
5874
+ // handed to a `value_call` is marshalled by BUILDING A FRESH `Value::Array` from
5875
+ // its elements — a copy by construction — so the callee's `push`es land in the copy
5876
+ // and the caller's array silently keeps its old contents. Nothing reports it: not
5877
+ // the compiler, not the runtime. An UNTYPED array passes the same `VmRef` and
5878
+ // aliases correctly, which means adding an annotation changed program BEHAVIOUR
5879
+ // rather than just representation.
5880
+ //
5881
+ // The call site cannot fix this — boxing a `Vec<i32>` into a `Value::Array` is a
5882
+ // copy however it is spelled. So the decision has to be made here, at the
5883
+ // declaration: if the array is ever passed as a call argument, keep it boxed.
5884
+ //
5885
+ // Same rule the read-only param analysis already applies from the other side
5886
+ // (`arr_param_readonly_fns` refuses an owned copy when a param is mutated, escapes
5887
+ // or is forwarded). Conservative on purpose: an array that never escapes is
5888
+ // untouched and keeps its native lowering.
5889
+ // GBA ONLY. The reporter checked the other backends: `tish run` and a native
5890
+ // `tish build` both alias correctly and print the expected result, so this is a
5891
+ // TARGET divergence rather than a language-wide one. Narrowing off-target would
5892
+ // deopt host code to fix a bug host code does not have, and one host test
5893
+ // (`escaping_array_keeps_oob_safe_lowering`) pins the native store it would remove.
5894
+ if self.emit_mode == crate::NativeEmitMode::Gba
5895
+ && matches!(rust_type, RustType::Vec(_))
5896
+ && self.arrays_passed_to_calls.contains(name.as_ref())
5897
+ {
5898
+ rust_type = RustType::Value;
5899
+ }
5900
+
5646
5901
  // M5 native-fn body: `let r = genRandom(1)` without a `: number` annotation.
5647
5902
  if self.native_fn_body_emit && rust_type == RustType::Value {
5648
5903
  if let Some(Expr::Call { callee, .. }) = init.as_ref() {
@@ -5770,12 +6025,22 @@ impl Codegen {
5770
6025
  return Ok(());
5771
6026
  }
5772
6027
 
5773
- // Module-level const f64 arrays — no local slot in `run()`.
6028
+ // Module-level const numeric arrays — no local slot in `run()`. The element type
6029
+ // must match the emitted representation, or a consumer reading the type context
6030
+ // coerces against a layout that is not there.
5774
6031
  if self.module_const_f64_arrays.contains_key(name.as_ref())
5775
6032
  && self.outer_vars_stack.len() == 1
5776
6033
  {
6034
+ let elem = if self
6035
+ .module_const_int_statics
6036
+ .contains(&Self::module_const_static(name.as_ref()))
6037
+ {
6038
+ RustType::I32
6039
+ } else {
6040
+ RustType::F64
6041
+ };
5777
6042
  self.type_context
5778
- .define(name.as_ref(), RustType::Vec(Box::new(RustType::F64)));
6043
+ .define(name.as_ref(), RustType::Vec(Box::new(elem)));
5779
6044
  return Ok(());
5780
6045
  }
5781
6046
 
@@ -6421,6 +6686,24 @@ impl Codegen {
6421
6686
  }
6422
6687
  }
6423
6688
  }
6689
+ VecRetKind::Str => {
6690
+ // #605. The body's own `return "…"`, emitted as an OWNED String so it
6691
+ // can leave the fn. `emit_typed_expr` already yields a `String` for a
6692
+ // string literal; anything else is converted rather than assumed,
6693
+ // because a wrong guess here is a type error in the emitted crate.
6694
+ let e = value.as_ref().ok_or_else(|| {
6695
+ CompileError::new("native-vec string fn: empty return", None)
6696
+ })?;
6697
+ let (c, ty) = self.emit_typed_expr(e)?;
6698
+ if ty == RustType::String {
6699
+ self.writeln(&format!("return {};", c));
6700
+ } else {
6701
+ self.writeln(&format!(
6702
+ "return {};",
6703
+ RustType::String.from_value_expr(&c)
6704
+ ));
6705
+ }
6706
+ }
6424
6707
  VecRetKind::Unit => {
6425
6708
  self.writeln("return;");
6426
6709
  }
@@ -7802,7 +8085,7 @@ impl Codegen {
7802
8085
  ));
7803
8086
  }
7804
8087
  if let Some(static_name) = self.module_const_f64_array_rust_ref(name.as_ref()) {
7805
- let v = Self::module_const_f64_array_as_value(&static_name);
8088
+ let v = self.module_const_f64_array_as_value(&static_name);
7806
8089
  return Ok(if self.value_fn_depth > 0 || !self.loop_stack.is_empty() {
7807
8090
  format!("({}).clone()", v)
7808
8091
  } else {
@@ -8979,27 +9262,31 @@ impl Codegen {
8979
9262
  // M5: boxed-context call to an eligible native fn → `Value::Number(name_native(..))`.
8980
9263
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8981
9264
  if self.native_fns.contains(fname.as_ref()) {
9265
+ let i32_abi = self.native_fns_i32.contains(fname.as_ref());
9266
+ let want =
9267
+ if i32_abi { RustType::I32 } else { RustType::F64 };
8982
9268
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
8983
9269
  let mut ok = true;
8984
9270
  for a in args {
8985
9271
  if let CallArg::Expr(e) = a {
8986
9272
  let (ac, at) = self.emit_typed_expr(e)?;
8987
- argc.push(if at == RustType::Value {
8988
- RustType::F64.from_value_expr(&ac)
8989
- } else {
8990
- ac
8991
- });
9273
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
8992
9274
  } else {
8993
9275
  ok = false;
8994
9276
  break;
8995
9277
  }
8996
9278
  }
8997
9279
  if ok {
8998
- return Ok(format!(
8999
- "Value::Number({}({}))",
9280
+ let call = format!(
9281
+ "{}({})",
9000
9282
  self.native_call_target(fname.as_ref()),
9001
9283
  argc.join(", ")
9002
- ));
9284
+ );
9285
+ return Ok(if i32_abi {
9286
+ format!("Value::Number(({}) as f64)", call)
9287
+ } else {
9288
+ format!("Value::Number({})", call)
9289
+ });
9003
9290
  }
9004
9291
  }
9005
9292
  }
@@ -11981,6 +12268,21 @@ impl Codegen {
11981
12268
  }
11982
12269
  }
11983
12270
 
12271
+ // Int-domain shortcut for an integer-typed local: `let nw: i32 = w | P_MARK` binds the
12272
+ // integer form rather than `(int as f64) as i32`, so `nw` is i32-backed and every later use
12273
+ // — including storing it into an `i32[]` — stays integral. Without this the annotation
12274
+ // actively costs: measured 23,4xx ticks/1000 for `let v: i32 = j & 255; arr[j] = v`
12275
+ // against 1,5xx for `arr[j] = j & 255` written directly.
12276
+ if target_type.is_integer_scalar() {
12277
+ if let Some(int_code) = self.emit_int32_for_int_consumer(expr)? {
12278
+ return Ok(if *target_type == RustType::I32 {
12279
+ int_code
12280
+ } else {
12281
+ format!("({}) as {}", int_code, target_type.to_rust_type_str())
12282
+ });
12283
+ }
12284
+ }
12285
+
11984
12286
  // #179 Stage C: `arr.length` on a native `Vec<_>` against an F64 target → native
11985
12287
  // `(arr.len() as f64)` (no boxing). Lets `let n = arr.length` stay native f64.
11986
12288
  if let (
@@ -12261,6 +12563,28 @@ impl Codegen {
12261
12563
  RustType::from_annotation(ann) == RustType::F64
12262
12564
  }
12263
12565
 
12566
+ fn ann_is_i32(ann: &TypeAnnotation) -> bool {
12567
+ RustType::from_annotation(ann) == RustType::I32
12568
+ }
12569
+
12570
+ /// Is every parameter AND the return annotated `: i32`?
12571
+ ///
12572
+ /// ALL OR NOTHING, deliberately. An `: i32` parameter reached through the boxed path is
12573
+ /// ToInt32-coerced on entry; promoted to a native `f64` it would not be, so 3.7 would arrive as
12574
+ /// 3 one way and 3.7 the other. Admitting `: i32` per-annotation would do exactly that to a
12575
+ /// function that mixes the two. A function whose whole signature is `i32` has one convention
12576
+ /// and keeps the coercion at the boundary, where the caller does it once on an argument that is
12577
+ /// usually already an integer.
12578
+ fn fn_sig_all_i32(params: &[FunParam], return_type: &Option<TypeAnnotation>) -> bool {
12579
+ return_type.as_ref().is_some_and(Self::ann_is_i32)
12580
+ && !params.is_empty()
12581
+ && params.iter().all(|p| {
12582
+ matches!(p, FunParam::Simple(tp)
12583
+ if tp.default.is_none()
12584
+ && tp.type_ann.as_ref().is_some_and(Self::ann_is_i32))
12585
+ })
12586
+ }
12587
+
12264
12588
  // ── Soundness: demote `number` locals that a reassignment can turn non-numeric ──────────────
12265
12589
  //
12266
12590
  // `let s = 0` is inferred `number` → lowered to a native `f64`, and a reassignment stores into
@@ -12278,6 +12602,246 @@ impl Codegen {
12278
12602
  // demoted). The map is name-flat across the whole program (a name demoted in one function is
12279
12603
  // demoted in all) — still sound, and harmless to the perf gauntlet, where each kernel is its
12280
12604
  // own program with unique accumulator names.
12605
+ /// Which top-level fns provably return a number, and as what native type. A fixpoint, because
12606
+ /// one qualifying fn makes calls to it numeric inside another; growing only, so it terminates.
12607
+ fn collect_user_fn_rets(
12608
+ &self,
12609
+ stmts: &[Statement],
12610
+ ) -> std::collections::HashMap<String, RustType> {
12611
+ // MODULE SCOPE ONLY. `collect_annotated_types` over the whole program builds a NAME-FLAT
12612
+ // map, which `collect_demoted_numeric_locals` can use because its error direction is safe —
12613
+ // over-demoting only costs a box. Proving is the opposite: a numeric `s` in one function
12614
+ // would make a STRING `s` in another look numeric, and the return gets coerced.
12615
+ //
12616
+ // That is not hypothetical. It shipped: `function ruleTag(mask: i32) { let s = ""; …;
12617
+ // return s }` was proven numeric off an unrelated numeric `s`, and every ROM using it
12618
+ // panicked with "expected number". Each fn gets its own scope now.
12619
+ let mut module_env: HashMap<String, RustType> = HashMap::new();
12620
+ let top_only: Vec<Statement> = stmts
12621
+ .iter()
12622
+ .filter(|s| matches!(s, Statement::VarDecl { .. }))
12623
+ .cloned()
12624
+ .collect();
12625
+ Self::collect_annotated_types(&top_only, &self.type_aliases, &mut module_env);
12626
+
12627
+ let mut decls: Vec<(String, &Statement, HashMap<String, RustType>)> = Vec::new();
12628
+ for s in stmts {
12629
+ if let Statement::FunDecl { async_: false, name, params, body, .. } = s {
12630
+ let mut env = module_env.clone();
12631
+ // This fn's own params and locals — not a nested closure's, whose names are its own.
12632
+ for prm in params {
12633
+ if let FunParam::Simple(tp) = prm {
12634
+ if let Some(ann) = &tp.type_ann {
12635
+ let ty = RustType::from_annotation(ann);
12636
+ if ty.is_native() {
12637
+ env.insert(tp.name.to_string(), ty);
12638
+ }
12639
+ }
12640
+ }
12641
+ }
12642
+ Self::collect_own_locals(body.as_ref(), &self.type_aliases, &mut env);
12643
+ decls.push((name.to_string(), body.as_ref(), env));
12644
+ }
12645
+ }
12646
+
12647
+ // A REASSIGNED name is not this fn at the call site, and a SHADOWED one is not either —
12648
+ // `emit_typed_expr` sees a bare callee name with no scope information. Excluding every name
12649
+ // ever bound as a variable or parameter is blunt, but the alternative is a miscompile.
12650
+ let mut reassigns: Vec<(String, &Expr)> = Vec::new();
12651
+ Self::collect_reassignments_stmts(stmts, &mut reassigns);
12652
+ let mut excluded: std::collections::HashSet<String> =
12653
+ reassigns.iter().map(|(n, _)| n.clone()).collect();
12654
+ Self::collect_bound_names(stmts, &mut excluded);
12655
+
12656
+ let mut out: std::collections::HashMap<String, RustType> = std::collections::HashMap::new();
12657
+ loop {
12658
+ let mut changed = false;
12659
+ for (name, body, env) in &decls {
12660
+ if out.contains_key(name) || excluded.contains(name) {
12661
+ continue;
12662
+ }
12663
+ let mut rets: Vec<&Expr> = Vec::new();
12664
+ Self::collect_return_exprs(body, &mut rets);
12665
+ if rets.is_empty() {
12666
+ continue; // no return yields `undefined`, which is not a number
12667
+ }
12668
+ let mut ty: Option<RustType> = None;
12669
+ let mut ok = true;
12670
+ for e in &rets {
12671
+ let Some(et) = self.proof_numeric_type(e, env, &out) else {
12672
+ ok = false;
12673
+ break;
12674
+ };
12675
+ ty = match ty {
12676
+ None => Some(et),
12677
+ Some(prev) if prev == et => Some(et),
12678
+ Some(_) => Some(RustType::F64), // mixed I32/F64 settles on the wider
12679
+ };
12680
+ }
12681
+ if ok {
12682
+ if let Some(final_ty) = ty {
12683
+ out.insert(name.clone(), final_ty);
12684
+ changed = true;
12685
+ }
12686
+ }
12687
+ }
12688
+ if !changed {
12689
+ break;
12690
+ }
12691
+ }
12692
+ out
12693
+ }
12694
+
12695
+ /// Is `e` provably a NUMBER, and which native type describes it?
12696
+ ///
12697
+ /// Separate from `expr_native_type`, which answers the stricter "can this be LOWERED natively"
12698
+ /// and is bound by `result_type_of_binop`, whose arms require BOTH operands `F64`. That rejects
12699
+ /// `arr[i] & 255` on a `Vec<i32>` — element `I32`, literal `F64` — which on GBA is nearly every
12700
+ /// masked read of a typed array. Widening that function would change lowering everywhere it is
12701
+ /// consulted, to serve a question it is not being asked.
12702
+ ///
12703
+ /// Two unconditional JS facts carry it: `- * / % **` coerce with ToNumber, and `& | ^ << >>`
12704
+ /// with ToInt32 — a number, and an int32 one, even if an operand was a string. `>>>` is excluded
12705
+ /// (its uint32 can exceed `i32::MAX`) and `+` needs both sides proven, because it concatenates.
12706
+ fn proof_numeric_type(
12707
+ &self,
12708
+ e: &Expr,
12709
+ env: &HashMap<String, RustType>,
12710
+ fn_rets: &std::collections::HashMap<String, RustType>,
12711
+ ) -> Option<RustType> {
12712
+ let numeric = |t: &RustType| matches!(t, RustType::F64 | RustType::I32);
12713
+ match e {
12714
+ Expr::Literal { value: Literal::Number(_), .. } => Some(RustType::F64),
12715
+ Expr::Ident { name, .. } => env.get(name.as_ref()).filter(|t| numeric(t)).cloned(),
12716
+ Expr::Index { object, optional: false, .. } => {
12717
+ if let Expr::Ident { name, .. } = object.as_ref() {
12718
+ if let Some(RustType::Vec(inner)) = env.get(name.as_ref()) {
12719
+ if numeric(inner) {
12720
+ return Some((**inner).clone());
12721
+ }
12722
+ }
12723
+ }
12724
+ None
12725
+ }
12726
+ Expr::Unary { op, operand, .. } => match op {
12727
+ UnaryOp::Neg | UnaryOp::Pos => {
12728
+ self.proof_numeric_type(operand, env, fn_rets).map(|_| RustType::F64)
12729
+ }
12730
+ _ => None,
12731
+ },
12732
+ Expr::Binary { left, op, right, .. } => match op {
12733
+ BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor | BinOp::Shl | BinOp::Shr => {
12734
+ Some(RustType::I32)
12735
+ }
12736
+ BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow => Some(RustType::F64),
12737
+ BinOp::Add => {
12738
+ let l = self.proof_numeric_type(left, env, fn_rets)?;
12739
+ let r = self.proof_numeric_type(right, env, fn_rets)?;
12740
+ Some(if l == RustType::I32 && r == RustType::I32 {
12741
+ RustType::I32
12742
+ } else {
12743
+ RustType::F64
12744
+ })
12745
+ }
12746
+ _ => None,
12747
+ },
12748
+ Expr::Conditional { then_branch, else_branch, .. } => {
12749
+ let a = self.proof_numeric_type(then_branch, env, fn_rets)?;
12750
+ let b = self.proof_numeric_type(else_branch, env, fn_rets)?;
12751
+ Some(if a == b { a } else { RustType::F64 })
12752
+ }
12753
+ Expr::Call { callee, .. } => match callee.as_ref() {
12754
+ Expr::Ident { name, .. } => fn_rets.get(name.as_ref()).cloned(),
12755
+ _ => None,
12756
+ },
12757
+ _ => None,
12758
+ }
12759
+ }
12760
+
12761
+ /// Annotated locals declared directly in this body — through blocks, ifs and loops, but NOT
12762
+ /// into a nested function, whose locals are its own scope and would re-create the name
12763
+ /// collision this exists to avoid.
12764
+ fn collect_own_locals(
12765
+ s: &Statement,
12766
+ aliases: &HashMap<String, RustType>,
12767
+ env: &mut HashMap<String, RustType>,
12768
+ ) {
12769
+ match s {
12770
+ Statement::VarDecl { .. } => {
12771
+ Self::collect_annotated_types(core::slice::from_ref(s), aliases, env)
12772
+ }
12773
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
12774
+ for x in statements {
12775
+ Self::collect_own_locals(x, aliases, env);
12776
+ }
12777
+ }
12778
+ Statement::If { then_branch, else_branch, .. } => {
12779
+ Self::collect_own_locals(then_branch, aliases, env);
12780
+ if let Some(e) = else_branch {
12781
+ Self::collect_own_locals(e, aliases, env);
12782
+ }
12783
+ }
12784
+ Statement::While { body, .. } | Statement::For { body, .. } => {
12785
+ Self::collect_own_locals(body, aliases, env)
12786
+ }
12787
+ _ => {}
12788
+ }
12789
+ }
12790
+
12791
+ /// Every name bound as a variable or a function parameter, anywhere, at any depth.
12792
+ fn collect_bound_names(stmts: &[Statement], out: &mut std::collections::HashSet<String>) {
12793
+ for s in stmts {
12794
+ match s {
12795
+ Statement::VarDecl { name, .. } => {
12796
+ out.insert(name.to_string());
12797
+ }
12798
+ Statement::FunDecl { params, body, .. } => {
12799
+ for p in params {
12800
+ if let FunParam::Simple(tp) = p {
12801
+ out.insert(tp.name.to_string());
12802
+ }
12803
+ }
12804
+ Self::collect_bound_names(core::slice::from_ref(body.as_ref()), out);
12805
+ }
12806
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
12807
+ Self::collect_bound_names(statements, out)
12808
+ }
12809
+ Statement::If { then_branch, else_branch, .. } => {
12810
+ Self::collect_bound_names(core::slice::from_ref(then_branch.as_ref()), out);
12811
+ if let Some(e) = else_branch {
12812
+ Self::collect_bound_names(core::slice::from_ref(e.as_ref()), out);
12813
+ }
12814
+ }
12815
+ Statement::While { body, .. } | Statement::For { body, .. } => {
12816
+ Self::collect_bound_names(core::slice::from_ref(body.as_ref()), out)
12817
+ }
12818
+ _ => {}
12819
+ }
12820
+ }
12821
+ }
12822
+
12823
+ /// Every `return <expr>` in a body, through blocks, ifs and loops. NOT into a nested function.
12824
+ fn collect_return_exprs<'a>(s: &'a Statement, out: &mut Vec<&'a Expr>) {
12825
+ match s {
12826
+ Statement::Return { value: Some(e), .. } => out.push(e),
12827
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
12828
+ for x in statements {
12829
+ Self::collect_return_exprs(x, out);
12830
+ }
12831
+ }
12832
+ Statement::If { then_branch, else_branch, .. } => {
12833
+ Self::collect_return_exprs(then_branch, out);
12834
+ if let Some(e) = else_branch {
12835
+ Self::collect_return_exprs(e, out);
12836
+ }
12837
+ }
12838
+ Statement::While { body, .. } | Statement::For { body, .. } => {
12839
+ Self::collect_return_exprs(body, out)
12840
+ }
12841
+ _ => {}
12842
+ }
12843
+ }
12844
+
12281
12845
  fn collect_demoted_numeric_locals(&self, stmts: &[Statement]) -> HashSet<String> {
12282
12846
  // 1. Flat env: every annotated local/param name → its native `RustType`.
12283
12847
  let mut env: HashMap<String, RustType> = HashMap::new();
@@ -13565,7 +14129,11 @@ impl Codegen {
13565
14129
  let in_bounds = self.index_in_bounds(index, name.as_ref());
13566
14130
  let idx_usize = self.emit_index_usize(index)?;
13567
14131
  let native_val = if *elem_type == RustType::I32 {
13568
- if let Expr::Binary {
14132
+ // A bitwise RHS stays in the integer domain instead of `(int as f64) as i32`. Exact —
14133
+ // see `emit_int32_for_int_consumer`.
14134
+ if let Some(int_code) = self.emit_int32_for_int_consumer(value)? {
14135
+ int_code
14136
+ } else if let Expr::Binary {
13569
14137
  left,
13570
14138
  op: BinOp::Sub,
13571
14139
  right,
@@ -13616,13 +14184,22 @@ impl Codegen {
13616
14184
  // so a RHS that reads the same cell (`a[i] += x`, `a[i] = a[j]`) releases its shared borrow
13617
14185
  // first (no overlapping lock → no deadlock), then store into the real element through the cell.
13618
14186
  if wrapped {
13619
- let needs_resize =
13620
- matches!(elem_type.as_ref(), RustType::F64 | RustType::Bool) && !in_bounds;
14187
+ // #611: I32 GROWS TOO. A boxed array absorbs `a[i] = v` past the end by growing; a
14188
+ // typed one used to fall through to a bare element store, so the same source line
14189
+ // behaved differently depending only on whether the annotation was present — the write
14190
+ // was lost with no error at compile time or run time. That silent divergence between
14191
+ // the typed and boxed forms is the worst of the available options, so the typed form
14192
+ // now matches the boxed one. Pad is `0`, which is what the READ path already answers
14193
+ // for an out-of-range `i32` element (`.get(i).copied().unwrap_or(0)`).
14194
+ let needs_resize = matches!(
14195
+ elem_type.as_ref(),
14196
+ RustType::F64 | RustType::Bool | RustType::I32
14197
+ ) && !in_bounds;
13621
14198
  let store = if needs_resize {
13622
- let pad = if matches!(elem_type.as_ref(), RustType::F64) {
13623
- "f64::NAN"
13624
- } else {
13625
- "false"
14199
+ let pad = match elem_type.as_ref() {
14200
+ RustType::F64 => "f64::NAN",
14201
+ RustType::I32 => "0",
14202
+ _ => "false",
13626
14203
  };
13627
14204
  format!(
13628
14205
  "let __i = {idx}; let __v = {val}; let mut __g = {esc}.borrow_mut(); if __i >= __g.len() {{ __g.resize(__i + 1, {pad}); }} __g[__i] = __v;",
@@ -13648,16 +14225,22 @@ impl Codegen {
13648
14225
  format!("{{ {}[{}] = {}; Value::Null }}", esc_obj, idx_usize, native_val)
13649
14226
  }
13650
14227
  }
13651
- RustType::F64 | RustType::Bool => {
13652
- let pad = if matches!(elem_type.as_ref(), RustType::F64) {
13653
- "f64::NAN"
13654
- } else {
13655
- "false"
14228
+ // #611: same grow-to-index rule on the unwrapped path — see the note above.
14229
+ RustType::F64 | RustType::Bool | RustType::I32 => {
14230
+ let pad = match elem_type.as_ref() {
14231
+ RustType::F64 => "f64::NAN",
14232
+ RustType::I32 => "0",
14233
+ _ => "false",
13656
14234
  };
13657
- format!(
13658
- "{{ let _idx = {}; if _idx >= {}.len() {{ {}.resize(_idx + 1, {}); }} {}[_idx] = {}; Value::Null }}",
14235
+ let store = format!(
14236
+ "{{ let _idx = {}; if _idx >= {}.len() {{ {}.resize(_idx + 1, {}); }} {}[_idx] = {}",
13659
14237
  idx_usize, esc_obj, esc_obj, pad, esc_obj, native_val
13660
- )
14238
+ );
14239
+ if side_effect_only {
14240
+ format!("{} }}", store)
14241
+ } else {
14242
+ format!("{}; Value::Null }}", store)
14243
+ }
13661
14244
  }
13662
14245
  _ if side_effect_only => {
13663
14246
  format!("{}[{}] = {}", esc_obj, idx_usize, native_val)
@@ -13677,6 +14260,12 @@ impl Codegen {
13677
14260
  return Ok(uv.clone());
13678
14261
  }
13679
14262
  }
14263
+ // Same int-domain shortcut as the element store, with one correction: `(neg as f64) as
14264
+ // usize` SATURATES to 0, while `(neg_i32) as usize` wraps to a huge value. `.max(0)` makes
14265
+ // the two identical for every input, and costs a compare against a soft-float call.
14266
+ if let Some(int_code) = self.emit_int32_for_int_consumer(index)? {
14267
+ return Ok(format!("({}).max(0) as usize", int_code));
14268
+ }
13680
14269
  let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
13681
14270
  if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
13682
14271
  Ok(format!("({}) as usize", idx_code))
@@ -13800,6 +14389,19 @@ impl Codegen {
13800
14389
  value: &Expr,
13801
14390
  target_ty: &RustType,
13802
14391
  ) -> Result<String, CompileError> {
14392
+ // Same int-domain shortcut as the `Vec<i32>` element store: a bitwise RHS bound to an
14393
+ // integer target keeps its integer form instead of `(int as f64) as i32`. Without this,
14394
+ // `let v: i32 = w & 255` is f64-backed and every later use of `v` pays the conversion —
14395
+ // measured at 23,4xx ticks/1000 against 1,5xx for the direct form.
14396
+ if target_ty.is_integer_scalar() {
14397
+ if let Some(int_code) = self.emit_int32_for_int_consumer(value)? {
14398
+ return Ok(if *target_ty == RustType::I32 {
14399
+ int_code
14400
+ } else {
14401
+ format!("({}) as {}", int_code, target_ty.to_rust_type_str())
14402
+ });
14403
+ }
14404
+ }
13803
14405
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
13804
14406
  if val_ty == *target_ty {
13805
14407
  return Ok(val_code);
@@ -13814,6 +14416,26 @@ impl Codegen {
13814
14416
  if *target_ty == RustType::F64 && val_ty.is_integer_scalar() {
13815
14417
  return Ok(format!("({}) as f64", val_code));
13816
14418
  }
14419
+ // FIXED CROSSES DIRECTLY, not through a `Value`. Without these arms a `fixed` reaching an
14420
+ // `f64` extern parameter (or the reverse) fell to the boxed round trip below — allocate a
14421
+ // Value::Number, then immediately unwrap it — which is the whole cost the typed-extern path
14422
+ // exists to remove, reintroduced at the boundary it was removed from (#607). Same scaling as
14423
+ // `RustType::Fixed`'s Value conversions in types.rs, so the values are identical either way.
14424
+ if *target_ty == RustType::F64 && val_ty == RustType::Fixed {
14425
+ return Ok(format!("(({}).to_raw() as f64 / 256.0)", val_code));
14426
+ }
14427
+ if *target_ty == RustType::Fixed && val_ty == RustType::F64 {
14428
+ return Ok(format!(
14429
+ "tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)",
14430
+ val_code
14431
+ ));
14432
+ }
14433
+ if *target_ty == RustType::Fixed && val_ty.is_integer_scalar() {
14434
+ return Ok(format!(
14435
+ "tishlang_runtime::Fixed::from_raw((({}) as i32).wrapping_mul(256))",
14436
+ val_code
14437
+ ));
14438
+ }
13817
14439
  let boxed = if val_ty.is_native() {
13818
14440
  val_ty.to_value_expr(&val_code)
13819
14441
  } else {
@@ -15699,6 +16321,9 @@ impl Codegen {
15699
16321
  let rt = self.expr_native_type(right, env);
15700
16322
  RustType::result_type_of_binop(*op, &lt, &rt).unwrap_or(RustType::Value)
15701
16323
  }
16324
+ // A call to a fn whose every return is PROVEN numeric. Without this a call is opaque and
16325
+ // any local assigned from one demotes to a boxed `Value`. `env` shadowing wins: a local
16326
+ // of that name is not the fn.
15702
16327
  // `vec[i]` where `vec` is a `number[]` (Vec<f64>) → the element type. A `Vec<f64>`
15703
16328
  // can only hold numbers, so this never feeds a string into the accumulator.
15704
16329
  Expr::Index {
@@ -15777,13 +16402,30 @@ impl Codegen {
15777
16402
  }
15778
16403
  RustType::Value
15779
16404
  }
16405
+ // ONE Call arm, and the singularity is load-bearing: the numeric-return proof briefly
16406
+ // lived in its own `Expr::Call` arm ABOVE this one, which shadowed everything below —
16407
+ // native-fn results, the Math intrinsics and the #169 reduce modelling all became
16408
+ // unreachable (rustc said so, as a warning nobody read) and every accumulator they
16409
+ // feed demoted to a boxed `Value`. CI caught it as issue_169 + the #320 push test.
15780
16410
  Expr::Call { callee, args, .. } => {
15781
- // M5 native fn (`fn f_native(..) -> f64`); requires all-positional args.
15782
16411
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
16412
+ // M5 native fn (`fn f_native(..) -> f64`); requires all-positional args.
15783
16413
  if self.native_fns.contains(fname.as_ref())
15784
16414
  && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
15785
16415
  {
15786
- return RustType::F64;
16416
+ return if self.native_fns_i32.contains(fname.as_ref()) {
16417
+ RustType::I32
16418
+ } else {
16419
+ RustType::F64
16420
+ };
16421
+ }
16422
+ // A call to a fn whose every return is PROVEN numeric (the boxed call's RESULT
16423
+ // stops being opaque — the dispatch is unchanged). `env` shadowing wins: a
16424
+ // local of that name is not the fn.
16425
+ if !env.contains_key(fname.as_ref()) {
16426
+ if let Some(ft) = self.user_fn_ret.get(fname.as_ref()) {
16427
+ return ft.clone();
16428
+ }
15787
16429
  }
15788
16430
  }
15789
16431
  // Single-arg `Math.<intrinsic>(x)` lowered to a direct `f64` method → number.
@@ -16302,7 +16944,15 @@ impl Codegen {
16302
16944
  {
16303
16945
  let sum_esc = Self::escape_ident(sum_name.as_ref());
16304
16946
  let arms: Vec<String> = (0..n)
16305
- .map(|i| format!("{} => {}[{}]", i, codes_static, i))
16947
+ // The fasta ladder is specialised around f64 arms, so an integer-represented
16948
+ // codes array widens here rather than perturbing that path.
16949
+ .map(|i| {
16950
+ if self.module_const_int_statics.contains(codes_static.as_str()) {
16951
+ format!("{} => {}[{}] as f64", i, codes_static, i)
16952
+ } else {
16953
+ format!("{} => {}[{}]", i, codes_static, i)
16954
+ }
16955
+ })
16306
16956
  .collect();
16307
16957
  self.writeln(&format!(
16308
16958
  "{} = (({} + match ({} as usize) {{ {}, _ => 0.0 }}) % 2147483647_f64);",
@@ -16460,11 +17110,110 @@ impl Codegen {
16460
17110
  self.native_fn_emit_name.is_some() || self.native_vec_ret.is_some()
16461
17111
  }
16462
17112
 
16463
- fn emit_statements_with_folds(&mut self, statements: &[Statement]) -> Result<(), CompileError> {
16464
- let mut i = 0;
16465
- while i < statements.len() {
16466
- if let Some(skip) = self.try_emit_const_cum_search_fold(&statements[i..])? {
16467
- i += skip;
17113
+ /// #629: move module-level `let`s with LITERAL initialisers ahead of any function declared
17114
+ /// above them that references the name. Returns `None` when nothing needs moving, so the common
17115
+ /// program keeps its exact statement slice and this costs one scan.
17116
+ ///
17117
+ /// Only bindings that are actually forward-referenced move, and only past the point where they
17118
+ /// are first needed — the relative order of everything else is preserved.
17119
+ fn hoist_forward_referenced_module_bindings(stmts: &[Statement]) -> Option<Vec<Statement>> {
17120
+ // Names bound at module level by a literal `let`, with the index of their declaration.
17121
+ let mut literal_decls: Vec<(usize, String)> = Vec::new();
17122
+ for (i, s) in stmts.iter().enumerate() {
17123
+ if let Statement::VarDecl {
17124
+ name,
17125
+ init: Some(e),
17126
+ ..
17127
+ } = s
17128
+ {
17129
+ if Self::is_literal_initialiser(e) {
17130
+ literal_decls.push((i, name.to_string()));
17131
+ }
17132
+ }
17133
+ }
17134
+ if literal_decls.is_empty() {
17135
+ return None;
17136
+ }
17137
+ // A NAME DECLARED MORE THAN ONCE AT MODULE LEVEL IS NEVER MOVED. An earlier mention of such
17138
+ // a name refers to the EARLIER binding, not forward to this one, so hoisting this
17139
+ // declaration over it silently changes which value the earlier code sees. That is a wrong
17140
+ // ANSWER, not a build break: tests/core/js_compat_gaps.tish declares `text` twice — a string
17141
+ // at the top and a different string further down — and moving the second above the first
17142
+ // made an intervening `slice` read the wrong source, printing `hello 123 world 45` where
17143
+ // `.mdx` was expected.
17144
+ let mut decl_counts: std::collections::HashMap<&str, usize> =
17145
+ std::collections::HashMap::new();
17146
+ for s in stmts {
17147
+ if let Statement::VarDecl { name, .. } = s {
17148
+ *decl_counts.entry(name.as_ref()).or_insert(0) += 1;
17149
+ }
17150
+ }
17151
+ // A binding needs moving when a FUNCTION BODY above its declaration mentions it. Deferred
17152
+ // execution is what makes a forward reference legal — the body does not run until called.
17153
+ //
17154
+ // Straight-line code above the declaration is deliberately NOT counted: there the mention
17155
+ // either refers to a previous binding of the name or is a genuine use-before-init, and in
17156
+ // neither case may the declaration be moved over it.
17157
+ let mut needed: Vec<(usize, usize)> = Vec::new(); // (decl index, first referencing index)
17158
+ for (di, name) in &literal_decls {
17159
+ if decl_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
17160
+ continue;
17161
+ }
17162
+ let mut first = None;
17163
+ for (i, s) in stmts.iter().enumerate().take(*di) {
17164
+ if matches!(s, Statement::FunDecl { .. }) && Self::stmt_mentions_ident(s, name) {
17165
+ first = Some(i);
17166
+ break;
17167
+ }
17168
+ }
17169
+ if let Some(f) = first {
17170
+ needed.push((*di, f));
17171
+ }
17172
+ }
17173
+ if needed.is_empty() {
17174
+ return None;
17175
+ }
17176
+ // Rebuild: each moved declaration is inserted immediately before the first statement that
17177
+ // references it, keeping every other statement in place.
17178
+ let mut out: Vec<Statement> = Vec::with_capacity(stmts.len());
17179
+ let moved: std::collections::HashSet<usize> = needed.iter().map(|(d, _)| *d).collect();
17180
+ for (i, s) in stmts.iter().enumerate() {
17181
+ for (di, fi) in &needed {
17182
+ if *fi == i {
17183
+ out.push(stmts[*di].clone());
17184
+ }
17185
+ }
17186
+ if !moved.contains(&i) {
17187
+ out.push(s.clone());
17188
+ }
17189
+ }
17190
+ Some(out)
17191
+ }
17192
+
17193
+ /// An initialiser that cannot observe or disturb program state, so moving it is invisible.
17194
+ /// Deliberately narrow — array/object literals are included only when every element is itself
17195
+ /// literal, so no call or property read comes along for the ride.
17196
+ fn is_literal_initialiser(e: &Expr) -> bool {
17197
+ match e {
17198
+ Expr::Literal { .. } => true,
17199
+ Expr::Unary { operand, .. } => Self::is_literal_initialiser(operand),
17200
+ Expr::Array { elements, .. } => elements.iter().all(|el| match el {
17201
+ ArrayElement::Expr(e) => Self::is_literal_initialiser(e),
17202
+ _ => false,
17203
+ }),
17204
+ Expr::Object { props, .. } => props.iter().all(|p| match p {
17205
+ ObjectProp::KeyValue(_, v, _) => Self::is_literal_initialiser(v),
17206
+ _ => false,
17207
+ }),
17208
+ _ => false,
17209
+ }
17210
+ }
17211
+
17212
+ fn emit_statements_with_folds(&mut self, statements: &[Statement]) -> Result<(), CompileError> {
17213
+ let mut i = 0;
17214
+ while i < statements.len() {
17215
+ if let Some(skip) = self.try_emit_const_cum_search_fold(&statements[i..])? {
17216
+ i += skip;
16468
17217
  continue;
16469
17218
  }
16470
17219
  if self.native_fn_body_emit || self.native_vec_ret.is_some() {
@@ -16534,6 +17283,22 @@ impl Codegen {
16534
17283
  }
16535
17284
 
16536
17285
  /// Emit `thread_local! { static G_NAME: Cell<f64> = ... }` for each eligible global.
17286
+ /// #594, GBA: the no_std twin of `emit_native_numeric_global_tls`. `SingleCore` is the facade's
17287
+ /// single-core static wrapper — sound because the GBA runs one cooperative thread and no
17288
+ /// interrupt handler touches interpreter state — and its `with()` mirrors `LocalKey::with`, so
17289
+ /// the read/write emission is shared with the TLS path rather than duplicated.
17290
+ fn emit_native_numeric_global_singlecore(&mut self) -> Result<(), CompileError> {
17291
+ for (name, init) in self.native_numeric_globals.clone() {
17292
+ self.writeln(&format!(
17293
+ "static {}: tishlang_runtime::SingleCore<core::cell::Cell<f64>> = \
17294
+ tishlang_runtime::SingleCore::new(core::cell::Cell::new({}));",
17295
+ Self::native_global_static(&name),
17296
+ Self::f64_lit(init),
17297
+ ));
17298
+ }
17299
+ Ok(())
17300
+ }
17301
+
16537
17302
  fn emit_native_numeric_global_tls(&mut self) -> Result<(), CompileError> {
16538
17303
  self.writeln("thread_local! {");
16539
17304
  self.indent += 1;
@@ -16552,6 +17317,9 @@ impl Codegen {
16552
17317
  /// Top-level `let name = [n0, n1, …]` with numeric literals, never reassigned — emitted as
16553
17318
  /// `const G_name: [f64; N]` for direct indexing from native fns (fasta `codes`/`probs`).
16554
17319
  fn collect_module_const_f64_arrays(stmts: &[Statement]) -> HashMap<String, Vec<f64>> {
17320
+ // Every name bound anywhere in the program by anything other than the candidate decls
17321
+ // themselves. Collected alongside the existing per-fn local scan below.
17322
+ let mut shadow_bindings: HashSet<String> = HashSet::new();
16555
17323
  use std::collections::{HashMap, HashSet};
16556
17324
  let mut candidates: HashMap<String, Vec<f64>> = HashMap::new();
16557
17325
  for s in stmts {
@@ -16592,6 +17360,16 @@ impl Codegen {
16592
17360
  candidates.remove(&n);
16593
17361
  }
16594
17362
  }
17363
+ // …AND ANY OTHER BINDING OF THE NAME, not just `let`/`const`. `collect_local_var_names`
17364
+ // sees VarDecl only, so a PARAMETER, a `for-of` variable or a `catch` binding named like
17365
+ // a module const did not disqualify it — and the const fast path in `emit_typed_expr`'s
17366
+ // Index arm is static, with no scope check, so `function f(T: number[], i) { return T[i] }`
17367
+ // read the MODULE `T` and returned the wrong number with no diagnostic. The sibling
17368
+ // numeric-global pass has always used `collect_binding_names` for exactly this reason.
17369
+ Self::collect_binding_names(s, &mut shadow_bindings);
17370
+ }
17371
+ for n in &shadow_bindings {
17372
+ candidates.remove(n);
16595
17373
  }
16596
17374
  let globals: HashSet<String> = candidates.keys().cloned().collect();
16597
17375
  let mut reassigns: Vec<(String, &Expr)> = Vec::new();
@@ -16630,41 +17408,82 @@ impl Codegen {
16630
17408
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
16631
17409
  Self::walk_stmts_for_module_const_disqualifiers(statements, g, bad);
16632
17410
  }
17411
+ // EVERY child expression has to be visited, not just the sub-statements. These arms used
17412
+ // to walk only the bodies, so a use in a loop or branch CONDITION was invisible — which
17413
+ // is how `while (i < SB_FORCE.length)` (card-gba qb-engine.tish) kept an array eligible
17414
+ // for hoisting even though `.length` is a disqualifying member use. Same defect class as
17415
+ // the missing closure arm below: the walker's silence meant "safe", not "absent".
16633
17416
  Statement::If {
17417
+ cond,
16634
17418
  then_branch,
16635
17419
  else_branch,
16636
17420
  ..
16637
17421
  } => {
17422
+ Self::walk_expr_for_module_const_disqualifiers(cond, g, bad);
16638
17423
  Self::walk_stmt_for_module_const_disqualifiers(then_branch, g, bad);
16639
17424
  if let Some(eb) = else_branch {
16640
17425
  Self::walk_stmt_for_module_const_disqualifiers(eb, g, bad);
16641
17426
  }
16642
17427
  }
16643
- Statement::While { body, .. } | Statement::DoWhile { body, .. } => {
17428
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
17429
+ Self::walk_expr_for_module_const_disqualifiers(cond, g, bad);
16644
17430
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
16645
17431
  }
16646
- Statement::For { init, body, .. } => {
17432
+ Statement::For {
17433
+ init,
17434
+ cond,
17435
+ update,
17436
+ body,
17437
+ ..
17438
+ } => {
16647
17439
  if let Some(i) = init {
16648
17440
  Self::walk_stmt_for_module_const_disqualifiers(i, g, bad);
16649
17441
  }
17442
+ if let Some(c) = cond {
17443
+ Self::walk_expr_for_module_const_disqualifiers(c, g, bad);
17444
+ }
17445
+ if let Some(u) = update {
17446
+ Self::walk_expr_for_module_const_disqualifiers(u, g, bad);
17447
+ }
16650
17448
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
16651
17449
  }
16652
- Statement::ForOf { body, .. }
16653
- | Statement::ForIn { body, .. } => {
17450
+ Statement::ForOf { iterable, body, .. }
17451
+ | Statement::ForIn {
17452
+ object: iterable,
17453
+ body,
17454
+ ..
17455
+ } => {
17456
+ // ITERATING THE ARRAY IS A SUPPORTED USE, not a disqualifying one: `for (x of X)`
17457
+ // lowers to a native index loop over the hoisted static (`for _fof_i0 in
17458
+ // 0..G_X.len()`), so the name never survives into the output. Walking the iterable
17459
+ // unconditionally would see a bare `Ident` and refuse the hoist, which loses the
17460
+ // very optimisation the pass exists for (tests/core/typed_array_forof.tish).
17461
+ // Anything else in iterable position is walked normally.
17462
+ if !matches!(iterable, Expr::Ident { name, .. } if name.as_ref() == g) {
17463
+ Self::walk_expr_for_module_const_disqualifiers(iterable, g, bad);
17464
+ }
16654
17465
  Self::walk_stmt_for_module_const_disqualifiers(body, g, bad);
16655
17466
  }
16656
17467
  Statement::Switch {
17468
+ expr,
16657
17469
  cases,
16658
17470
  default_body,
16659
17471
  ..
16660
17472
  } => {
16661
- for (_, stmts) in cases {
17473
+ Self::walk_expr_for_module_const_disqualifiers(expr, g, bad);
17474
+ for (case_expr, stmts) in cases {
17475
+ if let Some(e) = case_expr {
17476
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17477
+ }
16662
17478
  Self::walk_stmts_for_module_const_disqualifiers(stmts, g, bad);
16663
17479
  }
16664
17480
  if let Some(stmts) = default_body {
16665
17481
  Self::walk_stmts_for_module_const_disqualifiers(stmts, g, bad);
16666
17482
  }
16667
17483
  }
17484
+ Statement::Throw { value, .. } => {
17485
+ Self::walk_expr_for_module_const_disqualifiers(value, g, bad);
17486
+ }
16668
17487
  Statement::Try {
16669
17488
  body,
16670
17489
  catch_body,
@@ -16695,10 +17514,32 @@ impl Codegen {
16695
17514
  Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
16696
17515
  }
16697
17516
  }
16698
- _ => {}
17517
+ // CONSERVATIVE DEFAULT. This walker decides whether it is safe to move an array into a
17518
+ // `const`, so "I do not model this statement" must mean "refuse", never "permit" — the
17519
+ // old `_ => {}` meant the latter, and a wrong permit is a program that does not compile.
17520
+ // Over-rejecting only costs the hoist; under-rejecting cost four ROMs their build.
17521
+ s => {
17522
+ if Self::stmt_mentions_ident(s, g) {
17523
+ *bad = true;
17524
+ }
17525
+ }
16699
17526
  }
16700
17527
  }
16701
17528
 
17529
+ /// Complete-traversal membership test, used as the safe fallback by both walkers above.
17530
+ fn stmt_mentions_ident(s: &Statement, g: &str) -> bool {
17531
+ let mut idents = HashSet::new();
17532
+ Self::collect_stmt_idents(s, &mut idents);
17533
+ idents.contains(g)
17534
+ }
17535
+
17536
+ /// As `stmt_mentions_ident`, for an expression subtree.
17537
+ fn expr_mentions_ident(e: &Expr, g: &str) -> bool {
17538
+ let mut idents = HashSet::new();
17539
+ Self::collect_expr_idents(e, &mut idents);
17540
+ idents.contains(g)
17541
+ }
17542
+
16702
17543
  fn walk_expr_for_module_const_disqualifiers(e: &Expr, g: &str, bad: &mut bool) {
16703
17544
  if *bad {
16704
17545
  return;
@@ -16756,9 +17597,51 @@ impl Codegen {
16756
17597
  callee.as_ref(),
16757
17598
  Expr::Ident { name, .. } if name.as_ref() == "cumulative"
16758
17599
  );
17600
+ // A METHOD CALL ON THE ARRAY IS SUPPORTED — but only the ones that go through the
17601
+ // `Value` bridge. `X.join(", ")` emits the object as
17602
+ // `NumArrayBacking::Packed(G_X…)`, so the local name does not survive and the hoist
17603
+ // is fine (tests/core/template_literals.tish).
17604
+ //
17605
+ // THE FUSEABLE HOFs ARE NOT. `X.map(...)`, `X.reduce(...)` and their siblings lower
17606
+ // to a NATIVE iterator chain — `xs.iter().copied().map(...)` — which names the local
17607
+ // directly and is NOT rewritten to the static. Exempting them hoisted an array whose
17608
+ // every use then referenced a binding that no longer existed:
17609
+ //
17610
+ // let mut sum: f64 = { … for b in xs.iter().copied() { … } … };
17611
+ // error[E0425]: cannot find value `xs` in this scope
17612
+ //
17613
+ // Caught by the perf gauntlet's `typed_array_hof` fixture on the NATIVE target —
17614
+ // this walker is not GBA-gated, so a too-permissive exemption breaks the host.
17615
+ //
17616
+ // Nor does the exemption extend to a member READ such as `X.length`, which lowers to
17617
+ // a bare `X.len()` — one of the two shapes that broke the ROMs.
17618
+ // ALLOWLIST, NOT DENYLIST. This started as "any method call except the fuseable
17619
+ // HOFs", which is allow-by-default — and every MUTATING method walked straight
17620
+ // through it. `arr2.unshift(1, 2)` hoisted its receiver into a `const` and then
17621
+ // tried to mutate it, so the local vanished and rustc reported
17622
+ // `cannot find value arr2` (tests/core/array_methods.tish, under
17623
+ // TISH_PACKED_ARRAYS=1). `push`/`pop`/`splice`/`sort`/`reverse`/`fill` are the same
17624
+ // shape.
17625
+ //
17626
+ // So only methods PROVEN to route the receiver through the `Value` bridge are
17627
+ // exempt. `join` is the one this pass actually needs
17628
+ // (tests/core/template_literals.tish emits
17629
+ // `array_join(&Value::NumberArray(…Packed(G_NUMS…)))`, where the local name does not
17630
+ // survive). Anything else — mutators, the HOFs that lower to a native iterator chain
17631
+ // over the bare local, and any method added later — disqualifies the hoist, which is
17632
+ // the safe direction: it costs an optimisation, not a build.
17633
+ let member_call_on_g = matches!(
17634
+ callee.as_ref(),
17635
+ Expr::Member { object, prop, .. }
17636
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g)
17637
+ && matches!(
17638
+ prop,
17639
+ MemberProp::Name { name: m, .. } if m.as_ref() == "join"
17640
+ )
17641
+ );
16759
17642
  if matches!(callee.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) {
16760
17643
  *bad = true;
16761
- } else {
17644
+ } else if !member_call_on_g {
16762
17645
  Self::walk_expr_for_module_const_disqualifiers(callee, g, bad);
16763
17646
  }
16764
17647
  for (i, a) in args.iter().enumerate() {
@@ -16809,7 +17692,75 @@ impl Codegen {
16809
17692
  }
16810
17693
  }
16811
17694
  }
16812
- _ => {}
17695
+ // Everything below was missing, and the omission is what made this pass unsafe: an
17696
+ // admission check whose walker reports "no disqualifying use" for a shape it does not
17697
+ // recognise is silently permissive. `ArrowFunction` was the expensive one — every use
17698
+ // inside a CLOSURE landed in `_ => {}`, so an array a closure writes got hoisted to a
17699
+ // `const` anyway. Reads then renamed to `G_X` while the write kept the boxed local, and
17700
+ // that local is declared after the closure: rustc reports `cannot find value` on a name
17701
+ // the game never misspelled (card-gba queensblood/triad/gwent; tish-gba hyrule's
17702
+ // `rangLive`). Renaming the write would not have been a fix either — a `const` cannot be
17703
+ // assigned through, so a written array must not be hoisted at all. The sibling scalar
17704
+ // walker has always had these arms; this one is a copy that lost them.
17705
+ //
17706
+ // Indexing stays deliberately absent from the disqualifier list: `X[i]` is the whole
17707
+ // point of the hoist, so descending here must not become "any closure mention kills it",
17708
+ // which would give back the #594 reachability this pass exists to provide.
17709
+ Expr::ArrowFunction { body, .. } => match body {
17710
+ ArrowBody::Expr(e) => Self::walk_expr_for_module_const_disqualifiers(e, g, bad),
17711
+ ArrowBody::Block(s) => Self::walk_stmt_for_module_const_disqualifiers(s, g, bad),
17712
+ },
17713
+ // A write to a member of the const, by the same rule the `Member` arm above uses.
17714
+ Expr::MemberAssign { object, value, .. } => {
17715
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) {
17716
+ *bad = true;
17717
+ } else {
17718
+ Self::walk_expr_for_module_const_disqualifiers(object, g, bad);
17719
+ }
17720
+ Self::walk_expr_for_module_const_disqualifiers(value, g, bad);
17721
+ }
17722
+ Expr::Delete { target, .. }
17723
+ if matches!(target.as_ref(), Expr::Ident { name, .. } if name.as_ref() == g) =>
17724
+ {
17725
+ *bad = true;
17726
+ }
17727
+ // The remaining shapes need no special rule: a bare mention of the name is already
17728
+ // disqualifying via the `Ident` arm, so plain descent is sufficient.
17729
+ Expr::New { callee, args, .. } => {
17730
+ Self::walk_expr_for_module_const_disqualifiers(callee, g, bad);
17731
+ for a in args {
17732
+ if let CallArg::Expr(e) | CallArg::Spread(e) = a {
17733
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17734
+ }
17735
+ }
17736
+ }
17737
+ Expr::NullishCoalesce { left, right, .. } => {
17738
+ Self::walk_expr_for_module_const_disqualifiers(left, g, bad);
17739
+ Self::walk_expr_for_module_const_disqualifiers(right, g, bad);
17740
+ }
17741
+ Expr::TypeOf { operand, .. } => {
17742
+ Self::walk_expr_for_module_const_disqualifiers(operand, g, bad);
17743
+ }
17744
+ Expr::TemplateLiteral { exprs, .. } => {
17745
+ for e in exprs {
17746
+ Self::walk_expr_for_module_const_disqualifiers(e, g, bad);
17747
+ }
17748
+ }
17749
+ // Leaves, listed explicitly so they do NOT reach the fallback below. A literal cannot
17750
+ // mention a binding, and there is one in nearly every expression in a real program —
17751
+ // routing those through a whole-subtree ident scan turned this walk superlinear and hung
17752
+ // the compiler outright (a `tish build` of `scii` spun at 100% CPU for 40+ minutes in the
17753
+ // frontend, no rustc involved). The fallback is for shapes that might CONTAIN a
17754
+ // reference, not for every constant in the file.
17755
+ Expr::Literal { .. } => {}
17756
+ // CONSERVATIVE DEFAULT — see the statement walker. An expression shape this match does
17757
+ // not model (today: `Await`, `JsxElement`, and anything the AST gains later) disqualifies
17758
+ // if the name appears anywhere beneath it, rather than being waved through.
17759
+ other => {
17760
+ if Self::expr_mentions_ident(other, g) {
17761
+ *bad = true;
17762
+ }
17763
+ }
16813
17764
  }
16814
17765
  }
16815
17766
 
@@ -16993,15 +17944,41 @@ impl Codegen {
16993
17944
  )
16994
17945
  }
16995
17946
 
16996
- fn module_const_f64_array_as_value(static_name: &str) -> String {
17947
+ /// The boxed bridge. `NumArrayBacking::Packed` is a `Vec<f64>`, so an `[i32; N]` hoist widens on
17948
+ /// the way out — this is the escape hatch where the array is handed to code that wants a
17949
+ /// `Value`, and it is a whole-array copy either way.
17950
+ fn module_const_f64_array_as_value(&self, static_name: &str) -> String {
17951
+ let elems = if self.module_const_int_statics.contains(static_name) {
17952
+ format!("{}.iter().map(|v| *v as f64)", static_name)
17953
+ } else {
17954
+ format!("{}.iter().copied()", static_name)
17955
+ };
16997
17956
  format!(
16998
- "Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed({}.iter().copied().collect::<Vec<f64>>())))",
16999
- static_name
17957
+ "Value::NumberArray(VmRef::new(tishlang_runtime::NumArrayBacking::Packed({}.collect::<Vec<f64>>())))",
17958
+ elems
17000
17959
  )
17001
17960
  }
17002
17961
 
17003
17962
  fn emit_module_const_f64_arrays(&mut self) -> Result<(), CompileError> {
17004
17963
  for (name, vals) in self.module_const_f64_arrays.clone() {
17964
+ let static_name = Self::module_const_static(name.as_str());
17965
+ // `[i32; N]` where the values permit it — half the ROM, and reads that stay in the
17966
+ // integer domain instead of round-tripping through soft float. See the field comment on
17967
+ // `module_const_int_statics`.
17968
+ if self.module_const_int_statics.contains(&static_name) {
17969
+ let lit = vals
17970
+ .iter()
17971
+ .map(|v| format!("{}", *v as i32))
17972
+ .collect::<Vec<_>>()
17973
+ .join(", ");
17974
+ self.writeln(&format!(
17975
+ "const {}: [i32; {}] = [{}];",
17976
+ static_name,
17977
+ vals.len(),
17978
+ lit
17979
+ ));
17980
+ continue;
17981
+ }
17005
17982
  let lit = vals
17006
17983
  .iter()
17007
17984
  .map(|v| Self::f64_lit(*v))
@@ -17009,7 +17986,7 @@ impl Codegen {
17009
17986
  .join(", ");
17010
17987
  self.writeln(&format!(
17011
17988
  "const {}: [f64; {}] = [{}];",
17012
- Self::module_const_static(name.as_str()),
17989
+ static_name,
17013
17990
  vals.len(),
17014
17991
  lit
17015
17992
  ));
@@ -17413,35 +18390,74 @@ impl Codegen {
17413
18390
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
17414
18391
  Self::walk_stmts_for_global_disqualifiers(statements, g, bad);
17415
18392
  }
18393
+ // Conditions are visited, not just bodies. This walker had the same blind spot the
18394
+ // module-const one did: `while (i < X.length)` put a disqualifying use somewhere the
18395
+ // walk never reached, so the global was hoisted anyway and the emitted program
18396
+ // referenced a name that no longer existed. Latent here only because a scalar is rarely
18397
+ // spelled that way — the defect is identical.
17416
18398
  Statement::If {
18399
+ cond,
17417
18400
  then_branch,
17418
18401
  else_branch,
17419
18402
  ..
17420
18403
  } => {
18404
+ Self::walk_expr_for_global_disqualifiers(cond, g, bad);
17421
18405
  Self::walk_stmt_for_global_disqualifiers(then_branch, g, bad);
17422
18406
  if let Some(eb) = else_branch {
17423
18407
  Self::walk_stmt_for_global_disqualifiers(eb, g, bad);
17424
18408
  }
17425
18409
  }
17426
- Statement::While { body, .. }
17427
- | Statement::DoWhile { body, .. }
17428
- | Statement::For { body, .. }
17429
- | Statement::ForOf { body, .. }
17430
- | Statement::ForIn { body, .. } => {
18410
+ Statement::While { cond, body, .. } | Statement::DoWhile { body, cond, .. } => {
18411
+ Self::walk_expr_for_global_disqualifiers(cond, g, bad);
18412
+ Self::walk_stmt_for_global_disqualifiers(body, g, bad);
18413
+ }
18414
+ Statement::For {
18415
+ init,
18416
+ cond,
18417
+ update,
18418
+ body,
18419
+ ..
18420
+ } => {
18421
+ if let Some(i) = init {
18422
+ Self::walk_stmt_for_global_disqualifiers(i, g, bad);
18423
+ }
18424
+ if let Some(c) = cond {
18425
+ Self::walk_expr_for_global_disqualifiers(c, g, bad);
18426
+ }
18427
+ if let Some(u) = update {
18428
+ Self::walk_expr_for_global_disqualifiers(u, g, bad);
18429
+ }
18430
+ Self::walk_stmt_for_global_disqualifiers(body, g, bad);
18431
+ }
18432
+ Statement::ForOf { iterable, body, .. }
18433
+ | Statement::ForIn {
18434
+ object: iterable,
18435
+ body,
18436
+ ..
18437
+ } => {
18438
+ Self::walk_expr_for_global_disqualifiers(iterable, g, bad);
17431
18439
  Self::walk_stmt_for_global_disqualifiers(body, g, bad);
17432
18440
  }
17433
18441
  Statement::Switch {
18442
+ expr,
17434
18443
  cases,
17435
18444
  default_body,
17436
18445
  ..
17437
18446
  } => {
17438
- for (_, stmts) in cases {
18447
+ Self::walk_expr_for_global_disqualifiers(expr, g, bad);
18448
+ for (case_expr, stmts) in cases {
18449
+ if let Some(e) = case_expr {
18450
+ Self::walk_expr_for_global_disqualifiers(e, g, bad);
18451
+ }
17439
18452
  Self::walk_stmts_for_global_disqualifiers(stmts, g, bad);
17440
18453
  }
17441
18454
  if let Some(d) = default_body {
17442
18455
  Self::walk_stmts_for_global_disqualifiers(d, g, bad);
17443
18456
  }
17444
18457
  }
18458
+ Statement::Throw { value, .. } => {
18459
+ Self::walk_expr_for_global_disqualifiers(value, g, bad);
18460
+ }
17445
18461
  Statement::Try {
17446
18462
  body,
17447
18463
  catch_body,
@@ -17640,6 +18656,151 @@ impl Codegen {
17640
18656
  ok
17641
18657
  }
17642
18658
 
18659
+ /// #597: every `let x: T[]` name in the program, at any nesting depth. These are the candidates
18660
+ /// whose escape has to be checked before they may lower to a native `Vec`.
18661
+ fn collect_annotated_array_names(stmts: &[Statement], out: &mut Vec<String>) {
18662
+ for s in stmts {
18663
+ Self::collect_annotated_array_names_stmt(s, out);
18664
+ }
18665
+ }
18666
+
18667
+ fn collect_annotated_array_names_stmt(s: &Statement, out: &mut Vec<String>) {
18668
+ match s {
18669
+ Statement::VarDecl {
18670
+ name,
18671
+ type_ann: Some(t),
18672
+ ..
18673
+ } => {
18674
+ if matches!(crate::types::RustType::from_annotation(t), RustType::Vec(_)) {
18675
+ out.push(name.to_string());
18676
+ }
18677
+ }
18678
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
18679
+ for s in statements {
18680
+ Self::collect_annotated_array_names_stmt(s, out);
18681
+ }
18682
+ }
18683
+ Statement::If {
18684
+ then_branch,
18685
+ else_branch,
18686
+ ..
18687
+ } => {
18688
+ Self::collect_annotated_array_names_stmt(then_branch, out);
18689
+ if let Some(e) = else_branch {
18690
+ Self::collect_annotated_array_names_stmt(e, out);
18691
+ }
18692
+ }
18693
+ Statement::While { body, .. }
18694
+ | Statement::DoWhile { body, .. }
18695
+ | Statement::ForOf { body, .. }
18696
+ | Statement::ForIn { body, .. }
18697
+ | Statement::FunDecl { body, .. } => {
18698
+ Self::collect_annotated_array_names_stmt(body, out);
18699
+ }
18700
+ Statement::For { init, body, .. } => {
18701
+ if let Some(i) = init {
18702
+ Self::collect_annotated_array_names_stmt(i, out);
18703
+ }
18704
+ Self::collect_annotated_array_names_stmt(body, out);
18705
+ }
18706
+ Statement::Switch {
18707
+ cases,
18708
+ default_body,
18709
+ ..
18710
+ } => {
18711
+ for (_, stmts) in cases {
18712
+ for s in stmts {
18713
+ Self::collect_annotated_array_names_stmt(s, out);
18714
+ }
18715
+ }
18716
+ if let Some(stmts) = default_body {
18717
+ for s in stmts {
18718
+ Self::collect_annotated_array_names_stmt(s, out);
18719
+ }
18720
+ }
18721
+ }
18722
+ Statement::Try {
18723
+ body,
18724
+ catch_body,
18725
+ finally_body,
18726
+ ..
18727
+ } => {
18728
+ Self::collect_annotated_array_names_stmt(body, out);
18729
+ if let Some(c) = catch_body {
18730
+ Self::collect_annotated_array_names_stmt(c, out);
18731
+ }
18732
+ if let Some(f) = finally_body {
18733
+ Self::collect_annotated_array_names_stmt(f, out);
18734
+ }
18735
+ }
18736
+ _ => {}
18737
+ }
18738
+ }
18739
+
18740
+ /// #597: array names that ESCAPE — handed to a call as a bare argument, or otherwise used as a
18741
+ /// whole value rather than indexed.
18742
+ ///
18743
+ /// Reuses `scan_param_use`, the same analysis the read-only param path already trusts for this
18744
+ /// exact hazard: its `forwarded` flag is documented as "the callee could mutate the array, and a
18745
+ /// write to the caller's array would be lost on the owned copy". That is #597 from the other
18746
+ /// side, so the answer should come from one analysis rather than a second, subtly-different one.
18747
+ ///
18748
+ /// Over-approximating only costs an array its native lowering. Under-approximating brings back a
18749
+ /// silently wrong result, so the bias is deliberate.
18750
+ fn collect_escaping_array_names(stmts: &[Statement], names: &[String]) -> std::collections::HashSet<String> {
18751
+ // `for_each_stmt_expr` deliberately does NOT descend into `FunDecl` bodies, so the forwarding
18752
+ // call is normally invisible to it — the array is declared inside a function and passed
18753
+ // inside that same function, which is exactly the reported shape. Gather nested bodies as
18754
+ // additional roots first.
18755
+ fn add_roots<'a>(s: &'a Statement, roots: &mut Vec<&'a Statement>) {
18756
+ roots.push(s);
18757
+ match s {
18758
+ Statement::FunDecl { body, .. }
18759
+ | Statement::While { body, .. }
18760
+ | Statement::DoWhile { body, .. }
18761
+ | Statement::ForOf { body, .. }
18762
+ | Statement::ForIn { body, .. }
18763
+ | Statement::For { body, .. } => add_roots(body, roots),
18764
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
18765
+ for s in statements {
18766
+ add_roots(s, roots);
18767
+ }
18768
+ }
18769
+ Statement::If {
18770
+ then_branch,
18771
+ else_branch,
18772
+ ..
18773
+ } => {
18774
+ add_roots(then_branch, roots);
18775
+ if let Some(e) = else_branch {
18776
+ add_roots(e, roots);
18777
+ }
18778
+ }
18779
+ _ => {}
18780
+ }
18781
+ }
18782
+ let mut roots: Vec<&Statement> = Vec::new();
18783
+ for s in stmts {
18784
+ add_roots(s, &mut roots);
18785
+ }
18786
+ let mut out = std::collections::HashSet::new();
18787
+ for n in names {
18788
+ let mut f = ParamUse::default();
18789
+ for s in &roots {
18790
+ Self::for_each_stmt_expr(s, &mut |e| Self::scan_param_use(e, n, false, &mut f));
18791
+ }
18792
+ // `forwarded` ONLY, not `escaped`. `escaped` covers any whole-value use — including
18793
+ // `return arr`, which hands ownership out and does not create the aliasing hazard this
18794
+ // is about. Using it deopted arrays that are legitimately native
18795
+ // (`numeric_returning_fn_keeps_pushed_array_native`). #597 is specifically "passed as a
18796
+ // call argument", which is what `forwarded` means.
18797
+ if f.forwarded {
18798
+ out.insert(n.clone());
18799
+ }
18800
+ }
18801
+ out
18802
+ }
18803
+
17643
18804
  fn for_each_expr_root(stmts: &[Statement], f: &mut dyn FnMut(&Expr)) {
17644
18805
  for s in stmts {
17645
18806
  Self::for_each_expr_root_stmt(s, f);
@@ -17826,6 +18987,12 @@ impl Codegen {
17826
18987
  Some(rt) => Self::ann_is_number(rt),
17827
18988
  None => true,
17828
18989
  };
18990
+ // THE SECOND SHAPE: an all-`i32` signature, which is what a device with no FPU
18991
+ // wants and what the existing gate excluded. The fixpoint below is unchanged and
18992
+ // still has to prove the body native-safe and every return numeric — the annotation
18993
+ // never decides that on its own (see `liar` in the issue).
18994
+ let i32_ok = Self::fn_sig_all_i32(params, return_type);
18995
+ let (params_ok, ret_ok) = (params_ok || i32_ok, ret_ok || i32_ok);
17829
18996
  // #320: 0-param numeric fns (e.g. k_nucleotide's `nextBase()` — mutates a numeric
17830
18997
  // global, returns number) are eligible too; the fixpoint below still proves the body
17831
18998
  // native-safe + all-numeric-returns, so `fn name_native() -> f64` is sound.
@@ -18219,6 +19386,27 @@ impl Codegen {
18219
19386
  }
18220
19387
  }
18221
19388
 
19389
+ /// Can a top-level native `fn` name this indexed object? A parameter, a global with a static,
19390
+ /// a native-vec, or a local it declared itself — anything else is a binding that only exists
19391
+ /// inside `run()`.
19392
+ fn native_safe_indexable(
19393
+ object: &Expr,
19394
+ params: &std::collections::HashSet<String>,
19395
+ globals: &std::collections::HashSet<String>,
19396
+ nums: &HashSet<String>,
19397
+ native_vec_names: &std::collections::HashSet<String>,
19398
+ ) -> bool {
19399
+ match object {
19400
+ Expr::Ident { name, .. } => {
19401
+ params.contains(name.as_ref())
19402
+ || globals.contains(name.as_ref())
19403
+ || nums.contains(name.as_ref())
19404
+ || native_vec_names.contains(name.as_ref())
19405
+ }
19406
+ _ => false,
19407
+ }
19408
+ }
19409
+
18222
19410
  fn native_safe_expr(
18223
19411
  expr: &Expr,
18224
19412
  params: &std::collections::HashSet<String>,
@@ -18241,24 +19429,45 @@ impl Codegen {
18241
19429
  && Self::native_safe_expr(value, params, cand, globals, nums, native_vec_names)
18242
19430
  }
18243
19431
  Expr::Binary { left, op, right, .. } => {
18244
- matches!(
19432
+ let arith = matches!(
18245
19433
  op,
18246
19434
  BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow
18247
19435
  | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge
18248
19436
  | BinOp::StrictEq | BinOp::StrictNe | BinOp::And | BinOp::Or
18249
- ) && Self::native_safe_expr(left, params, cand, globals, nums, native_vec_names)
19437
+ );
19438
+ // Bitwise, unlike in `numeric_shaped`, still recurses into its operands: there the
19439
+ // question is what the RESULT is (always a number), here it is whether the emitter
19440
+ // can lower the whole expression natively. Gated with its sibling so that off the
19441
+ // GBA numeric vocabulary eligibility is byte-identical to before.
19442
+ let bitwise = crate::types::gba_numerics_enabled()
19443
+ && matches!(
19444
+ op,
19445
+ BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor
19446
+ | BinOp::Shl | BinOp::Shr | BinOp::UShr
19447
+ );
19448
+ (arith || bitwise)
19449
+ && Self::native_safe_expr(left, params, cand, globals, nums, native_vec_names)
18250
19450
  && Self::native_safe_expr(right, params, cand, globals, nums, native_vec_names)
18251
19451
  }
18252
19452
  Expr::Unary { op, operand, .. } => {
18253
- matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::Not)
19453
+ (matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::Not)
19454
+ || (crate::types::gba_numerics_enabled()
19455
+ && matches!(op, UnaryOp::BitNot)))
18254
19456
  && Self::native_safe_expr(operand, params, cand, globals, nums, native_vec_names)
18255
19457
  }
18256
19458
  Expr::PostfixInc { name, .. }
18257
19459
  | Expr::PrefixInc { name, .. }
18258
19460
  | Expr::PostfixDec { name, .. }
18259
19461
  | Expr::PrefixDec { name, .. } => nums.contains(name.as_ref()),
19462
+ // THE INDEXED THING MUST BE IN SCOPE FOR A FREE `fn`, not merely be an identifier.
19463
+ // This accepted any name at all, and a native fn is emitted at top level where a
19464
+ // module-scope binding — a `let` inside `run()`, captured by the boxed closures — does
19465
+ // not exist: `function reads(i: i32): i32 { return ARR[i] & 255 }` compiled to a
19466
+ // `reads_native` whose body named `ARR`, and rustc rejected the generated program with
19467
+ // E0425. Masked until now because the globals set was only ever populated with things
19468
+ // that DO have a top-level static.
18260
19469
  Expr::Index { object, index, .. } => {
18261
- matches!(object.as_ref(), Expr::Ident { .. })
19470
+ Self::native_safe_indexable(object, params, globals, nums, native_vec_names)
18262
19471
  && Self::native_safe_expr(index, params, cand, globals, nums, native_vec_names)
18263
19472
  }
18264
19473
  Expr::IndexAssign {
@@ -18267,7 +19476,7 @@ impl Codegen {
18267
19476
  value,
18268
19477
  ..
18269
19478
  } => {
18270
- matches!(object.as_ref(), Expr::Ident { .. })
19479
+ Self::native_safe_indexable(object, params, globals, nums, native_vec_names)
18271
19480
  && Self::native_safe_expr(index, params, cand, globals, nums, native_vec_names)
18272
19481
  && Self::native_safe_expr(value, params, cand, globals, nums, native_vec_names)
18273
19482
  }
@@ -18347,6 +19556,26 @@ impl Codegen {
18347
19556
  || globals.contains(name.as_ref())
18348
19557
  || nums.contains(name.as_ref())
18349
19558
  }
19559
+ // A BITWISE RESULT IS A NUMBER WHATEVER WENT IN. `& | ^ << >> >>>` are ToInt32/ToUint32
19560
+ // contexts in the language, so `"7" & 255` is 7 and `{} | 0` is 0 — the operands need
19561
+ // no proof of their own, which is why this arm does not recurse into them. Their
19562
+ // absence is not a small omission on a device with no FPU: `(a + b) | 0`, `a & 255` and
19563
+ // `w >> 16` are what integer code on such a target is made of, and every function
19564
+ // containing one was ineligible for a native `fn` no matter how it was annotated.
19565
+ Expr::Binary { op, .. }
19566
+ if crate::types::gba_numerics_enabled()
19567
+ && matches!(
19568
+ op,
19569
+ BinOp::BitAnd
19570
+ | BinOp::BitOr
19571
+ | BinOp::BitXor
19572
+ | BinOp::Shl
19573
+ | BinOp::Shr
19574
+ | BinOp::UShr
19575
+ ) =>
19576
+ {
19577
+ true
19578
+ }
18350
19579
  Expr::Binary { left, op, right, .. } => {
18351
19580
  matches!(
18352
19581
  op,
@@ -18354,6 +19583,8 @@ impl Codegen {
18354
19583
  ) && Self::numeric_shaped(left, params, cand, globals, nums)
18355
19584
  && Self::numeric_shaped(right, params, cand, globals, nums)
18356
19585
  }
19586
+ // Unary `~` is ToInt32 for the same reason.
19587
+ Expr::Unary { op: UnaryOp::BitNot, .. } if crate::types::gba_numerics_enabled() => true,
18357
19588
  Expr::Unary { op, operand, .. } => {
18358
19589
  matches!(op, UnaryOp::Neg | UnaryOp::Pos)
18359
19590
  && Self::numeric_shaped(operand, params, cand, globals, nums)
@@ -18500,6 +19731,46 @@ impl Codegen {
18500
19731
  /// guarded fn is copy 0, the one with the stack check, so every entry from outside the cycle is
18501
19732
  /// checked. Inside rotation copy `i`, an intra-SCC call targets the callee's copy `i+1` (the
18502
19733
  /// checked copy 0 again after K levels).
19734
+ /// Bring one argument to a native fn's parameter type.
19735
+ ///
19736
+ /// The f64 ABI accepts an `i32` expression as-is (Rust widens on the `as` the emitter already
19737
+ /// writes); the i32 ABI has to ToInt32 an f64 one, which is exactly the coercion the boxed
19738
+ /// path applied on entry — moved to the caller, where the value is usually already an integer
19739
+ /// and the conversion folds away.
19740
+ fn coerce_native_arg(&self, code: &str, from: RustType, want: RustType) -> String {
19741
+ if from == want {
19742
+ return code.to_string();
19743
+ }
19744
+ match (from, want) {
19745
+ (RustType::Value, w) => w.from_value_expr(code),
19746
+ (RustType::I32, RustType::F64) => format!("(({}) as f64)", code),
19747
+ // FIXED IS NOT AN f64 AND THE FALLTHROUGH BELOW USED TO PRETEND IT WAS. `fixed` is agb's
19748
+ // `Num<i32,8>`; handed to a native `f64` parameter it is a hard E0308 in the generated
19749
+ // program, not a slow path — which is how four call sites in one file of hyrule stopped
19750
+ // that game building the moment its helper became M5-eligible. The scaling is the same
19751
+ // one `RustType::Fixed`'s Value boundary uses (`types.rs:603`), so a value crossing here
19752
+ // reads identically to one crossing there.
19753
+ (RustType::Fixed, RustType::F64) => format!("(({}).to_raw() as f64 / 256.0)", code),
19754
+ // And the other direction, for the same reason: a native f64 reaching a `fixed`
19755
+ // parameter. Truncating, matching `Fixed::from_value_expr` (`types.rs:507`).
19756
+ (RustType::F64, RustType::Fixed) => format!(
19757
+ "tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)",
19758
+ code
19759
+ ),
19760
+ (RustType::I32, RustType::Fixed) => format!(
19761
+ "tishlang_runtime::Fixed::from_raw((({}) as i32).wrapping_mul(256))",
19762
+ code
19763
+ ),
19764
+ // DIVIDE, DO NOT SHIFT. `>> 8` is an arithmetic shift and FLOORS: fixed -1.5 would
19765
+ // become -2 and -0.0039 would become -1. Rust integer division truncates toward zero,
19766
+ // which is what ToInt32 and `as i32` do, so -1.5 -> -1 and -0.0039 -> 0. The two agree
19767
+ // on every non-negative value, which is exactly why this would have shipped.
19768
+ (RustType::Fixed, RustType::I32) => format!("(({}).to_raw() / 256)", code),
19769
+ (_, RustType::I32) => format!("tishlang_runtime::to_int32({})", code),
19770
+ _ => code.to_string(),
19771
+ }
19772
+ }
19773
+
18503
19774
  fn native_call_target(&self, fname: &str) -> String {
18504
19775
  let base = format!("{}_native", Self::escape_ident(fname));
18505
19776
  if let (Some((cur_scc, i, k)), Some(&(callee_scc, _))) =
@@ -18521,11 +19792,13 @@ impl Codegen {
18521
19792
  if !self.native_fns.contains(name.as_ref()) {
18522
19793
  continue;
18523
19794
  }
19795
+ let i32_abi = self.native_fns_i32.contains(name.as_ref());
19796
+ let abi = if i32_abi { "i32" } else { "f64" };
18524
19797
  let plist: Vec<String> = params
18525
19798
  .iter()
18526
19799
  .filter_map(|p| match p {
18527
19800
  FunParam::Simple(tp) => {
18528
- Some(format!("mut {}: f64", Self::escape_ident(tp.name.as_ref())))
19801
+ Some(format!("mut {}: {}", Self::escape_ident(tp.name.as_ref()), abi))
18529
19802
  }
18530
19803
  _ => None,
18531
19804
  })
@@ -18545,18 +19818,30 @@ impl Codegen {
18545
19818
  self.type_context.push_scope();
18546
19819
  for p in params {
18547
19820
  if let FunParam::Simple(tp) = p {
18548
- self.type_context.define(tp.name.as_ref(), RustType::F64);
19821
+ self.type_context.define(
19822
+ tp.name.as_ref(),
19823
+ if i32_abi { RustType::I32 } else { RustType::F64 },
19824
+ );
18549
19825
  }
18550
19826
  }
18551
19827
  let copy_suffix = if copy_idx == 0 { String::new() } else { format!("_r{}", copy_idx) };
18552
- self.writeln(&format!("fn {}_native{}({}) -> f64 {{", Self::escape_ident(name.as_ref()), copy_suffix, plist.join(", ")));
19828
+ self.writeln(&format!("fn {}_native{}({}) -> {} {{", Self::escape_ident(name.as_ref()), copy_suffix, plist.join(", "), abi));
19829
+ let saved_i32_abi = self.native_fn_abi_i32;
19830
+ self.native_fn_abi_i32 = i32_abi;
18553
19831
  self.indent += 1;
18554
19832
  if copies > 1 {
18555
19833
  self.native_recur_rotation = Some((scc_id, copy_idx, copies));
18556
19834
  if copy_idx == 0 {
18557
19835
  // The guard: one thread-local read + pointer compare; the cold bail parks
18558
19836
  // the catchable RangeError and unwinds via the NaN sentinel (#381).
18559
- self.writeln("if tishlang_runtime::stack_low() { return tishlang_runtime::recursion_tripped_f64(); }");
19837
+ // The f64 bail unwinds through a NaN sentinel, which an `i32` return has no
19838
+ // room for. `compute_native_recur_fns` therefore never gives an i32-ABI fn
19839
+ // rotation copies — see the guard where it is called.
19840
+ self.writeln(if i32_abi {
19841
+ "if tishlang_runtime::stack_low() { return 0; }"
19842
+ } else {
19843
+ "if tishlang_runtime::stack_low() { return tishlang_runtime::recursion_tripped_f64(); }"
19844
+ });
18560
19845
  }
18561
19846
  }
18562
19847
  if let Some((global, mul, add, modulus)) = self.native_lcg_fns.get(name.as_ref()) {
@@ -18628,10 +19913,11 @@ impl Codegen {
18628
19913
  self.native_lcg_hoist.take();
18629
19914
  self.native_lcg_hoist_int = false;
18630
19915
  if !self.native_fn_body_returned {
18631
- self.writeln("0.0");
19916
+ self.writeln(if i32_abi { "0" } else { "0.0" });
18632
19917
  }
18633
19918
  self.native_fn_emit_name = None;
18634
19919
  }
19920
+ self.native_fn_abi_i32 = saved_i32_abi;
18635
19921
  self.indent -= 1;
18636
19922
  self.writeln("}");
18637
19923
  self.type_context.pop_scope();
@@ -18688,7 +19974,20 @@ impl Codegen {
18688
19974
  self.skip_iter_local = Some(iter);
18689
19975
  }
18690
19976
  }
18691
- self.emit_statements_with_folds(statements)?;
19977
+ // A NATIVE FN BODY IS A SCOPE, and it has to say so. `VarDecl` registers every
19978
+ // name it emits into `outer_vars_stack.last_mut()`; without a frame of its own,
19979
+ // a native fn's locals land in the MODULE frame — where they do not exist, because
19980
+ // the body was emitted inside `fn name_native(..)` at top level. Any later closure
19981
+ // referencing a same-named local of its own then sees the phantom in `outer_vars`
19982
+ // and emits `let i_cell = VmRef::new(i.clone());` against nothing: E0425, 49 of the
19983
+ // 114 tish-gba examples, every one of them with an `i` or `b` loop counter.
19984
+ //
19985
+ // The boxed FunDecl path has always pushed here (both of its arms do); this path
19986
+ // was written without one and only became reachable when M5 was enabled for GBA.
19987
+ self.outer_vars_stack.push(Vec::new());
19988
+ let r = self.emit_statements_with_folds(statements);
19989
+ self.outer_vars_stack.pop();
19990
+ r?;
18692
19991
  if self.pending_stayed_var.is_none() {
18693
19992
  self.skip_iter_local = None;
18694
19993
  }
@@ -18696,10 +19995,31 @@ impl Codegen {
18696
19995
  Statement::Return { value, .. } => {
18697
19996
  let e = value.as_ref().expect("eligible return has a value");
18698
19997
  let (code, ty) = self.emit_typed_expr(e)?;
18699
- let f = if ty == RustType::F64 {
19998
+ let want = if self.native_fn_abi_i32 { RustType::I32 } else { RustType::F64 };
19999
+ // The int-domain shortcut FIRST for an i32 return. A bitwise expression types as
20000
+ // `F64` by design (`types.rs`), so taking the generic path below would emit
20001
+ // `to_int32(<i32 work> as f64)` — the exact round trip this ABI exists to remove,
20002
+ // reintroduced at the return. `emit_int32_operand` is the same helper the operator
20003
+ // fixes use.
20004
+ if want == RustType::I32 {
20005
+ if let Some(code) = self.emit_int32_operand(e)? {
20006
+ if let Some((global, _, _, _)) = self.native_lcg_hoist.as_ref() {
20007
+ let _ = global;
20008
+ }
20009
+ self.writeln(&format!("return {};", code));
20010
+ self.native_fn_body_returned = true;
20011
+ return Ok(());
20012
+ }
20013
+ }
20014
+ let (code, ty) = (code, ty);
20015
+ let f = if ty == want {
18700
20016
  code
18701
20017
  } else if ty == RustType::Value {
18702
- RustType::F64.from_value_expr(&code)
20018
+ want.from_value_expr(&code)
20019
+ } else if want == RustType::I32 {
20020
+ // An f64-typed expression returned from an `i32` fn takes the same ToInt32 the
20021
+ // boxed path would have applied at the consumer.
20022
+ format!("tishlang_runtime::to_int32({})", code)
18703
20023
  } else {
18704
20024
  code
18705
20025
  };
@@ -19336,6 +20656,17 @@ impl Codegen {
19336
20656
  // fn either — its scalar params would be typed `f64` and mistype the arg (e.g. `fn rec(m){
19337
20657
  // log.push(m) }` called `rec("ran")`). Same gate the M4/M5/S-0 paths use.
19338
20658
  let nonnumeric = Self::fns_called_with_nonnumeric_arg(&program.statements);
20659
+ // Every top-level fn name. A CALL to one is validated separately (`vec_body_calls_native`);
20660
+ // what matters here is that the identifier itself is not a `run()` local the way a module
20661
+ // `let` or a builtin like `Math` is.
20662
+ let fn_names: std::collections::HashSet<String> = program
20663
+ .statements
20664
+ .iter()
20665
+ .filter_map(|s| match s {
20666
+ Statement::FunDecl { name, .. } => Some(name.to_string()),
20667
+ _ => None,
20668
+ })
20669
+ .collect();
19339
20670
  for s in &program.statements {
19340
20671
  if let Statement::FunDecl {
19341
20672
  async_: false,
@@ -19362,12 +20693,41 @@ impl Codegen {
19362
20693
  .params
19363
20694
  .iter()
19364
20695
  .any(|(_, k)| matches!(k, VecParamKind::Array { .. }));
19365
- if has_array_param || Self::body_uses_local_vec_ops(body) {
20696
+ // #605: a scalars-in/STRING-out fn has neither an array parameter nor a local
20697
+ // vec op, so it never reached this group and stayed a boxed closure — which is
20698
+ // what "VecRetKind has no String" amounts to in practice. The return shape earns
20699
+ // admission on its own, but ONLY under three extra conditions, each of which a
20700
+ // review found by breaking the version without it:
20701
+ //
20702
+ // 1. TOTAL. `vec_fn_return_kind` rejects an explicit bare `return;` but cannot
20703
+ // see an IMPLICIT fall-through, and the lowered fn's tail yields
20704
+ // `String::new()` where the boxed one yields `Value::Null` — so
20705
+ // `function label(n){ if(n>0){return "pos"} }` returned "" instead of null.
20706
+ // A wrong value, silently, from ordinary code.
20707
+ // 2. NO STRING-COMPARED PARAM. `scan_param_use` counts `===` as arithmetic, so
20708
+ // `tag === "fire"` "proves" `tag` numeric and the param takes the f64 ABI —
20709
+ // a `panic!("expected number")` on a no_std cart, or E0308. (infer.rs treats
20710
+ // the same operator as OVERLOADED for exactly this reason.)
20711
+ // 3. NO MODULE READS, on EVERY target. A string or object module binding has a
20712
+ // top-level home nowhere, and this is the first rule that admits a fn shaped
20713
+ // to read one, so the GBA-only gate below is not enough.
20714
+ let string_out = sig.ret == VecRetKind::Str
20715
+ && Self::body_always_returns(body)
20716
+ && Self::body_names_only_its_own(params, body, &fn_names);
20717
+ if has_array_param || Self::body_uses_local_vec_ops(body) || string_out {
19366
20718
  sigs.insert(name.to_string(), sig);
19367
20719
  }
19368
20720
  }
19369
20721
  }
19370
20722
  }
20723
+ // #630: A NAME REMOVED HERE IS REJECTED FOR GOOD. `collect_bad_vec_callsites` is a
20724
+ // SOUNDNESS verdict — the fn is called somewhere in a way the native-vec ABI cannot
20725
+ // represent — and that fact is a property of the program, which does not change during this
20726
+ // analysis. Re-admitting it later can only produce the same wrong answer.
20727
+ //
20728
+ // Recording it is also what makes the forwarding fixpoint below terminate; see the note
20729
+ // there.
20730
+ let mut rejected: std::collections::HashSet<String> = std::collections::HashSet::new();
19371
20731
  loop {
19372
20732
  let mut remove: Vec<String> = Vec::new();
19373
20733
  for s in &program.statements {
@@ -19379,6 +20739,7 @@ impl Codegen {
19379
20739
  break;
19380
20740
  }
19381
20741
  for n in remove {
20742
+ rejected.insert(n.clone());
19382
20743
  sigs.remove(&n);
19383
20744
  }
19384
20745
  if sigs.is_empty() {
@@ -19414,6 +20775,22 @@ impl Codegen {
19414
20775
  if Self::ann_is_simple(return_type.as_ref(), "fixed") {
19415
20776
  continue;
19416
20777
  }
20778
+ // #630: AND IT MUST NOT RE-ADD A NAME THE REMOVAL PASS ALREADY REJECTED. This is
20779
+ // what makes the analysis terminate. Without it the two fixpoints fight: the
20780
+ // forwarding pass admits `F`, the removal pass below proves `F` unsound and
20781
+ // drops it, `added` is true so the outer loop runs again, and `F` — re-evaluated
20782
+ // against a program that has not changed — is admitted again. Forever.
20783
+ //
20784
+ // The only exits were `!added` and `sigs.is_empty()`, and an oscillating pair
20785
+ // satisfies neither: measured on the reporter's project it sat at a steady
20786
+ // `sigs=3` for 200+ iterations at 100% CPU, with no cargo child and nothing
20787
+ // printed, which is why it read as a dead build rather than a slow one.
20788
+ //
20789
+ // With this guard every outer iteration must admit a name never admitted before,
20790
+ // so the loop is bounded by the number of top-level fns.
20791
+ if rejected.contains(name.as_ref()) {
20792
+ continue;
20793
+ }
19417
20794
  if let Some(sig) = Self::infer_vec_fn_sig_forwarding(params, body, &sigs) {
19418
20795
  let has_array_param = sig
19419
20796
  .params
@@ -19440,6 +20817,7 @@ impl Codegen {
19440
20817
  break;
19441
20818
  }
19442
20819
  for n in remove {
20820
+ rejected.insert(n.clone());
19443
20821
  sigs.remove(&n);
19444
20822
  }
19445
20823
  if sigs.is_empty() {
@@ -19874,6 +21252,100 @@ impl Codegen {
19874
21252
  None
19875
21253
  }
19876
21254
 
21255
+ /// Does `body` name ONLY things a free `fn` can see — its own parameters, its own locals, and
21256
+ /// other top-level fns? Anything else (a module `let`, or a builtin like `Math`/`console`) is a
21257
+ /// local of `run()` in the emitted program, and a free fn naming one is E0425.
21258
+ ///
21259
+ /// Stricter than the module-binding check it replaced for this door, and deliberately so:
21260
+ /// `regex_redux`'s `nextBase()` is scalars-in/string-out, reaches for `Math.floor`, and the
21261
+ /// native-vec emitter lowers that to a `get_prop` against the `Math` binding — which is not a
21262
+ /// module `let`, so a module-only guard waved it through and the fixture stopped building.
21263
+ fn body_names_only_its_own(
21264
+ params: &[FunParam],
21265
+ body: &Statement,
21266
+ fn_names: &std::collections::HashSet<String>,
21267
+ ) -> bool {
21268
+ let mut referenced = HashSet::new();
21269
+ Self::collect_stmt_idents(body, &mut referenced);
21270
+ let mut bound: HashSet<String> = params
21271
+ .iter()
21272
+ .flat_map(|p| p.bound_names())
21273
+ .map(|n| n.to_string())
21274
+ .collect();
21275
+ Self::collect_local_var_names(body, &mut bound);
21276
+ referenced
21277
+ .iter()
21278
+ .all(|n| bound.contains(n) || fn_names.contains(n))
21279
+ }
21280
+
21281
+ /// Does every path through `body` end in an explicit `return <expr>`? Conservative: only a
21282
+ /// trailing `return`, or an if/else where BOTH arms are total, counts. Anything else may fall
21283
+ /// off the end, and a lowered fn's tail default (`String::new()` / `0.0`) is not what the boxed
21284
+ /// path yields there (`Value::Null`).
21285
+ fn body_always_returns(body: &Statement) -> bool {
21286
+ match body {
21287
+ Statement::Return { value: Some(_), .. } => true,
21288
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
21289
+ statements.last().is_some_and(Self::body_always_returns)
21290
+ }
21291
+ Statement::If { then_branch, else_branch, .. } => {
21292
+ else_branch.as_ref().is_some_and(|e| {
21293
+ Self::body_always_returns(then_branch) && Self::body_always_returns(e)
21294
+ })
21295
+ }
21296
+ _ => false,
21297
+ }
21298
+ }
21299
+
21300
+ /// Every module-level `let`/`const` name. On GBA these are locals of `run()` — nothing gives
21301
+ /// them a top-level home — so a free `fn` naming one does not compile.
21302
+ fn collect_module_level_binding_names(
21303
+ statements: &[Statement],
21304
+ ) -> std::collections::HashSet<String> {
21305
+ let mut out = std::collections::HashSet::new();
21306
+ for s in statements {
21307
+ Self::collect_module_binding_names_of(s, &mut out);
21308
+ }
21309
+ out
21310
+ }
21311
+
21312
+ /// One statement's module-level bindings. `Multi` matters: `let a = 1, b = 2` parses to it, and
21313
+ /// codegen emits each declarator into the CURRENT scope — so at module level those are `run()`
21314
+ /// locals exactly like a plain `let`, and missing them let a free fn name one (E0425).
21315
+ fn collect_module_binding_names_of(s: &Statement, out: &mut std::collections::HashSet<String>) {
21316
+ match s {
21317
+ Statement::VarDecl { name, .. } => {
21318
+ out.insert(name.to_string());
21319
+ }
21320
+ Statement::VarDeclDestructure { pattern, .. } => {
21321
+ Self::collect_destruct_names(pattern, out);
21322
+ }
21323
+ Statement::Multi { statements, .. } => {
21324
+ for inner in statements {
21325
+ Self::collect_module_binding_names_of(inner, out);
21326
+ }
21327
+ }
21328
+ _ => {}
21329
+ }
21330
+ }
21331
+
21332
+ /// Does `body` read a module binding it would not be able to see as a free `fn`? Names the body
21333
+ /// binds itself (params are checked by the caller; locals here) shadow the module one, so they
21334
+ /// do not count — that is the same shadow rule #556 established for parameters.
21335
+ fn body_reads_module_binding(
21336
+ body: &Statement,
21337
+ module_bindings: &std::collections::HashSet<String>,
21338
+ ) -> bool {
21339
+ // NO SHADOW SUBTRACTION, deliberately. `collect_local_var_names` flattens every nested
21340
+ // block, branch and loop into one set with no scope structure, so an inner
21341
+ // `if (..) { let CFG = 0 }` would mask a module `CFG` read elsewhere in the body and let the
21342
+ // fn lower with a free name (E0425). Over-rejecting costs a fn its lowering; under-rejecting
21343
+ // costs the whole program its build.
21344
+ let mut referenced = HashSet::new();
21345
+ Self::collect_stmt_idents(body, &mut referenced);
21346
+ referenced.iter().any(|n| module_bindings.contains(n))
21347
+ }
21348
+
19877
21349
  /// Detect, validate, and emit the native-vec fn group. On any gap the group is left empty.
19878
21350
  fn setup_native_vec_fns(&mut self, program: &Program) {
19879
21351
  use std::collections::HashMap;
@@ -19881,6 +21353,55 @@ impl Codegen {
19881
21353
  // #177 aggregate fns (`advance`/`offsetMomentum` on `Vec<Struct>`) take precedence — do not
19882
21354
  // also emit a broken `name_nv(&Vec<f64>)` wrapper for them.
19883
21355
  sigs.retain(|name, _| !self.aggregate_fns.contains_key(name));
21356
+ // ON GBA, A MODULE BINDING IS NOT REACHABLE FROM A FREE `fn`. The passes that give module
21357
+ // state a top-level home — `native_numeric_globals`' TLS cells and `module_const_f64_arrays`'
21358
+ // statics — are the std-emitting ones, and they are gated off for GBA. So every module
21359
+ // `let`/`const` is a local of `run()` there, while `name_nv` is emitted at top level: a body
21360
+ // that reads one compiles to `error[E0425]: cannot find value CFG in this scope` and the
21361
+ // game does not build (#594).
21362
+ //
21363
+ // Reject those fns here rather than emitting a broken program. They stay on the boxed path,
21364
+ // which is correct and merely slower. Scoped to GBA deliberately: off-target the statics DO
21365
+ // exist, the reference resolves, and eligibility must stay byte-identical to before.
21366
+ //
21367
+ // This does not close #594's other half — a lowered fn that can actually SEE module state,
21368
+ // which needs those bindings emitted as statics on GBA too. It stops the miscompile.
21369
+ if self.emit_mode == crate::NativeEmitMode::Gba {
21370
+ let mut module_bindings = Self::collect_module_level_binding_names(&program.statements);
21371
+ // …EXCEPT the ones that now have a top-level home. Since #594 a module const numeric
21372
+ // array is a `const` and a mutable numeric global is a `SingleCore` static, both
21373
+ // reachable from a free fn — rejecting a fn for reading those would give up exactly the
21374
+ // lowering this issue exists to enable. What remains unreachable is everything else:
21375
+ // boxed objects, strings, arrays of non-numbers.
21376
+ for k in self.module_const_f64_arrays.keys() {
21377
+ module_bindings.remove(k);
21378
+ }
21379
+ for k in self.native_numeric_globals.keys() {
21380
+ module_bindings.remove(k);
21381
+ }
21382
+ for k in self.module_const_f64_cum.keys() {
21383
+ module_bindings.remove(k);
21384
+ }
21385
+ for k in self.module_const_f64_aliases.keys() {
21386
+ module_bindings.remove(k);
21387
+ }
21388
+ if !module_bindings.is_empty() {
21389
+ let bodies_by_name: std::collections::HashMap<&str, &Statement> = program
21390
+ .statements
21391
+ .iter()
21392
+ .filter_map(|s| match s {
21393
+ Statement::FunDecl { name, body, .. } => Some((name.as_ref(), body.as_ref())),
21394
+ _ => None,
21395
+ })
21396
+ .collect();
21397
+ sigs.retain(|name, _| {
21398
+ let Some(body) = bodies_by_name.get(name.as_str()) else {
21399
+ return true;
21400
+ };
21401
+ !Self::body_reads_module_binding(body, &module_bindings)
21402
+ });
21403
+ }
21404
+ }
19884
21405
  if sigs.is_empty() {
19885
21406
  return;
19886
21407
  }
@@ -20209,7 +21730,7 @@ impl Codegen {
20209
21730
  }
20210
21731
  }
20211
21732
  Expr::Binary { left, op, right, .. } => {
20212
- let a = matches!(
21733
+ let arith = matches!(
20213
21734
  op,
20214
21735
  BinOp::Add
20215
21736
  | BinOp::Sub
@@ -20221,9 +21742,17 @@ impl Codegen {
20221
21742
  | BinOp::Le
20222
21743
  | BinOp::Gt
20223
21744
  | BinOp::Ge
20224
- | BinOp::StrictEq
20225
- | BinOp::StrictNe
20226
21745
  );
21746
+ // `===`/`!==` ARE OVERLOADED — they compare strings as readily as numbers, so one is
21747
+ // proof of a numeric operand ONLY when the other side is a numeric literal.
21748
+ // `tag === "fire"` used to mark `tag` numeric, which handed a string parameter the
21749
+ // f64 ABI: `panic!("expected number")` on a no_std cart, or E0308. `infer.rs`
21750
+ // classifies the same operator as overloaded for exactly this reason, and the two
21751
+ // disagreeing is what let it through here.
21752
+ let num_lit = |x: &Expr| matches!(x, Expr::Literal { value: Literal::Number(_), .. });
21753
+ let eq_numeric = matches!(op, BinOp::StrictEq | BinOp::StrictNe)
21754
+ && (num_lit(left) || num_lit(right));
21755
+ let a = arith || eq_numeric;
20227
21756
  Self::scan_param_use(left, p, a, f);
20228
21757
  Self::scan_param_use(right, p, a, f);
20229
21758
  }
@@ -20336,6 +21865,17 @@ impl Codegen {
20336
21865
  {
20337
21866
  return Some(VecRetKind::F64);
20338
21867
  }
21868
+ // #605: EVERY return a string literal. Held to the same standard as the numeric arm above —
21869
+ // agreement across all returns, proven from the expressions, never from the annotation,
21870
+ // because a return type is erased and unchecked (the `liar()` case). A mixed
21871
+ // string-and-number fn falls through to `None` and stays boxed, which is the only shape a
21872
+ // single native signature cannot express.
21873
+ if ret_exprs
21874
+ .iter()
21875
+ .all(|e| matches!(e, Expr::Literal { value: Literal::String(_), .. }))
21876
+ {
21877
+ return Some(VecRetKind::Str);
21878
+ }
20339
21879
  None
20340
21880
  }
20341
21881
 
@@ -20658,6 +22198,7 @@ impl Codegen {
20658
22198
  match sig.ret {
20659
22199
  VecRetKind::F64 => Some(RustType::F64),
20660
22200
  VecRetKind::VecF64 => Some(RustType::Vec(Box::new(RustType::F64))),
22201
+ VecRetKind::Str => Some(RustType::String),
20661
22202
  VecRetKind::Unit => None,
20662
22203
  }
20663
22204
  }
@@ -20696,6 +22237,7 @@ impl Codegen {
20696
22237
  let ret_str = match sig.ret {
20697
22238
  VecRetKind::F64 => " -> f64",
20698
22239
  VecRetKind::VecF64 => " -> Vec<f64>",
22240
+ VecRetKind::Str => " -> String",
20699
22241
  VecRetKind::Unit => "",
20700
22242
  };
20701
22243
  self.writeln("#[allow(non_snake_case, unused)]");
@@ -20759,6 +22301,11 @@ impl Codegen {
20759
22301
  if sig.ret == VecRetKind::F64 {
20760
22302
  self.writeln("0.0");
20761
22303
  }
22304
+ // Same total-function guarantee for the string arm: falling off the end yields "" rather
22305
+ // than leaving the emitted fn without a value on that path.
22306
+ if sig.ret == VecRetKind::Str {
22307
+ self.writeln("String::new()");
22308
+ }
20762
22309
  self.native_vec_ret = saved_ret;
20763
22310
  self.vec_ref_params = saved_refs;
20764
22311
  self.indent -= 1;
@@ -20849,6 +22396,13 @@ impl Codegen {
20849
22396
  call
20850
22397
  }
20851
22398
  }
22399
+ VecRetKind::Str => {
22400
+ if as_value {
22401
+ format!("Value::String(({}).into())", call)
22402
+ } else {
22403
+ call
22404
+ }
22405
+ }
20852
22406
  VecRetKind::Unit => {
20853
22407
  if as_value {
20854
22408
  format!("{{ {}; Value::Null }}", call)
@@ -23329,6 +24883,60 @@ impl Codegen {
23329
24883
  /// the round-trips. Crucially, only bitwise/shift nodes recurse — an `f64` `*`/`+`/`-` node is
23330
24884
  /// a *leaf* here, so e.g. `(h * 16777619) >>> 0` keeps its `f64` multiply (the 2^53 rule: the
23331
24885
  /// product exceeds 2^53 and must round in `f64` *before* `ToUint32`, exactly as V8 does).
24886
+ /// Int-domain code for a bitwise/shift expression that is about to be consumed by something
24887
+ /// wanting an INTEGER — a `Vec<i32>` element store, an array index.
24888
+ ///
24889
+ /// `result_type_of_binop` (types.rs) types every bitwise result `F64`, which is semantically
24890
+ /// right (JS bitwise returns a Number) and a real win against boxing. But the int-domain chain
24891
+ /// is then wrapped `(… ) as f64` and an integer consumer immediately casts it back, so on a
24892
+ /// target with no FPU every `arr[i] = w | BIT` pays a soft-float round trip in each direction.
24893
+ /// Measured on GBA (tish-gba `examples/bench-grid`): `arr[j] = 7` and `arr[j] = j` cost ~1.4
24894
+ /// ticks per 1000, `arr[j] = j & 255` cost 23.2, and a bitwise INDEX cost 19.9 — ~15x, twice per
24895
+ /// cell access for anything built on a packed word.
24896
+ ///
24897
+ /// SOUNDNESS. Only the SIGNED-result ops qualify. Their value is by construction inside i32, so
24898
+ /// `(x as f64) as i32` is the identity and dropping both casts cannot change it. `>>>` is
24899
+ /// excluded deliberately: it yields a uint32 Number that can exceed `i32::MAX`, and Rust's
24900
+ /// float→int `as` SATURATES, so the f64 route and the int route genuinely disagree there.
24901
+ fn emit_int32_for_int_consumer(
24902
+ &mut self,
24903
+ e: &Expr,
24904
+ ) -> Result<Option<String>, CompileError> {
24905
+ let Expr::Binary { op, .. } = e else {
24906
+ return Ok(None);
24907
+ };
24908
+ if !matches!(
24909
+ op,
24910
+ BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor | BinOp::Shl | BinOp::Shr
24911
+ ) {
24912
+ return Ok(None);
24913
+ }
24914
+ self.emit_int32_operand(e)
24915
+ }
24916
+
24917
+ /// An operand that is provably an int32: either one `emit_int32_operand` lowers, or one the
24918
+ /// type system already calls `i32`.
24919
+ ///
24920
+ /// The second case is what `emit_int32_operand` alone misses. An `: i32` local and a `Vec<i32>`
24921
+ /// element read need no ToInt32 lowering — they ARE integers — but that function's leaf fold
24922
+ /// only recognises `F64` leaves, so it declines them and falls back to a `to_int32` wrapper.
24923
+ /// Every accumulator has one on its left-hand side.
24924
+ fn int32_or_native_i32(&mut self, e: &Expr) -> Result<Option<String>, CompileError> {
24925
+ if let Some(c) = self.emit_int32_operand(e)? {
24926
+ // Reject the generic fold's own output: if it handed back a `to_int32(..)` wrapper it
24927
+ // has not actually reached the integer domain, and using it would keep the round trip
24928
+ // while claiming to have removed it.
24929
+ if !c.contains("to_int32") {
24930
+ return Ok(Some(c));
24931
+ }
24932
+ }
24933
+ let (code, ty) = self.emit_typed_expr(e)?;
24934
+ if ty == RustType::I32 {
24935
+ return Ok(Some(code));
24936
+ }
24937
+ Ok(None)
24938
+ }
24939
+
23332
24940
  fn emit_int32_operand(&mut self, e: &Expr) -> Result<Option<String>, CompileError> {
23333
24941
  if let Expr::Binary {
23334
24942
  left, op, right, ..
@@ -23369,6 +24977,33 @@ impl Codegen {
23369
24977
  };
23370
24978
  return Ok(Some(code));
23371
24979
  }
24980
+ // ARITHMETIC in a ToInt32 context: `acc + (w & MASK)`, `i - 1`.
24981
+ //
24982
+ // `+` is not itself a ToInt32 context, so this cannot be done in general — but THIS
24983
+ // FUNCTION'S CONTRACT is that its result will be ToInt32'd by the caller, and for int32
24984
+ // operands `to_int32(a + b) == a.wrapping_add(b)` exactly, since |a + b| < 2^32 is far
24985
+ // inside f64's exact-integer range. So inside this context, and only here, the integer
24986
+ // form is equivalent.
24987
+ //
24988
+ // It has to sit ABOVE the generic leaf fold below, which would otherwise swallow the
24989
+ // whole expression and hand back `to_int32((a as f64) + (b as f64))` — technically a
24990
+ // success, and exactly the round trip this exists to remove.
24991
+ //
24992
+ // Measured on GBA (tish-gba `examples/bench-grid`): `acc = acc + (arr[i] & 255)` ran at
24993
+ // 44.4 ticks an iteration against 1.64 for the same read unmasked — a 27x penalty on
24994
+ // every packed-word structure, since `w & FIELD` is how you read a field.
24995
+ if matches!(op, BinOp::Add | BinOp::Sub) {
24996
+ let li = self.int32_or_native_i32(left)?;
24997
+ let ri = self.int32_or_native_i32(right)?;
24998
+ if let (Some(li), Some(ri)) = (li, ri) {
24999
+ let f = if matches!(op, BinOp::Add) {
25000
+ "wrapping_add"
25001
+ } else {
25002
+ "wrapping_sub"
25003
+ };
25004
+ return Ok(Some(format!("({}).{}({})", li, f, ri)));
25005
+ }
25006
+ }
23372
25007
  }
23373
25008
  // `Math.imul(a, b)` in an int32 context → the raw i32 `wrapping_mul` (no f64 excursion, no
23374
25009
  // `to_int32` round-trip on the product), when both operands are themselves int32-lowerable.
@@ -23889,33 +25524,47 @@ impl Codegen {
23889
25524
  iv
23890
25525
  )
23891
25526
  };
23892
- let access = if in_bounds {
23893
- format!("{}[{}]", static_name, idx_usize)
25527
+ let is_int = self.module_const_int_statics.contains(&static_name);
25528
+ // The proven-in-bounds read on an integer array is the whole point: a
25529
+ // plain word load, reported as I32 so the consumer stays in the integer
25530
+ // domain instead of converting back and forth through f64.
25531
+ if in_bounds {
25532
+ let access = format!("{}[{}]", static_name, idx_usize);
25533
+ return Ok((
25534
+ access,
25535
+ if is_int { RustType::I32 } else { RustType::F64 },
25536
+ ));
25537
+ }
25538
+ let const_len = self
25539
+ .cum_static_and_len(name.as_ref())
25540
+ .map(|(_, n)| n)
25541
+ .or_else(|| {
25542
+ self.module_const_f64_arrays
25543
+ .get(name.as_ref())
25544
+ .map(|v| v.len())
25545
+ })
25546
+ .unwrap_or(0);
25547
+ // OUT-OF-RANGE STAYS f64. An unproven index still has to be able to
25548
+ // answer `NaN`, and there is no `i32` that means "absent" — picking a
25549
+ // sentinel here would silently turn a missing element into a real value.
25550
+ // So the bounds-checked form keeps today's exact semantics and only the
25551
+ // STORAGE narrows; the integer win is claimed above, where the index is
25552
+ // proven and no sentinel is reachable.
25553
+ let elem = if is_int {
25554
+ format!("{}[_i] as f64", static_name)
23894
25555
  } else {
23895
- let const_len = self
23896
- .cum_static_and_len(name.as_ref())
23897
- .map(|(_, n)| n)
23898
- .or_else(|| {
23899
- self.module_const_f64_arrays
23900
- .get(name.as_ref())
23901
- .map(|v| v.len())
23902
- })
23903
- .unwrap_or(0);
23904
- if const_len > 0 {
23905
- format!(
23906
- "{{ let _i = {}; if _i < {} {{ {}[_i] }} else {{ f64::NAN }} }}",
23907
- idx_usize,
23908
- const_len,
23909
- static_name
23910
- )
23911
- } else {
23912
- format!(
23913
- "{{ let _i = {}; if _i < {}.len() {{ {}[_i] }} else {{ f64::NAN }} }}",
23914
- idx_usize,
23915
- static_name,
23916
- static_name
23917
- )
23918
- }
25556
+ format!("{}[_i]", static_name)
25557
+ };
25558
+ let access = if const_len > 0 {
25559
+ format!(
25560
+ "{{ let _i = {}; if _i < {} {{ {} }} else {{ f64::NAN }} }}",
25561
+ idx_usize, const_len, elem
25562
+ )
25563
+ } else {
25564
+ format!(
25565
+ "{{ let _i = {}; if _i < {}.len() {{ {} }} else {{ f64::NAN }} }}",
25566
+ idx_usize, static_name, elem
25567
+ )
23919
25568
  };
23920
25569
  return Ok((access, RustType::F64));
23921
25570
  }
@@ -23944,45 +25593,15 @@ impl Codegen {
23944
25593
  };
23945
25594
  // #173 part 3: prove the index in-bounds BEFORE emitting it.
23946
25595
  let in_bounds = self.index_in_bounds(index, name.as_ref());
23947
- let idx_usize = if let Expr::Ident { name: idx_name, .. } =
23948
- index.as_ref()
23949
- {
23950
- if let Some(uv) =
23951
- self.usize_var_subst.get(idx_name.as_ref())
23952
- {
23953
- uv.clone()
23954
- } else {
23955
- let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
23956
- if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
23957
- format!("({}) as usize", idx_code)
23958
- } else {
23959
- let iv = if idx_ty.is_native() {
23960
- idx_ty.to_value_expr(&idx_code)
23961
- } else {
23962
- idx_code
23963
- };
23964
- format!(
23965
- "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
23966
- iv
23967
- )
23968
- }
23969
- }
23970
- } else {
23971
- let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
23972
- if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
23973
- format!("({}) as usize", idx_code)
23974
- } else {
23975
- let iv = if idx_ty.is_native() {
23976
- idx_ty.to_value_expr(&idx_code)
23977
- } else {
23978
- idx_code
23979
- };
23980
- format!(
23981
- "{{ let _i = &{}; if let Value::Number(n) = _i {{ *n as usize }} else {{ panic!(\"array index must be a number\") }} }}",
23982
- iv
23983
- )
23984
- }
23985
- };
25596
+ // The READ index goes through the same helper as the element STORE.
25597
+ // These two arms used to be a hand-inlined copy of it that predated
25598
+ // the int-domain shortcut, so `arr[a | b]` lowered its store index
25599
+ // integrally and its read index as `((a|b) as f64) as usize` — two
25600
+ // soft-float calls on ARM7TDMI, on the read half of every packed-grid
25601
+ // access. Measured in tish-gba: a 63-cell board copy whose body is
25602
+ // `P[dc|r] = P[sc|r] & keep` cost 2,103 ticks, 33 per cell, against
25603
+ // ~1.6 for a module `i32[]` read plus write.
25604
+ let idx_usize = self.emit_index_usize(index)?;
23986
25605
  // #567: inside the wrapped block the index is pre-bound to `__bi`
23987
25606
  // (evaluated BEFORE the borrow, so an index that itself reads this cell
23988
25607
  // — `arr[arr.length-1]` — doesn't double-lock the non-reentrant cell).
@@ -24017,6 +25636,19 @@ impl Codegen {
24017
25636
  // `borrow()` guard a non-Copy element must be cloned OUT (it can't be
24018
25637
  // moved from the borrow); a plain local keeps the direct place.
24019
25638
  _ if idx_wrapped => format!("{}[{}].clone()", esc_obj, idx_ref),
25639
+ // #600: a STRING element is cloned even unwrapped. `let s: string =
25640
+ // NAMES[i]` binds the element, and binding a non-Copy value out of a
25641
+ // `Vec` is a move — `error[E0507]: cannot move out of index`. The
25642
+ // obvious spelling was the one that did not compile, with indexing
25643
+ // straight into a call as the only working form.
25644
+ //
25645
+ // Scoped to String deliberately. The struct case (#571) looks
25646
+ // identical but must NOT clone here: `states[id].timer` projects a
25647
+ // field out of the place, and cloning would copy the whole struct on
25648
+ // every field read — the O(n)-per-read footgun #558 exists about.
25649
+ // A string is already copied at every boxed boundary, so the clone
25650
+ // costs nothing that was not already being paid.
25651
+ RustType::String => format!("{}[{}].clone()", esc_obj, idx_ref),
24020
25652
  _ => format!("{}[{}]", esc_obj, idx_ref),
24021
25653
  };
24022
25654
  // #567: wrap the wrapped-Vec read in a block that pre-binds the index
@@ -24106,6 +25738,7 @@ impl Codegen {
24106
25738
  let ty = match sig.ret {
24107
25739
  VecRetKind::F64 => RustType::F64,
24108
25740
  VecRetKind::VecF64 => RustType::Vec(Box::new(RustType::F64)),
25741
+ VecRetKind::Str => RustType::String,
24109
25742
  VecRetKind::Unit => RustType::Value,
24110
25743
  };
24111
25744
  return Ok((code, ty));
@@ -24113,6 +25746,39 @@ impl Codegen {
24113
25746
  }
24114
25747
  }
24115
25748
  }
25749
+ // A user fn whose every return is PROVEN numeric (`collect_user_fn_rets`). The call
25750
+ // stays boxed — this is not a calling convention — but its RESULT stops being
25751
+ // opaque, so the surrounding arithmetic stays integral instead of dropping to the
25752
+ // generic `ops::add` and its two soft-float conversions.
25753
+ //
25754
+ // The `_ =>` arm is unreachable by construction: membership required every return
25755
+ // to be numeric. Written out rather than `unreachable!()` so a proof bug surfaces as
25756
+ // the same diagnostic every other coercion in this file produces.
25757
+ // NOT for fns that also have an M5 native form: the direct-call path below emits
25758
+ // `name_native(..)` bare, and this block reaching them first wrapped that same call
25759
+ // in `Value::Number(..)` and match-unwrapped it straight back — the boxed round
25760
+ // trip the #320 push test pins against. A proof is the fallback, never the winner.
25761
+ if !self.user_fn_ret.is_empty() {
25762
+ if let Expr::Ident { name, .. } = callee.as_ref() {
25763
+ if self.native_fns.contains(name.as_ref()) {
25764
+ // fall through to the native direct-call path
25765
+ } else if let Some(ty) = self.user_fn_ret.get(name.as_ref()).cloned() {
25766
+ let boxed = self.emit_expr(expr)?;
25767
+ let conv = if ty == RustType::I32 {
25768
+ "tishlang_runtime::to_int32(*n)"
25769
+ } else {
25770
+ "*n"
25771
+ };
25772
+ return Ok((
25773
+ format!(
25774
+ "match &{} {{ Value::Number(n) => {}, _ => panic!(\"expected number\") }}",
25775
+ boxed, conv
25776
+ ),
25777
+ ty,
25778
+ ));
25779
+ }
25780
+ }
25781
+ }
24116
25782
  // Typed extern (a `cargo:` import with a declared native signature) used INSIDE a
24117
25783
  // native/typed expression: `entity_x(e) + 16`, `cvar(e, k) + 1`. Emit the direct
24118
25784
  // `crate::name_typed(<coerced args>)` and report its NATIVE return type, so the
@@ -24145,16 +25811,24 @@ impl Codegen {
24145
25811
  // M5: direct call to an eligible native fn -> `name_native(<native args>)`.
24146
25812
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
24147
25813
  if self.native_fns.contains(fname.as_ref()) {
25814
+ // The ABI this callee actually has. Passing every argument through
25815
+ // `coerce_native_arg` — as the boxed-context site already does — is what
25816
+ // keeps a NATIVE argument of the other width from being handed over raw:
25817
+ // `manhattan(c, r, tac_unit_col(id), tac_unit_row(id))` types its extern
25818
+ // args I32 and the callee takes f64, which emitted `manhattan_native(.., i32)`
25819
+ // and rustc rejected it with E0308. Value args keep their existing
25820
+ // from_value_expr path (coerce_native_arg's first arm IS that path).
25821
+ let want = if self.native_fns_i32.contains(fname.as_ref()) {
25822
+ RustType::I32
25823
+ } else {
25824
+ RustType::F64
25825
+ };
24148
25826
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
24149
25827
  let mut ok = true;
24150
25828
  for a in args {
24151
25829
  if let CallArg::Expr(e) = a {
24152
25830
  let (ac, at) = self.emit_typed_expr(e)?;
24153
- argc.push(if at == RustType::Value {
24154
- RustType::F64.from_value_expr(&ac)
24155
- } else {
24156
- ac
24157
- });
25831
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
24158
25832
  } else {
24159
25833
  ok = false;
24160
25834
  break;
@@ -24170,13 +25844,15 @@ impl Codegen {
24170
25844
  RustType::F64,
24171
25845
  ));
24172
25846
  }
25847
+ // …and report the ABI's OWN return type, for the same reason: an
25848
+ // i32-ABI callee reported as F64 mistypes every consumer downstream.
24173
25849
  return Ok((
24174
25850
  format!(
24175
25851
  "{}({})",
24176
25852
  self.native_call_target(fname.as_ref()),
24177
25853
  argc.join(", ")
24178
25854
  ),
24179
- RustType::F64,
25855
+ want,
24180
25856
  ));
24181
25857
  }
24182
25858
  }