@tishlang/tish-lsp 3.7.1 → 3.8.6

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.
@@ -1166,6 +1166,12 @@ pub(crate) struct Codegen {
1166
1166
  /// free `fn f_native(f64,..)->f64` (all params `: number`, returns `number`, native-safe
1167
1167
  /// body). Direct calls to these route to the native fn, bypassing the boxed `value_call`.
1168
1168
  native_fns: std::collections::HashSet<String>,
1169
+ /// Declared param types of each `native_fns` member, so a call site knows which argument slots
1170
+ /// are `&TishStruct_…` (passed by reference) rather than a coerced numeric.
1171
+ native_fn_param_types: std::collections::HashMap<String, Vec<RustType>>,
1172
+ /// Names ever used AS A VALUE (see [`Self::collect_value_used_expr`]). A `native_fns` member
1173
+ /// absent from this set needs no boxed `Value::native` wrapper in `run()`.
1174
+ value_used_names: std::collections::HashSet<String>,
1169
1175
  /// Set while emitting the body of an i32-ABI native fn, so `return` coerces to `i32`.
1170
1176
  native_fn_abi_i32: bool,
1171
1177
  /// The subset of `native_fns` emitted with an `fn(i32, ..) -> i32` signature instead of the
@@ -1447,6 +1453,8 @@ impl Codegen {
1447
1453
  collection_instance_locals: std::collections::HashSet::new(),
1448
1454
  hoisted_string_chars: std::collections::HashMap::new(),
1449
1455
  native_fns: std::collections::HashSet::new(),
1456
+ value_used_names: std::collections::HashSet::new(),
1457
+ native_fn_param_types: std::collections::HashMap::new(),
1450
1458
  native_fns_i32: std::collections::HashSet::new(),
1451
1459
  native_fn_abi_i32: false,
1452
1460
  native_fn_body_emit: false,
@@ -3319,11 +3327,88 @@ impl Codegen {
3319
3327
  let mut global_names: std::collections::HashSet<String> =
3320
3328
  self.native_numeric_globals.keys().cloned().collect();
3321
3329
  global_names.extend(self.module_const_f64_arrays.keys().cloned());
3330
+ // Publish the all-numeric externs before the proof runs, so a body calling into an
3331
+ // extension crate is not rejected for the call alone. `is_numeric` covers f64, i32 and
3332
+ // the narrow ints; anything else (string, array, struct) would need a boxing boundary
3333
+ // inside a lowered fn, which defeats the purpose.
3334
+ let numeric_externs: std::collections::HashSet<String> = self
3335
+ .extern_fns
3336
+ .iter()
3337
+ .filter(|(_, sigs)| {
3338
+ // ⚠️ NOT `is_numeric()` ALONE — that matches F64 only (types.rs), while every GBA
3339
+ // `declare fn f(row: i32): i32` is `I32`. Filtering on it rejected all 64 declares
3340
+ // in ffta-hud (probed: numeric_externs=0) and made this whole relaxation inert.
3341
+ // Same predicate the rest of the proof uses: f64, i32, or a narrow storage int.
3342
+ //
3343
+ // Widening this REQUIRES the f64 return coercion in `emit_native_fn_body` — an
3344
+ // i32 extern returned from an f64-ABI fn is otherwise emitted raw (E0308).
3345
+ let ok = |t: &RustType| t.is_numeric() || t.is_integer_scalar();
3346
+ !sigs.is_empty()
3347
+ && sigs.iter().all(|s| ok(&s.ret) && s.params.iter().all(ok))
3348
+ })
3349
+ .map(|(n, _)| n.clone())
3350
+ .collect();
3351
+ if std::env::var("TISH_PROBE_EXTERNS").as_deref() == Ok("1") {
3352
+ let mut names: Vec<&String> = numeric_externs.iter().collect();
3353
+ names.sort();
3354
+ eprintln!(
3355
+ "[probe] extern_fns={} numeric_externs={} sample={:?}",
3356
+ self.extern_fns.len(),
3357
+ numeric_externs.len(),
3358
+ names.iter().take(8).collect::<Vec<_>>()
3359
+ );
3360
+ }
3361
+ Self::numeric_externs_with(|s| {
3362
+ s.clear();
3363
+ // `TISH_NO_EXTERN_LOWER=1` publishes an EMPTY set, which reverts this build to the
3364
+ // pre-extern-admission behaviour without rebuilding the compiler. It exists so a ROM
3365
+ // can be A/B'd against one binary: two 5-minute game builds instead of two toolchain
3366
+ // rebuilds plus two game builds, and no edit to a shared checkout between them.
3367
+ if std::env::var("TISH_NO_EXTERN_LOWER").as_deref() != Ok("1") {
3368
+ s.extend(numeric_externs.iter().cloned());
3369
+ }
3370
+ });
3322
3371
  self.native_fns = Self::collect_native_fns(
3323
3372
  &program.statements,
3324
3373
  &global_names,
3325
3374
  &native_vec_names,
3375
+ &self.type_aliases,
3326
3376
  );
3377
+ self.native_fn_param_types = program
3378
+ .statements
3379
+ .iter()
3380
+ .filter_map(|st| match st {
3381
+ Statement::FunDecl { name, params, .. }
3382
+ if self.native_fns.contains(name.as_ref()) =>
3383
+ {
3384
+ let tys = params
3385
+ .iter()
3386
+ .filter_map(|p| match p {
3387
+ FunParam::Simple(tp) => Some(
3388
+ tp.type_ann
3389
+ .as_ref()
3390
+ .map(|a| {
3391
+ RustType::from_annotation_with_aliases(
3392
+ a,
3393
+ &self.type_aliases,
3394
+ )
3395
+ })
3396
+ .unwrap_or(RustType::F64),
3397
+ ),
3398
+ _ => None,
3399
+ })
3400
+ .collect();
3401
+ Some((name.to_string(), tys))
3402
+ }
3403
+ _ => None,
3404
+ })
3405
+ .collect();
3406
+ // Which names are ever used as a VALUE rather than only called. Drives the
3407
+ // boxed-wrapper elision at `Statement::FunDecl` — see `collect_value_used_expr`.
3408
+ self.value_used_names.clear();
3409
+ for s in &program.statements {
3410
+ Self::collect_value_used_stmt(s, &mut self.value_used_names);
3411
+ }
3327
3412
  // Which of the survivors carry the all-`i32` signature. Computed AFTER the fixpoint,
3328
3413
  // so a function the proof rejected never reaches the i32 ABI either.
3329
3414
  self.native_fns_i32 = program
@@ -3332,7 +3417,23 @@ impl Codegen {
3332
3417
  .filter_map(|s| match s {
3333
3418
  Statement::FunDecl { name, params, return_type, .. }
3334
3419
  if self.native_fns.contains(name.as_ref())
3335
- && Self::fn_sig_all_i32(params, return_type) =>
3420
+ && (Self::fn_sig_all_i32(params, return_type)
3421
+ // A struct-param fn: its struct slot is typed on its own, so
3422
+ // `fn_sig_all_i32` can never hold. Take the i32 return ABI when the
3423
+ // return is `: i32` and every NON-struct param is `: i32` too —
3424
+ // otherwise the body computes i32 while the signature says f64.
3425
+ // A struct param qualifies on ONE numeric field, matching the
3426
+ // candidacy gate. If this still demanded all-numeric, a mixed-struct
3427
+ // fn would lower its body to i32 arithmetic and then be handed the
3428
+ // f64 ABI — computing one thing and declaring another.
3429
+ || (return_type.as_ref().is_some_and(Self::ann_is_i32)
3430
+ && params.iter().any(|p| matches!(p, FunParam::Simple(tp)
3431
+ if tp.type_ann.as_ref().is_some_and(|a|
3432
+ !Self::struct_numeric_fields(a, &self.type_aliases).is_empty())))
3433
+ && params.iter().all(|p| matches!(p, FunParam::Simple(tp)
3434
+ if tp.type_ann.as_ref().is_some_and(|a|
3435
+ Self::ann_is_i32(a)
3436
+ || !Self::struct_numeric_fields(a, &self.type_aliases).is_empty()))))) =>
3336
3437
  {
3337
3438
  Some(name.to_string())
3338
3439
  }
@@ -3347,9 +3448,11 @@ impl Codegen {
3347
3448
  Self::collect_native_lcg_fns(&program.statements, &self.native_fns);
3348
3449
  // #381 — fns on a call-graph cycle get guarded rotation copies (default ON;
3349
3450
  // 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
- {
3451
+ // #655: GBA was excluded because the no_std runtime had no stack-pressure probe.
3452
+ // `tishlang_runtime_gba` now derives a real floor from the link map (`__iwram_end`),
3453
+ // and it matters MORE here than on the host: 32 KB of IWRAM, no MMU, no guard page,
3454
+ // so an unguarded overflow silently overwrites agb's live data instead of faulting.
3455
+ self.native_recur_fns = if crate::native_recur_guard_enabled() {
3353
3456
  Self::compute_native_recur_fns(&program.statements, &self.native_fns)
3354
3457
  } else {
3355
3458
  std::collections::HashMap::new()
@@ -4234,6 +4337,10 @@ impl Codegen {
4234
4337
  val_code
4235
4338
  } else if val_ty == RustType::Value {
4236
4339
  RustType::F64.from_value_expr(&val_code)
4340
+ } else if val_ty.is_integer_scalar() {
4341
+ // An i32-typed value into an f64-backed global. Widening is exact; emitted
4342
+ // raw it is E0308.
4343
+ format!("(({}) as f64)", val_code)
4237
4344
  } else {
4238
4345
  val_code
4239
4346
  };
@@ -4350,6 +4457,17 @@ impl Codegen {
4350
4457
  // `fixed` local must be lifted to `Fixed` — the RHS emitter reports numeric
4351
4458
  // literals as F64, so without this a `fixed` reassignment mistypes (E0308).
4352
4459
  format!("tishlang_runtime::Fixed::from_raw((({}) * 256.0) as i32)", val_code)
4460
+ } else if rust_type == RustType::F64 && val_ty.is_integer_scalar() {
4461
+ // ⚠️ AN i32 VALUE INTO AN f64 LOCAL, IN STATEMENT POSITION. The COMPARISON
4462
+ // emitter already widens (`n > ((MAX_UNITS) as f64)`), so
4463
+ //
4464
+ // if (n > MAX_UNITS) { n = MAX_UNITS } packages/clansave
4465
+ //
4466
+ // compiled its test and rejected its assignment: `n = MAX_UNITS;`, expected
4467
+ // f64 found i32. Needs a native local AND an `: i32`-annotated global on the
4468
+ // right, which is why it stayed hidden until a game imported clansave beside
4469
+ // the rest of its packages. Widening is exact.
4470
+ format!("(({}) as f64)", val_code)
4353
4471
  } else {
4354
4472
  val_code
4355
4473
  };
@@ -7037,6 +7155,34 @@ impl Codegen {
7037
7155
  {
7038
7156
  return Ok(());
7039
7157
  }
7158
+ // Same idea, wider: this fn already has a native lowering (`<name>_native`) that
7159
+ // every direct call routes to, and nothing in the program uses the NAME as a value
7160
+ // — so the boxed `Value::native` wrapper is dead weight. It is not free weight: the
7161
+ // wrapper and its cell are permanent slots in the enclosing frame, and for module
7162
+ // fns that frame is `run()`, which on GBA holds every one of them at once against a
7163
+ // 29 KB IWRAM stack. Skipping it is what makes "type your functions" actually pay.
7164
+ // ⚠️ AND ONLY WHEN EVERY CALL SITE CAN ACTUALLY BE DIRECT. A fn taking a STRUCT
7165
+ // still falls back to `value_call` when the argument is a plain boxed local --
7166
+ // `area(r)` in tests/core/struct_return does exactly that while `area(makeRect(..))`
7167
+ // lowers, so one call site needed the wrapper the other made look dead (E0425).
7168
+ // Restricting the elision to all-scalar parameter lists keeps the win for the
7169
+ // numeric fns it was written for and stops guessing about the rest.
7170
+ let all_scalar_params = self
7171
+ .native_fn_param_types
7172
+ .get(name.as_ref())
7173
+ .map(|ps| {
7174
+ ps.iter().all(|p| {
7175
+ matches!(p, RustType::F64 | RustType::I32 | RustType::Bool)
7176
+ || p.is_narrow_int()
7177
+ })
7178
+ })
7179
+ .unwrap_or(false);
7180
+ if self.native_fns.contains(name.as_ref())
7181
+ && !self.value_used_names.contains(name.as_ref())
7182
+ && all_scalar_params
7183
+ {
7184
+ return Ok(());
7185
+ }
7040
7186
  // Use Rc<RefCell<>> pattern to allow recursive function calls
7041
7187
  // The function can reference itself through the cell
7042
7188
  let name_raw = name.as_ref();
@@ -9391,12 +9537,28 @@ impl Codegen {
9391
9537
  let i32_abi = self.native_fns_i32.contains(fname.as_ref());
9392
9538
  let want =
9393
9539
  if i32_abi { RustType::I32 } else { RustType::F64 };
9540
+ let ptys = self.native_fn_param_types.get(fname.as_ref()).cloned();
9394
9541
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
9395
9542
  let mut ok = true;
9396
- for a in args {
9543
+ for (i, a) in args.iter().enumerate() {
9397
9544
  if let CallArg::Expr(e) = a {
9545
+ // A struct slot takes a shared reference to the caller's struct;
9546
+ // coercing it to a number would be nonsense (and would not compile).
9547
+ let is_struct = matches!(
9548
+ ptys.as_ref().and_then(|v| v.get(i)),
9549
+ Some(RustType::Named { .. })
9550
+ );
9398
9551
  let (ac, at) = self.emit_typed_expr(e)?;
9399
- argc.push(self.coerce_native_arg(&ac, at, want.clone()));
9552
+ if is_struct {
9553
+ if matches!(at, RustType::Named { .. }) {
9554
+ argc.push(format!("&{}", ac));
9555
+ } else {
9556
+ ok = false;
9557
+ break;
9558
+ }
9559
+ } else {
9560
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
9561
+ }
9400
9562
  } else {
9401
9563
  ok = false;
9402
9564
  break;
@@ -9835,6 +9997,10 @@ impl Codegen {
9835
9997
  val_code
9836
9998
  } else if val_ty == RustType::Value {
9837
9999
  RustType::F64.from_value_expr(&val_code)
10000
+ } else if val_ty.is_integer_scalar() {
10001
+ // An i32-typed value into an f64-backed global. Widening is exact; emitted
10002
+ // raw it is E0308.
10003
+ format!("(({}) as f64)", val_code)
9838
10004
  } else {
9839
10005
  val_code
9840
10006
  };
@@ -9857,6 +10023,12 @@ impl Codegen {
9857
10023
  val_code
9858
10024
  } else if val_ty == RustType::Value {
9859
10025
  rust_type.from_value_expr(&val_code)
10026
+ } else if rust_type == RustType::F64 && val_ty.is_integer_scalar() {
10027
+ // ⚠️ An `: i32` global assigned into an f64-typed local. `let n = roster.len()`
10028
+ // infers f64, and `n = MAX_UNITS` (a `let MAX_UNITS: i32` in packages/clansave)
10029
+ // emitted `n = MAX_UNITS;` raw -- E0308, expected f64 found i32. Needs BOTH a
10030
+ // native local and an i32-annotated global, which is why so little hit it.
10031
+ format!("(({}) as f64)", val_code)
9860
10032
  } else {
9861
10033
  val_code
9862
10034
  };
@@ -10367,6 +10539,126 @@ impl Codegen {
10367
10539
  idents
10368
10540
  }
10369
10541
 
10542
+ /// Names used AS A VALUE — i.e. everywhere except the callee slot of a direct `f(..)` call.
10543
+ /// A module fn that is only ever called directly needs no boxed `Value::native` wrapper: the
10544
+ /// native lowering (`f_native`) already serves every call site, and the wrapper is a permanent
10545
+ /// slot in `run()`'s stack frame. On GBA `run()` holds every module fn at once — 605 of them in
10546
+ /// `examples/ffta` — against a 29 KB IWRAM stack, so dropping the unused wrappers is the
10547
+ /// difference between a game fitting and overwriting the audio mixer.
10548
+ ///
10549
+ /// ⚠️ CONSERVATIVE BY CONSTRUCTION. Anything this does not structurally recurse through falls
10550
+ /// back to [`Self::collect_expr_idents`], which marks every name inside as value-used — that
10551
+ /// only forfeits the optimisation, it never removes a wrapper something needs. And if it ever
10552
+ /// were wrong, the generated Rust names a binding that does not exist: a loud `E0425` at build
10553
+ /// time, not a silently wrong ROM.
10554
+ fn collect_value_used_expr(expr: &Expr, idents: &mut HashSet<String>) {
10555
+ match expr {
10556
+ Expr::Call { callee, args, .. } => {
10557
+ // The callee slot of a direct call is NOT a value use -- UNLESS the call cannot be
10558
+ // emitted as a direct one. A SPREAD call always goes through `value_call`, which
10559
+ // needs the boxed binding by name, so eliding the wrapper leaves `let _callee =
10560
+ // (sum3).clone()` referring to nothing (E0425, tests/core/spread).
10561
+ let spreads = args.iter().any(|a| matches!(a, CallArg::Spread(_)));
10562
+ if !matches!(&**callee, Expr::Ident { .. }) || spreads {
10563
+ Self::collect_value_used_expr(callee, idents);
10564
+ }
10565
+ for arg in args {
10566
+ match arg {
10567
+ CallArg::Expr(e) | CallArg::Spread(e) => {
10568
+ Self::collect_value_used_expr(e, idents)
10569
+ }
10570
+ }
10571
+ }
10572
+ }
10573
+ Expr::Binary { left, right, .. } => {
10574
+ Self::collect_value_used_expr(left, idents);
10575
+ Self::collect_value_used_expr(right, idents);
10576
+ }
10577
+ Expr::Unary { operand, .. } => Self::collect_value_used_expr(operand, idents),
10578
+ Expr::Conditional {
10579
+ cond,
10580
+ then_branch,
10581
+ else_branch,
10582
+ ..
10583
+ } => {
10584
+ Self::collect_value_used_expr(cond, idents);
10585
+ Self::collect_value_used_expr(then_branch, idents);
10586
+ Self::collect_value_used_expr(else_branch, idents);
10587
+ }
10588
+ Expr::Member { object, prop, .. } => {
10589
+ Self::collect_value_used_expr(object, idents);
10590
+ if let MemberProp::Expr(e) = prop {
10591
+ Self::collect_value_used_expr(e, idents);
10592
+ }
10593
+ }
10594
+ Expr::Index { object, index, .. } => {
10595
+ Self::collect_value_used_expr(object, idents);
10596
+ Self::collect_value_used_expr(index, idents);
10597
+ }
10598
+ other => Self::collect_expr_idents(other, idents),
10599
+ }
10600
+ }
10601
+
10602
+ /// Statement-level driver for [`Self::collect_value_used_expr`]. Mirrors the shape of
10603
+ /// [`Self::collect_stmt_idents`]; unknown statement forms fall back to it (conservative).
10604
+ fn collect_value_used_stmt(stmt: &Statement, idents: &mut HashSet<String>) {
10605
+ match stmt {
10606
+ Statement::ExprStmt { expr, .. } => Self::collect_value_used_expr(expr, idents),
10607
+ Statement::VarDecl { init, .. } => {
10608
+ if let Some(e) = init {
10609
+ Self::collect_value_used_expr(e, idents);
10610
+ }
10611
+ }
10612
+ Statement::Return { value, .. } => {
10613
+ if let Some(e) = value {
10614
+ Self::collect_value_used_expr(e, idents);
10615
+ }
10616
+ }
10617
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
10618
+ for s in statements {
10619
+ Self::collect_value_used_stmt(s, idents);
10620
+ }
10621
+ }
10622
+ Statement::If {
10623
+ cond,
10624
+ then_branch,
10625
+ else_branch,
10626
+ ..
10627
+ } => {
10628
+ Self::collect_value_used_expr(cond, idents);
10629
+ Self::collect_value_used_stmt(then_branch, idents);
10630
+ if let Some(s) = else_branch {
10631
+ Self::collect_value_used_stmt(s, idents);
10632
+ }
10633
+ }
10634
+ Statement::While { cond, body, .. } => {
10635
+ Self::collect_value_used_expr(cond, idents);
10636
+ Self::collect_value_used_stmt(body, idents);
10637
+ }
10638
+ Statement::FunDecl { body, .. } => Self::collect_value_used_stmt(body, idents),
10639
+ // ⚠️ An EXPORTED fn must keep its boxed wrapper: the module namespace hands it out as
10640
+ // a `Value`, and an importer may do anything with it. Mark the exported name itself as
10641
+ // value-used, then walk the declaration normally.
10642
+ Statement::Export { declaration, .. } => match &**declaration {
10643
+ tishlang_ast::ExportDeclaration::Named(inner) => {
10644
+ if let Statement::FunDecl { name, .. } = &**inner {
10645
+ idents.insert(name.to_string());
10646
+ }
10647
+ Self::collect_value_used_stmt(inner, idents);
10648
+ }
10649
+ tishlang_ast::ExportDeclaration::Default(e) => Self::collect_value_used_expr(e, idents),
10650
+ tishlang_ast::ExportDeclaration::ReExport { specifiers, .. } => {
10651
+ for sp in specifiers {
10652
+ if let tishlang_ast::ImportSpecifier::Named { name, .. } = sp {
10653
+ idents.insert(name.to_string());
10654
+ }
10655
+ }
10656
+ }
10657
+ },
10658
+ other => Self::collect_stmt_idents(other, idents),
10659
+ }
10660
+ }
10661
+
10370
10662
  fn collect_expr_idents(expr: &Expr, idents: &mut HashSet<String>) {
10371
10663
  match expr {
10372
10664
  Expr::Ident { name, .. } => {
@@ -19222,6 +19514,7 @@ impl Codegen {
19222
19514
  statements: &[Statement],
19223
19515
  globals: &std::collections::HashSet<String>,
19224
19516
  native_vec_names: &std::collections::HashSet<String>,
19517
+ aliases: &std::collections::HashMap<String, RustType>,
19225
19518
  ) -> std::collections::HashSet<String> {
19226
19519
  use std::collections::HashSet;
19227
19520
  let mut cand: HashSet<String> = HashSet::new();
@@ -19240,7 +19533,14 @@ impl Codegen {
19240
19533
  let params_ok = params.iter().all(|p| {
19241
19534
  matches!(p, FunParam::Simple(tp)
19242
19535
  if tp.default.is_none()
19243
- && tp.type_ann.as_ref().map_or(true, Self::ann_is_number))
19536
+ && tp.type_ann.as_ref().map_or(true, |a| {
19537
+ Self::ann_is_number(a)
19538
+ // At least one numeric field is enough to be a CANDIDATE. The
19539
+ // fixpoint below still has to prove the body, and a read of a
19540
+ // non-numeric field fails that proof — so admitting a mixed
19541
+ // record here cannot lower anything that was not already sound.
19542
+ || !Self::struct_numeric_fields(a, aliases).is_empty()
19543
+ }))
19244
19544
  });
19245
19545
  // Return: an annotated `: number`, OR unannotated with all-numeric returns
19246
19546
  // (verified in the fixpoint via `returns_numeric`), so the native `-> f64` holds.
@@ -19253,7 +19553,19 @@ impl Codegen {
19253
19553
  // still has to prove the body native-safe and every return numeric — the annotation
19254
19554
  // never decides that on its own (see `liar` in the issue).
19255
19555
  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);
19556
+ // A struct-param fn can never satisfy `fn_sig_all_i32` (its struct slot is not an
19557
+ // `i32`), so an `: i32` return would reject it even though the return is perfectly
19558
+ // native. Accept an i32 return for those; the f64 ABI carries it, and the existing
19559
+ // all-i32 fast path is untouched for everyone else.
19560
+ let has_struct_param = params.iter().any(|p| {
19561
+ matches!(p, FunParam::Simple(tp)
19562
+ if tp.type_ann.as_ref()
19563
+ .is_some_and(|a| !Self::struct_numeric_fields(a, aliases).is_empty()))
19564
+ });
19565
+ let ret_ok = ret_ok
19566
+ || i32_ok
19567
+ || (has_struct_param && return_type.as_ref().is_some_and(Self::ann_is_i32));
19568
+ let params_ok = params_ok || i32_ok;
19257
19569
  // #320: 0-param numeric fns (e.g. k_nucleotide's `nextBase()` — mutates a numeric
19258
19570
  // global, returns number) are eligible too; the fixpoint below still proves the body
19259
19571
  // native-safe + all-numeric-returns, so `fn name_native() -> f64` is sound.
@@ -19307,6 +19619,25 @@ impl Codegen {
19307
19619
  }
19308
19620
  let pnames: HashSet<String> =
19309
19621
  params.iter().flat_map(|p| p.bound_names()).map(|n| n.to_string()).collect();
19622
+ // Each struct param maps to the fields that can lower — its NUMERIC ones. A param
19623
+ // with no numeric field is left out entirely, so it reads exactly as "not a struct
19624
+ // param" and nothing about it is native.
19625
+ let sparams: std::collections::HashMap<String, HashSet<String>> = params
19626
+ .iter()
19627
+ .filter_map(|p| match p {
19628
+ FunParam::Simple(tp) => tp.type_ann.as_ref().and_then(|a| {
19629
+ let fs = Self::struct_numeric_fields(a, aliases);
19630
+ (!fs.is_empty()).then(|| (tp.name.to_string(), fs))
19631
+ }),
19632
+ _ => None,
19633
+ })
19634
+ .collect();
19635
+ Self::struct_params_with(|m| {
19636
+ m.clear();
19637
+ for (k, v) in sparams.iter() {
19638
+ m.insert(k.clone(), v.clone());
19639
+ }
19640
+ });
19310
19641
  let nums = Self::native_fn_numeric_locals(
19311
19642
  body,
19312
19643
  &pnames,
@@ -19314,9 +19645,20 @@ impl Codegen {
19314
19645
  &cand,
19315
19646
  native_vec_names,
19316
19647
  );
19317
- if !Self::native_safe_stmt(body, &pnames, &cand, globals, &nums, native_vec_names)
19318
- || !Self::returns_numeric(body, &pnames, &cand, globals, &nums)
19319
- {
19648
+ let safe = Self::native_safe_stmt(body, &pnames, &cand, globals, &nums, native_vec_names);
19649
+ let rets = Self::returns_numeric(body, &pnames, &cand, globals, &nums);
19650
+ let ok = safe && rets;
19651
+ if !ok && std::env::var("TISH_PROBE_LOWERING").as_deref() == Ok("1") {
19652
+ // Which HALF rejected it. "body not native-safe" and "a return is not numeric"
19653
+ // are entirely different fixes, and the fixpoint silently collapsed both into a
19654
+ // candidate quietly disappearing.
19655
+ eprintln!(
19656
+ "[probe] {} rejected: body_safe={} returns_numeric={}",
19657
+ name, safe, rets
19658
+ );
19659
+ }
19660
+ Self::struct_params_with(|s| s.clear());
19661
+ if !ok {
19320
19662
  remove.push(name.to_string());
19321
19663
  }
19322
19664
  }
@@ -19512,6 +19854,114 @@ impl Codegen {
19512
19854
  }
19513
19855
 
19514
19856
  /// Numeric locals in an M5-eligible fn body (params + fixpoint literal/copy seeds).
19857
+ /// The current function's params that are annotated with a type alias whose fields are ALL
19858
+ /// numeric, so `p.field` is a machine-word load. Set for the duration of one function's
19859
+ /// native-safety proof (`collect_native_fns`) and read by the proof's leaf predicates.
19860
+ ///
19861
+ /// A side-channel rather than a threaded argument on purpose: the proof is five mutually
19862
+ /// recursive static helpers, and widening all of their signatures to carry a set that only two
19863
+ /// leaves consult is a large diff for no added safety. Codegen is single-threaded; the set is
19864
+ /// cleared after each function.
19865
+ ///
19866
+ /// ⚠️ THE FIELD SET IS WHAT MAKES THIS SOUND, AND IT REPLACED A BARE NAME SET FOR THAT REASON.
19867
+ /// While the map held only names, admitting a struct with a `string` field would have made
19868
+ /// `p.name` "numeric" here and emitted Rust that does not compile — so the gate demanded that
19869
+ /// EVERY field be numeric, and one string disqualified the whole function. Carrying the numeric
19870
+ /// fields per param removes that ceiling: `p.x` proves native and `p.name` does not, so a mixed
19871
+ /// record lowers its arithmetic and a function that touches the string simply fails the proof
19872
+ /// and stays boxed. Never widen this to a bare name test again.
19873
+ fn struct_params_with<R>(
19874
+ f: impl FnOnce(&mut std::collections::HashMap<String, HashSet<String>>) -> R,
19875
+ ) -> R {
19876
+ thread_local! {
19877
+ static STRUCT_PARAMS:
19878
+ std::cell::RefCell<std::collections::HashMap<String, HashSet<String>>> =
19879
+ std::cell::RefCell::new(std::collections::HashMap::new());
19880
+ }
19881
+ STRUCT_PARAMS.with(|c| f(&mut c.borrow_mut()))
19882
+ }
19883
+
19884
+ /// Declared externs whose signature is numbers in, a number out.
19885
+ ///
19886
+ /// A `declare fn` already resolves to a native Rust fn — the emitter calls
19887
+ /// `<crate>::<symbol>_typed(..)` directly (see [`Self::extern_sig_for_arity`]). But the proof
19888
+ /// only accepted calls to `cand` (the tish fns being lowered), so ANY function calling into an
19889
+ /// extension crate was rejected and stayed boxed. On a game that is nearly everything: measured
19890
+ /// on `examples/ffta-hud`, a fn doing `u.col * 100 + u.row` lowered while the one beside it
19891
+ /// calling `tac_add_unit(u.col, u.row, …)` did not, purely because of the call.
19892
+ ///
19893
+ /// Only ALL-NUMERIC signatures qualify. An extern taking or returning a string, an array or a
19894
+ /// struct is not callable from a lowered body without a boxing boundary, which is the thing
19895
+ /// being removed.
19896
+ ///
19897
+ /// A thread-local for the same reason [`Self::struct_params_with`] is one: the proof is a set of
19898
+ /// associated fns with no `&self`, and threading a new parameter through every one of them would
19899
+ /// touch far more code than the fact being communicated.
19900
+ fn numeric_externs_with<R>(f: impl FnOnce(&mut HashSet<String>) -> R) -> R {
19901
+ thread_local! {
19902
+ static NUMERIC_EXTERNS: std::cell::RefCell<HashSet<String>> =
19903
+ std::cell::RefCell::new(HashSet::new());
19904
+ }
19905
+ NUMERIC_EXTERNS.with(|c| f(&mut c.borrow_mut()))
19906
+ }
19907
+
19908
+ /// `p.field` where `p` is one of [`Self::struct_params_with`]'s params AND `field` is one of its
19909
+ /// NUMERIC fields. Both halves matter: the name alone would admit `p.name` on a mixed record.
19910
+ fn is_struct_param_field(e: &Expr) -> bool {
19911
+ let Expr::Member { object, prop, .. } = e else {
19912
+ return false;
19913
+ };
19914
+ let MemberProp::Name { name: fname, .. } = prop else {
19915
+ return false;
19916
+ };
19917
+ let Expr::Ident { name, .. } = object.as_ref() else {
19918
+ return false;
19919
+ };
19920
+ Self::struct_params_with(|m| {
19921
+ m.get(name.as_ref()).is_some_and(|fs| fs.contains(fname.as_ref()))
19922
+ })
19923
+ }
19924
+
19925
+ /// Every field of this annotation's aliased struct is a native NUMBER (the invariant above).
19926
+ fn ann_is_all_numeric_struct(
19927
+ ann: &TypeAnnotation,
19928
+ aliases: &std::collections::HashMap<String, RustType>,
19929
+ ) -> bool {
19930
+ matches!(
19931
+ RustType::from_annotation_with_aliases(ann, aliases),
19932
+ RustType::Named { ref fields, .. }
19933
+ if !fields.is_empty()
19934
+ && fields.iter().all(|(_, t)| {
19935
+ matches!(t, RustType::F64 | RustType::I32) || t.is_narrow_int()
19936
+ })
19937
+ )
19938
+ }
19939
+
19940
+ /// The NUMERIC fields of this annotation's aliased struct, by name.
19941
+ ///
19942
+ /// [`Self::ann_is_all_numeric_struct`] asks a whole-struct question, and that is the ceiling on
19943
+ /// what can lower: half of a game's record shapes carry a string, a null or an array beside
19944
+ /// their numbers, and one such field disqualifies the whole function even when it only ever
19945
+ /// touches the numeric ones. Answering per FIELD lets `m.x` lower while `m.name` does not — a
19946
+ /// function that reads the string fails the proof exactly as it does today, so nothing that
19947
+ /// lowers now stops lowering.
19948
+ ///
19949
+ /// An empty set means nothing here can lower: not a struct, or a struct with no numeric field.
19950
+ /// Callers treat empty as "not a struct param", which keeps the conservative default.
19951
+ fn struct_numeric_fields(
19952
+ ann: &TypeAnnotation,
19953
+ aliases: &std::collections::HashMap<String, RustType>,
19954
+ ) -> HashSet<String> {
19955
+ match RustType::from_annotation_with_aliases(ann, aliases) {
19956
+ RustType::Named { ref fields, .. } => fields
19957
+ .iter()
19958
+ .filter(|(_, t)| matches!(t, RustType::F64 | RustType::I32) || t.is_narrow_int())
19959
+ .map(|(n, _)| n.to_string())
19960
+ .collect(),
19961
+ _ => HashSet::new(),
19962
+ }
19963
+ }
19964
+
19515
19965
  fn native_fn_numeric_locals(
19516
19966
  body: &Statement,
19517
19967
  params: &HashSet<String>,
@@ -19545,6 +19995,10 @@ impl Codegen {
19545
19995
  ..
19546
19996
  } => {
19547
19997
  let numeric = type_ann.as_ref().is_some_and(Self::ann_is_number)
19998
+ // `: i32` is a number too. Without this, `let j: i32 = …` was never seeded as a
19999
+ // numeric local, so every later use of `j` failed the safety proof and the whole
20000
+ // function stayed boxed — for an annotation the author added to make it FASTER.
20001
+ || type_ann.as_ref().is_some_and(Self::ann_is_i32)
19548
20002
  || matches!(
19549
20003
  init,
19550
20004
  Some(Expr::Literal {
@@ -19564,7 +20018,13 @@ impl Codegen {
19564
20018
  }) if matches!(
19565
20019
  callee.as_ref(),
19566
20020
  Expr::Ident { name: fnname, .. }
19567
- if cand.contains(fnname.as_ref()) || native_vec_names.contains(fnname.as_ref())
20021
+ if cand.contains(fnname.as_ref())
20022
+ || native_vec_names.contains(fnname.as_ref())
20023
+ // A numbers-in-number-out extern yields a number, same as the
20024
+ // safety proof and `numeric_shaped` already accept. Every
20025
+ // analysis has to agree or a local initialised from an extern is
20026
+ // numeric in one and unknown in another.
20027
+ || Self::numeric_externs_with(|s| s.contains(fnname.as_ref()))
19568
20028
  )
19569
20029
  );
19570
20030
  if numeric {
@@ -19676,6 +20136,13 @@ impl Codegen {
19676
20136
  nums: &HashSet<String>,
19677
20137
  native_vec_names: &std::collections::HashSet<String>,
19678
20138
  ) -> bool {
20139
+ // A field read off an all-numeric struct param is a plain numeric load — `p.x` on a
20140
+ // `&TishStruct_P` is a machine word, not a boxed property lookup. Without this the proof
20141
+ // rejects every object-taking fn, which is why typing a game's functions never produced a
20142
+ // native lowering. See `STRUCT_PARAMS`.
20143
+ if Self::is_struct_param_field(expr) {
20144
+ return true;
20145
+ }
19679
20146
  match expr {
19680
20147
  Expr::Literal { value, .. } => matches!(value, Literal::Number(_) | Literal::Bool(_)),
19681
20148
  Expr::Ident { name, .. } => {
@@ -19750,7 +20217,13 @@ impl Codegen {
19750
20217
  }
19751
20218
  match callee.as_ref() {
19752
20219
  Expr::Ident { name, .. } => {
19753
- cand.contains(name.as_ref()) || native_vec_names.contains(name.as_ref())
20220
+ cand.contains(name.as_ref())
20221
+ || native_vec_names.contains(name.as_ref())
20222
+ // A numbers-in-number-out extern lowers to a direct
20223
+ // `<crate>::<symbol>_typed(..)` call, so it is native-safe. Without this
20224
+ // any fn touching an extension crate stayed boxed — which in a game is
20225
+ // most of them.
20226
+ || Self::numeric_externs_with(|s| s.contains(name.as_ref()))
19754
20227
  }
19755
20228
  Expr::Member { object, prop: MemberProp::Name { name: m, .. }, .. } => {
19756
20229
  matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
@@ -19810,6 +20283,10 @@ impl Codegen {
19810
20283
  globals: &std::collections::HashSet<String>,
19811
20284
  nums: &HashSet<String>,
19812
20285
  ) -> bool {
20286
+ // `p.field` on an all-numeric struct param IS a number — see `struct_params_with`.
20287
+ if Self::is_struct_param_field(e) {
20288
+ return true;
20289
+ }
19813
20290
  match e {
19814
20291
  Expr::Literal { value: Literal::Number(_), .. } => true,
19815
20292
  Expr::Ident { name, .. } => {
@@ -19855,7 +20332,12 @@ impl Codegen {
19855
20332
  && Self::numeric_shaped(else_branch, params, cand, globals, nums)
19856
20333
  }
19857
20334
  Expr::Call { callee, .. } => match callee.as_ref() {
19858
- Expr::Ident { name, .. } => cand.contains(name.as_ref()),
20335
+ // Same set as the safety proof: a numbers-in-number-out extern RETURNS a number, so
20336
+ // `return tac_unit_hp(id)` is a numeric return and the fn keeps its native ABI.
20337
+ Expr::Ident { name, .. } => {
20338
+ cand.contains(name.as_ref())
20339
+ || Self::numeric_externs_with(|s| s.contains(name.as_ref()))
20340
+ }
19859
20341
  Expr::Member { object, prop: MemberProp::Name { name: m, .. }, .. } => {
19860
20342
  matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
19861
20343
  && matches!(
@@ -20055,12 +20537,28 @@ impl Codegen {
20055
20537
  }
20056
20538
  let i32_abi = self.native_fns_i32.contains(name.as_ref());
20057
20539
  let abi = if i32_abi { "i32" } else { "f64" };
20540
+ // Per-parameter type. A struct param lowers to `&TishStruct_<alias>` — one pointer,
20541
+ // no boxing, and `p.field` becomes a machine-word load. Numeric params keep the
20542
+ // single f64/i32 ABI. `mut` only on the numerics: a shared reference is not rebound.
20058
20543
  let plist: Vec<String> = params
20059
20544
  .iter()
20060
20545
  .filter_map(|p| match p {
20061
- FunParam::Simple(tp) => {
20062
- Some(format!("mut {}: {}", Self::escape_ident(tp.name.as_ref()), abi))
20063
- }
20546
+ FunParam::Simple(tp) => Some(
20547
+ match tp.type_ann.as_ref().map(|a| {
20548
+ RustType::from_annotation_with_aliases(a, &self.type_aliases)
20549
+ }) {
20550
+ Some(RustType::Named { name: sname, .. }) => format!(
20551
+ "{}: &{}",
20552
+ Self::escape_ident(tp.name.as_ref()),
20553
+ crate::types::named_struct_ident(&sname)
20554
+ ),
20555
+ _ => format!(
20556
+ "mut {}: {}",
20557
+ Self::escape_ident(tp.name.as_ref()),
20558
+ abi
20559
+ ),
20560
+ },
20561
+ ),
20064
20562
  _ => None,
20065
20563
  })
20066
20564
  .collect();
@@ -20079,10 +20577,13 @@ impl Codegen {
20079
20577
  self.type_context.push_scope();
20080
20578
  for p in params {
20081
20579
  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
- );
20580
+ let ty = match tp.type_ann.as_ref().map(|a| {
20581
+ RustType::from_annotation_with_aliases(a, &self.type_aliases)
20582
+ }) {
20583
+ Some(named @ RustType::Named { .. }) => named,
20584
+ _ => if i32_abi { RustType::I32 } else { RustType::F64 },
20585
+ };
20586
+ self.type_context.define(tp.name.as_ref(), ty);
20086
20587
  }
20087
20588
  }
20088
20589
  let copy_suffix = if copy_idx == 0 { String::new() } else { format!("_r{}", copy_idx) };
@@ -20281,6 +20782,20 @@ impl Codegen {
20281
20782
  // An f64-typed expression returned from an `i32` fn takes the same ToInt32 the
20282
20783
  // boxed path would have applied at the consumer.
20283
20784
  format!("tishlang_runtime::to_int32({})", code)
20785
+ } else if want == RustType::F64 && ty.is_integer_scalar() {
20786
+ // An INTEGER-typed expression returned from an `f64`-ABI fn. Widening is exact
20787
+ // (i32 and every narrow width fit in f64), and without it the body computes an
20788
+ // integer while the signature declares f64:
20789
+ //
20790
+ // fn uiKeys_native() -> f64 { return tish_agb::keys_edge_typed(); }
20791
+ // ^ i32, expected f64 (E0308)
20792
+ //
20793
+ // This was unreachable while the only integer-returning callees were fns the
20794
+ // proof had already typed; it became reachable when calls to i32 externs were
20795
+ // admitted as native-safe, because an extern reports its own declared return
20796
+ // type. `Fixed` is deliberately NOT covered — it is fixed-point, so `as f64`
20797
+ // would reinterpret the scale rather than convert it.
20798
+ format!("(({}) as f64)", code)
20284
20799
  } else {
20285
20800
  code
20286
20801
  };
@@ -26125,10 +26640,27 @@ impl Codegen {
26125
26640
  };
26126
26641
  let mut argc: Vec<String> = Vec::with_capacity(args.len());
26127
26642
  let mut ok = true;
26128
- for a in args {
26643
+ for (idx, a) in args.iter().enumerate() {
26129
26644
  if let CallArg::Expr(e) = a {
26645
+ // A struct slot takes `&caller_struct`; coercing it to a number
26646
+ // would not compile. Mirrors the boxed-context call site.
26647
+ let is_struct = matches!(
26648
+ self.native_fn_param_types
26649
+ .get(fname.as_ref())
26650
+ .and_then(|v| v.get(idx)),
26651
+ Some(RustType::Named { .. })
26652
+ );
26130
26653
  let (ac, at) = self.emit_typed_expr(e)?;
26131
- argc.push(self.coerce_native_arg(&ac, at, want.clone()));
26654
+ if is_struct {
26655
+ if matches!(at, RustType::Named { .. }) {
26656
+ argc.push(format!("&{}", ac));
26657
+ } else {
26658
+ ok = false;
26659
+ break;
26660
+ }
26661
+ } else {
26662
+ argc.push(self.coerce_native_arg(&ac, at, want.clone()));
26663
+ }
26132
26664
  } else {
26133
26665
  ok = false;
26134
26666
  break;