From 16cc638f0bec64edee7164bec6ad6562c7d3e6a6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 5 Oct 2026 04:21:07 -0500 Subject: [PATCH] Compile text and glyph cache coordination in TypeScript - Own cache key matching, admission, retention, eviction, and ordered failure state in TypeScript. - Preserve native resource lifetimes with copied cache plans and compiled parity coverage. --- packages/core/src/runtime_policy.ts | 56 +++++++ packages/core/test/text_cache.test.ts | 38 +++++ src/primitives/canvas/frame.zig | 7 +- src/primitives/canvas/text_atlas.zig | 29 ++++ src/primitives/canvas/text_cache_policy.zig | 94 ++++++++++++ src/primitives/canvas/text_layout_cache.zig | 29 ++++ src/runtime/api.zig | 1 + src/runtime/canvas_frame.zig | 8 +- src/runtime/core.zig | 1 + src/runtime/flow.zig | 1 + src/runtime/ts_ui_app.zig | 2 + src/runtime/ui_app.zig | 2 + tests/ts-core/surface_layout_e2e_tests.zig | 3 + tests/ts-core/text_cache_e2e_tests.zig | 153 ++++++++++++++++++++ 14 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/text_cache.test.ts create mode 100644 src/primitives/canvas/text_cache_policy.zig create mode 100644 tests/ts-core/text_cache_e2e_tests.zig diff --git a/packages/core/src/runtime_policy.ts b/packages/core/src/runtime_policy.ts index e30fe1246..4a1d39bc6 100644 --- a/packages/core/src/runtime_policy.ts +++ b/packages/core/src/runtime_policy.ts @@ -116,6 +116,7 @@ export function native_window_policy(request: Uint8Array): Uint8Array { if (request[0] === 8) return nscvVirtualFlow(request); if (request[0] === 9) return nscvSemanticTree(request); if (request[0] === 10) return nscvExtentPolicy(request); + if (request[0] === 11) return nscvTextCachePolicy(request); const data = new DataView(request.buffer, request.byteOffset, request.byteLength); let at = 0; const byte = (): number => { @@ -1518,3 +1519,58 @@ function nscvExtentCorrections(request:Uint8Array):Uint8Array{ for(let i=0;i 1 || request[2] !== 0 || request[3] !== 0) throw new Error("invalid text cache request"); + const w = new DataView(request.buffer, request.byteOffset, request.byteLength); + const current = w.getUint32(4,true), previous = w.getUint32(8,true), capacity = w.getUint32(12,true), actionCapacity = w.getUint32(16,true), total = current + previous; + if (total > 4294967295 || request.length !== 48 + total * 80 || w.getUint32(20,true)!==0 || w.getUint32(40,true)!==0 || w.getUint32(44,true)!==0) throw new Error("invalid text cache shape"); + const glyph = request[1] === 1, slots = glyph ? 32768 : 8192, indexed = (current >= 64 || previous >= 64) && total <= slots / 2; + const equal = (a:number,b:number):boolean => { + const x=48+a*80,y=48+b*80; + const floats = glyph ? 1 : 6; + for(let i=0;i=0;i--){const source=current+i,bucket=w.getUint32(48+source*80+72,true)&(slots-1);prevNext[i]=prevHeads[bucket]!;prevHeads[bucket]=i+1;} + } + const lookup = (source:number,old:boolean):number => { + if(indexed){const heads=old?prevHeads:entryHeads,next=old?prevNext:entryNext,bucket=w.getUint32(48+source*80+72,true)&(slots-1);let match=-1; + for(let stored=heads[bucket]!;stored>0;stored=next[stored-1]!){const i=stored-1;if(equal(source,old?current+i:entries[i]!) && (match<0 || i{if(entries.length>=capacity)return false;const index=entries.length;entries.push(source);if(indexed){const bucket=w.getUint32(48+source*80+72,true)&(slots-1);entryNext[index]=entryHeads[bucket]!;entryHeads[bucket]=index+1;}return true;}; + const appendAction=(kind:number,source:number,cache:number):boolean=>{if(actions.length/3>=actionCapacity)return false;actions.push(kind,source,cache);return true;}; + for(let i=0;i=0)continue; + const old=lookup(i,true); + if(!appendEntry(i) || !appendAction(old<0?0:1,i,old)){failed=true;break;} + } + if(!failed)for(let i=0;i=0)continue; + const last={low:w.getUint32(48+source*80+64,true),high:w.getUint32(48+source*80+68,true)},frame={low:w.getUint32(24,true),high:w.getUint32(28,true)},retention={low:w.getUint32(32,true),high:w.getUint32(36,true)}; + const warm=!nscvFlowZero(retention) && (nscvFlowCompare(frame,last)<=0 || nscvFlowCompare(nscvFlowSubtract(frame,last),retention)<=0); + if(warm && entries.lengthw.setUint32(4+i*4,v,true));w.setBigUint64(24,frame,true);w.setBigUint64(32,retention,true); + [...current,...previous].forEach((v,i)=>{const a=48+i*80;w.setFloat32(a,v.size??12,true);w.setUint32(a+(glyph?4:24),v.key,true);w.setBigUint64(a+64,v.last??0n,true);w.setUint32(a+72,v.bucket??v.key,true);});return r; +} +function decode(r:Uint8Array){const w=new DataView(r.buffer),n=w.getUint32(0,true),m=w.getUint32(4,true);return {failed:w.getUint32(8,true),entries:Array.from({length:n},(_,i)=>w.getUint32(16+i*4,true)),actions:Array.from({length:m},(_,i)=>Array.from({length:3},(_,j)=>w.getUint32(16+n*4+i*12+j*4,true)))};} +for(const glyph of [false,true]){ + test(`${glyph?"glyph":"layout"} cache preserves first matches duplicates and retained order`,()=>{ + const out=decode(native_window_policy(request([{key:2},{key:2},{key:3}],[{key:2,last:1n},{key:2,last:1n},{key:4,last:9n},{key:4,last:9n},{key:5,last:1n}],100,100,10n,2n,glyph))); + assert.deepEqual(out,{failed:0,entries:[0,2,5],actions:[[1,0,0],[0,2,4294967295],[1,5,2],[2,7,4]]}); + }); + test(`${glyph?"glyph":"layout"} cache capacity errors preserve partial writes`,()=>{ + const c=[{key:1},{key:2}];assert.deepEqual(decode(native_window_policy(request(c,[],1,8,10n,2n,glyph))),{failed:1,entries:[0],actions:[[0,0,4294967295]]}); + assert.deepEqual(decode(native_window_policy(request(c,[],8,1,10n,2n,glyph))),{failed:1,entries:[0,1],actions:[[0,0,4294967295]]}); + const old=[{key:3,last:10n},{key:4,last:10n}];assert.deepEqual(decode(native_window_policy(request([],old,1,8,10n,2n,glyph))),{failed:0,entries:[0],actions:[[1,0,0],[2,1,1]]}); + }); + test(`${glyph?"glyph":"layout"} cache retention uses exact uint64 boundaries`,()=>{ + for(const frame of [0n,9007199254740993n,18446744073709551615n])for(const retention of [0n,1n,99n,18446744073709551615n])for(const last of [0n,frame,frame>0n?frame-1n:0n,frame>99n?frame-99n:0n]){ + const warm=retention>0n&&(frame<=last||frame-last<=retention);const out=decode(native_window_policy(request([],[{key:1,last}],1,1,frame,retention,glyph)));assert.equal(out.actions[0]![0],warm?1:2);assert.equal(out.entries.length,warm?1:0); + } + }); + test(`${glyph?"glyph":"layout"} cache handles signed zero NaN and hash collisions`,()=>{ + const current=Array.from({length:70},(_,i)=>({key:i%35,size:i%3===0?-0:0,bucket:7})),previous=Array.from({length:35},(_,key)=>({key,size:0,bucket:7,last:10n})); + const out=decode(native_window_policy(request(current,previous,100,100,10n,2n,glyph)));assert.equal(out.entries.length,35);assert.ok(out.actions.every(v=>v[0]===1));assert.deepEqual(out.actions.map(v=>v[2]),Array.from({length:35},(_,i)=>i)); + const nan=decode(native_window_policy(request([{key:1,size:NaN},{key:1,size:NaN}],[{key:1,size:NaN,last:10n}],10,10,10n,2n,glyph)));assert.deepEqual(nan.entries,[0,1,2]);assert.deepEqual(nan.actions.map(v=>v[0]),[0,0,1]); + }); + test(`${glyph?"glyph":"layout"} cache copied result survives later calls and oversized linear inputs`,()=>{ + const count=glyph?16385:4097,c=Array.from({length:count},(_,key)=>({key})),out=native_window_policy(request(c,[],count,count,10n,2n,glyph)),saved=out.slice();assert.equal(decode(out).entries.length,count);native_window_policy(request([],[]));assert.deepEqual(out,saved); + }); +} +test("cache wire rejects truncated extended reserved and invalid discriminator requests",()=>{ + const q=request([{key:1}],[]);for(let n=1;nnative_window_policy(q.subarray(0,n))); + assert.throws(()=>native_window_policy(new Uint8Array([...q,0])));for(const at of [1,2,3,20,40,44]){const bad=q.slice();bad[at]=3;assert.throws(()=>native_window_policy(bad));} +}); diff --git a/src/primitives/canvas/frame.zig b/src/primitives/canvas/frame.zig index 3e3d3fc63..714c9ed18 100644 --- a/src/primitives/canvas/frame.zig +++ b/src/primitives/canvas/frame.zig @@ -453,6 +453,7 @@ pub const CanvasFrame = struct { }; pub const CanvasFrameOptions = struct { + text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, frame_index: u64 = 0, timestamp_ns: u64 = 0, surface_size: geometry.SizeF = .{}, @@ -585,7 +586,8 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv storage.visual_effect_cache_actions, ); const glyph_atlas_plan = try next.glyphAtlasPlan(storage.glyph_atlas_entries); - const glyph_atlas_cache_plan = try glyph_atlas_plan.cachePlanWithRetention( + const glyph_atlas_cache_plan = try glyph_atlas_plan.cachePlanWithPolicy( + options.text_cache_policy, options.previous_glyph_atlas_cache, options.frame_index, options.glyph_atlas_cache_retention_frames, @@ -596,7 +598,8 @@ pub fn buildCanvasFrame(previous: ?DisplayList, next: DisplayList, options: Canv const text_layout_cache_plan = if (storage.text_layout_cache_entries.len == 0 and storage.text_layout_cache_actions.len == 0) TextLayoutCachePlan{} else - try text_layout_plan.cachePlanWithRetention( + try text_layout_plan.cachePlanWithPolicy( + options.text_cache_policy, options.previous_text_layout_cache, options.frame_index, options.text_layout_cache_retention_frames, diff --git a/src/primitives/canvas/text_atlas.zig b/src/primitives/canvas/text_atlas.zig index 7d20372be..729826f25 100644 --- a/src/primitives/canvas/text_atlas.zig +++ b/src/primitives/canvas/text_atlas.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("text_cache_policy.zig"); const std = @import("std"); const canvas = @import("root.zig"); const text_interaction = @import("text_interaction.zig"); @@ -49,6 +50,11 @@ pub const GlyphAtlasPlan = struct { var planner = GlyphAtlasCachePlanner.init(entries, actions); return planner.build(self, previous, frame_index, retention_frames); } + pub fn cachePlanWithPolicy(self: GlyphAtlasPlan, policy: ?compiled_cache.Policy, previous: []const GlyphAtlasCacheEntry, frame_index: u64, retention_frames: u64, entries: []GlyphAtlasCacheEntry, actions: []GlyphAtlasCacheAction) Error!GlyphAtlasCachePlan { + var planner = GlyphAtlasCachePlanner.init(entries, actions); + if (policy) |owner| return planner.buildCompiled(self, previous, frame_index, retention_frames, owner); + return planner.build(self, previous, frame_index, retention_frames); + } }; pub const GlyphAtlasPlanner = struct { @@ -287,6 +293,29 @@ pub const GlyphAtlasCachePlanner = struct { }; } + pub fn buildCompiled(self: *GlyphAtlasCachePlanner, plan: GlyphAtlasPlan, previous: []const GlyphAtlasCacheEntry, frame_index: u64, retention_frames: u64, policy: compiled_cache.Policy) Error!GlyphAtlasCachePlan { + self.reset(); + const current = plan.entries; + var decision = compiled_cache.Plan.init(true, current.len, previous.len, self.entries.len, self.actions.len, frame_index, retention_frames); + defer decision.deinit(); + for (current, 0..) |entry, i| decision.fact(i, entry.key, glyphAtlasKeyHash(entry.key), 0); + for (previous, 0..) |entry, i| decision.fact(current.len + i, entry.key, glyphAtlasKeyHash(entry.key), entry.last_used_frame); + decision.run(policy); + for (0..decision.entry_count) |i| { + const source = decision.source(i); + self.entries[i] = if (source < current.len) .{ .key = current[source].key, .last_used_frame = frame_index } else previous[source - current.len]; + } + self.entry_len = decision.entry_count; + for (0..decision.action_count) |i| { + const action = decision.action(i); + const source = action.source; + self.actions[i] = .{ .kind = @enumFromInt(action.kind), .key = if (source < current.len) current[source].key else previous[source - current.len].key, .atlas_index = if (source < current.len) source else null, .cache_index = action.cache }; + } + self.action_len = decision.action_count; + if (decision.failed) return error.GlyphAtlasCacheListFull; + return .{ .entries = self.entries[0..self.entry_len], .actions = self.actions[0..self.action_len] }; + } + /// First appended entry equal to `key`, walking the entry index's /// probe chain — the indexed equivalent of scanning /// `self.entries[0..self.entry_len]`. diff --git a/src/primitives/canvas/text_cache_policy.zig b/src/primitives/canvas/text_cache_policy.zig new file mode 100644 index 000000000..33a4ccfac --- /dev/null +++ b/src/primitives/canvas/text_cache_policy.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +pub const Policy = *const fn ([]const u8, []u8) usize; + +/// Copied key facts and output indices. Native retains all layouts, glyphs, +/// resource storage and hash capabilities for the lifetime of the frame. +pub const Plan = struct { + request: []u8, + result: []u8, + current: usize, + previous: usize, + entry_count: usize = 0, + action_count: usize = 0, + failed: bool = false, + pub fn init(glyph: bool, current: usize, previous: usize, capacity: usize, action_capacity: usize, frame: u64, retention: u64) Plan { + const total = current + previous; + const request = std.heap.page_allocator.alloc(u8, 48 + total * 80) catch @panic("text cache request allocation failed"); + const result = std.heap.page_allocator.alloc(u8, 16 + @min(capacity, total) * 4 + @min(action_capacity, total) * 12) catch @panic("text cache result allocation failed"); + @memset(request, 0); + request[0] = 11; + request[1] = if (glyph) 1 else 0; + putWord(request, 4, current); + putWord(request, 8, previous); + putWord(request, 12, capacity); + putWord(request, 16, action_capacity); + putInt(request, 24, frame); + putInt(request, 32, retention); + return .{ .request = request, .result = result, .current = current, .previous = previous }; + } + pub fn deinit(self: Plan) void { + std.heap.page_allocator.free(self.request); + std.heap.page_allocator.free(self.result); + } + pub fn fact(self: Plan, index: usize, key: anytype, hash: u64, last: u64) void { + const bytes = self.request[48 + index * 80 ..][0..80]; + const T = @TypeOf(key); + putFloat(bytes, 0, key.size); + if (comptime @hasField(T, "glyph_id")) { + putWord(bytes, 4, key.glyph_id); + putInt(bytes, 8, key.font_id); + putWord(bytes, 16, key.subpixel_x); + putWord(bytes, 20, key.subpixel_y); + } else { + putFloat(bytes, 4, key.origin.x); + putFloat(bytes, 8, key.origin.y); + putFloat(bytes, 12, key.max_width); + putFloat(bytes, 16, key.line_height); + // The sixth float is a reserved zero; equality remains exact. + putInt(bytes, 24, key.font_id); + putInt(bytes, 32, key.fingerprint); + putInt(bytes, 40, key.text_len); + putInt(bytes, 48, key.glyph_count); + putWord(bytes, 56, @intFromEnum(key.wrap)); + bytes[60] = @intCast(@intFromEnum(key.alignment)); + bytes[61] = @intCast(@intFromEnum(key.overflow)); + } + putInt(bytes, 64, last); + putWord(bytes, 72, @intCast(hash & 0xffffffff)); + } + pub fn run(self: *Plan, policy: Policy) void { + const length = policy(self.request, self.result); + if (length < 16) @panic("truncated compiled text cache result"); + self.entry_count = word(self.result, 0); + self.action_count = word(self.result, 4); + const status = word(self.result, 8); + if (status > 1 or word(self.result, 12) != 0 or self.entry_count > word(self.request, 12) or self.action_count > word(self.request, 16) or length != 16 + self.entry_count * 4 + self.action_count * 12) @panic("invalid compiled text cache result"); + self.failed = status == 1; + } + pub fn source(self: Plan, index: usize) usize { + const value = word(self.result, 16 + index * 4); + if (value >= self.current + self.previous) @panic("invalid compiled text cache entry source"); + return value; + } + pub const Action = struct { kind: u32, source: usize, cache: ?usize }; + pub fn action(self: Plan, index: usize) Action { + const at = 16 + self.entry_count * 4 + index * 12; + const kind = word(self.result, at); + const source_index = word(self.result, at + 4); + const cache = word(self.result, at + 8); + if (kind > 2 or source_index >= self.current + self.previous or (cache != 0xffffffff and cache >= self.previous)) @panic("invalid compiled text cache action"); + return .{ .kind = kind, .source = source_index, .cache = if (cache == 0xffffffff) null else cache }; + } +}; +fn putWord(bytes: []u8, at: usize, value: usize) void { + std.mem.writeInt(u32, bytes[at..][0..4], std.math.cast(u32, value) orelse @panic("text cache wire integer overflow"), .little); +} +fn putInt(bytes: []u8, at: usize, value: u64) void { + std.mem.writeInt(u64, bytes[at..][0..8], value, .little); +} +fn putFloat(bytes: []u8, at: usize, value: f32) void { + std.mem.writeInt(u32, bytes[at..][0..4], @bitCast(value), .little); +} +fn word(bytes: []const u8, at: usize) u32 { + return std.mem.readInt(u32, bytes[at..][0..4], .little); +} diff --git a/src/primitives/canvas/text_layout_cache.zig b/src/primitives/canvas/text_layout_cache.zig index d7c7bf483..52d701489 100644 --- a/src/primitives/canvas/text_layout_cache.zig +++ b/src/primitives/canvas/text_layout_cache.zig @@ -1,3 +1,4 @@ +const compiled_cache = @import("text_cache_policy.zig"); const geometry = @import("geometry"); const canvas = @import("root.zig"); const text_layout_types = @import("text_layout_types.zig"); @@ -49,6 +50,11 @@ pub const TextLayoutPlanSet = struct { var planner = TextLayoutCachePlanner.init(entries, actions); return planner.buildMany(self.plans, previous, frame_index, retention_frames); } + pub fn cachePlanWithPolicy(self: TextLayoutPlanSet, policy: ?compiled_cache.Policy, previous: []const TextLayoutCacheEntry, frame_index: u64, retention_frames: u64, entries: []TextLayoutCacheEntry, actions: []TextLayoutCacheAction) Error!TextLayoutCachePlan { + var planner = TextLayoutCachePlanner.init(entries, actions); + if (policy) |owner| return planner.buildCompiled(self, previous, frame_index, retention_frames, owner); + return planner.buildMany(self.plans, previous, frame_index, retention_frames); + } }; pub const TextLayoutCacheEntry = struct { @@ -200,6 +206,29 @@ pub const TextLayoutCachePlanner = struct { }; } + pub fn buildCompiled(self: *TextLayoutCachePlanner, plan: TextLayoutPlanSet, previous: []const TextLayoutCacheEntry, frame_index: u64, retention_frames: u64, policy: compiled_cache.Policy) Error!TextLayoutCachePlan { + self.reset(); + const current = plan.plans; + var decision = compiled_cache.Plan.init(false, current.len, previous.len, self.entries.len, self.actions.len, frame_index, retention_frames); + defer decision.deinit(); + for (current, 0..) |entry, i| decision.fact(i, entry.key, textLayoutKeyHash(entry.key), 0); + for (previous, 0..) |entry, i| decision.fact(current.len + i, entry.key, textLayoutKeyHash(entry.key), entry.last_used_frame); + decision.run(policy); + for (0..decision.entry_count) |i| { + const source = decision.source(i); + self.entries[i] = if (source < current.len) .{ .key = current[source].key, .line_count = current[source].lineCount(), .bounds = current[source].layout.bounds, .last_used_frame = frame_index } else previous[source - current.len]; + } + self.entry_len = decision.entry_count; + for (0..decision.action_count) |i| { + const action = decision.action(i); + const source = action.source; + self.actions[i] = .{ .kind = @enumFromInt(action.kind), .key = if (source < current.len) current[source].key else previous[source - current.len].key, .layout_index = if (source < current.len) source else null, .cache_index = action.cache }; + } + self.action_len = decision.action_count; + if (decision.failed) return error.TextLayoutCacheListFull; + return .{ .entries = self.entries[0..self.entry_len], .actions = self.actions[0..self.action_len] }; + } + /// First appended entry equal to `key`, walking the entry index's /// probe chain — the indexed equivalent of scanning /// `self.entries[0..self.entry_len]`. diff --git a/src/runtime/api.zig b/src/runtime/api.zig index 278bf30f8..2267be05c 100644 --- a/src/runtime/api.zig +++ b/src/runtime/api.zig @@ -428,6 +428,7 @@ pub fn App(comptime Runtime: type) type { /// Portable shell frame planning. Results are copied into native /// storage before any OS operation; the owning core cycle resets. shell_layout_policy: ?*const fn ([]const u8, []u8) usize = null, + text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, start_fn: ?StartFn = null, event_fn: ?EventFn = null, stop_fn: ?StopFn = null, diff --git a/src/runtime/canvas_frame.zig b/src/runtime/canvas_frame.zig index e6317a27c..49b638419 100644 --- a/src/runtime/canvas_frame.zig +++ b/src/runtime/canvas_frame.zig @@ -219,6 +219,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { if (self.views[index].kind != .gpu_surface) return error.InvalidViewOptions; var frame_options = options; + if (frame_options.text_cache_policy == null) frame_options.text_cache_policy = self.text_cache_policy; if (frame_options.surface_size.isEmpty()) frame_options.surface_size = self.views[index].frame.size(); frame_options.backdrop_blur_sample_extent_multiplier = backdropBlurSampleExtentMultiplier(self.options.platform.name); return self.views[index].canvasDisplayList().framePlan(previous, frame_options, storage); @@ -875,6 +876,7 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { launch_timing.lapOnce("first_plan_begin"); defer launch_timing.lapOnce("first_plan_done"); var frame_options = options; + if (frame_options.text_cache_policy == null) frame_options.text_cache_policy = self.text_cache_policy; frame_options.backdrop_blur_sample_extent_multiplier = backdropBlurSampleExtentMultiplier(self.options.platform.name); if (frame_options.surface_size.isEmpty()) { frame_options.surface_size = if (self.views[index].gpu_size.isEmpty()) self.views[index].frame.size() else self.views[index].gpu_size; @@ -1017,7 +1019,8 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { storage.visual_effect_cache_actions, ); const glyph_atlas_plan = try display_list.glyphAtlasPlan(storage.glyph_atlas_entries); - const glyph_atlas_cache_plan = try glyph_atlas_plan.cachePlanWithRetention( + const glyph_atlas_cache_plan = try glyph_atlas_plan.cachePlanWithPolicy( + frame_options.text_cache_policy, frame_options.previous_glyph_atlas_cache, frame_options.frame_index, frame_options.glyph_atlas_cache_retention_frames, @@ -1044,7 +1047,8 @@ pub fn RuntimeCanvasFrames(comptime Runtime: type) type { const text_layout_cache_plan = if (storage.text_layout_cache_entries.len == 0 and storage.text_layout_cache_actions.len == 0) canvas.TextLayoutCachePlan{} else - try text_layout_plan.cachePlanWithRetention( + try text_layout_plan.cachePlanWithPolicy( + frame_options.text_cache_policy, frame_options.previous_text_layout_cache, frame_options.frame_index, frame_options.text_layout_cache_retention_frames, diff --git a/src/runtime/core.zig b/src/runtime/core.zig index aaf509410..1ab521fbe 100644 --- a/src/runtime/core.zig +++ b/src/runtime/core.zig @@ -259,6 +259,7 @@ pub const Runtime = struct { shell_layouts: [platform.max_windows]RuntimeShellLayout = undefined, shell_layout_count: usize = 0, shell_layout_policy: ?*const fn ([]const u8, []u8) usize = null, + text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, /// Reused by runtime-created window restoration. `Store.loadWindow` /// needs the whole bounded state file plus decoded-string scratch; keep /// that 64-KiB workspace on the already heap-owned Runtime instead of diff --git a/src/runtime/flow.zig b/src/runtime/flow.zig index 57d6c83d9..8398f71ba 100644 --- a/src/runtime/flow.zig +++ b/src/runtime/flow.zig @@ -228,6 +228,7 @@ pub fn RuntimeFlow(comptime Runtime: type) type { switch (event_value) { .app_start => { self.shell_layout_policy = app.shell_layout_policy; + self.text_cache_policy = app.text_cache_policy; // A startup failure must be loud and attributable. // Without this record the error unwinds into the // platform stop path and the only trace is a bare diff --git a/src/runtime/ts_ui_app.zig b/src/runtime/ts_ui_app.zig index a555265a8..ef0bf549d 100644 --- a/src/runtime/ts_ui_app.zig +++ b/src/runtime/ts_ui_app.zig @@ -424,6 +424,8 @@ pub fn TsUiAppWithFeatures(comptime core: type, comptime features: ui_app.UiAppF if (comptime @hasDecl(core, "nativeWindowPolicy")) { if (options.shell_layout_policy != null) @panic("TsUiApp owns shell_layout_policy - remove custom shell layout wiring"); stamped.shell_layout_policy = core.nativeWindowPolicy; + if (options.text_cache_policy != null) @panic("TsUiApp owns text_cache_policy - remove custom text cache wiring"); + stamped.text_cache_policy = core.nativeWindowPolicy; } if (comptime @hasDecl(core, "nativeTabFocusPolicy")) { if (options.tab_focus_policy != null) @panic("TsUiApp owns tab_focus_policy - remove custom focus policy wiring"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 50208460e..2435c0388 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -484,6 +484,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe name: []const u8, scene: app_manifest.ShellConfig, shell_layout_policy: ?*const fn ([]const u8, []u8) usize = null, + text_cache_policy: ?*const fn ([]const u8, []u8) usize = null, canvas_label: []const u8, /// Fixed design tokens for an app that owns its look. Leave /// null (the default) and the stock tokens FOLLOW THE SYSTEM @@ -1506,6 +1507,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .name = self.options.name, .scene_fn = sceneFn, .shell_layout_policy = self.options.shell_layout_policy, + .text_cache_policy = self.options.text_cache_policy, .event_fn = eventFn, .stop_fn = stopFn, .replay_fn = replayFn, diff --git a/tests/ts-core/surface_layout_e2e_tests.zig b/tests/ts-core/surface_layout_e2e_tests.zig index d9145feab..ea51199aa 100644 --- a/tests/ts-core/surface_layout_e2e_tests.zig +++ b/tests/ts-core/surface_layout_e2e_tests.zig @@ -46,6 +46,7 @@ pub fn expectComplete(expected: anytype, actual: @TypeOf(expected)) anyerror!voi test { _ = @import("semantic_tree_e2e_tests.zig"); _ = @import("virtual_extent_e2e_tests.zig"); + _ = @import("text_cache_e2e_tests.zig"); } test "compiled modal defaults and f32 boundaries match the native reference" { @@ -216,6 +217,7 @@ test "TypeScript app adapter supplies placement through every theme path" { for (0..3) |mode| { const app = try Adapter.create(std.testing.allocator, .{}, .{ .name = "surface-layout", .scene = .{}, .canvas_label = "canvas", .view = Factory.view, .window_view = Factory.windowView, .tokens = if (mode == 1) .{} else null, .tokens_fn = if (mode == 2) Factory.tokens else null }); defer app.destroy(); + try std.testing.expect(app.app().text_cache_policy == core.nativeWindowPolicy); const tokens = app.effectiveTokens().withOverrides(.{}); try std.testing.expect(tokens.surface_layout_policy == core.nativeWindowPolicy); try std.testing.expect(tokens.grid_layout_policy == core.nativeWindowPolicy); @@ -245,6 +247,7 @@ test "app token paths retain adapter placement and consume supplied frames" { for (0..3) |mode| { const app = try App.create(std.testing.allocator, .{ .name = "surface-layout", .scene = .{}, .canvas_label = "canvas", .surface_layout_policy = Factory.policy, .intrinsic_layout_policy = core.nativeWindowPolicy, .view = Factory.view, .update = Factory.update, .tokens = if (mode == 1) .{} else null, .tokens_fn = if (mode == 2) Factory.tokens else null }); defer app.destroy(); + try std.testing.expect(app.app().text_cache_policy == null); const tokens = app.effectiveTokens().withOverrides(.{}); try std.testing.expect(tokens.surface_layout_policy == Factory.policy); try std.testing.expect(tokens.intrinsic_layout_policy == core.nativeWindowPolicy); diff --git a/tests/ts-core/text_cache_e2e_tests.zig b/tests/ts-core/text_cache_e2e_tests.zig new file mode 100644 index 000000000..04601cc4c --- /dev/null +++ b/tests/ts-core/text_cache_e2e_tests.zig @@ -0,0 +1,153 @@ +const std = @import("std"); +const sdk = @import("native_sdk"); +const core = @import("surface_fixture_core"); +const canvas = sdk.canvas; +const exact = @import("surface_layout_e2e_tests.zig").expectComplete; + +fn checkText(plans: []const canvas.TextLayoutPlan, old: []const canvas.TextLayoutCacheEntry, capacity: usize, actions: usize, frame: u64, retention: u64) !void { + const allocator = std.testing.allocator; + const a = try allocator.alloc(canvas.TextLayoutCacheEntry, capacity); + defer allocator.free(a); + const b = try allocator.alloc(canvas.TextLayoutCacheEntry, capacity); + defer allocator.free(b); + const aa = try allocator.alloc(canvas.TextLayoutCacheAction, actions); + defer allocator.free(aa); + const ba = try allocator.alloc(canvas.TextLayoutCacheAction, actions); + defer allocator.free(ba); + var reference = canvas.TextLayoutCachePlanner.init(a, aa); + var candidate = canvas.TextLayoutCachePlanner.init(b, ba); + const native = reference.buildMany(plans, old, frame, retention); + const compiled = candidate.buildCompiled(.{ .plans = plans }, old, frame, retention, core.nativeWindowPolicy); + if (native) |plan| try exact(plan, try compiled) else |err| try std.testing.expectError(err, compiled); + try exact(reference.entry_len, candidate.entry_len); + try exact(reference.action_len, candidate.action_len); + try exact(a[0..reference.entry_len], b[0..candidate.entry_len]); + try exact(aa[0..reference.action_len], ba[0..candidate.action_len]); +} +fn checkGlyph(plans: []const canvas.GlyphAtlasEntry, old: []const canvas.GlyphAtlasCacheEntry, capacity: usize, actions: usize, frame: u64, retention: u64) !void { + const allocator = std.testing.allocator; + const a = try allocator.alloc(canvas.GlyphAtlasCacheEntry, capacity); + defer allocator.free(a); + const b = try allocator.alloc(canvas.GlyphAtlasCacheEntry, capacity); + defer allocator.free(b); + const aa = try allocator.alloc(canvas.GlyphAtlasCacheAction, actions); + defer allocator.free(aa); + const ba = try allocator.alloc(canvas.GlyphAtlasCacheAction, actions); + defer allocator.free(ba); + var reference = canvas.GlyphAtlasCachePlanner.init(a, aa); + var candidate = canvas.GlyphAtlasCachePlanner.init(b, ba); + const native = reference.build(.{ .entries = plans }, old, frame, retention); + const compiled = candidate.buildCompiled(.{ .entries = plans }, old, frame, retention, core.nativeWindowPolicy); + if (native) |plan| try exact(plan, try compiled) else |err| try std.testing.expectError(err, compiled); + try exact(reference.entry_len, candidate.entry_len); + try exact(reference.action_len, candidate.action_len); + try exact(a[0..reference.entry_len], b[0..candidate.entry_len]); + try exact(aa[0..reference.action_len], ba[0..candidate.action_len]); +} +fn textKey(id: u64, value: f32) canvas.TextLayoutKey { + return .{ .font_id = 9007199254740993, .fingerprint = id, .size = value, .origin = .init(1.125, -0.0), .max_width = 99.5, .line_height = 17.25, .wrap = .word, .alignment = .end, .overflow = .clip, .text_len = 9007199254740995, .glyph_count = 9007199254740997 }; +} +fn glyphKey(id: u32, value: f32) canvas.GlyphAtlasKey { + return .{ .font_id = 9007199254740993, .glyph_id = id, .size = value, .subpixel_x = 255, .subpixel_y = 17 }; +} +test "compiled text cache preserves complete keys bounds lines order and capacity-error partial state" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const lines = [_]canvas.TextLine{ .{}, .{} }; + const current = [_]canvas.TextLayoutPlan{ .{ .key = textKey(9, 12), .layout = .{ .lines = &lines, .bounds = .init(-1, 2, 99, 31) } }, .{ .key = textKey(9, 12) }, .{ .key = textKey(10, 12) } }; + const old = [_]canvas.TextLayoutCacheEntry{ .{ .key = textKey(9, 12), .line_count = 90, .last_used_frame = 1 }, .{ .key = textKey(9, 12), .last_used_frame = 8 }, .{ .key = textKey(11, 12), .bounds = .init(2, 3, 4, 5), .last_used_frame = 9 }, .{ .key = textKey(11, 12), .last_used_frame = 9 }, .{ .key = textKey(12, 12), .last_used_frame = 1 } }; + for ([_]usize{ 0, 1, 2, 3, 9 }) |capacity| for ([_]usize{ 0, 1, 2, 3, 9 }) |actions| try checkText(¤t, &old, capacity, actions, 10, 2); +} +test "compiled glyph cache preserves full keys duplicate first matches and capacity-error partial state" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const current = [_]canvas.GlyphAtlasEntry{ .{ .key = glyphKey(1, 12), .command_index = 8, .glyph_index = 2 }, .{ .key = glyphKey(1, 12), .command_index = 9, .glyph_index = 3 }, .{ .key = glyphKey(2, 12), .command_index = 10, .glyph_index = 4 } }; + const old = [_]canvas.GlyphAtlasCacheEntry{ .{ .key = glyphKey(1, 12), .last_used_frame = 1 }, .{ .key = glyphKey(1, 12), .last_used_frame = 8 }, .{ .key = glyphKey(3, 12), .last_used_frame = 9 }, .{ .key = glyphKey(3, 12), .last_used_frame = 9 }, .{ .key = glyphKey(4, 12), .last_used_frame = 1 } }; + for ([_]usize{ 0, 1, 2, 3, 9 }) |capacity| for ([_]usize{ 0, 1, 2, 3, 9 }) |actions| try checkGlyph(¤t, &old, capacity, actions, 10, 2); +} +test "compiled text and glyph retention preserve exact uint64 frame age and rollback" { + _ = core.initialModel(); + defer core.rt.frameReset(); + for ([_]u64{ 0, 1, 9007199254740993, 0xffffffffffffffff }) |frame| for ([_]u64{ 0, 1, 99, 0xffffffffffffffff }) |retention| for ([_]u64{ 0, frame, frame -| 1, frame -| 99, 0xffffffffffffffff }) |last| { + try checkText(&.{}, &.{.{ .key = textKey(1, 12), .last_used_frame = last }}, 1, 1, frame, retention); + try checkGlyph(&.{}, &.{.{ .key = glyphKey(1, 12), .last_used_frame = last }}, 1, 1, frame, retention); + }; +} +test "compiled text and glyph caches preserve NaN infinity and signed-zero equality" { + _ = core.initialModel(); + defer core.rt.frameReset(); + for ([_]f32{ std.math.nan(f32), std.math.inf(f32), -std.math.inf(f32), -0.0, 0, 12.125 }) |value| { + try checkText(&.{ .{ .key = textKey(1, value) }, .{ .key = textKey(1, value) } }, &.{.{ .key = textKey(1, value), .last_used_frame = 10 }}, 9, 9, 10, 2); + try checkGlyph(&.{ .{ .key = glyphKey(1, value), .command_index = 0, .glyph_index = 0 }, .{ .key = glyphKey(1, value), .command_index = 1, .glyph_index = 0 } }, &.{.{ .key = glyphKey(1, value), .last_used_frame = 10 }}, 9, 9, 10, 2); + } + try checkText(&.{.{ .key = textKey(1, -0.0) }}, &.{.{ .key = textKey(1, 0), .last_used_frame = 9 }}, 9, 9, 10, 2); + try checkGlyph(&.{.{ .key = glyphKey(1, -0.0), .command_index = 0, .glyph_index = 0 }}, &.{.{ .key = glyphKey(1, 0), .last_used_frame = 9 }}, 9, 9, 10, 2); +} +test "compiled caches preserve indexed and oversized-library lookup order" { + _ = core.initialModel(); + defer core.rt.frameReset(); + const allocator = std.testing.allocator; + for ([_]usize{ 63, 64, 2048, 4097 }) |count| { + const current = try allocator.alloc(canvas.TextLayoutPlan, count); + defer allocator.free(current); + const old = try allocator.alloc(canvas.TextLayoutCacheEntry, count); + defer allocator.free(old); + for (current, old, 0..) |*p, *q, i| { + p.* = .{ .key = textKey(9007199254740993 + i / 2, 12) }; + q.* = .{ .key = p.key, .last_used_frame = 9, .line_count = i }; + } + try checkText(current, old, count, count * 2, 10, 2); + } + for ([_]usize{ 63, 64, 8192, 16385 }) |count| { + const current = try allocator.alloc(canvas.GlyphAtlasEntry, count); + defer allocator.free(current); + const old = try allocator.alloc(canvas.GlyphAtlasCacheEntry, count); + defer allocator.free(old); + for (current, old, 0..) |*p, *q, i| { + p.* = .{ .key = glyphKey(@intCast(i / 2), 12), .command_index = i, .glyph_index = i }; + q.* = .{ .key = p.key, .last_used_frame = 9 }; + } + try checkGlyph(current, old, count, count * 2, 10, 2); + } +} +test "compiled cache policy preserves borrowed app model and view bytes" { + _ = core.initialModel(); + const borrowed = core.modelSnapshot(); + defer core.rt.frameReset(); + const before = try std.testing.allocator.dupe(u8, borrowed); + defer std.testing.allocator.free(before); + try checkText(&.{.{ .key = textKey(1, 12) }}, &.{}, 1, 1, 10, 2); + try checkGlyph(&.{.{ .key = glyphKey(1, 12), .command_index = 0, .glyph_index = 0 }}, &.{}, 1, 1, 10, 2); + try std.testing.expectEqualSlices(u8, before, borrowed); +} + +test "text and glyph cache planning cost is measured beside the native reference" { + _ = core.initialModel(); + defer core.rt.frameReset(); + var plans: [128]canvas.TextLayoutPlan = undefined; + var old: [128]canvas.TextLayoutCacheEntry = undefined; + var glyphs: [128]canvas.GlyphAtlasEntry = undefined; + var old_glyphs: [128]canvas.GlyphAtlasCacheEntry = undefined; + for (0..128) |i| { + plans[i] = .{ .key = textKey(i, 12) }; + old[i] = .{ .key = plans[i].key, .last_used_frame = 9 }; + glyphs[i] = .{ .key = glyphKey(@intCast(i), 12), .command_index = i, .glyph_index = i }; + old_glyphs[i] = .{ .key = glyphs[i].key, .last_used_frame = 9 }; + } + var entries: [256]canvas.TextLayoutCacheEntry = undefined; + var actions: [256]canvas.TextLayoutCacheAction = undefined; + var glyph_entries: [256]canvas.GlyphAtlasCacheEntry = undefined; + var glyph_actions: [256]canvas.GlyphAtlasCacheAction = undefined; + for ([_]?*const fn ([]const u8, []u8) usize{ null, core.nativeWindowPolicy }, 0..) |policy, lane| { + const begin = std.Io.Timestamp.now(std.testing.io, .awake).nanoseconds; + for (0..100) |_| { + const text = try (canvas.TextLayoutPlanSet{ .plans = &plans }).cachePlanWithPolicy(policy, &old, 10, 2, &entries, &actions); + const glyph = try (canvas.GlyphAtlasPlan{ .entries = &glyphs }).cachePlanWithPolicy(policy, &old_glyphs, 10, 2, &glyph_entries, &glyph_actions); + std.mem.doNotOptimizeAway(text); + std.mem.doNotOptimizeAway(glyph); + core.rt.frameReset(); + } + const elapsed = std.Io.Timestamp.now(std.testing.io, .awake).nanoseconds - begin; + std.debug.print("128-layout/128-glyph cache pair lane {d}: {d} ns (including copying and enclosing reset)\n", .{ lane, @divTrunc(elapsed, 100) }); + } +}