Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions briefs/M1.0.3-resource-nonpod-fields.md

Large diffs are not rendered by default.

37 changes: 36 additions & 1 deletion src/core/ecs/registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ pub const FieldKind = enum {
u32_,
f32_,
f64_,
/// A `string` field slot: `{ ptr: u64, len: u32 }` (16 bytes, 8-aligned)
/// pointing into the Etch persistent heap (`src/etch/persistent.zig`,
/// `StringSlot`). **Resource-only by construction** (M1.0.3): the Etch
/// validator rejects `string` on `component` and `fieldKindFromTypeName`
/// only emits this kind for the `.resource` origin, so no component can ever
/// carry it — the component SoA/POD invariant (`engine-spec.md` §4) is
/// untouched. Tier-0 stays string-agnostic: it stores/copies the 16 raw
/// slot bytes; the Etch runtime owns the pointed-to bytes' lifetime.
string_,
/// An enum field slot: the variant's declaration-order index as a `u32`
/// discriminant (4 bytes, 4-aligned). POD — no persistent heap, no decref,
/// no teardown. **Resource-only** like `.string_` (validator-gated out of
/// components). The declared enum type's interned name id rides on
/// `FieldDesc.enum_type_name_id` so the Etch bridge can rebuild a typed
/// `enum_value{ type_name, variant }` on read.
enum_,

pub fn sizeBytes(self: FieldKind) usize {
return switch (self) {
Expand All @@ -49,6 +65,10 @@ pub const FieldKind = enum {
.u32_ => @sizeOf(u32),
.f32_ => @sizeOf(f32),
.f64_ => @sizeOf(f64),
// `{ ptr: u64, len: u32 }` padded to 8-alignment — must equal
// `@sizeOf(persistent.StringSlot)` (asserted in `ecs_bridge.zig`).
.string_ => 16,
.enum_ => @sizeOf(u32), // declaration-order discriminant
};
}

Expand All @@ -61,6 +81,8 @@ pub const FieldKind = enum {
.u32_ => @alignOf(u32),
.f32_ => @alignOf(f32),
.f64_ => @alignOf(f64),
.string_ => 8,
.enum_ => @alignOf(u32),
};
}

Expand All @@ -83,6 +105,14 @@ pub const FieldDesc = struct {
name: []const u8,
offset: u16,
kind: FieldKind,
/// For a `.enum_` field (resource-only, M1.0.3 E3): the Etch-interned id of
/// the declared enum type name (an AST `StringId`, kept opaque by Tier-0 —
/// a plain `u32`, never dereferenced here). Lets the Etch bridge rebuild a
/// typed `enum_value{ type_name, variant }` on read with no string pool.
/// Stored as the id (not a string) so it needs no allocation and cannot
/// dangle when the AST outlives nothing while the registry persists in the
/// world. `0` and unused for every non-`.enum_` kind.
enum_type_name_id: u32 = 0,
};

/// Full descriptor stored by the registry. `default_bytes` is `size` bytes
Expand Down Expand Up @@ -166,7 +196,12 @@ pub const Registry = struct {
errdefer for (fields_owned[0..dup_count]) |f| gpa.free(f.name);
for (desc.fields, 0..) |f, i| {
const fname_owned = try gpa.dupe(u8, f.name);
fields_owned[i] = .{ .name = fname_owned, .offset = f.offset, .kind = f.kind };
fields_owned[i] = .{
.name = fname_owned,
.offset = f.offset,
.kind = f.kind,
.enum_type_name_id = f.enum_type_name_id,
};
dup_count += 1;
}

Expand Down
84 changes: 84 additions & 0 deletions src/etch/ecs_bridge.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

const std = @import("std");
const value_mod = @import("value.zig");
const persistent = @import("persistent.zig");

const weld_core = @import("weld_core");
const RegistryNS = weld_core.ecs.registry;
Expand All @@ -34,6 +35,13 @@ const EntityId = value_mod.EntityId;
const Value = value_mod.Value;
const ComponentRef = value_mod.ComponentRef;

comptime {
// The `.string_` slot stride the registry reports must match the canonical
// `StringSlot` layout (`persistent.zig`) the bridge reads/writes — one
// source of truth across the Tier-0 / Etch boundary.
std.debug.assert(@sizeOf(persistent.StringSlot) == FieldKind.string_.sizeBytes());
}

/// Surfaced so callers of `Bridge.dispatchEntityGet` /
/// `dispatchResourceGet` can map a name-resolution failure into a
/// typed E-code without depending on `Registry`'s raw lookup return.
Expand Down Expand Up @@ -180,6 +188,16 @@ pub const Bridge = struct {
const bytes = store.getResource(resource_id) orelse return BridgeError.UnknownResource;
const field = registry.findField(resource_id, field_name) orelse return BridgeError.UnknownField;
const slice = bytes[field.offset .. field.offset + @as(u16, @intCast(field.kind.sizeBytes()))];
// Enum read (M1.0.3 E3): rebuild a typed `enum_value` from the slot's
// discriminant + the declared enum type's interned id on `FieldDesc`
// (the byte-only `readBytesAsValue` has no access to the latter). The
// `type_name` id matches the rest of the interpreter's enum machinery
// (`enum_decls` is keyed by it), so the value compares/matches correctly.
if (field.kind == .enum_) {
var disc: u32 = 0;
@memcpy(std.mem.asBytes(&disc), slice[0..@sizeOf(u32)]);
return .{ .enum_value = .{ .type_name = field.enum_type_name_id, .variant = disc } };
}
return readBytesAsValue(field.kind, slice);
}

Expand All @@ -195,6 +213,43 @@ pub const Bridge = struct {
const slice = bytes[field.offset .. field.offset + @as(u16, @intCast(field.kind.sizeBytes()))];
try writeValueAsBytes(field.kind, slice, v);
}

/// Promote `bytes` into a fresh persistent allocation and store it in a
/// resource `.string_` slot (`etch-memory-model.md` §6.7 rule-arena →
/// persistent promotion). The interpreter resolves the incoming string's
/// bytes (literal / rule-arena) and hands them here with the allocator.
///
/// Order is load-bearing (M1.0.3 E2 review guard, anti use-after-free): read
/// the old slot → alloc + copy the new value → write the new slot → only then
/// `decref` the *previous* slot value (never after overwriting it). The
/// previous value's `decref` is a no-op when it was the immortal default. An
/// empty write stores `{ptr=0,len=0}` and allocates nothing.
pub fn promoteResourceString(
gpa: std.mem.Allocator,
registry: *const Registry,
store: *ResourceStore,
resource_id: ComponentId,
field_name: []const u8,
bytes: []const u8,
) BridgeError!void {
const field = registry.findField(resource_id, field_name) orelse return BridgeError.UnknownField;
std.debug.assert(field.kind == .string_);
const buf = store.getMutResource(resource_id) orelse return BridgeError.UnknownResource;
const slot = buf[field.offset .. field.offset + @sizeOf(persistent.StringSlot)];

var old: persistent.StringSlot = undefined;
@memcpy(std.mem.asBytes(&old), slot);

var new_slot: persistent.StringSlot = .{};
if (bytes.len > 0) {
const block = persistent.alloc(gpa, persistent.type_string, bytes.len) catch return BridgeError.OutOfMemory;
@memcpy(block[0..bytes.len], bytes);
new_slot = .{ .ptr = @intFromPtr(block), .len = @intCast(bytes.len) };
}
@memcpy(slot, std.mem.asBytes(&new_slot));

if (old.ptr != 0) persistent.decref(gpa, @ptrFromInt(old.ptr));
}
};

// ─── Byte ↔ Value conversion ─────────────────────────────────────────────
Expand Down Expand Up @@ -236,6 +291,19 @@ pub fn readBytesAsValue(kind: FieldKind, bytes: []const u8) Value {
@memcpy(std.mem.asBytes(&v), bytes[0..@sizeOf(f64)]);
break :blk .{ .float_ = v };
},
// Borrowed read (M1.0.3 E2, resource-only): decode the `{ptr,len}` slot
// into a `string_persistent` view without incref'ing the block. `ptr==0`
// ⇔ empty string (the no-default / empty-write representation).
.string_ => blk: {
var ss: persistent.StringSlot = undefined;
@memcpy(std.mem.asBytes(&ss), bytes[0..@sizeOf(persistent.StringSlot)]);
break :blk .{ .string_persistent = .{ .ptr = ss.ptr, .len = ss.len } };
},
// Enum reads need the declared type's id (on `FieldDesc`), which this
// byte-only decoder lacks — `readResourceField` handles `.enum_` before
// delegating here, and components never carry `.enum_` (validator-gated).
// Proven invariant: this arm is never reached.
.enum_ => unreachable,
};
}

Expand Down Expand Up @@ -292,6 +360,22 @@ pub fn writeValueAsBytes(kind: FieldKind, bytes: []u8, v: Value) BridgeError!voi
};
@memcpy(bytes[0..@sizeOf(f32)], std.mem.asBytes(&x));
},
// A `.string_` write is a persistent promotion (alloc + copy + decref of
// the previous slot), which needs an allocator and the old slot bytes —
// the POD byte-encoder has neither. Resource string writes route through
// `promoteResourceString`; components never carry `.string_` (validator-
// gated). Reaching here is a bug, surfaced as a typed error, never a panic.
.string_ => return error.TypeMismatch,
// Enum write (M1.0.3 E3): store the variant's declaration-order index as
// the `u32` discriminant. POD — self-contained in the `enum_value`, so
// (unlike `.string_`) it goes through the generic write path.
.enum_ => {
const disc: u32 = switch (v) {
.enum_value => |e| e.variant,
else => return error.TypeMismatch,
};
@memcpy(bytes[0..@sizeOf(u32)], std.mem.asBytes(&disc));
},
}
}

Expand Down
Loading