@tishlang/tish-format 3.2.2 → 3.3.0

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)]
@@ -1120,6 +1125,12 @@ pub(crate) struct Codegen {
1120
1125
  /// free `fn f_native(f64,..)->f64` (all params `: number`, returns `number`, native-safe
1121
1126
  /// body). Direct calls to these route to the native fn, bypassing the boxed `value_call`.
1122
1127
  native_fns: std::collections::HashSet<String>,
1128
+ /// Set while emitting the body of an i32-ABI native fn, so `return` coerces to `i32`.
1129
+ native_fn_abi_i32: bool,
1130
+ /// The subset of `native_fns` emitted with an `fn(i32, ..) -> i32` signature instead of the
1131
+ /// f64 one — every parameter and the return annotated `: i32`. On ARM7TDMI this is the whole
1132
+ /// point: an f64 ABI makes every argument and every return a soft-float conversion.
1133
+ native_fns_i32: std::collections::HashSet<String>,
1123
1134
  /// True while emitting an M5 `fn name_native` body — keeps VarDecl inits on the native path.
1124
1135
  native_fn_body_emit: bool,
1125
1136
  /// M5 fn currently being emitted (`mandel_native`, `fastaRandom_native`, …).
@@ -1209,6 +1220,17 @@ pub(crate) struct Codegen {
1209
1220
  /// `RustType::Value`. This is the rust-AOT analogue of the VM array-JIT bailing to the
1210
1221
  /// interpreter on a non-numeric element. See `collect_demoted_numeric_locals`.
1211
1222
  demoted_numeric_locals: std::collections::HashSet<String>,
1223
+ /// Top-level fns whose EVERY return is provably numeric → the native type a call to one yields.
1224
+ ///
1225
+ /// Without it a call is opaque, so a local assigned from one demotes to a boxed `Value` and
1226
+ /// every arithmetic consumer falls back to the generic `ops::add` plus two soft-float
1227
+ /// conversions — most of the ~92 ticks a user-fn call costs on GBA
1228
+ /// (`docs/issues/user-fn-call-cost-on-gba.md`).
1229
+ ///
1230
+ /// KEYED ON A PROOF, NOT THE ANNOTATION. `function f(): i32 { return "no" }` compiles and
1231
+ /// returns the string — return types are erased — so trusting the declaration would reinterpret
1232
+ /// a `String` as a number.
1233
+ user_fn_ret: std::collections::HashMap<String, RustType>,
1212
1234
  /// Integer-range lattice (#174): names of `f64` locals the analysis proves always hold an
1213
1235
  /// integer within `[min, max]`, both strictly inside `(-2^53, 2^53)` so `as i64` is exact and
1214
1236
  /// `i64` arithmetic is bit-identical to the `f64` the interpreter/VM use. Lets the codegen
@@ -1293,6 +1315,16 @@ pub(crate) struct Codegen {
1293
1315
  /// `mod __scheme_<name>_<j> { … }` blocks and their per-scheme registration. The handle a tish
1294
1316
  /// name binds to is its index AMONG same-scheme entries (= its slot in that scheme's arena).
1295
1317
  scheme_files: Vec<(String, String)>,
1318
+ /// GBA only: the program-wide intern table for OBJECT-LITERAL KEYS, in first-seen order,
1319
+ /// plus the name→index map. `RefCell` so `cached_object_key` can stay `&self`.
1320
+ ///
1321
+ /// ⚠️ This is a HEAP fix, not a tidiness one. Off-GBA every key site gets its own `OnceLock`,
1322
+ /// which dedupes repeated evaluation of ONE site but never across sites — and a data table is
1323
+ /// N sibling object literals, so N separate sites each allocate their own copy of every key
1324
+ /// name. A real ROM measured 1,890 key sites over 474 distinct names: 1,416 duplicate `Arc<str>`
1325
+ /// allocations that exist only to spell "name" and "id" again. One table, indexed, kills all of
1326
+ /// them. See `cached_object_key`.
1327
+ gba_keys: std::cell::RefCell<(Vec<String>, std::collections::HashMap<String, usize>)>,
1296
1328
  /// Program links `tish:macos` / `tish:ios` — skip HeadlessHost install.
1297
1329
  has_native_ui_host: bool,
1298
1330
  /// Program references browser global `document` — inject tish-canvas.
@@ -1357,6 +1389,8 @@ impl Codegen {
1357
1389
  collection_instance_locals: std::collections::HashSet::new(),
1358
1390
  hoisted_string_chars: std::collections::HashMap::new(),
1359
1391
  native_fns: std::collections::HashSet::new(),
1392
+ native_fns_i32: std::collections::HashSet::new(),
1393
+ native_fn_abi_i32: false,
1360
1394
  native_fn_body_emit: false,
1361
1395
  native_fn_emit_name: None,
1362
1396
  native_fn_literal_args: std::collections::HashMap::new(),
@@ -1383,6 +1417,7 @@ impl Codegen {
1383
1417
  aggregate_array_locals: std::collections::HashSet::new(),
1384
1418
  agg_cur_ret: None,
1385
1419
  demoted_numeric_locals: std::collections::HashSet::new(),
1420
+ user_fn_ret: std::collections::HashMap::new(),
1386
1421
  int_range_locals: std::collections::HashMap::new(),
1387
1422
  diag_coord_indices: std::collections::HashSet::new(),
1388
1423
  int_i32_vec_locals: std::collections::HashSet::new(),
@@ -1408,6 +1443,7 @@ impl Codegen {
1408
1443
  emit_mode: crate::NativeEmitMode::DesktopBin,
1409
1444
  entry_exports: Vec::new(),
1410
1445
  scheme_files: Vec::new(),
1446
+ gba_keys: std::cell::RefCell::new((Vec::new(), std::collections::HashMap::new())),
1411
1447
  has_native_ui_host: false,
1412
1448
  program_uses_document: false,
1413
1449
  cse_subst: Vec::new(),
@@ -3112,27 +3148,48 @@ impl Codegen {
3112
3148
  // M5 (default-on via native_opts_enabled; TISH_NATIVE_OPT=0 opts out): emit a parallel
3113
3149
  // native `fn f_native` for each eligible top-level numeric fn at top level; direct calls
3114
3150
  // 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 {
3151
+ if crate::native_opts_enabled() {
3152
+ // MODULE STATE GETS A TOP-LEVEL HOME ON GBA TOO (#594). These passes used to be skipped
3153
+ // there because the numeric-global one emits `thread_local!`, which no_std has not got —
3154
+ // and the const-array pass was collateral, gated only because it shared the block. The
3155
+ // consequence was the bug this issue reports: a lowered free `fn` is emitted at top
3156
+ // level, so every module binding it reads is a local of `run()` that does not exist at
3157
+ // that scope, and the emitted crate fails with E0425.
3158
+ //
3159
+ // A const numeric array is `const G_X: [f64; N] = [..]` — no std in it at all. A mutable
3160
+ // numeric global becomes `SingleCore<Cell<f64>>`, the facade's single-core static whose
3161
+ // `with()` deliberately mirrors `LocalKey::with` (compat.rs), so every read and write the
3162
+ // emitter already produces (`G.with(|c| c.get())`) is unchanged.
3163
+ let gba = self.emit_mode == crate::NativeEmitMode::Gba;
3118
3164
  self.native_numeric_globals =
3119
3165
  Self::collect_native_numeric_globals(&program.statements);
3120
3166
  if !self.native_numeric_globals.is_empty() {
3121
- self.emit_native_numeric_global_tls()?;
3167
+ if gba {
3168
+ self.emit_native_numeric_global_singlecore()?;
3169
+ } else {
3170
+ self.emit_native_numeric_global_tls()?;
3171
+ }
3122
3172
  self.writeln("");
3123
3173
  }
3124
3174
  self.module_const_f64_arrays =
3125
3175
  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);
3176
+ self.module_const_f64_cum = Self::collect_module_const_cum(
3177
+ &program.statements,
3178
+ &self.module_const_f64_arrays,
3179
+ );
3180
+ self.module_const_f64_aliases = Self::collect_module_const_aliases(
3181
+ &program.statements,
3182
+ &self.module_const_f64_cum,
3183
+ );
3130
3184
  if !self.module_const_f64_arrays.is_empty() {
3131
3185
  self.emit_module_const_f64_arrays()?;
3132
3186
  self.writeln("");
3133
3187
  }
3134
- let native_vec_names: std::collections::HashSet<String> =
3135
- Self::detect_native_vec_fns(program).keys().cloned().collect();
3188
+ let native_vec_names: std::collections::HashSet<String> = if gba {
3189
+ std::collections::HashSet::new()
3190
+ } else {
3191
+ Self::detect_native_vec_fns(program).keys().cloned().collect()
3192
+ };
3136
3193
  let mut global_names: std::collections::HashSet<String> =
3137
3194
  self.native_numeric_globals.keys().cloned().collect();
3138
3195
  global_names.extend(self.module_const_f64_arrays.keys().cloned());
@@ -3141,6 +3198,21 @@ impl Codegen {
3141
3198
  &global_names,
3142
3199
  &native_vec_names,
3143
3200
  );
3201
+ // Which of the survivors carry the all-`i32` signature. Computed AFTER the fixpoint,
3202
+ // so a function the proof rejected never reaches the i32 ABI either.
3203
+ self.native_fns_i32 = program
3204
+ .statements
3205
+ .iter()
3206
+ .filter_map(|s| match s {
3207
+ Statement::FunDecl { name, params, return_type, .. }
3208
+ if self.native_fns.contains(name.as_ref())
3209
+ && Self::fn_sig_all_i32(params, return_type) =>
3210
+ {
3211
+ Some(name.to_string())
3212
+ }
3213
+ _ => None,
3214
+ })
3215
+ .collect();
3144
3216
  self.native_fn_literal_args =
3145
3217
  Self::collect_native_fn_literal_calls(&program.statements, &self.native_fns);
3146
3218
  self.native_fn_param_names =
@@ -3149,11 +3221,17 @@ impl Codegen {
3149
3221
  Self::collect_native_lcg_fns(&program.statements, &self.native_fns);
3150
3222
  // #381 — fns on a call-graph cycle get guarded rotation copies (default ON;
3151
3223
  // TISH_NATIVE_RECUR_GUARD=0 restores the plain unguarded emission).
3152
- self.native_recur_fns = if crate::native_recur_guard_enabled() {
3224
+ self.native_recur_fns = if crate::native_recur_guard_enabled()
3225
+ && self.emit_mode != crate::NativeEmitMode::Gba
3226
+ {
3153
3227
  Self::compute_native_recur_fns(&program.statements, &self.native_fns)
3154
3228
  } else {
3155
3229
  std::collections::HashMap::new()
3156
3230
  };
3231
+ // A fn on a call-graph cycle keeps the f64 ABI: its stack-guard bail unwinds through a
3232
+ // NaN sentinel (#381), and an `i32` return has no value to spare for that. Recursion is
3233
+ // not what the i32 shape exists for.
3234
+ self.native_fns_i32.retain(|n| !self.native_recur_fns.contains_key(n));
3157
3235
  if !self.native_fns.is_empty() {
3158
3236
  self.emit_native_fns(&program.statements)?;
3159
3237
  self.writeln("");
@@ -3196,6 +3274,9 @@ impl Codegen {
3196
3274
  // `VarDecl` lowers them as boxed `Value` rather than native `f64` (else the store coerces
3197
3275
  // and panics on a JS string-concat result like `s = s + arr[i]`). See
3198
3276
  // `collect_demoted_numeric_locals` / `demoted_numeric_locals`.
3277
+ // BEFORE the demotion pass, which consults it: a local assigned from a provably-numeric
3278
+ // call must stop demoting, which is half of where the win comes from.
3279
+ self.user_fn_ret = self.collect_user_fn_rets(&program.statements);
3199
3280
  self.demoted_numeric_locals = self.collect_demoted_numeric_locals(&program.statements);
3200
3281
  self.int_valued_locals = Self::collect_int_valued_locals(&program.statements);
3201
3282
  self.int_range_locals = self.collect_int_range_locals(&program.statements);
@@ -3862,11 +3943,64 @@ impl Codegen {
3862
3943
  /// unchanged, and `PropMap` dedup + hidden-class shapes key by text, not pointer — so shapes,
3863
3944
  /// insertion order, and `Object.keys` are all unaffected. Same inline-`static` idiom as the
3864
3945
  /// per-site `PropIC` caches.
3946
+ /// Append the GBA object-key intern table. Called ONCE, after the whole program is emitted,
3947
+ /// because the key set is only complete then — Rust does not care that a `fn` is defined below
3948
+ /// its callers.
3949
+ ///
3950
+ /// ⚠️ `static mut` + `&raw mut` rather than a `OnceLock`: the target is `#![no_std]` and the GBA
3951
+ /// is single-core, so there is no thread to race with. Taking a raw pointer avoids the
3952
+ /// `static_mut_refs` lint that a plain `&mut __TISH_KEYS` would trip.
3953
+ fn emit_gba_key_table(&mut self) {
3954
+ if self.emit_mode != crate::NativeEmitMode::Gba {
3955
+ return;
3956
+ }
3957
+ let names: Vec<String> = self.gba_keys.borrow().0.clone();
3958
+ if names.is_empty() {
3959
+ return;
3960
+ }
3961
+ let list = names
3962
+ .iter()
3963
+ .map(|k| format!("{:?}", k))
3964
+ .collect::<Vec<_>>()
3965
+ .join(", ");
3966
+ self.write(&format!(
3967
+ "\n// Object-literal keys, interned program-wide: {} distinct names.\n\
3968
+ static __TISH_KEY_NAMES: [&str; {}] = [{}];\n\
3969
+ static mut __TISH_KEYS: Option<Vec<Arc<str>>> = None;\n\
3970
+ fn __tish_key(i: usize) -> Arc<str> {{\n\
3971
+ \x20 unsafe {{\n\
3972
+ \x20 let t = &raw mut __TISH_KEYS;\n\
3973
+ \x20 if (*t).is_none() {{\n\
3974
+ \x20 *t = Some(__TISH_KEY_NAMES.iter().map(|s| Arc::from(*s)).collect());\n\
3975
+ \x20 }}\n\
3976
+ \x20 match (*t).as_ref() {{\n\
3977
+ \x20 Some(v) => v[i].clone(),\n\
3978
+ \x20 None => Arc::from(__TISH_KEY_NAMES[i]),\n\
3979
+ \x20 }}\n\
3980
+ \x20 }}\n\
3981
+ }}\n",
3982
+ names.len(),
3983
+ names.len(),
3984
+ list
3985
+ ));
3986
+ }
3987
+
3865
3988
  fn cached_object_key(&self, k: &str) -> String {
3866
3989
  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);
3990
+ // No `OnceLock` on no_std GBA — and a per-site cache would be the wrong tool anyway.
3991
+ // Intern PROGRAM-WIDE instead: every distinct key name is allocated once, at first use,
3992
+ // and each site clones an `Arc` (= `Rc`) out of one table. See the `gba_keys` field.
3993
+ let mut t = self.gba_keys.borrow_mut();
3994
+ let idx = match t.1.get(k) {
3995
+ Some(&i) => i,
3996
+ None => {
3997
+ let i = t.0.len();
3998
+ t.0.push(k.to_string());
3999
+ t.1.insert(k.to_string(), i);
4000
+ i
4001
+ }
4002
+ };
4003
+ return format!("__tish_key({})", idx);
3870
4004
  }
3871
4005
  format!(
3872
4006
  "{{ static K: ::std::sync::OnceLock<::std::sync::Arc<str>> = \
@@ -6421,6 +6555,24 @@ impl Codegen {
6421
6555
  }
6422
6556
  }
6423
6557
  }
6558
+ VecRetKind::Str => {
6559
+ // #605. The body's own `return "…"`, emitted as an OWNED String so it
6560
+ // can leave the fn. `emit_typed_expr` already yields a `String` for a
6561
+ // string literal; anything else is converted rather than assumed,
6562
+ // because a wrong guess here is a type error in the emitted crate.
6563
+ let e = value.as_ref().ok_or_else(|| {
6564
+ CompileError::new("native-vec string fn: empty return", None)
6565
+ })?;
6566
+ let (c, ty) = self.emit_typed_expr(e)?;
6567
+ if ty == RustType::String {
6568
+ self.writeln(&format!("return {};", c));
6569
+ } else {
6570
+ self.writeln(&format!(
6571
+ "return {};",
6572
+ RustType::String.from_value_expr(&c)
6573
+ ));
6574
+ }
6575
+ }
6424
6576
  VecRetKind::Unit => {
6425
6577
  self.writeln("return;");
6426
6578
  }
@@ -8979,27 +9131,31 @@ impl Codegen {
8979
9131
  // M5: boxed-context call to an eligible native fn → `Value::Number(name_native(..))`.
8980
9132
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8981
9133
  if self.native_fns.contains(fname.as_ref()) {
9134
+ let i32_abi = self.native_fns_i32.contains(fname.as_ref());
9135
+ let want =
9136
+ if i32_abi { RustType::I32 } else { RustType::F64 };
8982
9137
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
8983
9138
  let mut ok = true;
8984
9139
  for a in args {
8985
9140
  if let CallArg::Expr(e) = a {
8986
9141
  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
- });
9142
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
8992
9143
  } else {
8993
9144
  ok = false;
8994
9145
  break;
8995
9146
  }
8996
9147
  }
8997
9148
  if ok {
8998
- return Ok(format!(
8999
- "Value::Number({}({}))",
9149
+ let call = format!(
9150
+ "{}({})",
9000
9151
  self.native_call_target(fname.as_ref()),
9001
9152
  argc.join(", ")
9002
- ));
9153
+ );
9154
+ return Ok(if i32_abi {
9155
+ format!("Value::Number(({}) as f64)", call)
9156
+ } else {
9157
+ format!("Value::Number({})", call)
9158
+ });
9003
9159
  }
9004
9160
  }
9005
9161
  }
@@ -11981,6 +12137,21 @@ impl Codegen {
11981
12137
  }
11982
12138
  }
11983
12139
 
12140
+ // Int-domain shortcut for an integer-typed local: `let nw: i32 = w | P_MARK` binds the
12141
+ // integer form rather than `(int as f64) as i32`, so `nw` is i32-backed and every later use
12142
+ // — including storing it into an `i32[]` — stays integral. Without this the annotation
12143
+ // actively costs: measured 23,4xx ticks/1000 for `let v: i32 = j & 255; arr[j] = v`
12144
+ // against 1,5xx for `arr[j] = j & 255` written directly.
12145
+ if target_type.is_integer_scalar() {
12146
+ if let Some(int_code) = self.emit_int32_for_int_consumer(expr)? {
12147
+ return Ok(if *target_type == RustType::I32 {
12148
+ int_code
12149
+ } else {
12150
+ format!("({}) as {}", int_code, target_type.to_rust_type_str())
12151
+ });
12152
+ }
12153
+ }
12154
+
11984
12155
  // #179 Stage C: `arr.length` on a native `Vec<_>` against an F64 target → native
11985
12156
  // `(arr.len() as f64)` (no boxing). Lets `let n = arr.length` stay native f64.
11986
12157
  if let (
@@ -12261,6 +12432,28 @@ impl Codegen {
12261
12432
  RustType::from_annotation(ann) == RustType::F64
12262
12433
  }
12263
12434
 
12435
+ fn ann_is_i32(ann: &TypeAnnotation) -> bool {
12436
+ RustType::from_annotation(ann) == RustType::I32
12437
+ }
12438
+
12439
+ /// Is every parameter AND the return annotated `: i32`?
12440
+ ///
12441
+ /// ALL OR NOTHING, deliberately. An `: i32` parameter reached through the boxed path is
12442
+ /// ToInt32-coerced on entry; promoted to a native `f64` it would not be, so 3.7 would arrive as
12443
+ /// 3 one way and 3.7 the other. Admitting `: i32` per-annotation would do exactly that to a
12444
+ /// function that mixes the two. A function whose whole signature is `i32` has one convention
12445
+ /// and keeps the coercion at the boundary, where the caller does it once on an argument that is
12446
+ /// usually already an integer.
12447
+ fn fn_sig_all_i32(params: &[FunParam], return_type: &Option<TypeAnnotation>) -> bool {
12448
+ return_type.as_ref().is_some_and(Self::ann_is_i32)
12449
+ && !params.is_empty()
12450
+ && params.iter().all(|p| {
12451
+ matches!(p, FunParam::Simple(tp)
12452
+ if tp.default.is_none()
12453
+ && tp.type_ann.as_ref().is_some_and(Self::ann_is_i32))
12454
+ })
12455
+ }
12456
+
12264
12457
  // ── Soundness: demote `number` locals that a reassignment can turn non-numeric ──────────────
12265
12458
  //
12266
12459
  // `let s = 0` is inferred `number` → lowered to a native `f64`, and a reassignment stores into
@@ -12278,6 +12471,246 @@ impl Codegen {
12278
12471
  // demoted). The map is name-flat across the whole program (a name demoted in one function is
12279
12472
  // demoted in all) — still sound, and harmless to the perf gauntlet, where each kernel is its
12280
12473
  // own program with unique accumulator names.
12474
+ /// Which top-level fns provably return a number, and as what native type. A fixpoint, because
12475
+ /// one qualifying fn makes calls to it numeric inside another; growing only, so it terminates.
12476
+ fn collect_user_fn_rets(
12477
+ &self,
12478
+ stmts: &[Statement],
12479
+ ) -> std::collections::HashMap<String, RustType> {
12480
+ // MODULE SCOPE ONLY. `collect_annotated_types` over the whole program builds a NAME-FLAT
12481
+ // map, which `collect_demoted_numeric_locals` can use because its error direction is safe —
12482
+ // over-demoting only costs a box. Proving is the opposite: a numeric `s` in one function
12483
+ // would make a STRING `s` in another look numeric, and the return gets coerced.
12484
+ //
12485
+ // That is not hypothetical. It shipped: `function ruleTag(mask: i32) { let s = ""; …;
12486
+ // return s }` was proven numeric off an unrelated numeric `s`, and every ROM using it
12487
+ // panicked with "expected number". Each fn gets its own scope now.
12488
+ let mut module_env: HashMap<String, RustType> = HashMap::new();
12489
+ let top_only: Vec<Statement> = stmts
12490
+ .iter()
12491
+ .filter(|s| matches!(s, Statement::VarDecl { .. }))
12492
+ .cloned()
12493
+ .collect();
12494
+ Self::collect_annotated_types(&top_only, &self.type_aliases, &mut module_env);
12495
+
12496
+ let mut decls: Vec<(String, &Statement, HashMap<String, RustType>)> = Vec::new();
12497
+ for s in stmts {
12498
+ if let Statement::FunDecl { async_: false, name, params, body, .. } = s {
12499
+ let mut env = module_env.clone();
12500
+ // This fn's own params and locals — not a nested closure's, whose names are its own.
12501
+ for prm in params {
12502
+ if let FunParam::Simple(tp) = prm {
12503
+ if let Some(ann) = &tp.type_ann {
12504
+ let ty = RustType::from_annotation(ann);
12505
+ if ty.is_native() {
12506
+ env.insert(tp.name.to_string(), ty);
12507
+ }
12508
+ }
12509
+ }
12510
+ }
12511
+ Self::collect_own_locals(body.as_ref(), &self.type_aliases, &mut env);
12512
+ decls.push((name.to_string(), body.as_ref(), env));
12513
+ }
12514
+ }
12515
+
12516
+ // A REASSIGNED name is not this fn at the call site, and a SHADOWED one is not either —
12517
+ // `emit_typed_expr` sees a bare callee name with no scope information. Excluding every name
12518
+ // ever bound as a variable or parameter is blunt, but the alternative is a miscompile.
12519
+ let mut reassigns: Vec<(String, &Expr)> = Vec::new();
12520
+ Self::collect_reassignments_stmts(stmts, &mut reassigns);
12521
+ let mut excluded: std::collections::HashSet<String> =
12522
+ reassigns.iter().map(|(n, _)| n.clone()).collect();
12523
+ Self::collect_bound_names(stmts, &mut excluded);
12524
+
12525
+ let mut out: std::collections::HashMap<String, RustType> = std::collections::HashMap::new();
12526
+ loop {
12527
+ let mut changed = false;
12528
+ for (name, body, env) in &decls {
12529
+ if out.contains_key(name) || excluded.contains(name) {
12530
+ continue;
12531
+ }
12532
+ let mut rets: Vec<&Expr> = Vec::new();
12533
+ Self::collect_return_exprs(body, &mut rets);
12534
+ if rets.is_empty() {
12535
+ continue; // no return yields `undefined`, which is not a number
12536
+ }
12537
+ let mut ty: Option<RustType> = None;
12538
+ let mut ok = true;
12539
+ for e in &rets {
12540
+ let Some(et) = self.proof_numeric_type(e, env, &out) else {
12541
+ ok = false;
12542
+ break;
12543
+ };
12544
+ ty = match ty {
12545
+ None => Some(et),
12546
+ Some(prev) if prev == et => Some(et),
12547
+ Some(_) => Some(RustType::F64), // mixed I32/F64 settles on the wider
12548
+ };
12549
+ }
12550
+ if ok {
12551
+ if let Some(final_ty) = ty {
12552
+ out.insert(name.clone(), final_ty);
12553
+ changed = true;
12554
+ }
12555
+ }
12556
+ }
12557
+ if !changed {
12558
+ break;
12559
+ }
12560
+ }
12561
+ out
12562
+ }
12563
+
12564
+ /// Is `e` provably a NUMBER, and which native type describes it?
12565
+ ///
12566
+ /// Separate from `expr_native_type`, which answers the stricter "can this be LOWERED natively"
12567
+ /// and is bound by `result_type_of_binop`, whose arms require BOTH operands `F64`. That rejects
12568
+ /// `arr[i] & 255` on a `Vec<i32>` — element `I32`, literal `F64` — which on GBA is nearly every
12569
+ /// masked read of a typed array. Widening that function would change lowering everywhere it is
12570
+ /// consulted, to serve a question it is not being asked.
12571
+ ///
12572
+ /// Two unconditional JS facts carry it: `- * / % **` coerce with ToNumber, and `& | ^ << >>`
12573
+ /// with ToInt32 — a number, and an int32 one, even if an operand was a string. `>>>` is excluded
12574
+ /// (its uint32 can exceed `i32::MAX`) and `+` needs both sides proven, because it concatenates.
12575
+ fn proof_numeric_type(
12576
+ &self,
12577
+ e: &Expr,
12578
+ env: &HashMap<String, RustType>,
12579
+ fn_rets: &std::collections::HashMap<String, RustType>,
12580
+ ) -> Option<RustType> {
12581
+ let numeric = |t: &RustType| matches!(t, RustType::F64 | RustType::I32);
12582
+ match e {
12583
+ Expr::Literal { value: Literal::Number(_), .. } => Some(RustType::F64),
12584
+ Expr::Ident { name, .. } => env.get(name.as_ref()).filter(|t| numeric(t)).cloned(),
12585
+ Expr::Index { object, optional: false, .. } => {
12586
+ if let Expr::Ident { name, .. } = object.as_ref() {
12587
+ if let Some(RustType::Vec(inner)) = env.get(name.as_ref()) {
12588
+ if numeric(inner) {
12589
+ return Some((**inner).clone());
12590
+ }
12591
+ }
12592
+ }
12593
+ None
12594
+ }
12595
+ Expr::Unary { op, operand, .. } => match op {
12596
+ UnaryOp::Neg | UnaryOp::Pos => {
12597
+ self.proof_numeric_type(operand, env, fn_rets).map(|_| RustType::F64)
12598
+ }
12599
+ _ => None,
12600
+ },
12601
+ Expr::Binary { left, op, right, .. } => match op {
12602
+ BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor | BinOp::Shl | BinOp::Shr => {
12603
+ Some(RustType::I32)
12604
+ }
12605
+ BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow => Some(RustType::F64),
12606
+ BinOp::Add => {
12607
+ let l = self.proof_numeric_type(left, env, fn_rets)?;
12608
+ let r = self.proof_numeric_type(right, env, fn_rets)?;
12609
+ Some(if l == RustType::I32 && r == RustType::I32 {
12610
+ RustType::I32
12611
+ } else {
12612
+ RustType::F64
12613
+ })
12614
+ }
12615
+ _ => None,
12616
+ },
12617
+ Expr::Conditional { then_branch, else_branch, .. } => {
12618
+ let a = self.proof_numeric_type(then_branch, env, fn_rets)?;
12619
+ let b = self.proof_numeric_type(else_branch, env, fn_rets)?;
12620
+ Some(if a == b { a } else { RustType::F64 })
12621
+ }
12622
+ Expr::Call { callee, .. } => match callee.as_ref() {
12623
+ Expr::Ident { name, .. } => fn_rets.get(name.as_ref()).cloned(),
12624
+ _ => None,
12625
+ },
12626
+ _ => None,
12627
+ }
12628
+ }
12629
+
12630
+ /// Annotated locals declared directly in this body — through blocks, ifs and loops, but NOT
12631
+ /// into a nested function, whose locals are its own scope and would re-create the name
12632
+ /// collision this exists to avoid.
12633
+ fn collect_own_locals(
12634
+ s: &Statement,
12635
+ aliases: &HashMap<String, RustType>,
12636
+ env: &mut HashMap<String, RustType>,
12637
+ ) {
12638
+ match s {
12639
+ Statement::VarDecl { .. } => {
12640
+ Self::collect_annotated_types(core::slice::from_ref(s), aliases, env)
12641
+ }
12642
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
12643
+ for x in statements {
12644
+ Self::collect_own_locals(x, aliases, env);
12645
+ }
12646
+ }
12647
+ Statement::If { then_branch, else_branch, .. } => {
12648
+ Self::collect_own_locals(then_branch, aliases, env);
12649
+ if let Some(e) = else_branch {
12650
+ Self::collect_own_locals(e, aliases, env);
12651
+ }
12652
+ }
12653
+ Statement::While { body, .. } | Statement::For { body, .. } => {
12654
+ Self::collect_own_locals(body, aliases, env)
12655
+ }
12656
+ _ => {}
12657
+ }
12658
+ }
12659
+
12660
+ /// Every name bound as a variable or a function parameter, anywhere, at any depth.
12661
+ fn collect_bound_names(stmts: &[Statement], out: &mut std::collections::HashSet<String>) {
12662
+ for s in stmts {
12663
+ match s {
12664
+ Statement::VarDecl { name, .. } => {
12665
+ out.insert(name.to_string());
12666
+ }
12667
+ Statement::FunDecl { params, body, .. } => {
12668
+ for p in params {
12669
+ if let FunParam::Simple(tp) = p {
12670
+ out.insert(tp.name.to_string());
12671
+ }
12672
+ }
12673
+ Self::collect_bound_names(core::slice::from_ref(body.as_ref()), out);
12674
+ }
12675
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
12676
+ Self::collect_bound_names(statements, out)
12677
+ }
12678
+ Statement::If { then_branch, else_branch, .. } => {
12679
+ Self::collect_bound_names(core::slice::from_ref(then_branch.as_ref()), out);
12680
+ if let Some(e) = else_branch {
12681
+ Self::collect_bound_names(core::slice::from_ref(e.as_ref()), out);
12682
+ }
12683
+ }
12684
+ Statement::While { body, .. } | Statement::For { body, .. } => {
12685
+ Self::collect_bound_names(core::slice::from_ref(body.as_ref()), out)
12686
+ }
12687
+ _ => {}
12688
+ }
12689
+ }
12690
+ }
12691
+
12692
+ /// Every `return <expr>` in a body, through blocks, ifs and loops. NOT into a nested function.
12693
+ fn collect_return_exprs<'a>(s: &'a Statement, out: &mut Vec<&'a Expr>) {
12694
+ match s {
12695
+ Statement::Return { value: Some(e), .. } => out.push(e),
12696
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
12697
+ for x in statements {
12698
+ Self::collect_return_exprs(x, out);
12699
+ }
12700
+ }
12701
+ Statement::If { then_branch, else_branch, .. } => {
12702
+ Self::collect_return_exprs(then_branch, out);
12703
+ if let Some(e) = else_branch {
12704
+ Self::collect_return_exprs(e, out);
12705
+ }
12706
+ }
12707
+ Statement::While { body, .. } | Statement::For { body, .. } => {
12708
+ Self::collect_return_exprs(body, out)
12709
+ }
12710
+ _ => {}
12711
+ }
12712
+ }
12713
+
12281
12714
  fn collect_demoted_numeric_locals(&self, stmts: &[Statement]) -> HashSet<String> {
12282
12715
  // 1. Flat env: every annotated local/param name → its native `RustType`.
12283
12716
  let mut env: HashMap<String, RustType> = HashMap::new();
@@ -13565,7 +13998,11 @@ impl Codegen {
13565
13998
  let in_bounds = self.index_in_bounds(index, name.as_ref());
13566
13999
  let idx_usize = self.emit_index_usize(index)?;
13567
14000
  let native_val = if *elem_type == RustType::I32 {
13568
- if let Expr::Binary {
14001
+ // A bitwise RHS stays in the integer domain instead of `(int as f64) as i32`. Exact —
14002
+ // see `emit_int32_for_int_consumer`.
14003
+ if let Some(int_code) = self.emit_int32_for_int_consumer(value)? {
14004
+ int_code
14005
+ } else if let Expr::Binary {
13569
14006
  left,
13570
14007
  op: BinOp::Sub,
13571
14008
  right,
@@ -13677,6 +14114,12 @@ impl Codegen {
13677
14114
  return Ok(uv.clone());
13678
14115
  }
13679
14116
  }
14117
+ // Same int-domain shortcut as the element store, with one correction: `(neg as f64) as
14118
+ // usize` SATURATES to 0, while `(neg_i32) as usize` wraps to a huge value. `.max(0)` makes
14119
+ // the two identical for every input, and costs a compare against a soft-float call.
14120
+ if let Some(int_code) = self.emit_int32_for_int_consumer(index)? {
14121
+ return Ok(format!("({}).max(0) as usize", int_code));
14122
+ }
13680
14123
  let (idx_code, idx_ty) = self.emit_typed_expr(index)?;
13681
14124
  if idx_ty == RustType::F64 || idx_ty == RustType::I32 {
13682
14125
  Ok(format!("({}) as usize", idx_code))
@@ -13800,6 +14243,19 @@ impl Codegen {
13800
14243
  value: &Expr,
13801
14244
  target_ty: &RustType,
13802
14245
  ) -> Result<String, CompileError> {
14246
+ // Same int-domain shortcut as the `Vec<i32>` element store: a bitwise RHS bound to an
14247
+ // integer target keeps its integer form instead of `(int as f64) as i32`. Without this,
14248
+ // `let v: i32 = w & 255` is f64-backed and every later use of `v` pays the conversion —
14249
+ // measured at 23,4xx ticks/1000 against 1,5xx for the direct form.
14250
+ if target_ty.is_integer_scalar() {
14251
+ if let Some(int_code) = self.emit_int32_for_int_consumer(value)? {
14252
+ return Ok(if *target_ty == RustType::I32 {
14253
+ int_code
14254
+ } else {
14255
+ format!("({}) as {}", int_code, target_ty.to_rust_type_str())
14256
+ });
14257
+ }
14258
+ }
13803
14259
  let (val_code, val_ty) = self.emit_typed_expr(value)?;
13804
14260
  if val_ty == *target_ty {
13805
14261
  return Ok(val_code);
@@ -13814,6 +14270,26 @@ impl Codegen {
13814
14270
  if *target_ty == RustType::F64 && val_ty.is_integer_scalar() {
13815
14271
  return Ok(format!("({}) as f64", val_code));
13816
14272
  }
14273
+ // FIXED CROSSES DIRECTLY, not through a `Value`. Without these arms a `fixed` reaching an
14274
+ // `f64` extern parameter (or the reverse) fell to the boxed round trip below — allocate a
14275
+ // Value::Number, then immediately unwrap it — which is the whole cost the typed-extern path
14276
+ // exists to remove, reintroduced at the boundary it was removed from (#607). Same scaling as
14277
+ // `RustType::Fixed`'s Value conversions in types.rs, so the values are identical either way.
14278
+ if *target_ty == RustType::F64 && val_ty == RustType::Fixed {
14279
+ return Ok(format!("(({}).to_raw() as f64 / 256.0)", val_code));
14280
+ }
14281
+ if *target_ty == RustType::Fixed && val_ty == RustType::F64 {
14282
+ return Ok(format!(
14283
+ "tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)",
14284
+ val_code
14285
+ ));
14286
+ }
14287
+ if *target_ty == RustType::Fixed && val_ty.is_integer_scalar() {
14288
+ return Ok(format!(
14289
+ "tishlang_runtime::Fixed::from_raw((({}) as i32).wrapping_mul(256))",
14290
+ val_code
14291
+ ));
14292
+ }
13817
14293
  let boxed = if val_ty.is_native() {
13818
14294
  val_ty.to_value_expr(&val_code)
13819
14295
  } else {
@@ -15699,6 +16175,9 @@ impl Codegen {
15699
16175
  let rt = self.expr_native_type(right, env);
15700
16176
  RustType::result_type_of_binop(*op, &lt, &rt).unwrap_or(RustType::Value)
15701
16177
  }
16178
+ // A call to a fn whose every return is PROVEN numeric. Without this a call is opaque and
16179
+ // any local assigned from one demotes to a boxed `Value`. `env` shadowing wins: a local
16180
+ // of that name is not the fn.
15702
16181
  // `vec[i]` where `vec` is a `number[]` (Vec<f64>) → the element type. A `Vec<f64>`
15703
16182
  // can only hold numbers, so this never feeds a string into the accumulator.
15704
16183
  Expr::Index {
@@ -15777,13 +16256,30 @@ impl Codegen {
15777
16256
  }
15778
16257
  RustType::Value
15779
16258
  }
16259
+ // ONE Call arm, and the singularity is load-bearing: the numeric-return proof briefly
16260
+ // lived in its own `Expr::Call` arm ABOVE this one, which shadowed everything below —
16261
+ // native-fn results, the Math intrinsics and the #169 reduce modelling all became
16262
+ // unreachable (rustc said so, as a warning nobody read) and every accumulator they
16263
+ // feed demoted to a boxed `Value`. CI caught it as issue_169 + the #320 push test.
15780
16264
  Expr::Call { callee, args, .. } => {
15781
- // M5 native fn (`fn f_native(..) -> f64`); requires all-positional args.
15782
16265
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
16266
+ // M5 native fn (`fn f_native(..) -> f64`); requires all-positional args.
15783
16267
  if self.native_fns.contains(fname.as_ref())
15784
16268
  && args.iter().all(|a| matches!(a, CallArg::Expr(_)))
15785
16269
  {
15786
- return RustType::F64;
16270
+ return if self.native_fns_i32.contains(fname.as_ref()) {
16271
+ RustType::I32
16272
+ } else {
16273
+ RustType::F64
16274
+ };
16275
+ }
16276
+ // A call to a fn whose every return is PROVEN numeric (the boxed call's RESULT
16277
+ // stops being opaque — the dispatch is unchanged). `env` shadowing wins: a
16278
+ // local of that name is not the fn.
16279
+ if !env.contains_key(fname.as_ref()) {
16280
+ if let Some(ft) = self.user_fn_ret.get(fname.as_ref()) {
16281
+ return ft.clone();
16282
+ }
15787
16283
  }
15788
16284
  }
15789
16285
  // Single-arg `Math.<intrinsic>(x)` lowered to a direct `f64` method → number.
@@ -16534,6 +17030,22 @@ impl Codegen {
16534
17030
  }
16535
17031
 
16536
17032
  /// Emit `thread_local! { static G_NAME: Cell<f64> = ... }` for each eligible global.
17033
+ /// #594, GBA: the no_std twin of `emit_native_numeric_global_tls`. `SingleCore` is the facade's
17034
+ /// single-core static wrapper — sound because the GBA runs one cooperative thread and no
17035
+ /// interrupt handler touches interpreter state — and its `with()` mirrors `LocalKey::with`, so
17036
+ /// the read/write emission is shared with the TLS path rather than duplicated.
17037
+ fn emit_native_numeric_global_singlecore(&mut self) -> Result<(), CompileError> {
17038
+ for (name, init) in self.native_numeric_globals.clone() {
17039
+ self.writeln(&format!(
17040
+ "static {}: tishlang_runtime::SingleCore<core::cell::Cell<f64>> = \
17041
+ tishlang_runtime::SingleCore::new(core::cell::Cell::new({}));",
17042
+ Self::native_global_static(&name),
17043
+ Self::f64_lit(init),
17044
+ ));
17045
+ }
17046
+ Ok(())
17047
+ }
17048
+
16537
17049
  fn emit_native_numeric_global_tls(&mut self) -> Result<(), CompileError> {
16538
17050
  self.writeln("thread_local! {");
16539
17051
  self.indent += 1;
@@ -16552,6 +17064,9 @@ impl Codegen {
16552
17064
  /// Top-level `let name = [n0, n1, …]` with numeric literals, never reassigned — emitted as
16553
17065
  /// `const G_name: [f64; N]` for direct indexing from native fns (fasta `codes`/`probs`).
16554
17066
  fn collect_module_const_f64_arrays(stmts: &[Statement]) -> HashMap<String, Vec<f64>> {
17067
+ // Every name bound anywhere in the program by anything other than the candidate decls
17068
+ // themselves. Collected alongside the existing per-fn local scan below.
17069
+ let mut shadow_bindings: HashSet<String> = HashSet::new();
16555
17070
  use std::collections::{HashMap, HashSet};
16556
17071
  let mut candidates: HashMap<String, Vec<f64>> = HashMap::new();
16557
17072
  for s in stmts {
@@ -16592,6 +17107,16 @@ impl Codegen {
16592
17107
  candidates.remove(&n);
16593
17108
  }
16594
17109
  }
17110
+ // …AND ANY OTHER BINDING OF THE NAME, not just `let`/`const`. `collect_local_var_names`
17111
+ // sees VarDecl only, so a PARAMETER, a `for-of` variable or a `catch` binding named like
17112
+ // a module const did not disqualify it — and the const fast path in `emit_typed_expr`'s
17113
+ // Index arm is static, with no scope check, so `function f(T: number[], i) { return T[i] }`
17114
+ // read the MODULE `T` and returned the wrong number with no diagnostic. The sibling
17115
+ // numeric-global pass has always used `collect_binding_names` for exactly this reason.
17116
+ Self::collect_binding_names(s, &mut shadow_bindings);
17117
+ }
17118
+ for n in &shadow_bindings {
17119
+ candidates.remove(n);
16595
17120
  }
16596
17121
  let globals: HashSet<String> = candidates.keys().cloned().collect();
16597
17122
  let mut reassigns: Vec<(String, &Expr)> = Vec::new();
@@ -17826,6 +18351,12 @@ impl Codegen {
17826
18351
  Some(rt) => Self::ann_is_number(rt),
17827
18352
  None => true,
17828
18353
  };
18354
+ // THE SECOND SHAPE: an all-`i32` signature, which is what a device with no FPU
18355
+ // wants and what the existing gate excluded. The fixpoint below is unchanged and
18356
+ // still has to prove the body native-safe and every return numeric — the annotation
18357
+ // never decides that on its own (see `liar` in the issue).
18358
+ let i32_ok = Self::fn_sig_all_i32(params, return_type);
18359
+ let (params_ok, ret_ok) = (params_ok || i32_ok, ret_ok || i32_ok);
17829
18360
  // #320: 0-param numeric fns (e.g. k_nucleotide's `nextBase()` — mutates a numeric
17830
18361
  // global, returns number) are eligible too; the fixpoint below still proves the body
17831
18362
  // native-safe + all-numeric-returns, so `fn name_native() -> f64` is sound.
@@ -18219,6 +18750,27 @@ impl Codegen {
18219
18750
  }
18220
18751
  }
18221
18752
 
18753
+ /// Can a top-level native `fn` name this indexed object? A parameter, a global with a static,
18754
+ /// a native-vec, or a local it declared itself — anything else is a binding that only exists
18755
+ /// inside `run()`.
18756
+ fn native_safe_indexable(
18757
+ object: &Expr,
18758
+ params: &std::collections::HashSet<String>,
18759
+ globals: &std::collections::HashSet<String>,
18760
+ nums: &HashSet<String>,
18761
+ native_vec_names: &std::collections::HashSet<String>,
18762
+ ) -> bool {
18763
+ match object {
18764
+ Expr::Ident { name, .. } => {
18765
+ params.contains(name.as_ref())
18766
+ || globals.contains(name.as_ref())
18767
+ || nums.contains(name.as_ref())
18768
+ || native_vec_names.contains(name.as_ref())
18769
+ }
18770
+ _ => false,
18771
+ }
18772
+ }
18773
+
18222
18774
  fn native_safe_expr(
18223
18775
  expr: &Expr,
18224
18776
  params: &std::collections::HashSet<String>,
@@ -18241,24 +18793,45 @@ impl Codegen {
18241
18793
  && Self::native_safe_expr(value, params, cand, globals, nums, native_vec_names)
18242
18794
  }
18243
18795
  Expr::Binary { left, op, right, .. } => {
18244
- matches!(
18796
+ let arith = matches!(
18245
18797
  op,
18246
18798
  BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow
18247
18799
  | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge
18248
18800
  | BinOp::StrictEq | BinOp::StrictNe | BinOp::And | BinOp::Or
18249
- ) && Self::native_safe_expr(left, params, cand, globals, nums, native_vec_names)
18801
+ );
18802
+ // Bitwise, unlike in `numeric_shaped`, still recurses into its operands: there the
18803
+ // question is what the RESULT is (always a number), here it is whether the emitter
18804
+ // can lower the whole expression natively. Gated with its sibling so that off the
18805
+ // GBA numeric vocabulary eligibility is byte-identical to before.
18806
+ let bitwise = crate::types::gba_numerics_enabled()
18807
+ && matches!(
18808
+ op,
18809
+ BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor
18810
+ | BinOp::Shl | BinOp::Shr | BinOp::UShr
18811
+ );
18812
+ (arith || bitwise)
18813
+ && Self::native_safe_expr(left, params, cand, globals, nums, native_vec_names)
18250
18814
  && Self::native_safe_expr(right, params, cand, globals, nums, native_vec_names)
18251
18815
  }
18252
18816
  Expr::Unary { op, operand, .. } => {
18253
- matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::Not)
18817
+ (matches!(op, UnaryOp::Neg | UnaryOp::Pos | UnaryOp::Not)
18818
+ || (crate::types::gba_numerics_enabled()
18819
+ && matches!(op, UnaryOp::BitNot)))
18254
18820
  && Self::native_safe_expr(operand, params, cand, globals, nums, native_vec_names)
18255
18821
  }
18256
18822
  Expr::PostfixInc { name, .. }
18257
18823
  | Expr::PrefixInc { name, .. }
18258
18824
  | Expr::PostfixDec { name, .. }
18259
18825
  | Expr::PrefixDec { name, .. } => nums.contains(name.as_ref()),
18826
+ // THE INDEXED THING MUST BE IN SCOPE FOR A FREE `fn`, not merely be an identifier.
18827
+ // This accepted any name at all, and a native fn is emitted at top level where a
18828
+ // module-scope binding — a `let` inside `run()`, captured by the boxed closures — does
18829
+ // not exist: `function reads(i: i32): i32 { return ARR[i] & 255 }` compiled to a
18830
+ // `reads_native` whose body named `ARR`, and rustc rejected the generated program with
18831
+ // E0425. Masked until now because the globals set was only ever populated with things
18832
+ // that DO have a top-level static.
18260
18833
  Expr::Index { object, index, .. } => {
18261
- matches!(object.as_ref(), Expr::Ident { .. })
18834
+ Self::native_safe_indexable(object, params, globals, nums, native_vec_names)
18262
18835
  && Self::native_safe_expr(index, params, cand, globals, nums, native_vec_names)
18263
18836
  }
18264
18837
  Expr::IndexAssign {
@@ -18267,7 +18840,7 @@ impl Codegen {
18267
18840
  value,
18268
18841
  ..
18269
18842
  } => {
18270
- matches!(object.as_ref(), Expr::Ident { .. })
18843
+ Self::native_safe_indexable(object, params, globals, nums, native_vec_names)
18271
18844
  && Self::native_safe_expr(index, params, cand, globals, nums, native_vec_names)
18272
18845
  && Self::native_safe_expr(value, params, cand, globals, nums, native_vec_names)
18273
18846
  }
@@ -18347,6 +18920,26 @@ impl Codegen {
18347
18920
  || globals.contains(name.as_ref())
18348
18921
  || nums.contains(name.as_ref())
18349
18922
  }
18923
+ // A BITWISE RESULT IS A NUMBER WHATEVER WENT IN. `& | ^ << >> >>>` are ToInt32/ToUint32
18924
+ // contexts in the language, so `"7" & 255` is 7 and `{} | 0` is 0 — the operands need
18925
+ // no proof of their own, which is why this arm does not recurse into them. Their
18926
+ // absence is not a small omission on a device with no FPU: `(a + b) | 0`, `a & 255` and
18927
+ // `w >> 16` are what integer code on such a target is made of, and every function
18928
+ // containing one was ineligible for a native `fn` no matter how it was annotated.
18929
+ Expr::Binary { op, .. }
18930
+ if crate::types::gba_numerics_enabled()
18931
+ && matches!(
18932
+ op,
18933
+ BinOp::BitAnd
18934
+ | BinOp::BitOr
18935
+ | BinOp::BitXor
18936
+ | BinOp::Shl
18937
+ | BinOp::Shr
18938
+ | BinOp::UShr
18939
+ ) =>
18940
+ {
18941
+ true
18942
+ }
18350
18943
  Expr::Binary { left, op, right, .. } => {
18351
18944
  matches!(
18352
18945
  op,
@@ -18354,6 +18947,8 @@ impl Codegen {
18354
18947
  ) && Self::numeric_shaped(left, params, cand, globals, nums)
18355
18948
  && Self::numeric_shaped(right, params, cand, globals, nums)
18356
18949
  }
18950
+ // Unary `~` is ToInt32 for the same reason.
18951
+ Expr::Unary { op: UnaryOp::BitNot, .. } if crate::types::gba_numerics_enabled() => true,
18357
18952
  Expr::Unary { op, operand, .. } => {
18358
18953
  matches!(op, UnaryOp::Neg | UnaryOp::Pos)
18359
18954
  && Self::numeric_shaped(operand, params, cand, globals, nums)
@@ -18500,6 +19095,46 @@ impl Codegen {
18500
19095
  /// guarded fn is copy 0, the one with the stack check, so every entry from outside the cycle is
18501
19096
  /// checked. Inside rotation copy `i`, an intra-SCC call targets the callee's copy `i+1` (the
18502
19097
  /// checked copy 0 again after K levels).
19098
+ /// Bring one argument to a native fn's parameter type.
19099
+ ///
19100
+ /// The f64 ABI accepts an `i32` expression as-is (Rust widens on the `as` the emitter already
19101
+ /// writes); the i32 ABI has to ToInt32 an f64 one, which is exactly the coercion the boxed
19102
+ /// path applied on entry — moved to the caller, where the value is usually already an integer
19103
+ /// and the conversion folds away.
19104
+ fn coerce_native_arg(&self, code: &str, from: RustType, want: RustType) -> String {
19105
+ if from == want {
19106
+ return code.to_string();
19107
+ }
19108
+ match (from, want) {
19109
+ (RustType::Value, w) => w.from_value_expr(code),
19110
+ (RustType::I32, RustType::F64) => format!("(({}) as f64)", code),
19111
+ // FIXED IS NOT AN f64 AND THE FALLTHROUGH BELOW USED TO PRETEND IT WAS. `fixed` is agb's
19112
+ // `Num<i32,8>`; handed to a native `f64` parameter it is a hard E0308 in the generated
19113
+ // program, not a slow path — which is how four call sites in one file of hyrule stopped
19114
+ // that game building the moment its helper became M5-eligible. The scaling is the same
19115
+ // one `RustType::Fixed`'s Value boundary uses (`types.rs:603`), so a value crossing here
19116
+ // reads identically to one crossing there.
19117
+ (RustType::Fixed, RustType::F64) => format!("(({}).to_raw() as f64 / 256.0)", code),
19118
+ // And the other direction, for the same reason: a native f64 reaching a `fixed`
19119
+ // parameter. Truncating, matching `Fixed::from_value_expr` (`types.rs:507`).
19120
+ (RustType::F64, RustType::Fixed) => format!(
19121
+ "tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)",
19122
+ code
19123
+ ),
19124
+ (RustType::I32, RustType::Fixed) => format!(
19125
+ "tishlang_runtime::Fixed::from_raw((({}) as i32).wrapping_mul(256))",
19126
+ code
19127
+ ),
19128
+ // DIVIDE, DO NOT SHIFT. `>> 8` is an arithmetic shift and FLOORS: fixed -1.5 would
19129
+ // become -2 and -0.0039 would become -1. Rust integer division truncates toward zero,
19130
+ // which is what ToInt32 and `as i32` do, so -1.5 -> -1 and -0.0039 -> 0. The two agree
19131
+ // on every non-negative value, which is exactly why this would have shipped.
19132
+ (RustType::Fixed, RustType::I32) => format!("(({}).to_raw() / 256)", code),
19133
+ (_, RustType::I32) => format!("tishlang_runtime::to_int32({})", code),
19134
+ _ => code.to_string(),
19135
+ }
19136
+ }
19137
+
18503
19138
  fn native_call_target(&self, fname: &str) -> String {
18504
19139
  let base = format!("{}_native", Self::escape_ident(fname));
18505
19140
  if let (Some((cur_scc, i, k)), Some(&(callee_scc, _))) =
@@ -18521,11 +19156,13 @@ impl Codegen {
18521
19156
  if !self.native_fns.contains(name.as_ref()) {
18522
19157
  continue;
18523
19158
  }
19159
+ let i32_abi = self.native_fns_i32.contains(name.as_ref());
19160
+ let abi = if i32_abi { "i32" } else { "f64" };
18524
19161
  let plist: Vec<String> = params
18525
19162
  .iter()
18526
19163
  .filter_map(|p| match p {
18527
19164
  FunParam::Simple(tp) => {
18528
- Some(format!("mut {}: f64", Self::escape_ident(tp.name.as_ref())))
19165
+ Some(format!("mut {}: {}", Self::escape_ident(tp.name.as_ref()), abi))
18529
19166
  }
18530
19167
  _ => None,
18531
19168
  })
@@ -18545,18 +19182,30 @@ impl Codegen {
18545
19182
  self.type_context.push_scope();
18546
19183
  for p in params {
18547
19184
  if let FunParam::Simple(tp) = p {
18548
- self.type_context.define(tp.name.as_ref(), RustType::F64);
19185
+ self.type_context.define(
19186
+ tp.name.as_ref(),
19187
+ if i32_abi { RustType::I32 } else { RustType::F64 },
19188
+ );
18549
19189
  }
18550
19190
  }
18551
19191
  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(", ")));
19192
+ self.writeln(&format!("fn {}_native{}({}) -> {} {{", Self::escape_ident(name.as_ref()), copy_suffix, plist.join(", "), abi));
19193
+ let saved_i32_abi = self.native_fn_abi_i32;
19194
+ self.native_fn_abi_i32 = i32_abi;
18553
19195
  self.indent += 1;
18554
19196
  if copies > 1 {
18555
19197
  self.native_recur_rotation = Some((scc_id, copy_idx, copies));
18556
19198
  if copy_idx == 0 {
18557
19199
  // The guard: one thread-local read + pointer compare; the cold bail parks
18558
19200
  // the catchable RangeError and unwinds via the NaN sentinel (#381).
18559
- self.writeln("if tishlang_runtime::stack_low() { return tishlang_runtime::recursion_tripped_f64(); }");
19201
+ // The f64 bail unwinds through a NaN sentinel, which an `i32` return has no
19202
+ // room for. `compute_native_recur_fns` therefore never gives an i32-ABI fn
19203
+ // rotation copies — see the guard where it is called.
19204
+ self.writeln(if i32_abi {
19205
+ "if tishlang_runtime::stack_low() { return 0; }"
19206
+ } else {
19207
+ "if tishlang_runtime::stack_low() { return tishlang_runtime::recursion_tripped_f64(); }"
19208
+ });
18560
19209
  }
18561
19210
  }
18562
19211
  if let Some((global, mul, add, modulus)) = self.native_lcg_fns.get(name.as_ref()) {
@@ -18628,10 +19277,11 @@ impl Codegen {
18628
19277
  self.native_lcg_hoist.take();
18629
19278
  self.native_lcg_hoist_int = false;
18630
19279
  if !self.native_fn_body_returned {
18631
- self.writeln("0.0");
19280
+ self.writeln(if i32_abi { "0" } else { "0.0" });
18632
19281
  }
18633
19282
  self.native_fn_emit_name = None;
18634
19283
  }
19284
+ self.native_fn_abi_i32 = saved_i32_abi;
18635
19285
  self.indent -= 1;
18636
19286
  self.writeln("}");
18637
19287
  self.type_context.pop_scope();
@@ -18688,7 +19338,20 @@ impl Codegen {
18688
19338
  self.skip_iter_local = Some(iter);
18689
19339
  }
18690
19340
  }
18691
- self.emit_statements_with_folds(statements)?;
19341
+ // A NATIVE FN BODY IS A SCOPE, and it has to say so. `VarDecl` registers every
19342
+ // name it emits into `outer_vars_stack.last_mut()`; without a frame of its own,
19343
+ // a native fn's locals land in the MODULE frame — where they do not exist, because
19344
+ // the body was emitted inside `fn name_native(..)` at top level. Any later closure
19345
+ // referencing a same-named local of its own then sees the phantom in `outer_vars`
19346
+ // and emits `let i_cell = VmRef::new(i.clone());` against nothing: E0425, 49 of the
19347
+ // 114 tish-gba examples, every one of them with an `i` or `b` loop counter.
19348
+ //
19349
+ // The boxed FunDecl path has always pushed here (both of its arms do); this path
19350
+ // was written without one and only became reachable when M5 was enabled for GBA.
19351
+ self.outer_vars_stack.push(Vec::new());
19352
+ let r = self.emit_statements_with_folds(statements);
19353
+ self.outer_vars_stack.pop();
19354
+ r?;
18692
19355
  if self.pending_stayed_var.is_none() {
18693
19356
  self.skip_iter_local = None;
18694
19357
  }
@@ -18696,10 +19359,31 @@ impl Codegen {
18696
19359
  Statement::Return { value, .. } => {
18697
19360
  let e = value.as_ref().expect("eligible return has a value");
18698
19361
  let (code, ty) = self.emit_typed_expr(e)?;
18699
- let f = if ty == RustType::F64 {
19362
+ let want = if self.native_fn_abi_i32 { RustType::I32 } else { RustType::F64 };
19363
+ // The int-domain shortcut FIRST for an i32 return. A bitwise expression types as
19364
+ // `F64` by design (`types.rs`), so taking the generic path below would emit
19365
+ // `to_int32(<i32 work> as f64)` — the exact round trip this ABI exists to remove,
19366
+ // reintroduced at the return. `emit_int32_operand` is the same helper the operator
19367
+ // fixes use.
19368
+ if want == RustType::I32 {
19369
+ if let Some(code) = self.emit_int32_operand(e)? {
19370
+ if let Some((global, _, _, _)) = self.native_lcg_hoist.as_ref() {
19371
+ let _ = global;
19372
+ }
19373
+ self.writeln(&format!("return {};", code));
19374
+ self.native_fn_body_returned = true;
19375
+ return Ok(());
19376
+ }
19377
+ }
19378
+ let (code, ty) = (code, ty);
19379
+ let f = if ty == want {
18700
19380
  code
18701
19381
  } else if ty == RustType::Value {
18702
- RustType::F64.from_value_expr(&code)
19382
+ want.from_value_expr(&code)
19383
+ } else if want == RustType::I32 {
19384
+ // An f64-typed expression returned from an `i32` fn takes the same ToInt32 the
19385
+ // boxed path would have applied at the consumer.
19386
+ format!("tishlang_runtime::to_int32({})", code)
18703
19387
  } else {
18704
19388
  code
18705
19389
  };
@@ -19336,6 +20020,17 @@ impl Codegen {
19336
20020
  // fn either — its scalar params would be typed `f64` and mistype the arg (e.g. `fn rec(m){
19337
20021
  // log.push(m) }` called `rec("ran")`). Same gate the M4/M5/S-0 paths use.
19338
20022
  let nonnumeric = Self::fns_called_with_nonnumeric_arg(&program.statements);
20023
+ // Every top-level fn name. A CALL to one is validated separately (`vec_body_calls_native`);
20024
+ // what matters here is that the identifier itself is not a `run()` local the way a module
20025
+ // `let` or a builtin like `Math` is.
20026
+ let fn_names: std::collections::HashSet<String> = program
20027
+ .statements
20028
+ .iter()
20029
+ .filter_map(|s| match s {
20030
+ Statement::FunDecl { name, .. } => Some(name.to_string()),
20031
+ _ => None,
20032
+ })
20033
+ .collect();
19339
20034
  for s in &program.statements {
19340
20035
  if let Statement::FunDecl {
19341
20036
  async_: false,
@@ -19362,7 +20057,28 @@ impl Codegen {
19362
20057
  .params
19363
20058
  .iter()
19364
20059
  .any(|(_, k)| matches!(k, VecParamKind::Array { .. }));
19365
- if has_array_param || Self::body_uses_local_vec_ops(body) {
20060
+ // #605: a scalars-in/STRING-out fn has neither an array parameter nor a local
20061
+ // vec op, so it never reached this group and stayed a boxed closure — which is
20062
+ // what "VecRetKind has no String" amounts to in practice. The return shape earns
20063
+ // admission on its own, but ONLY under three extra conditions, each of which a
20064
+ // review found by breaking the version without it:
20065
+ //
20066
+ // 1. TOTAL. `vec_fn_return_kind` rejects an explicit bare `return;` but cannot
20067
+ // see an IMPLICIT fall-through, and the lowered fn's tail yields
20068
+ // `String::new()` where the boxed one yields `Value::Null` — so
20069
+ // `function label(n){ if(n>0){return "pos"} }` returned "" instead of null.
20070
+ // A wrong value, silently, from ordinary code.
20071
+ // 2. NO STRING-COMPARED PARAM. `scan_param_use` counts `===` as arithmetic, so
20072
+ // `tag === "fire"` "proves" `tag` numeric and the param takes the f64 ABI —
20073
+ // a `panic!("expected number")` on a no_std cart, or E0308. (infer.rs treats
20074
+ // the same operator as OVERLOADED for exactly this reason.)
20075
+ // 3. NO MODULE READS, on EVERY target. A string or object module binding has a
20076
+ // top-level home nowhere, and this is the first rule that admits a fn shaped
20077
+ // to read one, so the GBA-only gate below is not enough.
20078
+ let string_out = sig.ret == VecRetKind::Str
20079
+ && Self::body_always_returns(body)
20080
+ && Self::body_names_only_its_own(params, body, &fn_names);
20081
+ if has_array_param || Self::body_uses_local_vec_ops(body) || string_out {
19366
20082
  sigs.insert(name.to_string(), sig);
19367
20083
  }
19368
20084
  }
@@ -19874,6 +20590,100 @@ impl Codegen {
19874
20590
  None
19875
20591
  }
19876
20592
 
20593
+ /// Does `body` name ONLY things a free `fn` can see — its own parameters, its own locals, and
20594
+ /// other top-level fns? Anything else (a module `let`, or a builtin like `Math`/`console`) is a
20595
+ /// local of `run()` in the emitted program, and a free fn naming one is E0425.
20596
+ ///
20597
+ /// Stricter than the module-binding check it replaced for this door, and deliberately so:
20598
+ /// `regex_redux`'s `nextBase()` is scalars-in/string-out, reaches for `Math.floor`, and the
20599
+ /// native-vec emitter lowers that to a `get_prop` against the `Math` binding — which is not a
20600
+ /// module `let`, so a module-only guard waved it through and the fixture stopped building.
20601
+ fn body_names_only_its_own(
20602
+ params: &[FunParam],
20603
+ body: &Statement,
20604
+ fn_names: &std::collections::HashSet<String>,
20605
+ ) -> bool {
20606
+ let mut referenced = HashSet::new();
20607
+ Self::collect_stmt_idents(body, &mut referenced);
20608
+ let mut bound: HashSet<String> = params
20609
+ .iter()
20610
+ .flat_map(|p| p.bound_names())
20611
+ .map(|n| n.to_string())
20612
+ .collect();
20613
+ Self::collect_local_var_names(body, &mut bound);
20614
+ referenced
20615
+ .iter()
20616
+ .all(|n| bound.contains(n) || fn_names.contains(n))
20617
+ }
20618
+
20619
+ /// Does every path through `body` end in an explicit `return <expr>`? Conservative: only a
20620
+ /// trailing `return`, or an if/else where BOTH arms are total, counts. Anything else may fall
20621
+ /// off the end, and a lowered fn's tail default (`String::new()` / `0.0`) is not what the boxed
20622
+ /// path yields there (`Value::Null`).
20623
+ fn body_always_returns(body: &Statement) -> bool {
20624
+ match body {
20625
+ Statement::Return { value: Some(_), .. } => true,
20626
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
20627
+ statements.last().is_some_and(Self::body_always_returns)
20628
+ }
20629
+ Statement::If { then_branch, else_branch, .. } => {
20630
+ else_branch.as_ref().is_some_and(|e| {
20631
+ Self::body_always_returns(then_branch) && Self::body_always_returns(e)
20632
+ })
20633
+ }
20634
+ _ => false,
20635
+ }
20636
+ }
20637
+
20638
+ /// Every module-level `let`/`const` name. On GBA these are locals of `run()` — nothing gives
20639
+ /// them a top-level home — so a free `fn` naming one does not compile.
20640
+ fn collect_module_level_binding_names(
20641
+ statements: &[Statement],
20642
+ ) -> std::collections::HashSet<String> {
20643
+ let mut out = std::collections::HashSet::new();
20644
+ for s in statements {
20645
+ Self::collect_module_binding_names_of(s, &mut out);
20646
+ }
20647
+ out
20648
+ }
20649
+
20650
+ /// One statement's module-level bindings. `Multi` matters: `let a = 1, b = 2` parses to it, and
20651
+ /// codegen emits each declarator into the CURRENT scope — so at module level those are `run()`
20652
+ /// locals exactly like a plain `let`, and missing them let a free fn name one (E0425).
20653
+ fn collect_module_binding_names_of(s: &Statement, out: &mut std::collections::HashSet<String>) {
20654
+ match s {
20655
+ Statement::VarDecl { name, .. } => {
20656
+ out.insert(name.to_string());
20657
+ }
20658
+ Statement::VarDeclDestructure { pattern, .. } => {
20659
+ Self::collect_destruct_names(pattern, out);
20660
+ }
20661
+ Statement::Multi { statements, .. } => {
20662
+ for inner in statements {
20663
+ Self::collect_module_binding_names_of(inner, out);
20664
+ }
20665
+ }
20666
+ _ => {}
20667
+ }
20668
+ }
20669
+
20670
+ /// Does `body` read a module binding it would not be able to see as a free `fn`? Names the body
20671
+ /// binds itself (params are checked by the caller; locals here) shadow the module one, so they
20672
+ /// do not count — that is the same shadow rule #556 established for parameters.
20673
+ fn body_reads_module_binding(
20674
+ body: &Statement,
20675
+ module_bindings: &std::collections::HashSet<String>,
20676
+ ) -> bool {
20677
+ // NO SHADOW SUBTRACTION, deliberately. `collect_local_var_names` flattens every nested
20678
+ // block, branch and loop into one set with no scope structure, so an inner
20679
+ // `if (..) { let CFG = 0 }` would mask a module `CFG` read elsewhere in the body and let the
20680
+ // fn lower with a free name (E0425). Over-rejecting costs a fn its lowering; under-rejecting
20681
+ // costs the whole program its build.
20682
+ let mut referenced = HashSet::new();
20683
+ Self::collect_stmt_idents(body, &mut referenced);
20684
+ referenced.iter().any(|n| module_bindings.contains(n))
20685
+ }
20686
+
19877
20687
  /// Detect, validate, and emit the native-vec fn group. On any gap the group is left empty.
19878
20688
  fn setup_native_vec_fns(&mut self, program: &Program) {
19879
20689
  use std::collections::HashMap;
@@ -19881,6 +20691,55 @@ impl Codegen {
19881
20691
  // #177 aggregate fns (`advance`/`offsetMomentum` on `Vec<Struct>`) take precedence — do not
19882
20692
  // also emit a broken `name_nv(&Vec<f64>)` wrapper for them.
19883
20693
  sigs.retain(|name, _| !self.aggregate_fns.contains_key(name));
20694
+ // ON GBA, A MODULE BINDING IS NOT REACHABLE FROM A FREE `fn`. The passes that give module
20695
+ // state a top-level home — `native_numeric_globals`' TLS cells and `module_const_f64_arrays`'
20696
+ // statics — are the std-emitting ones, and they are gated off for GBA. So every module
20697
+ // `let`/`const` is a local of `run()` there, while `name_nv` is emitted at top level: a body
20698
+ // that reads one compiles to `error[E0425]: cannot find value CFG in this scope` and the
20699
+ // game does not build (#594).
20700
+ //
20701
+ // Reject those fns here rather than emitting a broken program. They stay on the boxed path,
20702
+ // which is correct and merely slower. Scoped to GBA deliberately: off-target the statics DO
20703
+ // exist, the reference resolves, and eligibility must stay byte-identical to before.
20704
+ //
20705
+ // This does not close #594's other half — a lowered fn that can actually SEE module state,
20706
+ // which needs those bindings emitted as statics on GBA too. It stops the miscompile.
20707
+ if self.emit_mode == crate::NativeEmitMode::Gba {
20708
+ let mut module_bindings = Self::collect_module_level_binding_names(&program.statements);
20709
+ // …EXCEPT the ones that now have a top-level home. Since #594 a module const numeric
20710
+ // array is a `const` and a mutable numeric global is a `SingleCore` static, both
20711
+ // reachable from a free fn — rejecting a fn for reading those would give up exactly the
20712
+ // lowering this issue exists to enable. What remains unreachable is everything else:
20713
+ // boxed objects, strings, arrays of non-numbers.
20714
+ for k in self.module_const_f64_arrays.keys() {
20715
+ module_bindings.remove(k);
20716
+ }
20717
+ for k in self.native_numeric_globals.keys() {
20718
+ module_bindings.remove(k);
20719
+ }
20720
+ for k in self.module_const_f64_cum.keys() {
20721
+ module_bindings.remove(k);
20722
+ }
20723
+ for k in self.module_const_f64_aliases.keys() {
20724
+ module_bindings.remove(k);
20725
+ }
20726
+ if !module_bindings.is_empty() {
20727
+ let bodies_by_name: std::collections::HashMap<&str, &Statement> = program
20728
+ .statements
20729
+ .iter()
20730
+ .filter_map(|s| match s {
20731
+ Statement::FunDecl { name, body, .. } => Some((name.as_ref(), body.as_ref())),
20732
+ _ => None,
20733
+ })
20734
+ .collect();
20735
+ sigs.retain(|name, _| {
20736
+ let Some(body) = bodies_by_name.get(name.as_str()) else {
20737
+ return true;
20738
+ };
20739
+ !Self::body_reads_module_binding(body, &module_bindings)
20740
+ });
20741
+ }
20742
+ }
19884
20743
  if sigs.is_empty() {
19885
20744
  return;
19886
20745
  }
@@ -20209,7 +21068,7 @@ impl Codegen {
20209
21068
  }
20210
21069
  }
20211
21070
  Expr::Binary { left, op, right, .. } => {
20212
- let a = matches!(
21071
+ let arith = matches!(
20213
21072
  op,
20214
21073
  BinOp::Add
20215
21074
  | BinOp::Sub
@@ -20221,9 +21080,17 @@ impl Codegen {
20221
21080
  | BinOp::Le
20222
21081
  | BinOp::Gt
20223
21082
  | BinOp::Ge
20224
- | BinOp::StrictEq
20225
- | BinOp::StrictNe
20226
21083
  );
21084
+ // `===`/`!==` ARE OVERLOADED — they compare strings as readily as numbers, so one is
21085
+ // proof of a numeric operand ONLY when the other side is a numeric literal.
21086
+ // `tag === "fire"` used to mark `tag` numeric, which handed a string parameter the
21087
+ // f64 ABI: `panic!("expected number")` on a no_std cart, or E0308. `infer.rs`
21088
+ // classifies the same operator as overloaded for exactly this reason, and the two
21089
+ // disagreeing is what let it through here.
21090
+ let num_lit = |x: &Expr| matches!(x, Expr::Literal { value: Literal::Number(_), .. });
21091
+ let eq_numeric = matches!(op, BinOp::StrictEq | BinOp::StrictNe)
21092
+ && (num_lit(left) || num_lit(right));
21093
+ let a = arith || eq_numeric;
20227
21094
  Self::scan_param_use(left, p, a, f);
20228
21095
  Self::scan_param_use(right, p, a, f);
20229
21096
  }
@@ -20336,6 +21203,17 @@ impl Codegen {
20336
21203
  {
20337
21204
  return Some(VecRetKind::F64);
20338
21205
  }
21206
+ // #605: EVERY return a string literal. Held to the same standard as the numeric arm above —
21207
+ // agreement across all returns, proven from the expressions, never from the annotation,
21208
+ // because a return type is erased and unchecked (the `liar()` case). A mixed
21209
+ // string-and-number fn falls through to `None` and stays boxed, which is the only shape a
21210
+ // single native signature cannot express.
21211
+ if ret_exprs
21212
+ .iter()
21213
+ .all(|e| matches!(e, Expr::Literal { value: Literal::String(_), .. }))
21214
+ {
21215
+ return Some(VecRetKind::Str);
21216
+ }
20339
21217
  None
20340
21218
  }
20341
21219
 
@@ -20658,6 +21536,7 @@ impl Codegen {
20658
21536
  match sig.ret {
20659
21537
  VecRetKind::F64 => Some(RustType::F64),
20660
21538
  VecRetKind::VecF64 => Some(RustType::Vec(Box::new(RustType::F64))),
21539
+ VecRetKind::Str => Some(RustType::String),
20661
21540
  VecRetKind::Unit => None,
20662
21541
  }
20663
21542
  }
@@ -20696,6 +21575,7 @@ impl Codegen {
20696
21575
  let ret_str = match sig.ret {
20697
21576
  VecRetKind::F64 => " -> f64",
20698
21577
  VecRetKind::VecF64 => " -> Vec<f64>",
21578
+ VecRetKind::Str => " -> String",
20699
21579
  VecRetKind::Unit => "",
20700
21580
  };
20701
21581
  self.writeln("#[allow(non_snake_case, unused)]");
@@ -20759,6 +21639,11 @@ impl Codegen {
20759
21639
  if sig.ret == VecRetKind::F64 {
20760
21640
  self.writeln("0.0");
20761
21641
  }
21642
+ // Same total-function guarantee for the string arm: falling off the end yields "" rather
21643
+ // than leaving the emitted fn without a value on that path.
21644
+ if sig.ret == VecRetKind::Str {
21645
+ self.writeln("String::new()");
21646
+ }
20762
21647
  self.native_vec_ret = saved_ret;
20763
21648
  self.vec_ref_params = saved_refs;
20764
21649
  self.indent -= 1;
@@ -20849,6 +21734,13 @@ impl Codegen {
20849
21734
  call
20850
21735
  }
20851
21736
  }
21737
+ VecRetKind::Str => {
21738
+ if as_value {
21739
+ format!("Value::String(({}).into())", call)
21740
+ } else {
21741
+ call
21742
+ }
21743
+ }
20852
21744
  VecRetKind::Unit => {
20853
21745
  if as_value {
20854
21746
  format!("{{ {}; Value::Null }}", call)
@@ -23329,6 +24221,60 @@ impl Codegen {
23329
24221
  /// the round-trips. Crucially, only bitwise/shift nodes recurse — an `f64` `*`/`+`/`-` node is
23330
24222
  /// a *leaf* here, so e.g. `(h * 16777619) >>> 0` keeps its `f64` multiply (the 2^53 rule: the
23331
24223
  /// product exceeds 2^53 and must round in `f64` *before* `ToUint32`, exactly as V8 does).
24224
+ /// Int-domain code for a bitwise/shift expression that is about to be consumed by something
24225
+ /// wanting an INTEGER — a `Vec<i32>` element store, an array index.
24226
+ ///
24227
+ /// `result_type_of_binop` (types.rs) types every bitwise result `F64`, which is semantically
24228
+ /// right (JS bitwise returns a Number) and a real win against boxing. But the int-domain chain
24229
+ /// is then wrapped `(… ) as f64` and an integer consumer immediately casts it back, so on a
24230
+ /// target with no FPU every `arr[i] = w | BIT` pays a soft-float round trip in each direction.
24231
+ /// Measured on GBA (tish-gba `examples/bench-grid`): `arr[j] = 7` and `arr[j] = j` cost ~1.4
24232
+ /// ticks per 1000, `arr[j] = j & 255` cost 23.2, and a bitwise INDEX cost 19.9 — ~15x, twice per
24233
+ /// cell access for anything built on a packed word.
24234
+ ///
24235
+ /// SOUNDNESS. Only the SIGNED-result ops qualify. Their value is by construction inside i32, so
24236
+ /// `(x as f64) as i32` is the identity and dropping both casts cannot change it. `>>>` is
24237
+ /// excluded deliberately: it yields a uint32 Number that can exceed `i32::MAX`, and Rust's
24238
+ /// float→int `as` SATURATES, so the f64 route and the int route genuinely disagree there.
24239
+ fn emit_int32_for_int_consumer(
24240
+ &mut self,
24241
+ e: &Expr,
24242
+ ) -> Result<Option<String>, CompileError> {
24243
+ let Expr::Binary { op, .. } = e else {
24244
+ return Ok(None);
24245
+ };
24246
+ if !matches!(
24247
+ op,
24248
+ BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor | BinOp::Shl | BinOp::Shr
24249
+ ) {
24250
+ return Ok(None);
24251
+ }
24252
+ self.emit_int32_operand(e)
24253
+ }
24254
+
24255
+ /// An operand that is provably an int32: either one `emit_int32_operand` lowers, or one the
24256
+ /// type system already calls `i32`.
24257
+ ///
24258
+ /// The second case is what `emit_int32_operand` alone misses. An `: i32` local and a `Vec<i32>`
24259
+ /// element read need no ToInt32 lowering — they ARE integers — but that function's leaf fold
24260
+ /// only recognises `F64` leaves, so it declines them and falls back to a `to_int32` wrapper.
24261
+ /// Every accumulator has one on its left-hand side.
24262
+ fn int32_or_native_i32(&mut self, e: &Expr) -> Result<Option<String>, CompileError> {
24263
+ if let Some(c) = self.emit_int32_operand(e)? {
24264
+ // Reject the generic fold's own output: if it handed back a `to_int32(..)` wrapper it
24265
+ // has not actually reached the integer domain, and using it would keep the round trip
24266
+ // while claiming to have removed it.
24267
+ if !c.contains("to_int32") {
24268
+ return Ok(Some(c));
24269
+ }
24270
+ }
24271
+ let (code, ty) = self.emit_typed_expr(e)?;
24272
+ if ty == RustType::I32 {
24273
+ return Ok(Some(code));
24274
+ }
24275
+ Ok(None)
24276
+ }
24277
+
23332
24278
  fn emit_int32_operand(&mut self, e: &Expr) -> Result<Option<String>, CompileError> {
23333
24279
  if let Expr::Binary {
23334
24280
  left, op, right, ..
@@ -23369,6 +24315,33 @@ impl Codegen {
23369
24315
  };
23370
24316
  return Ok(Some(code));
23371
24317
  }
24318
+ // ARITHMETIC in a ToInt32 context: `acc + (w & MASK)`, `i - 1`.
24319
+ //
24320
+ // `+` is not itself a ToInt32 context, so this cannot be done in general — but THIS
24321
+ // FUNCTION'S CONTRACT is that its result will be ToInt32'd by the caller, and for int32
24322
+ // operands `to_int32(a + b) == a.wrapping_add(b)` exactly, since |a + b| < 2^32 is far
24323
+ // inside f64's exact-integer range. So inside this context, and only here, the integer
24324
+ // form is equivalent.
24325
+ //
24326
+ // It has to sit ABOVE the generic leaf fold below, which would otherwise swallow the
24327
+ // whole expression and hand back `to_int32((a as f64) + (b as f64))` — technically a
24328
+ // success, and exactly the round trip this exists to remove.
24329
+ //
24330
+ // Measured on GBA (tish-gba `examples/bench-grid`): `acc = acc + (arr[i] & 255)` ran at
24331
+ // 44.4 ticks an iteration against 1.64 for the same read unmasked — a 27x penalty on
24332
+ // every packed-word structure, since `w & FIELD` is how you read a field.
24333
+ if matches!(op, BinOp::Add | BinOp::Sub) {
24334
+ let li = self.int32_or_native_i32(left)?;
24335
+ let ri = self.int32_or_native_i32(right)?;
24336
+ if let (Some(li), Some(ri)) = (li, ri) {
24337
+ let f = if matches!(op, BinOp::Add) {
24338
+ "wrapping_add"
24339
+ } else {
24340
+ "wrapping_sub"
24341
+ };
24342
+ return Ok(Some(format!("({}).{}({})", li, f, ri)));
24343
+ }
24344
+ }
23372
24345
  }
23373
24346
  // `Math.imul(a, b)` in an int32 context → the raw i32 `wrapping_mul` (no f64 excursion, no
23374
24347
  // `to_int32` round-trip on the product), when both operands are themselves int32-lowerable.
@@ -23944,45 +24917,15 @@ impl Codegen {
23944
24917
  };
23945
24918
  // #173 part 3: prove the index in-bounds BEFORE emitting it.
23946
24919
  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
- };
24920
+ // The READ index goes through the same helper as the element STORE.
24921
+ // These two arms used to be a hand-inlined copy of it that predated
24922
+ // the int-domain shortcut, so `arr[a | b]` lowered its store index
24923
+ // integrally and its read index as `((a|b) as f64) as usize` — two
24924
+ // soft-float calls on ARM7TDMI, on the read half of every packed-grid
24925
+ // access. Measured in tish-gba: a 63-cell board copy whose body is
24926
+ // `P[dc|r] = P[sc|r] & keep` cost 2,103 ticks, 33 per cell, against
24927
+ // ~1.6 for a module `i32[]` read plus write.
24928
+ let idx_usize = self.emit_index_usize(index)?;
23986
24929
  // #567: inside the wrapped block the index is pre-bound to `__bi`
23987
24930
  // (evaluated BEFORE the borrow, so an index that itself reads this cell
23988
24931
  // — `arr[arr.length-1]` — doesn't double-lock the non-reentrant cell).
@@ -24106,6 +25049,7 @@ impl Codegen {
24106
25049
  let ty = match sig.ret {
24107
25050
  VecRetKind::F64 => RustType::F64,
24108
25051
  VecRetKind::VecF64 => RustType::Vec(Box::new(RustType::F64)),
25052
+ VecRetKind::Str => RustType::String,
24109
25053
  VecRetKind::Unit => RustType::Value,
24110
25054
  };
24111
25055
  return Ok((code, ty));
@@ -24113,6 +25057,39 @@ impl Codegen {
24113
25057
  }
24114
25058
  }
24115
25059
  }
25060
+ // A user fn whose every return is PROVEN numeric (`collect_user_fn_rets`). The call
25061
+ // stays boxed — this is not a calling convention — but its RESULT stops being
25062
+ // opaque, so the surrounding arithmetic stays integral instead of dropping to the
25063
+ // generic `ops::add` and its two soft-float conversions.
25064
+ //
25065
+ // The `_ =>` arm is unreachable by construction: membership required every return
25066
+ // to be numeric. Written out rather than `unreachable!()` so a proof bug surfaces as
25067
+ // the same diagnostic every other coercion in this file produces.
25068
+ // NOT for fns that also have an M5 native form: the direct-call path below emits
25069
+ // `name_native(..)` bare, and this block reaching them first wrapped that same call
25070
+ // in `Value::Number(..)` and match-unwrapped it straight back — the boxed round
25071
+ // trip the #320 push test pins against. A proof is the fallback, never the winner.
25072
+ if !self.user_fn_ret.is_empty() {
25073
+ if let Expr::Ident { name, .. } = callee.as_ref() {
25074
+ if self.native_fns.contains(name.as_ref()) {
25075
+ // fall through to the native direct-call path
25076
+ } else if let Some(ty) = self.user_fn_ret.get(name.as_ref()).cloned() {
25077
+ let boxed = self.emit_expr(expr)?;
25078
+ let conv = if ty == RustType::I32 {
25079
+ "tishlang_runtime::to_int32(*n)"
25080
+ } else {
25081
+ "*n"
25082
+ };
25083
+ return Ok((
25084
+ format!(
25085
+ "match &{} {{ Value::Number(n) => {}, _ => panic!(\"expected number\") }}",
25086
+ boxed, conv
25087
+ ),
25088
+ ty,
25089
+ ));
25090
+ }
25091
+ }
25092
+ }
24116
25093
  // Typed extern (a `cargo:` import with a declared native signature) used INSIDE a
24117
25094
  // native/typed expression: `entity_x(e) + 16`, `cvar(e, k) + 1`. Emit the direct
24118
25095
  // `crate::name_typed(<coerced args>)` and report its NATIVE return type, so the
@@ -24145,16 +25122,24 @@ impl Codegen {
24145
25122
  // M5: direct call to an eligible native fn -> `name_native(<native args>)`.
24146
25123
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
24147
25124
  if self.native_fns.contains(fname.as_ref()) {
25125
+ // The ABI this callee actually has. Passing every argument through
25126
+ // `coerce_native_arg` — as the boxed-context site already does — is what
25127
+ // keeps a NATIVE argument of the other width from being handed over raw:
25128
+ // `manhattan(c, r, tac_unit_col(id), tac_unit_row(id))` types its extern
25129
+ // args I32 and the callee takes f64, which emitted `manhattan_native(.., i32)`
25130
+ // and rustc rejected it with E0308. Value args keep their existing
25131
+ // from_value_expr path (coerce_native_arg's first arm IS that path).
25132
+ let want = if self.native_fns_i32.contains(fname.as_ref()) {
25133
+ RustType::I32
25134
+ } else {
25135
+ RustType::F64
25136
+ };
24148
25137
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
24149
25138
  let mut ok = true;
24150
25139
  for a in args {
24151
25140
  if let CallArg::Expr(e) = a {
24152
25141
  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
- });
25142
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
24158
25143
  } else {
24159
25144
  ok = false;
24160
25145
  break;
@@ -24170,13 +25155,15 @@ impl Codegen {
24170
25155
  RustType::F64,
24171
25156
  ));
24172
25157
  }
25158
+ // …and report the ABI's OWN return type, for the same reason: an
25159
+ // i32-ABI callee reported as F64 mistypes every consumer downstream.
24173
25160
  return Ok((
24174
25161
  format!(
24175
25162
  "{}({})",
24176
25163
  self.native_call_target(fname.as_ref()),
24177
25164
  argc.join(", ")
24178
25165
  ),
24179
- RustType::F64,
25166
+ want,
24180
25167
  ));
24181
25168
  }
24182
25169
  }