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
10 changes: 6 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

181 changes: 181 additions & 0 deletions briefs/M1.0.9-extension-hooks.md

Large diffs are not rendered by default.

189 changes: 185 additions & 4 deletions src/core/ecs/world.zig
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ const EntityIdentityStore = entity_mod.EntityIdentityStore;
/// M1.0.6 E6 — the `on_attach` extension dispatch seam (D-E). A Tier-0 function
/// pointer the Etch bridge registers; the scene loader fires it after adding an
/// extension's components, passing the entity, the extension name, and the cooked
/// `on_attach` Etch source text (`null` if absent). M1.0.6 wires + fires the seam;
/// running the text is M1.0.9.
/// `on_attach` Etch source text (`null` if absent). M1.0.6 wired + fired the
/// seam; M1.0.9 registers the Etch bridge's callback, which re-parses + runs the
/// text — the seam itself still only fires whatever callback is registered.
pub const ExtensionAttachFn = *const fn (
ctx: ?*anyopaque,
world: *World,
Expand All @@ -108,6 +109,22 @@ pub const ExtensionAttachFn = *const fn (
/// A registered `on_attach` callback + its opaque context.
const AttachHook = struct { ctx: ?*anyopaque, func: ExtensionAttachFn };

/// M1.0.9 — the `on_detach` extension dispatch seam, mirror of
/// `ExtensionAttachFn`. Fired by the runtime `deactivate_extension` path BEFORE
/// removing the extension's components (so the hook still sees them), passing
/// the cooked `on_detach` Etch source text (`null` if absent). Never fired at
/// load — load only activates.
pub const ExtensionDetachFn = *const fn (
ctx: ?*anyopaque,
world: *World,
entity: EntityId,
extension_name: []const u8,
on_detach_text: ?[]const u8,
) anyerror!void;

/// A registered `on_detach` callback + its opaque context.
const DetachHook = struct { ctx: ?*anyopaque, func: ExtensionDetachFn };

/// Top-level ECS world — single archetype list, shared identity, shared
/// registry, shared resources.
pub const World = struct {
Expand Down Expand Up @@ -181,9 +198,25 @@ pub const World = struct {
/// callback the Etch bridge registers; the scene loader fires it after adding
/// an extension's components. `loader.zig` never calls the Etch VM directly —
/// it goes through this hook. **M1.0.6 wires + fires the seam only**; the
/// actual execution of `on_attach_text` (Etch code) is **M1.0.9**.
/// actual execution of `on_attach_text` (Etch code) is **M1.0.9** (wired in
/// the Etch bridge's registered callback, not here — the seam still just
/// fires).
attach_hook: ?AttachHook = null,

/// M1.0.9 — the `on_detach` extension dispatch seam, mirror of `attach_hook`.
/// Registered by the Etch bridge; fired by the runtime deactivate path before
/// removing an extension's components. `null` until registered (last wins).
detach_hook: ?DetachHook = null,

/// M1.0.9 — per-entity active-extension set: an entity → the OWNED copies of
/// the names of the extensions currently active on it, in activation order.
/// Populated by `addEntityExtension` inside the shared activate path (so load
/// AND runtime activation both track for free), pruned by
/// `removeEntityExtension` on deactivate, freed in `deinit`. Not serialized —
/// rebuilt at load from the Entity Extensions Table via the same path. Backs
/// the interpreter's `has_extension` / `active_extensions`.
entity_extensions: std.AutoHashMapUnmanaged(EntityId, std.ArrayListUnmanaged([]const u8)) = .empty,

pub fn init() World {
return .{
.identity = EntityIdentityStore.init(),
Expand Down Expand Up @@ -213,6 +246,15 @@ pub const World = struct {
self.registry.deinit(gpa);
self.identity.deinit(gpa);
self.observer_registry.deinit(gpa);
{
// Free each entity's owned extension-name copies + its list (M1.0.9).
var it = self.entity_extensions.valueIterator();
while (it.next()) |list| {
for (list.items) |name| gpa.free(name);
list.deinit(gpa);
}
self.entity_extensions.deinit(gpa);
}
self.* = undefined;
}

Expand Down Expand Up @@ -298,11 +340,77 @@ pub const World = struct {
/// extension `extension_name`, passing the cooked `on_attach_text` (the Etch
/// hook source; `null` if the extension has no `on_attach`). No-op if no hook
/// is registered. The loader calls this after adding the extension's
/// components. Executing the text is M1.0.9 — here the seam just fires.
/// components. The registered callback (the Etch bridge, M1.0.9) re-parses +
/// executes the text; here the seam just fires it.
pub fn dispatchOnAttach(self: *World, entity: EntityId, extension_name: []const u8, on_attach_text: ?[]const u8) anyerror!void {
if (self.attach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_attach_text);
}

/// M1.0.9 — register the `on_detach` extension dispatch callback (mirror of
/// `registerOnAttach`). One hook per world (last registration wins).
pub fn registerOnDetach(self: *World, ctx: ?*anyopaque, callback: ExtensionDetachFn) void {
self.detach_hook = .{ .ctx = ctx, .func = callback };
}

/// M1.0.9 — fire the `on_detach` seam for `entity`'s extension being
/// deactivated, passing the cooked `on_detach_text` (`null` if absent). The
/// runtime deactivate path calls this BEFORE removing the extension's
/// components, so the hook still sees them. No-op if no hook is registered.
pub fn dispatchOnDetach(self: *World, entity: EntityId, extension_name: []const u8, on_detach_text: ?[]const u8) anyerror!void {
if (self.detach_hook) |h| try h.func(h.ctx, self, entity, extension_name, on_detach_text);
}

/// M1.0.9 — record `name` as an active extension on `entity` (storing an
/// OWNED copy). Called inside the shared activate path after the extension's
/// components are added. A name already present is not duplicated (the
/// activate path rejects a re-activation via component conflict first, so
/// this is belt-and-braces).
pub fn addEntityExtension(self: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8) !void {
const gop = try self.entity_extensions.getOrPut(gpa, entity);
if (!gop.found_existing) gop.value_ptr.* = .empty;
for (gop.value_ptr.items) |existing| {
if (std.mem.eql(u8, existing, name)) return;
}
const owned = try gpa.dupe(u8, name);
errdefer gpa.free(owned);
try gop.value_ptr.append(gpa, owned);
}

/// M1.0.9 — drop `name` from `entity`'s active-extension set, freeing the
/// owned copy. No-op if absent. Removes the map entry once the set empties.
pub fn removeEntityExtension(self: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8) void {
const list = self.entity_extensions.getPtr(entity) orelse return;
var i: usize = 0;
while (i < list.items.len) : (i += 1) {
if (std.mem.eql(u8, list.items[i], name)) {
gpa.free(list.items[i]);
_ = list.orderedRemove(i);
break;
}
}
if (list.items.len == 0) {
list.deinit(gpa);
_ = self.entity_extensions.remove(entity);
}
}

/// M1.0.9 — whether `name` is currently active on `entity`.
pub fn hasEntityExtension(self: *const World, entity: EntityId, name: []const u8) bool {
const list = self.entity_extensions.getPtr(entity) orelse return false;
for (list.items) |existing| {
if (std.mem.eql(u8, existing, name)) return true;
}
return false;
}

/// M1.0.9 — the OWNED names of the extensions active on `entity`, in
/// activation order (empty slice if none). Borrowed view — valid until the
/// entity's set is next mutated.
pub fn entityExtensions(self: *const World, entity: EntityId) []const []const u8 {
const list = self.entity_extensions.getPtr(entity) orelse return &.{};
return list.items;
}

// ─── Component registration helpers ──────────────────────────────────

/// Register a component whose layout is described at runtime.
Expand Down Expand Up @@ -1113,3 +1221,76 @@ fn setTagBit(bytes: []u8, bit: u32, set: bool) void {
}
@memcpy(bytes[off .. off + 8], std.mem.asBytes(&word));
}

test "registerOnDetach / dispatchOnDetach fires the on_detach seam (M1.0.9)" {
const gpa = std.testing.allocator;
var world = World.init();
defer world.deinit(gpa);

const Spy = struct {
var fired: u32 = 0;
var saw_name: bool = false;
var saw_text: bool = false;
fn cb(_: ?*anyopaque, _: *World, _: EntityId, name: []const u8, text: ?[]const u8) anyerror!void {
fired += 1;
if (std.mem.eql(u8, name, "CombatModule")) saw_name = true;
if (text != null and std.mem.indexOf(u8, text.?, "Health") != null) saw_text = true;
}
};
Spy.fired = 0;
Spy.saw_name = false;
Spy.saw_text = false;

const e = EntityId{ .index = 1, .generation = 1 };
const detach_text = "entity.get_mut(Health).max -= 50";

// No hook registered → dispatch is a no-op (mirror of the on_attach seam).
try world.dispatchOnDetach(e, "CombatModule", detach_text);
try std.testing.expectEqual(@as(u32, 0), Spy.fired);

world.registerOnDetach(null, &Spy.cb);
try world.dispatchOnDetach(e, "CombatModule", detach_text);
try std.testing.expectEqual(@as(u32, 1), Spy.fired);
try std.testing.expect(Spy.saw_name);
try std.testing.expect(Spy.saw_text);
}

test "per-entity extension side-table tracks add / has / remove (M1.0.9)" {
const gpa = std.testing.allocator;
var world = World.init();
defer world.deinit(gpa);

const e1 = EntityId{ .index = 1, .generation = 1 };
const e2 = EntityId{ .index = 2, .generation = 1 };

try std.testing.expect(!world.hasEntityExtension(e1, "Combat"));
try std.testing.expectEqual(@as(usize, 0), world.entityExtensions(e1).len);

try world.addEntityExtension(gpa, e1, "Combat");
try world.addEntityExtension(gpa, e1, "Merchant");
try world.addEntityExtension(gpa, e2, "Combat");
// Re-adding the same name is a no-op (belt-and-braces dedup).
try world.addEntityExtension(gpa, e1, "Combat");

try std.testing.expect(world.hasEntityExtension(e1, "Combat"));
try std.testing.expect(world.hasEntityExtension(e1, "Merchant"));
try std.testing.expect(world.hasEntityExtension(e2, "Combat"));

const e1_exts = world.entityExtensions(e1);
try std.testing.expectEqual(@as(usize, 2), e1_exts.len);
try std.testing.expectEqualStrings("Combat", e1_exts[0]); // activation order
try std.testing.expectEqualStrings("Merchant", e1_exts[1]);

world.removeEntityExtension(gpa, e1, "Combat");
try std.testing.expect(!world.hasEntityExtension(e1, "Combat"));
try std.testing.expect(world.hasEntityExtension(e1, "Merchant"));
try std.testing.expectEqual(@as(usize, 1), world.entityExtensions(e1).len);

// e2 still has Combat — the set is per-entity.
try std.testing.expect(world.hasEntityExtension(e2, "Combat"));

// Draining the last extension drops the map entry; `deinit` frees the rest
// (the testing allocator flags any leak of the owned name copies).
world.removeEntityExtension(gpa, e1, "Merchant");
try std.testing.expectEqual(@as(usize, 0), world.entityExtensions(e1).len);
}
97 changes: 87 additions & 10 deletions src/core/scene/loader.zig
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,16 @@ pub fn loadFromBytes(world: *World, gpa: std.mem.Allocator, bytes: []const u8, e
// Extension activation (M1.0.6 E6): add each active extension's components +
// fire the `on_attach` seam. After resources, before `on_spawned`.
try applyExtensions(world, gpa, acc, uuid_to_entity, ext_resolver);
// M1.0.9 — drain the structural commands the `on_attach` hooks queued, AFTER
// the whole activation pass and BEFORE `on_spawned`, so a spawn observer sees
// a fully-materialised entity. `dispatchSpawnLifecycle` also opens with a
// drain; this explicit one keeps the ordering contract local to the load
// sequence (it does not depend on a downstream function's internal drain).
{
var hook_drain = command_buffer_mod.CommandBuffer.init(gpa, world);
defer hook_drain.deinit();
try observers_mod.flushWithObservers(&hook_drain, &world.observer_registry);
}
try dispatchSpawnLifecycle(world, gpa, spawned.items);

return .{
Expand Down Expand Up @@ -310,9 +320,9 @@ fn resolveCrossRefs(world: *World, acc: Accessor, remap: []const ComponentId, uu
/// Table, in table order, activate each of its extensions: resolve the extension
/// `.prefab.bin` by name (Prefab ID Table → `ExtensionResolver`), add its
/// components, and fire the `on_attach` Tier-0 seam. **No-op when the scene has no
/// active extensions** (so an extension-free scene needs no resolver). The actual
/// `on_attach` hook EXECUTION is M1.0.9 — here `dispatchOnAttach` only fires the
/// registered seam with the cooked hook text.
/// active extensions** (so an extension-free scene needs no resolver). The
/// `on_attach` hook EXECUTION (M1.0.9) runs inside the registered seam's callback
/// (the Etch bridge); here `dispatchOnAttach` fires it with the cooked hook text.
fn applyExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to_entity: UuidMap, ext_resolver: ?ExtensionResolver) !void {
const count = acc.extensionsCount();
if (count == 0) return;
Expand All @@ -336,13 +346,15 @@ fn applyExtensions(world: *World, gpa: std.mem.Allocator, acc: Accessor, uuid_to
}
}

/// Activate one extension on one entity (M1.0.6 E6) — the shared path reused by
/// the runtime `activate_extension` entry: open the extension's `.prefab.bin`, add
/// its single entity's components to `entity`, then fire the `on_attach` seam with
/// the cooked hook text. The extension prefab is mono-entity (cooked as such); a
/// component the entity already carries is a conflict (§30.5) — surfaced as
/// Activate one extension on one entity (M1.0.6 E6) — the shared bytes-taking
/// path reused by load (`applyExtensions`), the runtime `activate_extension`
/// entry, AND the interpreter's deferred B1 flush: open the extension's
/// `.prefab.bin`, add its single entity's components to `entity`, record the
/// active extension, then fire the `on_attach` seam with the cooked hook text.
/// The extension prefab is mono-entity (cooked as such); a component the entity
/// already carries is a conflict (§30.5) — surfaced as
/// `error.ExtensionComponentConflict` rather than the dynamic-add assert.
fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void {
pub fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void {
const ext = try openVerified(ext_bytes);

// Mono-entity: the extension's components live on its single entity.
Expand All @@ -365,11 +377,76 @@ fn activateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, na
}
}

// Fire the `on_attach` dispatch seam (D-E). Executing the text is M1.0.9.
// Record the extension as active on the entity BEFORE firing `on_attach`, so
// a hook that queries `has_extension` / `active_extensions` sees it (M1.0.9).
// Tracked here means load AND runtime activation both track for free.
try world.addEntityExtension(gpa, entity, name);

// Fire the `on_attach` dispatch seam (D-E). M1.0.9 — the Etch bridge's
// registered callback re-parses + executes `on_attach_text` against the live
// world; with no bridge registered (Tier-0 tests) the seam is a no-op.
const on_attach_text: ?[]const u8 = if (ext.hookCount() > 0) ext.hook(0).on_attach else null;
try world.dispatchOnAttach(entity, name, on_attach_text);
}

/// M1.0.9 — runtime extension activation entry, reached from Etch
/// `entity.activate_extension("X")` (the interpreter resolves the name through
/// the bridge's `ExtensionResolver`). Reuses the shared `activateExtension`
/// path: add components → record the active extension → fire `on_attach`.
/// Unknown name → `error.UnknownExtension`; a component the entity already
/// carries → `error.ExtensionComponentConflict` (§30.5 reject policy).
pub fn runtimeActivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void {
const bytes = resolver.resolve(name) orelse return error.UnknownExtension;
try activateExtension(world, gpa, entity, name, bytes);
}

/// M1.0.9 — runtime extension deactivation entry, reached from Etch
/// `entity.deactivate_extension("X")`. Fires the `on_detach` seam FIRST (so the
/// hook still reads the extension's components), then removes the extension's
/// declared components and drops the entity's active-extension record. The
/// extension must be active (`error.ExtensionNotActive` otherwise). The §30.5
/// reject conflict policy makes the component set unambiguous — no two active
/// extensions share a component — so removal needs no provenance tracking.
/// M1.0.9 — deactivate one extension on one entity given its cooked bytes: the
/// shared bytes-taking core reused by the runtime deactivate entry AND the
/// interpreter's deferred B1 flush. Fires `on_detach` FIRST (the hook still reads
/// the extension's components), then removes them, then drops the active record.
/// The extension must be active (`error.ExtensionNotActive`). The §30.5 reject
/// conflict policy makes the component set unambiguous — no provenance tracking.
pub fn deactivateExtension(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, ext_bytes: []const u8) !void {
if (!world.hasEntityExtension(entity, name)) return error.ExtensionNotActive;
const ext = try openVerified(ext_bytes);

// `on_detach` before the components go away (the hook can still read them).
const on_detach_text: ?[]const u8 = if (ext.hookCount() > 0) ext.hook(0).on_detach else null;
try world.dispatchOnDetach(entity, name, on_detach_text);

// Remove the extension's declared components (mono-entity, like activate).
var ai: u32 = 0;
while (ai < ext.archetypeCount()) : (ai += 1) {
const arch = ext.archetype(ai);
if (arch.entity_count == 0) continue;
var c: usize = 0;
while (c < arch.component_count) : (c += 1) {
const sch = ext.schema(arch.schemaIndex(c));
const cid = world.componentId(sch.name) orelse return error.UnknownComponent;
if (world.componentBytes(entity, cid) != null) {
try world.removeComponentDynamic(gpa, entity, cid);
}
}
}

world.removeEntityExtension(gpa, entity, name);
}

/// M1.0.9 — runtime deactivation entry (direct-programmatic path): resolve the
/// extension by name, then `deactivateExtension`. The Etch method goes through
/// the interpreter's deferred queue instead (B1); this stays for direct callers.
pub fn runtimeDeactivate(world: *World, gpa: std.mem.Allocator, entity: EntityId, name: []const u8, resolver: ExtensionResolver) !void {
const bytes = resolver.resolve(name) orelse return error.UnknownExtension;
try deactivateExtension(world, gpa, entity, name, bytes);
}

/// Load the resources block (E3) — the load-side mirror of M1.0.3's non-POD
/// resource path. For each resource: resolve its schema index → runtime
/// `ComponentId` (the E1 remap, already size/alignment-validated), copy the POD
Expand Down
Loading