From 10b5146e84dab02767d1ef18118b93c9fda92b4c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 17 Jul 2026 01:02:23 +0200 Subject: [PATCH 1/4] fix(wasm): three codegen divergences from the native backend Found porting a full 3D game (Bloom shooter) to --target web; each was invisible on native and produced maddeningly partial failures on wasm. 1. `new Array(n)` fell through to the generic class_new path and built a plain object: element writes landed as properties, but Array.isArray was false so `.length` read 0 forever. New "Array" case in the New emitter routes no-args to array_new, one arg to a new array_constructor_single bridge (ES2015 22.1.1: single number = length after validation, anything else = one element), and >=2 args to the array-literal lowering. 2. Namespace imports (`import * as W from "./mod"`) resolved to nothing: every `W.member` read undefined, and `W.fn(args)` fell to the class-dispatch fallback with an undefined receiver -- returning undefined WITHOUT executing fn. Named imports of the same symbols worked, so a program could load a world yet see every count as undefined. Member reads now resolve through dotted-key entries in imported_var_globals (same promoted-let globals the Named arm uses); member calls take a direct-call fast path via the new imported_ns_funcs map; members used as values wrap in a zero-capture closure, mirroring ExternFuncRef. 3. JS bitwise ops emitted trapping i32.trunc_f64_s, so `NaN | 0` -- which the spec defines as 0 (ToInt32) -- crashed the module with "float unrepresentable in integer range". Now i64.trunc_sat_f64_s + i32.wrap_i64: exact ToInt32 semantics (NaN->0, modular wrap) across the whole i64 range. nontrapping-fptoint is baseline WebAssembly in every browser since 2020. --- crates/perry-codegen-wasm/src/emit/binary.rs | 6 +- crates/perry-codegen-wasm/src/emit/compile.rs | 72 +++++++++++++++++++ .../perry-codegen-wasm/src/emit/expr/calls.rs | 31 ++++++++ .../src/emit/expr/classes.rs | 22 ++++++ .../src/emit/expr/literals_vars.rs | 3 +- .../src/emit/expr/objects.rs | 34 +++++++++ .../src/emit/module_emitter.rs | 8 +++ .../src/emit/string_collection.rs | 1 + crates/perry-codegen-wasm/src/wasm_runtime.js | 25 +++++++ 9 files changed, 199 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen-wasm/src/emit/binary.rs b/crates/perry-codegen-wasm/src/emit/binary.rs index 5dbe13ecbc..918c132be7 100644 --- a/crates/perry-codegen-wasm/src/emit/binary.rs +++ b/crates/perry-codegen-wasm/src/emit/binary.rs @@ -16,10 +16,12 @@ impl<'a> FuncEmitCtx<'a> { ) { self.emit_expr(func, left); func.instruction(&Instruction::F64ReinterpretI64); - func.instruction(&Instruction::I32TruncF64S); + func.instruction(&Instruction::I64TruncSatF64S); + func.instruction(&Instruction::I32WrapI64); self.emit_expr(func, right); func.instruction(&Instruction::F64ReinterpretI64); - func.instruction(&Instruction::I32TruncF64S); + func.instruction(&Instruction::I64TruncSatF64S); + func.instruction(&Instruction::I32WrapI64); func.instruction(&op); func.instruction(&Instruction::F64ConvertI32S); func.instruction(&Instruction::I64ReinterpretF64); diff --git a/crates/perry-codegen-wasm/src/emit/compile.rs b/crates/perry-codegen-wasm/src/emit/compile.rs index 8555a88601..e511b505d3 100644 --- a/crates/perry-codegen-wasm/src/emit/compile.rs +++ b/crates/perry-codegen-wasm/src/emit/compile.rs @@ -931,6 +931,78 @@ impl WasmModuleEmitter { .insert((consumer_idx, local.clone()), gidx); } } + // Namespace import (`import * as W from "./mod"`): + // register every exported module-level let under a + // DOTTED key ("W.MESH_COUNT"), so PropertyGet on the + // namespace ident resolves to the source module's + // promoted-let global — the same mechanism the Named + // arm above uses. Without this, every `W.member` read + // emitted a class_get_field on an undefined receiver + // and produced undefined (functions kept working via + // the whole-program name map, which made the failure + // maddeningly partial). + if let perry_hir::ir::ImportSpecifier::Namespace { local } = spec { + let src_module = &modules[src_idx].1; + for export in &src_module.exports { + if let perry_hir::ir::Export::Named { + local: src_local, + exported, + } = export + { + if let Some(&gidx) = src_lets.get(src_local.as_str()) { + self.imported_var_globals.insert( + (consumer_idx, format!("{}.{}", local, exported)), + gidx, + ); + } + } + } + // Fall-through parity with the Named arm: exports + // registered out-of-band still have their let — + // expose those by name unless an Export::Named + // already claimed the key. + for (name, &gidx) in src_lets.iter() { + self.imported_var_globals + .entry((consumer_idx, format!("{}.{}", local, name))) + .or_insert(gidx); + } + // Exported FUNCTIONS: `W.fn(args)` must call the + // source module's compiled function, `W.fn` as a + // value must wrap it in a closure. Register both + // export shapes: the exported_functions list + // (`export function fn`) and Export::Named + // clauses (`function fn() {}; export { fn }`). + let src_fm = &self.module_func_maps[src_idx]; + for (exp_name, fid) in &src_module.exported_functions { + if let Some(&fidx) = src_fm.get(fid) { + self.imported_ns_funcs.insert( + (consumer_idx, format!("{}.{}", local, exp_name)), + fidx, + ); + } + } + for export in &src_module.exports { + if let perry_hir::ir::Export::Named { + local: src_local, + exported, + } = export + { + for f in &src_module.functions { + if &f.name == src_local { + if let Some(&fidx) = src_fm.get(&f.id) { + self.imported_ns_funcs + .entry(( + consumer_idx, + format!("{}.{}", local, exported), + )) + .or_insert(fidx); + } + break; + } + } + } + } + } } } } diff --git a/crates/perry-codegen-wasm/src/emit/expr/calls.rs b/crates/perry-codegen-wasm/src/emit/expr/calls.rs index 49e8aacb96..e2f3a4bb58 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/calls.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/calls.rs @@ -11,6 +11,37 @@ impl<'a> FuncEmitCtx<'a> { Expr::Call { callee, args, .. } => { // Check for method call patterns: obj.method(args) if let Expr::PropertyGet { object, property } = callee.as_ref() { + // Namespace-import member call (`import * as W from "./mod"; + // W.fn(args)`): resolve to a DIRECT wasm call of the source + // module's function — the same lowering `fn(args)` gets via + // a named import. Without this the callee fell through to + // the class-dispatch fallback with an undefined receiver + // and silently returned undefined (never executing fn). + if let Expr::ExternFuncRef { name, .. } = object.as_ref() { + let key = ( + self.emitter.current_mod_idx, + format!("{}.{}", name, property), + ); + if let Some(&idx) = self.emitter.imported_ns_funcs.get(&key).copied().as_ref() { + for arg in args { + self.emit_expr(func, arg); + } + // Pad-up / drop-excess — see the FuncRef arm below (#183). + if let Some(&expected) = self.emitter.func_param_counts.get(&idx) { + for _ in args.len()..expected { + func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); + } + for _ in expected..args.len() { + func.instruction(&Instruction::Drop); + } + } + func.instruction(&Instruction::Call(idx)); + if self.emitter.void_funcs.contains(&idx) { + func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); + } + return true; + } + } // console.log/warn/error if let Expr::GlobalGet(_) = object.as_ref() { match property.as_str() { diff --git a/crates/perry-codegen-wasm/src/emit/expr/classes.rs b/crates/perry-codegen-wasm/src/emit/expr/classes.rs index 09bc41b41a..725fc0e6df 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/classes.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/classes.rs @@ -193,6 +193,28 @@ impl<'a> FuncEmitCtx<'a> { self.emit_memcall(func, "date_new", 1); return true; } + // `new Array()` / `new Array(n)` / `new Array(a, b, ...)`. + // Without this case the constructor fell through to the + // generic `class_new` path, which allocates a plain object + // — element writes landed as properties but Array.isArray + // was false, so `.length` read 0 forever. Mirrors the + // native builtin (perry-codegen lower_call/builtin.rs): + // no args → empty; one arg → runtime type check (number = + // length, ES2015 §22.1.1); ≥2 args → element-list form, + // identical to the array literal. + "Array" => { + if args.is_empty() { + self.emit_frame_begin(func, 0); + self.emit_memcall(func, "array_new", 0); + } else if args.len() == 1 { + self.emit_frame_begin(func, 1); + self.emit_store_arg(func, 0, &args[0]); + self.emit_memcall(func, "array_constructor_single", 1); + } else { + self.emit_expr(func, &Expr::Array(args.clone())); + } + return true; + } "Map" => { self.emit_frame_begin(func, 0); self.emit_memcall(func, "map_new", 0); diff --git a/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs b/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs index 9c341090f4..2d6d803ed7 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs @@ -383,7 +383,8 @@ impl<'a> FuncEmitCtx<'a> { UnaryOp::BitNot => { // ~x: convert i64 to f64, truncate to i32, bitwise not, convert back to i64 func.instruction(&Instruction::F64ReinterpretI64); - func.instruction(&Instruction::I32TruncF64S); + func.instruction(&Instruction::I64TruncSatF64S); + func.instruction(&Instruction::I32WrapI64); func.instruction(&Instruction::I32Const(-1)); func.instruction(&Instruction::I32Xor); func.instruction(&Instruction::F64ConvertI32S); diff --git a/crates/perry-codegen-wasm/src/emit/expr/objects.rs b/crates/perry-codegen-wasm/src/emit/expr/objects.rs index 60d1edb3e4..961fcaec21 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/objects.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/objects.rs @@ -106,6 +106,40 @@ impl<'a> FuncEmitCtx<'a> { } Expr::PropertyGet { object, property } => { + // Namespace-import member read (`import * as W; W.MEMBER`): + // the object lowers to ExternFuncRef("W"), which as a value is + // undefined — resolve the member against the source module's + // promoted-let global instead (registered under the dotted key + // in compile.rs). Must run before every other special case, + // including .length: `W.length` is a module member here, not + // a string/array length. + if let Expr::ExternFuncRef { name, .. } = object.as_ref() { + let key = ( + self.emitter.current_mod_idx, + format!("{}.{}", name, property), + ); + if let Some(&gidx) = self.emitter.imported_var_globals.get(&key) { + func.instruction(&Instruction::GlobalGet(gidx)); + return true; + } + // Member is an exported FUNCTION used as a value + // (`const f = W.fn`): wrap in a zero-capture closure, + // mirroring the ExternFuncRef value arm in classes.rs. + // (Direct calls take the fast path in calls.rs instead.) + if let Some(&func_idx) = self.emitter.imported_ns_funcs.get(&key) { + let table_idx = self + .emitter + .func_to_table_idx + .get(&func_idx) + .copied() + .unwrap_or(func_idx); + self.emit_frame_begin(func, 2); + self.emit_store_const(func, 0, table_idx as f64); + self.emit_store_const(func, 1, 0.0); + self.emit_memcall(func, "closure_new", 2); + return true; + } + } // Special case: .length uses string_len which handles both strings and arrays if property == "length" { self.emit_frame_begin(func, 1); diff --git a/crates/perry-codegen-wasm/src/emit/module_emitter.rs b/crates/perry-codegen-wasm/src/emit/module_emitter.rs index 883b5aab9f..045fbf2204 100644 --- a/crates/perry-codegen-wasm/src/emit/module_emitter.rs +++ b/crates/perry-codegen-wasm/src/emit/module_emitter.rs @@ -76,6 +76,13 @@ pub(super) struct WasmModuleEmitter { /// `GlobalGet(gidx)` reading the live module-let slot, matching the /// LLVM target's `perry_fn___()` getter path. pub(super) imported_var_globals: BTreeMap<(usize, String), u32>, + /// Namespace-import member FUNCTIONS: `(consumer_module_idx, "W.fn")` → + /// wasm function index. Companion to the dotted-key entries in + /// `imported_var_globals`: `import * as W from "./mod"` followed by + /// `W.fn(args)` resolves to a direct call (calls.rs), and `W.fn` as a + /// value to a zero-capture closure (objects.rs) — the same two shapes a + /// named import gets via ExternFuncRef. + pub(super) imported_ns_funcs: BTreeMap<(usize, String), u32>, } impl WasmModuleEmitter { @@ -108,6 +115,7 @@ impl WasmModuleEmitter { func_param_counts: BTreeMap::new(), async_js_code: Vec::new(), imported_var_globals: BTreeMap::new(), + imported_ns_funcs: BTreeMap::new(), } } diff --git a/crates/perry-codegen-wasm/src/emit/string_collection.rs b/crates/perry-codegen-wasm/src/emit/string_collection.rs index 68d4bd9fc6..0d2b206a40 100644 --- a/crates/perry-codegen-wasm/src/emit/string_collection.rs +++ b/crates/perry-codegen-wasm/src/emit/string_collection.rs @@ -55,6 +55,7 @@ impl WasmModuleEmitter { "object_has_property", "object_assign", "array_new", + "array_constructor_single", "array_push", "array_pop", "array_get", diff --git a/crates/perry-codegen-wasm/src/wasm_runtime.js b/crates/perry-codegen-wasm/src/wasm_runtime.js index 443e6a25f6..07485de56e 100644 --- a/crates/perry-codegen-wasm/src/wasm_runtime.js +++ b/crates/perry-codegen-wasm/src/wasm_runtime.js @@ -274,6 +274,21 @@ function buildImports() { array_new: () => nanboxPointer(allocHandle([])), + // `new Array(x)` — ES2015 §22.1.1: a single NUMBER argument is a + // length (must be a non-negative integer < 2^32), anything else is a + // one-element array. Mirrors js_array_constructor_single in the + // native runtime. + array_constructor_single: (value) => { + const v = toJsValue(value); + if (typeof v === 'number') { + if (!Number.isInteger(v) || v < 0 || v > 0xFFFFFFFF) { + throw new RangeError('Invalid array length'); + } + return nanboxPointer(allocHandle(new Array(v))); + } + return nanboxPointer(allocHandle([v])); + }, + // array_push(handle, value) -> handle (for chaining) array_push: (handle, value) => { const arr = getHandle(handle); @@ -1694,6 +1709,16 @@ const __memDispatch = { // Arrays — args are plain JS values (arr is the array itself, etc.) array_new: () => [], + // `new Array(x)`: single number = length (ES2015 §22.1.1), else element. + array_constructor_single: (value) => { + if (typeof value === 'number') { + if (!Number.isInteger(value) || value < 0 || value > 0xFFFFFFFF) { + throw new RangeError('Invalid array length'); + } + return new Array(value); + } + return [value]; + }, array_push: (arr, value) => { if (Array.isArray(arr)) arr.push(value); return arr; }, array_pop: (arr) => { if (!Array.isArray(arr) || arr.length === 0) return undefined; return arr.pop(); }, array_get: (arr, index) => { From 12e3f5fddd49232b95f06379e0f19b7383ea0e9e Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 17 Jul 2026 02:24:51 +0200 Subject: [PATCH 2/4] fix(wasm): namespace imports, re-export chains, per-consumer function resolution, ToInt32 bitwise Second batch from porting a full 3D game to --target web (first batch: new Array(n) / namespace member basics / trunc-sat groundwork). - Named imports now resolve through re-export chains: Export::Named whose local is itself an import binding, Export::ReExport, and export-* star chains, with directory specifiers ("./core") mapping to their index module and Windows path separators normalized. A library facade like bloom's index.ts re-exporting `Key` from core/keys.ts is three hops from the consumer; stopping at the first module made every re-exported const OBJECT read undefined while same-named scalars sometimes survived other paths - maddeningly partial failures. - Function calls/values resolve per-consumer (imported_func_indices, built from each module's own imports through the same chains) BEFORE the whole-program func_name_map. Bare names collide the moment two modules define the same function name - a serializer's local `vec3(v): string` captured the math library's `vec3(x,y,z)` for every caller in the program, which is why spawn positions read as NaN in a game whose world data was perfectly fine. func_name_map keeps exported-wins/or_insert ordering as the fallback. - Namespace member calls (`import * as W; W.fn(args)`) lower to a direct wasm call; `W.fn` as a value wraps in a zero-capture closure. Previously the callee fell to class-dispatch on an undefined receiver and silently returned undefined WITHOUT executing fn. - JS bitwise ops emit i64.trunc_sat_f64_s + i32.wrap_i64 instead of the trapping i32.trunc_f64_s: exact ToInt32 semantics (NaN -> 0, modular wrap) rather than "float unrepresentable in integer range" crashes on NaN. - PERRY_WASM_DEBUG_IMPORTS=1 dumps the per-import resolution table. --- crates/perry-codegen-wasm/src/emit/compile.rs | 175 ++++++++----- .../perry-codegen-wasm/src/emit/expr/calls.rs | 18 +- .../src/emit/expr/classes.rs | 10 +- crates/perry-codegen-wasm/src/emit/locals.rs | 243 ++++++++++++++++++ crates/perry-codegen-wasm/src/emit/mod.rs | 5 +- .../src/emit/module_emitter.rs | 8 + 6 files changed, 389 insertions(+), 70 deletions(-) diff --git a/crates/perry-codegen-wasm/src/emit/compile.rs b/crates/perry-codegen-wasm/src/emit/compile.rs index e511b505d3..8c1a4fa5be 100644 --- a/crates/perry-codegen-wasm/src/emit/compile.rs +++ b/crates/perry-codegen-wasm/src/emit/compile.rs @@ -688,6 +688,22 @@ impl WasmModuleEmitter { for &(fid, idx) in &per_module_async[mod_idx] { module_fm.insert(fid, idx); } + // Function names are NOT globally unique across modules (a + // serializer's local `function vec3(v): string` coexists with the + // math library's exported `vec3(x, y, z)`), but `func_name_map` — + // the ExternFuncRef cross-module resolution table — is keyed by + // bare name. Prefer EXPORTED functions (the only legitimate + // cross-module call targets); a module-local helper only claims a + // name nobody exported. + let exported_names: std::collections::HashSet<&str> = module + .exported_functions + .iter() + .map(|(n, _)| n.as_str()) + .chain(module.exports.iter().filter_map(|e| match e { + perry_hir::ir::Export::Named { local, .. } => Some(local.as_str()), + _ => None, + })) + .collect(); for func in &module.functions { if func.is_async { continue; // already registered as bridge import @@ -707,8 +723,16 @@ impl WasmModuleEmitter { self.void_funcs.insert(user_func_idx); } self.func_param_counts.insert(user_func_idx, param_count); - // Build func_name_map for ExternFuncRef resolution (name is globally unique) - self.func_name_map.insert(func.name.clone(), user_func_idx); + // Build func_name_map for ExternFuncRef resolution. Exported + // functions win the name; module-local helpers only fill a + // vacant slot (see exported_names above). + if exported_names.contains(func.name.as_str()) { + self.func_name_map.insert(func.name.clone(), user_func_idx); + } else { + self.func_name_map + .entry(func.name.clone()) + .or_insert(user_func_idx); + } user_func_idx += 1; } self.module_func_maps.push(module_fm); @@ -864,11 +888,13 @@ impl WasmModuleEmitter { // the driver) and `Module.name` is a relative-from-project-root path. // We compare paths by file-stem match against `Module.name` (which is // a leaf "name.ts" or "subdir/name.ts" string), falling back to a - // basename match. Re-exports (`Export::ReExport`) point at another - // module by `source`; we don't chase those here — a one-hop re-export - // is handled by the source's own exports list (the re-export pass - // typically flattens through), and complex chains can be added later - // with a visited-set on demand. + // basename match. Re-exports (`Export::ReExport`, `ExportAll`, and the + // import-then-`export { x }` shape) are chased by + // `resolve_export_to_let` with a depth cap — a library facade like + // bloom's `index.ts` re-exporting `Key` from `core/keys.ts` is two to + // three hops deep, and stopping at the first module made every + // re-exported const OBJECT read undefined (scalars sometimes survived + // via other paths, which made the failure look random). { // module.name → source module index let name_to_idx: std::collections::HashMap<&str, usize> = modules @@ -904,32 +930,46 @@ impl WasmModuleEmitter { let src_lets = &src_let_names[src_idx]; for spec in &import.specifiers { if let perry_hir::ir::ImportSpecifier::Named { imported, local } = spec { - // Walk the source module's exports to map the - // public `imported` name back to a source-local - // identifier, then look up that identifier's let. - let src_module = &modules[src_idx].1; - let mut resolved_local: Option<&str> = None; - for export in &src_module.exports { - if let perry_hir::ir::Export::Named { - local: src_local, - exported, - } = export - { - if exported == imported { - resolved_local = Some(src_local.as_str()); - break; - } - } + // Resolve the public `imported` name to a let + // global, following re-export chains (see + // resolve_export_to_let). + let resolved = resolve_export_to_let( + modules, + &src_let_names, + &name_to_idx, + src_idx, + imported, + 8, + ); + if std::env::var("PERRY_WASM_DEBUG_IMPORTS").is_ok() { + eprintln!( + "[wasm-imports] {} imports {{ {} }} from {} -> module #{} ({}) => {:?}", + modules[consumer_idx].1.name, + imported, + import.source, + src_idx, + modules[src_idx].1.name, + resolved, + ); } - // Direct fall-through: if no Export::Named matched - // but a Let with the imported name exists, use it. - // (Some HIR lowering shapes register exports out-of- - // band; this keeps `export const X = ...` robust.) - let key = resolved_local.unwrap_or(imported.as_str()); - if let Some(&gidx) = src_lets.get(key) { + if let Some(gidx) = resolved { self.imported_var_globals .insert((consumer_idx, local.clone()), gidx); } + // Function imports resolve per-consumer too — the + // whole-program func_name_map's bare-name keys + // collide across modules. + if let Some(fidx) = resolve_export_to_func( + modules, + &self.module_func_maps, + &name_to_idx, + src_idx, + imported, + 8, + ) { + self.imported_func_indices + .insert((consumer_idx, local.clone()), fidx); + } } // Namespace import (`import * as W from "./mod"`): // register every exported module-level let under a @@ -944,17 +984,23 @@ impl WasmModuleEmitter { if let perry_hir::ir::ImportSpecifier::Namespace { local } = spec { let src_module = &modules[src_idx].1; for export in &src_module.exports { - if let perry_hir::ir::Export::Named { - local: src_local, + let exported = match export { + perry_hir::ir::Export::Named { exported, .. } => exported, + perry_hir::ir::Export::ReExport { exported, .. } => exported, + _ => continue, + }; + if let Some(gidx) = resolve_export_to_let( + modules, + &src_let_names, + &name_to_idx, + src_idx, exported, - } = export - { - if let Some(&gidx) = src_lets.get(src_local.as_str()) { - self.imported_var_globals.insert( - (consumer_idx, format!("{}.{}", local, exported)), - gidx, - ); - } + 8, + ) { + self.imported_var_globals.insert( + (consumer_idx, format!("{}.{}", local, exported)), + gidx, + ); } } // Fall-through parity with the Named arm: exports @@ -968,13 +1014,18 @@ impl WasmModuleEmitter { } // Exported FUNCTIONS: `W.fn(args)` must call the // source module's compiled function, `W.fn` as a - // value must wrap it in a closure. Register both - // export shapes: the exported_functions list - // (`export function fn`) and Export::Named - // clauses (`function fn() {}; export { fn }`). - let src_fm = &self.module_func_maps[src_idx]; - for (exp_name, fid) in &src_module.exported_functions { - if let Some(&fidx) = src_fm.get(fid) { + // value must wrap it in a closure. Resolved with + // the same chain-following helper the named arm + // uses. + for (exp_name, _) in &src_module.exported_functions { + if let Some(fidx) = resolve_export_to_func( + modules, + &self.module_func_maps, + &name_to_idx, + src_idx, + exp_name, + 8, + ) { self.imported_ns_funcs.insert( (consumer_idx, format!("{}.{}", local, exp_name)), fidx, @@ -982,24 +1033,22 @@ impl WasmModuleEmitter { } } for export in &src_module.exports { - if let perry_hir::ir::Export::Named { - local: src_local, + let exported = match export { + perry_hir::ir::Export::Named { exported, .. } => exported, + perry_hir::ir::Export::ReExport { exported, .. } => exported, + _ => continue, + }; + if let Some(fidx) = resolve_export_to_func( + modules, + &self.module_func_maps, + &name_to_idx, + src_idx, exported, - } = export - { - for f in &src_module.functions { - if &f.name == src_local { - if let Some(&fidx) = src_fm.get(&f.id) { - self.imported_ns_funcs - .entry(( - consumer_idx, - format!("{}.{}", local, exported), - )) - .or_insert(fidx); - } - break; - } - } + 8, + ) { + self.imported_ns_funcs + .entry((consumer_idx, format!("{}.{}", local, exported))) + .or_insert(fidx); } } } diff --git a/crates/perry-codegen-wasm/src/emit/expr/calls.rs b/crates/perry-codegen-wasm/src/emit/expr/calls.rs index e2f3a4bb58..ddef7094b3 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/calls.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/calls.rs @@ -178,10 +178,20 @@ impl<'a> FuncEmitCtx<'a> { Expr::ExternFuncRef { name, return_type, .. } => { - // Cross-module or FFI function call — look up by name. - // See FuncRef arm above for why both pad-up and drop-excess - // are required (#183). - if let Some(&idx) = self.emitter.func_name_map.get(name) { + // Cross-module or FFI function call. The consumer's + // own import table wins (resolved through re-export + // chains); the whole-program name map is only a + // fallback, since its bare-name keys collide across + // modules. See FuncRef arm above for why both pad-up + // and drop-excess are required (#183). + let consumer_key = + (self.emitter.current_mod_idx, name.clone()); + if let Some(&idx) = self + .emitter + .imported_func_indices + .get(&consumer_key) + .or_else(|| self.emitter.func_name_map.get(name)) + { if let Some(&expected) = self.emitter.func_param_counts.get(&idx) { for _ in args.len()..expected { func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); diff --git a/crates/perry-codegen-wasm/src/emit/expr/classes.rs b/crates/perry-codegen-wasm/src/emit/expr/classes.rs index 725fc0e6df..a24400262b 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/classes.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/classes.rs @@ -104,8 +104,14 @@ impl<'a> FuncEmitCtx<'a> { let mod_key = (self.emitter.current_mod_idx, name.clone()); if let Some(&gidx) = self.emitter.imported_var_globals.get(&mod_key) { func.instruction(&Instruction::GlobalGet(gidx)); - } else if let Some(&func_idx) = self.emitter.func_name_map.get(name) { - // Create a closure wrapper with 0 captures (like FuncRef) + } else if let Some(&func_idx) = self + .emitter + .imported_func_indices + .get(&mod_key) + .or_else(|| self.emitter.func_name_map.get(name)) + { + // Create a closure wrapper with 0 captures (like FuncRef). + // Consumer import table first — bare names collide. let table_idx = self .emitter .func_to_table_idx diff --git a/crates/perry-codegen-wasm/src/emit/locals.rs b/crates/perry-codegen-wasm/src/emit/locals.rs index 4d145dd5ee..927e4f1733 100644 --- a/crates/perry-codegen-wasm/src/emit/locals.rs +++ b/crates/perry-codegen-wasm/src/emit/locals.rs @@ -132,3 +132,246 @@ pub(super) fn collect_locals( } } } + +/// String-based sibling of `resolve_source_module_idx` for `Export::ReExport +/// { source }` / `Export::ExportAll { source }`, which carry only the module +/// specifier (no resolved path). Same suffix/stem matching as the fallback +/// branch above. +pub(super) fn resolve_module_idx_by_source( + modules: &[(String, perry_hir::ir::Module)], + source: &str, +) -> Option { + let src = source + .trim_start_matches("./") + .trim_start_matches("../") + .replace('\\', "/"); + // A directory specifier ("./core") resolves to its index module. + let src_index = format!("{}/index", src); + let mut best: Option<(usize, usize)> = None; + for (i, (_, m)) in modules.iter().enumerate() { + // Module names are project-relative paths with the platform's + // separators ("engine\src\core\keys.ts" on Windows) — normalize. + let mn = m.name.replace('\\', "/"); + let stem = mn.rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_else(|| mn.clone()); + let hit = stem == src + || mn == src + || stem.ends_with(&format!("/{}", src)) + || stem.ends_with(&format!("/{}", src_index)) + || stem == src_index; + if hit { + let n = mn.len(); + if best.map(|(_, bn)| n > bn).unwrap_or(true) { + best = Some((i, n)); + } + } + } + best.map(|(i, _)| i) +} + +/// Resolve module `mod_idx`'s export `name` to a promoted-let wasm global, +/// following re-export chains: `Export::Named` whose local is itself an +/// import binding, `Export::ReExport { source, imported }`, and +/// `Export::ExportAll { source }` star re-exports. Depth-capped — a facade +/// index re-exporting from a sub-index re-exporting from the defining module +/// is the normal library shape (bloom's `Key` is three hops from a consumer). +pub(super) fn resolve_export_to_let( + modules: &[(String, perry_hir::ir::Module)], + src_let_names: &[std::collections::HashMap], + name_to_idx: &std::collections::HashMap<&str, usize>, + mod_idx: usize, + name: &str, + depth: u32, +) -> Option { + if depth == 0 { + return None; + } + let m = &modules[mod_idx].1; + for export in &m.exports { + match export { + perry_hir::ir::Export::Named { local, exported } if exported == name => { + if let Some(&g) = src_let_names[mod_idx].get(local.as_str()) { + return Some(g); + } + // The exported local may itself be an import binding + // (`import { Key } from "./core"; export { Key };`). + for import in &m.imports { + if import.type_only { + continue; + } + for spec in &import.specifiers { + if let perry_hir::ir::ImportSpecifier::Named { imported, local: il } = spec + { + if il == local { + if let Some(si) = + resolve_source_module_idx(modules, import, name_to_idx) + { + if let Some(g) = resolve_export_to_let( + modules, + src_let_names, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(g); + } + } + } + } + } + } + } + perry_hir::ir::Export::ReExport { + source, + imported, + exported, + } if exported == name => { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if let Some(g) = resolve_export_to_let( + modules, + src_let_names, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(g); + } + } + } + _ => {} + } + } + // Star re-exports: the name isn't listed, try every `export * from`. + for export in &m.exports { + if let perry_hir::ir::Export::ExportAll { source } = export { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if si != mod_idx { + if let Some(g) = resolve_export_to_let( + modules, + src_let_names, + name_to_idx, + si, + name, + depth - 1, + ) { + return Some(g); + } + } + } + } + } + // Fall-through: exports registered out-of-band keep their let by name. + src_let_names[mod_idx].get(name).copied() +} + +/// Function twin of `resolve_export_to_let`: resolve module `mod_idx`'s +/// export `name` to a compiled function index, following the same re-export +/// shapes. `module_func_maps[i]` maps FuncId → wasm function index for +/// module i. +pub(super) fn resolve_export_to_func( + modules: &[(String, perry_hir::ir::Module)], + module_func_maps: &[std::collections::BTreeMap], + name_to_idx: &std::collections::HashMap<&str, usize>, + mod_idx: usize, + name: &str, + depth: u32, +) -> Option { + if depth == 0 { + return None; + } + let m = &modules[mod_idx].1; + let find_local_fn = |local: &str| -> Option { + for f in &m.functions { + if f.name == local { + if let Some(&idx) = module_func_maps[mod_idx].get(&f.id) { + return Some(idx); + } + } + } + None + }; + // exported_functions is the authoritative `export function foo` list. + for (exp_name, fid) in &m.exported_functions { + if exp_name == name { + if let Some(&idx) = module_func_maps[mod_idx].get(fid) { + return Some(idx); + } + } + } + for export in &m.exports { + match export { + perry_hir::ir::Export::Named { local, exported } if exported == name => { + if let Some(idx) = find_local_fn(local) { + return Some(idx); + } + for import in &m.imports { + if import.type_only { + continue; + } + for spec in &import.specifiers { + if let perry_hir::ir::ImportSpecifier::Named { imported, local: il } = spec + { + if il == local { + if let Some(si) = + resolve_source_module_idx(modules, import, name_to_idx) + { + if let Some(idx) = resolve_export_to_func( + modules, + module_func_maps, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(idx); + } + } + } + } + } + } + } + perry_hir::ir::Export::ReExport { + source, + imported, + exported, + } if exported == name => { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if let Some(idx) = resolve_export_to_func( + modules, + module_func_maps, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(idx); + } + } + } + _ => {} + } + } + for export in &m.exports { + if let perry_hir::ir::Export::ExportAll { source } = export { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if si != mod_idx { + if let Some(idx) = resolve_export_to_func( + modules, + module_func_maps, + name_to_idx, + si, + name, + depth - 1, + ) { + return Some(idx); + } + } + } + } + } + // Fall-through: a function with that very name (exports registered + // out-of-band). + find_local_fn(name) +} diff --git a/crates/perry-codegen-wasm/src/emit/mod.rs b/crates/perry-codegen-wasm/src/emit/mod.rs index 570a18b2aa..35b15e7724 100644 --- a/crates/perry-codegen-wasm/src/emit/mod.rs +++ b/crates/perry-codegen-wasm/src/emit/mod.rs @@ -59,7 +59,10 @@ use constants::{ f64_const, EnumResolvedValue, STRING_TAG, TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED, }; use func_emit_ctx::FuncEmitCtx; -use locals::{collect_locals, collect_module_let_ids, resolve_source_module_idx}; +use locals::{ + collect_locals, collect_module_let_ids, resolve_export_to_func, + resolve_export_to_let, resolve_source_module_idx, +}; use module_emitter::WasmModuleEmitter; use runtime_imports::RuntimeImports; use stmt::has_return; diff --git a/crates/perry-codegen-wasm/src/emit/module_emitter.rs b/crates/perry-codegen-wasm/src/emit/module_emitter.rs index 045fbf2204..b4d9908c3c 100644 --- a/crates/perry-codegen-wasm/src/emit/module_emitter.rs +++ b/crates/perry-codegen-wasm/src/emit/module_emitter.rs @@ -83,6 +83,13 @@ pub(super) struct WasmModuleEmitter { /// value to a zero-capture closure (objects.rs) — the same two shapes a /// named import gets via ExternFuncRef. pub(super) imported_ns_funcs: BTreeMap<(usize, String), u32>, + /// Named-import FUNCTIONS, per consumer: `(consumer_module_idx, local)` + /// → wasm function index, resolved through re-export chains. Consulted + /// BEFORE the whole-program `func_name_map`, whose bare-name keys + /// collide the moment two modules define a same-named function (a local + /// serializer helper `vec3(v): string` must not capture the math + /// library's `vec3(x,y,z)` for every caller in the program). + pub(super) imported_func_indices: BTreeMap<(usize, String), u32>, } impl WasmModuleEmitter { @@ -116,6 +123,7 @@ impl WasmModuleEmitter { async_js_code: Vec::new(), imported_var_globals: BTreeMap::new(), imported_ns_funcs: BTreeMap::new(), + imported_func_indices: BTreeMap::new(), } } From fd36c846b74b77eaa2094f87968386d754b1a409 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 17 Jul 2026 09:55:19 +0200 Subject: [PATCH 3/4] fix(wasm): `x >>> 0` returned the signed value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third batch from the 3D-game web port. JS defines unsigned right shift as producing a ToUint32 result, but the codegen widened the i32 back to f64 SIGNED. Invisible for any shift >= 1 (shifting in a zero clears the sign bit, so signed and unsigned agree) — and wrong for exactly the canonical `x >>> 0` "reinterpret as unsigned" idiom, which handed back the negative input unchanged. Found via a game engine packing colours as `(a|r|g|b) >>> 0` — its own comment reads "use unsigned-shift-zero to keep the value positive when stored as f64". The negative f64 crossed the FFI into a Rust `as u32`, whose saturating cast floored it to 0: every model tint became transparent black, so alpha-cutout foliage discarded its entire canopy. `>>>` now widens with f64.convert_i32_u; every other bitwise operator keeps the signed conversion, which is correct for them. --- crates/perry-codegen-wasm/src/emit/binary.rs | 41 ++++++++++++++++++- .../src/emit/expr/literals_vars.rs | 3 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen-wasm/src/emit/binary.rs b/crates/perry-codegen-wasm/src/emit/binary.rs index 918c132be7..b08cb5b4d1 100644 --- a/crates/perry-codegen-wasm/src/emit/binary.rs +++ b/crates/perry-codegen-wasm/src/emit/binary.rs @@ -6,13 +6,46 @@ use super::*; impl<'a> FuncEmitCtx<'a> { - /// Emit a binary bitwise operation with proper i32 truncation + /// Emit a binary bitwise operation with proper i32 truncation. The + /// result is reinterpreted as a SIGNED i32 — correct for every JS + /// bitwise operator except `>>>`, which is defined to produce a + /// ToUint32 value (see `emit_bitwise_binary_u`). pub(super) fn emit_bitwise_binary( &mut self, func: &mut Function, left: &Expr, right: &Expr, op: Instruction<'static>, + ) { + self.emit_bitwise_binary_impl(func, left, right, op, false); + } + + /// `>>>` — JS's unsigned right shift yields a ToUint32 result, so the + /// i32 must be widened UNSIGNED. Converting it signed (as the shared + /// path does) is invisible for any shift >= 1, because shifting in a + /// zero clears the sign bit — but `x >>> 0`, the canonical + /// "reinterpret this as unsigned" idiom, then hands back the negative + /// input unchanged. Engine code packs ARGB with `(a|r|g|b) >>> 0` and + /// got a negative f64 across the FFI, where Rust's saturating + /// `as u32` floored it to 0 — every model tint became transparent + /// black. + pub(super) fn emit_bitwise_binary_u( + &mut self, + func: &mut Function, + left: &Expr, + right: &Expr, + op: Instruction<'static>, + ) { + self.emit_bitwise_binary_impl(func, left, right, op, true); + } + + fn emit_bitwise_binary_impl( + &mut self, + func: &mut Function, + left: &Expr, + right: &Expr, + op: Instruction<'static>, + result_unsigned: bool, ) { self.emit_expr(func, left); func.instruction(&Instruction::F64ReinterpretI64); @@ -23,7 +56,11 @@ impl<'a> FuncEmitCtx<'a> { func.instruction(&Instruction::I64TruncSatF64S); func.instruction(&Instruction::I32WrapI64); func.instruction(&op); - func.instruction(&Instruction::F64ConvertI32S); + if result_unsigned { + func.instruction(&Instruction::F64ConvertI32U); + } else { + func.instruction(&Instruction::F64ConvertI32S); + } func.instruction(&Instruction::I64ReinterpretF64); } } diff --git a/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs b/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs index 2d6d803ed7..4f73cf6187 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs @@ -199,7 +199,8 @@ impl<'a> FuncEmitCtx<'a> { self.emit_bitwise_binary(func, left, right, Instruction::I32ShrS); } BinaryOp::UShr => { - self.emit_bitwise_binary(func, left, right, Instruction::I32ShrU); + // ToUint32 result — see emit_bitwise_binary_u. + self.emit_bitwise_binary_u(func, left, right, Instruction::I32ShrU); } // Mod and Pow go through JS bridge (no native WASM instruction) // — use emit_store_arg to keep values as i64, like Add From d80eec2cffd7b8b29186f4db2dda96001b01deec Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 17 Jul 2026 12:48:48 +0200 Subject: [PATCH 4/4] fix(wasm): address CodeRabbit review on import/namespace resolution - Set current_mod_idx in the global-initializer and class-method emission loops. Per-consumer import resolution (imported_var_globals / imported_func_indices / imported_ns_funcs) is keyed by current_mod_idx; a module-scope initializer that called an imported symbol resolved against a stale consumer index and could bind another module's like-named export. (The exported-wins func_name_map fallback masked it for single-export names, but two modules exporting the same name would misbind.) - Gate the resolve_export_to_let / resolve_export_to_func fallbacks on a new module_exports_name() check. The tail fallback returned any same-named module-LOCAL, so during `export *` recursion a private `foo` in an early source masked a genuine exported `foo` in a later one. It now returns a local only when the name is actually part of that module's public surface. - Register namespace-import members from the module's PUBLIC export surface (collect_exported_names: named/re-export/function/object exports + a recursive walk of `export * from`), replacing a blanket "register every module-level let" loop that both leaked PRIVATE locals as `W.private` and skipped `export *` re-exports entirely. Verified: the 3D game this port was built for still boots, renders, and plays with 0 console errors after the tightened resolver. --- crates/perry-codegen-wasm/src/emit/compile.rs | 71 +++++---------- crates/perry-codegen-wasm/src/emit/locals.rs | 90 +++++++++++++++++-- crates/perry-codegen-wasm/src/emit/mod.rs | 2 +- 3 files changed, 110 insertions(+), 53 deletions(-) diff --git a/crates/perry-codegen-wasm/src/emit/compile.rs b/crates/perry-codegen-wasm/src/emit/compile.rs index 8c1a4fa5be..2b7eedd968 100644 --- a/crates/perry-codegen-wasm/src/emit/compile.rs +++ b/crates/perry-codegen-wasm/src/emit/compile.rs @@ -982,72 +982,41 @@ impl WasmModuleEmitter { // the whole-program name map, which made the failure // maddeningly partial). if let perry_hir::ir::ImportSpecifier::Namespace { local } = spec { - let src_module = &modules[src_idx].1; - for export in &src_module.exports { - let exported = match export { - perry_hir::ir::Export::Named { exported, .. } => exported, - perry_hir::ir::Export::ReExport { exported, .. } => exported, - _ => continue, - }; + // Register `W.` for exactly the source + // module's PUBLIC surface — its named/re-exported/ + // function/object exports, plus everything reached + // through `export * from "..."` (recursively). This + // replaced a blanket "register every module-level + // let" loop, which both exposed PRIVATE locals as + // `W.private` (not valid JS namespace members) and + // missed `export *` re-exports entirely. + let mut public: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + collect_exported_names(modules, src_idx, 8, &mut public); + for name in &public { if let Some(gidx) = resolve_export_to_let( modules, &src_let_names, &name_to_idx, src_idx, - exported, + name, 8, ) { self.imported_var_globals.insert( - (consumer_idx, format!("{}.{}", local, exported)), + (consumer_idx, format!("{}.{}", local, name)), gidx, ); } - } - // Fall-through parity with the Named arm: exports - // registered out-of-band still have their let — - // expose those by name unless an Export::Named - // already claimed the key. - for (name, &gidx) in src_lets.iter() { - self.imported_var_globals - .entry((consumer_idx, format!("{}.{}", local, name))) - .or_insert(gidx); - } - // Exported FUNCTIONS: `W.fn(args)` must call the - // source module's compiled function, `W.fn` as a - // value must wrap it in a closure. Resolved with - // the same chain-following helper the named arm - // uses. - for (exp_name, _) in &src_module.exported_functions { - if let Some(fidx) = resolve_export_to_func( - modules, - &self.module_func_maps, - &name_to_idx, - src_idx, - exp_name, - 8, - ) { - self.imported_ns_funcs.insert( - (consumer_idx, format!("{}.{}", local, exp_name)), - fidx, - ); - } - } - for export in &src_module.exports { - let exported = match export { - perry_hir::ir::Export::Named { exported, .. } => exported, - perry_hir::ir::Export::ReExport { exported, .. } => exported, - _ => continue, - }; if let Some(fidx) = resolve_export_to_func( modules, &self.module_func_maps, &name_to_idx, src_idx, - exported, + name, 8, ) { self.imported_ns_funcs - .entry((consumer_idx, format!("{}.{}", local, exported))) + .entry((consumer_idx, format!("{}.{}", local, name))) .or_insert(fidx); } } @@ -1432,6 +1401,13 @@ impl WasmModuleEmitter { // Initialize globals — swap in per-module func_map for correct FuncRef resolution for (mod_idx, (_, module)) in modules.iter().enumerate() { self.func_map = self.module_func_maps[mod_idx].clone(); + // Per-consumer import resolution (imported_var_globals / + // imported_func_indices / imported_ns_funcs) is keyed by + // current_mod_idx; a module-scope initializer that calls an + // imported symbol (e.g. `const P = vec3(...)`) resolves + // against a stale consumer without this and could bind + // another module's like-named export. + self.current_mod_idx = mod_idx; for global in &module.globals { if let Some(init) = &global.init { let mut ctx = @@ -1452,6 +1428,7 @@ impl WasmModuleEmitter { // Register class methods with the bridge and set up inheritance for (mod_idx, (_, module)) in modules.iter().enumerate() { self.func_map = self.module_func_maps[mod_idx].clone(); + self.current_mod_idx = mod_idx; // see the globals loop above for class in &module.classes { let class_name_id = self .string_map diff --git a/crates/perry-codegen-wasm/src/emit/locals.rs b/crates/perry-codegen-wasm/src/emit/locals.rs index 927e4f1733..caacb5f854 100644 --- a/crates/perry-codegen-wasm/src/emit/locals.rs +++ b/crates/perry-codegen-wasm/src/emit/locals.rs @@ -168,6 +168,74 @@ pub(super) fn resolve_module_idx_by_source( best.map(|(i, _)| i) } +/// Is `name` part of module `m`'s own PUBLIC surface — i.e. does it appear +/// in an `export` declaration (named, re-export, exported function, or +/// exported object)? Used to gate the resolve_export_to_* fallbacks so they +/// never hand back a PRIVATE module-local: during `export *` recursion, a +/// private `foo` in an early source must not mask a real exported `foo` in a +/// later one. Non-recursive by design — `export *`-reachable names are +/// resolved by the explicit ExportAll traversal, not the fallback. +pub(super) fn module_exports_name(m: &perry_hir::ir::Module, name: &str) -> bool { + for e in &m.exports { + match e { + perry_hir::ir::Export::Named { exported, .. } + | perry_hir::ir::Export::ReExport { exported, .. } => { + if exported == name { + return true; + } + } + _ => {} + } + } + m.exported_functions.iter().any(|(n, _)| n == name) + || m.exported_objects.iter().any(|n| n == name) +} + +/// The names a `import * as W from "mod"` namespace should expose: mod's own +/// named/re-exported/function/object exports, plus — recursively — every +/// name re-exported through `export * from "..."`. Deduped; depth-capped. +/// Replaces the old "register every module-level let" fallback, which leaked +/// private locals into the namespace object. +pub(super) fn collect_exported_names( + modules: &[(String, perry_hir::ir::Module)], + mod_idx: usize, + depth: u32, + out: &mut std::collections::BTreeSet, +) { + if depth == 0 { + return; + } + let m = &modules[mod_idx].1; + for e in &m.exports { + match e { + perry_hir::ir::Export::Named { exported, .. } + | perry_hir::ir::Export::ReExport { exported, .. } => { + out.insert(exported.clone()); + } + perry_hir::ir::Export::ExportAll { source } => { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if si != mod_idx { + collect_exported_names(modules, si, depth - 1, out); + } + } + } + // `export * as ns from "..."` binds the whole namespace under one + // name; the namespace object itself is not a promoted let we can + // resolve here, so expose the name (resolution is a no-op) rather + // than recurse into the source's members. + perry_hir::ir::Export::NamespaceReExport { name, .. } => { + out.insert(name.clone()); + } + } + } + for (n, _) in &m.exported_functions { + out.insert(n.clone()); + } + for n in &m.exported_objects { + out.insert(n.clone()); + } +} + /// Resolve module `mod_idx`'s export `name` to a promoted-let wasm global, /// following re-export chains: `Export::Named` whose local is itself an /// import binding, `Export::ReExport { source, imported }`, and @@ -261,8 +329,16 @@ pub(super) fn resolve_export_to_let( } } } - // Fall-through: exports registered out-of-band keep their let by name. - src_let_names[mod_idx].get(name).copied() + // Fall-through: an export registered out-of-band (e.g. an exported + // object-const) keeps its let by name — but ONLY if the name is actually + // part of this module's public surface. Returning a private local here + // would let it mask a genuine export of the same name in a later + // `export *` source. + if module_exports_name(m, name) { + src_let_names[mod_idx].get(name).copied() + } else { + None + } } /// Function twin of `resolve_export_to_let`: resolve module `mod_idx`'s @@ -371,7 +447,11 @@ pub(super) fn resolve_export_to_func( } } } - // Fall-through: a function with that very name (exports registered - // out-of-band). - find_local_fn(name) + // Fall-through: an out-of-band exported function keeps its name — but + // only if it's genuinely exported (see resolve_export_to_let). + if module_exports_name(m, name) { + find_local_fn(name) + } else { + None + } } diff --git a/crates/perry-codegen-wasm/src/emit/mod.rs b/crates/perry-codegen-wasm/src/emit/mod.rs index 35b15e7724..58963ea2f4 100644 --- a/crates/perry-codegen-wasm/src/emit/mod.rs +++ b/crates/perry-codegen-wasm/src/emit/mod.rs @@ -60,7 +60,7 @@ use constants::{ }; use func_emit_ctx::FuncEmitCtx; use locals::{ - collect_locals, collect_module_let_ids, resolve_export_to_func, + collect_exported_names, collect_locals, collect_module_let_ids, resolve_export_to_func, resolve_export_to_let, resolve_source_module_idx, }; use module_emitter::WasmModuleEmitter;