Commit Graph

270 Commits

Author SHA1 Message Date
agra
56414407fc ffi: drop static keyword on foreign-class methods; param type discriminates
`static name :: ...` was redundant — instance methods always declare
`self: *Self` as their first param by convention. The parser now derives
`is_static` from the first param's TYPE: if it's `*Self` the method is
an instance method; anything else (including no params at all) is a
class method. Removes a token from the surface, keeps the dispatch
behavior identical.

The receiver param's NAME doesn't matter — only its type. Calling the
first param `this`, `me`, `receiver`, etc. is fine as long as the type
is `*Self`. This mirrors how the rest of sx handles receiver dispatch.

Migration of every site that used the keyword:

- `library/modules/platform/android.sx` — `SurfaceView.new(ctx)`.
- `examples/ffi-jni-class-03-static.sx` — `Math.abs(n)`.
- `examples/ffi-jni-main-03-ctor.sx` — `SurfaceView.new(ctx)` in the
  `#jni_main` body.
- `examples/ffi-objc-dsl-05-static.sx` — NSObject's `.class()` /
  `.description()`.

164/164 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
2026-05-25 16:32:32 +03:00
agra
8406cc1fed ffi 3.1: Cls.static_method(args) lowers to objc_msg_send on the class object
Implementation half of the Phase 3.1 cadence step.
`lowerForeignStaticCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcStaticCall` helper
that loads the class object from a module-scoped cached slot (populated
once per module via `objc_getClass`) and dispatches `objc_msg_send`
with the same selector-mangling as Phase 3.0's instance dispatch.

Three pieces:

1. `Module.objc_class_cache` — parallel to `objc_selector_cache`,
   insertion-ordered list of (class_name, slot_GlobalId) so the
   constructor that calls `objc_getClass` per slot at module load
   is deterministic. `lookupObjcClass` / `appendObjcClass` accessors.
2. `internObjcClassObject` in lower.zig — get-or-create a
   `OBJC_CLASSLIST_REFERENCES_<Cls>` global pointer; matches clang's
   naming convention. `lowerObjcStaticCall` reuses
   `deriveObjcSelector` from 3.0 for the selector, loads the class
   slot, and emits `objc_msg_send(class_obj, sel, args)`.
3. `emitObjcClassInit` in emit_llvm.zig — companion to
   `emitObjcSelectorInit`. Walks `objc_class_cache`, synthesizes a
   constructor `__sx_objc_class_init` that calls `objc_getClass(name)`
   per slot, registers in `@llvm.global_ctors` for AOT (extending the
   existing array if the selector init already created it), and
   injects a direct call into main's prelude after any prior init
   calls so the ORC JIT path runs it too.

Surface form is `.` (`NSObject.class()`) matching JNI's `Alias.new(...)`
convention rather than the plan's notional `::` — avoids extending the
parser for a new postfix operator with no other use case.

Test `examples/ffi-objc-dsl-05-static.sx` exercises NSObject's
`+class` and `+description` class methods via the new syntax, asserts
both return non-null. NSObject is always available at module-load,
unlike runtime-created test classes that wouldn't exist yet when
the class-init constructor runs.

164/164 tests; chess builds + runs clean on all three platforms.
2026-05-25 16:23:24 +03:00
agra
b07ee53a39 ffi 3.1 (xfail): add Cls.static_method(args) regression test
xfail half of Phase 3.1: static calls on `#objc_class` aliases lower
to `objc_msg_send` against the class object (loaded once per module
via `objc_getClass`).

Test mirrors the Phase 3.0 pattern (`ffi-objc-dsl-01..04`): synthesize
a class at runtime via `objc_allocateClassPair`, add class methods on
the metaclass via `object_getClass(cls) + class_addMethod`, declare
the sx-side `#objc_class` with `static answer :: ...` / `static add ::
...`, then invoke `SxProbeStatic.answer()` / `.add(7, 35)`. Skips on
non-macOS.

Surface choice: the call site is `.` (`Cls.method(args)`), matching
JNI's existing static dispatch convention (`SurfaceView.new(ctx)`)
rather than the plan's notional `::` form. The lowering disambiguates
static vs instance by inspecting `method.is_static` on the foreign-
class member, same as JNI. Picking `.` avoids extending the parser
for a new postfix operator with no other use case.

Pre-3.1 snapshot pins the current bail diagnostic at
`lowerForeignStaticCall` (lower.zig:4475) — "static calls on
'objc_class' runtime not yet supported (Phase 3/4)" — fires twice
because both the niladic and the keyword-arg static call hit it.
exit=1.

164/164 tests; next commit implements the dispatch and flips the
snapshot to working output.
2026-05-25 16:13:05 +03:00
agra
53fe73acda ffi 3.0: inst.method(args) DSL dispatch on #objc_class receivers
Implementation half of the cadence step started in the previous commit.
`lowerForeignMethodCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcMethodCall` helper
that derives the Obj-C selector from the sx method name and lowers to
`objc_msg_send` against the cached SEL slot (same intern path as
explicit `#objc_call`).

Default selector mangling (matches clang's keyword-method convention):
- Niladic (arity 0 excluding self): name verbatim. `length()` → "length".
- Arity ≥ 1: split the sx method name on `_`; each piece becomes a
  keyword with a trailing `:`. `addObject(o)` → "addObject:";
  `combine_and(a, b)` → "combine:and:";
  `initWithFrame_options(f, o)` → "initWithFrame:options:".

Arity validation: keyword count (pieces from the `_`-split) must equal
call-site arity excluding self. Mismatch diagnoses at the call site
with a hint pointing at the forthcoming `#selector("...")` override
(Phase 3.2) for selectors that don't fit the underscore-split rule.

Mangling helper `deriveObjcSelector` and dispatch helper
`lowerObjcMethodCall` sit alongside `lowerForeignMethodCall`. The
existing fall-through diagnostic for non-JNI/non-Obj-C runtimes
remains for Swift (Phase 4 territory).

Tests `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,
04-mismatch}.sx` snapshots flip from the pre-3.0 bail diagnostic
(exit=1) to working output (exit=0 for cases 01-03) and the specific
keyword-count mismatch diagnostic for case 04. Each test follows the
established pattern from `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare a matching `#objc_class`, invoke the DSL
form. 163/163 tests; chess unaffected (JNI dispatch path untouched).
2026-05-25 16:10:22 +03:00
agra
a593d150ca ffi 3.0 (xfail): add inst.method(args) DSL regression tests + correct checkpoint
The previous FFI checkpoint claimed Phase 3 step 3.0 ("`inst.method(args)`
on #objc_class receivers") had landed. It hadn't — `lowerForeignMethodCall`
in lower.zig:4353 still bails for any non-JNI runtime with the generic
"method calls on '{runtime}' runtime not yet supported (Phase 3/4)"
diagnostic, no commit introduced an Obj-C DSL dispatch path, and the
planned regression files weren't on disk.

This commit is the xfail half of the proper cadence (test-add then
make-green in separate commits):

- examples/ffi-objc-dsl-01-niladic.sx — `length()` → selector "length".
- examples/ffi-objc-dsl-02-one-arg.sx — `addObject(o)` → "addObject:".
- examples/ffi-objc-dsl-03-multi-keyword.sx — `combine_and(a, b)` →
  "combine:and:" (sx name split on `_`, each piece becomes a keyword
  with a trailing `:`).
- examples/ffi-objc-dsl-04-mismatch.sx — `something_extra(x)` —
  keyword count (2) ≠ arity (1); must diagnose at the call site.

Each test follows the same pattern as `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare the sx-side `#objc_class` against the same
name, then invoke the DSL form. Skips with a "(not macos)" line on
non-macOS hosts. Snapshots currently lock in the bail diagnostic with
exit=1; the next commit implements the dispatch and the snapshots
flip to the working output (and exit=0).

Checkpoint corrected to flag the prior false claim and reposition 3.0
back at the top of the open list.
2026-05-25 16:07:19 +03:00
agra
071352e655 mem: remove resolveType(null) → .s64 silent fallback
CLAUDE.md REJECTED PATTERNS forbids silent default returns where the
"reasonable-looking" value happens to match one common case (s64 = 8
bytes = pointer-sized on the host) and is silently wrong everywhere
else. `resolveType(null) → .s64` was exactly this shape: a top-level
`g_pi := 3.14;` was silently typed as `s64`, producing a wrong-typed
slot and the wrong runtime value.

`resolveType` now takes a non-optional `*const Node`. Twelve callers
were classified:

- Six were already guarded by `if (x.type_annotation != null)` blocks
  — the null branch was unreachable. Cleaned up to optional-payload
  syntax (`if (cd.type_annotation) |ta|`) so the always-non-null path
  is obvious from the type.
- Two (`#objc_call` / `#jni_call` return types) pass `FfiIntrinsicCall.
  return_type`, which is `*Node` (not optional) in the AST — the
  silent fallback couldn't be reached there either.
- One (top-level `var_decl` at lower.zig:630) DID legitimately receive
  null when the user omitted both annotation and initializer typing.
  Now mirrors `lowerVarDecl`'s local-scope behavior: explicit
  annotation → resolveType; no annotation → `inferExprType` from the
  initializer; neither → diagnose with a real error message.
- One (`lowerComptimeGlobal`, fixed in commit 82e7b04 alongside
  Phase 1.4) already infers from the comptime expression.
- Two (JNI super-call / JNI method return type) were already
  hand-rolled with `if (rt) |t| resolveType(t) else .void`.

Regression at `examples/137-toplevel-var-type-inference.sx`: `g_count
:= 42;` / `g_pi := 3.14;` / `g_flag := true;` at module scope. Pre-fix
`g_pi` got silently typed as `s64` and printed `0` or garbage; now it
prints `3.140000`. 159/159 example tests + chess clean.
2026-05-25 15:59:32 +03:00
agra
179310d62b mem: Phase 1.4a — fat-pointer aggregates from #run serialize via host memory
The Phase 1.4 serializer left a silent malformed-const case: when the
interp evaluated a `#run` returning a string (or anything with a fat
pointer inside), the data field came in as a `.int` holding a libc
host address. `LLVMConstInt(ptr_type, addr, 1)` happily emitted `i0 0`
in the static const, and the runtime segfaulted on the first read.

Phase 1.4a closes this for string and slice destinations. The signature
of `valueToLLVMConst` now takes the IR `TypeId` (instead of just the
LLVM type) and a borrowed `*Interpreter`. A new helper
`serializeAggregateValue` splits on the IR type:

- `string` / `slice` (fat pointer `{data, len}`): extract `len`, read
  that many bytes from the data field's address (via `interp.heapSlice`
  for `heap_ptr`, via a new `readHostBytes` for `byte_ptr` / `.int`,
  via slice indexing for string literals). Emit the bytes as a private
  global byte array using the existing `emitConstStringGlobal`. The
  fat-pointer aggregate's data ptr resolves to the byte array's address.
- `struct`: walk the IR field types in lockstep with the value's
  fields; recurse with each declared field TypeId. This replaces the
  old LLVM-type-walk via `LLVMStructGetTypeAtIndex` which couldn't tell
  string-typed fields from generic ptr fields.
- `array`: walk with the element TypeId.

The remaining `.int → ptr` trap (a host address landing in a bare ptr
field outside a fat pointer) now bails loudly with a named diagnostic
identifying it as Phase 1.4a heap-walk follow-up territory. No
practical trigger in-tree, so deferred.

`Interpreter.heapSlice` promoted from package-private to `pub` so
the serializer can read interp-managed heap data.

Regression: `examples/136-comptime-string-global.sx` —
`GREETING :: #run build_greeting();` where `build_greeting` returns
`concat("hello", " world")`. Runtime prints `greeting = 'hello world'`
and `greeting.len = 11`. Pre-1.4a this segfaulted on the first read.

158/158 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
2026-05-25 15:45:33 +03:00
agra
da1063f1bb mem: allocator init returns state by value (drops state-struct heap alloc)
Building on the Option 3 lvalue-borrow rule, the long-lived allocators
in `library/modules/allocators.sx` (GPA, Arena, TrackingAllocator) now
return their state by value instead of via a heap-allocated `*T`. The
caller binds the result to a local; the local IS the allocator state.
`xx local` borrows that storage under Option 3, so the `Allocator`
protocol value's `ctx` points at the local — no heap allocation for
the state struct, no `free` of the state needed.

```sx
gpa     := GPA.init();                          // GPA (value)
arena   := Arena.init(xx gpa, 4096);            // Arena (value)
tracker := TrackingAllocator.init(xx gpa);      // TrackingAllocator (value)

push Context.{ allocator = xx tracker, data = null } { ... }
```

Why by-value:
- One fewer `libc_malloc` per allocator instance.
- No state-struct leak. The local is reclaimed at scope exit; `deinit`
  only handles downstream resources (chunks, etc.) — not its own struct.
- Owning structs can embed allocators as value fields directly.

Callsite changes:

- `library/modules/ui/pipeline.sx`: `arena_a: Arena;` / `arena_b:
  Arena;` (was `*Arena;`). The `build_arena: *Arena` local takes
  `@self.arena_a` / `@self.arena_b`.
- `examples/126-xx-recover-then-dispatch.sx`: `recovered == @gpa`
  instead of `recovered == gpa` (gpa is a value now).
- `examples/135-xx-lvalue-borrows.sx`: drop the `tracker_ptr.*`
  deref — `init` already returns the value.
- `examples/50-smoke.sx`: Arena alloc counts dropped by 1 (no
  state-struct allocation). Comments + snapshot updated.

`Arena.deinit` drops the trailing `parent.dealloc(xx a)` — the
caller's local owns the storage.

FFI IR snapshots regenerated to reflect the new signatures:
`@GPA.init` returns `i64` (was `ptr`); `@Arena.init` and
`@TrackingAllocator.init` use sret returns (was `ptr`).

CLAUDE.md "Allocator construction" rule rewritten around the
by-value convention. The forbidden caller-provides-storage and
redundant-pointer-rename patterns are still forbidden but for the
right reasons now (verbose, fragile) rather than as a workaround
for the old `init() -> *T` shape.

157/157 example tests pass; chess clean on macOS, iOS sim, and
Android via `tools/verify-step.sh`.
2026-05-25 15:33:28 +03:00
agra
b710a0a42a lang: xx <lvalue> borrows the operand's storage instead of heap-copying
`xx <struct-typed local>` used to heap-copy the value through context.allocator.
The protocol value's `ctx` pointed at the heap copy; the original local was
left behind, untouched. Mutations through the protocol never reached the
original, and direct reads of the original never saw protocol mutations.
Two-fork bug, silent, easy to write by mistake.

New rule (Option 3 in the discussion):

- `xx <lvalue>` — identifier, field access, index expression, deref —
  borrows the operand's storage. No heap copy, no `free` needed.
- `xx <rvalue>` — struct literal, function-call result, arithmetic, etc. —
  heap-copies through context.allocator. Unchanged from today.
- `xx @ptr` and `xx <pointer-typed value>` — borrows the pointee. Unchanged.

Single switch in `buildProtocolErasure` ([lower.zig:10334](src/ir/lower.zig#L10334))
gated by a new `isLvalueExpr` helper ([lower.zig:10322](src/ir/lower.zig#L10322)).
Struct-typed operand: if the AST shape is identifier/field/index/deref,
emit `lowerExprAsPtr(operand_node)` and skip the heap-copy; otherwise
keep the alloca-store-heap_copy path.

specs.md §3 ownership table extended to three rows (rvalue, lvalue,
pointer) with examples and rationale per row.

Regressions:

- `examples/130-xx-value-routes-through-context-allocator.sx` — the
  Phase 1.1 witness for heap-copy-via-context-allocator. Previous shape
  (`xx <local-value>`) is now a borrow under Option 3 and no longer
  exercises the heap-copy path. Rewritten to use a struct literal
  (`xx ByValue.{...}`) which still heap-copies through context.allocator
  — Tracer.count = 1 as before.
- `examples/135-xx-lvalue-borrows.sx` — new test. Dereferences a
  TrackingAllocator into a stack value, does `xx tracker` inside a
  push Context, and asserts alloc_count/dealloc_count on the LOCAL go
  up. Under old semantics this would have stayed at 0 (heap copy got
  the increments, local stayed stale).

157/157 example tests pass; chess clean on macOS / iOS sim / Android
(`tools/verify-step.sh` ran green immediately before this work).
2026-05-25 15:23:13 +03:00
agra
82e7b04cca mem: Phase 1.4 — serialize every interp Value variant for #run globals
`valueToLLVMConst` in emit_llvm previously handled int / float / boolean
and collapsed everything else into `LLVMConstNull(ty)`. A `#run` returning
a struct, string, function pointer, or anything aggregate produced a
zero-initialized global silently — the comptime result was computed by
the interp, then thrown away when emit_llvm couldn't represent it.

Replaced with a real walk:

- int / float / boolean — as before.
- null_val — `LLVMConstNull`.
- void_val / undef — `LLVMGetUndef`.
- func_ref — `func_map` lookup (already populated for the implicit-Context
  static initializer of `__sx_default_context`).
- string — `emitConstStringGlobal`, returns a pointer to the byte array.
- aggregate — recurse field-by-field. Struct: walk
  `LLVMStructGetTypeAtIndex` and emit `LLVMConstNamedStruct`. Array:
  walk `LLVMGetElementType` and emit `LLVMConstArray2`.

The remaining variants (heap_ptr, byte_ptr, slot_ptr, closure, type_tag)
bail loudly with a `std.debug.print` carrying the global name — per
CLAUDE.md REJECTED PATTERNS, no more silent unimplemented arms. heap_ptr
serialization requires threading the IR `TypeId` so the heap content can
be walked recursively; deferred to Phase 1.4a alongside cycle detection.
The call site at emit_llvm.zig:676 now passes `global.name` so the
diagnostic locates the offending `#run` binding.

Type-inference fix at the binding site: `NAME :: #run expr;` with no
annotation used to default to `s64` via `resolveType(null) -> .s64`,
so even a successful Phase 1.4 serialization would emit `{0, 0}` —
the global's destination type was wrong. `lowerComptimeGlobal` now
calls `inferExprType(expr)` when no annotation is given, so the
inferred type matches the comptime function's return type. The
broader `resolveType(null)` fallback is left in place for other
callers — flagged in the MEM checkpoint as a follow-up audit.

Regression: `examples/134-comptime-aggregate-global.sx` exercises
`POINT :: #run make_point()` returning a `Point { x: s32, y: s32 }`.
Both interp (`sx run`) and codegen (`sx build`) now print
`POINT.x = 7 / POINT.y = 13` instead of `0 / 0`. 156/156 example
tests pass; chess unchanged.
2026-05-25 15:01:58 +03:00
agra
f75b7caad1 docs: rewrite the long-lived-container rule as a self-contained principle
Previous version leaned on chess-specific terminology (GlyphCache, render,
frame arena) and made the rule read like a project memo. Replaced with a
generic `LongLived` example, a two-question test for when to apply, and
no incident-specific narrative. The "field name is by convention" line
removes the implicit prescription of `parent_allocator` so projects can
follow their own naming.

Also drops the explicit cross-reference list of existing examples — those
already drift with the code; the principle is enough to recognise the
shape when it appears.
2026-05-25 14:42:20 +03:00
agra
72593db953 mem: List(T) mutations gain optional alloc: Allocator = context.allocator
The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.

Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.

Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.

Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:

- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
  hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
  `ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
  render would otherwise leak resources into the frame arena).

Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.

Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
  The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
  through context.allocator" section with the `parent_allocator`
  capture template and the list of existing examples to mirror.

155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
2026-05-25 14:41:17 +03:00
agra
b263704664 mem: delete .heap_alloc/.heap_free IR ops + the silent libc-malloc escape
allocViaContext used to fall back to a direct `.heap_alloc` (libc
malloc) when `Context` wasn't registered — i.e. when the program
didn't import std.sx. That was a silent escape hatch: a program could
appear to allocate fine without a `Context`, sidestepping protocol
dispatch entirely. Same shape as the matchContextAllocCall trap we
removed, just in a different code path.

Now: every site that needs `Context` emits a clear diagnostic when
the type isn't in scope, pointing the user at the required import.

- `allocViaContext`: the three fallback branches (no implicit_ctx, no
  Context type, malformed Context struct) all call the new
  `diagnoseMissingContext("heap allocation")` and return a
  placeholder. Codegen no longer emits libc malloc as the silent
  no-import path.
- `lowerPush`: the no-Context branches used to silently drop the
  push and just lower the body. Now diagnose first, then lower
  (keeping the body's other diagnostics flowing).
- `lowerIdentifier` for "context": used to silently fall through to
  `global_names.get("context")` (which would emit an unresolved
  identifier with no actionable hint). Now diagnose with the
  required-import message.

With every consumer gone, the `.heap_alloc` and `.heap_free` IR ops
are deleted entirely:

- `inst.zig`: drop the Op variants.
- `interp.zig`: drop the execInst arms.
- `emit_llvm.zig`: drop the arms (the `getOrDeclareMalloc/Free`
  helpers stay — they're still used by the foreign-decl path for
  user-level `malloc`/`free` foreign bindings).
- `print.zig`: drop the printers + the isVoidOp arm.
- `emit_llvm.test.zig`: drop the unit test (op no longer exists).

155/155 example tests pass. Unit tests green. Chess green on macOS /
iOS sim / Android. A program that doesn't import std.sx and tries to
use `context.allocator.alloc` or `push Context.{}` or the `context`
identifier now gets a real error:

  error: heap allocation requires the Context type — add
  `#import "modules/std.sx";` (or a module that imports it)

Closes the last silent allocation-protocol escape.
2026-05-25 12:49:26 +03:00
agra
8e21cc5f73 mem: Phase 1.3 — closure env allocation through context.allocator
The closure trampoline's env-buffer heap-copy in `lowerLambda` used to
call `.heap_alloc` directly (libc malloc, no protocol). Now it routes
through `allocViaContext` like every other compiler-internal alloc,
so a closure created inside `push Context.{ allocator = ... }` honors
the installed allocator — trackers count the env, arenas absorb it,
custom allocators see it. Closes the last `.heap_alloc` shortcut for
sx-internal allocations.

One ordering subtlety fixed alongside: the deferred restore of
`current_ctx_ref` at lowerLambda exit fired AFTER the env-and-closure
build section, so `allocViaContext` was reading `Ref.fromIndex(0)`
(the lambda's own ctx param, only valid inside the lambda body) when
emitting the alloc in the CALLER's scope. Without the explicit
restore, the env_heap dispatch silently routed through the default
context — the captured tracker never saw it. Fixed by restoring
`current_ctx_ref` right after `self.builder.func = saved_func`, before
the env build.

Regression test: `examples/133-closure-env-routes-through-context-allocator.sx`
mirrors the 130-xx-value pattern — install a Tracer via `push Context`,
create a capturing closure inside, assert `Tracer.count = 1`. Without
the fix the count is 0 (env goes through default context). Verified
by stashing the lower.zig change and re-running.

Bonus: `examples/50-smoke.sx` "closure-gpa" output flips from
`allocs=-1` to `allocs=0`. The old `-1` was the bug's signature —
the test manually `dealloc`'d the env after the closure ran, but the
GPA had never seen the matching alloc, so its counter went negative.
With Phase 1.3 the alloc/dealloc balance at 0. Snapshot regen.

155/155 example tests pass (133 new + 50-smoke regen). Chess green on
macOS / iOS sim / Android.
2026-05-25 12:18:27 +03:00
agra
e843b7769d checkpoint: refresh MEM after silent-arm sweep + raw-ptr work
CHECKPOINT-MEM.md "Next step" still pointed at Phase 1.2 from the
old MEM plan — but four commits have landed since: matchContextAllocCall
drop, typed raw-pointer stores, call-conv mismatch detection, and the
silent-arms sweep. The "Current state" section also still listed
matchContextAllocCall as preserved and tied test counts to 152.

Updates:
- "Last completed step" now points at the silent-arm sweep + typed
  Store work.
- "Current state" rewritten: matchContextAllocCall is GONE, interp
  raw-pointer paths enumerated, val_ty threading mentioned,
  call-conv check called out.
- "Phase 0.3 audit findings" rewritten as historical context — chess
  no longer touches any pattern-match bypass; protocol dispatch runs.
- "Next step" recommends Phase 1.3 (closure env through context),
  notes Phase 1.2 was considered and skipped.
- Three new log entries for the four post-Step-9 commits.
2026-05-25 12:06:59 +03:00
agra
e9df33a7e3 mem: interp sweep — every silent arm now bails with a named reason
Apply the new CLAUDE.md "no silent unimplemented arms" rule to the
interp. Every `else => return error.CannotEvalComptime` and
`else => return val` (passthrough) gets a one-line `bailDetail` that
surfaces through `printInterpBailDiag` as
`op=X/X: <reason>` instead of a bare `CannotEvalComptime`.

Tightened sites:

- `.deref` else-arm used to return the operand unchanged for ANY
  Value kind. Now: enumerated allow-list (`.aggregate`, `.string`
  are legitimate pre-dereferenced values); scalars / handles / undef
  / null bail loudly. Previously, dereffing e.g. a `.boolean`
  silently produced a bare `.boolean` and the caller treated it as
  a successful deref.

- `.unbox_any` else-arm used to return the operand unchanged for any
  non-aggregate. Now: enumerated bails for scalars / handles / void.
  An unbox_any whose operand wasn't routed through `box_any` first
  is a frontend bug and now shows up as one.

- `.compiler_call` for an unregistered hook silently returned
  `CannotEvalComptime`. Now names the missing hook category in the
  detail.

- `.length` / `.data_ptr` / `.subslice` / `.array_to_slice` /
  `.global_addr` / `.call_indirect` / `struct_get` / `enum_tag` /
  `enum_payload` / `unary -` / `field_name_get` / `field_value_get`
  / `objc_msg_send` / `jni_msg_send`: every `else` arm now carries
  a specific reason.

- `evalArith` / `evalCmp` use `typeErrorDetail` so mismatched
  operand pairs surface "neither both-int nor both-float-coercible"
  instead of bare TypeError.

- `callForeign` distinguishes "dlsym error" vs "symbol not found"
  vs "> 8 args" instead of returning the same error for all three.

- `execBuiltin` arms for ops the lowering shouldn't have emitted at
  comptime (`.cast`, `.type_of`, `.alloc`, `.dealloc`) bail with a
  reason instead of a bare error.

154/154 still passing. Behavioural change: the `.deref` /
`.unbox_any` arms used to silently produce a value for Value kinds
they shouldn't have accepted. Any consumer relying on that silent
fall-through now bails — which is the point.
2026-05-25 11:57:12 +03:00
agra
4de565b7da docs: forbid silent unimplemented arms in REJECTED PATTERNS
Sibling to the silent-fallback-defaults rule. Catch-all `else`
branches that pass a value through unchanged, write a default width,
or swallow errors into a zero-init are the same class of bug — a
case the implementer didn't think of corrupts data silently.

Both bites this session:

- `storeAtRawPtr` writing 8 bytes regardless of IR type (fixed by
  threading val_ty through inst.Store).
- `.deref` else-arm returning val unchanged (now errors loudly for
  raw pointers).
- comptime init catch swallowing the error into `.void_val`.

Preferred order: implement the arm in the same step. If plumbing is
out of scope, bail loudly with `bailDetail(comptime msg)` and leave a
one-line comment about what's needed. Width/type/layout info that's
ambiguous from the Value tag belongs in the IR op struct, not in a
"this case is usually 8 bytes" shortcut.
2026-05-25 11:45:45 +03:00
agra
b47896eadf doc: rewrite stale evalComptimeString comment (no shortcut still exists) 2026-05-25 11:43:15 +03:00
agra
f2b3868579 mem: thread val_ty through inst.Store; per-width comptime regression test
The interp's `storeAtRawPtr` used to write 8 bytes from a `.int` /
`.float` Value regardless of the destination's declared width. The
Value tag flattens s8..s64/u*/pointer all to `.int`, so it can't
disambiguate widths on its own — every store risked clobbering up to
7 neighbor bytes if the actual IR type was sub-8.

Fix:

- `inst.Store` gains `val_ty: TypeId` (defaults to `.void` for
  backward compat with the LLVM emitter, which doesn't read it).
- `builder.store` captures `getRefType(val)` at emit time.
- `storeAtRawPtr` now takes `val_ty`, looks up
  `types.typeSizeBytes(val_ty)`, and writes exactly that many bytes:
  `.int` → width bytes of the i64 representation (1..8),
  `.float` → 4 (f32 round-trip via @floatCast) or 8,
  `.boolean` → 1 (zeros higher width bytes when destination is wider),
  `.null_val` → width bytes of zero. Width outside the expected band
  bails with a clear diagnostic.

Regression test: `examples/132-comptime-typed-store-widths.sx`. For
every primitive type (u8/u16/u32/u64, s8/s16/s32/s64, bool, f32, f64),
the test:

1. Allocates a 32-byte libc buffer through `context.allocator`.
2. Fills with sentinel byte 0xAA.
3. Writes ONE typed value at offset 8.
4. Sums every byte back.
5. Compares the runtime checksum (LLVM-emitted store, already
   correct) against a comptime checksum baked via `#run`.

Mismatch = neighbor clobber. The test exits non-zero with a per-width
"FAIL u8: comptime=X runtime=Y" line so future regressions surface
the offending width.

Also wired:

- Interp's `index_get` gains `.int` / `.byte_ptr` base arms — `buf[i]`
  through a raw libc-malloc'd pointer reads one byte at offset i.
  Used by the new test's `sum_bytes` loop; previously bailed at
  `op=index_get`.
- `emit_llvm`'s comptime-init catch block prints a real diagnostic
  instead of swallowing the error and filling the const with zero.
  Stale bail state from a previous init is cleared before each call.

154/154 example tests pass (the new test + the existing 153). Chess
still green on macOS / iOS sim / Android.
2026-05-25 11:41:59 +03:00
agra
26d96ac15e mem: add explicit bail diagnostics for unhandled raw-pointer interp paths
Comptime fall-through paths used to surface as bare `CannotEvalComptime`
with no hint about the actual limitation. Now each raw-pointer Value
combination that isn't yet wired sets `Interpreter.last_bail_detail`
with a one-line explanation; `printInterpBailDiag` appends it after
the op tag:

  error: post-link callback failed: CannotEvalComptime
    (op=load/load: comptime load through raw host pointer not supported
    (IR type width not threaded)) at .../bundle.sx:N:N

Sites covered: `.load` / `.store` / `.struct_gep` / `.deref` /
`.index_gep` arms for `.int`, `.byte_ptr`, `.heap_ptr` bases;
`storeAtRawPtr`'s catch-all (now exhaustively names every rejected
Value kind); foreign-arg marshalling of unsupported aggregate shapes.

Notable behaviour change: `.deref` through a raw pointer used to
silently return the pointer-as-int unchanged. That looked like a
successful deref to callers — now it errors loudly. Aggregate
passthrough (for `*string` / `*Closure` slot deref) is preserved.

The `storeAtRawPtr` `.int`/`.float` arms still assume 8-byte width —
the Store IR op doesn't carry val's TypeId. Documented inline at the
helper: real-world comptime stores hit 8-byte fields; smaller dests
would clobber. Threading val_ty into Store is left for when a
comptime path actually hits this.

153/153 still passing. The new diagnostics fire when a comptime path
goes through an unhandled shape — verified by reading the bail text
from a synthetic test (separate issue: `#run` silently drops the error
instead of surfacing the diagnostic to the user — out of scope here).
2026-05-25 11:08:03 +03:00
agra
d415bcceaa mem: drop matchContextAllocCall — interp reaches real memory through libc
Comptime now runs the full Allocator-protocol dispatch chain — the
same IR codegen emits — instead of being short-circuited at lowering
by an AST pattern-match. `context.allocator.alloc(size)` flows
through the protocol thunk into `CAllocator.alloc → libc_malloc`,
returning a real host-libc pointer. The interp picks it up as a raw
`.int` Value and treats it as memory.

The pieces:

- `evalComptimeString` now uses the parent module instead of spinning
  up a fresh ct_module. The parent already has every type, protocol,
  impl, and thunk registered (Allocator, CAllocator, Context, the
  GPA/Tracker thunks), so the dispatch chain runs without a separate
  scan pass. The comptime function is appended to the parent module;
  it's `is_comptime` so codegen skips it.

- Interp gains raw-pointer paths:
  - `index_gep(.aggregate{.int data_ptr, .int len}, idx)` produces a
    new `.byte_ptr` (a new Value variant) — byte-granular pointer that
    `store` writes 1 byte through. Mirrors the existing heap_ptr
    semantics for the same op shape.
  - `index_gep(.int, idx)` returns `.int = p + idx` (byte-addressed).
  - `store(.int_ptr, val)` writes val's bytes via `@ptrFromInt`.
    Handles int (8B), float (8B), bool (1B), null_val (8B of zeros).
  - `store(.byte_ptr, val)` writes a single byte.
  - `marshalForeignArg` handles `.aggregate{.int data, .int len}` and
    `.byte_ptr` — both copy bytes into a null-terminated tmp buffer
    for the C-side call.
  - `asString` reads `len` bytes from a `.int` data field via
    `@ptrFromInt`.
  - `resolveFieldLoad` / `resolveFieldStore` reject field-pointer
    aggregates whose first field is a wide integer (would otherwise
    mis-trigger on a struct stored on the stack with an int pointer
    in field 0).

- `lowerFunction` / `lazyLowerFunction` / `synthesizeJniMainStub`
  bind `current_ctx_ref = &__sx_default_context` for every
  callconv(.c) sx entry — not just `isExportedEntryName`. The JNI
  stubs need this so `context.X` in the body resolves through
  current_ctx_ref now that the pattern-match is gone.

- `matchContextAllocCall` and its dispatch site are deleted.

11 JNI/ObjC `.ir` snapshots regen — the comptime function appended to
the parent module shifts string-pool indices. 153/153 example tests
pass, chess green on macOS / iOS sim / Android.
2026-05-25 10:57:38 +03:00
agra
619aff85f6 mem: document the xx-sx-fn-to-*void hole in the call-conv check
Audit of library + game found every C-side callback already follows
the callconv(.c) rule. The static check at the bare-fn-ref site catches
typed fn-pointer mismatches; the one remaining hole is `xx <sx_fn> : *void`
(used by e.g. `class_addMethod(_, _, xx my_imp, _)`). Tried to close
it by requiring callconv(.c) on any sx fn cast to *void, but
examples/50-smoke.sx legitimately stores a default-conv sx fn into a
*void slot when manually constructing a Closure value — that path
goes through the sx-side closure trampoline ABI, not C. The compiler
can't distinguish C-side vs sx-side from the cast alone.

Leaving the hole open and documenting why. The existing libraries
follow the convention manually; the typed-fn-ptr check covers
pthread_create / SDL callbacks / GL loader-style sites which is where
the real-world bugs landed.
2026-05-25 10:11:07 +03:00
agra
f886d5f1be mem: reject call-conv mismatches at bare-fn → fn-ptr coercion
Passing a default-conv sx function to a `callconv(.c)` fn-pointer slot
(e.g. pthread_create's start routine) used to silently mismatch ABIs:
the C-side caller didn't supply __sx_ctx, so the sx-side body read its
first user param as garbage. The bug surfaced as a SIGSEGV inside
ANativeWindow_setBuffersGeometry on Android during chess bringup.

Now the compiler rejects the coercion outright at the bare-fn name
lookup site:

  error: call-convention mismatch: 'sx_handler' is declared with
  default sx convention but the target type expects callconv(.c)

Also: `#foreign` declarations without an explicit `callconv` now default
to `.c` instead of `.default`. Every external C symbol is by definition
C-conv; the previous default silently typed `objc_msgSend` (et al.) as
default-conv, so the check would fire on the consumer side when the
user typed a fn-ptr as `callconv(.c)`. With the foreign-default fix,
the existing typed-msgSend casts in `std/objc.sx` and `gpu/metal.sx`
keep type-checking and the rule is "C-conv on both sides or neither."

Caught by the new check (fixed in the same commit):
- `ios_gl_proc` in `platform/uikit.sx` lacked callconv(.c) but was
  passed to `load_gl` whose `get_proc` slot expects it.
- `ffi_apply_callback` / `ffi_apply_callback2` in
  `examples/ffi-06-callback.sx` had default-conv fn-ptr params but
  the C bodies (in the companion .c) are unambiguously C-conv.

Regression test: `examples/131-callconv-mismatch-diagnostic.sx`
locks in the diagnostic shape (sx-conv fn → callconv(.c) slot).

153/153 example tests pass. Chess green on macOS / iOS sim / Android.
2026-05-25 09:50:37 +03:00
agra
d4a342d0c1 mem: implicit-Context platform fixes — chess green on macOS/iOS/Android
Verify-step uncovered three categories of regressions where sx code
calls into the platform's C ABI through fn-pointer types or as a
registered callback. Every site now declares the right convention.

C-side calls INTO sx → callconv(.c) on the sx function:
- platform/android.sx: sx_android_render_thread_entry is the start
  routine pthread_create invokes — pthread treats it as a C function.
  Also annotate the pthread_create signature so the start-routine fn-
  pointer field rejects mismatching sx fns at compile time.

sx code calling typed fn-pointers cast from C symbols → callconv(.c)
on the fn-pointer type:
- opengl.sx: 55 GL fn-ptr globals + load_gl's proc-loader param. GL
  trampolines are macOS/iOS/Android system code.
- std/objc.sx: the two typed `objc_msgSend` casts.
- gpu/metal.sx: ~40 typed `objc_msgSend` casts across Metal command
  encoder / device / pipeline construction.

The block invoke trampolines (objc_block.sx) call back INTO sx (the
closure trampoline). The typed fn-ptr there stays default-conv so
ctx prepends correctly. Compiler change: a callconv(.c) sx function
now binds `current_ctx_ref` to `&__sx_default_context` at entry (used
to be gated by `isExportedEntryName`). C-callable sx callbacks like
the block invokes don't get their own __sx_ctx param but their bodies
still need a real Context to forward to the closure they delegate to.

Tests: 152/152 example suite + chess green on all 3 platforms.
Screenshots at /tmp/sx-game-{macos,iossim,android}.png.
2026-05-25 09:35:15 +03:00
agra
a8fb1233e3 mem: Step 9 — checkpoint reconciliation for implicit-Context refactor
Update CHECKPOINT-MEM.md to reflect that Steps 1-9 of
lets-see-options-for-merry-dijkstra.md are shipped. Notes that
ISSUE-MEM-002 is closed in the user-call path (matchContextAllocCall
remains as a documented comptime escape hatch), Phase 1.2/1.3/1.4
are unblocked, and points future sessions at the MEM plan
(tidy-doodling-cray.md) for the next phase.
2026-05-25 09:15:08 +03:00
agra
b69a2ea29c mem: Step 8 — delete context global from std.sx
The `context : Context = ---;` global in `library/modules/std.sx` had
no remaining readers — all `context.X` lookups in user code resolve
through `current_ctx_ref` (Step 5), `push Context.{...}` uses an alloca
slot (Step 6), and `allocViaContext` sources from the lowering's
current ref. `emitDefaultContextInit` (the only writer) was already
removed in Step 5.

`inferExprType` for the `context` identifier now returns the registered
`Context` type when implicit-ctx is enabled, mirroring the lowering's
identifier-handling fast path. Without this, `context.allocator` would
type as `s64` (the fallback) and the field access would fail.

11 JNI/ObjC IR snapshots regen — the `@context` LLVM global is gone
from each.

152/152 example tests pass.
2026-05-25 09:13:36 +03:00
agra
4bf5908792 mem: Steps 5-7 — context-identifier rebind + interp ctx bootstrap
Step 5 — `context` resolves through `current_ctx_ref`. The compile-time
emit of the default GPA into the `context` global is gone; entry points
already bind `current_ctx_ref` to `&__sx_default_context` and every
sx-to-sx call forwards it. `allocViaContext` sources from
`current_ctx_ref` too. `matchContextAllocCall` is kept as a comptime
escape hatch: the ct_module spun up by `evalComptimeString` doesn't get
the full Allocator/CAllocator/Context type registration so the protocol-
dispatch chain wouldn't run in the interp; codegen also wins from the
direct libc malloc/free.

Step 6 — `push Context.{...}` stack-discipline rewrite. Allocates a
fresh `Context` slot, binds `current_ctx_ref` to it for the body's
lexical scope, restores on exit. No global, no walk.

Step 7 — interp parity. `defaultContextValue()` builds the Context
aggregate (CAllocator thunks for alloc/dealloc, null data) on demand.
`interp.call` bootstraps slot_ptr(0) when an entry function with
implicit ctx is called sans args; `materializeCtxArg` dereferences the
caller's slot_ptr into the aggregate at every sx-to-sx call boundary so
the callee's `load(ref_0)` lands on the value; `load` of an aggregate
is a passthrough. `.global_addr` of `__sx_default_context` returns the
aggregate directly so exported entries' first-line `global_addr(...)`
runs cleanly in `#run`.

`ct_lowering` inherits `implicit_ctx_enabled` + `has_implicit_ctx` so
functions lowered into the ct module carry ctx like their main-module
twins.

152/152 example tests pass. Snapshots regen.
2026-05-25 09:10:04 +03:00
agra
92c6b47f12 mem: Step 3 — thread __sx_ctx through closure/fn-pointer/method dispatch
Continues the implicit-Context refactor. Bare-fn trampolines, lambda
trampolines, and protocol thunks now carry __sx_ctx at slot 0; call
sites for closures, fn-pointer variables, and method dispatch prepend
the caller's current ctx.

- emit_llvm.zig:1687 call_indirect treats `fp_ctx_slots` leading args
  as opaque ptr (the implicit ctx) when the fn-pointer is default-conv
  under has_implicit_ctx.
- lower.zig:fnPtrTypeWantsCtx predicate gates the prepend at both
  scope-local and global fn-pointer call sites.
- lower.zig:fixupMethodReceiver skips __sx_ctx when probing the
  receiver param's type.
- lower.zig:lowerLambda builds closure type from user-visible params
  only (skip ctx + env).
- lower.zig:closure(bare_fn) builds closure type from user-visible
  params only.
- module.zig: Module.has_implicit_ctx flag mirrors Lowering's switch
  so emit_llvm can read it without a back-pointer.

Tests updated:
- 5 ObjC-block/runtime tests get `callconv(.c)` on fn-ptr types
  cast from `objc_msgSend` / Block.invoke (C-side calls into sx).
- ffi-06-callback gets `callconv(.c)` on double_it/add_with_ctx —
  the registered C-side callbacks.
- 08-types snapshot regen (undefined-init drift from layout shift).
- 11 JNI/ObjC .ir snapshots regen for the ctx-prepended thunk
  signatures.

151/152 example tests pass. Remaining failure (05-run) is the
comptime/interp path that requires Step 7 (callWithDefaultContext).
2026-05-25 08:41:50 +03:00
agra
29784c22a8 mem: implicit-context foundation + many compiler fixes
The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):

  Step 1 — `CAllocator :: struct {}` stateless allocator in
    library/modules/allocators.sx, delegating directly to
    libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
    `func_ref: FuncId` leaf so nested aggregates can carry function
    pointers (the inline Allocator value's fn-ptr fields). Switch
    sites updated in emit_llvm.zig, print.zig, interp.zig.

  Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
    a static `__sx_default_context` global with a nested-aggregate
    init_val pointing at the CAllocator → Allocator thunks. The
    second-pass `initVtableGlobals` in emit_llvm.zig is generalised
    to handle `.aggregate` init_vals (re-emits after func_map is
    populated so func_ref leaves resolve to real symbols).

Also folded in from earlier work this session:

  - Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
    through `context.allocator` via the new `allocViaContext` helper.
  - interp.zig: `marshalForeignArg` double-offset bug fixed —
    `heapSlice` already adds `hp.offset` to the slice ptr, so the
    extra `+ hp.offset` was scribbling memcpy/memset into adjacent
    heap state, corrupting `heap.items[0]`. Symptom: `build_format`
    at comptime produced zero bytes, all `print` calls failed.
  - Lazy lowering: `lazyLowerFunction` now declares foreign-body
    functions as extern stubs in the local (comptime) module so
    cross-module foreign calls resolve.
  - Allocator API: all stdlib allocators on one-line `init() -> *T`
    (CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
    backed; BufAlloc: embeds state at head of user buffer).
  - issues 0038 (transitive #import), 0039 (chess + stdlib migration
    fallout), 0040 (generic struct method dot-dispatch), 0041
    (pointer types as type-arg), 0042 (alias name resolution) — all
    fixed; regression tests in examples/.
  - Diagnostic: `emitError` now embeds the lowering's
    `current_source_file` and enclosing function in the literal
    message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
    emit site so misattributed spans can't hide where the failure
    is.
  - tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
    (interp/codegen parity tester) added.

Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.
2026-05-24 22:59:20 +03:00
agra
0ba41b2980 ... 2026-05-24 13:42:35 +03:00
agra
49b39ba07a ... 2026-05-23 15:41:12 +03:00
agra
4c6c29b299 specs: §10.5 Bundling and Post-Link Callbacks
Documents the post-link callback model that the bundling-in-sx campaign
landed (Weeks 6 + 7):

  - Explicit opt-in via `BuildOptions.set_post_link_callback(fn)` or
    `set_post_link_module(name)` from a user `#run` block. No stdlib
    default; no implicit prelude. CLI `--bundle` / `--apk` auto-fallback
    to `post_link_module = "platform.bundle"` so existing CLI invocations
    keep working without an in-source registration.

  - `BuildOptions` surface: setters (link_flag / framework / output_path
    / wasm_shell / asset_dir / post_link_callback / post_link_module /
    bundle_path / bundle_id / codesign_identity / provisioning_profile /
    manifest_path / keystore_path) + accessors (binary_path / target_triple
    / is_macos / is_ios / is_ios_device / is_ios_simulator / is_android /
    framework / framework_path / jni_main / asset_dir families). Returned
    strings are "" when unset; counts are 0.

  - `fs.sx` / `process.sx` stdlib modules. Both work in two execution
    contexts: at runtime via the dynamic linker, and at #run / post-link
    via `src/ir/host_ffi.zig`'s dlsym(RTLD_DEFAULT) trampolines.

  - Per-target Apple `.app` flow: stage + Info.plist (macOS minimal vs
    iOS-shaped UIDeviceFamily/LSRequiresIPhoneOS/UIApplicationSceneManifest/
    DTPlatformName) + provisioning embed (iOS device) + Frameworks/ embed
    (iOS) + entitlements extraction (`security cms` + 3× `plutil`) +
    codesign with --entitlements when present.

  - Android `.apk` flow: SDK discovery → highest build-tools / platforms
    via `ls -1 | sort -V | tail -1` → stage lib/arm64-v8a/<libfoo.so> →
    manifest synth (NativeActivity vs `#jni_main` Activity) → javac + d8
    per `#jni_main` decl → aapt2 link → zip lib/dex/assets → zipalign →
    keytool debug keystore (first use) → apksigner sign.
2026-05-23 01:35:05 +03:00
agra
632e64512b bundling: Android APK pipeline moved into sx; android.sx state-on-plat
Week 7 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
plus the android.sx refactor + three sx-compiler fixes hit along the way
to get chess on Pixel 7 Pro responding to touch end-to-end.

library/modules/platform/bundle.sx now covers the Android APK shape
alongside macOS / iOS-sim / iOS-device. `android_bundle_main` discovers
the SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT / $HOME/Library/Android/sdk),
picks the highest-versioned build-tools + platforms via
`process.run("ls .. | sort -V | tail -1")`, stages
`<apk>.stage/lib/arm64-v8a/<libfoo.so>`, synthesizes
AndroidManifest.xml (NativeActivity vs `#jni_main` Activity branch),
writes each `#jni_main` decl's Java source under
`<stage>/java/<pkg>/<Cls>.java`, runs javac --release 11 + d8 to
produce classes.dex, aapt2-links the unaligned APK, appends lib/ +
classes.dex + each registered asset tree via zip, zipalign + ensure
debug keystore via keytool + apksigner sign.

Compiler-side accessors (src/ir/compiler_hooks.zig + library/modules/compiler.sx):
- is_android predicate.
- set_manifest_path / manifest_path + set_keystore_path / keystore_path.
- jni_main_count / jni_main_foreign_path_at(i) /
  jni_main_java_source_at(i) surface the `#jni_main` emissions that
  the Zig createApk previously consumed directly.
- main.zig wires manifest_path, keystore_path, and the per-decl
  (foreign_path, java_source) parallel slices into BuildConfig before
  invoking the post-link callback.

CLI `--apk <path>` keeps working as a transitional alias: it now feeds
bundle_path so the existing auto-`post_link_module = "platform.bundle"`
shim fires the same way as `--bundle`. main.zig no longer calls
target.createApk directly.

Deletions in src/target.zig: createApk, compileJniMainSources,
buildJniMainManifest, buildAndroidManifest, ensureDebugKeystore,
libNameFromSoBasename, plus helpers splitForeignPath / discoverJavac /
discoverAndroidSdk / findHighestSubdir / runProcess / runProcessIn
(~400 lines). git grep returns only the obituary comment.

library/modules/platform/android.sx refactor (chess Android dependency):
- Module-level globals retired (g_app_window, g_egl_*, g_viewport_*,
  g_dpi_scale, g_should_stop, g_render_thread*, g_user_main_fn,
  g_touch_*) → AndroidPlatform struct fields.
- All sx_android_* helpers take `plat: *AndroidPlatform` as first arg.
  Render thread receives plat via pthread_create's arg.
- New `logical_w: f32 = 0.0` field. Consumers set it before init() to
  define the design width in points; `recompute_scale` derives
  `dpi_scale = pixel_w / logical_w` (or 1.0 if unset). Called on
  init / set_viewport / egl_init. drain_touches divides incoming
  physical pixel coords by dpi_scale so chess sees logical-space
  positions matching its layout. Touch lands on the right squares.

Three sx-compiler bugs hit + fixed along the way:

1. Top-level `inline if OS == .X { decls }` body decls were silently
   dropped because scanDecls/lowerDecls had no .if_expr arm. New
   `flattenComptimeConditionals` pre-pass in src/imports.zig
   (threaded via ComptimeContext from core.zig) hoists matching arms
   recursively. Regression at examples/124-inline-if-hoist-toplevel.sx.

2. Parser rejected `#import` / `#framework` inside inline-if bodies
   because parseStmt in src/parser.zig only had arms for `#insert`.
   Added the missing arms. Regression at
   examples/123-inline-if-import-in-body.sx (landed earlier).

3. JNI `Call<T>Method` switches in src/ir/emit_llvm.zig (instance /
   nonvirtual / static) were missing `.f32` rows — jfloat returns
   (e.g. MotionEvent.getX/getY) fell into the silent-undef else arm.
   Chess's sx_android_push_touch(plat, getAction(), getX(), getY())
   delivered garbage f32 coords to the touch ring, so taps landed
   nowhere recognisable. Added `.f32 => Jni.Call{Static,Nonvirtual,}FloatMethod`
   rows to all three switches; lifted unsupported-type detection
   from emit_llvm into lowerForeignMethodCall with proper
   source-spanned diagnostics (`isJniReturnTypeSupported`). Regressions
   at examples/ffi-jni-call-10-jfloat-return.sx,
   examples/ffi-jni-class-09-multi-float-args.sx,
   examples/ffi-jni-call-11-unsupported-return-diag.sx.

Stale-snapshot drift in tests/expected/ffi-objc-call-03-selector-sharing.ir
and ffi-objc-call-06-sret-return.ir picks up the new BuildOptions
accessor extern decls (is_android, set_manifest_path,
set_keystore_path, jni_main_count, jni_main_foreign_path_at,
jni_main_java_source_at). Verified diff is dead-decl-only.

Chess on Pixel 7 Pro: tap on e2 white pawn -> yellow selection +
green dots on legal e3/e4 targets; tap on e4 -> board updates with
1. e4, "Black to move" + "1. e4" in info panel.

zig build && zig build test && bash tests/run_examples.sh -> 145/145
green. bash tests/cross_compile.sh -> 7/7 green.
2026-05-23 01:28:32 +03:00
agra
5cc62e63c3 bundling: fs/process stdlib + post-link callback + Apple .app in sx
Campaign Weeks 3-6 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
land in one push: the bundling pipeline that used to live in
src/target.zig (createBundle, embedFramework, extractEntitlements,
buildInfoPlist, codesign) now lives in
library/modules/platform/bundle.sx and runs in the IR interpreter
after target.link() returns.

New language-side surface:
- library/modules/fs.sx — POSIX libc bindings (open/read/write/close,
  mkdir/unlink/rmdir, chmod, rename, access, basename/dirname). Variadic
  open() lowers to C's varargs via the new args: ..T form. Direct libc
  calls bypass *File method dispatch so they work from the post-link
  IR interpreter.
- library/modules/process.sx — popen-based run(cmd) returning
  ProcessResult{ exit_code, stdout }, plus env() and find_executable().
- library/modules/std.sx — xml_escape(s) and variadic path_join(parts).
- library/modules/compiler.sx — BuildOptions grows
  set_post_link_callback / set_post_link_module / binary_path
  accessors; bundle_path/bundle_id/codesign_identity/provisioning_profile
  setters + accessors; per-target predicates is_macos/is_ios/
  is_ios_device/is_ios_simulator + target_triple; framework_count /
  framework_at(i) / framework_path_count / framework_path_at(i);
  add_asset_dir(src, dest) + asset_dir_count / src_at / dest_at.

Compiler-side wiring:
- src/ir/compiler_hooks.zig — BuildConfig now carries post_link_callback_fn,
  post_link_module, binary_path, bundle_*, target_triple,
  target_frameworks, target_framework_paths, asset_dirs. Hook registry
  exposes every accessor; getters return "" / 0 for unset fields so
  bundle.sx can treat absent values uniformly.
- src/ir/host_ffi.zig (new) — dlsym(RTLD_DEFAULT) + arity-switched cdecl
  trampolines so #foreign("c") declarations resolve through the host
  libc during #run / post-link interpretation.
- src/ir/interp.zig — callForeign dispatch; build_config pointer
  injection so accessor hooks see live state during re-entry.
- src/core.zig — keeps the IR module alive past generateCode; exposes
  invokeByName / invokeByFuncId so main.zig can re-enter the
  interpreter after linking.
- src/main.zig — wires bundle/codesign/provisioning CLI flags +
  target_triple + framework lists into BuildConfig; invokes the
  post-link callback (by FuncId or by <module>.bundle_main lookup) once
  target.link() returns. When --bundle is set but no callback is
  registered, auto-falls-back to post_link_module = "platform.bundle"
  so the legacy --bundle CLI keeps working for any program that imports
  modules/platform/bundle.sx.

Apple .app bundler (library/modules/platform/bundle.sx):
- Single bundle_main entry covers macOS, iOS simulator, iOS device.
  Per-target Info.plist switch keys off is_ios()/is_ios_simulator() —
  iOS emits UIDeviceFamily / LSRequiresIPhoneOS /
  UIApplicationSceneManifest / DTPlatformName (iPhoneOS or
  iPhoneSimulator); macOS emits the minimal CFBundle* set.
- iOS-only steps:
  - Provisioning embed: fs.read_file + fs.write_file to
    <bundle>/embedded.mobileprovision.
  - Framework embed: recursive cp -R per -F search path into
    <bundle>/Frameworks/<Name>.framework/ (until fs.sx grows list_dir).
  - Entitlements extraction: four process.run calls (security cms -D,
    plutil -extract Entitlements xml1, plutil -extract
    ApplicationIdentifierPrefix.0, plutil -replace application-identifier)
    resolving the wildcard <TEAM>.* -> <TEAM>.<bundle_id>.
  - Real codesign with --entitlements when present.
- Asset dirs (add_asset_dir): recursive cp -R src/. into <bundle>/dest/.
  Missing src is treated as "nothing to do" so projects can register
  add_asset_dir("assets", "assets") unconditionally.

Parser:
- parseStmt() now accepts #import \"path\"; and #framework \"Name\"; as
  statement-position tokens. Needed for top-level
  inline if OS == .android { #import \"modules/platform/android.sx\"; }
  blocks (issue-0042 flatten pass surfaces them); chess's
  inline-if-with-#import was rejected at parse time before this fix.

Removals from src/target.zig:
- createBundle, embedFramework, extractEntitlements, buildInfoPlist,
  codesign (~210 lines). main.zig no longer calls createBundle after
  link(); the sx callback is the single entry point.

Tests / regression markers (all run under sx run host JIT):
- examples/115-post-link-callback.sx — callback registration round-trip.
- examples/116-fs-roundtrip.sx — fs.write_file -> fs.read_file -> exists.
- examples/117-process-roundtrip.sx — process.run + env + find_executable.
- examples/118-macos-bundle.sx — macOS .app via bundle_main callback.
- examples/119-interp-cast-ptr-cmp.sx — cast(T) val under interpreter.
- examples/120-interp-variadic-any.sx — variadic ..Any indexing in IR
  interpreter.
- examples/121-ios-sim-bundle.sx — iOS-sim cross-compile + .app with
  iOS-shaped Info.plist (added to tests/cross_compile.sh as the
  ios-sim tuple).
- examples/122-ios-device-bundle.sx — iOS device cross-compile +
  full codesign pipeline (provisioning embed + entitlements
  extraction + --entitlements codesign). Manually verified end-to-end:
  installed via xcrun devicectl device install app + launched
  successfully on iPhone 17 Pro.
- examples/123-inline-if-import-in-body.sx — locks in the parser fix.

zig build && zig build test && bash tests/run_examples.sh => 141 passed,
0 failed; bash tests/cross_compile.sh => 7 passed, 0 failed.
2026-05-22 19:03:31 +03:00
agra
30fed66616 ffi #foreign: C-variadic tail via args: ..T
Trailing `args: ..T` on a #foreign declaration now lowers to the C
calling convention's `...` instead of sx-side slice-packing. Drops
the per-arity #foreign-shim workaround for callers of variadic C
APIs (__android_log_print, printf-family, etc.). Closes issue-0043.

- IR: Function.is_variadic on inst.Function; declareFunction drops
  the variadic param from the IR signature for foreign+variadic
  decls.
- emit_llvm: LLVMFunctionType receives is_var_arg=1 when the flag
  is set; call lowering passes extras through unchanged.
- Lowering: packVariadicCallArgs early-outs for foreign+variadic
  (no slice-pack); new promoteCVariadicArgs applies C default
  argument promotion (bool/s8/s16/u8/u16 -> s32, f32 -> f64) to
  extras past the fixed param count.
- Test: examples/ffi-foreign-cvariadic.sx + .c exercise s64/f64/s32
  returns through C va_arg over s32/f64/*u8 element types.

134 host + 6 cross tests pass on the WIP-less baseline.
2026-05-22 13:13:43 +03:00
agra
cc29cfa7ce ffi #jni_main: jni_java_emit + android.sx + manifest fixes; chess on Pixel
Combined slice — gets chess rendering on a Pixel 7 Pro via the
`#jni_main` pipeline. Half-dozen jni_java_emit fixes plus the rebuilt
stdlib android module:

  jni_java_emit:
    - `#implements Alias;` body members render as Java `implements`
      clauses on the class header (space-separated, registry-resolved).
    - Drop the implicit `super.<method>(args)` call from the @Override
      delegate — interface impls (SurfaceHolder.Callback) have no
      super; user calls super explicitly from sx-side via
      `super.method(args)` lowered to `CallNonvirtual<T>Method`.
    - `static { System.loadLibrary("<libname>"); }` static init block,
      lib name derived from the build's `-o` basename.
    - `name: Type;` body items render as private Java fields.
    - `$` (JNI nested-class shape) → `.` in Java source: e.g.
      `android/view/SurfaceHolder$Callback` → `android.view.SurfaceHolder.Callback`.
    - Non-void @Override bodies `return` the native delegate's result.

  lower.zig:
    - `super.method(args)` sugar inside a `#jni_main` (or any
      sx-defined `#jni_class`) bodied method lowers to JNI
      `CallNonvirtual<T>Method` with the parent class resolved via
      `#extends` (default Activity).
    - `Alias.new(args)` constructor sugar lowers to JNI
      `FindClass + GetMethodID("<init>", sig) + NewObject`.
    - `jniMapParamType` stops erasing pointer types so method dispatch
      on foreign-class params (`holder.getSurface()`) resolves.
    - synthesizeJniMainStub pushes the env arg onto the lexical
      `#jni_env` stack so omitted-env `#jni_call` and `super.method`
      sites see it.

  target.zig:
    - Manifest synthesised from `#jni_main` adds
      `android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"`
      so sx apps own the whole window (no title strip, no status bar).

  library/modules/platform/android.sx (NEW):
    - Replaces the retired NativeActivity-based module under #jni_main.
    - Foreign-class decls for Bundle / Context / Surface / SurfaceHolder
      / SurfaceView / MotionEvent / View / Activity / SurfaceHolderCallback /
      AssetManagerJ.
    - libandroid / EGL / pthread foreign C decls.
    - Helpers consumers call from their Activity body:
      `sx_android_forward_assets(env, ctx)`,
      `sx_android_attach_window(env, holder)`,
      `sx_android_detach_window()`,
      `sx_android_set_viewport(w, h)`,
      `sx_android_start_render_thread(main_fn)`,
      `sx_android_push_touch(action, x, y)`.
    - Render thread brings up EGL on the ANativeWindow then calls the
      user-supplied entry fn pointer.
    - `AndroidPlatform` struct + `impl Platform` (init / begin_frame /
      end_frame / poll_events / safe_insets / keyboard / show_keyboard /
      hide_keyboard / stop / shutdown / run_frame_loop).

End-to-end verified on a Pixel 7 Pro: chess APK builds via
`sx build --target android --apk ... --bundle-id ... -o ...`, installs
via `adb install -r`, launches and renders the chess board with all
pieces in starting position. No title strip, no flicker. Touch events
reach `sx_android_push_touch` and drain through `poll_events` (debug-
verified) — chess's pipeline-side hit-test routing + DPI-correct
sizing remain as follow-ups.

138 host / 8 cross / `zig build test` all green.
2026-05-20 19:50:25 +03:00
agra
c02b6b3b1b ffi #jni_main: Alias.new(args) constructor dispatch via JNI NewObject
Adds the constructor-invocation arm of the foreign-class DSL:
`SurfaceView.new(ctx)` (where `SurfaceView` is a `#foreign #jni_class`
with `static new :: (ctx: *Context) -> *Self;`) lowers to
`FindClass(env, "android/view/SurfaceView") + GetMethodID(env, cls,
"<init>", "(args)V") + NewObject(env, cls, mid, args...)`. Returns
the fresh jobject.

  - inst.zig: `JniMsgSend.is_constructor` flag + `parent_class_path`
    re-purposed to carry the class being constructed (alongside its
    existing nonvirtual-super-class use). Mutually exclusive with
    `is_static` / `is_nonvirtual`.
  - lower.zig: `lowerCall.field_access` arm now recognises
    `Alias.method(args)` where `Alias` resolves in `foreign_class_map`
    and the matching member is `static`. `new` routes to a new
    `lowerForeignStaticCall` that derives a `(args)V` JNI descriptor
    and emits a `JniMsgSend` with `is_constructor=true`. Non-`new`
    static calls report a clear "use #jni_static_call" diagnostic
    until that sugar lands.
  - emit_llvm.zig: new `NewObject` vtable slot (28) + `emitJniConstructor`
    helper expanding the FindClass+GetMethodID+NewObject chain. The
    jni_msg_send arm short-circuits to it when `is_constructor` is set.

Smoke `ffi-jni-main-03-ctor.sx` exercises both this slice and the
previous super-dispatch slice in a single `onCreate` body: calls
`super.onCreate(b)` then constructs a `SurfaceView` with the Activity
as Context. IR shows the expected six-stage chain (FindClass+GetMethodID+
CallNonvirtual + FindClass+GetMethodID+NewObject); APK builds clean.

Naming caveat: the Java type `android.content.Context` clashes with
sx stdlib's `Context :: struct {...}` (heap-context). The smoke aliases
it `JContext` — future work could add a path-prefix or `as` rename
form on `#jni_class` to avoid the manual rename.

133 host / 6 cross / zig build test all green.
2026-05-20 17:14:51 +03:00
agra
36f40057f7 ffi #jni_main: emit name: Type; body members as private Java fields
`#jni_class` body items of the form `name: Type;` were parsed into
`ForeignFieldDecl` but dropped by jni_java_emit. They now render as
private Java fields between the static init block and the @Override
delegates, using the same primitive / `*Foo` → fully-qualified-name
type mapping as method parameters.

Needed for the chess-on-Pixel `SxApp` Activity to hold its
`SurfaceView` reference: `view: SurfaceView;` → `private
android.view.SurfaceView view;`.
2026-05-20 17:01:24 +03:00
agra
d946e3d577 ffi #jni_main: sx-side super.method(args) dispatch via CallNonvirtual<T>Method
Inside a `#jni_main` (or any sx-defined `#jni_class`) bodied method,
`super.method(args)` lowers to JNI's nonvirtual dispatch against the
parent class resolved via `#extends` (default `android.app.Activity`).

  - lower.zig: tracks `current_foreign_class` + `current_foreign_method`
    around each `synthesizeJniMainStub` body; pushes the JNIEnv* arg
    onto the lexical `#jni_env` stack so omitted-env JNI calls inside
    the body see env without a wrapper. New `lowerSuperCall` handles
    the `super.method(args)` receiver pattern: derives parent path,
    reuses the enclosing method's signature when names match (the
    common `super.<override>(args)` case), or looks up the method on
    the parent class declared as `#foreign #jni_class`.
  - inst.zig: `JniMsgSend` gains `is_nonvirtual: bool` and
    `parent_class_path: ?[]const u8` — the dispatch tag + super class
    foreign path. Mutually exclusive with `is_static`.
  - emit_llvm.zig: new `CallNonvirtual<T>Method` vtable slots + a
    fourth dispatch arm. Resolves the parent jclass via
    `FindClass(env, parent_path)` (per-call; caching is follow-up),
    then `GetMethodID(env, parent_cls, name, sig)`, then
    `CallNonvirtual<T>Method(env, obj, parent_cls, mid, args...)`.

Disassembly on the smoke confirms the chain:
`ldr [env+0x30]` (FindClass) → `ldr [env+0x108]` (GetMethodID) →
`ldr [env+0x2d8]` (CallNonvirtualVoidMethod) with `(env, self,
parent_cls, mid, bundle)`.

132 host / 5 cross / zig build test all green. The slice unblocks
Activity lifecycle overrides (onCreate, onResume, onPause) calling
their required `super.<method>(args)` without raw `#jni_call`
boilerplate.
2026-05-20 16:57:30 +03:00
agra
d43f21f39e ffi #jni_main: emit static { System.loadLibrary(...); } in the Java class
Required for Android to resolve the `Java_*` symbols R.3 synthesises:
without `System.loadLibrary(...)` running before the Activity calls its
first native method, JNI lookup fails with UnsatisfiedLinkError.

The lib name comes from the build's `-o` basename — `/tmp/libsxchess.so`
→ `sxchess` — derived in `Compilation.collectJniMainEmissions` and
threaded through new `jni_java_emit.Options.lib_name`. When `-o` is
unset (or doesn't match `lib*.so`), the emitter omits the static init
and the caller must arrange loading another way.

dex confirmation on the slice 2 smoke: `<clinit>` static constructor
appears alongside `<init>` and `sx_onCreate` — the bytecode invokes
`System.loadLibrary("sxjnimain")` matching `/tmp/libsxjnimain.so`.

131 host / 4 cross / zig build test all green.
2026-05-20 16:45:41 +03:00
agra
22768d9adf ffi #jni_main: drop implicit super call from @Override delegate
The Java @Override no longer injects a `super.<method>(...)` call before
the native delegate. The user calls super from the sx-side body when
needed — via a forthcoming `super.method(args)` dispatch lowered to
`CallNonvirtual<Type>Method` on JNI classes.

Two reasons:

  - Interface method impls (e.g. SurfaceHolder.Callback) have no super
    to call. The previous emit produced javac-rejected code for those.
  - Lifecycle overrides may want to skip super in some cases, or call
    it with different args. The emitter can't second-guess intent.

User-space control of the dispatch keeps the emitter free of "is this
an interface method or a supertype override?" guesswork. The dex
shrinks by one virtual-method bytecode invocation per override.

Caveat: until the sx-side `super.method(args)` dispatch lands, Activity
lifecycle methods (onCreate, onResume, etc.) that mandate `super.<>`
will throw `SuperNotCalledException` at runtime if their bodies don't
do their own JNI dispatch. The slice 2 smoke still launches cleanly
because its onCreate body is empty.

131 host / 4 cross / zig build test all green.
2026-05-20 16:41:13 +03:00
agra
bce5448fe9 ffi #jni_main: emit implements clauses from #implements Alias; members
`jni_java_emit` previously dropped `#implements` members on the floor.
They now compose into the Java class header — first one prefixed with
` implements `, subsequent ones comma-separated. Aliases resolve
through the class registry just like `#extends`: an unmapped alias
passes through verbatim (handy for built-in JVM interfaces like
`java.lang.Runnable` without declaring a `#jni_class` for them).

First building block of the chess-on-Pixel migration: the new Activity
needs `implements android.view.SurfaceHolder$Callback` to receive
surfaceCreated / surfaceChanged / surfaceDestroyed callbacks from the
SurfaceView it hosts.

Unit test locks in both the registry-resolved and pass-through paths.
131 host / zig build test green.
2026-05-20 15:38:05 +03:00
agra
619d524bac ffi #jni_main R.5: retire legacy NativeActivity surface
Deletes the entire NativeActivity / native_app_glue / ALooper stack
that the previous Android entry path was built around:

  - `examples/99-android-egl-clear.sx` — the demo of the legacy path.
  - `library/modules/platform/android.sx` — `AndroidPlatform.init`,
    `run_frame_loop`, `sx_android_bootstrap`, `g_android_app`, plus
    the ALooper / AInputEvent / ANativeActivity / AConfiguration
    foreign decls that fed them. The JNI helpers (`sx_load_javavm_fn`,
    `sx_android_get_env`, `sx_query_safe_insets_jni`, the
    `ANATIVEACTIVITY_*` offsets) were tied to the ANativeActivity*
    delivered to `android_main` — they're stale now that the OS hands
    sx code a Java Activity directly via `onCreate(JNIEnv*, jobject)`.
  - `library/vendors/sx_android_jni/sx_android_jni.c` — the input-
    handler installer (`sx_android_install_input_handler`), which
    poked NDK app-pointer field offsets that no longer exist.

`library/modules/platform/android_jni.sx` (the `Activity`/`Window`/
`View`/`WindowInsets` `#jni_class` registry used for safe-insets
dispatch) survives — it's standalone declarative bindings useful from
any `#jni_main` onCreate body. Docstring updated to drop the
"imported from android.sx" framing.

131 host / 4 cross / zig build test all green. End-to-end smoke APK
still produces the expected JNI-mangled symbol +
SxApp-extends-Activity dex.

External consumers (chess) will need to migrate their entry from the
`AndroidPlatform.run_frame_loop` model to the `#jni_main` model
(Java-side Activity drives lifecycle; onSurfaceChanged / Choreographer
drive frames via JNI callbacks). That migration is downstream work.
2026-05-20 15:17:24 +03:00
agra
3300bfb0df ffi #jni_main R.4: require #jni_main on Android; retire android_main acceptance
`checkRequiredEntryPoints` no longer accepts `android_main` as an
Android entry — `#jni_main #jni_class("...")` is now the sole accepted
path. The diagnostic walks the user through declaring an Activity +
Bundle foreign decl. `isExportedEntryName` drops `android_main` and
`ANativeActivity_onCreate` (both were for the legacy NativeActivity
glue path R.2 stopped linking by default).

Migrates the two cross-compile examples that previously carried an
`android_main` trampoline to a minimal `#jni_main #jni_class(...) { }`
stub:

  - `examples/ffi-jni-call-02-void.sx` — tests `#jni_call(void)` lowering
  - `examples/ffi-objc-call-10-os-gate.sx` — tests `inline if OS` gating

Both stubs are empty (no `onCreate` body) so they exercise the
entry-point check + R.3's JNI-symbol synthesis pass produces no
symbols. 131 host / 4 cross / zig build test all green.

`examples/99-android-egl-clear.sx` still uses `android_main` and the
AndroidPlatform/native_app_glue stack — its Android-target build now
fails the entry-point check. R.5 removes it along with the rest of
the legacy NativeActivity surface.
2026-05-20 15:13:33 +03:00
agra
063bbb5419 ffi #jni_main R.3: synthesize JNI-mangled exports for bodied methods
After lowering completes, a new pass walks `foreign_class_map` and, for
every bodied non-static method on a `#jni_main #jni_class("...")` decl,
synthesises a C-ABI exported function whose name follows JNI's name-
mangling convention:

    `Java_<pkg-mangled>_<Class>_sx_1<method-mangled>`

(`/` → `_`, `_` → `_1`). Android's JNI runtime resolves `private native
sx_<method>(...)` declared in the bundled classes.dex via this symbol
without needing an explicit `JNI_OnLoad`/`RegisterNatives` — the
name-mangling fallback is enough.

Param ABI: `(env: *void, self: *void)` prepended (JNIEnv* + jobject
receiver), followed by the user-declared params with pointer types
type-erased to `*void`. The user's body is lowered through the normal
fn-body pipeline with `env`, `self`, and the user-named params bound in
scope. `isExportedEntryName` now also returns true for any name starting
with `Java_` so emit_llvm sets external linkage.

Verified end-to-end: `llvm-nm -D` on the slice 2 smoke .so shows
`Java_co_swipelab_sxjnimain_SxApp_sx_1onCreate` as an exported T
symbol. 131 host / 4 cross / zig build test all green.

Future work (R.3b territory): richer typing inside bodies so `*Self` /
`*Bundle` params support method dispatch through the foreign-class
slot interning. For now `self`/`b` are opaque `*void` jobjects in
scope — fine for stub bodies and `#jni_call`-driven dispatch.
2026-05-20 15:10:33 +03:00
agra
2461218111 ffi #jni_main R.2: drop native_app_glue from Android link when #jni_main present
`target.link` now takes a `has_jni_main: bool` parameter (passed by
main.zig from `comp.getJniMainEmissions().len > 0`). When set:

  - native_app_glue.c is not compiled — no `.glue.o` produced.
  - `-u ANativeActivity_onCreate` is not added to the link argv.
  - The Java-driven Activity is the entry; the .so just provides JNI
    impls, bound at load time via the `JNI_OnLoad` slice R.3 will
    synthesize.

Legacy NativeActivity builds (no `#jni_main` decl) are unchanged: glue
is still compiled and `ANativeActivity_onCreate` still retained.

Verified end-to-end:
  - #jni_main .so: `llvm-nm -D` shows neither `ANativeActivity_onCreate`
    nor `android_main` (correct — Java side drives entry).
  - Legacy .so (99-android-egl-clear): both symbols still exported.

131 host / 4 cross / zig build test all green.
2026-05-20 14:59:49 +03:00
agra
8ae4e0c653 ffi #jni_main R.1: manifest synthesis + default parent → android.app.Activity
When `Compilation.lowering_jni_main_decls` is non-empty, `createApk`
synthesises a manifest whose `<activity android:name>` points at the
user's `#jni_main` class (dotted form of the foreign path), sets
`android:hasCode="true"` so Android loads the bundled classes.dex, and
drops the `android.app.lib_name` meta-data (that's the NativeActivity-
specific autoload mechanism — Java-driven Activities load the .so via
`System.loadLibrary` from a Java static initializer slice R.3 will
emit). The legacy NativeActivity path stays as the fallback when no
`#jni_main` decl is present.

`jni_java_emit.zig`'s default superclass moves from
`android.app.NativeActivity` to `android.app.Activity` — the former
requires native_app_glue's `ANativeActivity_onCreate` to be in the .so,
which the next slice (R.2) will stop linking by default.

Verified end-to-end on the slice 2 smoke APK: `aapt2 dump xmltree`
shows `android:name="co.swipelab.sxjnimain.SxApp"` + `hasCode="true"`,
and `dexdump -l plain` confirms SxApp now extends `Landroid/app/Activity;`.
99-android-egl-clear's APK still uses the NativeActivity manifest as
before (legacy path intact for R.2-R.5).
2026-05-20 14:55:26 +03:00
agra
5648337749 ffi #jni_main: accept #jni_main as Android entry; modern smoke shape
Loosens lower.zig's `checkRequiredEntryPoints` to accept either a
`#jni_main #jni_class("...")` decl OR the legacy `android_main`
trampoline. The diagnostic now shows both options when neither is
present.

Updates the slice 2 smoke (`examples/ffi-jni-main-01-emit.sx`) to
express the modern shape — drops `android_main`, declares
`Bundle :: #foreign #jni_class("android/os/Bundle")`, and overrides
`onCreate :: (self: *Self, b: *Bundle) { }` inside the #jni_main class.
The emitted Java now correctly declares `void onCreate(Bundle b)` as
@Override + a matching `private native void sx_onCreate(Bundle b)`
delegate, verified via dexdump.

Full retirement of `android_main` (deleting native_app_glue from the
Android link path, dropping `AndroidPlatform.run_frame_loop`, migrating
chess/EGL demo to the Java-driven lifecycle) is multi-slice rework
and stays as follow-up.
2026-05-20 14:50:21 +03:00
agra
1ae43495c2 ffi #jni_main slice 2: AOT pipeline — .java + javac + d8 → classes.dex in APK
Compilation.lowering_jni_main_decls is populated by lowerToIR (iterating
foreign_class_map for is_main && !is_foreign && runtime==jni_class,
deduped by foreign_path); each entry carries the pre-rendered Java source
from jni_java_emit.emitJavaSource.

createApk extended: when the emission list is non-empty, write each
.java under <stage>/java/<pkg>/<Class>.java, javac --release 11 to
<stage>/classes/, d8 --release --lib <android_jar> --output <stage>
to produce <stage>/classes.dex, then zip the .dex into the unaligned
APK at root level. javac discovery: $JAVA_HOME/bin/javac first, then
`which javac`.

Manifest still hardcodes android.app.NativeActivity (slice 3 wires the
user's class name + android:hasCode="true"), so the bundled .dex is
present but unreferenced at runtime. End-to-end verified via dexdump on
the smoke example's APK — Lco/swipelab/sxjnimain/SxApp; extending
NativeActivity shows up in classes.dex. Non-#jni_main APK builds
(99-android-egl-clear.sx) produce the same shape as before.

Cross-compile tuple added for examples/ffi-jni-main-01-emit.sx
(compile-only — APK exercise is manual).
2026-05-20 14:42:03 +03:00
agra
7ea7ad778e ffi #jni_main slice 1: Java source emitter (pure fn + unit tests)
`src/ir/jni_java_emit.zig`'s `emitJavaSource` takes a
`ForeignClassDecl` with `is_main = true` and returns the `.java`
source text. AOT pipeline integration (javac + d8 + APK bundling +
manifest synthesis + RegisterNatives) lands in subsequent slices.

Emission shape per bodied method:

    @Override
    public <ret> <name>(<params>) {
        super.<name>(<args>);
        sx_<name>(<args>);
    }
    private native <ret> sx_<name>(<params>);

Declaration-only methods (no body — references inherited Java
methods that sx wants to *call*) are skipped — no override, no
native delegate.

`#extends Alias` resolves through the supplied class registry to
the parent's foreign Java path. Default parent is
`android.app.NativeActivity` when `#extends` is absent.

Type matrix: primitives (void/bool/s8..s64/u8/u16/f32/f64), `*Self`
elided as the receiver (Java's implicit `this`), `*void` as
`Object`, `*Foo` cross-class refs resolved through the class
registry.

Six unit tests cover: non-main rejection, full void onCreate(Bundle)
shape with @Override delegate, primitive params, declaration-only
skipping, `#extends Alias` resolution, default-package classes.

130/130 examples still green; zig test clean.
2026-05-20 14:16:40 +03:00