(* emit.ml — the `.wob` bytecode emitter (plan 3, Task 1). Lowers the parsed, typechecked, owner-annotated program into a `.wob` v1 image. Two documents govern every byte produced here and neither is negotiable from this file: - docs/plan/oop-vm/00-wob-format.md — the normative format - runtime/src/wob.h — its machine-readable twin The round-trip rule from the plan's global constraints: an image `woc` produces that `wovm`'s loader (runtime/src/loader.c) rejects is always an emitter bug. Everything the loader validates is therefore maintained as an invariant here — register counts 1..64 with args <= registers, every static register operand < the method's register count, constant/class/callee/slot indexes in range, CALL argument windows inside the caller's frame, jump targets inside the code, a terminator as the last instruction, builtin ids with their fixed arity, and strictly ascending line/drop tables whose masks fit the register count. ---- what this module consumes rather than re-derives ---------------- Types.symbols declarations, per-class field lists Types.wob_kind_of_typ the .wob field kind of a declared type Owner.tables the four ownership tables, verbatim: moves -> which MOVEs are real transfers drops -> DROP placement + drop-table masks rcs -> RC_INC / RC_DEC, minus ELIDED pairs residuals -> the ONLY places borrow ops appear Where the drop map is synced is worth stating once: the owner table is authoritative at every node it records a LIVE-MASK for, which is every Call expression and every DbStub — so those sync from the table. An `Index` read, a `map` element write and a `for` cursor step lower to BUILTIN instructions that are NOT Call nodes, so no table entry exists to sync to; they run on the emitter's running mask, which is exactly right for them. Residual regions are anchored by owner.ml on more than one node kind (a call expression, an assignment statement, a moved place), so each consumption site marks the region used and anything left over at the end of the unit is WO-E404 — a region nobody wrapped would ship the aliasing check silently disabled, which is the one failure a residual site exists to prevent. Two obligations dump.ml states for the tables and this file honors: the residual table may name the same canonical operand in several entries, so borrow guards are coalesced *per operand* (one acquire / release pair per operand register, strongest access kind winning) — emitting a pair per table entry would ask for both an exclusive and a shared borrow of one object and self-trap on legal code; and the conditional-move drop rule (JOIN-DROP) is already normalized in the table, so its entries are emitted as given. ---- register allocation -------------------------------------------- One scope-stack allocator per method, three tiers: r0 `self`, for a class method (the VM's window convention: ICALL leaves the receiver in the callee's r0, see runtime/src/vm.c's ICALL case) next parameters, in declaration order — `arg_cnt` is therefore (self ? 1 : 0) + parameter count then locals, allocated on declaration (`let`, and a `for`'s cursor plus its three loop-carried slots), released when their block ends above those expression temporaries from a high-water pool, reset to the local watermark at every statement boundary Temporaries are a bump allocator, which is what makes the Lua-style call window safe: a call's window sits at the current top, so every register the callee's frame overlaps (it zeroes r[argc..regc) at entry) is already dead. Argument slots are reserved *before* any argument is evaluated, so a nested call inside argument i cannot clobber an already-filled slot of the outer window. A method needing more than 64 registers is WO-E401 — a diagnostic, never a truncation. ---- what the front end does not supply ----------------------------- Nothing in plan 2 exports a per-expression type table (types.ml's pass 2 returns unit), so this file carries a small local type resolver (`ty_of_expr`) for the four decisions that need one: EQ vs EQS, direct CALL vs ICALL, which container builtin an `Index` or a `get`/`set` names, and the element/key kinds a fresh container is created with. It resolves types the same way owner.ml's own `expr_ty` does; it does not re-derive ownership, kinds, or any table. *) open Ast module SM = Map.Make (String) (* ============================================================ Diagnostics — WO-E4xx, the emitter's reserved range (diag.ml) ============================================================ *) (* WO-E401 — the method needs more registers than the VM's 64-slot window allows. The spec's register budget is a hard format limit (runtime/src/wob.h WO_MAX_REGS), so this is a diagnostic and the method is never silently truncated. *) let over_budget_code = Diag.emitter_prefix ^ "01" (* WO-E402 — a value that does not fit the instruction encoding: more than 65536 constants/classes/methods/interface slots (LOADK, NEW, CALL and ICALL carry a 16-bit operand), a field index above 255 (GETF/SETF carry a byte), or a jump farther than the signed 16-bit displacement. Same doctrine as WO-E401: name the limit, emit nothing. *) let limit_code = Diag.emitter_prefix ^ "02" (* WO-E403 — a construct the milestone-1 instruction set cannot express (an unresolved name, an element write into a `multi`, iterating a `map`). The front end accepts more surface than the VM implements — see the scope fence in the plan — and the emitter's job at that boundary is to say so, not to invent bytecode. *) let cannot_lower_code = Diag.emitter_prefix ^ "03" (* WO-E404 — a residual borrow site whose operand the emitter cannot tie to a live register at the guarded region. Emitting the region unguarded would drop the one enforcement the residual site exists for (a real aliasing violation would go unchecked), so this fails loudly instead. *) let unguardable_code = Diag.emitter_prefix ^ "04" (* WO-E405 — the program entry (the zero-arg free fn `main` selected below) declares a return type other than `Int`. The systems-track spec (docs/superpowers/specs/2026-08-01-systems-track-design.md:70) is explicit that a free `fn main(args) -> Int` compiles as a program *because* the return value is the exit code — so an entry returning a class was never legal, it just wasn't checked. Left unchecked, a `@gc` return escapes into runtime/src/main.c's `uint64_t ret`, which the driver has no way to release (the .wob method table carries no return kind), inflating that object's refcount permanently — the exact leak this diagnostic closes off at the source instead of in the runtime. `main` with no return annotation at all is unaffected (nothing declared, nothing to contradict `Int`). *) let entry_return_code = Diag.emitter_prefix ^ "05" (* WO-E406 (haxe-parity Task 1, modules) — a call through a reserved stdlib alias (`use fs`/`proc`/`net`/`time`/`json`/`env`) survived typechecking (types.ml accepts it as UNKNOWN-BUT-RESERVED: no E207/E225/arity check, since the six namespaces' members arrive in plan 9) and reached emission. There is nothing to lower it to yet — no signature, no builtin id — so this is the one place that still says so, and only if such a call actually survives this far; `use fs` declared and never called never reaches this code at all. *) let stdlib_not_linked_code = Diag.emitter_prefix ^ "06" (* ============================================================ Format constants (mirror of runtime/src/wob.h — never diverge) ============================================================ *) let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) let wob_version = 1 let wob_hdr_size = 44 let wob_none = 0xFFFFFFFF let k_int = 0 let k_text = 1 let max_regs = 64 let classf_gc = 0x01 let op_nop = 0 let op_loadk = 1 let op_move = 2 let op_add = 3 let op_sub = 4 let op_mul = 5 let op_div = 6 let op_neg = 7 let op_concat = 8 let op_eq = 9 let op_lt = 10 let op_le = 11 let op_eqs = 12 let op_jmp = 13 let op_jz = 14 let op_call = 15 let op_icall = 16 let op_ret = 17 let op_ret0 = 18 let op_new = 19 let op_getf = 20 let op_setf = 21 let op_drop = 22 let op_borrow_s = 23 let op_borrow_x = 24 let op_release_s = 25 let op_release_x = 26 let op_rc_inc = 27 let op_rc_dec = 28 let op_builtin = 29 let op_db_stub = 30 (* haxe-parity Task 5: try/catch (runtime/src/wob.h's WOP_TRY/WOP_ENDTRY) *) let op_try = 32 let op_endtry = 33 let b_now = 0 let b_print = 1 let b_print_int = 2 let b_words = 3 let b_multi_new = 4 let b_multi_push = 5 let b_multi_get = 6 let b_count = 7 let b_latest = 8 let b_map_new = 9 let b_map_set = 10 let b_map_get = 11 let b_map_has = 12 (* haxe-parity Task 2: the one fenced VM addition this task takes — string interpolation's Int-to-Text conversion (`"${count} lines"`). runtime/src/wob.h WO_B_INT_TO_TEXT = 13. *) let b_int_to_text = 13 (* haxe-parity Task 4: the one fenced VM addition of the enum-payload work — reads a variant object's tag (the object header's own class_id; docs/plan/oop-vm/00-wob-format.md "enum payload variants") so a switch over a payload union can compare tags without a per-arm allocation. runtime/src/wob.h WO_B_VARIANT_TAG = 14. Compiler- internal: never a source-callable name (not in is_builtin_name / types.ml's builtin_signatures — 08-builtin-surface.md is unchanged). *) let b_variant_tag = 14 (* haxe-parity Task 5: fills the catch arm's freshly allocated `Error` record from the trap the VM landed with (field order 0 code, 1 line, 2 method, 3 msg — Types.error_record_fields). runtime/src/wob.h WO_B_ERR_FILL = 15. Compiler-internal, like b_variant_tag: never a source-callable name. *) let b_err_fill = 15 let ins_abc op a b c = op lor (a lsl 8) lor (b lsl 16) lor (c lsl 24) let ins_abx op a bx = op lor (a lsl 8) lor (bx lsl 16) let ins_asbx op a sbx = ins_abx op a (sbx + 32768) (* ============================================================ Byte buffer ============================================================ *) module Buf = struct type t = { mutable b : Bytes.t; mutable len : int; } let create () = { b = Bytes.create 1024; len = 0 } let room (t : t) (n : int) : unit = if t.len + n > Bytes.length t.b then begin let cap = ref (Bytes.length t.b) in while t.len + n > !cap do cap := !cap * 2 done; let nb = Bytes.create !cap in Bytes.blit t.b 0 nb 0 t.len; t.b <- nb end let u8 (t : t) (v : int) : unit = room t 1; Bytes.set_uint8 t.b t.len (v land 0xFF); t.len <- t.len + 1 let u16 (t : t) (v : int) : unit = room t 2; Bytes.set_uint16_le t.b t.len (v land 0xFFFF); t.len <- t.len + 2 (* u32 via Int32: 0xFFFFFFFF (WOB_NONE) truncates to -1l, whose little-endian bytes are FF FF FF FF — exactly what the loader compares against. *) let u32 (t : t) (v : int) : unit = room t 4; Bytes.set_int32_le t.b t.len (Int32.of_int v); t.len <- t.len + 4 let i64 (t : t) (v : int64) : unit = room t 8; Bytes.set_int64_le t.b t.len v; t.len <- t.len + 8 let str (t : t) (s : string) : unit = let n = String.length s in room t n; Bytes.blit_string s 0 t.b t.len n; t.len <- t.len + n let contents (t : t) : string = Bytes.sub_string t.b 0 t.len end (* growable instruction array (jumps are patched after the fact) *) type code = { mutable a : int array; mutable n : int; } let code_create () = { a = Array.make 64 0; n = 0 } let code_push (c : code) (v : int) : unit = if c.n = Array.length c.a then begin let na = Array.make (2 * c.n) 0 in Array.blit c.a 0 na 0 c.n; c.a <- na end; c.a.(c.n) <- v; c.n <- c.n + 1 (* ============================================================ Program-level tables ============================================================ *) type clsrec = { cr_name : string; cr_gc : bool; cr_fields : (string * Ast.field_ty) array; cr_methods : string list; (* method names, declaration order *) } type ifacerec = { ir_name : string; ir_slot_base : int; ir_methods : (string * int) list; (* name, parameter count *) } type methrec = { mr_name : string; mr_class : int option; mr_argc : int; mutable mr_regc : int; mutable mr_code : int array; mutable mr_lines : (int * int) list; mutable mr_drops : (int * int64 * int64) list; } (* one input unit: a discovered file with its own program and its own owner tables. Node ids are minted per parse, so they are unique within a file and NOT across files — every side table keyed on a node id is therefore built per unit. *) type input = { file : string; prog : Ast.program; tables : Owner.tables; } type pctx = { p_syms : Types.symbols; p_coll : Diag.Collector.t; p_classes : clsrec array; p_class_id : int SM.t; p_ifaces : ifacerec array; p_iface_id : int SM.t; (* method index by ("Class.method") for methods and ("name") for free fns — free fns and classes share a namespace only through this lookup, never in the emitted table *) p_method_id : int SM.t; p_methods : methrec array; (* haxe-parity Task 1 (modules): file -> that file's own `use` edges. Front-door module visibility is already fully decided by types.ml's check_modules before the emitter ever runs — this exists only for the two things emission itself still needs to know: (1) a stdlib-reserved alias reaching a real call site here is WO-E406 (nothing to lower it to, see that code's doc comment), and (2) which alias names a project module at all (as opposed to a receiver expression), so a qualified call can be resolved against that module's own symbols below rather than p_syms' flat merge. *) p_uses : (string, Types.use_edge list) Hashtbl.t; (* haxe-parity Task 1 (modules), CRITICAL 1 review fix: p_syms.free_fns is one flat table, first-wins merged across the *whole* discovered tree regardless of module (main.ml's merge_symbols, unchanged by this task) — exactly right for `main`/every other pre-Task-1 lookup, and exactly wrong the instant two different modules declare a same-named `pub fn` and a qualified call means to pick between them: the flat merge already silently dropped one, so looking a qualified call's callee up there can return the *wrong module's* body no matter how carefully the call site names its alias. A qualified call therefore resolves through *this* table instead — module id -> that module's own, unmerged-with-anyone-else `symbols` (built by Types.module_symbols, the same per-module grouping types.ml's own resolver uses) — never through p_syms for that one purpose. `p_module_of` (file -> module id) is what turns a bare call's *own* file into the same key, so an own-module bare call to a name that happens to collide with some other module's same-named `pub fn` resolves correctly too (see free_fn_key's doc comment). *) p_module_syms : (string, Types.symbols) Hashtbl.t; p_module_of : string -> string; (* every free-fn name declared by more than one distinct module — see free_fn_key's doc comment. *) p_colliding : (string, unit) Hashtbl.t; (* constant pool, deduplicated *) p_kints : (int, int) Hashtbl.t; p_ktexts : (string, int) Hashtbl.t; mutable p_consts : [ `Int of int | `Text of string ] list; (* rev *) mutable p_nconsts : int; } let const_int (p : pctx) (v : int) : int = match Hashtbl.find_opt p.p_kints v with | Some i -> i | None -> let i = p.p_nconsts in Hashtbl.replace p.p_kints v i; p.p_consts <- `Int v :: p.p_consts; p.p_nconsts <- i + 1; i let const_text (p : pctx) (s : string) : int = match Hashtbl.find_opt p.p_ktexts s with | Some i -> i | None -> let i = p.p_nconsts in Hashtbl.replace p.p_ktexts s i; p.p_consts <- `Text s :: p.p_consts; p.p_nconsts <- i + 1; i (* ============================================================ Per-method lowering state ============================================================ *) (* haxe-parity Task 2: one enclosing loop's own backpatch lists. `break`/`continue` inside the loop's body emit a blind `JMP 0` at their own site (their own drops already ran, from Owner's v_break/v_continue tables) and record that instruction's pc here; the loop that pushed this frame patches every recorded pc once it knows the real target — `lf_breaks` always to "right after the whole loop" (same target `JZ`'s own exit uses), `lf_continues` to wherever *that* loop shape re-enters its own condition check (`while`: the top; `for`: right before the increment; `do...while`: right before the condition). *) type loop_frame = { lf_node : int; mutable lf_breaks : int list; mutable lf_continues : int list; } type fstate = { f_file : string; f_fn : string; f_code : code; mutable f_cur_line : int; (* line of the construct being lowered *) mutable f_line : int; (* last line written to the table *) mutable f_lines : (int * int) list; (* rev *) mutable f_owned : int64; (* running owned-register mask *) mutable f_gc : int64; (* running @gc-register mask *) mutable f_last_owned : int64; mutable f_last_gc : int64; mutable f_drops : (int * int64 * int64) list; (* rev *) mutable f_nlocals : int; mutable f_temp : int; mutable f_max : int; mutable f_env : (string * (int * Ast.field_ty)) list; (* innermost first *) f_decl : (int, int) Hashtbl.t; (* declaring node id -> register *) (* declaring nodes in declaration order, innermost last — a stack, so the nodes a block declared are the ones pushed since it opened. Needed because owner.ml anchors a scope's @gc releases on the scope's own node, and an `if`'s THEN and ELSE scopes share that node: which release belongs to which arm is answered by which block declared the local. *) mutable f_declared : int list; f_node : (int, int) Hashtbl.t; (* expression node id -> register *) (* register -> the kind of holder living in it, for the one case the declaration site cannot answer: a whole-local assignment re-initializes a local that had been moved out of, so its bit goes back into the live mask *) f_kind : (int, Owner.local_kind) Hashtbl.t; (* this path has returned, so it contributes no state to a merge — the same rule owner.ml's own `diverged` follows, for the same reason: a branch that returned never reaches the join, and letting its (empty) live set intersect the other branch's would strip a still-live register out of the drop map *) mutable f_div : bool; (* the farthest pc any jump was patched to. A forward jump out of the last `if`/`while` of a body targets the position *after* the last instruction, which the loader reads as "jump out of code" — so the implicit return has to be appended for that reason too, not only when the last instruction is not a terminator. *) mutable f_maxjmp : int; mutable f_over : bool; (* WO-E401 already reported for this method *) (* haxe-parity Task 2: innermost-first stack of enclosing loop backpatch frames — see loop_frame's own doc comment. Empty outside any loop, which is exactly how emit_break/emit_continue detect a `break`/`continue` with no legal target (WO-E403, "cannot lower" — the same convention as every other construct nothing upstream tracks loop nesting to reject earlier). *) mutable f_loops : loop_frame list; } (* ---- per-unit views of the four owner tables ---- *) type views = { v_move : (int, string) Hashtbl.t; (* place-expr node -> moved place text *) v_scope : (int * string, Owner.drop_item list) Hashtbl.t; v_join : (int * string, Owner.drop_item list) Hashtbl.t; v_return : (int, Owner.drop_item list) Hashtbl.t; (* haxe-parity Task 2: DBreak/DContinue's own views, by the break/continue statement's own node id — mirrors v_return exactly, just bounded to the enclosing loop instead of the whole function (owner.ml's live_holders_upto). *) v_break : (int, Owner.drop_item list) Hashtbl.t; v_continue : (int, Owner.drop_item list) Hashtbl.t; v_overwrite : (int, unit) Hashtbl.t; v_mask : (int, Owner.drop_item list) Hashtbl.t; (* holder decl nodes: every declaring node the DROPS table ever names. This is how the emitter learns which locals the frame destroys without re-deriving owner.ml's own "holds" decision. *) v_holder : (int, Owner.local_kind) Hashtbl.t; v_rc : (int, Owner.rc_site list) Hashtbl.t; (* by rc_node *) (* region node -> the region's position and its per-operand coalesced guards *) v_res : (int, Ast.pos * (int * Owner.acc_kind) list) Hashtbl.t; (* regions the emitter actually wrapped. owner.ml anchors a residual region on four different node kinds (a call expression, an assignment statement, a moved place); a region nobody consumed would silently ship unguarded, which is the one failure mode a residual site exists to prevent — so what is left over at the end of the unit is WO-E404, never silence. *) v_res_used : (int, unit) Hashtbl.t; } let build_views (t : Owner.tables) : views = let v = { v_move = Hashtbl.create 16; v_scope = Hashtbl.create 16; v_join = Hashtbl.create 16; v_return = Hashtbl.create 16; v_break = Hashtbl.create 16; v_continue = Hashtbl.create 16; v_overwrite = Hashtbl.create 16; v_mask = Hashtbl.create 16; v_holder = Hashtbl.create 16; v_rc = Hashtbl.create 16; v_res = Hashtbl.create 16; v_res_used = Hashtbl.create 16 } in List.iter (fun (m : Owner.move_site) -> Hashtbl.replace v.v_move m.Owner.mv_node m.Owner.mv_place) t.Owner.moves; List.iter (fun (d : Owner.drop_site) -> let items = d.Owner.dr_items in List.iter (fun (i : Owner.drop_item) -> Hashtbl.replace v.v_holder i.Owner.di_node i.Owner.di_kind) items; match d.Owner.dr_kind with | Owner.DScope label -> Hashtbl.replace v.v_scope (d.Owner.dr_node, label) items | Owner.DBranchJoin label -> Hashtbl.replace v.v_join (d.Owner.dr_node, label) items | Owner.DReturn -> Hashtbl.replace v.v_return d.Owner.dr_node items | Owner.DBreak -> Hashtbl.replace v.v_break d.Owner.dr_node items | Owner.DContinue -> Hashtbl.replace v.v_continue d.Owner.dr_node items | Owner.DOverwrite -> Hashtbl.replace v.v_overwrite d.Owner.dr_node () | Owner.DLiveMask -> Hashtbl.replace v.v_mask d.Owner.dr_node items) t.Owner.drops; List.iter (fun (r : Owner.rc_site) -> let prev = try Hashtbl.find v.v_rc r.Owner.rc_node with Not_found -> [] in Hashtbl.replace v.v_rc r.Owner.rc_node (prev @ [ r ])) t.Owner.rcs; (* Guard coalescing, per dump.ml's normative note: one entry per (region, operand) with the strongest access kind, never one pair per table entry. AExcl outranks AShared; a move is never a residual side. *) (* AMove is defensive: a move names a whole local, and relate answers Overlap or Disjoint for a place with no projections, so a move can never be a residual side (dump.ml says the same). If one ever appeared, an exclusive guard is the conservative reading. *) let stronger (a : Owner.acc_kind) (b : Owner.acc_kind) = match (a, b) with | Owner.AExcl, _ | _, Owner.AExcl -> Owner.AExcl | Owner.AMove, _ | _, Owner.AMove -> Owner.AExcl | Owner.AShared, Owner.AShared -> Owner.AShared in List.iter (fun (r : Owner.residual_site) -> let cur = try snd (Hashtbl.find v.v_res r.Owner.rs_node) with Not_found -> [] in let add acc (node, kind) = match List.assoc_opt node acc with | None -> acc @ [ (node, kind) ] | Some k0 -> List.map (fun (n, k) -> if n = node then (n, stronger k0 kind) else (n, k)) acc in let cur = add cur (r.Owner.rs_a_node, r.Owner.rs_a_kind) in let cur = add cur (r.Owner.rs_b_node, r.Owner.rs_b_kind) in Hashtbl.replace v.v_res r.Owner.rs_node (r.Owner.rs_pos, cur)) t.Owner.residuals; v (* ============================================================ Diagnostic helpers ============================================================ *) let err (p : pctx) ~code ~(file : string) ~(pos : Ast.pos) ~message : unit = Diag.Collector.add p.p_coll (Diag.error ~code ~file ~line:pos.line ~col:pos.col ~message ()) (* ============================================================ Registers ============================================================ *) let bump (f : fstate) (r : int) : unit = if r > f.f_max then f.f_max <- r let over_budget (p : pctx) (f : fstate) (pos : Ast.pos) : unit = if not f.f_over then begin f.f_over <- true; err p ~code:over_budget_code ~file:f.f_file ~pos ~message: (Printf.sprintf "`%s` needs more than %d registers — the VM's register window is %d slots; split the \ method or reduce the number of live locals" f.f_fn max_regs max_regs) end (* Past the budget the allocation is clamped so the instruction encoding (one byte per register operand) stays well-formed: WO-E401 has already failed the compile, and a malformed instruction array would only crash the serializer on the way out. *) let alloc_local (p : pctx) (f : fstate) (pos : Ast.pos) : int = let r = f.f_nlocals in if r >= max_regs then begin over_budget p f pos; max_regs - 1 end else begin f.f_nlocals <- r + 1; if f.f_temp < f.f_nlocals then f.f_temp <- f.f_nlocals; bump f r; r end let alloc_temp (p : pctx) (f : fstate) (pos : Ast.pos) : int = let r = f.f_temp in if r >= max_regs then begin over_budget p f pos; max_regs - 1 end else begin f.f_temp <- r + 1; bump f r; r end let alloc_temps (p : pctx) (f : fstate) (pos : Ast.pos) (n : int) : int = let base = f.f_temp in for _ = 1 to n do ignore (alloc_temp p f pos) done; if base >= max_regs then max_regs - 1 else base let stmt_reset (f : fstate) : unit = f.f_temp <- f.f_nlocals (* ============================================================ Instruction emission (line + drop tables ride along) ============================================================ *) let put (f : fstate) (ins : int) : unit = let pc = f.f_code.n in if f.f_cur_line <> f.f_line then begin f.f_lines <- (pc, f.f_cur_line) :: f.f_lines; f.f_line <- f.f_cur_line end; if f.f_owned <> f.f_last_owned || f.f_gc <> f.f_last_gc then begin f.f_drops <- (pc, f.f_owned, f.f_gc) :: f.f_drops; f.f_last_owned <- f.f_owned; f.f_last_gc <- f.f_gc end; code_push f.f_code ins let here (f : fstate) : int = f.f_code.n let patch_jump (p : pctx) (f : fstate) ~(file : string) ~(pos : Ast.pos) (at : int) (target : int) : unit = let d = target - (at + 1) in if d < -32768 || d > 32767 then err p ~code:limit_code ~file ~pos ~message: (Printf.sprintf "jump displacement %d does not fit the 16-bit signed field" d) else begin if target > f.f_maxjmp then f.f_maxjmp <- target; let ins = f.f_code.a.(at) in f.f_code.a.(at) <- ins_asbx (ins land 0xFF) ((ins lsr 8) land 0xFF) d end (* ---- live-mask bookkeeping ---- *) let mask_set (f : fstate) (kind : Owner.local_kind) (r : int) : unit = Hashtbl.replace f.f_kind r kind; let bit = Int64.shift_left 1L r in match kind with | Owner.LOwned -> f.f_owned <- Int64.logor f.f_owned bit | Owner.LGc -> f.f_gc <- Int64.logor f.f_gc bit let mask_clear (f : fstate) (r : int) : unit = let bit = Int64.lognot (Int64.shift_left 1L r) in f.f_owned <- Int64.logand f.f_owned bit; f.f_gc <- Int64.logand f.f_gc bit (* Control-flow merge: keep only what is live on *every* incoming path. Over-approximating liveness at a merge is the dangerous direction — a register the other path already moved out of would be dropped a second time during unwinding — so the merge intersects. *) let mask_meet (f : fstate) (o : int64) (g : int64) : unit = f.f_owned <- Int64.logand f.f_owned o; f.f_gc <- Int64.logand f.f_gc g (* ============================================================ Types (the small local resolver — see this file's module doc) ============================================================ *) let rec unwrap (ft : Ast.field_ty) : Ast.field_ty = match ft with Nullable inner -> unwrap inner | t -> t let kind_byte : Types.wob_kind -> int = function | Types.WO_K_SCALAR -> 0 | Types.WO_K_OWNED -> 1 | Types.WO_K_GCREF -> 2 | Types.WO_K_TEXT -> 3 | Types.WO_K_MULTI -> 4 | Types.WO_K_MAP -> 5 (* `?T` has no kind byte of its own in the v1 format (kinds run 0..5; the loader rejects 6). It needs none: a nullable field stores what T stores and spells nil as 0, and every drop plan in runtime/src/gc.c already ignores a 0 slot for every kind. So the kind of `?T` is the kind of T — resolved by unwrapping before the call, this arm is unreachable. *) | Types.WO_K_NULLABLE -> 0 let field_kind (p : pctx) (ft : Ast.field_ty) : int = kind_byte (Types.wob_kind_of_typ p.p_syms (Types.typ_of_field_ty (unwrap ft))) let class_of_name (p : pctx) (n : string) : int option = SM.find_opt n p.p_class_id let field_of (p : pctx) (cid : int) (fname : string) : (int * Ast.field_ty) option = let fs = p.p_classes.(cid).cr_fields in let rec go i = if i >= Array.length fs then None else let n, ty = fs.(i) in if n = fname then Some (i, ty) else go (i + 1) in go 0 let free_fn (p : pctx) (n : string) : Types.free_fn_info option = Types.StringMap.find_opt n p.p_syms.Types.free_fns (* haxe-parity Task 1 (modules): is `alias` one of file `f_file`'s own `use` edges? Returns the edge so the caller can tell stdlib apart from a project module (Types.use_edge.ue_is_stdlib). *) let use_edge_for (p : pctx) ~(file : string) (alias : string) : Types.use_edge option = match Hashtbl.find_opt p.p_uses file with | None -> None | Some edges -> List.find_opt (fun (u : Types.use_edge) -> u.Types.ue_alias = alias) edges (* haxe-parity Task 1 (modules), CRITICAL 1 review fix: computed once per `emit` call (below) and threaded through pctx (p_colliding) rather than kept as an `emit`-local, since free_fn_key (right below) is needed by emit_call, part of the emit_expr/emit_call mutually recursive group defined further down this file — a plain local to `emit` (itself defined at the bottom of the file) would not be in scope there. A name collides when more than one *distinct* module declares a free fn with that exact name; only possible now that modules exist at all (pre-Task-1, every discovered file was one flat namespace, so this table is always empty for any program that predates `use`). *) let compute_colliding_fn_names ~(module_of : string -> string) (units : input list) : (string, unit) Hashtbl.t = let fn_modules : (string, (string, unit) Hashtbl.t) Hashtbl.t = Hashtbl.create 32 in List.iter (fun u -> List.iter (function | Ast.Fn (m : Ast.method_decl) -> let mid = module_of u.file in let mods = match Hashtbl.find_opt fn_modules m.name with | Some t -> t | None -> let t = Hashtbl.create 2 in Hashtbl.replace fn_modules m.name t; t in Hashtbl.replace mods mid () | Ast.Class _ | Ast.Interface _ | Ast.Use _ | Ast.Const _ | Ast.Union _ -> ()) u.prog.decls) units; let colliding : (string, unit) Hashtbl.t = Hashtbl.create 8 in Hashtbl.iter (fun name mods -> if Hashtbl.length mods >= 2 then Hashtbl.replace colliding name ()) fn_modules; colliding (* A free fn's method-table key: its plain name, unless that name collides across modules, in which case it's `"#"` — the same composite-key idea class methods already use (`"Class.method"`). Every free-fn method-table key, on both the build side (emit's own pass 2, before `p : pctx` even exists yet — hence taking the raw `colliding` table rather than `p`) and the lookup side (emit_call, via `p.p_colliding`) goes through this now, so a non-colliding name (everything before this task, and the overwhelming majority after it) is completely unaffected — same plain key as always. *) let free_fn_key (colliding : (string, unit) Hashtbl.t) (mid : string) (name : string) : string = if Hashtbl.mem colliding name then mid ^ "#" ^ name else name let class_method (p : pctx) (cname : string) (m : string) : Types.method_info option = match Types.StringMap.find_opt cname p.p_syms.Types.classes with | None -> None | Some (c : Types.class_info) -> List.find_opt (fun (mi : Types.method_info) -> mi.Types.name = m) c.Types.methods (* haxe-parity Task 7: the `static fn` behind `Flock.held(path)`. Kept separate from class_method so an instance method is never callable through a class name, and a static never through an instance. *) let static_method (p : pctx) (cname : string) (m : string) : Types.method_info option = match class_method p cname m with Some mi when mi.Types.is_static -> Some mi | _ -> None let iface_method (p : pctx) (iname : string) (m : string) : (int * Types.method_sig_info) option = match SM.find_opt iname p.p_iface_id with | None -> None | Some iid -> ( let ir = p.p_ifaces.(iid) in let rec go i = function | [] -> None | (n, _) :: tl -> if n = m then Some (ir.ir_slot_base + i) else go (i + 1) tl in match go 0 ir.ir_methods with | None -> None | Some slot -> ( match Types.StringMap.find_opt iname p.p_syms.Types.interfaces with | None -> None | Some (ii : Types.interface_info) -> ( match List.find_opt (fun (s : Types.method_sig_info) -> s.Types.name = m) ii.Types.methods with | None -> None | Some sg -> Some (slot, sg)))) let builtin_ret (name : string) (argty : Ast.field_ty option) : Ast.field_ty option = match name with | "int_to_text" -> Some (Scalar "Text") | "now" -> Some (Scalar "Timestamp") | "print" | "print_int" | "push" | "set" -> Some (Scalar "Int") | "words" | "count" -> Some (Scalar "Int") | "has" -> Some (Scalar "Bool") | "latest" -> ( match argty with Some t -> ( match unwrap t with Multi e -> Some (Scalar e) | _ -> None) | None -> None) | "get" -> ( match argty with | Some t -> ( match unwrap t with Multi e -> Some (Scalar e) | Map (_, v) -> Some (Scalar v) | _ -> None) | None -> None) | _ -> None let is_builtin_name (n : string) = List.mem n [ "now"; "print"; "print_int"; "words"; "multi_new"; "push"; "get"; "count"; "latest"; "map_new"; "set"; "has"; "int_to_text" ] (* ---- unions and variants (haxe-parity Task 4) ------------------------ All read straight off p_syms (types.ml's own tables) — the emitter derives nothing types.ml already knows. A payload union's variants each have a compiler-generated class-table entry keyed "." in p_class_id (built in emit's pass 1, below; idents can never contain a dot, so the composite key cannot collide with a source-declared class — the same mangling convention "Class.method" already uses in the method table). An all-bare union has no entries at all: its variants ARE their ordinals. *) let find_union (p : pctx) (n : string) : Types.union_info option = Types.StringMap.find_opt n p.p_syms.Types.unions let find_variant (p : pctx) (n : string) : (Types.union_info * Types.variant_info) option = Types.find_variant p.p_syms n let variant_class_key (u : Types.union_info) (vi : Types.variant_info) : string = u.Types.u_name ^ "." ^ vi.Types.vi_name (* the runtime tag a case pattern / bare reference compares or loads: the class-table id for a payload union's variant (the object header's class_id — the format doc's variant convention), the ordinal for an all-bare union's. *) let variant_tag_value (p : pctx) (u : Types.union_info) (vi : Types.variant_info) : int = if u.Types.u_has_payload then match SM.find_opt (variant_class_key u vi) p.p_class_id with | Some cid -> cid | None -> 0 (* unreachable: pass 1 registers every payload-union variant *) else vi.Types.vi_tag let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option = match e.kind with | IntLit _ -> Some (Scalar "Int") | StrLit _ -> Some (Scalar "Text") | BoolLit _ -> Some (Scalar "Bool") (* Same rule as owner.ml's expr_ty: a non-empty list literal knows its element type; an empty `[]`/`{}` is contextual on its destination. *) | ListLit (first :: _) -> ( match ty_of_expr p f first with Some (Scalar n) -> Some (Multi n) | _ -> None) | ListLit [] | MapLit -> None (* haxe-parity Task 5: a `try` yields its try arm's type — types.ml has already required the catch arm to agree. *) | Try t -> ty_of_expr p f t.body | Ident n -> ( match List.assoc_opt n f.f_env with | Some (_, t) -> Some t | None -> ( (* haxe-parity Task 4: a bare variant reference types as its union; locals/params always won above, so a shadowing binding is never mistaken for a variant. *) match find_variant p n with | Some (u, _) -> Some (Scalar u.Types.u_name) | None -> None)) | Field (base, fname) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with | Scalar cn -> ( match class_of_name p cn with | Some cid -> ( match field_of p cid fname with Some (_, t) -> Some t | None -> None) | None -> None) | _ -> None) | None -> None) | Index (base, _) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with Multi e' -> Some (Scalar e') | Map (_, v) -> Some (Scalar v) | _ -> None) | None -> None) | Call (callee, args) -> ( match callee.kind with | Ident n -> ( (* haxe-parity Task 1 (modules), CRITICAL 1 review fix: own module first — see emit_call's identical fix for why p_syms' flat merge is the wrong table once two modules can share a free-fn name. *) let own_mid = p.p_module_of f.f_file in let own_fi = match Hashtbl.find_opt p.p_module_syms own_mid with | Some msyms -> Types.StringMap.find_opt n msyms.Types.free_fns | None -> None in match own_fi with | Some fi -> fi.Types.ret | None -> ( match free_fn p n with | Some fi -> fi.Types.ret | None -> ( (* haxe-parity Task 4: variant construction — the call's value is the union's own type (a fresh variant object). *) match find_variant p n with | Some (u, _) -> Some (Scalar u.Types.u_name) | None -> if is_builtin_name n then builtin_ret n (match args with a :: _ -> ty_of_expr p f a | [] -> None) else None))) | Field (base, mname) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with | Scalar cn -> ( match class_method p cn mname with | Some mi -> mi.Types.ret | None -> ( match iface_method p cn mname with Some (_, sg) -> sg.Types.ret | None -> None)) | _ -> None) | None -> ( (* base didn't resolve as a value at all (no local/param/self named that) — a qualified free-fn call through a `use` alias (haxe-parity Task 1) has exactly this shape; a genuine receiver is always caught by the `Some bt` arm above, so locals/params still shadow a same-named alias here too. *) match base.kind with (* haxe-parity Task 7: a static call's base names a class, which is never a value — checked before the `use`-alias reading, since a class name and a module alias are both bare Idents here. *) | Ident cls_name when static_method p cls_name mname <> None -> ( match static_method p cls_name mname with Some mi -> mi.Types.ret | None -> None) | Ident alias -> ( match use_edge_for p ~file:f.f_file alias with | Some u when u.Types.ue_is_stdlib -> None (* nothing to infer a return type from yet *) | Some u -> ( let target_mid = Types.path_str u.Types.ue_segments in match Hashtbl.find_opt p.p_module_syms target_mid with | Some msyms -> ( match Types.StringMap.find_opt mname msyms.Types.free_fns with | Some fi -> fi.Types.ret | None -> None) | None -> None) | None -> None) | _ -> None)) | _ -> None) | Unary (Neg, o) -> ty_of_expr p f o | Binary (op, l, _) -> ( match op with | Concat -> Some (Scalar "Text") | Eq | Ne | Lt | Le | Gt | Ge | And | Or -> Some (Scalar "Bool") | Add | Sub | Mul | Div | Mod -> ( match ty_of_expr p f l with Some t -> Some t | None -> Some (Scalar "Int"))) | Ctor (cn, _) -> Some (Scalar cn) | Interp _ -> Some (Scalar "Text") | DbStub _ -> None | Switch (subject, arms) -> ( (* haxe-parity Task 3: mirrors types.ml's own `typecheck_switch` — the first arm's type wins (types.ml already proved every other arm agrees, or reported WO-E201 if not) — but derived through this file's own, narrower `ty_of_expr` rather than duplicating that pass. Needed for real, not a "not chased" placeholder like `Index`/`Binary` above: this is what tells `Let`'s own emission (below) whether a `let v = switch ... { case ...: "text"; ... }` with no `: Type` annotation holds `Text` or `Int` — get it wrong and a later `${v}` interpolation calls `int_to_text` on a Text register, or an `==` picks EQ over EQS. Task 4 fix round 1 (review Critical 1b): an arm yielding its own payload BINDING (`case Boxed(b): b;`) types as the binding's declared field type — the binding is not in f_env at derivation time (it exists only while the arm's own body is emitted), so the plain recursive call returned None and the Int fallback broke legal code downstream (`field access on Int`). Mirrors owner.ml's binding_ty_of_arm. *) match arms with | [] -> None | first :: _ -> ( match List.rev first.body with | { s_kind = ExprStmt ve; _ } :: _ -> ( match ve.kind with | Ident n -> ( match emit_binding_ty_of_arm p f subject first n with | Some fty -> Some fty | None -> ty_of_expr p f ve) | _ -> ty_of_expr p f ve) | _ -> None)) (* The declared type of payload binding [n], when [arm]'s pattern binds it off [subject]'s union — None whenever this is not that shape. Mirrors owner.ml's binding_ty_of_arm (same convention as every other mirrored deriver pair between these two files). *) and emit_binding_ty_of_arm (p : pctx) (f : fstate) (subject : Ast.expr) (arm : Ast.switch_arm) (n : string) : Ast.field_ty option = match ty_of_expr p f subject with | Some (Scalar sn) -> ( match find_union p sn with | Some u when u.Types.u_has_payload -> ( match arm.values with | [ { kind = Call ({ kind = Ident vname; _ }, bargs); _ } ] -> ( match List.find_opt (fun (vi : Types.variant_info) -> vi.Types.vi_name = vname) u.Types.u_variants with | Some vi when List.length bargs = List.length vi.Types.vi_fields -> let rec zip (args : Ast.expr list) fields = match (args, fields) with | { kind = Ident bn; _ } :: _, (_, fty) :: _ when bn = n -> Some fty | _ :: ta, _ :: tf -> zip ta tf | _ -> None in zip bargs vi.Types.vi_fields | _ -> None) | _ -> None) | _ -> None) | _ -> None let is_text (p : pctx) (f : fstate) (e : Ast.expr) : bool = match ty_of_expr p f e with Some t -> ( match unwrap t with Scalar "Text" -> true | _ -> false) | None -> false (* ============================================================ Lowering ============================================================ *) let lookup_local (f : fstate) (n : string) : (int * Ast.field_ty) option = List.assoc_opt n f.f_env let reg_of_item (p : pctx) (f : fstate) (i : Owner.drop_item) : int option = match Hashtbl.find_opt f.f_decl i.Owner.di_node with | Some r -> Some r | None -> ( match lookup_local f i.Owner.di_name with | Some (r, _) -> Some r | None -> err p ~code:unguardable_code ~file:f.f_file ~pos:{ line = 0; col = 0 } ~message: (Printf.sprintf "ownership table names `%s`, which has no register in `%s`" i.Owner.di_name f.f_fn); None) (* DROP for every item of a drop set, in the table's own order (the tables already list them innermost-scope-first, reverse declaration order inside a scope — that is destruction order). *) let emit_drops (p : pctx) (f : fstate) (items : Owner.drop_item list) : unit = List.iter (fun (i : Owner.drop_item) -> match reg_of_item p f i with | None -> () | Some r -> ( match i.Owner.di_kind with | Owner.LOwned -> put f (ins_abc op_drop r 0 0); mask_clear f r | Owner.LGc -> (* a @gc handle's release is an rc site, never a DROP: the RC table carries it (with its own ELIDED decision) *) ())) items let emit_scope_drops (p : pctx) (f : fstate) (v : views) ~(node : int) ~(label : string) : unit = match Hashtbl.find_opt v.v_scope (node, label) with | Some items -> emit_drops p f items | None -> () (* the declaring nodes a block introduced: everything pushed onto the declaration stack since it opened *) let declared_since (f : fstate) (saved : int list) : int list = let rec take n l = if n <= 0 then [] else match l with [] -> [] | x :: tl -> x :: take (n - 1) tl in take (List.length f.f_declared - List.length saved) f.f_declared let emit_join_drops (p : pctx) (f : fstate) (v : views) ~(node : int) ~(label : string) : unit = match Hashtbl.find_opt v.v_join (node, label) with | Some items -> emit_drops p f items | None -> () (* RC sites, minus the ELIDED ones — the spec's zero-cost promise lives here and in the residual-only borrow rule. `which` selects acquires or releases; a return site carries both plus the returned value's own escape increment, and acquires must precede releases or a balanced pair could momentarily reach rc 0. *) let emit_rc (p : pctx) (f : fstate) (v : views) ~(node : int) ~(acquire : bool) ?(groups : int list option) () : unit = match Hashtbl.find_opt v.v_rc node with | None -> () | Some sites -> List.iter (fun (r : Owner.rc_site) -> let want = match r.Owner.rc_op with Owner.RcAcquire -> true | Owner.RcRelease -> false in let in_scope = match groups with None -> true | Some gs -> List.mem r.Owner.rc_group gs in if want = acquire && in_scope && not r.Owner.rc_elided then let reg = if r.Owner.rc_group >= 0 then Hashtbl.find_opt f.f_decl r.Owner.rc_group else Hashtbl.find_opt f.f_node r.Owner.rc_node in match reg with | Some g -> put f (ins_abc (if acquire then op_rc_inc else op_rc_dec) g 0 0); if not acquire then mask_clear f g | None -> err p ~code:unguardable_code ~file:f.f_file ~pos:r.Owner.rc_pos ~message: (Printf.sprintf "rc site for `%s` has no register in `%s`" r.Owner.rc_place f.f_fn)) sites (* The frame's drop map at a call / DB_STUB site. The owner table is authoritative here (it is taken after the call's own argument transfers, so a value moved in is the callee's responsibility); an absent entry means nothing is live. *) let sync_mask (p : pctx) (f : fstate) (v : views) (node : int) : unit = f.f_owned <- 0L; f.f_gc <- 0L; match Hashtbl.find_opt v.v_mask node with | None -> () | Some items -> List.iter (fun (i : Owner.drop_item) -> match reg_of_item p f i with Some r -> mask_set f i.Owner.di_kind r | None -> ()) items (* Residual borrow guards around one region, coalesced per operand. [gbase] names a run of registers reserved *below* the call window: a guard may not live in the window itself, because the callee's frame overlaps the window (it may assign to its own parameters, and it zeroes the slots above them) and the call's return value lands on the window base. Releasing such a register after the call would hand the borrow word of whatever now sits there — in the worst case an integer result — to wo_release_excl. So each guarded operand is copied out of the window into its own stable slot first. *) let residual_guards (p : pctx) (f : fstate) (v : views) (node : int) (gbase : int option) : (int * Owner.acc_kind) list = match Hashtbl.find_opt v.v_res node with | None -> [] | Some (pos, ops) -> Hashtbl.replace v.v_res_used node (); List.concat (List.mapi (fun i (onode, kind) -> match Hashtbl.find_opt f.f_node onode with | Some r -> ( match gbase with | None -> [ (r, kind) ] (* no call in the region: guard in place *) | Some gb -> let g = gb + i in if g <> r then put f (ins_abc op_move g r 0); [ (g, kind) ]) | None -> err p ~code:unguardable_code ~file:f.f_file ~pos ~message: (Printf.sprintf "residual borrow site in `%s` names an operand with no live register — the \ runtime guard cannot be placed" f.f_fn); []) ops) let residual_count (v : views) (node : int) : int = match Hashtbl.find_opt v.v_res node with None -> 0 | Some (_, ops) -> List.length ops let acquire_guards (f : fstate) (gs : (int * Owner.acc_kind) list) : unit = List.iter (fun (r, k) -> match k with | Owner.AShared -> put f (ins_abc op_borrow_s r 0 0) | Owner.AExcl | Owner.AMove -> put f (ins_abc op_borrow_x r 0 0)) gs let release_guards (f : fstate) (gs : (int * Owner.acc_kind) list) : unit = List.iter (fun (r, k) -> match k with | Owner.AShared -> put f (ins_abc op_release_s r 0 0) | Owner.AExcl | Owner.AMove -> put f (ins_abc op_release_x r 0 0)) (List.rev gs) let check_bx (p : pctx) (f : fstate) (pos : Ast.pos) (what : string) (v : int) : int = if v > 0xFFFF then begin err p ~code:limit_code ~file:f.f_file ~pos ~message:(Printf.sprintf "%s index %d exceeds the 16-bit instruction field" what v); 0 end else v let check_field_idx (p : pctx) (f : fstate) (pos : Ast.pos) (v : int) : int = if v > 0xFF then begin err p ~code:limit_code ~file:f.f_file ~pos ~message:(Printf.sprintf "field index %d exceeds the 8-bit GETF/SETF field" v); 0 end else v (* Kind immediates for a fresh container, taken from the type the value is expected to have at its destination (a constructor field, an assignment target, a declared parameter type). There is deliberately no default: the kinds are the container's drop plan (runtime/src/gc.c), so guessing SCALAR for a `multi Item` would leak every element and guessing it for a `map` would leak every key. Milestone-1 `let` has no container type annotation (parser.ml takes a bare identifier), so a fresh container must be created somewhere its type is declared — otherwise WO-E403 at the creation site, where the reader can act on it. *) let container_imm (p : pctx) (expected : Ast.field_ty option) (map : bool) : int option = match expected with | Some t -> ( match unwrap t with | Multi e when not map -> Some (field_kind p (Scalar e)) | Map (k, v) when map -> Some (field_kind p (Scalar k) lor (field_kind p (Scalar v) lsl 4)) | _ -> None) | None -> None let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) : unit = f.f_cur_line <- e.pos.line; (match e.kind with | IntLit n -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p n))) | BoolLit b -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p (if b then 1 else 0)))) | StrLit s -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_text p s))) (* Container literals lower to exactly what `multi_new()`/`map_new()` lower to — the element kinds are the destination's, never guessed (docs/plan/oop-vm/08-builtin-surface.md) — plus one `multi_push` per element, in source order. *) | ListLit items -> ( match container_imm p expected false with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: "a list literal needs a destination of declared type `multi T` — its element kind is \ the container's drop plan and cannot be guessed"; put f (ins_abx op_loadk dst (const_int p 0)) | Some imm -> sync_mask p f v e.id; put f (ins_abc op_builtin dst imm b_multi_new); let elem_ty = match expected with | Some t -> ( match unwrap t with Multi en -> Some (Scalar en) | _ -> None) | None -> None in let outer = f.f_temp in if f.f_temp <= dst then f.f_temp <- dst + 1; List.iter (fun (item : Ast.expr) -> let save = f.f_temp in let base = alloc_temps p f e.pos 2 in put f (ins_abc op_move base dst 0); (match elem_ty with | Some et -> emit_expr p f v ~dst:(base + 1) ~expected:et item | None -> emit_expr p f v ~dst:(base + 1) item); sync_mask p f v e.id; f.f_cur_line <- item.pos.line; put f (ins_abc op_builtin base base b_multi_push); f.f_temp <- save) items; f.f_temp <- outer) | MapLit -> ( match container_imm p expected true with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: "an empty map literal needs a destination of declared type `map` — its key and \ value kinds are the container's drop plan and cannot be guessed"; put f (ins_abx op_loadk dst (const_int p 0)) | Some imm -> sync_mask p f v e.id; put f (ins_abc op_builtin dst imm b_map_new)) | Try t -> emit_try p f v ~dst ?expected e t.body t.ename t.handler | Ident n -> ( match lookup_local f n with | Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0) | None -> ( (* haxe-parity Task 4: a bare variant reference. All-bare union: the value IS the ordinal tag — one LOADK, no heap. Payload union: every value of the union is a variant object, so even a bare variant allocates its (zero-field) class — NEW is all it takes, the header's class_id is the tag. *) match find_variant p n with | Some (u, vi) -> if u.Types.u_has_payload then put f (ins_abx op_new dst (check_bx p f e.pos "class" (variant_tag_value p u vi))) else put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p vi.Types.vi_tag))) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "`%s` is not a local, parameter, or `self` — nothing to load" n); put f (ins_abx op_loadk dst (const_int p 0)))) | Field (base, fname) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with | Scalar cn -> ( match class_of_name p cn with | Some cid -> ( match field_of p cid fname with | Some (idx, _) -> let b = emit_operand p f v base in put f (ins_abc op_getf dst b (check_field_idx p f e.pos idx)) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "`%s` has no field `%s`" cn fname); put f (ins_abx op_loadk dst (const_int p 0))) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "field access on `%s`, which is not a declared class" cn); put f (ins_abx op_loadk dst (const_int p 0))) | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "field `%s` read from a value that is not a class instance" fname); put f (ins_abx op_loadk dst (const_int p 0))) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: (Printf.sprintf "cannot resolve the type of the value `%s` is read from" fname); put f (ins_abx op_loadk dst (const_int p 0))) | Index (base, idx) -> ( let bid = match ty_of_expr p f base with | Some bt -> ( match unwrap bt with Multi _ -> Some b_multi_get | Map _ -> Some b_map_get | _ -> None) | None -> None in match bid with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:"indexing a value that is neither a `multi` nor a `map`"; put f (ins_abx op_loadk dst (const_int p 0)) | Some bid -> let w = alloc_temps p f e.pos 2 in emit_expr p f v ~dst:w base; emit_expr p f v ~dst:(w + 1) idx; put f (ins_abc op_builtin dst w bid)) | Unary (Neg, o) -> let b = emit_operand p f v o in put f (ins_abc op_neg dst b 0) | Binary (op, l, r) -> emit_binary p f v ~dst op l r | Ctor (cn, fields) -> emit_ctor p f v ~dst e cn fields | Interp inner -> ( (* haxe-parity Task 2: the type-directed half of the interpolation desugar (parser.ml's own doc comment on Ast.Interp) — a Text interpolant passes through untouched; an Int one is wrapped in the `int_to_text` builtin; anything else has no lowering (the brief's own scope: "desugar Int-typed expressions", not every scalar). *) match ty_of_expr p f inner with | Some t -> ( match unwrap t with | Scalar "Text" -> emit_expr p f v ~dst inner | Scalar "Int" -> emit_builtin p f v ~dst e "int_to_text" [ inner ] | other -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: (Printf.sprintf "cannot interpolate a value of type `%s` in \"${...}\" — only Text and Int are \ supported" (Dump.field_ty_str other)); put f (ins_abx op_loadk dst (const_int p 0))) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:"cannot resolve the interpolated expression's type"; put f (ins_abx op_loadk dst (const_int p 0))) | Call (callee, args) -> emit_call p f v ~dst ?expected e callee args | DbStub _ -> sync_mask p f v e.id; put f (ins_abc op_db_stub 0 0 0) | Switch (subject, arms) -> emit_switch p f v e ~dst subject arms); Hashtbl.replace f.f_node e.id dst; (* An escaping @gc value takes its increment right where the value lands. owner.ml's gc_escape anchors that acquire on the *place expression's* own node and gives it group -1 (never elidable), and it fires for all four escapes alike: a constructor field, an assignment into a field, a `take` argument, and a return. Emitting it here — once, at the one place every expression passes through — is what keeps all four in step; anchoring it per statement kind is how the constructor-field case went missing. *) emit_rc p f v ~node:e.id ~acquire:true () (* An operand that only needs to *be* in some register: a place already living in one is used where it is, everything else lands in a fresh temporary. This is what keeps a proven method's disassembly free of pointless MOVEs. *) and emit_operand (p : pctx) (f : fstate) (v : views) (e : Ast.expr) : int = match e.kind with | Ident n when lookup_local f n <> None -> let r = match lookup_local f n with Some (r, _) -> r | None -> 0 in Hashtbl.replace f.f_node e.id r; r | _ -> let t = alloc_temp p f e.pos in emit_expr p f v ~dst:t e; t (* The last value a statement computes (a `return` operand, a discarded expression statement) needs no reserved slot: a place stays where it lives, everything else lands in the next free temporary, which a call then reuses as its own window base so the result needs no MOVE. *) and emit_tail (p : pctx) (f : fstate) (v : views) (e : Ast.expr) : int = match e.kind with | Ident n when lookup_local f n <> None -> let r = match lookup_local f n with Some (r, _) -> r | None -> 0 in Hashtbl.replace f.f_node e.id r; emit_rc p f v ~node:e.id ~acquire:true (); r | _ -> (* allocate (so the register counts towards the budget and the method's register count) and immediately un-reserve *) let t = alloc_temp p f e.pos in f.f_temp <- t; emit_expr p f v ~dst:t e; t and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop) (l : Ast.expr) (r : Ast.expr) : unit = let pos = l.pos in let simple o = let a = emit_operand p f v l in let b = emit_operand p f v r in put f (ins_abc o dst a b) in let swapped o = let a = emit_operand p f v l in let b = emit_operand p f v r in put f (ins_abc o dst b a) in match op with | Add -> simple op_add | Sub -> simple op_sub | Mul -> simple op_mul | Div -> simple op_div | Concat -> simple op_concat | Lt -> simple op_lt | Le -> simple op_le | Gt -> swapped op_lt | Ge -> swapped op_le | Eq -> if is_text p f l || is_text p f r then simple op_eqs else simple op_eq | Ne -> (* no NE opcode in the v1 set: `a != b` is `(a == b) == 0`. The format doc governs, so this is a lowering, not a new opcode. *) let a = emit_operand p f v l in let b = emit_operand p f v r in let t = alloc_temp p f pos in put f (ins_abc (if is_text p f l || is_text p f r then op_eqs else op_eq) t a b); let z = alloc_temp p f pos in put f (ins_abx op_loadk z (check_bx p f pos "constant" (const_int p 0))); put f (ins_abc op_eq dst t z) | And -> (* haxe-parity Task 2: short-circuit -- `l`'s own value (0 or 1) lands directly in `dst`; if it is already false, `r` is never evaluated at all (the fixture's own proof: a right operand that would trap, e.g. division by zero, must not run) and `dst` keeps `l`'s value. Otherwise `r`'s value overwrites `dst`, becoming the result. Compare-and-jump on the existing JZ opcode, no new one. *) emit_expr p f v ~dst l; f.f_cur_line <- pos.line; let jz = here f in put f (ins_asbx op_jz dst 0); emit_expr p f v ~dst r; patch_jump p f ~file:f.f_file ~pos jz (here f) | Or -> (* same shape, the other way: `l` true short-circuits (skip `r`, keep `l`'s true value); `l` false falls through to `r`. JZ plus one JMP (to skip `r` on the true path), still no new opcode. *) emit_expr p f v ~dst l; f.f_cur_line <- pos.line; let jz = here f in put f (ins_asbx op_jz dst 0); let jmp = here f in put f (ins_asbx op_jmp 0 0); patch_jump p f ~file:f.f_file ~pos jz (here f); emit_expr p f v ~dst r; patch_jump p f ~file:f.f_file ~pos jmp (here f) | Mod -> (* no MOD opcode either: truncating `a % b` is `a - (a / b) * b`, exact for the VM's truncating DIV (which traps on 0 and on INT64_MIN / -1 — both correct for `%` as well). *) let a = emit_operand p f v l in let b = emit_operand p f v r in let q = alloc_temp p f pos in put f (ins_abc op_div q a b); put f (ins_abc op_mul q q b); put f (ins_abc op_sub dst a q) (* haxe-parity Task 3: compare-and-jump chain on the existing EQ/EQS/ JZ/JMP opcodes — no new opcode, per the brief. The subject is evaluated exactly once, into `subj_reg`, and every arm's comparisons read it; `dst` is where an arm's value lands (mirrors `Binary`'s own And/Or short-circuit above: the caller's own `dst` is written directly, no intermediate temp to move out of). One case's `values` (the sample's own `case "@daily", "@midnight":` comma form) chains: value 1's failure falls through to check value 2; any success jumps straight to the arm body, skipping the rest of that arm's own checks; the LAST value's failure falls through to the NEXT ARM's own first check (backpatched once that arm starts emitting, `pending_fail`). `default` has no checks at all — it always matches whatever reaches it — which is also why the missing-default case (already a WO-E208 *error*, so this image is never written — see main.ml's own has_error gate) still lowers without crashing: the last real arm's `pending_fail` simply has nowhere to go but the switch's own exit, same as `end_jumps` below. Each arm is its own drop scope: this inlines emit_block's own save/restore-and-drop shape (`f_nlocals`/`f_env`/`f_declared`, `emit_scope_drops`, the @gc release group) rather than calling emit_block outright, because emit_block emits every statement *generically* — including the last one, which for a value-yielding arm must land in the caller's `dst`, not a throwaway temp the way emit_block's own ExprStmt handling (emit_tail) would. An arm whose body doesn't end in `ExprStmt` (every arm in the sample's own statement-position switches, which end in `return`) writes nothing into `dst` — correct either way: statement position discards it regardless, and expression position already has types.ml's own WO-E201 for an arm that fails to yield a value (see typecheck_switch's own doc comment — this file does not re-check that). `f.f_div` (this path has returned) mirrors emit_if's own THEN/ELSE bookkeeping, generalized to N arms: an arm that diverged emits no trailing jump to the switch's exit and contributes nothing to the owned/@gc mask merge below (mask_meet, folded pairwise across every *non*-diverging arm — the N-way generalization of emit_if's own 2-way `mask_meet f then_owned then_gc` call, same helper, unchanged). `emit_join_drops` (JOIN-DROP: a value this arm kept but some *other* arm moved) is owner.ml's own table entry, keyed exactly the way emit_if's THEN/ELSE already are — `(e.id, "ARM")` here vs. `(s.s_id, "THEN"/"ELSE")` there — so this is a lookup, not new logic. *) (* `~want_value` (Task 4 fix round 1, Critical 1): true everywhere the switch's value is consumed (a `let`'s value, a `return`, nested in an expression — every emit_expr path), false only for the discarded statement position (emit_stmt's own ExprStmt special case, mirroring types.ml's want_value:false). It gates exactly one thing: the payload MOVE-OUT below — an arm yielding its own binding nulls the subject's field only when someone actually takes ownership of the value; a discarded yield must leave the shell intact or the payload would leak with nobody left to drop it. *) and emit_switch ?(want_value = true) (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(dst : int) (subject : Ast.expr) (arms : Ast.switch_arm list) : unit = let subj_is_text = is_text p f subject in f.f_cur_line <- subject.pos.line; let subj_reg = emit_operand p f v subject in (* `dst` is not always already-reserved the way an ordinary expr's `dst` is: in statement position (`switch {...}` alone, discarded), `emit_tail`'s own "allocate then immediately un-reserve" convention hands this a `dst` that is the *next free* temp/local slot — bug found by this task's own ASan fixture, not a theoretical worry: an arm's own `let` (`alloc_local`'s register is `f_nlocals`, entirely independent of `f_temp`) legitimately picked that same slot, and the arm's own trailing value-write into `dst` then silently clobbered the live local sitting there before its DROP ran — a real, ASan-confirmed leak (RED), not a hypothetical. Reserving `dst` here, for the whole switch, fixes it at the source rather than special-casing the discard path: every `alloc_local`/ `alloc_temp` inside any arm is now guaranteed a register above `dst`. A `let`-value switch's `dst` is already `< f_nlocals` (`alloc_local` ran before this function was ever called), so this is a no-op there — restoring `saved_nlocals` afterward gives back exactly nothing it did not itself reserve. *) let saved_nlocals = f.f_nlocals in if f.f_nlocals <= dst then f.f_nlocals <- dst + 1; if f.f_temp <= dst then f.f_temp <- dst + 1; bump f dst; (* haxe-parity Task 4: a union-typed subject compares variant TAGS. All-bare union: the subject register already holds the ordinal — compare it directly, exactly the scalar chain below. Payload union: read the tag (the object header's class_id) once, via the variant_tag builtin, and compare that; the subject register itself stays live into the arm BODIES (payload bindings GETF from it), so both it and the tag temp are reserved past every arm-local for the switch's own duration — the same saved_nlocals guard `dst` already rides, restored in one place below. *) let subj_union = match ty_of_expr p f subject with | Some (Scalar n) -> find_union p n | _ -> None in (* through `?T` too (fix round 1) — for PATTERN RECOGNITION only. A `?Union` subject with variant cases is already a hard WO-E201 upstream (types.ml), so no image carrying this lowering is ever written; recognizing the patterns anyway (tag constants instead of expression evaluation, bindings bound) keeps the emitter from cascading misleading "`m` is not a local" errors on top of the real diagnostic. Tag reads (variant_tag) and the payload move-out keep gating on the EXACT `subj_union` above — never nullable-unwrapped. *) let subj_union_deep = match subj_union with | Some _ as u -> u | None -> ( match ty_of_expr p f subject with | Some t -> ( match unwrap t with Scalar n -> find_union p n | _ -> None) | None -> None) in let cmp_reg = match subj_union with | Some u when u.Types.u_has_payload -> if f.f_nlocals <= subj_reg then f.f_nlocals <- subj_reg + 1; if f.f_temp <= subj_reg then f.f_temp <- subj_reg + 1; let t = alloc_temp p f subject.pos in f.f_cur_line <- subject.pos.line; put f (ins_abc op_builtin t subj_reg b_variant_tag); if f.f_nlocals <= t then f.f_nlocals <- t + 1; t | _ -> subj_reg in (* the tag constant a case pattern compares against, or None for the plain-value path (non-union subjects; also a malformed pattern over a union — already diagnosed upstream, WO-E201/E203, so no image is ever written — which falls back to the value path rather than crashing the serializer). *) let union_case_tag (value_e : Ast.expr) : int option = match subj_union_deep with | None -> None | Some u -> ( let vname = match value_e.Ast.kind with | Ast.Ident n -> Some n | Ast.Call ({ Ast.kind = Ast.Ident n; _ }, _) -> Some n | _ -> None in match vname with | None -> None | Some n -> ( match List.find_opt (fun (vi : Types.variant_info) -> vi.Types.vi_name = n) u.Types.u_variants with | Some vi -> Some (variant_tag_value p u vi) | None -> None)) in let switch_temp_base = f.f_temp in let entry_owned = f.f_owned and entry_gc = f.f_gc in let div0 = f.f_div in let pending_fail = ref [] in let end_jumps = ref [] in let arm_results = ref [] in (* review fix, Critical 1: `default` has no comparison of its own — it matches unconditionally — so lowering the arms in raw *source* order made any `case` arm written after a `default` permanently unreachable dead code (reviewer-reproduced: `default` first, `case 2` after it, `classify(2)` returned the `default` value). `Ast.switch_lowering_order` moves `default` to the end before the chain is built; owner.ml's `analyze_switch` walks the identical order so the "ARM" labels the two files hand each other over the drop-scope/JOIN-DROP tables never drift apart. *) List.iteri (fun i (arm : Ast.switch_arm) -> let label = Printf.sprintf "ARM%d" i in List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:arm.Ast.arm_pos pc (here f)) !pending_fail; pending_fail := []; f.f_owned <- entry_owned; f.f_gc <- entry_gc; f.f_div <- div0; f.f_temp <- switch_temp_base; (if not arm.Ast.is_default then begin let n = List.length arm.Ast.values in let to_body = ref [] in List.iteri (fun j (value_e : Ast.expr) -> f.f_cur_line <- value_e.Ast.pos.line; let b = match union_case_tag value_e with | Some tagv -> (* a variant pattern never evaluates as an expression — its tag constant is loaded directly (a payload pattern lowered through emit_operand would NEW a fresh object and compare pointers: always false) *) let tb = alloc_temp p f value_e.Ast.pos in put f (ins_abx op_loadk tb (check_bx p f value_e.Ast.pos "constant" (const_int p tagv))); tb | None -> emit_operand p f v value_e in let t = alloc_temp p f value_e.Ast.pos in put f (ins_abc (if subj_is_text then op_eqs else op_eq) t cmp_reg b); let jz = here f in put f (ins_asbx op_jz t 0); if j < n - 1 then begin let jmp = here f in put f (ins_asbx op_jmp 0 0); patch_jump p f ~file:f.f_file ~pos:value_e.Ast.pos jz (here f); to_body := jmp :: !to_body end else pending_fail := jz :: !pending_fail) arm.Ast.values; let body_start = here f in List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:arm.Ast.arm_pos pc body_start) !to_body end); let saved_locals = f.f_nlocals in let saved_env = f.f_env in let saved_decls = f.f_declared in (* haxe-parity Task 4: payload bindings — GETF the variant object's fields into fresh arm-locals before the body runs. Plain registers holding borrows of the subject's own fields: never in a drop set (owner.ml declares them l_holds = false), undone by the same env/locals restore every arm already gets. *) let arm_binds = ref [] in (match subj_union_deep with | Some u when u.Types.u_has_payload -> ( match arm.Ast.values with | [ { Ast.kind = Ast.Call ({ Ast.kind = Ast.Ident vname; _ }, bargs); _ } ] -> ( match List.find_opt (fun (vi : Types.variant_info) -> vi.Types.vi_name = vname) u.Types.u_variants with | Some vi when List.length bargs = List.length vi.Types.vi_fields -> List.iteri (fun idx (barg : Ast.expr) -> match barg.Ast.kind with | Ast.Ident bn -> let _, fty = List.nth vi.Types.vi_fields idx in let r = alloc_local p f barg.Ast.pos in f.f_cur_line <- barg.Ast.pos.line; put f (ins_abc op_getf r subj_reg (check_field_idx p f barg.Ast.pos idx)); f.f_env <- (bn, (r, fty)) :: f.f_env; arm_binds := (bn, r, idx, fty) :: !arm_binds | _ -> ()) bargs | _ -> ()) | _ -> ()) | _ -> ()); (match List.rev arm.Ast.body with | [] -> () | last :: rev_init -> List.iter (emit_stmt p f v) (List.rev rev_init); (match last.Ast.s_kind with | Ast.ExprStmt ve -> stmt_reset f; f.f_cur_line <- last.Ast.s_pos.line; emit_expr p f v ~dst ve; (* Task 4 fix round 1 (Critical 1a/1c): the arm yields its own payload binding — a MOVE OUT of the variant object. The payload pointer just landed in `dst` (its new owner's register); null the shell's field so the shell's ordinary recursive drop plan — which already skips zero slots (runtime/src/gc.c wo_drop_kind) — frees the shell only, never the escaped payload. Without this, the subject's scope-end drop and the new owner's drop both freed the payload: a real reviewer-reproduced double free. Gated on `want_value` (a discarded yield must leave the shell whole) and on the name still resolving to the BINDING's own register (an arm-local `let` shadowing the binding is an ordinary yield, not an escape). Fix round 2: pointer-kind payload fields ONLY. Escaping a SCALAR field (Int/Bool/Timestamp/Id/ref, a bare-union tag) is a COPY — there is no ownership to move, nothing the shell's drop plan would double-free, and the SETF-0 was indistinguishable from a legitimate 0: the reviewer's m3d probe re-switched the same subject and read 0 where 5 lived. Kind 0 is WO_K_SCALAR (runtime/src/wob.h). *) (if want_value then match ve.Ast.kind with | Ast.Ident n -> ( match List.find_opt (fun (bn, _, _, _) -> bn = n) !arm_binds with | Some (_, breg, fidx, bfty) when (match lookup_local f n with Some (r, _) -> r = breg | None -> false) && field_kind p bfty <> 0 -> let save = f.f_temp in let z = alloc_temp p f ve.Ast.pos in put f (ins_abx op_loadk z (check_bx p f ve.Ast.pos "constant" (const_int p 0))); put f (ins_abc op_setf subj_reg (check_field_idx p f ve.Ast.pos fidx) z); f.f_temp <- save | _ -> ()) | _ -> ()); (match Hashtbl.find_opt v.v_move ve.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()) | _ -> emit_stmt p f v last)); emit_scope_drops p f v ~node:e.id ~label; emit_rc p f v ~node:e.id ~acquire:false ~groups:(declared_since f saved_decls) (); f.f_nlocals <- saved_locals; f.f_env <- saved_env; f.f_declared <- saved_decls; f.f_temp <- saved_locals; emit_join_drops p f v ~node:e.id ~label; (if not f.f_div then begin f.f_cur_line <- arm.Ast.arm_pos.line; let pc = here f in put f (ins_asbx op_jmp 0 0); end_jumps := pc :: !end_jumps end); arm_results := (f.f_owned, f.f_gc, f.f_div) :: !arm_results) (Ast.switch_lowering_order arms); let exit_pc = here f in List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:e.pos pc exit_pc) !pending_fail; List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:e.pos pc exit_pc) !end_jumps; f.f_nlocals <- saved_nlocals; match List.filter (fun (_, _, d) -> not d) (List.rev !arm_results) with | [] -> f.f_div <- true | (o0, g0, _) :: rest -> f.f_owned <- o0; f.f_gc <- g0; List.iter (fun (o, g, _) -> mask_meet f o g) rest; f.f_div <- div0 (* haxe-parity Task 5: `try body catch (e) handler`. TRY ereg, ->handler register the region; ereg is where the error dst> record lands if it fires ENDTRY the region completed: pop it JMP ->exit handler: NEW ereg, Error the record is the compiler's allocation, so BUILTIN ereg, err_fill its drop is the ordinary scope-end one dst> exit: The two arms are joined exactly like a switch's: each arm's ending owned/gc masks meet, and each drops what the other moved (emit_join_drops with the labels owner.ml's analyze_try recorded). The VM releases whatever the body itself owned before landing — the live mask at the try site is what it reads — so the handler starts from the entry state, which is what the owner pass assumed. *) and emit_try (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) (body : Ast.expr) (ename : string) (handler : Ast.stmt list) : unit = (* dst is reserved for the whole construct — same reason emit_switch does it: a handler-local `let` must never be handed dst's register *) let saved_nlocals = f.f_nlocals in if f.f_nlocals <= dst then f.f_nlocals <- dst + 1; if f.f_temp <= dst then f.f_temp <- dst + 1; bump f dst; let ereg = alloc_local p f e.pos in f.f_cur_line <- e.pos.line; let try_pc = here f in put f (ins_asbx op_try ereg 0); let entry_owned = f.f_owned and entry_gc = f.f_gc in let div0 = f.f_div in (match expected with | Some t -> emit_expr p f v ~dst ~expected:t body | None -> emit_expr p f v ~dst body); f.f_cur_line <- e.pos.line; put f (ins_abc op_endtry 0 0 0); emit_join_drops p f v ~node:e.id ~label:"TRYBODY"; let body_owned = f.f_owned and body_gc = f.f_gc and body_div = f.f_div in let skip_pc = here f in put f (ins_asbx op_jmp 0 0); patch_jump p f ~file:f.f_file ~pos:e.pos try_pc (here f); f.f_owned <- entry_owned; f.f_gc <- entry_gc; f.f_div <- div0; let saved_env = f.f_env and saved_decls = f.f_declared in let saved_locals = f.f_nlocals in (match class_of_name p Types.error_record_name with | Some ecid -> f.f_cur_line <- e.pos.line; put f (ins_abx op_new ereg (check_bx p f e.pos "class" ecid)); put f (ins_abc op_builtin ereg ereg b_err_fill) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:"no class-table entry for the `Error` record — a `try` cannot bind its error"; put f (ins_abx op_loadk ereg (const_int p 0))); f.f_env <- (ename, (ereg, Ast.Scalar Types.error_record_name)) :: f.f_env; Hashtbl.replace f.f_decl e.id ereg; f.f_declared <- e.id :: f.f_declared; mask_set f (match Hashtbl.find_opt v.v_holder e.id with Some k -> k | None -> Owner.LOwned) ereg; (match List.rev handler with | [] -> () | last :: rev_init -> ( List.iter (emit_stmt p f v) (List.rev rev_init); match last.Ast.s_kind with | Ast.ExprStmt ve -> stmt_reset f; f.f_cur_line <- last.Ast.s_pos.line; (match expected with | Some t -> emit_expr p f v ~dst ~expected:t ve | None -> emit_expr p f v ~dst ve) | _ -> emit_stmt p f v last)); emit_scope_drops p f v ~node:e.id ~label:"CATCH"; emit_rc p f v ~node:e.id ~acquire:false ~groups:(declared_since f saved_decls) (); f.f_nlocals <- saved_locals; f.f_env <- saved_env; f.f_declared <- saved_decls; f.f_temp <- saved_locals; emit_join_drops p f v ~node:e.id ~label:"CATCHJOIN"; patch_jump p f ~file:f.f_file ~pos:e.pos skip_pc (here f); f.f_nlocals <- saved_nlocals; (* the state after the try is what both arms agree on *) if body_div then () else if f.f_div then begin f.f_owned <- body_owned; f.f_gc <- body_gc; f.f_div <- div0 end else begin mask_meet f body_owned body_gc; f.f_div <- div0 end and emit_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (cn : string) (fields : (string * Ast.expr) list) : unit = match class_of_name p cn with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "constructor of `%s`, which is not a declared class" cn); put f (ins_abx op_loadk dst (const_int p 0)) | Some cid -> put f (ins_abx op_new dst (check_bx p f e.pos "class" cid)); (* dst holds the live object pointer every SETF below reads -- but in tail position (`return Box{...}`, emit_tail's allocate-then- un-reserve convention) dst sits AT f_temp, so the field-value temp would be dst itself and the value-write would clobber the pointer before SETF reads it (NEW r0; LOADK r0; SETF r0,f0,r0 -- an int64 stored through as a heap pointer). Reserve dst past the field loop; same guard emit_switch uses for its placeholder dst. *) let outer = f.f_temp in if f.f_temp <= dst then f.f_temp <- dst + 1; List.iter (fun ((fname : string), (fe : Ast.expr)) -> match field_of p cid fname with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "`%s` has no field `%s`" cn fname) | Some (idx, fty) -> (* one temporary, reused for every field: each field's value is dead the instant its SETF retires, so holding a slot per field would make a wide class (the normal shape of a @table class) burn the register budget for nothing. Same save/restore the call and builtin windows use. *) let save = f.f_temp in let t = alloc_temp p f fe.pos in emit_expr p f v ~dst:t ~expected:fty fe; f.f_cur_line <- fe.pos.line; put f (ins_abc op_setf dst (check_field_idx p f e.pos idx) t); f.f_temp <- save) fields; (* haxe-parity Task 4: fields the literal omitted. A declared default is emitted and stored (this is what makes `TailState {}` construct — the sample's own defaults-fill-in pattern); a `?`-typed field with no default stays the zero word NEW left (nil-by-shape). Appended AFTER the provided-field loop, never interleaved, so a literal that provides every field emits byte-identical code to before this task. A field that is neither provided, defaulted, nor nullable was already WO-E206 upstream — no image is written, so no arm is needed here. *) (match Types.StringMap.find_opt cn p.p_syms.Types.classes with | None -> () | Some (ci : Types.class_info) -> let provided = List.map fst fields in List.iter (fun (fname, _, fdefault, _) -> match fdefault with | Some d when not (List.mem fname provided) -> ( match field_of p cid fname with | None -> () | Some (idx, fty) -> let save = f.f_temp in let t = alloc_temp p f e.pos in emit_default_value p f ~dst:t ~fty ~pos:e.pos d; f.f_cur_line <- e.pos.line; put f (ins_abc op_setf dst (check_field_idx p f e.pos idx) t); f.f_temp <- save) | _ -> ()) ci.Types.fields); f.f_temp <- outer (* The default expressions the emitter can lower (haxe-parity Task 4): the literal shapes the sample's own typedefs use — Int (optionally negated), Text, Bool, `now()` (parse_default_expr's own recognized case), and `[]` (an empty container, kinds from the field's declared type — the same container_imm rule multi_new/map_new already follow). A default is an opaque token span by design (ast.ml), so anything richer is WO-E403 — a diagnostic, never invented bytecode. *) and emit_default_value (p : pctx) (f : fstate) ~(dst : int) ~(fty : Ast.field_ty) ~(pos : Ast.pos) (d : Ast.default_expr) : unit = let bad () = err p ~code:cannot_lower_code ~file:f.f_file ~pos ~message: "cannot lower this field default — only Int/Text/Bool literals, `now()`, and `[]` are \ supported"; put f (ins_abx op_loadk dst (const_int p 0)) in match d with | Ast.DefaultNow -> put f (ins_abc op_builtin dst dst b_now) | Ast.DefaultOpaque toks -> ( match List.map (fun (t : Token.t) -> t.Token.kind) toks with | [ Token.Int n ] -> put f (ins_abx op_loadk dst (check_bx p f pos "constant" (const_int p n))) | [ Token.Dash; Token.Int n ] -> put f (ins_abx op_loadk dst (check_bx p f pos "constant" (const_int p (-n)))) | [ Token.Str s ] -> put f (ins_abx op_loadk dst (check_bx p f pos "constant" (const_text p s))) | [ Token.KwTrue ] -> put f (ins_abx op_loadk dst (const_int p 1)) | [ Token.KwFalse ] -> put f (ins_abx op_loadk dst (const_int p 0)) | [ Token.LBracket; Token.RBracket ] -> ( match container_imm p (Some fty) (match unwrap fty with Map _ -> true | _ -> false) with | Some imm -> put f (ins_abc op_builtin dst imm (match unwrap fty with Map _ -> b_map_new | _ -> b_multi_new)) | None -> bad ()) | _ -> bad ()) (* haxe-parity Task 4: variant construction (`Failed("boom")`). NEW of the variant's own class (its id IS the tag — the header carries it, nothing else to set), then positional SETFs, exactly a constructor literal with positional instead of named fields. Same dst-reservation guard as emit_ctor (tail position hands this a dst at f_temp). Arity was already WO-E203 upstream (types.ml); this keeps the emitter's own belt-and-braces WO-E403 like every call path does. *) and emit_variant_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (u : Types.union_info) (vi : Types.variant_info) (args : Ast.expr list) : unit = if not (check_arity p f e ~what:(Printf.sprintf "variant `%s` of `%s`" vi.Types.vi_name u.Types.u_name) ~want:(List.length vi.Types.vi_fields) ~got:(List.length args)) then put f (ins_abx op_loadk dst (const_int p 0)) else if not u.Types.u_has_payload then (* a bare-union variant "called" with its zero arguments is the same value as the bare reference: the ordinal tag *) put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p vi.Types.vi_tag))) else begin put f (ins_abx op_new dst (check_bx p f e.pos "class" (variant_tag_value p u vi))); let outer = f.f_temp in if f.f_temp <= dst then f.f_temp <- dst + 1; List.iteri (fun idx (ae : Ast.expr) -> match List.nth_opt vi.Types.vi_fields idx with | None -> () | Some (_, fty) -> let save = f.f_temp in let t = alloc_temp p f ae.pos in emit_expr p f v ~dst:t ~expected:fty ae; f.f_cur_line <- ae.pos.line; put f (ins_abc op_setf dst (check_field_idx p f e.pos idx) t); f.f_temp <- save) args; f.f_temp <- outer end (* ---- calls --------------------------------------------------------- The window convention (runtime/src/vm.c's CALL / ICALL): the callee's r0 is the caller's slot A, arguments occupy A..A+argc-1, and the return value lands back in A. `arg_cnt` counts `self`, so a method call places the receiver at A and its arguments from A+1. The whole window is reserved before any argument is evaluated: a nested call inside argument i then gets a window above the outer one and cannot clobber an already-filled slot. *) and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) (callee : Ast.expr) (args : Ast.expr list) : unit = ignore expected; match callee.kind with | Ident name -> ( (* haxe-parity Task 1 (modules), CRITICAL 1 review fix: this file's own module's own free_fns first — never p_syms' flat merge — so an own-module bare call to a name that happens to collide with some other module's same-named `pub fn` still resolves to *this* module's declaration, not whichever one the flat merge kept. Falls back to p_syms only when the own module doesn't declare it at all (a bare call resolving through a single `use`d module, or a builtin) — every pre-existing, non-colliding case behaves exactly as before: a name with only one declaration anywhere is never mangled either way, so free_fn_key returns it unchanged. *) let own_mid = p.p_module_of f.f_file in let own_fi = match Hashtbl.find_opt p.p_module_syms own_mid with | Some msyms -> Types.StringMap.find_opt name msyms.Types.free_fns | None -> None in match own_fi with | Some fi -> emit_direct p f v ~dst e ~key:(free_fn_key p.p_colliding own_mid name) ~recv:None ~params:fi.Types.params args | None -> ( match free_fn p name with | Some fi -> emit_direct p f v ~dst e ~key:name ~recv:None ~params:fi.Types.params args | None -> ( (* haxe-parity Task 4: variant construction by name. A declared free fn of the same name already won above (the same shadowing rule builtins follow). *) match find_variant p name with | Some (u, vi) -> emit_variant_ctor p f v ~dst e u vi args | None -> if is_builtin_name name then emit_builtin p f v ~dst ?expected e name args else begin err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "call to `%s`, which is not a declared fn or a builtin" name); put f (ins_abx op_loadk dst (const_int p 0)) end))) | Field (base, mname) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with | Scalar cn -> ( match class_method p cn mname with | Some mi when mi.Types.is_static -> (* haxe-parity Task 7: a static has no receiver — reaching it through an instance is an error, not an implicit `Cls.` *) err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: (Printf.sprintf "`%s` is a `static fn` — call it as `%s.%s(...)`, not on an instance" mname cn mname); put f (ins_abx op_loadk dst (const_int p 0)) | Some mi -> emit_direct p f v ~dst e ~key:(cn ^ "." ^ mname) ~recv:(Some base) ~params:mi.Types.params args | None -> ( match iface_method p cn mname with | Some (slot, sg) -> emit_iface p f v ~dst e ~slot ~base ~params:sg.Types.params args | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "`%s` has no method `%s`" cn mname); put f (ins_abx op_loadk dst (const_int p 0)))) | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "method `%s` called on a value that is not a class instance" mname); put f (ins_abx op_loadk dst (const_int p 0))) | None -> ( (* haxe-parity Task 1 (modules): before falling to the generic "cannot resolve the receiver" error, check whether `base` is actually a `use` alias for this file rather than an unresolvable receiver expression — a genuine local/param/self receiver is always caught by the `Some bt` arm above (ty_of_expr resolves it), so this is reached only when `base` names nothing ty_of_expr knows, which is exactly the shape a bare module alias has. types.ml's check_modules already proved the reference legitimate (own module unconditionally, a used module only its `pub` names) before the compile got this far — that front-door check decided whether this call was even allowed, not anything here. Two shapes reach emission: - a stdlib-reserved alias (`fs`/`proc`/...): nothing to lower to yet (member signatures arrive in plan 9) — WO-E406, the one place this milestone still says so, and only because this call actually survived to emission (an unused `use fs` never reaches this code at all). - a project-module alias: resolves through *that module's own* symbols (p_module_syms), never p_syms' flat merge (CRITICAL 1 review fix) — p_syms may have silently dropped this exact module's declaration in favor of some other module's same-named `pub fn` (main.ml's merge_symbols is first-wins across the whole discovered tree, oblivious to modules). This is what makes `a.thing()` and `b.thing()` genuinely distinct once qualification disambiguates them, not two spellings of whichever one the flat merge happened to keep. *) match base.kind with (* haxe-parity Task 7: `Flock.held(path)` — the base names a class, so there is no receiver to pass and the window holds parameters only (`recv:None`, exactly like a free fn). Checked before the `use`-alias reading: both are bare Idents at this point. *) | Ident cls_name when static_method p cls_name mname <> None -> ( match static_method p cls_name mname with | Some mi -> emit_direct p f v ~dst e ~key:(cls_name ^ "." ^ mname) ~recv:None ~params:mi.Types.params args | None -> ()) | Ident alias -> ( match use_edge_for p ~file:f.f_file alias with | Some u when u.Types.ue_is_stdlib -> err p ~code:stdlib_not_linked_code ~file:f.f_file ~pos:e.pos ~message: (Printf.sprintf "stdlib module `%s` is not linked in this milestone (called as `%s.%s`)" alias alias mname); put f (ins_abx op_loadk dst (const_int p 0)) | Some u -> ( let target_mid = Types.path_str u.Types.ue_segments in let target_fi = match Hashtbl.find_opt p.p_module_syms target_mid with | Some msyms -> Types.StringMap.find_opt mname msyms.Types.free_fns | None -> None in match target_fi with | Some fi -> emit_direct p f v ~dst e ~key:(free_fn_key p.p_colliding target_mid mname) ~recv:None ~params:fi.Types.params args | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: (Printf.sprintf "call to `%s.%s`, which is not a declared fn in module `%s`" alias mname alias); put f (ins_abx op_loadk dst (const_int p 0))) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "cannot resolve the receiver's type for the call to `%s`" mname); put f (ins_abx op_loadk dst (const_int p 0))) | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "cannot resolve the receiver's type for the call to `%s`" mname); put f (ins_abx op_loadk dst (const_int p 0)))) | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:"only a name or a `receiver.method` form can be called"; put f (ins_abx op_loadk dst (const_int p 0)) (* The call-site argument count has to match the callee's, or the window the caller reserves is not the window the callee reads: the loader's own "call window exceeds frame" check rejects the image, and a too-wide call would hand the callee whatever the caller happened to leave in those registers. Nothing upstream catches this — types.ml declares WO-E203 for bad arity and never raises it (see the error catalog) — so the emitter is where it stops. *) and check_arity (p : pctx) (f : fstate) (e : Ast.expr) ~(what : string) ~(want : int) ~(got : int) : bool = if want = got then true else begin err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message: (Printf.sprintf "%s takes %d argument(s), given %d" what want got); false end (* The third return component (Task 4 fix rounds 1-2): stable registers holding OWNED HEAP temporaries passed by borrow — a construction or an owned-returning call as an argument (`get(Boxed(Pay{}))`, `peek(Pay{})` — the reviewer's h5/m5c per-iteration leaks) is a fresh owned value NOBODY owned before this fix: it is not a place, so owner.ml never tracks it, and no scope ever dropped it. The borrow convention means the callee never takes it either (a callee that stores or returns the borrow is already WO-E304), so the CALLER reaps it: each qualifying argument's pointer is copied to a slot below the window (the callee's frame overlaps the window and may overwrite the arg slot itself — same reasoning as the residual-guard gbase) and DROPped by emit_direct/emit_iface once the call returns. Recursive drop is correct both ways: a payload the callee moved out (the switch escape above) left the field nulled, which the drop plan skips; an untouched temp frees payload and shell together. Emitter-only, no owner table: the temp never exists in owner.ml's world, so there is no entry to conflict with; the one imprecision is a trap DURING the call (the frame's drop mask cannot name a register owner.ml never saw — the temp leaks on that path, the same pre-existing behavior every expression temporary has). *) and call_window (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(recv : Ast.expr option) ~(params : (string * Ast.field_ty * Ast.param_conv) list) (args : Ast.expr list) : int * int * int list = let nrecv = match recv with Some _ -> 1 | None -> 0 in let argc = nrecv + List.length args in (* Fix round 2 widened this from payload-union temps to ANY owned heap temporary: the sibling leak (`peek(Pay{})`, a plain record ctor in a loop — reviewer's m5c, 10,780 KB vs a 1,532 KB control) was the same fresh-value-nobody-owns shape with a class instead of a union. Qualifies: a Ctor literal, a variant construction, or a call whose result is a payload union or a declared NON-@gc class (records included — same table). Excluded, each for its own reason: `take` params (a real transfer — the callee owns it, m6a/m6c prove that path flat); places (their scope drops them); @gc classes (their creating reference is the rc system's, and DROP is the wrong op for a counted handle); Text (owner.ml classes it Copy, so a callee may legitimately STORE a Text argument — classes and unions can't, WO-E304 polices those borrows); interface-typed call results (ty_of_expr names the interface, not the concrete class — still leak, disclosed). *) let owned_heap_temp (a : Ast.expr) : bool = (match a.kind with | Ident n -> lookup_local f n = None (* a local is a place, never a temp *) | Call _ | Ctor _ -> true | _ -> false) && (match ty_of_expr p f a with | Some t -> ( match unwrap t with | Scalar n -> ( match find_union p n with | Some u -> u.Types.u_has_payload | None -> ( match Types.StringMap.find_opt n p.p_syms.Types.classes with | Some (ci : Types.class_info) -> not ci.Types.is_gc | None -> false)) | _ -> false) | None -> false) in let temp_idx = List.mapi (fun i a -> let conv = match List.nth_opt params i with Some (_, _, c) -> c | None -> Ast.Borrow in if conv = Ast.Borrow && owned_heap_temp a then Some i else None) args |> List.filter_map Fun.id in let tbase = alloc_temps p f e.pos (List.length temp_idx) in let base = alloc_temps p f e.pos (max argc 1) in (match recv with None -> () | Some r -> emit_expr p f v ~dst:base r); List.iteri (fun i a -> let expected = match List.nth_opt params i with Some (_, t, _) -> Some t | None -> None in let slot = base + nrecv + i in let save = f.f_temp in (match expected with | Some t -> emit_expr p f v ~dst:slot ~expected:t a | None -> emit_expr p f v ~dst:slot a); f.f_temp <- save) args; let temp_drops = List.mapi (fun j i -> let g = tbase + j in put f (ins_abc op_move g (base + nrecv + i) 0); g) temp_idx in (base, argc, temp_drops) and emit_direct (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) ~(key : string) ~(recv : Ast.expr option) ~(params : (string * Ast.field_ty * Ast.param_conv) list) (args : Ast.expr list) : unit = match SM.find_opt key p.p_method_id with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:(Printf.sprintf "no emitted method for `%s`" key); put f (ins_abx op_loadk dst (const_int p 0)) | Some midx when check_arity p f e ~what:(Printf.sprintf "`%s`" key) ~want:(List.length params) ~got:(List.length args) -> let gbase = alloc_temps p f e.pos (residual_count v e.id) in let base, _, temp_drops = call_window p f v e ~recv ~params args in let guards = residual_guards p f v e.id (Some gbase) in acquire_guards f guards; (* the frame's drop map, from the owner table, effective at the CALL *) sync_mask p f v e.id; f.f_cur_line <- e.pos.line; put f (ins_abx op_call base (check_bx p f e.pos "method" midx)); release_guards f guards; (* owned variant temporaries this call borrowed — reaped here, see call_window's own doc comment *) List.iter (fun g -> put f (ins_abc op_drop g 0 0)) temp_drops; if dst <> base then put f (ins_abc op_move dst base 0) | Some _ -> put f (ins_abx op_loadk dst (const_int p 0)) and emit_iface (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) ~(slot : int) ~(base : Ast.expr) ~(params : (string * Ast.field_ty * Ast.param_conv) list) (args : Ast.expr list) : unit = if not (check_arity p f e ~what:"the interface method" ~want:(List.length params) ~got:(List.length args)) then put f (ins_abx op_loadk dst (const_int p 0)) else begin let gbase = alloc_temps p f e.pos (residual_count v e.id) in let w, _, temp_drops = call_window p f v e ~recv:(Some base) ~params args in let guards = residual_guards p f v e.id (Some gbase) in acquire_guards f guards; sync_mask p f v e.id; f.f_cur_line <- e.pos.line; put f (ins_abx op_icall w (check_bx p f e.pos "interface slot" slot)); release_guards f guards; (* owned variant temporaries this call borrowed — see call_window *) List.iter (fun g -> put f (ins_abc op_drop g 0 0)) temp_drops; if dst <> w then put f (ins_abc op_move dst w 0) end (* ---- builtins ------------------------------------------------------ Source spellings of the format doc's BUILTIN ids. `get`/`set` and `push`/`count`/`latest`/`has` read on the container they are given, so one source name covers a `multi` and a `map` where the runtime has two ids. A user-declared free fn of the same name wins (checked before this function is reached) — a declared name is never shadowed by a builtin. *) and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) (name : string) (args : Ast.expr list) : unit = let bad msg = err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:msg; put f (ins_abx op_loadk dst (const_int p 0)) in let arity_of id = if id = b_now || id = b_multi_new || id = b_map_new then 0 else if id = b_print || id = b_print_int || id = b_words || id = b_count || id = b_latest || id = b_int_to_text then 1 else if id = b_multi_push || id = b_multi_get || id = b_map_get || id = b_map_has then 2 else 3 in let container_id first_arg on_multi on_map = match ty_of_expr p f first_arg with | Some t -> ( match unwrap t with Multi _ -> Some on_multi | Map _ -> Some on_map | _ -> None) | None -> None in let fixed id = let n = arity_of id in if List.length args <> n then bad (Printf.sprintf "builtin `%s` takes %d argument(s), given %d" name n (List.length args)) else begin let base = alloc_temps p f e.pos (max n 1) in List.iteri (fun i a -> let save = f.f_temp in emit_expr p f v ~dst:(base + i) a; f.f_temp <- save) args; sync_mask p f v e.id; f.f_cur_line <- e.pos.line; put f (ins_abc op_builtin dst base id) end in match name with | "now" -> fixed b_now | "print" -> fixed b_print | "print_int" -> fixed b_print_int | "words" -> fixed b_words | "count" -> fixed b_count | "latest" -> fixed b_latest | "int_to_text" -> fixed b_int_to_text | "multi_new" | "map_new" -> let is_map = name = "map_new" in if args <> [] then bad (Printf.sprintf "builtin `%s` takes no arguments" name) else ( match container_imm p expected is_map with | None -> bad (Printf.sprintf "`%s` needs a destination of declared type `%s` — its element kinds are the container's drop plan and cannot be guessed; build it into a field of that type" name (if is_map then "map" else "multi T")) | Some imm -> sync_mask p f v e.id; f.f_cur_line <- e.pos.line; put f (ins_abc op_builtin dst imm (if is_map then b_map_new else b_multi_new))) | "push" -> ( match args with | a :: _ -> ( match container_id a b_multi_push b_multi_push with | Some id -> fixed id | None -> bad "builtin `push` needs a `multi` as its first argument") | [] -> bad "builtin `push` takes 2 arguments, given 0") | "get" -> ( match args with | a :: _ -> ( match container_id a b_multi_get b_map_get with | Some id -> fixed id | None -> bad "builtin `get` needs a `multi` or a `map` as its first argument") | [] -> bad "builtin `get` takes 2 arguments, given 0") | "set" -> ( match args with | a :: _ -> ( match container_id a b_map_set b_map_set with | Some id -> fixed id | None -> bad "builtin `set` needs a `map` as its first argument") | [] -> bad "builtin `set` takes 3 arguments, given 0") | "has" -> ( match args with | a :: _ -> ( match container_id a b_map_has b_map_has with | Some id -> fixed id | None -> bad "builtin `has` needs a `map` as its first argument") | [] -> bad "builtin `has` takes 2 arguments, given 0") | _ -> bad (Printf.sprintf "unknown builtin `%s`" name) (* ---- statements ---------------------------------------------------- *) and emit_stmt (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit = stmt_reset f; f.f_cur_line <- s.s_pos.line; match s.s_kind with | Let { name; ty; value } -> let declared = ty in let vty = match declared with | Some t -> t | None -> ( match ty_of_expr p f value with Some t -> t | None -> Scalar "Int") in let r = alloc_local p f s.s_pos in (match declared with | Some t -> emit_expr p f v ~dst:r ~expected:t value | None -> emit_expr p f v ~dst:r value); f.f_env <- (name, (r, vty)) :: f.f_env; Hashtbl.replace f.f_decl s.s_id r; f.f_declared <- s.s_id :: f.f_declared; (* a real transfer at this `let` retires the source: the VM's MOVE is the move, so the only thing left to do is stop calling the source live (the format doc's own words) *) (match Hashtbl.find_opt v.v_move value.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()); (match Hashtbl.find_opt v.v_holder s.s_id with | Some kind -> mask_set f kind r | None -> ()); emit_rc p f v ~node:s.s_id ~acquire:true () | Assign { target; value } -> emit_assign p f v s target value | ExprStmt ({ kind = Ast.Switch (subj, arms); _ } as e) -> (* Task 4 fix round 1: the one place a switch's value is DISCARDED — mirrors types.ml's own want_value:false special case. The flag gates the payload move-out (see emit_switch's doc comment): a discarded binding yield must leave the shell intact. The dst convention and the emit_expr epilogue (node register, escape increments) are replicated from emit_tail's non-place path so this stays byte-identical to the generic path for everything but the flag. *) let t = alloc_temp p f e.pos in f.f_temp <- t; f.f_cur_line <- e.pos.line; emit_switch ~want_value:false p f v e ~dst:t subj arms; Hashtbl.replace f.f_node e.id t; emit_rc p f v ~node:e.id ~acquire:true (); (match Hashtbl.find_opt v.v_move e.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()) | ExprStmt e -> (* a discarded value lands in the next free temporary *without* reserving it: a call then places its own window at that same slot and needs no MOVE to hand the result back, and nothing later in this statement can want the register (the statement ends here) *) ignore (emit_tail p f v e); (match Hashtbl.find_opt v.v_move e.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()) | Return opt -> emit_return p f v s opt | If { cond; then_body; else_body } -> emit_if p f v s cond then_body else_body | While { cond; body } -> emit_while p f v s cond body | For { var; iter; body } -> emit_for p f v s var iter body | Break -> emit_break p f v s | Continue -> emit_continue p f v s | DoWhile { body; cond } -> emit_do_while p f v s body cond and emit_assign (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (target : Ast.expr) (value : Ast.expr) : unit = let overwrite = Hashtbl.mem v.v_overwrite s.s_id in (* a @gc value the assignment displaces is released, not dropped: the RC table carries that RELEASE at the assignment's own node *) let releases = match Hashtbl.find_opt v.v_rc s.s_id with | None -> false | Some sites -> List.exists (fun (r : Owner.rc_site) -> r.Owner.rc_op = Owner.RcRelease && not r.Owner.rc_elided) sites in match target.kind with | Ident n -> ( match lookup_local f n with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:(Printf.sprintf "assignment to `%s`, which is not a local or parameter" n) | Some (r, ty) -> Hashtbl.replace f.f_node target.id r; if overwrite || releases then begin (* the replaced value dies here (the owner table's OVERWRITE or RELEASE entry); compute the new one into a temporary first so destroying the old one cannot destroy what is about to be stored *) let t = alloc_temp p f value.pos in emit_expr p f v ~dst:t ~expected:ty value; f.f_cur_line <- s.s_pos.line; if overwrite then begin put f (ins_abc op_drop r 0 0); mask_clear f r end; if releases then begin Hashtbl.replace f.f_node s.s_id r; emit_rc p f v ~node:s.s_id ~acquire:false () end; put f (ins_abc op_move r t 0) end else emit_expr p f v ~dst:r ~expected:ty value; (* a whole-local target cannot be an unprovable alias of anything (relate answers Overlap or Disjoint for a place with no projections), so this normally finds nothing; consumed anyway so the end-of-unit backstop stays exact rather than special-cased *) let guards = residual_guards p f v s.s_id None in acquire_guards f guards; release_guards f guards; (match Hashtbl.find_opt v.v_move value.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()); (* a whole-local assignment re-initializes it: whatever kind of holder lived in that register holds again *) match Hashtbl.find_opt f.f_kind r with Some kind -> mask_set f kind r | None -> ()) | Field (base, fname) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with | Scalar cn -> ( match class_of_name p cn with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:(Printf.sprintf "assignment into `%s`, which is not a declared class" cn) | Some cid -> ( match field_of p cid fname with | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:(Printf.sprintf "`%s` has no field `%s`" cn fname) | Some (idx, fty) -> let b = emit_operand p f v base in Hashtbl.replace f.f_node target.id b; let idx = check_field_idx p f target.pos idx in if overwrite || releases then begin (* SETF never auto-drops (format doc): the compiler emits the destruction of the field's previous value — a DROP for an owned field, an rc release for a @gc one *) let old = alloc_temp p f target.pos in f.f_cur_line <- s.s_pos.line; put f (ins_abc op_getf old b idx); if overwrite then put f (ins_abc op_drop old 0 0); if releases then begin Hashtbl.replace f.f_node s.s_id old; emit_rc p f v ~node:s.s_id ~acquire:false () end end; let t = alloc_temp p f value.pos in emit_expr p f v ~dst:t ~expected:fty value; f.f_cur_line <- s.s_pos.line; (* an assignment is a region of its own: owner.ml anchors the residual sites it produces on the *statement*, not on any call expression. Guarded in place — no call intervenes, so no operand register can be clobbered — and around the mutation only, which is the narrowest correct window. *) let guards = residual_guards p f v s.s_id None in acquire_guards f guards; put f (ins_abc op_setf b idx t); release_guards f guards; match Hashtbl.find_opt v.v_move value.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ())) | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:(Printf.sprintf "assignment into field `%s` of a non-class value" fname)) | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:(Printf.sprintf "cannot resolve the type of the value `%s` is written to" fname)) | Index (base, idx) -> ( match ty_of_expr p f base with | Some bt -> ( match unwrap bt with | Map _ -> (* `m[k] = x` is the map_set builtin: container, key, value in three consecutive registers *) let w = alloc_temps p f s.s_pos 3 in emit_expr p f v ~dst:w base; Hashtbl.replace f.f_node target.id w; emit_expr p f v ~dst:(w + 1) idx; emit_expr p f v ~dst:(w + 2) value; let sink = alloc_temp p f s.s_pos in f.f_cur_line <- s.s_pos.line; let guards = residual_guards p f v s.s_id None in acquire_guards f guards; put f (ins_abc op_builtin sink w b_map_set); release_guards f guards | Multi _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message: "element assignment into a `multi` — the v1 instruction set has push/get but no \ element write" | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:"element assignment into a value that is neither a `multi` nor a `map`") | None -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:"cannot resolve the container's type for an element assignment") | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos ~message:"assignment target must be a local, a field, or an element" and emit_return (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (opt : Ast.expr option) : unit = match opt with | None -> emit_rc p f v ~node:s.s_id ~acquire:true (); (match Hashtbl.find_opt v.v_return s.s_id with Some items -> emit_drops p f items | None -> ()); emit_rc p f v ~node:s.s_id ~acquire:false (); f.f_cur_line <- s.s_pos.line; put f (ins_abc op_ret0 0 0 0); f.f_div <- true | Some e -> let t = emit_tail p f v e in (match Hashtbl.find_opt v.v_move e.id with | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) | None -> ()); (* the escaping value's own increment was emitted where the value landed (emit_expr / emit_tail), which is before the frame's releases below — a balanced pair must never reach rc 0 in between *) emit_rc p f v ~node:s.s_id ~acquire:true (); (match Hashtbl.find_opt v.v_return s.s_id with Some items -> emit_drops p f items | None -> ()); emit_rc p f v ~node:s.s_id ~acquire:false (); f.f_cur_line <- s.s_pos.line; put f (ins_abc op_ret t 0 0); f.f_div <- true (* haxe-parity Task 2: `break`/`continue` mirror emit_return's own shape exactly (drops from the owner table at this node, then jump) — the only difference is *where* the jump lands, which this frame does not know yet (the enclosing loop patches it once it does — loop_frame's own doc comment). `f.f_div <- true` afterward is the same call emit_return makes for the same reason: the rest of this block is unreachable, and emit_if's own THEN/ELSE merge already knows how to fold that into a branch join (unchanged by this task — a `break` inside an `if` inside a loop takes the exact path a `return` there already does). Empty `f_loops` (no enclosing loop) is WO-E403: there is no legal jump target to lower onto, the same "cannot lower" convention every other construct with nothing to lower to uses. *) and emit_break (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit = match f.f_loops with | [] -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:s.s_pos ~message:"`break` outside of a loop" | lf :: _ -> emit_rc p f v ~node:s.s_id ~acquire:true (); (match Hashtbl.find_opt v.v_break s.s_id with Some items -> emit_drops p f items | None -> ()); emit_rc p f v ~node:s.s_id ~acquire:false (); f.f_cur_line <- s.s_pos.line; let pc = here f in put f (ins_asbx op_jmp 0 0); lf.lf_breaks <- pc :: lf.lf_breaks; f.f_div <- true and emit_continue (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit = match f.f_loops with | [] -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:s.s_pos ~message:"`continue` outside of a loop" | lf :: _ -> emit_rc p f v ~node:s.s_id ~acquire:true (); (match Hashtbl.find_opt v.v_continue s.s_id with Some items -> emit_drops p f items | None -> ()); emit_rc p f v ~node:s.s_id ~acquire:false (); f.f_cur_line <- s.s_pos.line; let pc = here f in put f (ins_asbx op_jmp 0 0); lf.lf_continues <- pc :: lf.lf_continues; f.f_div <- true and emit_block (p : pctx) (f : fstate) (v : views) ~(node : int) ~(label : string) (body : Ast.stmt list) : unit = let saved_locals = f.f_nlocals in let saved_env = f.f_env in let saved_decls = f.f_declared in List.iter (emit_stmt p f v) body; (* scope end: the owner table's DROPs first, then the @gc releases for the handles this block declared — owner.ml's own pop_scope order *) emit_scope_drops p f v ~node ~label; emit_rc p f v ~node ~acquire:false ~groups:(declared_since f saved_decls) (); f.f_nlocals <- saved_locals; f.f_env <- saved_env; f.f_declared <- saved_decls; f.f_temp <- saved_locals and emit_if (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (cond : Ast.expr) (then_body : Ast.stmt list) (else_body : (Ast.pos * Ast.stmt list) option) : unit = let t = alloc_temp p f s.s_pos in emit_expr p f v ~dst:t cond; f.f_cur_line <- s.s_pos.line; let jz = here f in put f (ins_asbx op_jz t 0); let entry_owned = f.f_owned and entry_gc = f.f_gc in let div0 = f.f_div in emit_block p f v ~node:s.s_id ~label:"THEN" then_body; emit_join_drops p f v ~node:s.s_id ~label:"THEN"; let has_else_code = (match else_body with Some (_, b) -> b <> [] | None -> false) || Hashtbl.mem v.v_join (s.s_id, "ELSE") || Hashtbl.mem v.v_scope (s.s_id, "ELSE") in (* the jump over the else arm still belongs to the then path, so it is emitted before the mask goes back to the branch point *) let jmp = if has_else_code then Some (here f) else None in (match jmp with None -> () | Some _ -> put f (ins_asbx op_jmp 0 0)); let then_owned = f.f_owned and then_gc = f.f_gc in let div_then = f.f_div in f.f_owned <- entry_owned; f.f_gc <- entry_gc; f.f_div <- div0; patch_jump p f ~file:f.f_file ~pos:s.s_pos jz (here f); (match jmp with | None -> () | Some jmp -> (match else_body with | Some (epos, b) -> f.f_cur_line <- epos.line; emit_block p f v ~node:s.s_id ~label:"ELSE" b | None -> emit_scope_drops p f v ~node:s.s_id ~label:"ELSE"); emit_join_drops p f v ~node:s.s_id ~label:"ELSE"; patch_jump p f ~file:f.f_file ~pos:s.s_pos jmp (here f)); let div_else = f.f_div in if div_then && div_else then f.f_div <- true else if div_then then f.f_div <- div0 (* the else arm's state stands *) else begin f.f_div <- div0; if not div_else then mask_meet f then_owned then_gc else begin f.f_owned <- then_owned; f.f_gc <- then_gc end end and emit_while (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (cond : Ast.expr) (body : Ast.stmt list) : unit = let top = here f in let entry_owned = f.f_owned and entry_gc = f.f_gc in let div0 = f.f_div in let t = alloc_temp p f s.s_pos in emit_expr p f v ~dst:t cond; f.f_cur_line <- s.s_pos.line; let jz = here f in put f (ins_asbx op_jz t 0); let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in f.f_loops <- lf :: f.f_loops; emit_block p f v ~node:s.s_id ~label:"WHILE" body; f.f_loops <- List.tl f.f_loops; f.f_cur_line <- s.s_pos.line; let back = here f in put f (ins_asbx op_jmp 0 0); patch_jump p f ~file:f.f_file ~pos:s.s_pos back top; (* haxe-parity Task 2: `continue` re-enters at the condition check — `top`, the exact pc the back-edge above already jumps to; never a second copy of the condition. *) List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:s.s_pos pc top) lf.lf_continues; let exit_pc = here f in patch_jump p f ~file:f.f_file ~pos:s.s_pos jz exit_pc; (* `break` exits to the exact same place the condition's own JZ does. *) List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:s.s_pos pc exit_pc) lf.lf_breaks; (* a loop may run zero times, so the exit state always includes the entry state; a body that returned/broke/continued contributes nothing. Disclosed, secondary-mechanism approximation (haxe-parity Task 2): a `break`'s own mask at the moment it jumped is not separately folded into this meet — f_owned/f_gc feed only the per-pc trap-unwind table (this file's own `put`, above), never an emission decision (every DROP/RC instruction break/continue itself needs is already emitted at emit_break/emit_continue's own site, from the owner table, unconditionally) — so the only thing this could under/over-track is which registers a *trap during the narrow window right after this loop* would additionally destroy, not whether break/continue's own owned value is dropped at all. *) if f.f_div then begin f.f_owned <- entry_owned; f.f_gc <- entry_gc end else mask_meet f entry_owned entry_gc; f.f_div <- div0 (* `for it in c` over a `multi`: the container, cursor index and length are loop-carried, so they are locals of the loop's own scope (below every statement temporary), and the container/index pair is adjacent because BUILTIN's argument window is consecutive. A `map` has no key-enumeration builtin in v1, so iterating one is WO-E403 rather than invented bytecode. *) and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string) (iter : Ast.expr) (body : Ast.stmt list) : unit = match ty_of_expr p f iter with | Some t when (match unwrap t with Multi _ -> true | _ -> false) -> let elem = match unwrap t with Multi e -> Scalar e | other -> other in let saved_locals = f.f_nlocals in let saved_env = f.f_env in let saved_decls = f.f_declared in let div0 = f.f_div in let rc = alloc_local p f s.s_pos in let ri = alloc_local p f s.s_pos in let rn = alloc_local p f s.s_pos in let rv = alloc_local p f s.s_pos in f.f_temp <- f.f_nlocals; emit_expr p f v ~dst:rc iter; f.f_cur_line <- s.s_pos.line; put f (ins_abc op_builtin rn rc b_count); put f (ins_abx op_loadk ri (check_bx p f s.s_pos "constant" (const_int p 0))); f.f_env <- (var, (rv, elem)) :: f.f_env; Hashtbl.replace f.f_decl s.s_id rv; f.f_declared <- s.s_id :: f.f_declared; let top = here f in let entry_owned = f.f_owned and entry_gc = f.f_gc in f.f_temp <- f.f_nlocals; let tc = alloc_temp p f s.s_pos in put f (ins_abc op_lt tc ri rn); let jz = here f in put f (ins_asbx op_jz tc 0); put f (ins_abc op_builtin rv rc b_multi_get); let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in f.f_loops <- lf :: f.f_loops; List.iter (emit_stmt p f v) body; f.f_loops <- List.tl f.f_loops; emit_scope_drops p f v ~node:s.s_id ~label:"FOR"; emit_rc p f v ~node:s.s_id ~acquire:false ~groups:(declared_since f saved_decls) (); (* haxe-parity Task 2: `continue` re-enters right here — after this iteration's own scope-end cleanup (a `continue` already ran the equivalent of it at its own site, from v_continue — see emit_continue — so landing after the *normal* cleanup above never double-drops), and before the increment, so the next iteration still advances. *) let continue_target = here f in List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:s.s_pos pc continue_target) lf.lf_continues; f.f_temp <- f.f_nlocals; f.f_cur_line <- s.s_pos.line; let one = alloc_temp p f s.s_pos in put f (ins_abx op_loadk one (check_bx p f s.s_pos "constant" (const_int p 1))); put f (ins_abc op_add ri ri one); let back = here f in put f (ins_asbx op_jmp 0 0); patch_jump p f ~file:f.f_file ~pos:s.s_pos back top; let exit_pc = here f in patch_jump p f ~file:f.f_file ~pos:s.s_pos jz exit_pc; List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:s.s_pos pc exit_pc) lf.lf_breaks; if f.f_div then begin f.f_owned <- entry_owned; f.f_gc <- entry_gc end else mask_meet f entry_owned entry_gc; f.f_div <- div0; f.f_nlocals <- saved_locals; f.f_env <- saved_env; f.f_declared <- saved_decls; f.f_temp <- saved_locals | _ -> err p ~code:cannot_lower_code ~file:f.f_file ~pos:s.s_pos ~message: "`for` can only iterate a `multi` — the v1 builtins expose no key enumeration for a `map`" (* haxe-parity Task 2: `do { body } while cond` — body first, condition after, otherwise the exact same JZ/JMP shape `while` uses (no new opcode). `continue` re-enters at the condition check (the one point every iteration passes through, whichever way it got there); `break` exits to the same place the condition's own JZ does. Unlike while/for, the body always runs at least once — there is no zero-iteration path to merge the exit mask with, so (unlike emit_while/emit_for) the non-diverged case simply keeps whatever mask the condition check left, no `mask_meet` needed. The `f.f_div`/entry-restore branch below is kept anyway, for consistency with owner.ml's `fixpoint` (shared, unmodified, across all three loop shapes, and always resets `diverged` to its pre-loop value) — a disclosed, documented approximation for the one shape neither pass chases precisely: a body that unconditionally returns/breaks on every path, making the condition dead code. No fixture in this task has that shape. *) and emit_do_while (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (body : Ast.stmt list) (cond : Ast.expr) : unit = let top = here f in let entry_owned = f.f_owned and entry_gc = f.f_gc in let div0 = f.f_div in let lf = { lf_node = s.s_id; lf_breaks = []; lf_continues = [] } in f.f_loops <- lf :: f.f_loops; emit_block p f v ~node:s.s_id ~label:"DO" body; f.f_loops <- List.tl f.f_loops; let cond_pc = here f in List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:s.s_pos pc cond_pc) lf.lf_continues; let t = alloc_temp p f s.s_pos in emit_expr p f v ~dst:t cond; f.f_cur_line <- s.s_pos.line; let jz = here f in put f (ins_asbx op_jz t 0); let back = here f in put f (ins_asbx op_jmp 0 0); patch_jump p f ~file:f.f_file ~pos:s.s_pos back top; let exit_pc = here f in patch_jump p f ~file:f.f_file ~pos:s.s_pos jz exit_pc; List.iter (fun pc -> patch_jump p f ~file:f.f_file ~pos:s.s_pos pc exit_pc) lf.lf_breaks; if f.f_div then begin f.f_owned <- entry_owned; f.f_gc <- entry_gc end else mask_meet f entry_owned entry_gc; f.f_div <- div0 (* ============================================================ One method ============================================================ *) let emit_method (p : pctx) (v : views) ~(file : string) ~(self_class : (int * string) option) (m : Ast.method_decl) (rec_ : methrec) : unit = let f = { f_file = file; f_fn = m.name; f_code = code_create (); f_cur_line = m.pos.line; f_line = -1; f_lines = []; f_owned = 0L; f_gc = 0L; f_last_owned = 0L; f_last_gc = 0L; f_drops = []; f_nlocals = 0; f_temp = 0; f_max = 0; f_env = []; f_decl = Hashtbl.create 16; f_node = Hashtbl.create 64; f_kind = Hashtbl.create 16; f_declared = []; f_div = false; f_maxjmp = 0; f_over = false; f_loops = [] } in (match self_class with | None -> () | Some (_, cn) -> let r = alloc_local p f m.pos in f.f_env <- ("self", (r, Scalar cn)) :: f.f_env; Hashtbl.replace f.f_decl m.id r; f.f_declared <- m.id :: f.f_declared); List.iter (fun (pa : Ast.param) -> let r = alloc_local p f pa.pos in f.f_env <- (pa.name, (r, pa.ty)) :: f.f_env; Hashtbl.replace f.f_decl pa.id r; f.f_declared <- pa.id :: f.f_declared; match Hashtbl.find_opt v.v_holder pa.id with | Some kind -> mask_set f kind r | None -> ( (* The one holder the tables cannot always name: a `take` parameter holds from the first instruction, but if it is moved on before the function's first call site no LIVE-MASK entry ever mentions it (the owner pass records masks only at call / DB_STUB sites) and a trap in between would leak it. The kind comes from types.ml's own field-kind mapping, which agrees with owner.ml's parameter rule by construction: OWNED/MULTI/ MAP hold as owned values, GCREF as a counted handle, and SCALAR/TEXT are copies that never drop. *) match pa.conv with | Borrow | Mut -> () | Take -> ( match field_kind p pa.ty with | 1 | 4 | 5 -> mask_set f Owner.LOwned r | 2 -> mask_set f Owner.LGc r | _ -> ()))) m.params; List.iter (emit_stmt p f v) m.body; stmt_reset f; f.f_cur_line <- m.pos.line; emit_scope_drops p f v ~node:m.id ~label:"BODY"; emit_rc p f v ~node:m.id ~acquire:false ~groups:(declared_since f []) (); (* the terminator rule: the loader rejects a method whose last instruction is not one, and the implicit void return is what control falling off the end means *) let need_ret = f.f_code.n = 0 || f.f_maxjmp >= f.f_code.n || let last = f.f_code.a.(f.f_code.n - 1) land 0xFF in not (last = op_ret || last = op_ret0) in if need_ret then put f (ins_abc op_ret0 0 0 0); rec_.mr_regc <- max 1 (f.f_max + 1); if rec_.mr_regc > max_regs then begin over_budget p f m.pos; rec_.mr_regc <- max_regs end; rec_.mr_code <- Array.sub f.f_code.a 0 f.f_code.n; rec_.mr_lines <- List.rev f.f_lines; rec_.mr_drops <- List.rev f.f_drops (* ============================================================ Program assembly ============================================================ *) (* haxe-parity Task 5: does this program catch anywhere? Only then does the `Error` record earn a class-table entry — so no image that never writes `try` gains a class it does not use. *) let program_uses_try (prog : Ast.program) : bool = let found = ref false in Types.walk_program (fun _ (e : Ast.expr) -> match e.Ast.kind with Ast.Try _ -> found := true | _ -> ()) prog; !found (* Structural satisfaction, Go-style (spec section 2): a class satisfies an interface when it has a method of the same name and parameter count for every method the interface declares. There is no `implements` keyword by doctrine, so this is the whole rule — and it is also why the satisfying set is computed here: types.ml declares WO-E205 but, per the error catalog, never raises it (the check has no legal call site in the milestone grammar). *) let satisfies (p : pctx) (cid : int) (ir : ifacerec) : int list option = let cr = p.p_classes.(cid) in let rec go acc = function | [] -> Some (List.rev acc) | (mname, nparams) :: tl -> ( if not (List.mem mname cr.cr_methods) then None else match class_method p cr.cr_name mname with (* A `static fn` has no receiver to dispatch on, so it can never satisfy an interface method however well its name and arity line up (haxe-parity Task 7). *) | Some mi when mi.Types.is_static -> None | Some mi when List.length mi.Types.params = nparams -> ( match SM.find_opt (cr.cr_name ^ "." ^ mname) p.p_method_id with | Some midx -> go (midx :: acc) tl | None -> None) | _ -> None) in go [] ir.ir_methods let emit ~(syms : Types.symbols) ~(module_of : string -> string) ~(module_syms : (string, Types.symbols) Hashtbl.t) (coll : Diag.Collector.t) (units : input list) : string = let colliding = compute_colliding_fn_names ~module_of units in (* ---- pass 1: declarations, in discovery then declaration order ---- *) let classes = ref [] and class_id = ref SM.empty and nclasses = ref 0 in let ifaces = ref [] and iface_id = ref SM.empty and nifaces = ref 0 and nslots = ref 0 in let methods = ref [] and method_id = ref SM.empty and nmethods = ref 0 in let entry = ref wob_none in (* (file, self class option, method_decl, unit) in method-table order *) let bodies = ref [] in (* haxe-parity Task 4: typedef records are STRUCTURAL — two records with the same shape share ONE class-table entry, keyed by this rendering of the ordered field list. Defaults are part of the key deliberately: aliases whose defaults differ get their own entries (identical layout either way, so interchangeability is unaffected — class ids only decide layout and drop plan), because sharing one entry would make emit_ctor's default-filling read whichever alias registered first. types.ml's typ_equal compares fields only — strictly wider than this key, and safe for exactly that layout reason. *) let record_shape : (string, int) Hashtbl.t = Hashtbl.create 8 in let record_shape_key (c : Ast.class_decl) : string = String.concat ";" (List.map (fun (fl : Ast.field) -> let dflt = match fl.default with | None -> "" | Some Ast.DefaultNow -> "=now()" | Some (Ast.DefaultOpaque toks) -> "=" ^ String.concat " " (List.map (fun (t : Token.t) -> Dump.kind_label t.Token.kind) toks) in fl.name ^ ":" ^ Dump.field_ty_str fl.ty ^ dflt) c.fields) in List.iter (fun u -> List.iter (function | Ast.Class (c : Ast.class_decl) -> if not (SM.mem c.name !class_id) then begin let shape = if c.is_record then Some (record_shape_key c) else None in let alias_of = match shape with Some key -> Hashtbl.find_opt record_shape key | None -> None in match alias_of with | Some cid -> (* structural alias: this name maps onto the shape's existing entry; no new clsrec *) class_id := SM.add c.name cid !class_id | None -> let cid = !nclasses in class_id := SM.add c.name cid !class_id; incr nclasses; (match shape with | Some key -> Hashtbl.replace record_shape key cid | None -> ()); classes := { cr_name = c.name; cr_gc = c.is_gc; cr_fields = Array.of_list (List.map (fun (fl : Ast.field) -> (fl.name, fl.ty)) c.fields); cr_methods = List.map (fun (m : Ast.method_decl) -> m.name) c.methods } :: !classes end | Ast.Union (ud : Ast.union_decl) -> (* haxe-parity Task 4: a payload union gets one compiler- generated class entry PER VARIANT (bare variants of the same union included — a uniform heap representation is what lets one register hold "any variant of this union"); the entry's id is the variant's runtime tag, carried by the object header's own class_id. An all-bare union gets nothing here at all: its values are plain ordinals. *) if List.exists (fun (vd : Ast.variant_decl) -> vd.Ast.v_fields <> []) ud.variants then List.iter (fun (vd : Ast.variant_decl) -> let key = ud.name ^ "." ^ vd.Ast.v_name in if not (SM.mem key !class_id) then begin let cid = !nclasses in class_id := SM.add key cid !class_id; incr nclasses; classes := { cr_name = key; cr_gc = false; cr_fields = Array.of_list vd.Ast.v_fields; cr_methods = [] } :: !classes end) ud.variants | Ast.Interface (i : Ast.interface_decl) -> (* an interface with no methods gets no slots and no rows: the loader rejects a zero-method interface entry, and no ICALL could ever name one *) if i.methods <> [] && not (SM.mem i.name !iface_id) then begin let iid = !nifaces in iface_id := SM.add i.name iid !iface_id; incr nifaces; ifaces := { ir_name = i.name; ir_slot_base = !nslots; ir_methods = List.map (fun (s : Ast.method_sig) -> (s.name, List.length s.params)) i.methods } :: !ifaces; nslots := !nslots + List.length i.methods end | Ast.Fn _ -> () | Ast.Use _ -> () | Ast.Const _ -> ()) u.prog.decls) units; (* haxe-parity Task 5: the record `catch (e)` binds needs a real class-table entry (it is an ordinary heap object with two owned Texts, so the drop plan is the ordinary one). Added only for a program that actually catches — every existing image keeps its exact class table — and only when nothing already claims the name. Its field order is Types.error_record_fields, which is the same order the VM's WO_B_ERR_FILL builtin writes. *) if (not (SM.mem Types.error_record_name !class_id)) && List.exists (fun u -> program_uses_try u.prog) units then begin let cid = !nclasses in class_id := SM.add Types.error_record_name cid !class_id; incr nclasses; classes := { cr_name = Types.error_record_name; cr_gc = false; cr_fields = Array.of_list Types.error_record_fields; cr_methods = [] } :: !classes end; let class_id = !class_id in let p_classes = Array.of_list (List.rev !classes) in let p_ifaces = Array.of_list (List.rev !ifaces) in List.iter (fun u -> List.iter (function | Ast.Class (c : Ast.class_decl) -> let cid = SM.find c.name class_id in List.iter (fun (m : Ast.method_decl) -> let key = c.name ^ "." ^ m.name in if not (SM.mem key !method_id) then begin method_id := SM.add key !nmethods !method_id; (* haxe-parity Task 7: a `static fn` has no receiver, so its window holds parameters only and its body binds no `self` (self_class = None below) — otherwise it is an ordinary method record, keyed `Class.name` like any other. *) methods := { mr_name = m.name; mr_class = Some cid; mr_argc = (if m.is_static then 0 else 1) + List.length m.params; mr_regc = 1; mr_code = [||]; mr_lines = []; mr_drops = [] } :: !methods; bodies := (u, (if m.is_static then None else Some (cid, c.name)), m, !nmethods) :: !bodies; incr nmethods end) c.methods | Ast.Interface _ -> () | Ast.Fn (m : Ast.method_decl) -> let key = free_fn_key colliding (module_of u.file) m.name in if not (SM.mem key !method_id) then begin method_id := SM.add key !nmethods !method_id; methods := { mr_name = m.name; mr_class = None; mr_argc = List.length m.params; mr_regc = 1; mr_code = [||]; mr_lines = []; mr_drops = [] } :: !methods; bodies := (u, None, m, !nmethods) :: !bodies; (* the entry point is the zero-argument free fn `main` — the loader's own rule for an entry (a zero-arg free fn) plus one fixed name so `wovm image.wob` needs no flag *) if m.name = "main" && m.params = [] then begin entry := !nmethods; (match m.ret with | Some ty when ty <> Ast.Scalar "Int" -> Diag.Collector.add coll (Diag.error ~code:entry_return_code ~file:u.file ~line:m.pos.line ~col:m.pos.col ~message: (Printf.sprintf "entry `main` declares return type `%s` — the entry's return \ value is the process exit code, so it must return `Int`" (Dump.field_ty_str ty)) ()) | Some _ | None -> ()) end; incr nmethods end | Ast.Use _ -> () | Ast.Const _ -> () | Ast.Union _ -> () (* haxe-parity Task 4: no methods to emit *)) u.prog.decls) units; let p_methods = Array.of_list (List.rev !methods) in let p_uses : (string, Types.use_edge list) Hashtbl.t = Hashtbl.create 8 in List.iter (fun u -> Hashtbl.replace p_uses u.file (Types.uses_of_program u.prog)) units; let p = { p_syms = syms; p_coll = coll; p_classes; p_class_id = class_id; p_ifaces; p_iface_id = !iface_id; p_method_id = !method_id; p_methods; p_uses; p_module_syms = module_syms; p_module_of = module_of; p_colliding = colliding; p_kints = Hashtbl.create 32; p_ktexts = Hashtbl.create 32; p_consts = []; p_nconsts = 0 } in (* names are constants; interning them first keeps the pool's low indexes stable and readable in a disassembly *) let class_name_k = Array.map (fun c -> const_text p c.cr_name) p_classes in let iface_name_k = Array.map (fun i -> const_text p i.ir_name) p_ifaces in let method_name_k = Array.map (fun m -> const_text p m.mr_name) p_methods in (* ---- pass 2: method bodies ---- *) let views_of = Hashtbl.create 8 in List.iter (fun u -> Hashtbl.replace views_of u.file (build_views u.tables)) units; List.iter (fun (u, self_class, m, idx) -> let v = Hashtbl.find views_of u.file in emit_method p v ~file:u.file ~self_class m p_methods.(idx)) (List.rev !bodies); (* The residual table is the ONLY licence to emit a borrow op, and it is also an obligation: every region in it must end up wrapped. The regions are anchored on several different node kinds (a call expression, an assignment statement, a moved place), so a lowering that forgets one would ship the aliasing check silently disabled — the exact hazard the site exists for. Anything unconsumed is WO-E404 here, reported at the region's own position. *) List.iter (fun u -> let v = Hashtbl.find views_of u.file in let leftover = Hashtbl.fold (fun node (pos, _) acc -> if Hashtbl.mem v.v_res_used node then acc else (pos, node) :: acc) v.v_res [] in List.iter (fun ((pos : Ast.pos), _) -> err p ~code:unguardable_code ~file:u.file ~pos ~message: "residual borrow site was never wrapped in runtime guards — the emitter has no \ lowering for this region, and leaving it unguarded would disable the aliasing \ check it exists for") (List.sort (fun ((a : Ast.pos), _) ((b : Ast.pos), _) -> compare (a.line, a.col) (b.line, b.col)) leftover)) units; (* ---- pass 3: vtable rows ---- *) let rows = ref [] in Array.iteri (fun cid _ -> Array.iteri (fun iid ir -> match satisfies p cid ir with | Some ms -> rows := (cid, iid, ms) :: !rows | None -> ()) p_ifaces) p_classes; let rows = List.rev !rows in (* ---- pass 4: serialize ---- *) let consts = Buf.create () in List.iter (fun c -> match c with | `Int n -> Buf.u8 consts k_int; Buf.i64 consts (Int64.of_int n) | `Text s -> Buf.u8 consts k_text; Buf.u32 consts (String.length s); Buf.str consts s) (List.rev p.p_consts); let cls = Buf.create () in Array.iteri (fun cid (c : clsrec) -> Buf.u32 cls class_name_k.(cid); Buf.u32 cls (if c.cr_gc then classf_gc else 0); Buf.u32 cls (Array.length c.cr_fields); Array.iter (fun (_, ty) -> Buf.u8 cls (field_kind p ty)) c.cr_fields; let pad = (4 - (Array.length c.cr_fields mod 4)) mod 4 in for _ = 1 to pad do Buf.u8 cls 0 done) p_classes; let ifs = Buf.create () in Array.iteri (fun iid (i : ifacerec) -> Buf.u32 ifs iface_name_k.(iid); Buf.u32 ifs (List.length i.ir_methods)) p_ifaces; Buf.u32 ifs (List.length rows); List.iter (fun (cid, iid, ms) -> Buf.u32 ifs cid; Buf.u32 ifs iid; List.iter (fun m -> Buf.u32 ifs m) ms) rows; let mth = Buf.create () in Array.iteri (fun idx (m : methrec) -> Buf.u32 mth method_name_k.(idx); Buf.u32 mth (match m.mr_class with Some c -> c | None -> wob_none); Buf.u8 mth m.mr_argc; Buf.u8 mth m.mr_regc; Buf.u16 mth 0; Buf.u32 mth (Array.length m.mr_code * 4); Array.iter (fun i -> Buf.u32 mth i) m.mr_code; Buf.u32 mth (List.length m.mr_lines); List.iter (fun (pc, line) -> Buf.u32 mth pc; Buf.u32 mth line) m.mr_lines; Buf.u32 mth (List.length m.mr_drops); List.iter (fun (pc, owned, gc) -> Buf.u32 mth pc; Buf.i64 mth owned; Buf.i64 mth gc) m.mr_drops) p_methods; let out = Buf.create () in let off = ref wob_hdr_size in Buf.u32 out wob_magic; Buf.u32 out wob_version; Buf.u32 out !off; Buf.u32 out p.p_nconsts; off := !off + consts.Buf.len; Buf.u32 out !off; Buf.u32 out (Array.length p_classes); off := !off + cls.Buf.len; Buf.u32 out !off; Buf.u32 out (Array.length p_ifaces); off := !off + ifs.Buf.len; Buf.u32 out !off; Buf.u32 out (Array.length p_methods); Buf.u32 out !entry; Buf.str out (Buf.contents consts); Buf.str out (Buf.contents cls); Buf.str out (Buf.contents ifs); Buf.str out (Buf.contents mth); Buf.contents out