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
56 changes: 56 additions & 0 deletions packages/core/src/runtime_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -1518,3 +1519,58 @@ function nscvExtentCorrections(request:Uint8Array):Uint8Array{
for(let i=0;i<measured.length;i++){const at=32+i*16,e=measured[i]!;nscvFlowWrite(out,at,e.index);out.setFloat32(at+8,e.delta,true);out.setFloat32(at+12,e.prefix,true);}
return result;
}

/** Ordered text-layout and glyph-cache reconciliation over copied key facts.
* Native supplies hash buckets and stores resources; equality, admission,
* retention and eviction belong here. No pointers or arena reset cross this call.
*/
function nscvTextCachePolicy(request: Uint8Array): Uint8Array {
if (request.length < 48 || request[1]! > 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<floats;i++) if(w.getFloat32(x+i*4,true)!==w.getFloat32(y+i*4,true))return false;
const start=glyph?4:24,end=glyph?24:64;
for(let i=start;i<end;i++)if(request[x+i]!==request[y+i])return false;
return true;
};
const entries:number[]=[],actions:number[]=[];
const prevHeads=new Uint32Array(indexed?slots:0),entryHeads=new Uint32Array(indexed?slots:0),prevNext=new Uint32Array(indexed?previous:0),entryNext=new Uint32Array(indexed?Math.min(capacity,total):0);
if(indexed) {
// Reverse insertion retains the lowest original index at each chain head.
for(let i=previous-1;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<match))match=i;}
return match;
}
const count=old?previous:entries.length;
for(let i=0;i<count;i++)if(equal(source,old?current+i:entries[i]!))return i;
return -1;
};
let failed=false;
const appendEntry=(source:number):boolean=>{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<current;i++){
if(lookup(i,false)>=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<previous;i++){
const source=current+i;if(lookup(source,false)>=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.length<capacity){if(!appendEntry(source) || !appendAction(1,source,i)){failed=true;break;}}
else if(!appendAction(2,source,i)){failed=true;break;}
}
const result=new Uint8Array(16+entries.length*4+actions.length*4),out=new DataView(result.buffer);
out.setUint32(0,entries.length,true);out.setUint32(4,actions.length/3,true);out.setUint32(8,failed?1:0,true);
for(let i=0;i<entries.length;i++)out.setUint32(16+i*4,entries[i]!,true);
for(let i=0;i<actions.length;i++)out.setUint32(16+entries.length*4+i*4,actions[i]!<0?4294967295:actions[i]!,true);
return result;
}
38 changes: 38 additions & 0 deletions packages/core/test/text_cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { native_window_policy } from "../src/runtime_policy.ts";
type Fact={key:number,size?:number,last?:bigint,bucket?:number};
function request(current:Fact[],previous:Fact[],capacity=100,actions=100,frame=10n,retention=2n,glyph=false){
const r=new Uint8Array(48+(current.length+previous.length)*80),w=new DataView(r.buffer);r[0]=11;r[1]=glyph?1:0;
[current.length,previous.length,capacity,actions].forEach((v,i)=>w.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;n<q.length;n++)assert.throws(()=>native_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));}
});
7 changes: 5 additions & 2 deletions src/primitives/canvas/frame.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 = .{},
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/primitives/canvas/text_atlas.zig
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]`.
Expand Down
94 changes: 94 additions & 0 deletions src/primitives/canvas/text_cache_policy.zig
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading