lib/ui/src/asset/registry.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3
4 const capacity_mod = @import("capacity.zig");
5 const model = @import("model.zig");
6
7 const Allocator = std.mem.Allocator;
8 const AssetHandle = model.AssetHandle;
9 const Bytes = model.Bytes;
10 const FontDescriptor = model.FontDescriptor;
11 const ImageDescriptor = model.ImageDescriptor;
12
13 pub const Registry = struct {
14 phase: alloc_phase.capacity.Phase,
15 limits: Limits,
16 capacity: Capacity,
17 storage: []align(8) u8,
18 fonts: []FontDescriptor,
19 font_bytes: []Bytes,
20 font_generation: []u32,
21 font_used: u32,
22 images: []ImageDescriptor,
23 image_bytes: []Bytes,
24 image_generation: []u32,
25 image_used: u32,
26 strings: []u8,
27 owned_bytes: []u8,
28 font_occupied: []u64,
29 image_occupied: []u64,
30 epoch: u64,
31
32 pub const Limits: type = capacity_mod.Limits;
33 pub const Capacity: type = capacity_mod.Capacity;
34 pub const Exhaustion: type = model.Error;
35 pub const InitError: type = Allocator.Error || capacity_mod.Error;
36 pub const claim: alloc_phase.capacity.Declaration = .{
37 .source = .{
38 .id = "ui.asset_table",
39 .kind = .phase_static,
40 .limit_source = .caller,
41 .storage = .{
42 .covered = &.{.{
43 .id = "asset_table_and_owned_payload_pool",
44 .lifetime = .steady,
45 .detail = "font and image descriptors, byte views, generation and occupancy arrays, family strings, and the copied payload pool in one allocation",
46 }},
47 .excluded = &.{
48 "borrowed font and image bytes kept alive by the caller",
49 "embedded fallback face bytes held in startup static storage",
50 },
51 },
52 .capacity = .{
53 .inputs = &.{
54 alloc_phase.capacity.bindInput(Limits, "fonts", "fonts"),
55 alloc_phase.capacity.bindInput(Limits, "images", "images"),
56 alloc_phase.capacity.bindInput(Limits, "owned_bytes", "owned_bytes"),
57 },
58 .type_selectors = &.{
59 alloc_phase.capacity.bindType(FontDescriptor, "font_descriptor"),
60 alloc_phase.capacity.bindType(ImageDescriptor, "image_descriptor"),
61 alloc_phase.capacity.bindType(Bytes, "bytes"),
62 alloc_phase.capacity.bindType(u32, "generation"),
63 alloc_phase.capacity.bindType(u64, "occupancy_word"),
64 },
65 .nodes = &.{
66 .{ .input = 0 },
67 .{ .input = 1 },
68 .{ .input = 2 },
69 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 0 } } },
70 .{ .alignment = .{ .node = 3, .alignment = .{ .concrete_type = 2 } } },
71 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 2 } } },
72 .{ .add = .{ .left = 4, .right = 5 } },
73 .{ .alignment = .{ .node = 6, .alignment = .{ .concrete_type = 3 } } },
74 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 3 } } },
75 .{ .add = .{ .left = 7, .right = 8 } },
76 .{ .alignment = .{ .node = 9, .alignment = .{ .concrete_type = 4 } } },
77 .{ .constant = 64 },
78 .{ .ceiling_division = .{ .left = 0, .right = 11 } },
79 .{ .scale = .{ .node = 12, .coefficient = .{ .size_of_concrete_type = 4 } } },
80 .{ .add = .{ .left = 10, .right = 13 } },
81 .{ .alignment = .{ .node = 14, .alignment = .{ .concrete_type = 1 } } },
82 .{ .scale = .{ .node = 1, .coefficient = .{ .size_of_concrete_type = 1 } } },
83 .{ .add = .{ .left = 15, .right = 16 } },
84 .{ .alignment = .{ .node = 17, .alignment = .{ .concrete_type = 2 } } },
85 .{ .scale = .{ .node = 1, .coefficient = .{ .size_of_concrete_type = 2 } } },
86 .{ .add = .{ .left = 18, .right = 19 } },
87 .{ .alignment = .{ .node = 20, .alignment = .{ .concrete_type = 3 } } },
88 .{ .scale = .{ .node = 1, .coefficient = .{ .size_of_concrete_type = 3 } } },
89 .{ .add = .{ .left = 21, .right = 22 } },
90 .{ .alignment = .{ .node = 23, .alignment = .{ .concrete_type = 4 } } },
91 .{ .ceiling_division = .{ .left = 1, .right = 11 } },
92 .{ .scale = .{ .node = 25, .coefficient = .{ .size_of_concrete_type = 4 } } },
93 .{ .add = .{ .left = 24, .right = 26 } },
94 .{ .scale = .{ .node = 0, .coefficient = .{ .literal = capacity_mod.family_bytes_per_font } } },
95 .{ .add = .{ .left = 27, .right = 28 } },
96 .{ .add = .{ .left = 29, .right = 2 } },
97 },
98 .assertions = &.{.{
99 .scope = .closure_total,
100 .measure = .retained,
101 .relation = .exact,
102 .expression = 30,
103 }},
104 },
105 .overload = .{
106 .kind = .reject_before_mutation,
107 .detail = "slot, family length, and owned payload quota checks run before changing table fields",
108 },
109 .risks = .{
110 .transitive = .{
111 .status = .open,
112 .detail = "owned bytes use one allocation at initialization; the bounded registration scan takes no allocator",
113 },
114 .foreign = .{
115 .status = .excluded,
116 .detail = "registration and lookup operate on caller and table bytes without an operating-system call",
117 },
118 },
119 .obligations = &.{
120 .{ .key = "ui_asset_capacity", .role = .capacity_model },
121 .{ .key = "ui_asset_acquisition", .role = .acquisition },
122 .{ .key = "ui_asset_generation", .role = .integration },
123 .{ .key = "ui_asset_oom", .role = .overload },
124 .{ .key = "ui_asset_teardown", .role = .teardown },
125 },
126 },
127 .bindings = .{
128 .owner = @This(),
129 .seal = .{
130 .family = alloc_phase.capacity.selector(@This().activate),
131 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
132 },
133 .teardown = .{
134 .family = alloc_phase.capacity.selector(@This().deinit),
135 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
136 },
137 },
138 };
139
140 pub fn init(allocator: Allocator, limits: Limits) InitError!Registry {
141 const capacity = try Capacity.derive(limits);
142 const storage = try allocator.alignedAlloc(u8, .fromByteUnits(8), capacity.total_bytes);
143 var self = Registry{
144 .phase = .initialization,
145 .limits = limits,
146 .capacity = capacity,
147 .storage = storage,
148 .fonts = &.{},
149 .font_bytes = &.{},
150 .font_generation = &.{},
151 .font_used = 0,
152 .images = &.{},
153 .image_bytes = &.{},
154 .image_generation = &.{},
155 .image_used = 0,
156 .strings = &.{},
157 .owned_bytes = &.{},
158 .font_occupied = &.{},
159 .image_occupied = &.{},
160 .epoch = 0,
161 };
162 self.place();
163 @memset(self.storage, 0);
164 for (self.font_bytes) |*entry| entry.* = .{ .borrowed = &.{} };
165 for (self.image_bytes) |*entry| entry.* = .{ .borrowed = &.{} };
166 @memset(self.font_generation, 1);
167 @memset(self.image_generation, 1);
168 return self;
169 }
170
171 pub fn activate(self: *Registry) void {
172 std.debug.assert(self.phase == .initialization);
173 std.debug.assert(self.storage.len == self.capacity.total_bytes);
174 self.phase = .steady;
175 }
176
177 pub fn deinit(self: *Registry, allocator: Allocator) void {
178 std.debug.assert(self.phase != .teardown);
179 allocator.free(self.storage);
180 self.phase = .teardown;
181 self.storage = &.{};
182 self.fonts = &.{};
183 self.font_bytes = &.{};
184 self.font_generation = &.{};
185 self.images = &.{};
186 self.image_bytes = &.{};
187 self.image_generation = &.{};
188 self.strings = &.{};
189 self.owned_bytes = &.{};
190 self.font_occupied = &.{};
191 self.image_occupied = &.{};
192 self.font_used = 0;
193 self.image_used = 0;
194 self.epoch = 0;
195 }
196
197 pub fn registerFont(self: *Registry, family: []const u8, input: FontDescriptor, bytes: Bytes) model.Error!AssetHandle {
198 std.debug.assert(self.phase == .steady);
199 if (family.len > capacity_mod.family_bytes_per_font) return error.FamilyTooLong;
200 const index = firstFree(self.font_occupied, self.limits.fonts) orelse return error.FontCapacityExceeded;
201 const source = bytes.slice();
202 const offset = switch (bytes) {
203 .borrowed => null,
204 .owned => self.findOwned(source.len) orelse return error.OwnedBytesExceeded,
205 };
206 const family_offset = @as(usize, index) * capacity_mod.family_bytes_per_font;
207 var descriptor = input;
208 descriptor.family_offset = @intCast(family_offset);
209 descriptor.family_len = @intCast(family.len);
210 @memcpy(self.strings[family_offset..][0..family.len], family);
211 self.fonts[index] = descriptor;
212 self.font_bytes[index] = self.storeBytes(bytes, offset);
213 setOccupied(self.font_occupied, index, true);
214 self.font_used += 1;
215 self.epoch +%= 1;
216 return .{ .index = index, .generation = self.font_generation[index] };
217 }
218
219 pub fn registerImage(self: *Registry, descriptor: ImageDescriptor, bytes: Bytes) model.Error!AssetHandle {
220 std.debug.assert(self.phase == .steady);
221 const index = firstFree(self.image_occupied, self.limits.images) orelse return error.ImageCapacityExceeded;
222 const source = bytes.slice();
223 const offset = switch (bytes) {
224 .borrowed => null,
225 .owned => self.findOwned(source.len) orelse return error.OwnedBytesExceeded,
226 };
227 self.images[index] = descriptor;
228 self.image_bytes[index] = self.storeBytes(bytes, offset);
229 setOccupied(self.image_occupied, index, true);
230 self.image_used += 1;
231 self.epoch +%= 1;
232 return .{ .index = index, .generation = self.image_generation[index] };
233 }
234
235 pub fn release(self: *Registry, kind: model.Kind, handle: AssetHandle) model.Error!void {
236 std.debug.assert(self.phase == .steady);
237 switch (kind) {
238 .font => {
239 if (!self.hasFont(handle)) return error.StaleAsset;
240 if (handle.generation == std.math.maxInt(u32)) return error.GenerationExhausted;
241 clearOwned(self.font_bytes[handle.index]);
242 const start = @as(usize, handle.index) * capacity_mod.family_bytes_per_font;
243 @memset(self.strings[start..][0..capacity_mod.family_bytes_per_font], 0);
244 self.fonts[handle.index] = .{};
245 self.font_bytes[handle.index] = .{ .borrowed = &.{} };
246 self.font_generation[handle.index] += 1;
247 setOccupied(self.font_occupied, handle.index, false);
248 self.font_used -= 1;
249 self.epoch +%= 1;
250 },
251 .image => {
252 if (!self.hasImage(handle)) return error.StaleAsset;
253 if (handle.generation == std.math.maxInt(u32)) return error.GenerationExhausted;
254 clearOwned(self.image_bytes[handle.index]);
255 self.images[handle.index] = .{};
256 self.image_bytes[handle.index] = .{ .borrowed = &.{} };
257 self.image_generation[handle.index] += 1;
258 setOccupied(self.image_occupied, handle.index, false);
259 self.image_used -= 1;
260 self.epoch +%= 1;
261 },
262 }
263 }
264
265 pub fn hasFont(self: *const Registry, handle: AssetHandle) bool {
266 return handle.index < self.limits.fonts and occupied(self.font_occupied, handle.index) and self.font_generation[handle.index] == handle.generation;
267 }
268
269 pub fn hasImage(self: *const Registry, handle: AssetHandle) bool {
270 return handle.index < self.limits.images and occupied(self.image_occupied, handle.index) and self.image_generation[handle.index] == handle.generation;
271 }
272
273 pub fn font(self: *const Registry, handle: AssetHandle) model.Error!model.Font {
274 if (!self.hasFont(handle)) return error.StaleAsset;
275 const descriptor = self.fonts[handle.index];
276 return .{
277 .descriptor = descriptor,
278 .family = self.strings[descriptor.family_offset..][0..descriptor.family_len],
279 .bytes = self.font_bytes[handle.index].slice(),
280 };
281 }
282
283 pub fn image(self: *const Registry, handle: AssetHandle) model.Error!model.Image {
284 if (!self.hasImage(handle)) return error.StaleAsset;
285 return .{ .descriptor = self.images[handle.index], .bytes = self.image_bytes[handle.index].slice() };
286 }
287
288 fn storeBytes(self: *Registry, bytes: Bytes, offset: ?usize) Bytes {
289 return switch (bytes) {
290 .borrowed => bytes,
291 .owned => |source| blk: {
292 const start = offset.?;
293 @memcpy(self.owned_bytes[start..][0..source.len], source);
294 break :blk .{ .owned = self.owned_bytes[start..][0..source.len] };
295 },
296 };
297 }
298
299 fn findOwned(self: *const Registry, len: usize) ?usize {
300 if (len > self.owned_bytes.len) return null;
301 var candidate: usize = 0;
302 while (candidate <= self.owned_bytes.len - len) {
303 var conflict_end: usize = candidate;
304 for (self.font_bytes, 0..) |entry, index| {
305 if (!occupied(self.font_occupied, @intCast(index))) continue;
306 overlapEnd(self.owned_bytes, entry, candidate, len, &conflict_end);
307 }
308 for (self.image_bytes, 0..) |entry, index| {
309 if (!occupied(self.image_occupied, @intCast(index))) continue;
310 overlapEnd(self.owned_bytes, entry, candidate, len, &conflict_end);
311 }
312 if (conflict_end == candidate) return candidate;
313 candidate = conflict_end;
314 }
315 return null;
316 }
317
318 fn place(self: *Registry) void {
319 var cursor: usize = 0;
320 self.fonts = take(self.storage, &cursor, FontDescriptor, self.limits.fonts);
321 self.font_bytes = take(self.storage, &cursor, Bytes, self.limits.fonts);
322 self.font_generation = take(self.storage, &cursor, u32, self.limits.fonts);
323 self.font_occupied = take(self.storage, &cursor, u64, std.math.divCeil(usize, self.limits.fonts, 64) catch unreachable);
324 self.images = take(self.storage, &cursor, ImageDescriptor, self.limits.images);
325 self.image_bytes = take(self.storage, &cursor, Bytes, self.limits.images);
326 self.image_generation = take(self.storage, &cursor, u32, self.limits.images);
327 self.image_occupied = take(self.storage, &cursor, u64, std.math.divCeil(usize, self.limits.images, 64) catch unreachable);
328 self.strings = take(self.storage, &cursor, u8, self.capacity.string_bytes);
329 self.owned_bytes = take(self.storage, &cursor, u8, self.limits.owned_bytes);
330 std.debug.assert(cursor == self.capacity.total_bytes);
331 }
332 };
333
334 fn take(storage: []align(8) u8, cursor: *usize, comptime T: type, count: usize) []T {
335 cursor.* = std.mem.alignForward(usize, cursor.*, @alignOf(T));
336 const size = count * @sizeOf(T);
337 const bytes: []align(@alignOf(T)) u8 = @alignCast(storage[cursor.*..][0..size]);
338 cursor.* += size;
339 return std.mem.bytesAsSlice(T, bytes);
340 }
341
342 fn occupied(words: []const u64, index: u32) bool {
343 return (words[index / 64] & (@as(u64, 1) << @intCast(index % 64))) != 0;
344 }
345
346 fn setOccupied(words: []u64, index: u32, value: bool) void {
347 const mask = @as(u64, 1) << @intCast(index % 64);
348 if (value) words[index / 64] |= mask else words[index / 64] &= ~mask;
349 }
350
351 fn firstFree(words: []const u64, count: u32) ?u32 {
352 for (words, 0..) |word, word_index| {
353 const first = word_index * 64;
354 const valid = @min(64, count - @as(u32, @intCast(first)));
355 const mask: u64 = if (valid == 64) std.math.maxInt(u64) else (@as(u64, 1) << @intCast(valid)) - 1;
356 const free = ~word & mask;
357 if (free != 0) return @intCast(first + @ctz(free));
358 }
359 return null;
360 }
361
362 fn overlapEnd(pool: []const u8, entry: Bytes, candidate: usize, len: usize, conflict_end: *usize) void {
363 const bytes = switch (entry) {
364 .borrowed => return,
365 .owned => |value| value,
366 };
367 if (bytes.len == 0) return;
368 const start = @intFromPtr(bytes.ptr) - @intFromPtr(pool.ptr);
369 const end = start + bytes.len;
370 if (start < candidate + len and candidate < end) conflict_end.* = @max(conflict_end.*, end);
371 }
372
373 fn clearOwned(entry: Bytes) void {
374 switch (entry) {
375 .borrowed => {},
376 .owned => |bytes| @memset(@constCast(bytes), 0),
377 }
378 }
379
380 comptime {
381 alloc_phase.capacity.requireAllocatorRejectingOwnerShape(Registry);
382 }
383
384 test "ui_asset_capacity default table fits 47104 bytes" {
385 comptime {
386 @stardustClaim(alloc_phase.capacity.witness(Registry, "ui_asset_capacity"), null, null, null, null, null, null);
387 }
388 const derived = try capacity_mod.Capacity.derive(.{});
389 try std.testing.expectEqual(capacity_mod.default_table_bytes, derived.total_bytes);
390 try std.testing.expectError(
391 error.CapacityOverflow,
392 capacity_mod.Capacity.derive(.{ .fonts = std.math.maxInt(u32) }),
393 );
394 }
395
396 fn openAndClose(allocator: Allocator) !void {
397 var registry = try Registry.init(allocator, .{});
398 registry.activate();
399 registry.deinit(allocator);
400 }
401
402 test "ui_asset_acquisition retries after allocation failure" {
403 comptime {
404 @stardustClaim(alloc_phase.capacity.witness(Registry, "ui_asset_acquisition"), null, null, null, null, null, null);
405 }
406 try std.testing.checkAllAllocationFailures(std.testing.allocator, openAndClose, .{});
407 }
408
409 test "ui_asset_generation register release and reuse" {
410 comptime {
411 @stardustClaim(alloc_phase.capacity.witness(Registry, "ui_asset_generation"), null, null, null, null, null, null);
412 }
413 var registry = try Registry.init(std.testing.allocator, .{ .fonts = 1, .images = 0, .owned_bytes = 8 });
414 defer registry.deinit(std.testing.allocator);
415 registry.activate();
416 var caller = [_]u8{ 1, 2, 3 };
417 const first = try registry.registerFont("Mono", .{}, .{ .borrowed = &caller });
418 try std.testing.expectEqualSlices(u8, &caller, (try registry.font(first)).bytes);
419 caller[0] = 4;
420 try std.testing.expectEqual(@as(u8, 4), (try registry.font(first)).bytes[0]);
421 try registry.release(.font, first);
422 try std.testing.expectError(error.StaleAsset, registry.font(first));
423 const next = try registry.registerFont("Mono", .{}, .{ .borrowed = &caller });
424 try std.testing.expectEqual(first.index, next.index);
425 try std.testing.expectEqual(first.generation + 1, next.generation);
426 }
427
428 test "ui_asset_oom registration rejects before mutation" {
429 comptime {
430 @stardustClaim(alloc_phase.capacity.witness(Registry, "ui_asset_oom"), null, null, null, null, null, null);
431 }
432 var registry = try Registry.init(std.testing.allocator, .{ .fonts = 1, .images = 1, .owned_bytes = 3 });
433 defer registry.deinit(std.testing.allocator);
434 registry.activate();
435 const first = try registry.registerFont("A", .{}, .{ .owned = "abc" });
436 try std.testing.expectError(error.FontCapacityExceeded, registry.registerFont("B", .{}, .{ .borrowed = "" }));
437 try std.testing.expectError(error.OwnedBytesExceeded, registry.registerImage(.{}, .{ .owned = "d" }));
438 try std.testing.expectEqual(@as(u32, 1), registry.font_used);
439 try std.testing.expectEqual(@as(u32, 0), registry.image_used);
440 try std.testing.expectEqualStrings("abc", (try registry.font(first)).bytes);
441 }
442
443 test "ui_asset_capacity accepts each maximum and rejects maximum plus one" {
444 var registry = try Registry.init(std.testing.allocator, .{ .fonts = 2, .images = 1, .owned_bytes = 4 });
445 defer registry.deinit(std.testing.allocator);
446 registry.activate();
447 _ = try registry.registerFont("A", .{}, .{ .owned = "ab" });
448 _ = try registry.registerFont("B", .{}, .{ .borrowed = "" });
449 _ = try registry.registerImage(.{}, .{ .owned = "cd" });
450 try std.testing.expectEqual(@as(u32, 2), registry.font_used);
451 try std.testing.expectEqual(@as(u32, 1), registry.image_used);
452 try std.testing.expectError(error.FontCapacityExceeded, registry.registerFont("C", .{}, .{ .borrowed = "" }));
453 try std.testing.expectError(error.ImageCapacityExceeded, registry.registerImage(.{}, .{ .borrowed = "" }));
454 try std.testing.expectEqual(@as(u32, 2), registry.font_used);
455 try std.testing.expectEqual(@as(u32, 1), registry.image_used);
456 }
457
458 test "ui_asset_capacity default slot and owned byte maxima" {
459 var registry = try Registry.init(std.testing.allocator, .{});
460 defer registry.deinit(std.testing.allocator);
461 registry.activate();
462 for (0..registry.limits.fonts) |_| {
463 _ = try registry.registerFont("A", .{}, .{ .borrowed = "" });
464 }
465 try std.testing.expectError(error.FontCapacityExceeded, registry.registerFont("A", .{}, .{ .borrowed = "" }));
466 for (0..registry.limits.images) |_| {
467 _ = try registry.registerImage(.{}, .{ .borrowed = "" });
468 }
469 try std.testing.expectError(error.ImageCapacityExceeded, registry.registerImage(.{}, .{ .borrowed = "" }));
470 try std.testing.expectEqual(registry.limits.fonts, registry.font_used);
471 try std.testing.expectEqual(registry.limits.images, registry.image_used);
472
473 var bytes_registry = try Registry.init(std.testing.allocator, .{ .fonts = 1, .images = 1 });
474 defer bytes_registry.deinit(std.testing.allocator);
475 bytes_registry.activate();
476 const full: [41_328]u8 = @splat(0xA5);
477 const handle = try bytes_registry.registerFont("A", .{}, .{ .owned = &full });
478 try std.testing.expectEqualSlices(u8, &full, (try bytes_registry.font(handle)).bytes);
479 try std.testing.expectError(error.OwnedBytesExceeded, bytes_registry.registerImage(.{}, .{ .owned = "x" }));
480 try std.testing.expectEqual(@as(u32, 0), bytes_registry.image_used);
481 }
482
483 test "ui_asset_teardown borrows caller bytes and returns owned table once" {
484 comptime {
485 @stardustClaim(alloc_phase.capacity.witness(Registry, "ui_asset_teardown"), null, null, null, null, null, null);
486 }
487 var borrowed = [_]u8{ 5, 6 };
488 var registry = try Registry.init(std.testing.allocator, .{ .fonts = 1, .images = 1, .owned_bytes = 3 });
489 registry.activate();
490 const font = try registry.registerFont("A", .{}, .{ .borrowed = &borrowed });
491 var source = [_]u8{ 'x', 'y', 'z' };
492 const image = try registry.registerImage(.{}, .{ .owned = &source });
493 source[0] = 'q';
494 try std.testing.expectEqualSlices(u8, &borrowed, (try registry.font(font)).bytes);
495 try std.testing.expectEqualStrings("xyz", (try registry.image(image)).bytes);
496 try registry.release(.image, image);
497 registry.deinit(std.testing.allocator);
498 borrowed[0] = 7;
499 try std.testing.expectEqual(@as(u8, 7), borrowed[0]);
500 try std.testing.expectEqual(alloc_phase.capacity.Phase.teardown, registry.phase);
501 }