Skip to content

Tags: tishlang/tish

Tags

v3.8.2

Toggle v3.8.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

v3.8.1

Toggle v3.8.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

v3.8.0

Toggle v3.8.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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).

v3.7.9

Toggle v3.7.9's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

v3.7.8

Toggle v3.7.8's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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).

v3.7.7

Toggle v3.7.7's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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).

v3.7.6

Toggle v3.7.6's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

v3.7.5

Toggle v3.7.5's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

v3.7.4

Toggle v3.7.4's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.

v3.7.3

Toggle v3.7.3's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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.