@tishlang/tish-format 3.7.1 → 3.9.1

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.
@@ -1122,6 +1122,24 @@ pub(crate) struct Codegen {
1122
1122
  /// Variables currently wrapped in Rc<RefCell<Value>> for mutable capture in closures
1123
1123
  /// These need special handling: reads via .borrow().clone(), writes via *var.borrow_mut()
1124
1124
  refcell_wrapped_vars: std::collections::HashSet<String>,
1125
+ /// #684: module bindings whose `VmRef` cell is declared UP FRONT, at the top of `run()`, because
1126
+ /// a closure emitted ABOVE the `let` writes (or reads) them. The `let` then assigns into the
1127
+ /// pre-declared cell instead of creating one. See `collect_forward_referenced_cell_bindings`.
1128
+ predeclared_cell_bindings: std::collections::HashSet<String>,
1129
+ /// #682: is the module-statics promotion active for this compile? Decided once in
1130
+ /// `emit_program` from the target, the linked features and `TISH_MODULE_STATICS`.
1131
+ module_statics_on: bool,
1132
+ /// #682: top-level function names whose boxed closure lives in a module static
1133
+ /// (`__TISH_GF_<name>`) rather than a `run()` local. Reads resolve to the static, so nothing
1134
+ /// captures them.
1135
+ module_fn_statics: std::collections::HashSet<String>,
1136
+ /// #682: module-level DATA bindings whose `VmRef` cell is held by a module static
1137
+ /// (`__TISH_GV_<name>`) instead of being threaded into every closure as a `_cell` clone.
1138
+ module_var_statics: std::collections::HashSet<String>,
1139
+ /// #682: `(rust type, default value)` for each promoted binding's cell, recorded when its
1140
+ /// `let` is emitted. The accessor `fn` naming both is emitted after `run()`, so the type never
1141
+ /// has to be predicted ahead of inference.
1142
+ module_var_static_types: std::collections::HashMap<String, (String, String)>,
1125
1143
  /// #176 (default-on via `native_opts_enabled`): top-level `let` bindings lowered to `thread_local Cell<f64>`
1126
1144
  /// (`G_NAME`) when every use is a numeric read or a whole-binding numeric assign (fasta `seed`).
1127
1145
  native_numeric_globals: std::collections::HashMap<String, f64>,
@@ -1166,6 +1184,12 @@ pub(crate) struct Codegen {
1166
1184
  /// free `fn f_native(f64,..)->f64` (all params `: number`, returns `number`, native-safe
1167
1185
  /// body). Direct calls to these route to the native fn, bypassing the boxed `value_call`.
1168
1186
  native_fns: std::collections::HashSet<String>,
1187
+ /// Declared param types of each `native_fns` member, so a call site knows which argument slots
1188
+ /// are `&TishStruct_…` (passed by reference) rather than a coerced numeric.
1189
+ native_fn_param_types: std::collections::HashMap<String, Vec<RustType>>,
1190
+ /// Names ever used AS A VALUE (see [`Self::collect_value_used_expr`]). A `native_fns` member
1191
+ /// absent from this set needs no boxed `Value::native` wrapper in `run()`.
1192
+ value_used_names: std::collections::HashSet<String>,
1169
1193
  /// Set while emitting the body of an i32-ABI native fn, so `return` coerces to `i32`.
1170
1194
  native_fn_abi_i32: bool,
1171
1195
  /// The subset of `native_fns` emitted with an `fn(i32, ..) -> i32` signature instead of the
@@ -1437,6 +1461,11 @@ impl Codegen {
1437
1461
  outer_params_stack: Vec::new(),
1438
1462
  outer_vars_stack: vec![Vec::new()], // Start with module-level scope
1439
1463
  refcell_wrapped_vars: std::collections::HashSet::new(),
1464
+ predeclared_cell_bindings: std::collections::HashSet::new(),
1465
+ module_statics_on: false,
1466
+ module_fn_statics: std::collections::HashSet::new(),
1467
+ module_var_statics: std::collections::HashSet::new(),
1468
+ module_var_static_types: std::collections::HashMap::new(),
1440
1469
  native_numeric_globals: std::collections::HashMap::new(),
1441
1470
  module_const_f64_arrays: std::collections::HashMap::new(),
1442
1471
  module_const_int_statics: std::collections::HashSet::new(),
@@ -1447,6 +1476,8 @@ impl Codegen {
1447
1476
  collection_instance_locals: std::collections::HashSet::new(),
1448
1477
  hoisted_string_chars: std::collections::HashMap::new(),
1449
1478
  native_fns: std::collections::HashSet::new(),
1479
+ value_used_names: std::collections::HashSet::new(),
1480
+ native_fn_param_types: std::collections::HashMap::new(),
1450
1481
  native_fns_i32: std::collections::HashSet::new(),
1451
1482
  native_fn_abi_i32: false,
1452
1483
  native_fn_body_emit: false,
@@ -3097,7 +3128,7 @@ impl Codegen {
3097
3128
  // no_std GBA ROM: alloc-based, Arc aliases to Rc in the facade. The
3098
3129
  // prelude `use tishlang_runtime::{…}` below is unchanged — those names
3099
3130
  // are re-exported by the tishlang_runtime_gba facade.
3100
- self.write("#![no_std]\n#![no_main]\n#![allow(unused, non_snake_case)]\n\n");
3131
+ self.write("#![no_std]\n#![no_main]\n#![allow(unused, non_snake_case, non_upper_case_globals)]\n\n");
3101
3132
  self.write("extern crate alloc;\n");
3102
3133
  self.write("use core::cell::RefCell;\n");
3103
3134
  self.write("use alloc::rc::Rc;\n");
@@ -3109,7 +3140,7 @@ impl Codegen {
3109
3140
  // f64 transcendentals for inline math (`Math.sqrt(x)` → `x.sqrt()`).
3110
3141
  self.write("use tishlang_runtime::FloatExt;\n");
3111
3142
  } else {
3112
- self.write("#![allow(unused, non_snake_case)]\n\n");
3143
+ self.write("#![allow(unused, non_snake_case, non_upper_case_globals)]\n\n");
3113
3144
  self.write("use std::cell::RefCell;\n");
3114
3145
  self.write("use std::rc::Rc;\n");
3115
3146
  self.write("use std::sync::Arc;\n");
@@ -3319,11 +3350,88 @@ impl Codegen {
3319
3350
  let mut global_names: std::collections::HashSet<String> =
3320
3351
  self.native_numeric_globals.keys().cloned().collect();
3321
3352
  global_names.extend(self.module_const_f64_arrays.keys().cloned());
3353
+ // Publish the all-numeric externs before the proof runs, so a body calling into an
3354
+ // extension crate is not rejected for the call alone. `is_numeric` covers f64, i32 and
3355
+ // the narrow ints; anything else (string, array, struct) would need a boxing boundary
3356
+ // inside a lowered fn, which defeats the purpose.
3357
+ let numeric_externs: std::collections::HashSet<String> = self
3358
+ .extern_fns
3359
+ .iter()
3360
+ .filter(|(_, sigs)| {
3361
+ // ⚠️ NOT `is_numeric()` ALONE — that matches F64 only (types.rs), while every GBA
3362
+ // `declare fn f(row: i32): i32` is `I32`. Filtering on it rejected all 64 declares
3363
+ // in ffta-hud (probed: numeric_externs=0) and made this whole relaxation inert.
3364
+ // Same predicate the rest of the proof uses: f64, i32, or a narrow storage int.
3365
+ //
3366
+ // Widening this REQUIRES the f64 return coercion in `emit_native_fn_body` — an
3367
+ // i32 extern returned from an f64-ABI fn is otherwise emitted raw (E0308).
3368
+ let ok = |t: &RustType| t.is_numeric() || t.is_integer_scalar();
3369
+ !sigs.is_empty()
3370
+ && sigs.iter().all(|s| ok(&s.ret) && s.params.iter().all(ok))
3371
+ })
3372
+ .map(|(n, _)| n.clone())
3373
+ .collect();
3374
+ if std::env::var("TISH_PROBE_EXTERNS").as_deref() == Ok("1") {
3375
+ let mut names: Vec<&String> = numeric_externs.iter().collect();
3376
+ names.sort();
3377
+ eprintln!(
3378
+ "[probe] extern_fns={} numeric_externs={} sample={:?}",
3379
+ self.extern_fns.len(),
3380
+ numeric_externs.len(),
3381
+ names.iter().take(8).collect::<Vec<_>>()
3382
+ );
3383
+ }
3384
+ Self::numeric_externs_with(|s| {
3385
+ s.clear();
3386
+ // `TISH_NO_EXTERN_LOWER=1` publishes an EMPTY set, which reverts this build to the
3387
+ // pre-extern-admission behaviour without rebuilding the compiler. It exists so a ROM
3388
+ // can be A/B'd against one binary: two 5-minute game builds instead of two toolchain
3389
+ // rebuilds plus two game builds, and no edit to a shared checkout between them.
3390
+ if std::env::var("TISH_NO_EXTERN_LOWER").as_deref() != Ok("1") {
3391
+ s.extend(numeric_externs.iter().cloned());
3392
+ }
3393
+ });
3322
3394
  self.native_fns = Self::collect_native_fns(
3323
3395
  &program.statements,
3324
3396
  &global_names,
3325
3397
  &native_vec_names,
3398
+ &self.type_aliases,
3326
3399
  );
3400
+ self.native_fn_param_types = program
3401
+ .statements
3402
+ .iter()
3403
+ .filter_map(|st| match st {
3404
+ Statement::FunDecl { name, params, .. }
3405
+ if self.native_fns.contains(name.as_ref()) =>
3406
+ {
3407
+ let tys = params
3408
+ .iter()
3409
+ .filter_map(|p| match p {
3410
+ FunParam::Simple(tp) => Some(
3411
+ tp.type_ann
3412
+ .as_ref()
3413
+ .map(|a| {
3414
+ RustType::from_annotation_with_aliases(
3415
+ a,
3416
+ &self.type_aliases,
3417
+ )
3418
+ })
3419
+ .unwrap_or(RustType::F64),
3420
+ ),
3421
+ _ => None,
3422
+ })
3423
+ .collect();
3424
+ Some((name.to_string(), tys))
3425
+ }
3426
+ _ => None,
3427
+ })
3428
+ .collect();
3429
+ // Which names are ever used as a VALUE rather than only called. Drives the
3430
+ // boxed-wrapper elision at `Statement::FunDecl` — see `collect_value_used_expr`.
3431
+ self.value_used_names.clear();
3432
+ for s in &program.statements {
3433
+ Self::collect_value_used_stmt(s, &mut self.value_used_names);
3434
+ }
3327
3435
  // Which of the survivors carry the all-`i32` signature. Computed AFTER the fixpoint,
3328
3436
  // so a function the proof rejected never reaches the i32 ABI either.
3329
3437
  self.native_fns_i32 = program
@@ -3332,7 +3440,23 @@ impl Codegen {
3332
3440
  .filter_map(|s| match s {
3333
3441
  Statement::FunDecl { name, params, return_type, .. }
3334
3442
  if self.native_fns.contains(name.as_ref())
3335
- && Self::fn_sig_all_i32(params, return_type) =>
3443
+ && (Self::fn_sig_all_i32(params, return_type)
3444
+ // A struct-param fn: its struct slot is typed on its own, so
3445
+ // `fn_sig_all_i32` can never hold. Take the i32 return ABI when the
3446
+ // return is `: i32` and every NON-struct param is `: i32` too —
3447
+ // otherwise the body computes i32 while the signature says f64.
3448
+ // A struct param qualifies on ONE numeric field, matching the
3449
+ // candidacy gate. If this still demanded all-numeric, a mixed-struct
3450
+ // fn would lower its body to i32 arithmetic and then be handed the
3451
+ // f64 ABI — computing one thing and declaring another.
3452
+ || (return_type.as_ref().is_some_and(Self::ann_is_i32)
3453
+ && params.iter().any(|p| matches!(p, FunParam::Simple(tp)
3454
+ if tp.type_ann.as_ref().is_some_and(|a|
3455
+ !Self::struct_numeric_fields(a, &self.type_aliases).is_empty())))
3456
+ && params.iter().all(|p| matches!(p, FunParam::Simple(tp)
3457
+ if tp.type_ann.as_ref().is_some_and(|a|
3458
+ Self::ann_is_i32(a)
3459
+ || !Self::struct_numeric_fields(a, &self.type_aliases).is_empty()))))) =>
3336
3460
  {
3337
3461
  Some(name.to_string())
3338
3462
  }
@@ -3347,9 +3471,11 @@ impl Codegen {
3347
3471
  Self::collect_native_lcg_fns(&program.statements, &self.native_fns);
3348
3472
  // #381 — fns on a call-graph cycle get guarded rotation copies (default ON;
3349
3473
  // TISH_NATIVE_RECUR_GUARD=0 restores the plain unguarded emission).
3350
- self.native_recur_fns = if crate::native_recur_guard_enabled()
3351
- && self.emit_mode != crate::NativeEmitMode::Gba
3352
- {
3474
+ // #655: GBA was excluded because the no_std runtime had no stack-pressure probe.
3475
+ // `tishlang_runtime_gba` now derives a real floor from the link map (`__iwram_end`),
3476
+ // and it matters MORE here than on the host: 32 KB of IWRAM, no MMU, no guard page,
3477
+ // so an unguarded overflow silently overwrites agb's live data instead of faulting.
3478
+ self.native_recur_fns = if crate::native_recur_guard_enabled() {
3353
3479
  Self::compute_native_recur_fns(&program.statements, &self.native_fns)
3354
3480
  } else {
3355
3481
  std::collections::HashMap::new()
@@ -3429,6 +3555,46 @@ impl Codegen {
3429
3555
  }
3430
3556
  self.nonneg_locals = self.collect_nonneg_locals(&program.statements);
3431
3557
  self.shift_half_of = Self::collect_shift_half_of(&program.statements);
3558
+ // #682 — MODULE FUNCTIONS GET A TOP-LEVEL HOME. Every top-level fn currently contributes a
3559
+ // `VmRef` cell, a boxed closure and one `_ref` clone per capturing sibling to `run()`'s
3560
+ // single frame; on a real GBA game that frame is 27–30 KB against a 32.5 KB IWRAM stack and
3561
+ // grows with program size. Parking the closure in a static takes all three out of the frame.
3562
+ //
3563
+ // OFF WHENEVER TISH CODE CAN RUN ON ANOTHER THREAD. The host storage is `thread_local!`
3564
+ // (forced by `Value`: `send-values` is off unless `http` is on, so `VmRef` is
3565
+ // `Rc<RefCell<_>>` and a plain `static` would not compile — the same constraint `RustLib`
3566
+ // documents). A `serve` handler runs on a tokio worker, which would read that worker's
3567
+ // *uninitialised* copy. `http`/`ws` are the only ways to get there, and the GBA — the target
3568
+ // this exists for — has neither.
3569
+ let threads_possible = self.program_may_call_tish_off_thread(program);
3570
+ self.module_statics_on = crate::module_statics_flag()
3571
+ .unwrap_or(self.emit_mode == crate::NativeEmitMode::Gba || !threads_possible);
3572
+ // `RustLib` publishes its exports by naming the `run()` local, so its bindings must stay
3573
+ // locals; the whole point there is that the frame outlives the call anyway.
3574
+ if self.emit_mode == crate::NativeEmitMode::RustLib {
3575
+ self.module_statics_on = false;
3576
+ }
3577
+ if crate::module_statics_flag().unwrap_or(true) {
3578
+ // The shim that keeps the builtin preamble's construction temporaries out of `run()`'s
3579
+ // frame. Generic over the closure's return type so the ~40 builtin types never have to
3580
+ // be spelled out; `#[inline(never)]` is the whole point — inlined, the temporaries come
3581
+ // straight back.
3582
+ self.writeln(
3583
+ "#[inline(never)] fn __tish_no_inline<T>(f: impl FnOnce() -> T) -> T { f() }",
3584
+ );
3585
+ self.writeln("");
3586
+ }
3587
+ if self.module_statics_on {
3588
+ self.module_fn_statics = self
3589
+ .prescan_function_decls(&program.statements)
3590
+ .into_iter()
3591
+ .filter(|n| {
3592
+ !(self.aggregate_alias.is_some() && self.aggregate_fns.contains_key(n))
3593
+ })
3594
+ .collect();
3595
+ self.emit_module_fn_statics()?;
3596
+ }
3597
+
3432
3598
  // Gba mode is sync in P2 (real async lowering is P5); `std::error::Error` in
3433
3599
  // the signature is rewritten to `core::error::Error` by the no_std post-pass.
3434
3600
  if self.is_async && self.emit_mode != crate::NativeEmitMode::Gba {
@@ -3440,6 +3606,29 @@ impl Codegen {
3440
3606
  }
3441
3607
  self.indent += 1;
3442
3608
 
3609
+ // #682 — THE BUILTIN PREAMBLE IS BUILT IN ITS OWN FRAME.
3610
+ //
3611
+ // `console`, `Math`, `JSON`, `Array`, the nine TypedArray constructors and the rest are
3612
+ // ~40 bindings, and each is built from a `Value::object(ObjectMap::from([ … ]))` whose pair
3613
+ // array is a large STACK temporary. Measured on a hello-world, that preamble alone is
3614
+ // 4,192 B of `run()`'s frame before a line of the program runs — 13% of the GBA's whole
3615
+ // stack, for a fixed set of objects that never change.
3616
+ //
3617
+ // The bindings themselves are cheap (one `Value` each); it is the construction that is not.
3618
+ // So the whole preamble is emitted into a closure handed to a `#[inline(never)]` shim: the
3619
+ // temporaries live and die in that call's frame, and `run()` receives only the values.
3620
+ // The shim is generic over the closure's return type, so nothing here has to spell out the
3621
+ // ~40 types (`document` is a `VmRef<Value>`, the rest are `Value`s).
3622
+ // Not gated on `module_statics_on`: a frame boundary has none of that switch's hazards —
3623
+ // no per-thread storage, nothing published by name — so every target gets it. The kill
3624
+ // switch still turns it off, so one flag restores the whole pre-#682 emission.
3625
+ let preamble_buffered = crate::module_statics_flag().unwrap_or(true);
3626
+ let saved_output = if preamble_buffered {
3627
+ Some(std::mem::take(&mut self.output))
3628
+ } else {
3629
+ None
3630
+ };
3631
+
3443
3632
  // Before anything else: one namespace object per native module, shared by all of its imports.
3444
3633
  self.emit_native_namespace_preamble();
3445
3634
 
@@ -3767,6 +3956,11 @@ impl Codegen {
3767
3956
  );
3768
3957
  }
3769
3958
 
3959
+ if let Some(saved) = saved_output {
3960
+ let preamble = std::mem::replace(&mut self.output, saved);
3961
+ self.emit_buffered_preamble(&preamble);
3962
+ }
3963
+
3770
3964
  // Polars, Egui etc. are emitted via VarDecl from import { X } from 'tish:...'
3771
3965
 
3772
3966
  // Pre-scan for top-level function declarations and create cells (for mutual recursion)
@@ -3778,6 +3972,10 @@ impl Codegen {
3778
3972
  if self.aggregate_alias.is_some() && self.aggregate_fns.contains_key(func_name) {
3779
3973
  continue;
3780
3974
  }
3975
+ // #682: promoted module fns live in `GF_NAME`, so they need no `run()` cell at all.
3976
+ if self.is_module_fn_static(func_name) {
3977
+ continue;
3978
+ }
3781
3979
  let escaped = Self::escape_ident(func_name);
3782
3980
  self.writeln(&format!(
3783
3981
  "let {}_cell: VmRef<Value> = VmRef::new(Value::Null);",
@@ -3819,7 +4017,56 @@ impl Codegen {
3819
4017
  // above it establish. A literal cannot observe or disturb anything, so moving it is not
3820
4018
  // visible to the program.
3821
4019
  let reordered = Self::hoist_forward_referenced_module_bindings(&program.statements);
3822
- self.emit_statements_with_folds(reordered.as_deref().unwrap_or(&program.statements))?;
4020
+ let module_stmts = reordered.as_deref().unwrap_or(&program.statements);
4021
+ // #684 — the residue the hoist above cannot move: a module binding that is a capture cell
4022
+ // AND is captured by a closure emitted before its `let`. Declaring the cell here, ahead of
4023
+ // every statement, is what makes the capture resolvable in both directions; the `let` below
4024
+ // assigns into it (see the `predeclared_cell_bindings` arm in `Statement::VarDecl`).
4025
+ // #682 — module DATA bindings whose cell moves into a static. Computed here because it
4026
+ // needs `refcell_wrapped_vars`; the static ITEMS are emitted after `run()` (Rust items are
4027
+ // order-independent, and this is the first point where the set is known).
4028
+ if self.module_statics_on {
4029
+ self.module_var_statics =
4030
+ self.collect_module_var_statics(module_stmts, &self.refcell_wrapped_vars);
4031
+ for name in self.module_var_statics.clone() {
4032
+ // The handle is a `VmRef` local exactly as before, so every read/write site is
4033
+ // unchanged — it just comes out of the static instead of down a capture chain.
4034
+ self.writeln(&format!(
4035
+ "let {} = {};",
4036
+ Self::escape_ident(&name),
4037
+ Self::module_var_static_handle(&name)
4038
+ ));
4039
+ self.rc_cell_storage_define(&name);
4040
+ if let Some(scope) = self.outer_vars_stack.last_mut() {
4041
+ scope.push(name.clone());
4042
+ }
4043
+ }
4044
+ }
4045
+ self.predeclared_cell_bindings =
4046
+ Self::collect_forward_referenced_cell_bindings(module_stmts, &self.refcell_wrapped_vars);
4047
+ // A promoted binding already has its cell up front, from the static.
4048
+ self.predeclared_cell_bindings
4049
+ .retain(|n| !self.module_var_statics.contains(n));
4050
+ self.predeclared_cell_bindings
4051
+ .retain(|n| !self.module_const_f64_arrays.contains_key(n));
4052
+ self.predeclared_cell_bindings
4053
+ .retain(|n| !self.module_const_f64_aliases.contains_key(n));
4054
+ let mut predeclared: Vec<String> = self.predeclared_cell_bindings.iter().cloned().collect();
4055
+ predeclared.sort();
4056
+ for name in &predeclared {
4057
+ self.writeln(&format!(
4058
+ "let {}: VmRef<Value> = VmRef::new(Value::Null);",
4059
+ Self::escape_ident(name)
4060
+ ));
4061
+ // The cell holds a boxed `Value`: the binding's type is not known until its initialiser
4062
+ // is emitted, and a forward capture needs the storage to exist before then.
4063
+ self.type_context.define(name, RustType::Value);
4064
+ self.rc_cell_storage_define(name);
4065
+ if let Some(scope) = self.outer_vars_stack.last_mut() {
4066
+ scope.push(name.clone());
4067
+ }
4068
+ }
4069
+ self.emit_statements_with_folds(module_stmts)?;
3823
4070
  if self.is_async {
3824
4071
  self.async_context_stack.pop();
3825
4072
  }
@@ -3858,6 +4105,13 @@ impl Codegen {
3858
4105
  self.writeln("Ok(())");
3859
4106
  self.indent -= 1;
3860
4107
  self.writeln("}");
4108
+ // #682 — the module-data statics. Emitted here, after `run()`, because the eligible set is
4109
+ // only known once `run()`'s capture prepass has run; Rust items are order-independent, so
4110
+ // the reads above resolve to them regardless.
4111
+ if self.module_statics_on {
4112
+ self.writeln("");
4113
+ self.emit_module_var_statics()?;
4114
+ }
3861
4115
  if self.emit_mode == crate::NativeEmitMode::EmbeddedLib {
3862
4116
  self.writeln("");
3863
4117
  self.writeln("#[no_mangle]");
@@ -4234,6 +4488,10 @@ impl Codegen {
4234
4488
  val_code
4235
4489
  } else if val_ty == RustType::Value {
4236
4490
  RustType::F64.from_value_expr(&val_code)
4491
+ } else if val_ty.is_integer_scalar() {
4492
+ // An i32-typed value into an f64-backed global. Widening is exact; emitted
4493
+ // raw it is E0308.
4494
+ format!("(({}) as f64)", val_code)
4237
4495
  } else {
4238
4496
  val_code
4239
4497
  };
@@ -4350,6 +4608,17 @@ impl Codegen {
4350
4608
  // `fixed` local must be lifted to `Fixed` — the RHS emitter reports numeric
4351
4609
  // literals as F64, so without this a `fixed` reassignment mistypes (E0308).
4352
4610
  format!("tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)", val_code)
4611
+ } else if rust_type == RustType::F64 && val_ty.is_integer_scalar() {
4612
+ // ⚠️ AN i32 VALUE INTO AN f64 LOCAL, IN STATEMENT POSITION. The COMPARISON
4613
+ // emitter already widens (`n > ((MAX_UNITS) as f64)`), so
4614
+ //
4615
+ // if (n > MAX_UNITS) { n = MAX_UNITS } packages/clansave
4616
+ //
4617
+ // compiled its test and rejected its assignment: `n = MAX_UNITS;`, expected
4618
+ // f64 found i32. Needs a native local AND an `: i32`-annotated global on the
4619
+ // right, which is why it stayed hidden until a game imported clansave beside
4620
+ // the rest of its packages. Widening is exact.
4621
+ format!("(({}) as f64)", val_code)
4353
4622
  } else {
4354
4623
  val_code
4355
4624
  };
@@ -6098,6 +6367,25 @@ impl Codegen {
6098
6367
  rust_type = RustType::Vec(Box::new(RustType::I32));
6099
6368
  }
6100
6369
 
6370
+ // #684: the cell for this module binding was declared at the top of `run()` because a
6371
+ // closure above the `let` captures it. Assign into that cell rather than shadowing it
6372
+ // with a fresh `let` — a second `let` would leave the earlier closures pointing at a
6373
+ // cell nothing ever writes.
6374
+ if self.predeclared_cell_bindings.contains(name.as_ref())
6375
+ && self.outer_vars_stack.len() == 1
6376
+ {
6377
+ self.type_context.define(name.as_ref(), RustType::Value);
6378
+ if let Some(init_e) = init.as_ref() {
6379
+ let val = self.emit_expr(init_e)?;
6380
+ self.writeln(&format!(
6381
+ "*{}.borrow_mut() = ({}).clone();",
6382
+ Self::escape_ident(name.as_ref()),
6383
+ val
6384
+ ));
6385
+ }
6386
+ return Ok(());
6387
+ }
6388
+
6101
6389
  // #176: top-level numeric globals live in a thread_local `Cell<f64>` — no local slot.
6102
6390
  if self.native_numeric_globals.contains_key(name.as_ref())
6103
6391
  && self.outer_vars_stack.len() == 1
@@ -6242,7 +6530,20 @@ impl Codegen {
6242
6530
  Some(e) => self.emit_native_expr(e, &rust_type)?,
6243
6531
  None => rust_type.default_value(),
6244
6532
  };
6245
- if self.refcell_wrapped_vars.contains(name.as_ref()) {
6533
+ if self.is_module_var_static(name.as_ref())
6534
+ && self.outer_vars_stack.len() == 1
6535
+ {
6536
+ // #682: the cell is the static's; the `let` writes THROUGH the handle
6537
+ // already bound at the top of `run()`. Its type is recorded here — the
6538
+ // point where inference has finally settled it — for the accessor emitted
6539
+ // after `run()`.
6540
+ self.module_var_static_types.insert(
6541
+ name.to_string(),
6542
+ (rust_type.to_rust_type_str(), rust_type.default_value()),
6543
+ );
6544
+ self.writeln(&format!("*{}.borrow_mut() = {};", escaped_name, expr_str));
6545
+ self.rc_cell_storage_define(name.as_ref());
6546
+ } else if self.refcell_wrapped_vars.contains(name.as_ref()) {
6246
6547
  // Closure-mutated: same Rc<RefCell<T>> pattern as Value (assignments use borrow_mut)
6247
6548
  self.writeln(&format!("let {} = VmRef::new({});", escaped_name, expr_str));
6248
6549
  self.rc_cell_storage_define(name.as_ref());
@@ -6266,12 +6567,23 @@ impl Codegen {
6266
6567
  None => ("Value::Null".to_string(), false),
6267
6568
  };
6268
6569
  // Vars that are mutated by nested closures must be RefCell from the start
6269
- if self.refcell_wrapped_vars.contains(name.as_ref()) {
6270
- let init_val = if clone_needed {
6271
- format!("({}).clone()", expr_str)
6272
- } else {
6273
- expr_str.to_string()
6274
- };
6570
+ let init_val = if clone_needed {
6571
+ format!("({}).clone()", expr_str)
6572
+ } else {
6573
+ expr_str.to_string()
6574
+ };
6575
+ if self.is_module_var_static(name.as_ref())
6576
+ && self.outer_vars_stack.len() == 1
6577
+ {
6578
+ // #682 — see the native branch above: the cell belongs to the static, and
6579
+ // the `let` writes through the handle bound at the top of `run()`.
6580
+ self.module_var_static_types.insert(
6581
+ name.to_string(),
6582
+ ("Value".to_string(), "Value::Null".to_string()),
6583
+ );
6584
+ self.writeln(&format!("*{}.borrow_mut() = {};", escaped_name, init_val));
6585
+ self.rc_cell_storage_define(name.as_ref());
6586
+ } else if self.refcell_wrapped_vars.contains(name.as_ref()) {
6275
6587
  self.writeln(&format!("let {} = VmRef::new({});", escaped_name, init_val));
6276
6588
  self.rc_cell_storage_define(name.as_ref());
6277
6589
  } else if clone_needed {
@@ -7037,6 +7349,34 @@ impl Codegen {
7037
7349
  {
7038
7350
  return Ok(());
7039
7351
  }
7352
+ // Same idea, wider: this fn already has a native lowering (`<name>_native`) that
7353
+ // every direct call routes to, and nothing in the program uses the NAME as a value
7354
+ // — so the boxed `Value::native` wrapper is dead weight. It is not free weight: the
7355
+ // wrapper and its cell are permanent slots in the enclosing frame, and for module
7356
+ // fns that frame is `run()`, which on GBA holds every one of them at once against a
7357
+ // 29 KB IWRAM stack. Skipping it is what makes "type your functions" actually pay.
7358
+ // ⚠️ AND ONLY WHEN EVERY CALL SITE CAN ACTUALLY BE DIRECT. A fn taking a STRUCT
7359
+ // still falls back to `value_call` when the argument is a plain boxed local --
7360
+ // `area(r)` in tests/core/struct_return does exactly that while `area(makeRect(..))`
7361
+ // lowers, so one call site needed the wrapper the other made look dead (E0425).
7362
+ // Restricting the elision to all-scalar parameter lists keeps the win for the
7363
+ // numeric fns it was written for and stops guessing about the rest.
7364
+ let all_scalar_params = self
7365
+ .native_fn_param_types
7366
+ .get(name.as_ref())
7367
+ .map(|ps| {
7368
+ ps.iter().all(|p| {
7369
+ matches!(p, RustType::F64 | RustType::I32 | RustType::Bool)
7370
+ || p.is_narrow_int()
7371
+ })
7372
+ })
7373
+ .unwrap_or(false);
7374
+ if self.native_fns.contains(name.as_ref())
7375
+ && !self.value_used_names.contains(name.as_ref())
7376
+ && all_scalar_params
7377
+ {
7378
+ return Ok(());
7379
+ }
7040
7380
  // Use Rc<RefCell<>> pattern to allow recursive function calls
7041
7381
  // The function can reference itself through the cell
7042
7382
  let name_raw = name.as_ref();
@@ -7083,6 +7423,10 @@ impl Codegen {
7083
7423
  && !param_names.contains(name)
7084
7424
  && !local_var_names.contains(name)
7085
7425
  })
7426
+ // #682: a promoted module binding is reachable from its static at any depth,
7427
+ // so it is not captured — which is what removes the `_cell` clone this closure
7428
+ // would otherwise add to the ENCLOSING frame (for a module fn, `run()`'s).
7429
+ .filter(|name| !self.is_module_var_static(name))
7086
7430
  .filter(|name| {
7087
7431
  ![
7088
7432
  "Boolean",
@@ -7144,7 +7488,17 @@ impl Codegen {
7144
7488
  }
7145
7489
  }
7146
7490
 
7147
- self.writeln(&format!("let {} = {{", name_str));
7491
+ // #682: a promoted module fn is BUILT INTO ITS STATIC, so neither the closure nor
7492
+ // a `let` for it occupies a slot in `run()`'s frame.
7493
+ let to_static = self.is_module_fn_static(name_raw);
7494
+ if to_static {
7495
+ self.writeln(&format!(
7496
+ "{}.with(|c| *c.borrow_mut() = {{",
7497
+ Self::module_fn_static_name(name_raw)
7498
+ ));
7499
+ } else {
7500
+ self.writeln(&format!("let {} = {{", name_str));
7501
+ }
7148
7502
  self.indent += 1;
7149
7503
  // Clone RefCell for outer vars so closure can capture
7150
7504
  for outer_var in &outer_vars {
@@ -7155,7 +7509,9 @@ impl Codegen {
7155
7509
  ));
7156
7510
  }
7157
7511
  // Clone the cell so the closure can reference the function recursively
7158
- let needs_self_ref = referenced.contains(name_raw);
7512
+ // A promoted fn reaches itself through its static; no `_ref` clone, and so no
7513
+ // extra slot in the enclosing frame either.
7514
+ let needs_self_ref = referenced.contains(name_raw) && !to_static;
7159
7515
  if needs_self_ref {
7160
7516
  self.writeln(&format!(
7161
7517
  "let {}_ref = {}_cell.clone();",
@@ -7169,7 +7525,12 @@ impl Codegen {
7169
7525
  .map(|scope| {
7170
7526
  scope
7171
7527
  .iter()
7172
- .filter(|s| s.as_str() != name_raw && referenced.contains(s.as_str()))
7528
+ .filter(|s| {
7529
+ s.as_str() != name_raw
7530
+ && referenced.contains(s.as_str())
7531
+ // #682: a sibling in a static is read from the static.
7532
+ && !self.is_module_fn_static(s.as_str())
7533
+ })
7173
7534
  .cloned()
7174
7535
  .collect()
7175
7536
  })
@@ -7205,6 +7566,7 @@ impl Codegen {
7205
7566
  for f in scope {
7206
7567
  if f.as_str() != name_raw
7207
7568
  && referenced.contains(f.as_str())
7569
+ && !self.is_module_fn_static(f.as_str())
7208
7570
  && !sibling_fns.iter().any(|s| s == f)
7209
7571
  && !outer_vars.iter().any(|v| v == f)
7210
7572
  && !outer_params.iter().any(|p| p == f)
@@ -7279,7 +7641,10 @@ impl Codegen {
7279
7641
  "NaN",
7280
7642
  "serve",
7281
7643
  ] {
7282
- if referenced.contains(*builtin) {
7644
+ // A user `function serve(...)` (etc.) SHADOWS the builtin: it lowers to a
7645
+ // `__TISH_GF_*` static, so there is no `serve` local to clone here and the
7646
+ // emitted prelude would be E0425. The call sites already read the static.
7647
+ if referenced.contains(*builtin) && !self.is_module_fn_static(builtin) {
7283
7648
  self.writeln(&format!("let {} = {}.clone();", builtin, builtin));
7284
7649
  }
7285
7650
  }
@@ -7320,6 +7685,29 @@ impl Codegen {
7320
7685
  if crate::native_recur_guard_enabled() {
7321
7686
  self.writeln("let Some(_tish_depth) = tishlang_runtime::enter_call_guarded() else { return Value::Null; };");
7322
7687
  }
7688
+ // #682: a promoted module binding is fetched straight from its static. One local
7689
+ // per referencing closure, and none at all in the frames in between — where the
7690
+ // `_cell` chain used to leave one at every level.
7691
+ if self.module_statics_on {
7692
+ let mut promoted: Vec<String> = self
7693
+ .module_var_statics
7694
+ .iter()
7695
+ .filter(|n| {
7696
+ referenced.contains(n.as_str())
7697
+ && !param_names.contains(n.as_str())
7698
+ && !local_var_names.contains(n.as_str())
7699
+ })
7700
+ .cloned()
7701
+ .collect();
7702
+ promoted.sort();
7703
+ for n in &promoted {
7704
+ self.writeln(&format!(
7705
+ "let {} = {};",
7706
+ Self::escape_ident(n),
7707
+ Self::module_var_static_handle(n)
7708
+ ));
7709
+ }
7710
+ }
7323
7711
  // Mutable outer vars: capture the RefCell so assignments use borrow_mut
7324
7712
  for outer_var in &mutable_outer_vars {
7325
7713
  let var_escaped = Self::escape_ident(outer_var);
@@ -7623,12 +8011,16 @@ impl Codegen {
7623
8011
  self.value_fn_depth = self.value_fn_depth.saturating_sub(1);
7624
8012
  self.try_closure_depth = saved_try_depth;
7625
8013
  self.indent -= 1;
7626
- self.writeln("};");
7627
- // Update the cell with the actual function value
7628
- self.writeln(&format!(
7629
- "*{}_cell.borrow_mut() = {}.clone();",
7630
- name_str, name_str
7631
- ));
8014
+ if to_static {
8015
+ self.writeln("});");
8016
+ } else {
8017
+ self.writeln("};");
8018
+ // Update the cell with the actual function value
8019
+ self.writeln(&format!(
8020
+ "*{}_cell.borrow_mut() = {}.clone();",
8021
+ name_str, name_str
8022
+ ));
8023
+ }
7632
8024
  }
7633
8025
  }
7634
8026
  Ok(())
@@ -8220,6 +8612,16 @@ impl Codegen {
8220
8612
  if let Some(uv) = self.usize_var_subst.get(name.as_ref()) {
8221
8613
  return Ok(format!("Value::Number(({} as f64))", uv));
8222
8614
  }
8615
+ // #682: a module fn lives in `GF_NAME`, not in a local — and unlike a capture, the
8616
+ // static is in scope at every depth, so nothing has to be threaded in to reach it.
8617
+ // ⚠️ Unless a PARAM or LOCAL of the enclosing function shadows the name: earshot's
8618
+ // `let dist: i32` lost to packages/sfx.tish's internal `function dist(...)` here,
8619
+ // and the callee got a function cell where it expected a number.
8620
+ if self.is_module_fn_static(name.as_ref())
8621
+ && !self.type_context.is_locally_shadowed(name.as_ref())
8622
+ {
8623
+ return Ok(Self::module_fn_static_read(name.as_ref()));
8624
+ }
8223
8625
  let escaped = Self::escape_ident(name.as_ref());
8224
8626
  if self.refcell_wrapped_vars.contains(name.as_ref()) {
8225
8627
  let var_type = self.type_context.get_type(name.as_ref());
@@ -9391,12 +9793,28 @@ impl Codegen {
9391
9793
  let i32_abi = self.native_fns_i32.contains(fname.as_ref());
9392
9794
  let want =
9393
9795
  if i32_abi { RustType::I32 } else { RustType::F64 };
9796
+ let ptys = self.native_fn_param_types.get(fname.as_ref()).cloned();
9394
9797
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
9395
9798
  let mut ok = true;
9396
- for a in args {
9799
+ for (i, a) in args.iter().enumerate() {
9397
9800
  if let CallArg::Expr(e) = a {
9801
+ // A struct slot takes a shared reference to the caller's struct;
9802
+ // coercing it to a number would be nonsense (and would not compile).
9803
+ let is_struct = matches!(
9804
+ ptys.as_ref().and_then(|v| v.get(i)),
9805
+ Some(RustType::Named { .. })
9806
+ );
9398
9807
  let (ac, at) = self.emit_typed_expr(e)?;
9399
- argc.push(self.coerce_native_arg(&ac, at, want.clone()));
9808
+ if is_struct {
9809
+ if matches!(at, RustType::Named { .. }) {
9810
+ argc.push(format!("&{}", ac));
9811
+ } else {
9812
+ ok = false;
9813
+ break;
9814
+ }
9815
+ } else {
9816
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
9817
+ }
9400
9818
  } else {
9401
9819
  ok = false;
9402
9820
  break;
@@ -9835,6 +10253,10 @@ impl Codegen {
9835
10253
  val_code
9836
10254
  } else if val_ty == RustType::Value {
9837
10255
  RustType::F64.from_value_expr(&val_code)
10256
+ } else if val_ty.is_integer_scalar() {
10257
+ // An i32-typed value into an f64-backed global. Widening is exact; emitted
10258
+ // raw it is E0308.
10259
+ format!("(({}) as f64)", val_code)
9838
10260
  } else {
9839
10261
  val_code
9840
10262
  };
@@ -9857,6 +10279,12 @@ impl Codegen {
9857
10279
  val_code
9858
10280
  } else if val_ty == RustType::Value {
9859
10281
  rust_type.from_value_expr(&val_code)
10282
+ } else if rust_type == RustType::F64 && val_ty.is_integer_scalar() {
10283
+ // ⚠️ An `: i32` global assigned into an f64-typed local. `let n = roster.len()`
10284
+ // infers f64, and `n = MAX_UNITS` (a `let MAX_UNITS: i32` in packages/clansave)
10285
+ // emitted `n = MAX_UNITS;` raw -- E0308, expected f64 found i32. Needs BOTH a
10286
+ // native local and an i32-annotated global, which is why so little hit it.
10287
+ format!("(({}) as f64)", val_code)
9860
10288
  } else {
9861
10289
  val_code
9862
10290
  };
@@ -10367,6 +10795,126 @@ impl Codegen {
10367
10795
  idents
10368
10796
  }
10369
10797
 
10798
+ /// Names used AS A VALUE — i.e. everywhere except the callee slot of a direct `f(..)` call.
10799
+ /// A module fn that is only ever called directly needs no boxed `Value::native` wrapper: the
10800
+ /// native lowering (`f_native`) already serves every call site, and the wrapper is a permanent
10801
+ /// slot in `run()`'s stack frame. On GBA `run()` holds every module fn at once — 605 of them in
10802
+ /// `examples/ffta` — against a 29 KB IWRAM stack, so dropping the unused wrappers is the
10803
+ /// difference between a game fitting and overwriting the audio mixer.
10804
+ ///
10805
+ /// ⚠️ CONSERVATIVE BY CONSTRUCTION. Anything this does not structurally recurse through falls
10806
+ /// back to [`Self::collect_expr_idents`], which marks every name inside as value-used — that
10807
+ /// only forfeits the optimisation, it never removes a wrapper something needs. And if it ever
10808
+ /// were wrong, the generated Rust names a binding that does not exist: a loud `E0425` at build
10809
+ /// time, not a silently wrong ROM.
10810
+ fn collect_value_used_expr(expr: &Expr, idents: &mut HashSet<String>) {
10811
+ match expr {
10812
+ Expr::Call { callee, args, .. } => {
10813
+ // The callee slot of a direct call is NOT a value use -- UNLESS the call cannot be
10814
+ // emitted as a direct one. A SPREAD call always goes through `value_call`, which
10815
+ // needs the boxed binding by name, so eliding the wrapper leaves `let _callee =
10816
+ // (sum3).clone()` referring to nothing (E0425, tests/core/spread).
10817
+ let spreads = args.iter().any(|a| matches!(a, CallArg::Spread(_)));
10818
+ if !matches!(&**callee, Expr::Ident { .. }) || spreads {
10819
+ Self::collect_value_used_expr(callee, idents);
10820
+ }
10821
+ for arg in args {
10822
+ match arg {
10823
+ CallArg::Expr(e) | CallArg::Spread(e) => {
10824
+ Self::collect_value_used_expr(e, idents)
10825
+ }
10826
+ }
10827
+ }
10828
+ }
10829
+ Expr::Binary { left, right, .. } => {
10830
+ Self::collect_value_used_expr(left, idents);
10831
+ Self::collect_value_used_expr(right, idents);
10832
+ }
10833
+ Expr::Unary { operand, .. } => Self::collect_value_used_expr(operand, idents),
10834
+ Expr::Conditional {
10835
+ cond,
10836
+ then_branch,
10837
+ else_branch,
10838
+ ..
10839
+ } => {
10840
+ Self::collect_value_used_expr(cond, idents);
10841
+ Self::collect_value_used_expr(then_branch, idents);
10842
+ Self::collect_value_used_expr(else_branch, idents);
10843
+ }
10844
+ Expr::Member { object, prop, .. } => {
10845
+ Self::collect_value_used_expr(object, idents);
10846
+ if let MemberProp::Expr(e) = prop {
10847
+ Self::collect_value_used_expr(e, idents);
10848
+ }
10849
+ }
10850
+ Expr::Index { object, index, .. } => {
10851
+ Self::collect_value_used_expr(object, idents);
10852
+ Self::collect_value_used_expr(index, idents);
10853
+ }
10854
+ other => Self::collect_expr_idents(other, idents),
10855
+ }
10856
+ }
10857
+
10858
+ /// Statement-level driver for [`Self::collect_value_used_expr`]. Mirrors the shape of
10859
+ /// [`Self::collect_stmt_idents`]; unknown statement forms fall back to it (conservative).
10860
+ fn collect_value_used_stmt(stmt: &Statement, idents: &mut HashSet<String>) {
10861
+ match stmt {
10862
+ Statement::ExprStmt { expr, .. } => Self::collect_value_used_expr(expr, idents),
10863
+ Statement::VarDecl { init, .. } => {
10864
+ if let Some(e) = init {
10865
+ Self::collect_value_used_expr(e, idents);
10866
+ }
10867
+ }
10868
+ Statement::Return { value, .. } => {
10869
+ if let Some(e) = value {
10870
+ Self::collect_value_used_expr(e, idents);
10871
+ }
10872
+ }
10873
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
10874
+ for s in statements {
10875
+ Self::collect_value_used_stmt(s, idents);
10876
+ }
10877
+ }
10878
+ Statement::If {
10879
+ cond,
10880
+ then_branch,
10881
+ else_branch,
10882
+ ..
10883
+ } => {
10884
+ Self::collect_value_used_expr(cond, idents);
10885
+ Self::collect_value_used_stmt(then_branch, idents);
10886
+ if let Some(s) = else_branch {
10887
+ Self::collect_value_used_stmt(s, idents);
10888
+ }
10889
+ }
10890
+ Statement::While { cond, body, .. } => {
10891
+ Self::collect_value_used_expr(cond, idents);
10892
+ Self::collect_value_used_stmt(body, idents);
10893
+ }
10894
+ Statement::FunDecl { body, .. } => Self::collect_value_used_stmt(body, idents),
10895
+ // ⚠️ An EXPORTED fn must keep its boxed wrapper: the module namespace hands it out as
10896
+ // a `Value`, and an importer may do anything with it. Mark the exported name itself as
10897
+ // value-used, then walk the declaration normally.
10898
+ Statement::Export { declaration, .. } => match &**declaration {
10899
+ tishlang_ast::ExportDeclaration::Named(inner) => {
10900
+ if let Statement::FunDecl { name, .. } = &**inner {
10901
+ idents.insert(name.to_string());
10902
+ }
10903
+ Self::collect_value_used_stmt(inner, idents);
10904
+ }
10905
+ tishlang_ast::ExportDeclaration::Default(e) => Self::collect_value_used_expr(e, idents),
10906
+ tishlang_ast::ExportDeclaration::ReExport { specifiers, .. } => {
10907
+ for sp in specifiers {
10908
+ if let tishlang_ast::ImportSpecifier::Named { name, .. } = sp {
10909
+ idents.insert(name.to_string());
10910
+ }
10911
+ }
10912
+ }
10913
+ },
10914
+ other => Self::collect_stmt_idents(other, idents),
10915
+ }
10916
+ }
10917
+
10370
10918
  fn collect_expr_idents(expr: &Expr, idents: &mut HashSet<String>) {
10371
10919
  match expr {
10372
10920
  Expr::Ident { name, .. } => {
@@ -12024,6 +12572,8 @@ impl Codegen {
12024
12572
  ]
12025
12573
  .contains(&name.as_str())
12026
12574
  && !self.native_numeric_globals.contains_key(name.as_str())
12575
+ // #682: reachable from its static at any depth — nothing to capture.
12576
+ && !self.is_module_var_static(name.as_str())
12027
12577
  })
12028
12578
  .collect();
12029
12579
 
@@ -12114,7 +12664,9 @@ impl Codegen {
12114
12664
  "RegExp",
12115
12665
  "Polars",
12116
12666
  ] {
12117
- if referenced.contains(*builtin) {
12667
+ // Same shadowing rule as the value-fn prelude: a user module function of this name
12668
+ // lowers to a `__TISH_GF_*` static, so no builtin local exists to clone.
12669
+ if referenced.contains(*builtin) && !self.is_module_fn_static(builtin) {
12118
12670
  code.push_str(&format!(" let {} = {}.clone();\n", builtin, builtin));
12119
12671
  }
12120
12672
  }
@@ -12126,7 +12678,15 @@ impl Codegen {
12126
12678
  .map(|scope| {
12127
12679
  scope
12128
12680
  .iter()
12129
- .filter(|f| referenced.contains(f.as_str()) && !param_names.contains(*f))
12681
+ .filter(|f| {
12682
+ referenced.contains(f.as_str())
12683
+ && !param_names.contains(*f)
12684
+ // #682: a promoted module fn has no `_cell` anywhere — it lives in
12685
+ // `__TISH_GF_<name>` and the body reads it from there. Cloning a cell
12686
+ // that was never emitted is `error[E0425]: cannot find value
12687
+ // bagList_cell`, and it takes the whole ROM down.
12688
+ && !self.is_module_fn_static(f.as_str())
12689
+ })
12130
12690
  .cloned()
12131
12691
  .collect()
12132
12692
  })
@@ -12181,6 +12741,12 @@ impl Codegen {
12181
12741
  // arrow whose grandparent-scope var was TLS-lowered). Same exclusion the
12182
12742
  // FunDecl closure path and this fn's own outer_vars filter already apply.
12183
12743
  && !self.native_numeric_globals.contains_key(name.as_str())
12744
+ // #682: same reason — a promoted module binding has no boxed local to
12745
+ // capture; the body reads it out of its static. Module FNS as well as data:
12746
+ // `let present_ref = VmRef::new(present.clone())` against a fn that now lives
12747
+ // in `__TISH_GF_present` is `error[E0425]: cannot find value present`.
12748
+ && !self.is_module_var_static(name.as_str())
12749
+ && !self.is_module_fn_static(name.as_str())
12184
12750
  })
12185
12751
  .cloned()
12186
12752
  .collect()
@@ -12202,6 +12768,26 @@ impl Codegen {
12202
12768
  let saved_try_depth = self.try_closure_depth;
12203
12769
  self.try_closure_depth = 0;
12204
12770
 
12771
+ // #682: same handle fetch as the FunDecl path — see the comment there.
12772
+ if self.module_statics_on {
12773
+ let mut promoted: Vec<&String> = self
12774
+ .module_var_statics
12775
+ .iter()
12776
+ .filter(|n| {
12777
+ referenced.contains(n.as_str())
12778
+ && !param_names.contains(n.as_str())
12779
+ && !local_var_names.contains(n.as_str())
12780
+ })
12781
+ .collect();
12782
+ promoted.sort();
12783
+ for n in promoted {
12784
+ code.push_str(&format!(
12785
+ " let {} = {};\n",
12786
+ Self::escape_ident(n),
12787
+ Self::module_var_static_handle(n)
12788
+ ));
12789
+ }
12790
+ }
12205
12791
  // Make captured outer params available as plain Values (from _ref RefCells)
12206
12792
  for outer_param in &outer_params {
12207
12793
  let param_escaped = Self::escape_ident(outer_param);
@@ -16828,6 +17414,295 @@ impl Codegen {
16828
17414
  format!("G_{}", safe.to_uppercase())
16829
17415
  }
16830
17416
 
17417
+ /// #682 — does this program hand a tish closure to something that will call it on ANOTHER
17418
+ /// thread? Those are the surfaces the host's `thread_local!` storage cannot serve: the worker
17419
+ /// would read its own uninitialised copy of every module fn.
17420
+ ///
17421
+ /// Keyed on what the program NAMES, not on which capabilities are linked — `tish build` links
17422
+ /// the whole capability set by default, so a feature test would switch this off for every
17423
+ /// desktop program including the ones that never open a socket. Conservative in the safe
17424
+ /// direction: a program that merely mentions one of these names keeps the old emission.
17425
+ fn program_may_call_tish_off_thread(&self, program: &Program) -> bool {
17426
+ let mut idents = HashSet::new();
17427
+ for s in &program.statements {
17428
+ Self::collect_stmt_idents(s, &mut idents);
17429
+ }
17430
+ // `serve(port, handler)` dispatches the handler on tokio workers; `serve(port, {onWorker})`
17431
+ // builds one per accept thread. `Server`/`WebSocket`/`wsAccept` are the ws equivalents.
17432
+ for n in ["serve", "Server", "WebSocket", "wsAccept"] {
17433
+ if idents.contains(n) {
17434
+ return true;
17435
+ }
17436
+ }
17437
+ // `Promise.spawn` is an OS-thread spawn, and only exists in ws-linked programs. The member
17438
+ // name is not an ident, so the receiver is what we can see.
17439
+ if self.has_feature("ws") && idents.contains("Promise") {
17440
+ return true;
17441
+ }
17442
+ false
17443
+ }
17444
+
17445
+ /// #682 — re-emit a buffered builtin preamble inside the `#[inline(never)]` shim, handing its
17446
+ /// bindings back as a tuple. See the call site for why.
17447
+ ///
17448
+ /// The names come from the buffer itself rather than a hand-kept list: half the preamble is
17449
+ /// feature-gated (`fs`, `http`, `timers`, JSX…), so what was actually emitted is the only
17450
+ /// reliable answer. Only declarations at the preamble's own indent are taken — the `let`s
17451
+ /// nested inside a `Value::object({ … })` block are that block's business.
17452
+ /// Re-emit the buffered builtin preamble inside the `#[inline(never)]` shim, handing its
17453
+ /// base-indent bindings back as a tuple. The names are read out of the emitted text rather than
17454
+ /// kept in a list beside it: half the preamble is feature-gated (`fs`, `http`, `timers`, JSX…),
17455
+ /// so what was actually emitted is the only reliable answer. The preamble is a flat run of
17456
+ /// `writeln`s, so an indent test identifies its own declarations exactly — a chunk of user code
17457
+ /// is not, which is why `chunk_escaping_names` works off the AST instead.
17458
+ fn emit_buffered_preamble(&mut self, preamble: &str) {
17459
+ let base = " ".repeat(self.indent);
17460
+ let mut names: Vec<(bool, String)> = Vec::new();
17461
+ for line in preamble.lines() {
17462
+ let Some(rest) = line.strip_prefix(&base) else {
17463
+ continue;
17464
+ };
17465
+ if rest.starts_with(' ') {
17466
+ continue;
17467
+ }
17468
+ // `emit_native_namespace_preamble` writes `#[allow(unused_variables)] let __tish_native_ns_0 = …`.
17469
+ // Without stripping the attribute the scan does not see a `let` at all, the binding stays
17470
+ // inside the closure, and every chunk that imports from a native module fails with
17471
+ // `error[E0425]: cannot find value __tish_native_ns_0`.
17472
+ let rest = match rest.find("] ") {
17473
+ Some(i) if rest.starts_with("#[") => &rest[i + 2..],
17474
+ _ => rest,
17475
+ };
17476
+ let Some(rest) = rest.strip_prefix("let ") else {
17477
+ continue;
17478
+ };
17479
+ let (is_mut, rest) = match rest.strip_prefix("mut ") {
17480
+ Some(r) => (true, r),
17481
+ None => (false, rest),
17482
+ };
17483
+ let name: String = rest
17484
+ .chars()
17485
+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
17486
+ .collect();
17487
+ // `let x: String = …` counts — the annotation is dropped on the way through the
17488
+ // tuple and inference puts it back. A destructuring `let (a, b) = …` does not: its name
17489
+ // parse comes back empty, and anything else unexpected is skipped rather than guessed
17490
+ // at. (Missing one is not silent: the binding stays inside the closure and the next
17491
+ // reference to it fails to compile.)
17492
+ let after = rest[name.len()..].trim_start();
17493
+ if name.is_empty() || !(after.starts_with('=') || after.starts_with(':')) {
17494
+ continue;
17495
+ }
17496
+ if names.iter().any(|(_, n)| *n == name) {
17497
+ continue;
17498
+ }
17499
+ names.push((is_mut, name));
17500
+ }
17501
+ let binds = names
17502
+ .iter()
17503
+ .map(|(m, n)| if *m { format!("mut {n}") } else { n.clone() })
17504
+ .collect::<Vec<_>>()
17505
+ .join(", ");
17506
+ let vals = names
17507
+ .iter()
17508
+ .map(|(_, n)| n.clone())
17509
+ .collect::<Vec<_>>()
17510
+ .join(", ");
17511
+ self.writeln(&format!("let ({binds}) = __tish_no_inline(|| {{"));
17512
+ self.output.push_str(preamble);
17513
+ self.indent += 1;
17514
+ self.writeln(&format!("({vals})"));
17515
+ self.indent -= 1;
17516
+ self.writeln("});");
17517
+ }
17518
+
17519
+ /// #682 — static name for a module-scope FUNCTION binding (`heroAnim` → `__TISH_GF_heroAnim`).
17520
+ ///
17521
+ /// CASE-PRESERVING, unlike `global_static_name`'s `G_*`: tish identifiers are case-sensitive, so
17522
+ /// upper-casing them collides two distinct bindings onto one static. `fl` and `Fl` in
17523
+ /// tests/core did exactly that — `error[E0428]: the name GV_FL is defined multiple times`. The
17524
+ /// `__TISH_` prefix is reserved, so a user binding cannot shadow one of these either.
17525
+ fn module_fn_static_name(name: &str) -> String {
17526
+ format!("__TISH_GF_{}", Self::static_ident_body(name))
17527
+ }
17528
+
17529
+ /// The token-safe body of a generated static's name: `escape_ident` may hand back a raw
17530
+ /// identifier (`r#fn` for the keyword `fn`), and `#` does not tokenize inside a longer name.
17531
+ fn static_ident_body(name: &str) -> String {
17532
+ let esc = Self::escape_ident(name);
17533
+ let raw = esc.strip_prefix("r#").unwrap_or(&esc);
17534
+ raw.chars()
17535
+ .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
17536
+ .collect()
17537
+ }
17538
+
17539
+ /// Read a module fn out of its static. Emitted INSIDE closure bodies as well as at top level:
17540
+ /// the read happens when the expression runs, by which time every module fn is installed.
17541
+ fn module_fn_static_read(name: &str) -> String {
17542
+ format!(
17543
+ "{}.with(|c| (*c.borrow()).clone())",
17544
+ Self::module_fn_static_name(name)
17545
+ )
17546
+ }
17547
+
17548
+ /// #682 — which module DATA bindings can move their cell into a static.
17549
+ ///
17550
+ /// Restricted to bindings that are ALREADY a `VmRef` cell (`wrapped`), because those are the
17551
+ /// ones a closure capture threads down through every enclosing frame — a plain module local
17552
+ /// costs one slot in `run()` and nothing further.
17553
+ ///
17554
+ /// Excluded, in order: a name declared more than once at module level (the second `let` is a
17555
+ /// different binding); a name bound anywhere else in the program — a parameter, a local `let`,
17556
+ /// a `for…of` or `catch` binding — since the static read site has no scope information to tell
17557
+ /// the two apart; and any name another emission path already claims, either with its own static
17558
+ /// (#176 numeric globals, module const arrays) or with a declaration that never reaches the
17559
+ /// write-through branch below (an i32 loop var).
17560
+ ///
17561
+ /// Note what is NOT a criterion: the binding's TYPE. The static and its accessor are emitted
17562
+ /// after `run()`, from the type recorded when the `let` was emitted, so nothing here has to
17563
+ /// predict what inference will decide.
17564
+ fn collect_module_var_statics(
17565
+ &self,
17566
+ stmts: &[Statement],
17567
+ wrapped: &HashSet<String>,
17568
+ ) -> HashSet<String> {
17569
+ let mut decl_counts: HashMap<String, usize> = HashMap::new();
17570
+ let mut candidates: Vec<String> = Vec::new();
17571
+ for st in stmts {
17572
+ if let Statement::VarDecl { name, .. } = st {
17573
+ *decl_counts.entry(name.to_string()).or_insert(0) += 1;
17574
+ candidates.push(name.to_string());
17575
+ }
17576
+ }
17577
+ let mut shadow: HashSet<String> = HashSet::new();
17578
+ for st in stmts {
17579
+ Self::collect_binding_names(st, &mut shadow);
17580
+ // A module-level `let x` IS the declaration, not a shadow of it — every other `let`
17581
+ // in the program is. (`collect_binding_names` above already covers the parameter,
17582
+ // `for…of` and `catch` bindings an initialiser's arrow can introduce.)
17583
+ if !matches!(st, Statement::VarDecl { .. }) {
17584
+ Self::collect_local_var_names(st, &mut shadow);
17585
+ }
17586
+ }
17587
+ let mut out = HashSet::new();
17588
+ for name in candidates {
17589
+ if !wrapped.contains(&name)
17590
+ || decl_counts.get(&name).copied().unwrap_or(0) > 1
17591
+ || shadow.contains(&name)
17592
+ || self.native_numeric_globals.contains_key(&name)
17593
+ || self.module_const_f64_arrays.contains_key(&name)
17594
+ || self.module_const_f64_aliases.contains_key(&name)
17595
+ || self.i32_loop_vars.contains(&name)
17596
+ || self.module_fn_statics.contains(&name)
17597
+ {
17598
+ continue;
17599
+ }
17600
+ out.insert(name);
17601
+ }
17602
+ out
17603
+ }
17604
+
17605
+ /// #682 — static name for a module-scope DATA binding (`deadCol` → `__TISH_GV_deadCol`).
17606
+ /// Case-preserving for the reason `module_fn_static_name` documents.
17607
+ fn module_var_static_name(name: &str) -> String {
17608
+ format!("__TISH_GV_{}", Self::static_ident_body(name))
17609
+ }
17610
+
17611
+ /// Clone the binding's `VmRef` handle out of its static.
17612
+ ///
17613
+ /// The static holds the CELL, not the value, so every existing read/write site keeps working
17614
+ /// verbatim — `(*x.borrow())`, `*x.borrow_mut() = …`, `vm_read(&x)` all still see a `VmRef`
17615
+ /// local. What changes is where that local comes from: a closure now takes it straight from the
17616
+ /// static instead of having it threaded in through one `_cell` clone per enclosing scope, and
17617
+ /// each of those clones was a slot in `run()`'s frame.
17618
+ ///
17619
+ /// `OnceCell` rather than a plain value because `VmRef::new` is not const, and the handle must
17620
+ /// exist before the binding's `let` runs — a closure created above it captures the same cell.
17621
+ fn module_var_static_handle(name: &str) -> String {
17622
+ format!("{}()", Self::module_var_accessor_name(name))
17623
+ }
17624
+
17625
+ /// The accessor that hands out the cell. Every use site calls this and never names the cell's
17626
+ /// type, so the type is free to be recorded when the binding's `let` is emitted rather than
17627
+ /// predicted before inference has run.
17628
+ fn module_var_accessor_name(name: &str) -> String {
17629
+ format!("__tish_gv_{}", Self::static_ident_body(name))
17630
+ }
17631
+
17632
+ fn is_module_var_static(&self, name: &str) -> bool {
17633
+ self.module_statics_on && self.module_var_statics.contains(name)
17634
+ }
17635
+
17636
+ fn emit_module_var_statics(&mut self) -> Result<(), CompileError> {
17637
+ if self.module_var_statics.is_empty() {
17638
+ return Ok(());
17639
+ }
17640
+ let mut names: Vec<String> = self.module_var_statics.iter().cloned().collect();
17641
+ names.sort();
17642
+ let gba = self.emit_mode == crate::NativeEmitMode::Gba;
17643
+ for n in &names {
17644
+ let (ty, default) = self
17645
+ .module_var_static_types
17646
+ .get(n)
17647
+ .cloned()
17648
+ .unwrap_or_else(|| ("Value".to_string(), "Value::Null".to_string()));
17649
+ let stat = Self::module_var_static_name(n);
17650
+ if gba {
17651
+ self.writeln(&format!(
17652
+ "static {stat}: tishlang_runtime::SingleCore<core::cell::OnceCell<VmRef<{ty}>>> = \
17653
+ tishlang_runtime::SingleCore::new(core::cell::OnceCell::new());"
17654
+ ));
17655
+ } else {
17656
+ self.writeln(&format!(
17657
+ "thread_local! {{ static {stat}: std::cell::OnceCell<VmRef<{ty}>> = const {{ std::cell::OnceCell::new() }}; }}"
17658
+ ));
17659
+ }
17660
+ self.writeln(&format!(
17661
+ "#[inline] fn {}() -> VmRef<{ty}> {{ {stat}.with(|c| c.get_or_init(|| VmRef::new({default})).clone()) }}",
17662
+ Self::module_var_accessor_name(n)
17663
+ ));
17664
+ }
17665
+ self.writeln("");
17666
+ Ok(())
17667
+ }
17668
+
17669
+ fn is_module_fn_static(&self, name: &str) -> bool {
17670
+ self.module_statics_on && self.module_fn_statics.contains(name)
17671
+ }
17672
+
17673
+ /// Declare one static per promoted module fn, before `run()`. `SingleCore` on GBA (no_std has
17674
+ /// no `thread_local!`), `thread_local!` on the host — the same split #594 made for the numeric
17675
+ /// globals, and for the same reason: `with()` mirrors `LocalKey::with`, so every read site is
17676
+ /// written once and works on both.
17677
+ fn emit_module_fn_statics(&mut self) -> Result<(), CompileError> {
17678
+ if self.module_fn_statics.is_empty() {
17679
+ return Ok(());
17680
+ }
17681
+ let mut names: Vec<String> = self.module_fn_statics.iter().cloned().collect();
17682
+ names.sort();
17683
+ if self.emit_mode == crate::NativeEmitMode::Gba {
17684
+ for n in &names {
17685
+ self.writeln(&format!(
17686
+ "static {}: tishlang_runtime::SingleCore<core::cell::RefCell<Value>> = \
17687
+ tishlang_runtime::SingleCore::new(core::cell::RefCell::new(Value::Null));",
17688
+ Self::module_fn_static_name(n)
17689
+ ));
17690
+ }
17691
+ } else {
17692
+ // ONE `thread_local!` PER STATIC, not one block listing them all: the macro recurses
17693
+ // once per item, and a real program has hundreds of module fns — a single block blows
17694
+ // rustc's default `recursion_limit = 128` before it ever type-checks.
17695
+ for n in &names {
17696
+ self.writeln(&format!(
17697
+ "thread_local! {{ static {}: std::cell::RefCell<Value> = const {{ std::cell::RefCell::new(Value::Null) }}; }}",
17698
+ Self::module_fn_static_name(n)
17699
+ ));
17700
+ }
17701
+ }
17702
+ self.writeln("");
17703
+ Ok(())
17704
+ }
17705
+
16831
17706
  fn native_global_static(name: &str) -> String {
16832
17707
  Self::global_static_name(name)
16833
17708
  }
@@ -17451,6 +18326,76 @@ impl Codegen {
17451
18326
  Some(out)
17452
18327
  }
17453
18328
 
18329
+ /// #684 — MODULE BINDINGS A CLOSURE ABOVE THEM TOUCHES, THAT THE HOIST CANNOT REACH.
18330
+ ///
18331
+ /// `hoist_forward_referenced_module_bindings` moves the declaration up, but only for a LITERAL
18332
+ /// initialiser referenced from a top-level `FunDecl` — deliberately, since moving a call would
18333
+ /// move its side effects. Everything else it leaves in place, and for a binding that is also a
18334
+ /// capture cell (`refcell_wrapped_vars`) the result does not compile:
18335
+ ///
18336
+ /// ```text
18337
+ /// let mk = [() => { function noteDeath(c) { deadCol = c } … }] // *deadCol.borrow_mut() = …
18338
+ /// export let deadCol: i32 = -1 // let deadCol = VmRef::new(..)
18339
+ /// ```
18340
+ ///
18341
+ /// The write emits `*deadCol.borrow_mut()` because `refcell_wrapped_vars` — a whole-program
18342
+ /// prepass — says the binding is a cell, while the capture list comes from `outer_vars_stack`,
18343
+ /// which is built as statements are emitted and does not have the name yet. Nothing is captured,
18344
+ /// nothing is in scope: `error[E0425]: cannot find value 'deadCol' in this scope`.
18345
+ ///
18346
+ /// The fix is not to move the declaration but to split it: declare the CELL up front (holding
18347
+ /// `Value::Null`, which is what the binding's value is before its `let` runs anyway) and let the
18348
+ /// `let` assign into it. Every closure — above or below — then captures the same cell, and the
18349
+ /// initialiser stays exactly where the program put it.
18350
+ ///
18351
+ /// Only names declared EXACTLY ONCE at module level qualify: with two declarations a mention
18352
+ /// above the second one refers to the first, and merging them into one cell would change which
18353
+ /// value it sees (the same hazard the hoist documents).
18354
+ fn collect_forward_referenced_cell_bindings(
18355
+ stmts: &[Statement],
18356
+ wrapped: &HashSet<String>,
18357
+ ) -> HashSet<String> {
18358
+ let mut decl_index: HashMap<String, usize> = HashMap::new();
18359
+ let mut decl_counts: HashMap<String, usize> = HashMap::new();
18360
+ for (i, s) in stmts.iter().enumerate() {
18361
+ if let Statement::VarDecl { name, .. } = s {
18362
+ *decl_counts.entry(name.to_string()).or_insert(0) += 1;
18363
+ decl_index.entry(name.to_string()).or_insert(i);
18364
+ }
18365
+ }
18366
+ let mut out = HashSet::new();
18367
+ for (name, di) in &decl_index {
18368
+ if !wrapped.contains(name.as_str()) {
18369
+ continue;
18370
+ }
18371
+ if decl_counts.get(name.as_str()).copied().unwrap_or(0) > 1 {
18372
+ continue;
18373
+ }
18374
+ if stmts
18375
+ .iter()
18376
+ .take(*di)
18377
+ .any(|s| Self::stmt_closure_captures_ident(s, name))
18378
+ {
18379
+ out.insert(name.clone());
18380
+ }
18381
+ }
18382
+ out
18383
+ }
18384
+
18385
+ /// Does a CLOSURE inside this statement capture `name`? Straight-line code above a declaration
18386
+ /// is deliberately not counted — there a mention is a genuine use-before-init, not a legal
18387
+ /// forward reference, and must keep failing rather than silently reading `Value::Null`.
18388
+ ///
18389
+ /// Reuses the same walker `collect_vars_needing_capture_cell` uses to decide cell-ness in the
18390
+ /// first place, so the two answers cannot drift apart.
18391
+ fn stmt_closure_captures_ident(stmt: &Statement, name: &str) -> bool {
18392
+ let mut block_vars = HashSet::new();
18393
+ block_vars.insert(name.to_string());
18394
+ let mut captured = HashSet::new();
18395
+ Self::collect_captured_block_vars_from_statements(stmt, &block_vars, &mut captured);
18396
+ captured.contains(name)
18397
+ }
18398
+
17454
18399
  /// An initialiser that cannot observe or disturb program state, so moving it is invisible.
17455
18400
  /// Deliberately narrow — array/object literals are included only when every element is itself
17456
18401
  /// literal, so no call or property read comes along for the ride.
@@ -19222,6 +20167,7 @@ impl Codegen {
19222
20167
  statements: &[Statement],
19223
20168
  globals: &std::collections::HashSet<String>,
19224
20169
  native_vec_names: &std::collections::HashSet<String>,
20170
+ aliases: &std::collections::HashMap<String, RustType>,
19225
20171
  ) -> std::collections::HashSet<String> {
19226
20172
  use std::collections::HashSet;
19227
20173
  let mut cand: HashSet<String> = HashSet::new();
@@ -19240,7 +20186,14 @@ impl Codegen {
19240
20186
  let params_ok = params.iter().all(|p| {
19241
20187
  matches!(p, FunParam::Simple(tp)
19242
20188
  if tp.default.is_none()
19243
- && tp.type_ann.as_ref().map_or(true, Self::ann_is_number))
20189
+ && tp.type_ann.as_ref().map_or(true, |a| {
20190
+ Self::ann_is_number(a)
20191
+ // At least one numeric field is enough to be a CANDIDATE. The
20192
+ // fixpoint below still has to prove the body, and a read of a
20193
+ // non-numeric field fails that proof — so admitting a mixed
20194
+ // record here cannot lower anything that was not already sound.
20195
+ || !Self::struct_numeric_fields(a, aliases).is_empty()
20196
+ }))
19244
20197
  });
19245
20198
  // Return: an annotated `: number`, OR unannotated with all-numeric returns
19246
20199
  // (verified in the fixpoint via `returns_numeric`), so the native `-> f64` holds.
@@ -19253,7 +20206,19 @@ impl Codegen {
19253
20206
  // still has to prove the body native-safe and every return numeric — the annotation
19254
20207
  // never decides that on its own (see `liar` in the issue).
19255
20208
  let i32_ok = Self::fn_sig_all_i32(params, return_type);
19256
- let (params_ok, ret_ok) = (params_ok || i32_ok, ret_ok || i32_ok);
20209
+ // A struct-param fn can never satisfy `fn_sig_all_i32` (its struct slot is not an
20210
+ // `i32`), so an `: i32` return would reject it even though the return is perfectly
20211
+ // native. Accept an i32 return for those; the f64 ABI carries it, and the existing
20212
+ // all-i32 fast path is untouched for everyone else.
20213
+ let has_struct_param = params.iter().any(|p| {
20214
+ matches!(p, FunParam::Simple(tp)
20215
+ if tp.type_ann.as_ref()
20216
+ .is_some_and(|a| !Self::struct_numeric_fields(a, aliases).is_empty()))
20217
+ });
20218
+ let ret_ok = ret_ok
20219
+ || i32_ok
20220
+ || (has_struct_param && return_type.as_ref().is_some_and(Self::ann_is_i32));
20221
+ let params_ok = params_ok || i32_ok;
19257
20222
  // #320: 0-param numeric fns (e.g. k_nucleotide's `nextBase()` — mutates a numeric
19258
20223
  // global, returns number) are eligible too; the fixpoint below still proves the body
19259
20224
  // native-safe + all-numeric-returns, so `fn name_native() -> f64` is sound.
@@ -19307,6 +20272,25 @@ impl Codegen {
19307
20272
  }
19308
20273
  let pnames: HashSet<String> =
19309
20274
  params.iter().flat_map(|p| p.bound_names()).map(|n| n.to_string()).collect();
20275
+ // Each struct param maps to the fields that can lower — its NUMERIC ones. A param
20276
+ // with no numeric field is left out entirely, so it reads exactly as "not a struct
20277
+ // param" and nothing about it is native.
20278
+ let sparams: std::collections::HashMap<String, HashSet<String>> = params
20279
+ .iter()
20280
+ .filter_map(|p| match p {
20281
+ FunParam::Simple(tp) => tp.type_ann.as_ref().and_then(|a| {
20282
+ let fs = Self::struct_numeric_fields(a, aliases);
20283
+ (!fs.is_empty()).then(|| (tp.name.to_string(), fs))
20284
+ }),
20285
+ _ => None,
20286
+ })
20287
+ .collect();
20288
+ Self::struct_params_with(|m| {
20289
+ m.clear();
20290
+ for (k, v) in sparams.iter() {
20291
+ m.insert(k.clone(), v.clone());
20292
+ }
20293
+ });
19310
20294
  let nums = Self::native_fn_numeric_locals(
19311
20295
  body,
19312
20296
  &pnames,
@@ -19314,9 +20298,20 @@ impl Codegen {
19314
20298
  &cand,
19315
20299
  native_vec_names,
19316
20300
  );
19317
- if !Self::native_safe_stmt(body, &pnames, &cand, globals, &nums, native_vec_names)
19318
- || !Self::returns_numeric(body, &pnames, &cand, globals, &nums)
19319
- {
20301
+ let safe = Self::native_safe_stmt(body, &pnames, &cand, globals, &nums, native_vec_names);
20302
+ let rets = Self::returns_numeric(body, &pnames, &cand, globals, &nums);
20303
+ let ok = safe && rets;
20304
+ if !ok && std::env::var("TISH_PROBE_LOWERING").as_deref() == Ok("1") {
20305
+ // Which HALF rejected it. "body not native-safe" and "a return is not numeric"
20306
+ // are entirely different fixes, and the fixpoint silently collapsed both into a
20307
+ // candidate quietly disappearing.
20308
+ eprintln!(
20309
+ "[probe] {} rejected: body_safe={} returns_numeric={}",
20310
+ name, safe, rets
20311
+ );
20312
+ }
20313
+ Self::struct_params_with(|s| s.clear());
20314
+ if !ok {
19320
20315
  remove.push(name.to_string());
19321
20316
  }
19322
20317
  }
@@ -19512,6 +20507,114 @@ impl Codegen {
19512
20507
  }
19513
20508
 
19514
20509
  /// Numeric locals in an M5-eligible fn body (params + fixpoint literal/copy seeds).
20510
+ /// The current function's params that are annotated with a type alias whose fields are ALL
20511
+ /// numeric, so `p.field` is a machine-word load. Set for the duration of one function's
20512
+ /// native-safety proof (`collect_native_fns`) and read by the proof's leaf predicates.
20513
+ ///
20514
+ /// A side-channel rather than a threaded argument on purpose: the proof is five mutually
20515
+ /// recursive static helpers, and widening all of their signatures to carry a set that only two
20516
+ /// leaves consult is a large diff for no added safety. Codegen is single-threaded; the set is
20517
+ /// cleared after each function.
20518
+ ///
20519
+ /// ⚠️ THE FIELD SET IS WHAT MAKES THIS SOUND, AND IT REPLACED A BARE NAME SET FOR THAT REASON.
20520
+ /// While the map held only names, admitting a struct with a `string` field would have made
20521
+ /// `p.name` "numeric" here and emitted Rust that does not compile — so the gate demanded that
20522
+ /// EVERY field be numeric, and one string disqualified the whole function. Carrying the numeric
20523
+ /// fields per param removes that ceiling: `p.x` proves native and `p.name` does not, so a mixed
20524
+ /// record lowers its arithmetic and a function that touches the string simply fails the proof
20525
+ /// and stays boxed. Never widen this to a bare name test again.
20526
+ fn struct_params_with<R>(
20527
+ f: impl FnOnce(&mut std::collections::HashMap<String, HashSet<String>>) -> R,
20528
+ ) -> R {
20529
+ thread_local! {
20530
+ static STRUCT_PARAMS:
20531
+ std::cell::RefCell<std::collections::HashMap<String, HashSet<String>>> =
20532
+ std::cell::RefCell::new(std::collections::HashMap::new());
20533
+ }
20534
+ STRUCT_PARAMS.with(|c| f(&mut c.borrow_mut()))
20535
+ }
20536
+
20537
+ /// Declared externs whose signature is numbers in, a number out.
20538
+ ///
20539
+ /// A `declare fn` already resolves to a native Rust fn — the emitter calls
20540
+ /// `<crate>::<symbol>_typed(..)` directly (see [`Self::extern_sig_for_arity`]). But the proof
20541
+ /// only accepted calls to `cand` (the tish fns being lowered), so ANY function calling into an
20542
+ /// extension crate was rejected and stayed boxed. On a game that is nearly everything: measured
20543
+ /// on `examples/ffta-hud`, a fn doing `u.col * 100 + u.row` lowered while the one beside it
20544
+ /// calling `tac_add_unit(u.col, u.row, …)` did not, purely because of the call.
20545
+ ///
20546
+ /// Only ALL-NUMERIC signatures qualify. An extern taking or returning a string, an array or a
20547
+ /// struct is not callable from a lowered body without a boxing boundary, which is the thing
20548
+ /// being removed.
20549
+ ///
20550
+ /// A thread-local for the same reason [`Self::struct_params_with`] is one: the proof is a set of
20551
+ /// associated fns with no `&self`, and threading a new parameter through every one of them would
20552
+ /// touch far more code than the fact being communicated.
20553
+ fn numeric_externs_with<R>(f: impl FnOnce(&mut HashSet<String>) -> R) -> R {
20554
+ thread_local! {
20555
+ static NUMERIC_EXTERNS: std::cell::RefCell<HashSet<String>> =
20556
+ std::cell::RefCell::new(HashSet::new());
20557
+ }
20558
+ NUMERIC_EXTERNS.with(|c| f(&mut c.borrow_mut()))
20559
+ }
20560
+
20561
+ /// `p.field` where `p` is one of [`Self::struct_params_with`]'s params AND `field` is one of its
20562
+ /// NUMERIC fields. Both halves matter: the name alone would admit `p.name` on a mixed record.
20563
+ fn is_struct_param_field(e: &Expr) -> bool {
20564
+ let Expr::Member { object, prop, .. } = e else {
20565
+ return false;
20566
+ };
20567
+ let MemberProp::Name { name: fname, .. } = prop else {
20568
+ return false;
20569
+ };
20570
+ let Expr::Ident { name, .. } = object.as_ref() else {
20571
+ return false;
20572
+ };
20573
+ Self::struct_params_with(|m| {
20574
+ m.get(name.as_ref()).is_some_and(|fs| fs.contains(fname.as_ref()))
20575
+ })
20576
+ }
20577
+
20578
+ /// Every field of this annotation's aliased struct is a native NUMBER (the invariant above).
20579
+ fn ann_is_all_numeric_struct(
20580
+ ann: &TypeAnnotation,
20581
+ aliases: &std::collections::HashMap<String, RustType>,
20582
+ ) -> bool {
20583
+ matches!(
20584
+ RustType::from_annotation_with_aliases(ann, aliases),
20585
+ RustType::Named { ref fields, .. }
20586
+ if !fields.is_empty()
20587
+ && fields.iter().all(|(_, t)| {
20588
+ matches!(t, RustType::F64 | RustType::I32) || t.is_narrow_int()
20589
+ })
20590
+ )
20591
+ }
20592
+
20593
+ /// The NUMERIC fields of this annotation's aliased struct, by name.
20594
+ ///
20595
+ /// [`Self::ann_is_all_numeric_struct`] asks a whole-struct question, and that is the ceiling on
20596
+ /// what can lower: half of a game's record shapes carry a string, a null or an array beside
20597
+ /// their numbers, and one such field disqualifies the whole function even when it only ever
20598
+ /// touches the numeric ones. Answering per FIELD lets `m.x` lower while `m.name` does not — a
20599
+ /// function that reads the string fails the proof exactly as it does today, so nothing that
20600
+ /// lowers now stops lowering.
20601
+ ///
20602
+ /// An empty set means nothing here can lower: not a struct, or a struct with no numeric field.
20603
+ /// Callers treat empty as "not a struct param", which keeps the conservative default.
20604
+ fn struct_numeric_fields(
20605
+ ann: &TypeAnnotation,
20606
+ aliases: &std::collections::HashMap<String, RustType>,
20607
+ ) -> HashSet<String> {
20608
+ match RustType::from_annotation_with_aliases(ann, aliases) {
20609
+ RustType::Named { ref fields, .. } => fields
20610
+ .iter()
20611
+ .filter(|(_, t)| matches!(t, RustType::F64 | RustType::I32) || t.is_narrow_int())
20612
+ .map(|(n, _)| n.to_string())
20613
+ .collect(),
20614
+ _ => HashSet::new(),
20615
+ }
20616
+ }
20617
+
19515
20618
  fn native_fn_numeric_locals(
19516
20619
  body: &Statement,
19517
20620
  params: &HashSet<String>,
@@ -19545,6 +20648,10 @@ impl Codegen {
19545
20648
  ..
19546
20649
  } => {
19547
20650
  let numeric = type_ann.as_ref().is_some_and(Self::ann_is_number)
20651
+ // `: i32` is a number too. Without this, `let j: i32 = …` was never seeded as a
20652
+ // numeric local, so every later use of `j` failed the safety proof and the whole
20653
+ // function stayed boxed — for an annotation the author added to make it FASTER.
20654
+ || type_ann.as_ref().is_some_and(Self::ann_is_i32)
19548
20655
  || matches!(
19549
20656
  init,
19550
20657
  Some(Expr::Literal {
@@ -19564,7 +20671,13 @@ impl Codegen {
19564
20671
  }) if matches!(
19565
20672
  callee.as_ref(),
19566
20673
  Expr::Ident { name: fnname, .. }
19567
- if cand.contains(fnname.as_ref()) || native_vec_names.contains(fnname.as_ref())
20674
+ if cand.contains(fnname.as_ref())
20675
+ || native_vec_names.contains(fnname.as_ref())
20676
+ // A numbers-in-number-out extern yields a number, same as the
20677
+ // safety proof and `numeric_shaped` already accept. Every
20678
+ // analysis has to agree or a local initialised from an extern is
20679
+ // numeric in one and unknown in another.
20680
+ || Self::numeric_externs_with(|s| s.contains(fnname.as_ref()))
19568
20681
  )
19569
20682
  );
19570
20683
  if numeric {
@@ -19676,6 +20789,13 @@ impl Codegen {
19676
20789
  nums: &HashSet<String>,
19677
20790
  native_vec_names: &std::collections::HashSet<String>,
19678
20791
  ) -> bool {
20792
+ // A field read off an all-numeric struct param is a plain numeric load — `p.x` on a
20793
+ // `&TishStruct_P` is a machine word, not a boxed property lookup. Without this the proof
20794
+ // rejects every object-taking fn, which is why typing a game's functions never produced a
20795
+ // native lowering. See `STRUCT_PARAMS`.
20796
+ if Self::is_struct_param_field(expr) {
20797
+ return true;
20798
+ }
19679
20799
  match expr {
19680
20800
  Expr::Literal { value, .. } => matches!(value, Literal::Number(_) | Literal::Bool(_)),
19681
20801
  Expr::Ident { name, .. } => {
@@ -19750,7 +20870,13 @@ impl Codegen {
19750
20870
  }
19751
20871
  match callee.as_ref() {
19752
20872
  Expr::Ident { name, .. } => {
19753
- cand.contains(name.as_ref()) || native_vec_names.contains(name.as_ref())
20873
+ cand.contains(name.as_ref())
20874
+ || native_vec_names.contains(name.as_ref())
20875
+ // A numbers-in-number-out extern lowers to a direct
20876
+ // `<crate>::<symbol>_typed(..)` call, so it is native-safe. Without this
20877
+ // any fn touching an extension crate stayed boxed — which in a game is
20878
+ // most of them.
20879
+ || Self::numeric_externs_with(|s| s.contains(name.as_ref()))
19754
20880
  }
19755
20881
  Expr::Member { object, prop: MemberProp::Name { name: m, .. }, .. } => {
19756
20882
  matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
@@ -19810,6 +20936,10 @@ impl Codegen {
19810
20936
  globals: &std::collections::HashSet<String>,
19811
20937
  nums: &HashSet<String>,
19812
20938
  ) -> bool {
20939
+ // `p.field` on an all-numeric struct param IS a number — see `struct_params_with`.
20940
+ if Self::is_struct_param_field(e) {
20941
+ return true;
20942
+ }
19813
20943
  match e {
19814
20944
  Expr::Literal { value: Literal::Number(_), .. } => true,
19815
20945
  Expr::Ident { name, .. } => {
@@ -19855,7 +20985,12 @@ impl Codegen {
19855
20985
  && Self::numeric_shaped(else_branch, params, cand, globals, nums)
19856
20986
  }
19857
20987
  Expr::Call { callee, .. } => match callee.as_ref() {
19858
- Expr::Ident { name, .. } => cand.contains(name.as_ref()),
20988
+ // Same set as the safety proof: a numbers-in-number-out extern RETURNS a number, so
20989
+ // `return tac_unit_hp(id)` is a numeric return and the fn keeps its native ABI.
20990
+ Expr::Ident { name, .. } => {
20991
+ cand.contains(name.as_ref())
20992
+ || Self::numeric_externs_with(|s| s.contains(name.as_ref()))
20993
+ }
19859
20994
  Expr::Member { object, prop: MemberProp::Name { name: m, .. }, .. } => {
19860
20995
  matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
19861
20996
  && matches!(
@@ -20055,12 +21190,28 @@ impl Codegen {
20055
21190
  }
20056
21191
  let i32_abi = self.native_fns_i32.contains(name.as_ref());
20057
21192
  let abi = if i32_abi { "i32" } else { "f64" };
21193
+ // Per-parameter type. A struct param lowers to `&TishStruct_<alias>` — one pointer,
21194
+ // no boxing, and `p.field` becomes a machine-word load. Numeric params keep the
21195
+ // single f64/i32 ABI. `mut` only on the numerics: a shared reference is not rebound.
20058
21196
  let plist: Vec<String> = params
20059
21197
  .iter()
20060
21198
  .filter_map(|p| match p {
20061
- FunParam::Simple(tp) => {
20062
- Some(format!("mut {}: {}", Self::escape_ident(tp.name.as_ref()), abi))
20063
- }
21199
+ FunParam::Simple(tp) => Some(
21200
+ match tp.type_ann.as_ref().map(|a| {
21201
+ RustType::from_annotation_with_aliases(a, &self.type_aliases)
21202
+ }) {
21203
+ Some(RustType::Named { name: sname, .. }) => format!(
21204
+ "{}: &{}",
21205
+ Self::escape_ident(tp.name.as_ref()),
21206
+ crate::types::named_struct_ident(&sname)
21207
+ ),
21208
+ _ => format!(
21209
+ "mut {}: {}",
21210
+ Self::escape_ident(tp.name.as_ref()),
21211
+ abi
21212
+ ),
21213
+ },
21214
+ ),
20064
21215
  _ => None,
20065
21216
  })
20066
21217
  .collect();
@@ -20079,10 +21230,13 @@ impl Codegen {
20079
21230
  self.type_context.push_scope();
20080
21231
  for p in params {
20081
21232
  if let FunParam::Simple(tp) = p {
20082
- self.type_context.define(
20083
- tp.name.as_ref(),
20084
- if i32_abi { RustType::I32 } else { RustType::F64 },
20085
- );
21233
+ let ty = match tp.type_ann.as_ref().map(|a| {
21234
+ RustType::from_annotation_with_aliases(a, &self.type_aliases)
21235
+ }) {
21236
+ Some(named @ RustType::Named { .. }) => named,
21237
+ _ => if i32_abi { RustType::I32 } else { RustType::F64 },
21238
+ };
21239
+ self.type_context.define(tp.name.as_ref(), ty);
20086
21240
  }
20087
21241
  }
20088
21242
  let copy_suffix = if copy_idx == 0 { String::new() } else { format!("_r{}", copy_idx) };
@@ -20281,6 +21435,20 @@ impl Codegen {
20281
21435
  // An f64-typed expression returned from an `i32` fn takes the same ToInt32 the
20282
21436
  // boxed path would have applied at the consumer.
20283
21437
  format!("tishlang_runtime::to_int32({})", code)
21438
+ } else if want == RustType::F64 && ty.is_integer_scalar() {
21439
+ // An INTEGER-typed expression returned from an `f64`-ABI fn. Widening is exact
21440
+ // (i32 and every narrow width fit in f64), and without it the body computes an
21441
+ // integer while the signature declares f64:
21442
+ //
21443
+ // fn uiKeys_native() -> f64 { return tish_agb::keys_edge_typed(); }
21444
+ // ^ i32, expected f64 (E0308)
21445
+ //
21446
+ // This was unreachable while the only integer-returning callees were fns the
21447
+ // proof had already typed; it became reachable when calls to i32 externs were
21448
+ // admitted as native-safe, because an extern reports its own declared return
21449
+ // type. `Fixed` is deliberately NOT covered — it is fixed-point, so `as f64`
21450
+ // would reinterpret the scale rather than convert it.
21451
+ format!("(({}) as f64)", code)
20284
21452
  } else {
20285
21453
  code
20286
21454
  };
@@ -26125,10 +27293,27 @@ impl Codegen {
26125
27293
  };
26126
27294
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
26127
27295
  let mut ok = true;
26128
- for a in args {
27296
+ for (idx, a) in args.iter().enumerate() {
26129
27297
  if let CallArg::Expr(e) = a {
27298
+ // A struct slot takes `&caller_struct`; coercing it to a number
27299
+ // would not compile. Mirrors the boxed-context call site.
27300
+ let is_struct = matches!(
27301
+ self.native_fn_param_types
27302
+ .get(fname.as_ref())
27303
+ .and_then(|v| v.get(idx)),
27304
+ Some(RustType::Named { .. })
27305
+ );
26130
27306
  let (ac, at) = self.emit_typed_expr(e)?;
26131
- argc.push(self.coerce_native_arg(&ac, at, want.clone()));
27307
+ if is_struct {
27308
+ if matches!(at, RustType::Named { .. }) {
27309
+ argc.push(format!("&{}", ac));
27310
+ } else {
27311
+ ok = false;
27312
+ break;
27313
+ }
27314
+ } else {
27315
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
27316
+ }
26132
27317
  } else {
26133
27318
  ok = false;
26134
27319
  break;