mem: thread val_ty through inst.Store; per-width comptime regression test

The interp's `storeAtRawPtr` used to write 8 bytes from a `.int` /
`.float` Value regardless of the destination's declared width. The
Value tag flattens s8..s64/u*/pointer all to `.int`, so it can't
disambiguate widths on its own — every store risked clobbering up to
7 neighbor bytes if the actual IR type was sub-8.

Fix:

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

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

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

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

Also wired:

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

154/154 example tests pass (the new test + the existing 153). Chess
still green on macOS / iOS sim / Android.
This commit is contained in:
agra
2026-05-25 11:41:59 +03:00
parent 26d96ac15e
commit f2b3868579
7 changed files with 255 additions and 25 deletions

View File

@@ -0,0 +1,190 @@
// Lock down the interp's raw-pointer store width per primitive type.
//
// Each helper allocates a 32-byte buffer through `context.allocator`,
// fills it with a sentinel byte (0xAA), writes ONE typed value at
// offset 8, then sums every byte back. A correctly-sized store touches
// exactly `sizeof(T)` bytes, so the sum equals
// 31 * 0xAA + sum-of-bytes-in-the-written-value.
// A wrong width (e.g. an 8-byte store at a 1-byte slot) clobbers
// neighbors with zeros and the sum drops.
//
// Each test computes its expected sum at COMPTIME (the value is baked
// into a `#run` constant — the interp's `storeAtRawPtr` runs). The
// runtime program prints the same checksum computed by codegen
// (LLVM-emitted typed stores). The two MUST match — that's the
// regression assertion.
//
// To pin the test: every helper returns its checksum; main prints
// "ok" iff every comptime-baked checksum equals the runtime-recomputed
// one. Failure prints which width diverged.
#import "modules/std.sx";
SENTINEL :u8: 0xAA; // 170 — neighbor pattern
BUF_SIZE :s64: 32;
TARGET :s64: 8; // offset where the typed store lands
// ── per-width helpers ───────────────────────────────────────────────
fill :: (buf: [*]u8) {
i : s64 = 0;
while i < BUF_SIZE { buf[i] = SENTINEL; i += 1; }
}
sum_bytes :: (buf: [*]u8) -> s64 {
s : s64 = 0;
i : s64 = 0;
while i < BUF_SIZE { s += xx buf[i]; i += 1; }
s;
}
run_u8 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *u8 = xx @buf[TARGET];
p.* = 0x42;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_u16 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *u16 = xx @buf[TARGET];
p.* = 0x0102;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_u32 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *u32 = xx @buf[TARGET];
p.* = 0x01020304;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_u64 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *u64 = xx @buf[TARGET];
p.* = 0x0102030405060708;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_s8 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *s8 = xx @buf[TARGET];
p.* = 0x42;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_s16 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *s16 = xx @buf[TARGET];
p.* = 0x0102;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_s32 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *s32 = xx @buf[TARGET];
p.* = 0x01020304;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_s64 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *s64 = xx @buf[TARGET];
p.* = 0x0102030405060708;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_bool :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *bool = xx @buf[TARGET];
p.* = true;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_f32 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *f32 = xx @buf[TARGET];
p.* = 1.0;
s := sum_bytes(buf);
free(xx buf);
s;
}
run_f64 :: () -> s64 {
buf : [*]u8 = xx malloc(BUF_SIZE);
fill(buf);
p : *f64 = xx @buf[TARGET];
p.* = 1.0;
s := sum_bytes(buf);
free(xx buf);
s;
}
// ── comptime-baked expected checksums ───────────────────────────────
// `#run` evaluates each helper via the interp, so its
// `storeAtRawPtr(addr, val, val_ty)` honors the declared width.
EXP_U8 :: #run run_u8();
EXP_U16 :: #run run_u16();
EXP_U32 :: #run run_u32();
EXP_U64 :: #run run_u64();
EXP_S8 :: #run run_s8();
EXP_S16 :: #run run_s16();
EXP_S32 :: #run run_s32();
EXP_S64 :: #run run_s64();
EXP_BOOL :: #run run_bool();
EXP_F32 :: #run run_f32();
EXP_F64 :: #run run_f64();
// ── runtime comparison ──────────────────────────────────────────────
check :: (label: string, got: s64, want: s64) -> bool {
if got == want { return true; }
print("FAIL {}: comptime={} runtime={}\n", label, want, got);
false;
}
main :: () -> s32 {
ok := true;
if !check("u8", run_u8(), EXP_U8) { ok = false; }
if !check("u16", run_u16(), EXP_U16) { ok = false; }
if !check("u32", run_u32(), EXP_U32) { ok = false; }
if !check("u64", run_u64(), EXP_U64) { ok = false; }
if !check("s8", run_s8(), EXP_S8) { ok = false; }
if !check("s16", run_s16(), EXP_S16) { ok = false; }
if !check("s32", run_s32(), EXP_S32) { ok = false; }
if !check("s64", run_s64(), EXP_S64) { ok = false; }
if !check("bool", run_bool(), EXP_BOOL) { ok = false; }
if !check("f32", run_f32(), EXP_F32) { ok = false; }
if !check("f64", run_f64(), EXP_F64) { ok = false; }
if ok { print("ok\n"); return 0; }
return 1;
}

View File

@@ -659,7 +659,20 @@ pub const LLVMEmitter = struct {
if (global.comptime_func) |func_id| {
var interp_inst = Interpreter.init(self.ir_mod, self.alloc);
interp_inst.build_config = &self.build_config;
const result = interp_inst.call(func_id, &.{}) catch .void_val;
Interpreter.last_bail_op = null;
Interpreter.last_bail_builtin = null;
Interpreter.last_bail_detail = null;
const result = interp_inst.call(func_id, &.{}) catch |err| blk: {
// Surface the bail loudly instead of silently filling
// the const with zero. Stale state from a previous
// comptime function would otherwise hide the error.
const op = Interpreter.last_bail_op orelse "<unknown>";
const detail = Interpreter.last_bail_detail orelse "";
const sep: []const u8 = if (detail.len > 0) ": " else "";
const gname = self.ir_mod.types.getString(global.name);
std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail });
break :blk .void_val;
};
const init_val = self.valueToLLVMConst(result, llvm_ty);
c.LLVMSetInitializer(llvm_global, init_val);
} else if (global.init_val) |iv| {

View File

@@ -249,6 +249,12 @@ pub const TriOp = struct {
pub const Store = struct {
ptr: Ref,
val: Ref,
/// Declared type of the value being stored. Threaded through so the
/// interp's raw-pointer store knows the destination byte width — a
/// `.int` Value alone is ambiguous (s8/s16/s32/s64/u*/usize/pointer
/// all flatten to `.int`). The LLVM emitter ignores this (LLVM knows
/// the width from the SSA value's type already).
val_ty: TypeId = .void,
};
pub const Conversion = struct {

View File

@@ -198,38 +198,45 @@ pub const Interpreter = struct {
self.hooks.deinit();
}
/// Write `val` to the raw host address `addr`. Used when the
/// Write `val` to the raw host address `addr` using exactly the
/// number of bytes declared by `val_ty`. Used when the
/// protocol-dispatch chain bottoms out at a foreign-libc-malloc
/// pointer and sx code stores through it. Comptime safety is the
/// caller's responsibility — wild writes will fault.
///
/// **Width assumption.** `.int` and `.float` always write 8 bytes.
/// The Store IR op doesn't currently thread val's TypeId into the
/// interp, so we can't tell s32/s64 or f32/f64 apart from the
/// Value tag. Real-world comptime paths (protocol erasure heap
/// copies, Context aggregate stores) hit 8-byte fields, so this
/// works in practice. If a comptime store ever hits a smaller
/// destination through a raw pointer, neighbors get clobbered —
/// add `val_ty` to `inst.Store` and switch on it here.
fn storeAtRawPtr(self: *Interpreter, addr: i64, val: Value) InterpError!void {
_ = self;
fn storeAtRawPtr(self: *Interpreter, addr: i64, val: Value, val_ty: @import("types.zig").TypeId) InterpError!void {
const dst: [*]u8 = @ptrFromInt(@as(usize, @bitCast(addr)));
const width = self.module.types.typeSizeBytes(val_ty);
switch (val) {
.int => |v| {
// Width is whatever the declared IR type says (s8..s64,
// u8..u64, usize, pointer-as-int, bool-after-extension).
// The Value tag itself is .int regardless.
if (width == 0 or width > 8) return bailDetail("comptime store of int through raw pointer: unexpected declared width (expected 1..8 bytes)");
const bytes = std.mem.toBytes(v);
@memcpy(dst[0..bytes.len], &bytes);
@memcpy(dst[0..width], bytes[0..width]);
},
.float => |v| {
const bytes = std.mem.toBytes(v);
@memcpy(dst[0..bytes.len], &bytes);
switch (width) {
8 => {
const bytes = std.mem.toBytes(v);
@memcpy(dst[0..8], &bytes);
},
4 => {
const f32_v: f32 = @floatCast(v);
const bytes = std.mem.toBytes(f32_v);
@memcpy(dst[0..4], &bytes);
},
else => return bailDetail("comptime store of float through raw pointer: unexpected declared width (expected 4 or 8 bytes)"),
}
},
.boolean => |v| {
if (width == 0) return bailDetail("comptime store of bool through raw pointer: zero-width destination");
@memset(dst[0..width], 0);
dst[0] = if (v) 1 else 0;
},
.null_val => {
const zero: u64 = 0;
const bytes = std.mem.toBytes(zero);
@memcpy(dst[0..bytes.len], &bytes);
if (width == 0 or width > 8) return bailDetail("comptime store of null through raw pointer: unexpected declared width");
@memset(dst[0..width], 0);
},
.aggregate => return bailDetail("comptime store of aggregate through raw pointer not supported (struct field layout not threaded into Store IR op)"),
.heap_ptr => return bailDetail("comptime store of interp-heap pointer through raw pointer not supported"),
@@ -704,11 +711,10 @@ pub const Interpreter = struct {
self.heapStoreByte(hp, byte);
},
// Raw host pointer (from foreign call, e.g. libc_malloc).
// 8-byte stride assumed — covers the s64/pointer/f64 cases
// sx hits via comptime protocol erasure. Aggregate stores
// unpack and recurse.
// `val_ty` carries the declared destination width so we
// write exactly that many bytes — no neighbor clobber.
.int => |p| {
try storeAtRawPtr(self, p, val);
try storeAtRawPtr(self, p, val, s.val_ty);
},
// Byte-granular pointer (from index_gep on a string).
// Always a 1-byte store — matches the heap_ptr arm.
@@ -1014,7 +1020,19 @@ pub const Interpreter = struct {
if (i >= fields.len) return error.OutOfBounds;
return .{ .value = fields[i] };
},
else => return error.CannotEvalComptime,
// Raw host pointer base — `buf[i]` reads one byte at
// offset i. Matches the byte-addressed `index_gep`
// semantics for the same shape. Used by comptime sx
// code that walks libc-malloc'd buffers.
.int => |p| {
const src: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(p)));
return .{ .value = .{ .int = src[i] } };
},
.byte_ptr => |addr| {
const src: [*]const u8 = @ptrFromInt(addr);
return .{ .value = .{ .int = src[i] } };
},
else => return bailDetail("comptime index_get: unsupported base kind"),
}
},
.length => |u| {

View File

@@ -329,7 +329,8 @@ pub const Builder = struct {
}
pub fn store(self: *Builder, ptr: Ref, val: Ref) void {
self.emitVoid(.{ .store = .{ .ptr = ptr, .val = val } }, .void);
const val_ty = self.getRefType(val);
self.emitVoid(.{ .store = .{ .ptr = ptr, .val = val, .val_ty = val_ty } }, .void);
}
// ── Struct ops ──────────────────────────────────────────────────

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@
ok