Tags: tishlang/tish
Tags
fix(native): #669 — two generated-Rust compile failures on ordinary a… …rray shapes (#677) Both are hard `rustc` errors in the emitted program, so the tish source simply does not build, and both shapes are unremarkable. E0382 — pushing a bare variable MOVES it. For a non-Copy element type (`Value`, `String`, an aggregate) the identifier was moved into the Vec, so a second push of the same variable in one loop is a use-after-move: while (i < 4) { C.push(i); D.push(i); i = i + 1 } error[E0382]: use of moved value: `i` Filling two arrays from one counter. Giving each loop its own counter avoids it, which is what made it read as arbitrary. Bare identifiers of a non-Copy element type are now cloned into the push; Copy elements keep the bare move. E0596 — a captured binding handed to a native-vec fn as `&mut Vec` was not declared `mut`: let U = U_capt.clone(); mutate_nv(&mut U); // cannot borrow `U` as mutable The prelude binding is now `let mut`. The generated crate is `#![allow(unused, ...)]`, which covers `unused_mut`, so this is silent wherever the binding is never mutated. Fixing the second also makes an UNANNOTATED array passed to a mutating callee return the right value on host native, which is one shape of #668. The ANNOTATED case is still wrong there and is deliberately left out of the fixture rather than skipped — #668 tracks it and is blocked on a separate design problem. Two tests/core fixtures, both agreeing across interp/vm/native/node.
fix(native): #675 — a declare-fn with a non-scalar parameter must not… … register a typed extern (#676) `readonly` (#672) did the right thing to the aliasing analysis but was unusable on the natives it was built for: declaring the function to carry the marker also opted it into typed-extern dispatch, and an array parameter has no scalar ABI to generate a sibling from. error[E0425]: cannot find function `grid_from_gids_typed` The eligibility check asked `RustType::is_native()`, which is only `!= RustType::Value` — so `Vec`, objects, tuples and `Option`/`Boxed` wrappers all passed it. Added `has_scalar_extern_abi`, which asks the question actually being decided: does this type cross the extern boundary as itself? Declaration and dispatch are now independent, which is what this case wants: the declaration still contributes its `readonly` flags to the aliasing analysis, so the caller's array keeps its typed representation, while the CALL stays on the boxed namespace path it already used — it runs once per level and nobody is trying to speed it up. The `cargo_example_project` fixture gained an array-taking `sink(n: number, readonly data: number[])` alongside the existing scalar `add`, so one compile covers both: no `sink_typed`, and `add_typed` still direct. Verified to fail without the change.
feat(native): #672 — `readonly` on a declare-fn parameter keeps the c… …aller's array typed (#674) #663 stopped boxing an escaping array when the callee can be proven not to alias it, but its predicate returns false for a `cargo:` native: the body is invisible, and conservative is the only safe guess. So an array handed to a native once per level was still boxed on every read everywhere in the program — measured at 3.8x (1.55 ticks/read against 5.87). A native's contract is already declared, though, and that is the fact #663 was missing rather than one it decided against: declare fn grid_from_gids(w: i32, h: i32, readonly data: i32[]): void `readonly` asserts what the compiler cannot see — read during the call, not retained, not written through — and the array keeps its typed representation. Opt-in, deliberately. The issue floats a blanket rule that declare-fn array params are read-only unless marked otherwise; that inverts the safe default, and a native that DOES write through its argument would silently lose those writes with nothing in the source to suggest it. Silence has to keep meaning "assume it might alias". `readonly` is not made a reserved word — it is a marker only when an identifier follows, so `declare fn f(readonly: i32)` still declares a parameter with that name. The formatter preserves it: dropping it would silently reintroduce the per-read cost on the next `fmt`, so it is load-bearing rather than cosmetic. Verified on the issue's shape: an array never forwarded stays typed, one forwarded to a `readonly`-marked native stays typed, and one forwarded to an unmarked native still boxes. Full workspace suite green (749).
fix(native): #658 — an out-of-range promoted-array read answers null,… … like every other backend (#673) Completes the previous commit. Keeping the read in the integer domain meant an out-of-range index produced the in-band sentinel `0` where it used to produce `NaN` — but neither is what "absent" means, and neither matched interp/vm/node, which all answer `null`. The sentinel only exists because a typed slot has no value spare for it. In VALUE position there is no such constraint: the representation is already a `Value`, so the bounds check can yield `Value::Null` directly. Added at the two places an unproven read reaches a boxed context — the general index-in-value path and the `ops::*` operand boxing, which is the one the reported `console.log("x=" + LIT[bad])` shape goes through. LIT[999999] before: native NaN, then 0 now: null, on every backend Also fixes the `E0308` half of #669, which this fixture ran straight into: an array lowered to `Vec<Value>` with a native RHS passed the raw `f64` through unconverted, so `WARM[i] = …` did not compile at all once the array was boxed for any reason. It now boxes the operand. Verified on gba (mgba) and host native against interp/vm/node — all four now print `add=30,60 oobInt=null oobFlt=null`. Full workspace suite green.
fix(resolve): #659 — two modules must not bind the same top-level nam… …e via imports (#671) An import specifier lowers to `const <bind> = <source>` in the one merged top-level scope, so two modules that pick the same `bind` emitted two `const`s with one name. `tish build` succeeded either way — only `node --check` or actually loading the bundle caught it, so it could ship unnoticed. Two cases, and the reported one is the milder: same source duplicate but equivalent. One alias now serves every importer, emitted at the first importer's position, which precedes every later use. different source `import { x as f }` in one module and `import { y as f }` in another. The second binding SHADOWED the first, so a call in the FIRST module silently ran the other module's function — on interp and vm too, not just in the js bundle. These are now renamed apart, with that module's references rewritten. The split runs after the export table is built, because that is what makes `source` knowable; doing it in the earlier declaration-isolation pass would key off import bindings and churn every ordinary import (the case #587's note warns about, and it broke #654's fixture when tried). Verified: the reported repro now passes `node --check` and prints 9 8 7 on interp/vm/node; the aliasing variant prints x1:1 y2:2 where it previously printed y2:1 y2:2 on every backend. Full workspace suite green (738).
fix: perf(gba): #658 — keep the bounds-checked promoted-array read in… … the integer domain (#670) #645 gave the masked / provably-in-range read a direct `G_LIT[i]` load, but the fallback emitted for every other index shape stayed `f64`: { let _i = ...; if _i < 2304 { G_LIT[_i] as f64 } else { f64::NAN } } as i32 That is two soft-float calls per element on a chip with no FPU — one widening out, one narrowing at the consumer. The report measures ~25 ticks/element against ~0.4 for the identical array, values and loop read behind a mask. The `f64::NAN` sentinel that shape existed to preserve is not a semantic any backend agrees with. For an out-of-range read: interp / vm / node -> null native -> NaN So NaN was already a native-only divergence, and the round trip was protecting nothing. An integer consumer cannot tell the difference either way, because `f64::NAN as i32` is already 0 — exactly the sentinel used here — so the common case is byte-identical and simply loses the conversions. Only a read reaching a boxed context can observe it, and there it trades one wrong answer for another; that divergence is pre-existing and tracked separately. Non-integer promoted arrays keep the f64/NaN form unchanged. Verified on GBA (mgba): the additive-index result now matches the masked one and matches interp/vm/node. Full workspace suite green (738).
fix: perf(gba): #663 — only box an escaping array when the callee can… … actually alias it (#667) collect_escaping_array_names (#597) boxed an annotated module array as soon as it was passed as a call argument. But being forwarded is not by itself an aliasing hazard — it is one only if the callee writes through the reference or lets it escape. Boxing on the mere fact of a call costs a boxed read on every OTHER access, which is usually a hot loop, while the call that caused it may run once per level. The report measures 3.8x per read. An array now stays typed when EVERY forwarding site names a locally-declared fn whose receiving parameter is neither written, escaped, nor forwarded onward. Anything opaque — a `cargo:` native, a method call, a value-position callee, a rest/destructured param, an unknown arity — keeps today's conservative boxing. The forwarding sites are recorded in `scan_param_use` at the one place that already sets `forwarded`, rather than by a second AST walk. That is deliberate: a separate walker could miss an expression variant and silently call a real forward harmless, which is a wrong answer rather than a slow one. Verified on GBA (mgba) against interp/vm/node: a read-only callee leaves the array typed, while the mutating, forwarding and escaping shapes all still box. Note the trigger is broader than the report — any boxed call does it, not only a `cargo:` native.
fix(native): #665 — don't hold a read borrow across value_call in arg… …ument position (#666) A module variable in call-argument position lowered to `(*cell.borrow())`, whose guard is a temporary and so lives to the end of the enclosing STATEMENT — that is, across the `value_call`. A callee that assigns that same variable then fails its `borrow_mut`. The shape is ordinary — `setG(g)`, `descend(seed)` — and it does not fail at the call site but inside the callee, on a line that only assigns a local-looking name, with a panic that names vmref.rs rather than anything in the program. Both failure modes reproduce: gba panics at tish_core/src/vmref.rs:86 (borrow_mut) native host DEADLOCKS — under a VmRef backed by a Mutex the second lock is non-reentrant, so the process stops at 0% CPU with no output The boxed branch immediately below has always used `vm_read`, which copies out and drops the guard at its own return; its doc comment describes this exact hazard one build config over. Use it for the native-typed branch too. The argument is passed by value either way, so this is not a semantic change. tests/core/module_var_self_arg.tish covers the numeric, string and boxed setter-called-with-its-own-variable shapes; verified to hang pre-fix on native and to agree across interp/vm/native/gba/node after.
fix: perf(gba): #581 — stop paying fat LTO on every ROM build (#664) build_gba_rom hardcoded a release profile with lto = "fat" and one codegen unit, so every `tish build --target gba` paid a full fat-LTO link over the ~20k-line run() a real game generates. Profiles now come from one place, gba_cargo_profiles_toml: default opt-level 3, thin LTO, 8 CGUs TISH_GBA_FAT_LTO=1 opt-level 3, fat LTO, 1 CGU (smallest ROM) TISH_FAST_NATIVE_BUILD=1 opt-level 1, no LTO, 16 CGUs (iteration) TISH_GBA_DEBUG=1 keep release debuginfo Release debuginfo is now off by default rather than debug = true. Measured on a synthetic 400-fn ROM (14,173-line generated main.rs), incremental rebuild, isolated build dir per profile: fat ~38s, thin ~34s, fast ~1.0s. This box sat at load average 17, so fat-vs-thin is inside the noise and only the fast profile separates cleanly — thin is not measurably faster here, but it is not slower either and its ROM is 3.1% smaller. The build-time win is the fast profile, which stays opt-in: opt-level 1 by default would silently make every shipped ROM slow. All three profiles boot under mgba with identical program output. Numbers and guidance recorded in docs/gba-target.md; a unit test pins the policy so the default cannot drift back to fat.
perf(native): #654 — immutable captured bindings need no VmRef cell (#… …662) A module-level const that is never reassigned was still lowered to a `VmRef` cell, and every call of any function mentioning it re-borrowed and re-cloned the value out of that cell — repeated work to fetch something already fixed. The cost scaled with how many constants a function happened to name, an invisible relationship that made the fastest thing to write a magic number rather than a named constant. A read-only captured var is never assigned anywhere in its defining scope (that is exactly what keeps it out of `rc_cell_storage`), so the cell can never change and the indirection buys nothing. Snapshot it by value at closure creation instead: no VmRef allocation per closure, no RefCell borrow per call. Verified on GBA (mgba) and the host; full workspace suite green, including test_mvp_programs_native, which AOT-builds every fixture.
PreviousNext