diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5951aba..203ffc35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: - name: Check core portability run: npm run check:core-portability + - name: Check native vendor integrity + run: npm run check:native-vendor + - name: Check Unicode pins (submodule sync) run: npm run check:unicode diff --git a/AGENTS.md b/AGENTS.md index e95a3375..9b2fceec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Key pipeline files: - `packages/core/src/runtime/router/wheel.ts` — mouse wheel routing for scroll targets - `packages/core/src/renderer/renderToDrawlist/renderTree.ts` — stack-based DFS renderer - `packages/core/src/layout/dropdownGeometry.ts` — shared dropdown overlay geometry -- `packages/core/src/drawlist/builder_v1.ts` — ZRDL binary drawlist builder +- `packages/core/src/drawlist/builder.ts` — ZRDL binary drawlist builder (unified) ## Layout Engine Baseline (Current) @@ -129,7 +129,7 @@ When core widget APIs change, JSX must be updated in the same change set. ### Drawlist writer codegen guardrail (MUST for ZRDL command changes) -The v3/v4/v5 command writer implementation is code-generated. Never hand-edit +The command writer implementation is code-generated. Never hand-edit `packages/core/src/drawlist/writers.gen.ts`. When changing drawlist command layout/opcodes/field widths/offsets: @@ -170,6 +170,25 @@ node scripts/run-tests.mjs --filter "widget" 2. If changing runtime, layout, or renderer code, also run integration tests. 3. Run the full suite before committing. +### Mandatory Live PTY Validation for UI Regressions + +For rendering/layout/theme regressions, do not stop at unit snapshots. Run the +app in a real PTY and collect frame audit evidence yourself before asking a +human to reproduce. + +Canonical runbook: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +Minimum required checks for UI regression work: + +1. Run target app/template in PTY with deterministic viewport. +2. Exercise relevant routes/keys (for starship: `1..6`, `t`, `q`). +3. Capture `REZI_FRAME_AUDIT` logs and analyze with + `node scripts/frame-audit-report.mjs ... --latest-pid`. +4. Capture app-level debug snapshots (`REZI_STARSHIP_DEBUG=1`) when applicable. +5. Include concrete evidence in your report (hash changes, route summary, key stages). + ## Verification Protocol (Two-Agent Verification) When verifying documentation or code changes, split into two passes: diff --git a/CLAUDE.md b/CLAUDE.md index d68d98ee..433355bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,9 +50,9 @@ packages/core/src/ renderToDrawlist/ renderTree.ts # Stack-based DFS renderer drawlist/ - builder_v1.ts # ZRDL binary drawlist builder (v1) - builder_v2.ts # Drawlist builder v2 (cursor protocol) - builder_v3.ts # Drawlist builder v3 + builder.ts # ZRDL binary drawlist builder (single unified version) + builderBase.ts # Abstract base class for drawlist builder + writers.gen.ts # Generated drawlist command writers (codegen) keybindings/ manager.ts # Modal keybinding system parser.ts # Key sequence parsing @@ -505,6 +505,15 @@ result.toText(); // Render to plain text for snapshots Test runner: `node:test`. Run all tests with `node scripts/run-tests.mjs`. +For rendering regressions, add a live PTY verification pass and frame-audit +evidence (not just snapshot/unit tests). Use: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +This runbook covers deterministic viewport setup, worker-mode PTY execution, +route/theme key driving, and cross-layer log analysis (`REZI_FRAME_AUDIT`, +`REZI_STARSHIP_DEBUG`, `frame-audit-report.mjs`). + ## Skills (Repeatable Recipes) Project-level skills for both Claude Code and Codex: diff --git a/docs/backend/native.md b/docs/backend/native.md index 854414c6..6bbd151e 100644 --- a/docs/backend/native.md +++ b/docs/backend/native.md @@ -163,7 +163,7 @@ source. - **Node.js 18+** with npm - **Rust toolchain** (for napi-rs compilation) - **C toolchain** (gcc, clang, or MSVC) for the Zireael engine -- **Git submodules** initialized (`vendor/zireael` must be present) +- **Git metadata available** (for vendor pin checks; full submodule checkout is needed when refreshing vendored engine sources) ### Build Command diff --git a/docs/dev/build.md b/docs/dev/build.md index 8447ea8b..2586fd48 100644 --- a/docs/dev/build.md +++ b/docs/dev/build.md @@ -35,9 +35,10 @@ cd Rezi git submodule update --init --recursive ``` -The `vendor/zireael` submodule contains the Zireael C engine source. It must be -present for native addon builds, but is not required for TypeScript-only -development. +The `vendor/zireael` submodule tracks the upstream Zireael source and commit +pin metadata. Native addon builds compile from the package-local snapshot at +`packages/native/vendor/zireael`; submodule checkout is required when syncing +or auditing vendored engine updates, but not for TypeScript-only development. Install all dependencies: diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md new file mode 100644 index 00000000..bdb18d91 --- /dev/null +++ b/docs/dev/live-pty-debugging.md @@ -0,0 +1,176 @@ +# Live PTY UI Testing and Frame Audit Runbook + +This runbook documents how to validate Rezi UI behavior autonomously in a real +terminal (PTY), capture end-to-end frame telemetry, and pinpoint regressions +across core/node/native layers. + +Use this before asking a human for screenshots. + +## Why this exists + +Headless/unit tests catch many issues, but rendering regressions often involve: + +- terminal dimensions and capability negotiation +- worker transport boundaries (core -> node worker -> native) +- partial redraw/damage behavior across many frames + +The PTY + frame-audit workflow gives deterministic evidence for all of those. + +## Prerequisites + +From repo root: + +```bash +cd /home/k3nig/Rezi +npx tsc -b packages/core packages/node packages/create-rezi +``` + +## Canonical interactive run (Starship template) + +This enables: + +- app-level debug snapshots (`REZI_STARSHIP_DEBUG`) +- cross-layer frame audit (`REZI_FRAME_AUDIT`) +- worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) + +```bash +cd /home/k3nig/Rezi +: > /tmp/rezi-frame-audit.ndjson +: > /tmp/starship.log + +env -u NO_COLOR \ + REZI_STARSHIP_EXECUTION_MODE=worker \ + REZI_STARSHIP_DEBUG=1 \ + REZI_STARSHIP_DEBUG_LOG=/tmp/starship.log \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +Key controls in template: + +- `1..6`: route switch (bridge/engineering/crew/comms/cargo/settings) +- `t`: cycle theme +- `q`: quit + +## Deterministic viewport (important) + +Many regressions are viewport-threshold dependent. Always test with a known +size before comparing runs. + +For an interactive shell/PTY: + +```bash +stty rows 68 cols 300 +``` + +Then launch the app in that same PTY. + +## Autonomous PTY execution (agent workflow) + +When your agent runtime supports PTY stdin/stdout control: + +1. Start app in PTY mode (with env above). +2. Send key sequences (`2`, `3`, `t`, `q`) through stdin. +3. Wait between keys to allow frames to settle. +4. Quit and analyze logs. + +Do not rely only on static test snapshots for visual regressions. + +## Frame audit analysis + +Use the built-in analyzer: + +```bash +node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson --latest-pid +``` + +What to look for: + +- `backend_submitted`, `worker_payload`, `worker_accepted`, `worker_completed` + should stay aligned in worker mode. +- `hash_mismatch_backend_vs_worker` should be `0`. +- `top_opcodes` should reflect expected widget workload. +- `route_summary` should show submissions for every exercised route. +- `native_summary_records`/`native_header_records` confirm native debug pull + from worker path. + +If a log contains multiple app runs, always use `--latest-pid` (or `--pid=`) +to avoid mixed-session confusion. + +## Useful grep patterns + +```bash +rg "runtime.command|runtime.fatal|shell.layout|engineering.layout|engineering.render|crew.render" /tmp/starship.log +rg "\"stage\":\"table.layout\"|\"stage\":\"drawlist.built\"|\"stage\":\"frame.submitted\"|\"stage\":\"frame.completed\"" /tmp/rezi-frame-audit.ndjson +``` + +## Optional deep capture (drawlist bytes) + +Capture raw drawlist payload snapshots for diffing: + +```bash +env \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_DUMP_DIR=/tmp/rezi-drawlist-dumps \ + REZI_FRAME_AUDIT_DUMP_MAX=20 \ + REZI_FRAME_AUDIT_DUMP_ROUTE=crew \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +This writes paired `.bin` + `.json` files with hashes and metadata. + +## Native trace through frame-audit + +Native debug records are enabled by frame audit in worker mode. Controls: + +- `REZI_FRAME_AUDIT_NATIVE=1|0` (default on when frame audit is enabled) +- `REZI_FRAME_AUDIT_NATIVE_RING=` (ring size override) + +Look for stages such as: + +- `native.debug.header` +- `native.drawlist.summary` +- `native.frame.*` +- `native.perf.*` + +## Triage playbook for common regressions + +### 1) “Theme only updates animated region” + +Check: + +1. `runtime.command` contains `cycle-theme`. +2. `drawlist.built` hashes change after theme switch. +3. `frame.submitted`/`frame.completed` continue for that route. + +If hashes do not change, bug is likely in view/theme resolution. +If hashes change but screen does not, investigate native diff/damage path. + +### 2) “Table looks empty or only one row visible” + +Check `table.layout` record: + +- `bodyH` +- `visibleRows` +- `startIndex` / `endIndex` +- table rect height + +If `bodyH` is too small, inspect parent layout/flex and sibling widgets +(pagination or controls often steal height). + +### 3) “Worker mode renders differently from inline” + +Run both modes with identical viewport and compare audit summaries: + +- worker: `REZI_STARSHIP_EXECUTION_MODE=worker` +- inline: `REZI_STARSHIP_EXECUTION_MODE=inline` + +If only worker diverges, focus on backend transport and worker audit stages. + +## Guardrails + +- Keep all instrumentation opt-in via env vars. +- Never print continuous debug spam to stdout during normal app usage. +- Write logs to files (`/tmp/...`) and inspect post-run. +- Prefer deterministic viewport + scripted route/theme steps when verifying fixes. diff --git a/docs/dev/repo-layout.md b/docs/dev/repo-layout.md index b54a9bde..3c2d7504 100644 --- a/docs/dev/repo-layout.md +++ b/docs/dev/repo-layout.md @@ -144,6 +144,7 @@ Build, test, and CI automation scripts. | `docs.sh` | Documentation build/serve with automatic venv management. | | `guardrails.sh` | Repository hygiene checks for forbidden patterns (legacy scope/name, unresolved task markers, and synthetic-content markers). | | `check-core-portability.mjs` | Scans `@rezi-ui/core` for prohibited Node.js imports. | +| `check-native-vendor-integrity.mjs` | Verifies native vendor source wiring and `VENDOR_COMMIT.txt` pin consistency with `vendor/zireael`. | | `check-unicode-sync.mjs` | Verifies Unicode table versions are consistent. | | `check-create-rezi-templates.mjs` | Validates scaffolding templates are up to date. | | `verify-native-pack.mjs` | Checks native package contents before npm publish. | @@ -152,9 +153,11 @@ Build, test, and CI automation scripts. ## vendor/zireael The Zireael C rendering engine, pinned as a git submodule. This is the upstream -source used by `@rezi-ui/native` for compilation. The native package keeps its -own vendored snapshot at `packages/native/vendor/zireael` as the compile-time -source. +reference tree. `@rezi-ui/native` compiles from the package-local snapshot at +`packages/native/vendor/zireael` (see `packages/native/build.rs`). + +`packages/native/vendor/VENDOR_COMMIT.txt` must match the repo gitlink pointer +for `vendor/zireael`; CI enforces this via `npm run check:native-vendor`. Initialize with: diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 3eab0163..f3fdd9b1 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -66,6 +66,18 @@ npm run test:e2e npm run test:e2e:reduced ``` +## Live PTY Rendering Validation (for UI regressions) + +For terminal rendering/theme/layout regressions, run a live PTY session with +frame-audit instrumentation in addition to normal tests. + +Use the dedicated runbook: + +- [Live PTY UI Testing and Frame Audit Runbook](live-pty-debugging.md) + +That guide includes deterministic viewport setup, worker-mode run commands, +scripted key driving, and cross-layer telemetry analysis. + ## Test Categories ### Unit Tests diff --git a/docs/protocol/versioning.md b/docs/protocol/versioning.md index 079681f2..ac7da681 100644 --- a/docs/protocol/versioning.md +++ b/docs/protocol/versioning.md @@ -24,6 +24,8 @@ Pinned drawlist version: Rezi currently emits ZRDL v1, and the engine accepts v1/v2. ZRDL v2 adds opcode `14` (`BLIT_RECT`). +Style link reference fields (`linkUriRef`, `linkIdRef`) are stable across v1/v2: +`0` means unset and positive values are 1-based string resource IDs. ## Event Batch (ZREV) diff --git a/docs/protocol/zrdl.md b/docs/protocol/zrdl.md index d7975a4d..4efbe886 100644 --- a/docs/protocol/zrdl.md +++ b/docs/protocol/zrdl.md @@ -57,6 +57,23 @@ Commands are 4-byte aligned. - `FREE_*` invalidates that ID. - Draw commands referencing unknown IDs fail. +## Style Payload Link References + +Text-bearing commands carry style fields that include hyperlink references: + +- `linkUriRef` (`u32`) +- `linkIdRef` (`u32`) + +These fields use the same ID space as `DEF_STRING`/`FREE_STRING` and are +**1-based**: + +- `0` means "no active link" (sentinel unset) +- `N > 0` maps to string resource ID `N` + +Rezi encodes link references as `(internedIndex + 1)`, and Zireael resolves +them as direct string IDs. This is part of the v1/v2 wire contract and does +not introduce a new drawlist version. + ## Codegen Command layouts are defined in `scripts/drawlist-spec.ts`. diff --git a/examples/regression-dashboard/README.md b/examples/regression-dashboard/README.md new file mode 100644 index 00000000..698b7393 --- /dev/null +++ b/examples/regression-dashboard/README.md @@ -0,0 +1,47 @@ +# Regression Dashboard Example + +Dashboard-based regression app for manually validating rendering behavior after core refactors. + +## Run (from repo root) + +```bash +npm --prefix examples/regression-dashboard run start +``` + +Interactive mode requires a real TTY terminal. + +## Run with HSR + +```bash +npm --prefix examples/regression-dashboard run dev +``` + +## Headless Preview (non-TTY safe) + +```bash +npm --prefix examples/regression-dashboard run preview +``` + +## Build / Typecheck / Test + +```bash +npm --prefix examples/regression-dashboard run build +npm --prefix examples/regression-dashboard run typecheck +npm --prefix examples/regression-dashboard run test +``` + +## What to check + +- Scroll service lanes with wheel/keys and verify no visual tearing. +- Change filters/theme while telemetry ticks are active. +- Open/close help modal repeatedly. +- Verify focus/selection remains stable during rapid updates. + +## Controls + +- `up` / `down` or `j` / `k`: Move selection +- `f`: Cycle fleet filter +- `t`: Cycle theme +- `p` or `space`: Pause/resume telemetry stream +- `h` or `?`: Toggle help modal +- `q` or `ctrl+c`: Quit diff --git a/examples/regression-dashboard/package-lock.json b/examples/regression-dashboard/package-lock.json new file mode 100644 index 00000000..27fb03e1 --- /dev/null +++ b/examples/regression-dashboard/package-lock.json @@ -0,0 +1,55 @@ +{ + "name": "regression-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "regression-dashboard", + "dependencies": { + "@rezi-ui/core": "file:../../packages/core", + "@rezi-ui/node": "file:../../packages/node" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, + "../../packages/core": { + "name": "@rezi-ui/core", + "version": "0.1.0-alpha.34", + "license": "Apache-2.0", + "devDependencies": { + "@rezi-ui/testkit": "0.1.0-alpha.34" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, + "../../packages/node": { + "name": "@rezi-ui/node", + "version": "0.1.0-alpha.34", + "license": "Apache-2.0", + "dependencies": { + "@rezi-ui/core": "0.1.0-alpha.34", + "@rezi-ui/native": "0.1.0-alpha.34" + }, + "devDependencies": { + "@xterm/headless": "^6.0.0", + "node-pty": "^1.1.0" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, + "node_modules/@rezi-ui/core": { + "resolved": "../../packages/core", + "link": true + }, + "node_modules/@rezi-ui/node": { + "resolved": "../../packages/node", + "link": true + } + } +} diff --git a/examples/regression-dashboard/package.json b/examples/regression-dashboard/package.json new file mode 100644 index 00000000..8e5cf64e --- /dev/null +++ b/examples/regression-dashboard/package.json @@ -0,0 +1,21 @@ +{ + "name": "regression-dashboard", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/main.ts", + "dev": "tsx src/main.ts --hsr", + "preview": "tsx src/main.ts --headless", + "build": "tsc -p tsconfig.json --pretty false", + "typecheck": "tsc --noEmit", + "test": "tsx --test src/__tests__/*.test.ts" + }, + "dependencies": { + "@rezi-ui/core": "file:../../packages/core", + "@rezi-ui/node": "file:../../packages/node" + }, + "engines": { + "node": ">=18", + "bun": ">=1.3.0" + } +} diff --git a/examples/regression-dashboard/src/__tests__/keybindings.test.ts b/examples/regression-dashboard/src/__tests__/keybindings.test.ts new file mode 100644 index 00000000..f42c3542 --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/keybindings.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveDashboardCommand } from "../helpers/keybindings.js"; + +test("dashboard keybinding map resolves canonical keys", () => { + assert.equal(resolveDashboardCommand("q"), "quit"); + assert.equal(resolveDashboardCommand("j"), "move-down"); + assert.equal(resolveDashboardCommand("k"), "move-up"); + assert.equal(resolveDashboardCommand("f"), "cycle-filter"); + assert.equal(resolveDashboardCommand("t"), "cycle-theme"); + assert.equal(resolveDashboardCommand("x"), undefined); +}); diff --git a/examples/regression-dashboard/src/__tests__/reducer.test.ts b/examples/regression-dashboard/src/__tests__/reducer.test.ts new file mode 100644 index 00000000..213055cf --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/reducer.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createInitialState, reduceDashboardState } from "../helpers/state.js"; + +test("dashboard reducer toggles pause state", () => { + const initial = createInitialState(0); + const next = reduceDashboardState(initial, { type: "toggle-pause" }); + assert.equal(next.paused, true); + const resumed = reduceDashboardState(next, { type: "toggle-pause" }); + assert.equal(resumed.paused, false); +}); + +test("dashboard reducer tick updates counters and services", () => { + const initial = createInitialState(0); + const next = reduceDashboardState(initial, { type: "tick", nowMs: 1000 }); + assert.equal(next.tick, 1); + assert.equal(next.lastUpdatedMs, 1000); + assert.notDeepEqual(next.services, initial.services); +}); diff --git a/examples/regression-dashboard/src/__tests__/render.test.ts b/examples/regression-dashboard/src/__tests__/render.test.ts new file mode 100644 index 00000000..0e61cdbf --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/render.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTestRenderer } from "@rezi-ui/core/testing"; +import { createInitialState } from "../helpers/state.js"; +import { renderOverviewScreen } from "../screens/overview.js"; + +test("dashboard overview render includes core markers", () => { + const state = createInitialState(0); + const renderer = createTestRenderer({ viewport: { cols: 120, rows: 34 } }); + const tree = renderOverviewScreen(state, { + onTogglePause: () => {}, + onCycleFilter: () => {}, + onCycleTheme: () => {}, + onToggleHelp: () => {}, + onSelectService: () => {}, + }); + + const output = renderer.render(tree).toText(); + assert.match(output, /Regression Dashboard/); + assert.match(output, /Service Fleet/); +}); diff --git a/examples/regression-dashboard/src/__tests__/telemetry.test.ts b/examples/regression-dashboard/src/__tests__/telemetry.test.ts new file mode 100644 index 00000000..3787f74c --- /dev/null +++ b/examples/regression-dashboard/src/__tests__/telemetry.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTelemetryStream } from "../helpers/telemetry.js"; + +async function nextWithTimeout( + iterator: AsyncIterator, + timeoutMs = 1000, +): Promise> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + iterator.next(), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`next() timed out after ${String(timeoutMs)}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +test("telemetry stream yields ticks with timestamps", async () => { + const stream = createTelemetryStream(10); + const iterator = stream[Symbol.asyncIterator](); + + const first = await nextWithTimeout(iterator); + assert.equal(first.done, false); + assert.ok(first.value.nowMs > 0); + + const second = await nextWithTimeout(iterator); + assert.equal(second.done, false); + assert.ok(second.value.nowMs >= first.value.nowMs); + + await iterator.return?.(); +}); + +test("telemetry stream stops after return()", async () => { + const stream = createTelemetryStream(10); + const iterator = stream[Symbol.asyncIterator](); + + await iterator.return?.(); + const next = await nextWithTimeout(iterator); + assert.equal(next.done, true); +}); diff --git a/examples/regression-dashboard/src/helpers/formatters.ts b/examples/regression-dashboard/src/helpers/formatters.ts new file mode 100644 index 00000000..a92ad0c6 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/formatters.ts @@ -0,0 +1,53 @@ +import type { BadgeVariant } from "@rezi-ui/core"; +import type { Service, ServiceFilter, ServiceStatus } from "../types.js"; + +export function formatLatency(ms: number): string { + return `${Math.round(ms)} ms`; +} + +export function formatErrorRate(percent: number): string { + return `${percent.toFixed(2)}%`; +} + +export function formatTraffic(rpm: number): string { + if (rpm >= 1000) return `${(rpm / 1000).toFixed(1)}k rpm`; + return `${rpm.toFixed(0)} rpm`; +} + +export function statusBadge( + status: ServiceStatus, +): Readonly<{ label: string; variant: BadgeVariant }> { + if (status === "healthy") return { label: "Healthy", variant: "success" }; + if (status === "warning") return { label: "Warning", variant: "warning" }; + return { label: "Critical", variant: "error" }; +} + +export function statusGlyph(status: ServiceStatus): string { + if (status === "healthy") return "●"; + if (status === "warning") return "▲"; + return "■"; +} + +export function filterLabel(filter: ServiceFilter): string { + if (filter === "all") return "All"; + if (filter === "healthy") return "Healthy"; + if (filter === "warning") return "Warning"; + return "Down"; +} + +export function fleetCounts( + services: readonly Service[], +): Readonly<{ healthy: number; warning: number; down: number }> { + return Object.freeze({ + healthy: services.filter((service) => service.status === "healthy").length, + warning: services.filter((service) => service.status === "warning").length, + down: services.filter((service) => service.status === "down").length, + }); +} + +export function overallStatus(services: readonly Service[]): ServiceStatus { + const counts = fleetCounts(services); + if (counts.down > 0) return "down"; + if (counts.warning > 0) return "warning"; + return "healthy"; +} diff --git a/examples/regression-dashboard/src/helpers/keybindings.ts b/examples/regression-dashboard/src/helpers/keybindings.ts new file mode 100644 index 00000000..6c345ad8 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/keybindings.ts @@ -0,0 +1,27 @@ +export type DashboardCommand = + | "quit" + | "move-up" + | "move-down" + | "toggle-help" + | "toggle-pause" + | "cycle-filter" + | "cycle-theme"; + +const COMMAND_BY_KEY: Readonly> = Object.freeze({ + q: "quit", + "ctrl+c": "quit", + up: "move-up", + k: "move-up", + down: "move-down", + j: "move-down", + h: "toggle-help", + "shift+/": "toggle-help", + p: "toggle-pause", + space: "toggle-pause", + f: "cycle-filter", + t: "cycle-theme", +}); + +export function resolveDashboardCommand(key: string): DashboardCommand | undefined { + return COMMAND_BY_KEY[key.toLowerCase()]; +} diff --git a/examples/regression-dashboard/src/helpers/state.ts b/examples/regression-dashboard/src/helpers/state.ts new file mode 100644 index 00000000..b2fb0b05 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/state.ts @@ -0,0 +1,204 @@ +import { DEFAULT_THEME_NAME, cycleThemeName } from "../theme.js"; +import type { + DashboardAction, + DashboardState, + Service, + ServiceFilter, + ServiceStatus, +} from "../types.js"; + +const FILTER_ORDER: readonly ServiceFilter[] = Object.freeze(["all", "warning", "down", "healthy"]); +const MAX_HISTORY = 18; + +const SEED_SERVICES: readonly Service[] = Object.freeze([ + { + id: "auth", + name: "Auth Gateway", + region: "us-east-1", + owner: "Identity", + status: "healthy", + latencyMs: 23, + errorRate: 0.2, + trafficRpm: 14320, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 23)), + }, + { + id: "billing", + name: "Billing API", + region: "us-west-2", + owner: "Commerce", + status: "warning", + latencyMs: 83, + errorRate: 1.1, + trafficRpm: 7390, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 83)), + }, + { + id: "search", + name: "Search Index", + region: "eu-central-1", + owner: "Discovery", + status: "healthy", + latencyMs: 37, + errorRate: 0.34, + trafficRpm: 9880, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 37)), + }, + { + id: "notify", + name: "Notification Bus", + region: "eu-west-1", + owner: "Messaging", + status: "healthy", + latencyMs: 31, + errorRate: 0.27, + trafficRpm: 8120, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 31)), + }, + { + id: "exports", + name: "Export Workers", + region: "us-east-1", + owner: "Data Platform", + status: "down", + latencyMs: 152, + errorRate: 4.4, + trafficRpm: 810, + history: Object.freeze(Array.from({ length: MAX_HISTORY }, () => 152)), + }, +]); + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function nextFilter(current: ServiceFilter): ServiceFilter { + const index = FILTER_ORDER.indexOf(current); + const next = index < 0 ? 0 : (index + 1) % FILTER_ORDER.length; + return FILTER_ORDER[next] ?? "all"; +} + +function deriveStatus(latencyMs: number, errorRate: number): ServiceStatus { + if (latencyMs >= 130 || errorRate >= 3.2) return "down"; + if (latencyMs >= 78 || errorRate >= 1.0) return "warning"; + return "healthy"; +} + +function evolveService(service: Service, index: number, nextTick: number): Service { + const phase = nextTick * 0.28 + index * 0.91; + const latency = clamp( + Math.round(service.latencyMs + Math.sin(phase) * 8 + Math.cos(phase * 0.66) * 6), + 12, + 220, + ); + const errorRate = round2(clamp(service.errorRate + Math.sin(phase * 0.73) * 0.15, 0.03, 6.4)); + const trafficRpm = clamp( + Math.round(service.trafficRpm + Math.sin(phase) * 420 + Math.cos(phase * 0.33) * 220), + 400, + 26000, + ); + const status = deriveStatus(latency, errorRate); + + return { + ...service, + latencyMs: latency, + errorRate, + trafficRpm, + status, + history: Object.freeze([...service.history, latency].slice(-MAX_HISTORY)), + }; +} + +function resolveSelectedId(state: DashboardState): string { + const visible = visibleServices(state); + if (visible.length === 0) return ""; + if (visible.some((service) => service.id === state.selectedId)) return state.selectedId; + return visible[0]?.id ?? ""; +} + +export function createInitialState(nowMs = Date.now()): DashboardState { + return { + services: SEED_SERVICES, + selectedId: + SEED_SERVICES.find((service) => service.status !== "healthy")?.id ?? + SEED_SERVICES[0]?.id ?? + "", + filter: "all", + paused: false, + showHelp: false, + themeName: DEFAULT_THEME_NAME, + tick: 0, + startedAtMs: nowMs, + lastUpdatedMs: nowMs, + }; +} + +export function visibleServices(state: DashboardState): readonly Service[] { + if (state.filter === "all") return state.services; + return state.services.filter((service) => service.status === state.filter); +} + +export function selectedService(state: DashboardState): Service | undefined { + const visible = visibleServices(state); + return visible.find((service) => service.id === state.selectedId) ?? visible[0]; +} + +export function reduceDashboardState( + state: DashboardState, + action: DashboardAction, +): DashboardState { + if (action.type === "toggle-pause") { + return { ...state, paused: !state.paused }; + } + + if (action.type === "toggle-help") { + return { ...state, showHelp: !state.showHelp }; + } + + if (action.type === "cycle-filter") { + const next = { ...state, filter: nextFilter(state.filter) }; + return { ...next, selectedId: resolveSelectedId(next) }; + } + + if (action.type === "cycle-theme") { + return { ...state, themeName: cycleThemeName(state.themeName) }; + } + + if (action.type === "set-selected-id") { + if (!state.services.some((service) => service.id === action.serviceId)) return state; + return { ...state, selectedId: action.serviceId }; + } + + if (action.type === "move-selection") { + const visible = visibleServices(state); + if (visible.length === 0) return state; + const current = visible.findIndex((service) => service.id === state.selectedId); + const from = current < 0 ? 0 : current; + const next = clamp(from + action.delta, 0, visible.length - 1); + const selected = visible[next]; + return selected ? { ...state, selectedId: selected.id } : state; + } + + if (action.type === "tick") { + if (state.paused) { + return { ...state, lastUpdatedMs: action.nowMs }; + } + const nextTick = state.tick + 1; + const services = state.services.map((service, index) => + evolveService(service, index, nextTick), + ); + const next = { + ...state, + services, + tick: nextTick, + lastUpdatedMs: action.nowMs, + }; + return { ...next, selectedId: resolveSelectedId(next) }; + } + + return state; +} diff --git a/examples/regression-dashboard/src/helpers/telemetry.ts b/examples/regression-dashboard/src/helpers/telemetry.ts new file mode 100644 index 00000000..50d48ab5 --- /dev/null +++ b/examples/regression-dashboard/src/helpers/telemetry.ts @@ -0,0 +1,22 @@ +export type TelemetryTick = Readonly<{ + nowMs: number; +}>; + +function normalizeIntervalMs(intervalMs: number): number { + if (!Number.isFinite(intervalMs) || intervalMs <= 0) return 1000; + return Math.floor(intervalMs); +} + +/** + * Create an endless async telemetry stream that yields at a fixed cadence. + */ +export async function* createTelemetryStream( + intervalMs: number, +): AsyncGenerator { + const cadenceMs = normalizeIntervalMs(intervalMs); + + while (true) { + await new Promise((resolve) => setTimeout(resolve, cadenceMs)); + yield { nowMs: Date.now() }; + } +} diff --git a/examples/regression-dashboard/src/main.ts b/examples/regression-dashboard/src/main.ts new file mode 100644 index 00000000..8e8e06e7 --- /dev/null +++ b/examples/regression-dashboard/src/main.ts @@ -0,0 +1,524 @@ +import { appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { exit } from "node:process"; +import { createDrawlistBuilder } from "@rezi-ui/core"; +import { parsePayload, parseRecordHeader } from "@rezi-ui/core/debug"; +import { createNodeApp } from "@rezi-ui/node"; +import { resolveDashboardCommand } from "./helpers/keybindings.js"; +import { reduceDashboardState, selectedService } from "./helpers/state.js"; +import { createInitialState } from "./helpers/state.js"; +import { renderOverviewScreen } from "./screens/overview.js"; +import { themeSpec } from "./theme.js"; +import type { DashboardAction, DashboardState } from "./types.js"; + +const UI_FPS_CAP = 30; +const TICK_MS = 900; +const DRAWLIST_HEADER_SIZE = 64; +const DEBUG_HEADER_SIZE = 40; +const DEBUG_QUERY_MAX_RECORDS = 64; +const ENABLE_BACKEND_DEBUG = process.env.REZI_REGRESSION_BACKEND_DEBUG !== "0"; +const DEBUG_LOG_PATH = + process.env.REZI_REGRESSION_DEBUG_LOG ?? `${tmpdir()}/rezi-regression-dashboard.log`; + +const initialState = createInitialState(); +const enableHsr = process.argv.includes("--hsr") || process.env.REZI_HSR === "1"; +const forceHeadless = process.argv.includes("--headless"); +const hasInteractiveTty = process.stdout.isTTY === true && process.stdin.isTTY === true; + +type OverviewRenderer = typeof renderOverviewScreen; +type OverviewModule = Readonly<{ + renderOverviewScreen?: OverviewRenderer; +}>; + +function serializeDetail(detail: unknown): string { + if (detail instanceof Error) { + return JSON.stringify({ + name: detail.name, + message: detail.message, + stack: detail.stack, + }); + } + if (typeof detail === "string") return detail; + try { + return JSON.stringify(detail); + } catch { + return String(detail); + } +} + +function describeError(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}`; + return serializeDetail(error); +} + +function stderrLog(message: string): void { + try { + process.stderr.write(`${message}\n`); + } catch { + // best-effort diagnostics only + } +} + +function toSignedI32(value: number): number { + return value > 0x7fff_ffff ? value - 0x1_0000_0000 : value; +} + +type DrawlistHeaderSummary = Readonly<{ + magic: number; + version: number; + headerSize: number; + totalSize: number; + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsSpanOffset: number; + stringsCount: number; + stringsBytesOffset: number; + stringsBytesLen: number; + blobsSpanOffset: number; + blobsCount: number; + blobsBytesOffset: number; + blobsBytesLen: number; + reserved0: number; +}>; + +function summarizeDrawlistHeader(bytes: Uint8Array): DrawlistHeaderSummary | null { + if (bytes.byteLength < DRAWLIST_HEADER_SIZE) return null; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const u32 = (offset: number) => dv.getUint32(offset, true); + return { + magic: u32(0), + version: u32(4), + headerSize: u32(8), + totalSize: u32(12), + cmdOffset: u32(16), + cmdBytes: u32(20), + cmdCount: u32(24), + stringsSpanOffset: u32(28), + stringsCount: u32(32), + stringsBytesOffset: u32(36), + stringsBytesLen: u32(40), + blobsSpanOffset: u32(44), + blobsCount: u32(48), + blobsBytesOffset: u32(52), + blobsBytesLen: u32(56), + reserved0: u32(60), + }; +} + +function summarizeDebugPayload(payload: unknown): unknown { + if (!payload || typeof payload !== "object") return payload; + const value = payload as Readonly>; + if (value.kind === "drawlistBytes") { + const bytes = value.bytes; + if (bytes instanceof Uint8Array) { + return { + kind: "drawlistBytes", + byteLength: bytes.byteLength, + header: summarizeDrawlistHeader(bytes), + }; + } + } + if (typeof value.validationResult === "number" && typeof value.executionResult === "number") { + return { + ...value, + validationResultSigned: toSignedI32(value.validationResult), + executionResultSigned: toSignedI32(value.executionResult), + }; + } + return value; +} + +function probeDrawlistHeader(): DrawlistHeaderSummary | null { + const builder = createDrawlistBuilder({}); + builder.clear(); + builder.drawText(0, 0, "probe"); + const built = builder.build(); + if (!built.ok) return null; + return summarizeDrawlistHeader(built.bytes); +} + +function debugLog(step: string, detail?: unknown): void { + try { + const payload = + detail === undefined + ? "" + : ` ${typeof detail === "string" ? detail : serializeDetail(detail)}`; + appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} ${step}${payload}\n`); + } catch { + // best-effort diagnostics only + } +} + +debugLog("boot", { + pid: process.pid, + cwd: process.cwd(), + term: process.env.TERM ?? null, + stdoutTTY: process.stdout.isTTY === true, + stdinTTY: process.stdin.isTTY === true, + stdoutCols: process.stdout.columns ?? null, + stdoutRows: process.stdout.rows ?? null, + argv: process.argv.slice(2), +}); + +let terminating = false; +function terminateProcessNow(exitCode: number): void { + if (terminating) return; + terminating = true; + process.exitCode = exitCode; + exit(exitCode); +} + +process.on("uncaughtException", (error) => { + debugLog("uncaughtException", error); + stderrLog(`Regression dashboard uncaught exception: ${describeError(error)}`); + terminateProcessNow(1); +}); +process.on("unhandledRejection", (reason) => { + debugLog("unhandledRejection", reason); + stderrLog(`Regression dashboard unhandled rejection: ${describeError(reason)}`); + terminateProcessNow(1); +}); +process.on("beforeExit", (code) => { + debugLog("beforeExit", { code }); +}); +process.on("exit", (code) => { + debugLog("exit", { code }); +}); +process.on("SIGTERM", () => { + debugLog("signal", "SIGTERM"); + terminateProcessNow(143); +}); +process.on("SIGINT", () => { + debugLog("signal", "SIGINT"); + terminateProcessNow(130); +}); + +if (forceHeadless || !hasInteractiveTty) { + debugLog("mode.headless", { forceHeadless, hasInteractiveTty }); + const { createTestRenderer } = await import("@rezi-ui/core/testing"); + const renderer = createTestRenderer({ viewport: { cols: 120, rows: 34 } }); + const tree = renderOverviewScreen(initialState, { + onTogglePause: () => {}, + onCycleFilter: () => {}, + onCycleTheme: () => {}, + onToggleHelp: () => {}, + onSelectService: () => {}, + }); + process.stdout.write(`${renderer.render(tree).toText()}\n`); + if (!forceHeadless) { + process.stderr.write( + "Regression dashboard: interactive mode needs a real TTY. Run this in a terminal, or use --headless.\n", + ); + } + debugLog("mode.headless.exit"); + exit(0); +} + +const ttyCols = + typeof process.stdout.columns === "number" && Number.isInteger(process.stdout.columns) + ? process.stdout.columns + : 0; +const ttyRows = + typeof process.stdout.rows === "number" && Number.isInteger(process.stdout.rows) + ? process.stdout.rows + : 0; +if (ttyCols <= 0 || ttyRows <= 0) { + const message = `Regression dashboard: terminal reports invalid size cols=${String(ttyCols)} rows=${String(ttyRows)}. Run \`stty rows 24 cols 80\` and retry, or run with --headless.`; + stderrLog(message); + debugLog("mode.invalid-tty-size", { ttyCols, ttyRows }); + exit(1); +} + +debugLog("app.create.begin"); +const app = createNodeApp({ + config: { + fpsCap: UI_FPS_CAP, + emojiWidthPolicy: "auto", + executionMode: "inline", + }, + initialState, + theme: themeSpec(initialState.themeName).theme, + ...(enableHsr + ? { + hotReload: { + viewModule: new URL("./screens/overview.ts", import.meta.url), + moduleRoot: new URL("./", import.meta.url), + resolveView: (moduleNs: unknown) => { + const render = (moduleNs as OverviewModule).renderOverviewScreen; + if (typeof render !== "function") { + throw new Error( + "HSR: ./screens/overview.ts must export renderOverviewScreen(state, actions)", + ); + } + return buildOverviewView(render); + }, + }, + } + : {}), +}); +debugLog("app.create.ok"); +let protocolMismatchReported = false; +const drawlistHeaderProbe = probeDrawlistHeader(); +debugLog("drawlist.probe.header", drawlistHeaderProbe); + +function buildOverviewView(renderer: OverviewRenderer) { + return (state: DashboardState) => + renderer(state, { + onTogglePause: () => dispatch({ type: "toggle-pause" }), + onCycleFilter: () => dispatch({ type: "cycle-filter" }), + onCycleTheme: () => dispatch({ type: "cycle-theme" }), + onToggleHelp: () => dispatch({ type: "toggle-help" }), + onSelectService: (serviceId) => dispatch({ type: "set-selected-id", serviceId }), + }); +} + +function dispatch(action: DashboardAction): void { + let nextThemeName = initialState.themeName; + let themeChanged = false; + + app.update((previous) => { + const next = reduceDashboardState(previous, action); + if (next.themeName !== previous.themeName) { + nextThemeName = next.themeName; + themeChanged = true; + } + return next; + }); + + if (themeChanged) { + app.setTheme(themeSpec(nextThemeName).theme); + } +} + +let stopping = false; +let telemetryTimer: ReturnType | null = null; +let fatalStopScheduled = false; + +async function stopApp(exitCode = 0): Promise { + if (stopping) return; + stopping = true; + + if (telemetryTimer) { + clearInterval(telemetryTimer); + telemetryTimer = null; + } + + try { + await app.stop(); + } catch { + // Ignore shutdown races. + } + + app.dispose(); + exit(exitCode); +} + +function applyCommand(command: ReturnType): void { + if (!command) return; + + if (command === "quit") { + void stopApp(); + return; + } + + if (command === "move-up") { + dispatch({ type: "move-selection", delta: -1 }); + return; + } + + if (command === "move-down") { + dispatch({ type: "move-selection", delta: 1 }); + return; + } + + if (command === "toggle-help") { + dispatch({ type: "toggle-help" }); + return; + } + + if (command === "toggle-pause") { + dispatch({ type: "toggle-pause" }); + return; + } + + if (command === "cycle-filter") { + dispatch({ type: "cycle-filter" }); + return; + } + + if (command === "cycle-theme") { + dispatch({ type: "cycle-theme" }); + } +} + +app.view(buildOverviewView(renderOverviewScreen)); +debugLog("app.view.set"); + +app.keys({ + q: () => applyCommand(resolveDashboardCommand("q")), + "ctrl+c": () => applyCommand(resolveDashboardCommand("ctrl+c")), + up: () => applyCommand(resolveDashboardCommand("up")), + down: () => applyCommand(resolveDashboardCommand("down")), + j: () => applyCommand(resolveDashboardCommand("j")), + k: () => applyCommand(resolveDashboardCommand("k")), + h: () => applyCommand(resolveDashboardCommand("h")), + "shift+/": () => applyCommand(resolveDashboardCommand("shift+/")), + f: () => applyCommand(resolveDashboardCommand("f")), + t: () => applyCommand(resolveDashboardCommand("t")), + p: () => applyCommand(resolveDashboardCommand("p")), + space: () => applyCommand(resolveDashboardCommand("space")), + escape: () => { + app.update((state) => (state.showHelp ? { ...state, showHelp: false } : state)); + }, + enter: () => { + app.update((state) => { + const selected = selectedService(state); + if (!selected) return state; + return { ...state, selectedId: selected.id }; + }); + }, +}); +debugLog("app.keys.set"); + +async function dumpBackendDebug(reason: string): Promise { + if (!ENABLE_BACKEND_DEBUG) return; + try { + const queried = await app.backend.debug.debugQuery({ maxRecords: DEBUG_QUERY_MAX_RECORDS }); + debugLog("backend.debug.query", { + reason, + result: queried.result, + headersByteLength: queried.headers.byteLength, + }); + + let lastDrawlistHeader: DrawlistHeaderSummary | null = null; + for ( + let offset = 0; + offset + DEBUG_HEADER_SIZE <= queried.headers.byteLength; + offset += DEBUG_HEADER_SIZE + ) { + const headerParsed = parseRecordHeader(queried.headers, offset); + if (!headerParsed.ok) { + debugLog("backend.debug.header.parse.error", { reason, offset, error: headerParsed.error }); + continue; + } + + const header = headerParsed.value; + const payloadBytes = + header.payloadSize > 0 + ? ((await app.backend.debug.debugGetPayload(header.recordId)) ?? new Uint8Array(0)) + : new Uint8Array(0); + const payloadParsed = parsePayload(header.category, payloadBytes); + if (!payloadParsed.ok) { + debugLog("backend.debug.payload.parse.error", { + reason, + header, + error: payloadParsed.error, + }); + continue; + } + + const payloadSummary = summarizeDebugPayload(payloadParsed.value); + debugLog("backend.debug.record", { reason, header, payload: payloadSummary }); + + if (payloadSummary && typeof payloadSummary === "object") { + const record = payloadSummary as Readonly>; + if (record.kind === "drawlistBytes") { + const headerSummary = record.header; + if (headerSummary && typeof headerSummary === "object") { + lastDrawlistHeader = headerSummary as DrawlistHeaderSummary; + } + } else if ( + !protocolMismatchReported && + typeof record.validationResultSigned === "number" && + record.validationResultSigned === -5 && + lastDrawlistHeader !== null && + (lastDrawlistHeader.stringsCount !== 0 || lastDrawlistHeader.blobsCount !== 0) + ) { + protocolMismatchReported = true; + const message = `Regression dashboard: native drawlist validation failed with ZR_ERR_FORMAT. Captured header uses strings/blobs sections (stringsCount=${String(lastDrawlistHeader.stringsCount)}, blobsCount=${String(lastDrawlistHeader.blobsCount)}), but the current native expects these header fields to be zero in drawlist v1. This indicates @rezi-ui/core and @rezi-ui/native drawlist wire formats are out of sync.`; + stderrLog(message); + debugLog("diagnostic.drawlist-wire-mismatch", { + reason, + validationResultSigned: record.validationResultSigned, + header: lastDrawlistHeader, + }); + } + } + } + } catch (error) { + debugLog("backend.debug.query.error", { reason, error: describeError(error) }); + } +} + +if (ENABLE_BACKEND_DEBUG) { + try { + debugLog("backend.debug.enable.begin"); + await app.backend.debug.debugEnable({ + enabled: true, + ringCapacity: 2048, + minSeverity: "trace", + captureDrawlistBytes: true, + }); + debugLog("backend.debug.enable.ok"); + } catch (error) { + debugLog("backend.debug.enable.error", error); + stderrLog( + `Regression dashboard: failed to enable backend debug trace: ${describeError(error)}`, + ); + } +} + +app.onEvent((event) => { + if (event.kind === "fatal") { + debugLog("event.fatal", event); + stderrLog(`Regression dashboard fatal: ${event.code}: ${event.detail}`); + process.exitCode = 1; + if ( + !protocolMismatchReported && + event.detail.includes("engine_submit_drawlist failed: code=-5") && + drawlistHeaderProbe !== null && + (drawlistHeaderProbe.stringsCount !== 0 || drawlistHeaderProbe.blobsCount !== 0) + ) { + protocolMismatchReported = true; + const message = `Regression dashboard: detected drawlist wire-format mismatch. Builder probe emits non-zero header string/blob sections (stringsCount=${String(drawlistHeaderProbe.stringsCount)}, blobsCount=${String(drawlistHeaderProbe.blobsCount)}), while native submit is failing with ZR_ERR_FORMAT (-5). This indicates @rezi-ui/core and @rezi-ui/native are out of sync.`; + stderrLog(message); + debugLog("diagnostic.drawlist-wire-mismatch", { event, drawlistHeaderProbe }); + } + if (telemetryTimer) { + clearInterval(telemetryTimer); + telemetryTimer = null; + } + if (!fatalStopScheduled) { + fatalStopScheduled = true; + void (async () => { + await dumpBackendDebug("fatal-event"); + await stopApp(1); + })(); + } + } +}); + +telemetryTimer = setInterval(() => { + dispatch({ type: "tick", nowMs: Date.now() }); +}, TICK_MS); +debugLog("timer.started", { tickMs: TICK_MS }); + +try { + debugLog("app.start.begin"); + await app.start(); + debugLog("app.start.ok"); +} catch (error) { + debugLog("app.start.error", error); + stderrLog(`Regression dashboard startup failed: ${describeError(error)}`); + process.exitCode = 1; + await dumpBackendDebug("app-start-error"); + await stopApp(1); +} finally { + debugLog("app.start.finally"); + if (telemetryTimer) { + clearInterval(telemetryTimer); + telemetryTimer = null; + } + debugLog("timer.stopped"); +} diff --git a/examples/regression-dashboard/src/screens/overview.ts b/examples/regression-dashboard/src/screens/overview.ts new file mode 100644 index 00000000..fd49250b --- /dev/null +++ b/examples/regression-dashboard/src/screens/overview.ts @@ -0,0 +1,206 @@ +import type { VNode } from "@rezi-ui/core"; +import { ui, when } from "@rezi-ui/core"; +import { + filterLabel, + fleetCounts, + formatErrorRate, + formatLatency, + formatTraffic, + overallStatus, + statusBadge, + statusGlyph, +} from "../helpers/formatters.js"; +import { selectedService, visibleServices } from "../helpers/state.js"; +import { PRODUCT_NAME, PRODUCT_TAGLINE, TEMPLATE_LABEL, stylesForTheme, themeSpec } from "../theme.js"; +import type { DashboardState } from "../types.js"; + +type DashboardScreenHandlers = Readonly<{ + onTogglePause: () => void; + onCycleFilter: () => void; + onCycleTheme: () => void; + onToggleHelp: () => void; + onSelectService: (serviceId: string) => void; +}>; + +function panel(title: string, body: readonly VNode[], style: Readonly>): VNode { + return ui.panel({ title, style }, body); +} + +export function renderOverviewScreen(state: DashboardState, handlers: DashboardScreenHandlers): VNode { + const styles = stylesForTheme(state.themeName); + const visible = visibleServices(state); + const selected = selectedService(state); + const counts = fleetCounts(state.services); + const health = statusBadge(overallStatus(state.services)); + const theme = themeSpec(state.themeName); + + const serviceRows: readonly VNode[] = + visible.length === 0 + ? [ui.text("No services match the current filter.", { style: styles.mutedStyle })] + : visible.map((service) => { + const selectedMarker = service.id === selected?.id ? "▸" : " "; + return ui.row({ key: service.id, gap: 1, items: "center", wrap: true }, [ + ui.text(selectedMarker, { style: styles.accentStyle }), + ui.badge(statusGlyph(service.status), { variant: statusBadge(service.status).variant }), + ui.button({ + id: `service-${service.id}`, + label: `${service.name} · ${service.region}`, + onPress: () => handlers.onSelectService(service.id), + }), + ui.tag(formatLatency(service.latencyMs), { + variant: service.status === "down" ? "error" : service.status === "warning" ? "warning" : "info", + }), + ui.tag(formatErrorRate(service.errorRate), { + variant: service.errorRate >= 3 ? "error" : service.errorRate >= 1 ? "warning" : "default", + }), + ui.text(formatTraffic(service.trafficRpm), { style: styles.mutedStyle }), + ]); + }); + + const uptimeSec = Math.max(1, Math.floor((Date.now() - state.startedAtMs) / 1000)); + const updateRate = (state.tick / uptimeSec).toFixed(2); + const inspectorContent = + when( + Boolean(selected), + () => { + const service = selected as NonNullable; + return ui.column({ gap: 1 }, [ + ui.row({ gap: 1, wrap: true }, [ + ui.badge(service.name, { variant: statusBadge(service.status).variant }), + ui.tag(service.owner, { variant: "default" }), + ui.tag(service.region, { variant: "info" }), + ]), + ui.text(`Latency: ${formatLatency(service.latencyMs)}`), + ui.text(`Error Rate: ${formatErrorRate(service.errorRate)}`), + ui.text(`Traffic: ${formatTraffic(service.trafficRpm)}`), + ui.text(`Update rate: ${updateRate} Hz`, { style: styles.mutedStyle }), + ui.sparkline(service.history, { width: 18, min: 0, max: 220 }), + ]); + }, + () => ui.text("No service selected.", { style: styles.mutedStyle }), + ) ?? ui.text("No service selected.", { style: styles.mutedStyle }); + + const content = ui.page({ + p: 1, + gap: 1, + header: ui.header({ + title: PRODUCT_NAME, + subtitle: PRODUCT_TAGLINE, + actions: [ + ui.badge(TEMPLATE_LABEL, { variant: "info" }), + ui.badge(`Fleet ${health.label}`, { variant: health.variant }), + ui.status(state.paused ? "away" : "online", { + label: state.paused ? "Paused" : "Streaming", + }), + ui.tag(`Theme ${theme.label}`, { variant: theme.badge }), + ], + }), + body: ui.column({ gap: 1 }, [ + panel( + "Actions", + [ + ui.actions([ + ui.button({ + id: "filter", + label: `Filter: ${filterLabel(state.filter)}`, + intent: "secondary", + onPress: handlers.onCycleFilter, + }), + ui.button({ + id: "theme", + label: "Cycle Theme", + intent: "secondary", + onPress: handlers.onCycleTheme, + }), + ui.button({ + id: "pause", + label: state.paused ? "Resume Stream" : "Pause Stream", + intent: state.paused ? "primary" : "warning", + onPress: handlers.onTogglePause, + }), + ui.button({ + id: "help", + label: "Help", + intent: "link", + onPress: handlers.onToggleHelp, + }), + ]), + ], + styles.panelStyle, + ), + ui.row({ gap: 1, wrap: true, items: "stretch" }, [ + panel( + "Service Fleet", + [ + ui.row({ gap: 1, wrap: true }, [ + ui.badge(`Healthy ${String(counts.healthy)}`, { variant: "success" }), + ui.badge(`Warning ${String(counts.warning)}`, { variant: "warning" }), + ui.badge(`Down ${String(counts.down)}`, { variant: "error" }), + ]), + ui.box({ height: 10, overflow: "scroll", border: "none" }, [...serviceRows]), + ui.table({ + id: "fleet-table", + columns: [ + { key: "name", header: "Service", flex: 1 }, + { key: "status", header: "Status", width: 8 }, + { key: "latencyMs", header: "Latency", width: 9, align: "right" }, + ], + data: visible, + getRowKey: (service) => service.id, + selection: selected ? [selected.id] : [], + selectionMode: "single", + onSelectionChange: (keys) => { + const key = keys[0]; + if (typeof key === "string") handlers.onSelectService(key); + }, + onRowPress: (row) => handlers.onSelectService(row.id), + dsSize: "sm", + dsTone: "default", + }), + ], + styles.panelStyle, + ), + panel( + "Inspector", + [inspectorContent], + styles.panelStyle, + ), + ]), + ]), + footer: ui.statusBar({ + left: [ui.text("Keys: q quit · j/k or arrows move · f filter · t theme · p pause · h/? help", { + style: styles.mutedStyle, + })], + right: [ui.text(`Tick ${String(state.tick)}`, { style: styles.mutedStyle })], + }), + }); + + if (!state.showHelp) return content; + + return ui.layers([ + content, + ui.modal({ + id: "dashboard-help", + title: `${PRODUCT_NAME} Commands`, + width: 70, + backdrop: "none", + returnFocusTo: "help", + content: ui.column({ gap: 1 }, [ + ui.text("q, ctrl+c : quit"), + ui.text("j/k, up/down : move selection"), + ui.text("f : cycle service filter"), + ui.text("t : cycle theme"), + ui.text("p or space : pause stream"), + ui.text("h, ? or escape : close help"), + ]), + actions: [ + ui.button({ + id: "help-close", + label: "Close", + onPress: handlers.onToggleHelp, + }), + ], + onClose: handlers.onToggleHelp, + }), + ]); +} diff --git a/examples/regression-dashboard/src/theme.ts b/examples/regression-dashboard/src/theme.ts new file mode 100644 index 00000000..a80b912f --- /dev/null +++ b/examples/regression-dashboard/src/theme.ts @@ -0,0 +1,51 @@ +import type { BadgeVariant, TextStyle, ThemeDefinition } from "@rezi-ui/core"; +import { darkTheme, lightTheme, nordTheme } from "@rezi-ui/core"; +import type { ThemeName } from "./types.js"; + +type ThemeSpec = Readonly<{ + label: string; + badge: BadgeVariant; + theme: ThemeDefinition; +}>; + +export const PRODUCT_NAME = "Regression Dashboard"; +export const TEMPLATE_LABEL = "dashboard"; +export const PRODUCT_TAGLINE = "Deterministic incident dashboard starter"; +export const DEFAULT_THEME_NAME: ThemeName = "nord"; + +const THEME_ORDER: readonly ThemeName[] = Object.freeze(["nord", "dark", "light"]); + +const THEME_BY_NAME: Record = { + nord: { label: "Nord", badge: "info", theme: nordTheme }, + dark: { label: "Dark", badge: "default", theme: darkTheme }, + light: { label: "Light", badge: "success", theme: lightTheme }, +}; + +export function themeSpec(themeName: ThemeName): ThemeSpec { + return THEME_BY_NAME[themeName]; +} + +export function cycleThemeName(current: ThemeName): ThemeName { + const index = THEME_ORDER.indexOf(current); + const next = index < 0 ? 0 : (index + 1) % THEME_ORDER.length; + return THEME_ORDER[next] ?? DEFAULT_THEME_NAME; +} + +export type DashboardStyles = Readonly<{ + rootStyle: TextStyle; + panelStyle: TextStyle; + stripStyle: TextStyle; + mutedStyle: TextStyle; + accentStyle: TextStyle; +}>; + +export function stylesForTheme(themeName: ThemeName): DashboardStyles { + const colors = themeSpec(themeName).theme.colors; + return Object.freeze({ + rootStyle: { bg: colors.bg.base, fg: colors.fg.primary }, + panelStyle: { bg: colors.bg.elevated, fg: colors.fg.primary }, + stripStyle: { bg: colors.bg.subtle, fg: colors.fg.primary }, + mutedStyle: { fg: colors.fg.secondary, dim: true }, + accentStyle: { fg: colors.accent.primary, bold: true }, + }); +} diff --git a/examples/regression-dashboard/src/types.ts b/examples/regression-dashboard/src/types.ts new file mode 100644 index 00000000..9715dadd --- /dev/null +++ b/examples/regression-dashboard/src/types.ts @@ -0,0 +1,36 @@ +export type ServiceStatus = "healthy" | "warning" | "down"; +export type ServiceFilter = "all" | ServiceStatus; +export type ThemeName = "nord" | "dark" | "light"; + +export type Service = Readonly<{ + id: string; + name: string; + region: string; + owner: string; + status: ServiceStatus; + latencyMs: number; + errorRate: number; + trafficRpm: number; + history: readonly number[]; +}>; + +export type DashboardState = Readonly<{ + services: readonly Service[]; + selectedId: string; + filter: ServiceFilter; + paused: boolean; + showHelp: boolean; + themeName: ThemeName; + tick: number; + startedAtMs: number; + lastUpdatedMs: number; +}>; + +export type DashboardAction = + | Readonly<{ type: "tick"; nowMs: number }> + | Readonly<{ type: "toggle-pause" }> + | Readonly<{ type: "toggle-help" }> + | Readonly<{ type: "cycle-filter" }> + | Readonly<{ type: "cycle-theme" }> + | Readonly<{ type: "move-selection"; delta: -1 | 1 }> + | Readonly<{ type: "set-selected-id"; serviceId: string }>; diff --git a/examples/regression-dashboard/tsconfig.json b/examples/regression-dashboard/tsconfig.json new file mode 100644 index 00000000..96f0f5a8 --- /dev/null +++ b/examples/regression-dashboard/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../../packages/core" }, { "path": "../../packages/node" }] +} diff --git a/mkdocs.yml b/mkdocs.yml index c66f6e8e..bcc9b91b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -250,6 +250,7 @@ nav: - Repo Layout: dev/repo-layout.md - Build: dev/build.md - Testing: dev/testing.md + - Live PTY Debugging: dev/live-pty-debugging.md - Code Standards: dev/code-standards.md - Ink Compat Debugging: dev/ink-compat-debugging.md - Perf Regressions: dev/perf-regressions.md diff --git a/package-lock.json b/package-lock.json index d94421d2..4d9f81a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,16 @@ "@rezi-ui/node": "0.1.0-alpha.34" } }, + "examples/regression-dashboard": { + "dependencies": { + "@rezi-ui/core": "file:../../packages/core", + "@rezi-ui/node": "file:../../packages/node" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=18" + } + }, "examples/release-demo": { "extraneous": true, "dependencies": { @@ -2201,6 +2211,10 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/regression-dashboard": { + "resolved": "examples/regression-dashboard", + "link": true + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -3240,9 +3254,7 @@ "license": "Apache-2.0", "dependencies": { "@rezi-ui/core": "0.1.0-alpha.34", - "@rezi-ui/node": "0.1.0-alpha.34", - "react": "^18.2.0 || ^19.0.0", - "react-reconciler": "^0.29.0 || ^0.30.0 || ^0.31.0" + "@rezi-ui/node": "0.1.0-alpha.34" }, "devDependencies": { "@types/react": "^18.2.0 || ^19.0.0", @@ -3253,7 +3265,8 @@ "node": ">=18" }, "peerDependencies": { - "react": "^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0", + "react-reconciler": "^0.29.0 || ^0.30.0 || ^0.31.0" } }, "packages/ink-compat/node_modules/react-reconciler": { @@ -3261,6 +3274,7 @@ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -3275,7 +3289,8 @@ "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "packages/jsx": { "name": "@rezi-ui/jsx", diff --git a/package.json b/package.json index 5c17a608..d4ce8332 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "check:create-rezi-templates": "node scripts/check-create-rezi-templates.mjs", "check:core-portability": "node scripts/check-core-portability.mjs", "check:docs": "npm run docs:build", + "check:native-vendor": "node scripts/check-native-vendor-integrity.mjs", "check:unicode": "node scripts/check-unicode-sync.mjs", - "check": "npm run codegen:check && npm run check:create-rezi-templates && npm run check:core-portability && npm run check:docs && npm run check:unicode", + "check": "npm run codegen:check && npm run check:create-rezi-templates && npm run check:core-portability && npm run check:native-vendor && npm run check:docs && npm run check:unicode", "test": "node scripts/run-tests.mjs", "test:e2e": "node scripts/run-e2e.mjs", "test:e2e:reduced": "node scripts/run-e2e.mjs --profile reduced", diff --git a/packages/core/src/__tests__/drawlistDecode.ts b/packages/core/src/__tests__/drawlistDecode.ts new file mode 100644 index 00000000..88f421c4 --- /dev/null +++ b/packages/core/src/__tests__/drawlistDecode.ts @@ -0,0 +1,358 @@ +export const OP_CLEAR = 1; +export const OP_FILL_RECT = 2; +export const OP_DRAW_TEXT = 3; +export const OP_PUSH_CLIP = 4; +export const OP_POP_CLIP = 5; +export const OP_DRAW_TEXT_RUN = 6; +export const OP_SET_CURSOR = 7; +export const OP_DRAW_CANVAS = 8; +export const OP_DRAW_IMAGE = 9; +export const OP_DEF_STRING = 10; +export const OP_FREE_STRING = 11; +export const OP_DEF_BLOB = 12; +export const OP_FREE_BLOB = 13; +export const OP_BLIT_RECT = 14; + +export type DrawlistCommandHeader = Readonly<{ + opcode: number; + size: number; + offset: number; + payloadOffset: number; + payloadSize: number; +}>; + +export type DrawTextCommand = Readonly<{ + x: number; + y: number; + stringId: number; + byteOff: number; + byteLen: number; + text: string; +}>; + +export type DrawTextRunCommand = Readonly<{ + x: number; + y: number; + blobId: number; + blobBytes: Uint8Array | null; +}>; + +type ResourceState = Readonly<{ + strings: ReadonlyMap; + blobs: ReadonlyMap; +}>; + +const HEADER_SIZE = 64; +const ZRDL_MAGIC = 0x4c44_525a; +const MIN_CMD_SIZE = 8; +const DECODER = new TextDecoder(); + +function u16(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint16(off, true); +} + +function i32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getInt32(off, true); +} + +export function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function cloneBytes(bytes: Uint8Array): Uint8Array { + return Uint8Array.from(bytes); +} + +function readCommandBounds(bytes: Uint8Array): Readonly<{ cmdOffset: number; cmdEnd: number }> { + if (bytes.byteLength < HEADER_SIZE) { + throw new Error(`drawlist too small for header (len=${String(bytes.byteLength)})`); + } + + const cmdOffset = u32(bytes, 16); + const cmdBytes = u32(bytes, 20); + const cmdEnd = cmdOffset + cmdBytes; + if (cmdBytes === 0) return { cmdOffset, cmdEnd }; + if (cmdOffset < HEADER_SIZE || cmdOffset > bytes.byteLength) { + throw new Error( + `drawlist cmdOffset out of bounds (cmdOffset=${String(cmdOffset)}, len=${String(bytes.byteLength)})`, + ); + } + if (cmdEnd < cmdOffset || cmdEnd > bytes.byteLength) { + throw new Error( + `drawlist cmd range out of bounds (cmdOffset=${String(cmdOffset)}, cmdBytes=${String(cmdBytes)}, len=${String(bytes.byteLength)})`, + ); + } + return { cmdOffset, cmdEnd }; +} + +export function parseCommandHeaders(bytes: Uint8Array): readonly DrawlistCommandHeader[] { + const { cmdOffset, cmdEnd } = readCommandBounds(bytes); + const out: DrawlistCommandHeader[] = []; + + let off = cmdOffset; + while (off < cmdEnd) { + const opcode = u16(bytes, off); + const size = u32(bytes, off + 4); + if (size < MIN_CMD_SIZE) { + throw new Error(`command size below minimum at offset ${String(off)} (size=${String(size)})`); + } + if ((size & 3) !== 0) { + throw new Error( + `command size not 4-byte aligned at offset ${String(off)} (size=${String(size)})`, + ); + } + const next = off + size; + if (next > cmdEnd) { + throw new Error( + `command overruns cmd section at offset ${String(off)} (size=${String(size)}, cmdEnd=${String(cmdEnd)})`, + ); + } + out.push({ + opcode, + size, + offset: off, + payloadOffset: off + MIN_CMD_SIZE, + payloadSize: size - MIN_CMD_SIZE, + }); + off = next; + } + + if (off !== cmdEnd) { + throw new Error( + `command parse did not end exactly at cmdEnd (off=${String(off)}, cmdEnd=${String(cmdEnd)})`, + ); + } + + return Object.freeze(out); +} + +function parseResourceState(bytes: Uint8Array): ResourceState { + const headers = parseCommandHeaders(bytes); + const strings = new Map(); + const blobs = new Map(); + + for (const cmd of headers) { + switch (cmd.opcode) { + case OP_DEF_STRING: { + if (cmd.size < 16) { + throw new Error( + `DEF_STRING too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); + } + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + if (dataEnd > cmd.offset + cmd.size) { + throw new Error( + `DEF_STRING payload overflow at offset ${String(cmd.offset)} (len=${String(byteLen)}, size=${String(cmd.size)})`, + ); + } + strings.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_STRING: { + if (cmd.size !== 12) { + throw new Error( + `FREE_STRING wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); + } + strings.delete(u32(bytes, cmd.offset + 8)); + break; + } + case OP_DEF_BLOB: { + if (cmd.size < 16) { + throw new Error( + `DEF_BLOB too small at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); + } + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + if (dataEnd > cmd.offset + cmd.size) { + throw new Error( + `DEF_BLOB payload overflow at offset ${String(cmd.offset)} (len=${String(byteLen)}, size=${String(cmd.size)})`, + ); + } + blobs.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_BLOB: { + if (cmd.size !== 12) { + throw new Error( + `FREE_BLOB wrong size at offset ${String(cmd.offset)} size=${String(cmd.size)}`, + ); + } + blobs.delete(u32(bytes, cmd.offset + 8)); + break; + } + default: + break; + } + } + + return Object.freeze({ + strings: strings as ReadonlyMap, + blobs: blobs as ReadonlyMap, + }); +} + +function parseInternedStringsSingle(bytes: Uint8Array): readonly string[] { + const resources = parseResourceState(bytes); + const ids = [...resources.strings.keys()].sort((a, b) => a - b); + const out: string[] = []; + for (const id of ids) { + const strBytes = resources.strings.get(id); + if (!strBytes) continue; + out.push(DECODER.decode(strBytes)); + } + return Object.freeze(out); +} + +export function parseInternedStrings(bytes: Uint8Array): readonly string[] { + if (!(bytes instanceof Uint8Array) || bytes.byteLength < HEADER_SIZE) { + return Object.freeze([]); + } + + const merged: string[] = []; + let off = 0; + let parsedAny = false; + while (off + HEADER_SIZE <= bytes.byteLength) { + const magic = u32(bytes, off + 0); + const headerSize = u32(bytes, off + 8); + const totalSize = u32(bytes, off + 12); + if ( + magic !== ZRDL_MAGIC || + headerSize !== HEADER_SIZE || + totalSize < HEADER_SIZE || + (totalSize & 3) !== 0 || + off + totalSize > bytes.byteLength + ) { + break; + } + + parsedAny = true; + try { + const list = parseInternedStringsSingle(bytes.subarray(off, off + totalSize)); + merged.push(...list); + } catch { + return Object.freeze([]); + } + off += totalSize; + } + + if (parsedAny && off === bytes.byteLength) { + return Object.freeze(merged); + } + + try { + return parseInternedStringsSingle(bytes); + } catch { + return Object.freeze([]); + } +} + +export function parseBlobById(bytes: Uint8Array, blobId: number): Uint8Array | null { + const resources = parseResourceState(bytes); + const blob = resources.blobs.get(blobId); + if (!blob) return null; + return cloneBytes(blob); +} + +export function parseDrawTextCommands(bytes: Uint8Array): readonly DrawTextCommand[] { + const headers = parseCommandHeaders(bytes); + const strings = new Map(); + const out: DrawTextCommand[] = []; + + for (const cmd of headers) { + switch (cmd.opcode) { + case OP_DEF_STRING: { + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + strings.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_STRING: { + const id = u32(bytes, cmd.offset + 8); + strings.delete(id); + break; + } + case OP_DRAW_TEXT: { + if (cmd.size < 28) break; + const stringId = u32(bytes, cmd.offset + 16); + const byteOff = u32(bytes, cmd.offset + 20); + const byteLen = u32(bytes, cmd.offset + 24); + const str = strings.get(stringId); + let text = ""; + if (str) { + const end = byteOff + byteLen; + if (end <= str.byteLength) { + text = DECODER.decode(str.subarray(byteOff, end)); + } + } + out.push( + Object.freeze({ + x: i32(bytes, cmd.offset + 8), + y: i32(bytes, cmd.offset + 12), + stringId, + byteOff, + byteLen, + text, + }), + ); + break; + } + default: + break; + } + } + + return Object.freeze(out); +} + +export function parseDrawTextRunCommands(bytes: Uint8Array): readonly DrawTextRunCommand[] { + const headers = parseCommandHeaders(bytes); + const blobs = new Map(); + const out: DrawTextRunCommand[] = []; + + for (const cmd of headers) { + switch (cmd.opcode) { + case OP_DEF_BLOB: { + const id = u32(bytes, cmd.offset + 8); + const byteLen = u32(bytes, cmd.offset + 12); + const dataStart = cmd.offset + 16; + const dataEnd = dataStart + byteLen; + blobs.set(id, cloneBytes(bytes.subarray(dataStart, dataEnd))); + break; + } + case OP_FREE_BLOB: { + blobs.delete(u32(bytes, cmd.offset + 8)); + break; + } + case OP_DRAW_TEXT_RUN: { + if (cmd.size < 24) break; + const blobId = u32(bytes, cmd.offset + 16); + const blobBytes = blobs.get(blobId) ?? null; + out.push( + Object.freeze({ + x: i32(bytes, cmd.offset + 8), + y: i32(bytes, cmd.offset + 12), + blobId, + blobBytes: blobBytes ? cloneBytes(blobBytes) : null, + }), + ); + break; + } + default: + break; + } + } + + return Object.freeze(out); +} diff --git a/packages/core/src/__tests__/integration/integration.dashboard.test.ts b/packages/core/src/__tests__/integration/integration.dashboard.test.ts index 2a3d293e..4d49c684 100644 --- a/packages/core/src/__tests__/integration/integration.dashboard.test.ts +++ b/packages/core/src/__tests__/integration/integration.dashboard.test.ts @@ -13,6 +13,7 @@ import { ZR_KEY_TAB, } from "../../keybindings/keyCodes.js"; import { ui } from "../../widgets/ui.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; type SectionId = "overview" | "files" | "settings"; @@ -90,112 +91,6 @@ function u32(bytes: Uint8Array, off: number): number { return dv.getUint32(off, true); } -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - assert.ok(cmdEnd <= bytes.byteLength, "command section must be in bounds"); - - const out: string[] = []; - const seen = new Set(); - const decoder = new TextDecoder(); - const pushUnique = (text: string): void => { - if (seen.has(text)) return; - seen.add(text); - out.push(text); - }; - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.ok(size >= 8, "command size must be >= 8"); - - if (opcode === 3 && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - if (stringIndex < count) { - const span = spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - if (byteOff + byteLen <= strLen) { - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - } - - off += size; - } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - - const blobsSpanOffset = u32(bytes, 44); - const blobsCount = u32(bytes, 48); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - const blobsEnd = blobsBytesOffset + blobsBytesLen; - assert.ok(blobsEnd <= bytes.byteLength, "blob section must be in bounds"); - - for (let i = 0; i < blobsCount; i++) { - const span = blobsSpanOffset + i * 8; - const blobStart = blobsBytesOffset + u32(bytes, span); - const blobEnd = blobStart + u32(bytes, span + 4); - if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue; - - const segmentCount = u32(bytes, blobStart); - const segmentBase = blobStart + 4; - for (let seg = 0; seg < segmentCount; seg++) { - const segStart = segmentBase + seg * 40; - const segEnd = segStart + 40; - if (segEnd > blobEnd) break; - - const stringIndex = u32(bytes, segStart + 28); - const byteOff = u32(bytes, segStart + 32); - const byteLen = u32(bytes, segStart + 36); - if (stringIndex >= count) continue; - - const stringSpan = spanOffset + stringIndex * 8; - const strOff = u32(bytes, stringSpan); - const strLen = u32(bytes, stringSpan + 4); - if (byteOff + byteLen > strLen) continue; - - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - - return Object.freeze(out); -} - function parseHeader(bytes: Uint8Array): Readonly<{ totalSize: number; cmdOffset: number; @@ -208,7 +103,7 @@ function parseHeader(bytes: Uint8Array): Readonly<{ cmdOffset: u32(bytes, 16), cmdBytes: u32(bytes, 20), cmdCount: u32(bytes, 24), - stringCount: u32(bytes, 32), + stringCount: parseInternedStrings(bytes).length, }); } diff --git a/packages/core/src/__tests__/integration/integration.file-manager.test.ts b/packages/core/src/__tests__/integration/integration.file-manager.test.ts index 03441150..4dc1237b 100644 --- a/packages/core/src/__tests__/integration/integration.file-manager.test.ts +++ b/packages/core/src/__tests__/integration/integration.file-manager.test.ts @@ -22,6 +22,7 @@ import { } from "../../keybindings/keyCodes.js"; import type { CommandItem, CommandSource, FileNode } from "../../widgets/types.js"; import { ui } from "../../widgets/ui.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; @@ -336,109 +337,6 @@ function u32(bytes: Uint8Array, off: number): number { return dv.getUint32(off, true); } -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.equal(tableEnd <= bytes.byteLength, true); - assert.equal(cmdEnd <= bytes.byteLength, true); - - const out: string[] = []; - const seen = new Set(); - const decoder = new TextDecoder(); - const pushUnique = (text: string): void => { - if (seen.has(text)) return; - seen.add(text); - out.push(text); - }; - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.equal(end <= tableEnd, true); - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true); - if (opcode === 3 && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - if (stringIndex < count) { - const span = spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - if (byteOff + byteLen <= strLen) { - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - } - off += size; - } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - - const blobsSpanOffset = u32(bytes, 44); - const blobsCount = u32(bytes, 48); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - const blobsEnd = blobsBytesOffset + blobsBytesLen; - assert.equal(blobsEnd <= bytes.byteLength, true); - - for (let i = 0; i < blobsCount; i++) { - const span = blobsSpanOffset + i * 8; - const blobStart = blobsBytesOffset + u32(bytes, span); - const blobEnd = blobStart + u32(bytes, span + 4); - if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue; - - const segmentCount = u32(bytes, blobStart); - const segmentBase = blobStart + 4; - for (let seg = 0; seg < segmentCount; seg++) { - const segStart = segmentBase + seg * 40; - const segEnd = segStart + 40; - if (segEnd > blobEnd) break; - - const stringIndex = u32(bytes, segStart + 28); - const byteOff = u32(bytes, segStart + 32); - const byteLen = u32(bytes, segStart + 36); - if (stringIndex >= count) continue; - - const stringSpan = spanOffset + stringIndex * 8; - const strOff = u32(bytes, stringSpan); - const strLen = u32(bytes, stringSpan + 4); - if (byteOff + byteLen > strLen) continue; - - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - - return Object.freeze(out); -} - function containsText(strings: readonly string[], needle: string): boolean { return strings.some((entry) => entry.includes(needle)); } diff --git a/packages/core/src/__tests__/integration/integration.form-editor.test.ts b/packages/core/src/__tests__/integration/integration.form-editor.test.ts index b5dea9d3..160545cc 100644 --- a/packages/core/src/__tests__/integration/integration.form-editor.test.ts +++ b/packages/core/src/__tests__/integration/integration.form-editor.test.ts @@ -17,6 +17,7 @@ import { ZR_MOD_SHIFT, } from "../../keybindings/keyCodes.js"; import type { Rect } from "../../layout/types.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; const VIEWPORT = Object.freeze({ cols: 96, rows: 30 }); @@ -660,11 +661,6 @@ function u32(bytes: Uint8Array, off: number): number { return dv.getUint32(off, true); } -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - function splitDrawlists(bundle: Uint8Array): readonly Uint8Array[] { const out: Uint8Array[] = []; let off = 0; @@ -682,107 +678,6 @@ function splitDrawlists(bundle: Uint8Array): readonly Uint8Array[] { return out; } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return []; - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength); - assert.ok(cmdEnd <= bytes.byteLength); - - const out: string[] = []; - const seen = new Set(); - const decoder = new TextDecoder(); - const pushUnique = (text: string): void => { - if (seen.has(text)) return; - seen.add(text); - out.push(text); - }; - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.ok(end <= tableEnd); - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.ok(size >= 8); - if (opcode === 3 && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - if (stringIndex < count) { - const span = spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - if (byteOff + byteLen <= strLen) { - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - } - off += size; - } - assert.equal(off, cmdEnd); - - const blobsSpanOffset = u32(bytes, 44); - const blobsCount = u32(bytes, 48); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - const blobsEnd = blobsBytesOffset + blobsBytesLen; - assert.ok(blobsEnd <= bytes.byteLength); - - for (let i = 0; i < blobsCount; i++) { - const span = blobsSpanOffset + i * 8; - const blobStart = blobsBytesOffset + u32(bytes, span); - const blobEnd = blobStart + u32(bytes, span + 4); - if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue; - - const segmentCount = u32(bytes, blobStart); - const segmentBase = blobStart + 4; - for (let seg = 0; seg < segmentCount; seg++) { - const segStart = segmentBase + seg * 40; - const segEnd = segStart + 40; - if (segEnd > blobEnd) break; - - const stringIndex = u32(bytes, segStart + 28); - const byteOff = u32(bytes, segStart + 32); - const byteLen = u32(bytes, segStart + 36); - if (stringIndex >= count) continue; - - const stringSpan = spanOffset + stringIndex * 8; - const strOff = u32(bytes, stringSpan); - const strLen = u32(bytes, stringSpan + 4); - if (byteOff + byteLen > strLen) continue; - - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - - return out; -} - function latestFrameStrings(backend: StubBackend): readonly string[] { const frame = backend.requestedFrames[backend.requestedFrames.length - 1]; if (!frame) { diff --git a/packages/core/src/__tests__/integration/integration.reflow.test.ts b/packages/core/src/__tests__/integration/integration.reflow.test.ts index 180c3a6c..b99de5e9 100644 --- a/packages/core/src/__tests__/integration/integration.reflow.test.ts +++ b/packages/core/src/__tests__/integration/integration.reflow.test.ts @@ -8,28 +8,24 @@ import { StubBackend } from "../../app/__tests__/stubBackend.js"; import { createApp } from "../../app/createApp.js"; import type { App } from "../../index.js"; import { ui } from "../../widgets/ui.js"; +import { + OP_CLEAR, + OP_DRAW_TEXT, + OP_FILL_RECT, + OP_PUSH_CLIP, + parseCommandHeaders, + parseDrawTextCommands, + parseInternedStrings, +} from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; type Viewport = Readonly<{ cols: number; rows: number }>; type Rect = Readonly<{ x: number; y: number; w: number; h: number }>; type DrawTextCommand = Readonly<{ x: number; y: number; text: string }>; -type StringHeader = Readonly<{ - spanOffset: number; - count: number; - bytesOffset: number; - bytesLen: number; -}>; type TableRow = Readonly<{ id: string; name: string; score: number }>; type TreeNode = Readonly<{ id: string; children: readonly TreeNode[] }>; -const OP_CLEAR = 1; -const OP_FILL_RECT = 2; -const OP_DRAW_TEXT = 3; -const OP_PUSH_CLIP = 4; - -const DECODER = new TextDecoder(); - function u16(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint16(off, true); @@ -46,120 +42,7 @@ function i32(bytes: Uint8Array, off: number): number { } function parseOpcodes(bytes: Uint8Array): readonly number[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const end = cmdOffset + cmdBytes; - - const out: number[] = []; - let off = cmdOffset; - while (off < end) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true, "command size must be >= 8"); - out.push(opcode); - off += size; - } - assert.equal(off, end, "commands must parse exactly to cmd end"); - return Object.freeze(out); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.equal(tableEnd <= bytes.byteLength, true, "string table must be in-bounds"); - assert.equal(cmdEnd <= bytes.byteLength, true, "command section must be in-bounds"); - - const out: string[] = []; - const seen = new Set(); - const pushUnique = (text: string): void => { - if (seen.has(text)) return; - seen.add(text); - out.push(text); - }; - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.equal(end <= tableEnd, true, "string span must be in-bounds"); - pushUnique(DECODER.decode(bytes.subarray(start, end))); - } - - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true, "command size must be >= 8"); - if (opcode === OP_DRAW_TEXT && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - if (stringIndex < count) { - const span = spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - if (byteOff + byteLen <= strLen) { - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(DECODER.decode(bytes.subarray(start, end))); - } - } - } - } - off += size; - } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - - const blobsSpanOffset = u32(bytes, 44); - const blobsCount = u32(bytes, 48); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - const blobsEnd = blobsBytesOffset + blobsBytesLen; - assert.equal(blobsEnd <= bytes.byteLength, true, "blob section must be in-bounds"); - - for (let i = 0; i < blobsCount; i++) { - const span = blobsSpanOffset + i * 8; - const blobStart = blobsBytesOffset + u32(bytes, span); - const blobEnd = blobStart + u32(bytes, span + 4); - if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue; - - const segmentCount = u32(bytes, blobStart); - const segmentBase = blobStart + 4; - for (let seg = 0; seg < segmentCount; seg++) { - const segStart = segmentBase + seg * 40; - const segEnd = segStart + 40; - if (segEnd > blobEnd) break; - - const stringIndex = u32(bytes, segStart + 28); - const byteOff = u32(bytes, segStart + 32); - const byteLen = u32(bytes, segStart + 36); - if (stringIndex >= count) continue; - - const stringSpan = spanOffset + stringIndex * 8; - const strOff = u32(bytes, stringSpan); - const strLen = u32(bytes, stringSpan + 4); - if (byteOff + byteLen > strLen) continue; - - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(DECODER.decode(bytes.subarray(start, end))); - } - } - } - - return Object.freeze(out); + return Object.freeze(parseCommandHeaders(bytes).map((cmd) => cmd.opcode)); } function parseFillRects(bytes: Uint8Array): readonly Rect[] { @@ -212,62 +95,16 @@ function parsePushClips(bytes: Uint8Array): readonly Rect[] { return Object.freeze(out); } -function readStringHeader(bytes: Uint8Array): StringHeader { - return { - spanOffset: u32(bytes, 28), - count: u32(bytes, 32), - bytesOffset: u32(bytes, 36), - bytesLen: u32(bytes, 40), - }; -} - -function decodeStringSlice( - bytes: Uint8Array, - header: StringHeader, - stringIndex: number, - byteOff: number, - byteLen: number, -): string { - assert.equal( - stringIndex >= 0 && stringIndex < header.count, - true, - "string index must be in-bounds", - ); - const span = header.spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - assert.equal(byteOff + byteLen <= strLen, true, "string slice must be in-bounds"); - const start = header.bytesOffset + strOff + byteOff; - const end = start + byteLen; - return DECODER.decode(bytes.subarray(start, end)); -} - function parseDrawTexts(bytes: Uint8Array): readonly DrawTextCommand[] { - const header = readStringHeader(bytes); - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - - const out: DrawTextCommand[] = []; - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true, "command size must be >= 8"); - if (opcode === OP_DRAW_TEXT && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - out.push({ - x: i32(bytes, off + 8), - y: i32(bytes, off + 12), - text: decodeStringSlice(bytes, header, stringIndex, byteOff, byteLen), - }); - } - off += size; - } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - return Object.freeze(out); + return Object.freeze( + parseDrawTextCommands(bytes).map((cmd) => + Object.freeze({ + x: cmd.x, + y: cmd.y, + text: cmd.text, + }), + ), + ); } function countOpcode(opcodes: readonly number[], opcode: number): number { diff --git a/packages/core/src/__tests__/integration/integration.resize.test.ts b/packages/core/src/__tests__/integration/integration.resize.test.ts index 14b9344e..5a0521c0 100644 --- a/packages/core/src/__tests__/integration/integration.resize.test.ts +++ b/packages/core/src/__tests__/integration/integration.resize.test.ts @@ -8,6 +8,7 @@ import { StubBackend } from "../../app/__tests__/stubBackend.js"; import { createApp } from "../../app/createApp.js"; import type { App } from "../../index.js"; import { ui } from "../../widgets/ui.js"; +import { parseInternedStrings } from "../drawlistDecode.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; type Viewport = Readonly<{ cols: number; rows: number }>; @@ -51,105 +52,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const cmdOffset = u32(bytes, 16); - const cmdBytes = u32(bytes, 20); - const cmdEnd = cmdOffset + cmdBytes; - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.equal(tableEnd <= bytes.byteLength, true, "string table must be in-bounds"); - assert.equal(cmdEnd <= bytes.byteLength, true, "command section must be in-bounds"); - const decoder = new TextDecoder(); - const out: string[] = []; - const seen = new Set(); - const pushUnique = (text: string): void => { - if (seen.has(text)) return; - seen.add(text); - out.push(text); - }; - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.equal(end <= tableEnd, true, "string span must be in-bounds"); - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - - let off = cmdOffset; - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.equal(size >= 8, true, "command size must be >= 8"); - if (opcode === OP_DRAW_TEXT && size >= 48) { - const stringIndex = u32(bytes, off + 16); - const byteOff = u32(bytes, off + 20); - const byteLen = u32(bytes, off + 24); - if (stringIndex < count) { - const span = spanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - if (byteOff + byteLen <= strLen) { - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - } - off += size; - } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - - const blobsSpanOffset = u32(bytes, 44); - const blobsCount = u32(bytes, 48); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - const blobsEnd = blobsBytesOffset + blobsBytesLen; - assert.equal(blobsEnd <= bytes.byteLength, true, "blob section must be in-bounds"); - - for (let i = 0; i < blobsCount; i++) { - const span = blobsSpanOffset + i * 8; - const blobStart = blobsBytesOffset + u32(bytes, span); - const blobEnd = blobStart + u32(bytes, span + 4); - if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue; - - const segmentCount = u32(bytes, blobStart); - const segmentBase = blobStart + 4; - for (let seg = 0; seg < segmentCount; seg++) { - const segStart = segmentBase + seg * 40; - const segEnd = segStart + 40; - if (segEnd > blobEnd) break; - - const stringIndex = u32(bytes, segStart + 28); - const byteOff = u32(bytes, segStart + 32); - const byteLen = u32(bytes, segStart + 36); - if (stringIndex >= count) continue; - - const stringSpan = spanOffset + stringIndex * 8; - const strOff = u32(bytes, stringSpan); - const strLen = u32(bytes, stringSpan + 4); - if (byteOff + byteLen > strLen) continue; - - const start = bytesOffset + strOff + byteOff; - const end = start + byteLen; - if (end <= tableEnd) { - pushUnique(decoder.decode(bytes.subarray(start, end))); - } - } - } - - return Object.freeze(out); -} - function parseFillRects(bytes: Uint8Array): readonly Rect[] { const cmdOffset = u32(bytes, 16); const cmdBytes = u32(bytes, 20); diff --git a/packages/core/src/abi.ts b/packages/core/src/abi.ts index 02da3e03..0ac2aa36 100644 --- a/packages/core/src/abi.ts +++ b/packages/core/src/abi.ts @@ -20,7 +20,6 @@ export const ZR_ENGINE_ABI_PATCH = 0; * Binary format version pins. */ export const ZR_DRAWLIST_VERSION_V1 = 1; -export const ZR_DRAWLIST_VERSION_V2 = 2; export const ZR_DRAWLIST_VERSION = ZR_DRAWLIST_VERSION_V1; export const ZR_EVENT_BATCH_VERSION_V1 = 1; diff --git a/packages/core/src/app/__tests__/hotStateReload.test.ts b/packages/core/src/app/__tests__/hotStateReload.test.ts index 510e3434..a7fa4c5c 100644 --- a/packages/core/src/app/__tests__/hotStateReload.test.ts +++ b/packages/core/src/app/__tests__/hotStateReload.test.ts @@ -1,4 +1,5 @@ import { assert, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { ZrUiError } from "../../abi.js"; import { defineWidget, ui } from "../../index.js"; import { ZR_KEY_ENTER, ZR_KEY_HOME, ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; @@ -6,32 +7,6 @@ import { createApp } from "../createApp.js"; import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; import { StubBackend } from "./stubBackend.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function latestFrameStrings(backend: StubBackend): readonly string[] { const frame = backend.requestedFrames[backend.requestedFrames.length - 1]; return parseInternedStrings(frame ?? new Uint8Array()); diff --git a/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts b/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts index f9355a94..d928ca94 100644 --- a/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts +++ b/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts @@ -1,4 +1,5 @@ import { assert, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { ZrUiError } from "../../abi.js"; import { ZR_MOD_CTRL, ZR_MOD_SHIFT, charToKeyCode } from "../../keybindings/keyCodes.js"; import { ui } from "../../widgets/ui.js"; @@ -6,32 +7,6 @@ import { createAppWithInspectorOverlay } from "../inspectorOverlayHelper.js"; import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; import { StubBackend } from "./stubBackend.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - async function pushEvents( backend: StubBackend, events: NonNullable[0]["events"]>, diff --git a/packages/core/src/app/__tests__/resilience.test.ts b/packages/core/src/app/__tests__/resilience.test.ts index 915c7401..a8e9b5c5 100644 --- a/packages/core/src/app/__tests__/resilience.test.ts +++ b/packages/core/src/app/__tests__/resilience.test.ts @@ -1,35 +1,10 @@ import { assert, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { defineWidget, ui } from "../../index.js"; import { createApp } from "../createApp.js"; import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; import { StubBackend } from "./stubBackend.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const start = bytesOffset + u32(bytes, span); - const end = start + u32(bytes, span + 4); - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - async function pushEvents( backend: StubBackend, events: NonNullable[0]["events"]>, diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index d80ec1b6..b034557a 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -203,19 +203,17 @@ function readBackendPositiveIntMarker( return value; } -function readBackendDrawlistVersionMarker(backend: RuntimeBackend): 1 | 2 | 3 | 4 | 5 | null { +function readBackendDrawlistVersionMarker(backend: RuntimeBackend): 1 | null { const value = (backend as RuntimeBackend & Readonly>)[ BACKEND_DRAWLIST_VERSION_MARKER ]; if (value === undefined) return null; - if ( - typeof value !== "number" || - !Number.isInteger(value) || - (value !== 1 && value !== 2 && value !== 3 && value !== 4 && value !== 5) - ) { - invalidProps(`backend marker ${BACKEND_DRAWLIST_VERSION_MARKER} must be an integer in [1..5]`); - } - return value as 1 | 2 | 3 | 4 | 5; + if (value !== 1) { + invalidProps( + `backend marker ${BACKEND_DRAWLIST_VERSION_MARKER} must be 1 (received ${String(value)})`, + ); + } + return 1; } function monotonicNowMs(): number { diff --git a/packages/core/src/app/rawRenderer.ts b/packages/core/src/app/rawRenderer.ts index 7bb448c4..ae39ba66 100644 --- a/packages/core/src/app/rawRenderer.ts +++ b/packages/core/src/app/rawRenderer.ts @@ -10,8 +10,9 @@ * @see docs/guide/lifecycle-and-updates.md */ -import type { RuntimeBackend } from "../backend.js"; +import { FRAME_ACCEPTED_ACK_MARKER, type RuntimeBackend } from "../backend.js"; import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; +import { FRAME_AUDIT_ENABLED, drawlistFingerprint, emitFrameAudit } from "../perf/frameAudit.js"; import { perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import type { DrawFn } from "./types.js"; @@ -48,6 +49,7 @@ function describeThrown(v: unknown): string { export class RawRenderer { private readonly backend: RuntimeBackend; private readonly builder: DrawlistBuilder; + private frameAuditSeq = 0; constructor( opts: Readonly<{ @@ -115,7 +117,54 @@ export class RawRenderer { } try { + const auditSeq = this.frameAuditSeq + 1; + this.frameAuditSeq = auditSeq; + const fingerprint = FRAME_AUDIT_ENABLED ? drawlistFingerprint(built.bytes) : null; + if (fingerprint !== null) { + emitFrameAudit("rawRenderer", "drawlist.built", { + frameSeq: auditSeq, + ...fingerprint, + }); + } const inFlight = this.backend.requestFrame(built.bytes); + if (fingerprint !== null) { + emitFrameAudit("rawRenderer", "backend.request", { + frameSeq: auditSeq, + ...fingerprint, + }); + const acceptedAck = ( + inFlight as Promise & + Partial>> + )[FRAME_ACCEPTED_ACK_MARKER]; + if (acceptedAck !== undefined) { + void acceptedAck.then( + () => + emitFrameAudit("rawRenderer", "backend.accepted", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("rawRenderer", "backend.accepted_error", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } + void inFlight.then( + () => + emitFrameAudit("rawRenderer", "backend.completed", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("rawRenderer", "backend.completed_error", { + frameSeq: auditSeq, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } return { ok: true, inFlight }; } catch (e: unknown) { return { ok: false, code: "ZRUI_BACKEND_ERROR", detail: describeThrown(e) }; diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 88a29329..cc55f8f3 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -22,7 +22,14 @@ */ import type { CursorShape } from "../abi.js"; -import { BACKEND_RAW_WRITE_MARKER, type BackendRawWrite, type RuntimeBackend } from "../backend.js"; +import { + BACKEND_BEGIN_FRAME_MARKER, + BACKEND_RAW_WRITE_MARKER, + type BackendBeginFrame, + type BackendRawWrite, + FRAME_ACCEPTED_ACK_MARKER, + type RuntimeBackend, +} from "../backend.js"; import { CURSOR_DEFAULTS } from "../cursor/index.js"; import { type DrawlistBuilder, createDrawlistBuilder } from "../drawlist/index.js"; import type { ZrevEvent } from "../events.js"; @@ -55,10 +62,6 @@ import { } from "../keybindings/keyCodes.js"; import type { LayoutOverflowMetadata } from "../layout/constraints.js"; import { computeDropdownGeometry } from "../layout/dropdownGeometry.js"; -import { - computeDirtyLayoutSet, - instanceDirtySetToVNodeDirtySet, -} from "../layout/engine/dirtySet.js"; import { hitTestAnyId, hitTestFocusable } from "../layout/hitTest.js"; import { type LayoutTree, layout } from "../layout/layout.js"; import { @@ -69,15 +72,12 @@ import { } from "../layout/responsive.js"; import { measureTextCells } from "../layout/textMeasure.js"; import type { Rect } from "../layout/types.js"; +import { FRAME_AUDIT_ENABLED, drawlistFingerprint, emitFrameAudit } from "../perf/frameAudit.js"; import { PERF_DETAIL_ENABLED, PERF_ENABLED, perfMarkEnd, perfMarkStart } from "../perf/perf.js"; import { type CursorInfo, renderToDrawlist } from "../renderer/renderToDrawlist.js"; import { getRuntimeNodeDamageRect } from "../renderer/renderToDrawlist/damageBounds.js"; import { renderTree } from "../renderer/renderToDrawlist/renderTree.js"; import { DEFAULT_BASE_STYLE } from "../renderer/renderToDrawlist/textStyle.js"; -import { - focusIndicatorEnabled as focusIndicatorEnabledForLogs, - readFocusConfig as readFocusConfigForLogs, -} from "../renderer/renderToDrawlist/widgets/focusConfig.js"; import { type CommitOk, type PendingExitAnimation, @@ -494,6 +494,13 @@ const LAYOUT_WARNINGS_ENV_RAW = const LAYOUT_WARNINGS_ENV = LAYOUT_WARNINGS_ENV_RAW.toLowerCase(); const DEV_LAYOUT_WARNINGS = DEV_MODE && (LAYOUT_WARNINGS_ENV === "1" || LAYOUT_WARNINGS_ENV === "true"); +const FRAME_AUDIT_TREE_ENABLED = + FRAME_AUDIT_ENABLED && + ( + globalThis as { + process?: { env?: { REZI_FRAME_AUDIT_TREE?: string } }; + } + ).process?.env?.REZI_FRAME_AUDIT_TREE === "1"; function warnDev(message: string): void { const c = (globalThis as { console?: { warn?: (msg: string) => void } }).console; @@ -535,47 +542,295 @@ function rectEquals(a: Rect, b: Rect): boolean { return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; } -function rectsIntersect(a: Rect, b: Rect): boolean { - if (a.w <= 0 || a.h <= 0 || b.w <= 0 || b.h <= 0) return false; - return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +function monotonicNowMs(): number { + const perf = (globalThis as { performance?: { now?: () => number } }).performance; + const perfNow = perf?.now; + if (typeof perfNow === "function") return perfNow.call(perf); + return Date.now(); } -function rectsIntersection(a: Rect, b: Rect): Rect | null { - const x0 = Math.max(a.x, b.x); - const y0 = Math.max(a.y, b.y); - const x1 = Math.min(a.x + a.w, b.x + b.w); - const y1 = Math.min(a.y + a.h, b.y + b.h); - if (x1 <= x0 || y1 <= y0) return null; - return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; +function pushLimited(list: string[], value: string, max: number): void { + if (list.length >= max) return; + list.push(value); +} + +function normalizeAuditText(value: string, maxChars = 96): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + +function describeAuditVNode(vnode: VNode): string { + const kind = vnode.kind; + const props = vnode.props as Readonly<{ id?: unknown; title?: unknown }> | undefined; + const id = typeof props?.id === "string" && props.id.length > 0 ? props.id : null; + const title = + typeof props?.title === "string" && props.title.length > 0 + ? normalizeAuditText(props.title, 24) + : null; + if (id !== null) return `${kind}#${id}`; + if (title !== null) return `${kind}[${title}]`; + return kind; } -function rectContainsRect(outer: Rect, inner: Rect): boolean { - if (inner.w <= 0 || inner.h <= 0) return false; - return ( - inner.x >= outer.x && - inner.y >= outer.y && - inner.x + inner.w <= outer.x + outer.w && - inner.y + inner.h <= outer.y + outer.h +function summarizeRuntimeTreeForAudit( + root: RuntimeInstance, + layoutRoot: LayoutTree, +): Readonly> { + const kindCounts = new Map(); + const zeroAreaKindCounts = new Map(); + const textSamples: string[] = []; + const titleSamples: string[] = []; + const titleRectSamples: string[] = []; + const zeroAreaTitleSamples: string[] = []; + const mismatchSamples: string[] = []; + const needleHits = new Set(); + const needles = [ + "Engineering Controls", + "Subsystem Tree", + "Crew Manifest", + "Search Crew", + "Channel Controls", + "Ship Settings", + ]; + + let nodeCount = 0; + let textNodeCount = 0; + let boxTitleCount = 0; + let compositeNodeCount = 0; + let zeroAreaNodes = 0; + let maxDepth = 0; + let maxChildrenDelta = 0; + + const stack: Array< + Readonly<{ node: RuntimeInstance; layout: LayoutTree; depth: number; path: string }> + > = [Object.freeze({ node: root, layout: layoutRoot, depth: 0, path: "root" })]; + const rootRect = layoutRoot.rect; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + const { node, layout, depth, path } = current; + nodeCount += 1; + if (depth > maxDepth) maxDepth = depth; + + const kind = node.vnode.kind; + const layoutKind = layout.vnode.kind; + kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1); + if ("__composite" in (node.vnode as object)) { + compositeNodeCount += 1; + } + if (kind !== layoutKind) { + const runtimeLabel = describeAuditVNode(node.vnode); + const layoutLabel = describeAuditVNode(layout.vnode); + pushLimited(mismatchSamples, `${path}:${runtimeLabel}!${layoutLabel}`, 24); + } + + const rect = layout.rect; + if (rect.w <= 0 || rect.h <= 0) { + zeroAreaNodes += 1; + zeroAreaKindCounts.set(kind, (zeroAreaKindCounts.get(kind) ?? 0) + 1); + } + + if (kind === "text") { + textNodeCount += 1; + const text = (node.vnode as Readonly<{ text: string }>).text; + pushLimited(textSamples, normalizeAuditText(text), 24); + for (const needle of needles) { + if (text.includes(needle)) needleHits.add(needle); + } + } else { + const props = node.vnode.props as Readonly<{ title?: unknown }> | undefined; + if (typeof props?.title === "string" && props.title.length > 0) { + boxTitleCount += 1; + pushLimited(titleSamples, normalizeAuditText(props.title), 24); + const offRoot = + rect.x + rect.w <= rootRect.x || + rect.y + rect.h <= rootRect.y || + rect.x >= rootRect.x + rootRect.w || + rect.y >= rootRect.y + rootRect.h; + const titleRectSummary = `${normalizeAuditText(props.title, 48)}@${String(rect.x)},${String(rect.y)},${String(rect.w)},${String(rect.h)}${offRoot ? ":off-root" : ""}`; + pushLimited(titleRectSamples, titleRectSummary, 24); + if (rect.w <= 0 || rect.h <= 0) { + pushLimited(zeroAreaTitleSamples, titleRectSummary, 24); + } + for (const needle of needles) { + if (props.title.includes(needle)) needleHits.add(needle); + } + } + } + + const childCount = Math.min(node.children.length, layout.children.length); + const delta = Math.abs(node.children.length - layout.children.length); + if (delta > 0) { + const id = (node.vnode.props as Readonly<{ id?: unknown }> | undefined)?.id; + const props = node.vnode.props as Readonly<{ title?: unknown }> | undefined; + const label = + typeof id === "string" && id.length > 0 + ? `${kind}#${id}` + : typeof props?.title === "string" && props.title.length > 0 + ? `${kind}[${normalizeAuditText(props.title, 32)}]` + : kind; + pushLimited( + mismatchSamples, + `${path}/${label}:runtimeChildren=${String(node.children.length)} layoutChildren=${String(layout.children.length)} layoutNode=${describeAuditVNode(layout.vnode)}`, + 24, + ); + } + if (delta > maxChildrenDelta) maxChildrenDelta = delta; + for (let i = childCount - 1; i >= 0; i--) { + const child = node.children[i]; + const childLayout = layout.children[i]; + if (!child || !childLayout) continue; + stack.push( + Object.freeze({ + node: child, + layout: childLayout, + depth: depth + 1, + path: `${path}/${child.vnode.kind}[${String(i)}]`, + }), + ); + } + } + + const topKinds = Object.fromEntries( + [...kindCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12), + ); + const topZeroAreaKinds = Object.fromEntries( + [...zeroAreaKindCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12), ); + + return Object.freeze({ + nodeCount, + textNodeCount, + boxTitleCount, + compositeNodeCount, + zeroAreaNodes, + maxDepth, + maxChildrenDelta, + topKinds, + topZeroAreaKinds, + textSamples, + titleSamples, + titleRectSamples, + zeroAreaTitleSamples, + mismatchSamples, + needleHits: [...needleHits].sort(), + }); } -function nodeKindCanPaintOverlay(kind: VNode["kind"]): boolean { - switch (kind) { - case "themed": - case "focusZone": - case "focusTrap": - case "layers": - return false; - default: - return true; +type RuntimeLayoutShapeMismatch = Readonly<{ + path: string; + depth: number; + reason: "kind" | "children"; + runtimeKind: string; + layoutKind: string; + runtimeChildCount: number; + layoutChildCount: number; + runtimeTrail: readonly string[]; + layoutTrail: readonly string[]; +}>; + +function findRuntimeLayoutShapeMismatch( + root: RuntimeInstance, + layoutRoot: LayoutTree, +): RuntimeLayoutShapeMismatch | null { + const queue: Array< + Readonly<{ + runtimeNode: RuntimeInstance; + layoutNode: LayoutTree; + path: string; + depth: number; + runtimeTrail: readonly string[]; + layoutTrail: readonly string[]; + }> + > = [ + Object.freeze({ + runtimeNode: root, + layoutNode: layoutRoot, + path: "root", + depth: 0, + runtimeTrail: Object.freeze([describeAuditVNode(root.vnode)]), + layoutTrail: Object.freeze([describeAuditVNode(layoutRoot.vnode)]), + }), + ]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; + const { runtimeNode, layoutNode, path, depth, runtimeTrail, layoutTrail } = current; + const runtimeKind = runtimeNode.vnode.kind; + const layoutKind = layoutNode.vnode.kind; + const runtimeChildCount = runtimeNode.children.length; + const layoutChildCount = layoutNode.children.length; + if (runtimeKind !== layoutKind) { + return Object.freeze({ + path, + depth, + reason: "kind", + runtimeKind, + layoutKind, + runtimeChildCount, + layoutChildCount, + runtimeTrail, + layoutTrail, + }); + } + if (runtimeChildCount !== layoutChildCount) { + return Object.freeze({ + path, + depth, + reason: "children", + runtimeKind, + layoutKind, + runtimeChildCount, + layoutChildCount, + runtimeTrail, + layoutTrail, + }); + } + + for (let i = 0; i < runtimeChildCount; i++) { + const runtimeChild = runtimeNode.children[i]; + const layoutChild = layoutNode.children[i]; + if (!runtimeChild || !layoutChild) { + return Object.freeze({ + path: `${path}/${runtimeChild ? runtimeChild.vnode.kind : "missing"}[${String(i)}]`, + depth: depth + 1, + reason: "children", + runtimeKind: runtimeChild?.vnode.kind ?? "", + layoutKind: layoutChild?.vnode.kind ?? "", + runtimeChildCount: runtimeChild?.children.length ?? -1, + layoutChildCount: layoutChild?.children.length ?? -1, + runtimeTrail, + layoutTrail, + }); + } + const nextRuntimeTrail = Object.freeze([ + ...runtimeTrail.slice(-11), + describeAuditVNode(runtimeChild.vnode), + ]); + const nextLayoutTrail = Object.freeze([ + ...layoutTrail.slice(-11), + describeAuditVNode(layoutChild.vnode), + ]); + queue.push( + Object.freeze({ + runtimeNode: runtimeChild, + layoutNode: layoutChild, + path: `${path}/${runtimeChild.vnode.kind}[${String(i)}]`, + depth: depth + 1, + runtimeTrail: nextRuntimeTrail, + layoutTrail: nextLayoutTrail, + }), + ); + } } + + return null; } -function monotonicNowMs(): number { - const perf = (globalThis as { performance?: { now?: () => number } }).performance; - const perfNow = perf?.now; - if (typeof perfNow === "function") return perfNow.call(perf); - return Date.now(); +function hasRuntimeLayoutShapeMismatch(root: RuntimeInstance, layoutRoot: LayoutTree): boolean { + return findRuntimeLayoutShapeMismatch(root, layoutRoot) !== null; } function cloneFocusManagerState(state: FocusManagerState): FocusManagerState { @@ -596,38 +851,6 @@ type ErrorBoundaryState = Readonly<{ stack?: string; }>; -type LogsScrollBlitPlan = Readonly<{ - instanceId: InstanceId; - srcX: number; - srcY: number; - w: number; - h: number; - dstX: number; - dstY: number; - damageRects: readonly Rect[]; -}>; - -type LogsScrollSnapshot = Readonly<{ - instanceId: InstanceId; - rect: Rect; - contentRect: Rect; - scrollTop: number; - filteredLength: number; - entriesRef: LogsConsoleProps["entries"]; - levelFilterRef: readonly string[]; - sourceFilterRef: readonly string[]; - searchQuery: string | null; - expandedEntriesRef: readonly string[]; - showTimestamps: boolean; - showSource: boolean; - focusConfigRef: LogsConsoleProps["focusConfig"]; - focusedStyleRef: LogsConsoleProps["focusedStyle"]; - scrollbarVariant: LogsConsoleProps["scrollbarVariant"]; - scrollbarStyleRef: LogsConsoleProps["scrollbarStyle"]; - focused: boolean; - showFocusRing: boolean; -}>; - /** * Renderer for widget view mode. * @@ -779,8 +1002,6 @@ export class WidgetRenderer { private readonly diffViewerExpandedHunksById = new Map>(); private readonly logsConsoleLastGTimeById = new Map(); - private readonly logsConsoleInstanceIdById = new Map(); - private readonly logsScrollSnapshotById = new Map(); // Tracks whether the currently committed tree needs routing rebuild traversals. private hadRoutingWidgets = false; @@ -1178,8 +1399,13 @@ export class WidgetRenderer { } } - private cleanupUnmountedInstanceIds(unmountedInstanceIds: readonly InstanceId[]): void { + private cleanupUnmountedInstanceIds( + unmountedInstanceIds: readonly InstanceId[], + opts: Readonly<{ skipIds?: ReadonlySet }> = {}, + ): void { + const skipIds = opts.skipIds; for (const unmountedId of unmountedInstanceIds) { + if (skipIds?.has(unmountedId)) continue; this.inputCursorByInstanceId.delete(unmountedId); this.inputSelectionByInstanceId.delete(unmountedId); this.inputWorkingValueByInstanceId.delete(unmountedId); @@ -1200,12 +1426,11 @@ export class WidgetRenderer { private scheduleExitAnimations( pendingExitAnimations: readonly PendingExitAnimation[], frameNowMs: number, - prevRuntimeRoot: RuntimeInstance | null, - prevLayoutRoot: LayoutTree | null, + prevLayoutSubtreeByInstanceId: ReadonlyMap | null, ): void { if (pendingExitAnimations.length === 0) return; - if (!prevRuntimeRoot || !prevLayoutRoot) { + if (!prevLayoutSubtreeByInstanceId) { for (const pending of pendingExitAnimations) { pending.runDeferredLocalStateCleanup(); this.cleanupUnmountedInstanceIds(pending.subtreeInstanceIds); @@ -1213,15 +1438,10 @@ export class WidgetRenderer { return; } - this.collectLayoutSubtreeByInstanceId( - prevRuntimeRoot, - prevLayoutRoot, - this._pooledPrevLayoutSubtreeByInstanceId, - ); const missingLayout = scheduleExitAnimationsImpl({ pendingExitAnimations, frameNowMs, - layoutSubtreeByInstanceId: this._pooledPrevLayoutSubtreeByInstanceId, + layoutSubtreeByInstanceId: prevLayoutSubtreeByInstanceId, prevFrameOpacityByInstanceId: this._prevFrameOpacityByInstanceId, exitTransitionTrackByInstanceId: this.exitTransitionTrackByInstanceId, exitRenderNodeByInstanceId: this.exitRenderNodeByInstanceId, @@ -2366,15 +2586,6 @@ export class WidgetRenderer { ); } - private clipDamageRectsWithoutMerge(viewport: Viewport): readonly Rect[] { - this._pooledMergedDamageRects.length = 0; - for (const rect of this._pooledDamageRects) { - const clipped = clipRectToViewport(rect, viewport); - if (clipped) this._pooledMergedDamageRects.push(clipped); - } - return this._pooledMergedDamageRects; - } - private isDamageAreaTooLarge(viewport: Viewport): boolean { return isDamageAreaTooLargeImpl(viewport, this._pooledMergedDamageRects); } @@ -2412,312 +2623,6 @@ export class WidgetRenderer { this._lastRenderedFocusAnnouncement = nextFrameState.lastRenderedFocusAnnouncement; } - private resolveLogsConsoleRenderGeometry( - rect: Rect, - props: LogsConsoleProps, - focusedId: string | null, - ): Readonly<{ focused: boolean; showFocusRing: boolean; contentRect: Rect }> { - const focused = focusedId === props.id; - const focusConfig = readFocusConfigForLogs(props.focusConfig); - const showFocusRing = focused && focusIndicatorEnabledForLogs(focusConfig); - if (!showFocusRing) { - return Object.freeze({ focused, showFocusRing, contentRect: rect }); - } - return Object.freeze({ - focused, - showFocusRing, - contentRect: { - x: rect.x + 1, - y: rect.y + 1, - w: Math.max(0, rect.w - 2), - h: Math.max(0, rect.h - 2), - }, - }); - } - - private hasUnsafeAncestorClipForBlit(instanceId: InstanceId, contentRect: Rect): boolean { - if (contentRect.w <= 0 || contentRect.h <= 0) return true; - if (!this.committedRoot) return true; - - const nodeByInstanceId = new Map(); - const parentByInstanceId = new Map(); - const stack: RuntimeInstance[] = [this.committedRoot]; - nodeByInstanceId.set(this.committedRoot.instanceId, this.committedRoot); - parentByInstanceId.set(this.committedRoot.instanceId, null); - - let foundTarget = this.committedRoot.instanceId === instanceId; - while (stack.length > 0 && !foundTarget) { - const node = stack.pop(); - if (!node) continue; - - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; - if (!child) continue; - nodeByInstanceId.set(child.instanceId, child); - parentByInstanceId.set(child.instanceId, node.instanceId); - if (child.instanceId === instanceId) { - foundTarget = true; - break; - } - stack.push(child); - } - } - if (!foundTarget) return true; - - let effectiveClip: Rect | null = null; - let currentParentId = parentByInstanceId.get(instanceId) ?? null; - while (currentParentId !== null) { - const ancestor = nodeByInstanceId.get(currentParentId); - if (!ancestor) return true; - - switch (ancestor.vnode.kind) { - case "row": - case "column": - case "grid": - case "box": { - const overflow = (ancestor.vnode.props as { overflow?: unknown } | undefined)?.overflow; - if (overflow === "hidden" || overflow === "scroll") { - return true; - } - break; - } - case "modal": - case "layer": - return true; - case "splitPane": - case "panelGroup": - case "resizablePanel": { - const ancestorRect = this._pooledRectByInstanceId.get(ancestor.instanceId); - if (!ancestorRect) return true; - effectiveClip = effectiveClip - ? rectsIntersection(effectiveClip, ancestorRect) - : ancestorRect; - if (!effectiveClip) return true; - break; - } - default: - break; - } - - currentParentId = parentByInstanceId.get(currentParentId) ?? null; - } - - if (!effectiveClip) return false; - return !rectContainsRect(effectiveClip, contentRect); - } - - private hasPaintedOverlapAfterInstance(instanceId: InstanceId, contentRect: Rect): boolean { - if (contentRect.w <= 0 || contentRect.h <= 0) return true; - if (!this.committedRoot) return true; - - const stack: Array> = [ - { node: this.committedRoot, depth: 0 }, - ]; - let foundTarget = false; - let targetDepth = -1; - let afterTargetSubtree = false; - - while (stack.length > 0) { - const current = stack.pop(); - if (!current) continue; - const node = current.node; - const depth = current.depth; - - if (!foundTarget) { - if (node.instanceId === instanceId) { - foundTarget = true; - targetDepth = depth; - } - } else { - if (!afterTargetSubtree && depth <= targetDepth) { - afterTargetSubtree = true; - } - if (afterTargetSubtree && nodeKindCanPaintOverlay(node.vnode.kind)) { - const candidateRect = this._pooledRectByInstanceId.get(node.instanceId); - if (candidateRect && rectsIntersect(candidateRect, contentRect)) { - return true; - } - } - } - - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; - if (!child) continue; - stack.push({ node: child, depth: depth + 1 }); - } - } - - return !foundTarget; - } - - private readLogsFilteredLength(props: LogsConsoleProps): number { - const cached = this.logsConsoleRenderCacheById.get(props.id); - if (cached) return cached.filtered.length; - return applyFilters(props.entries, props.levelFilter, props.sourceFilter, props.searchQuery) - .length; - } - - private buildLogsScrollBlitPlans( - prevFocusedId: string | null, - nextFocusedId: string | null, - ): ReadonlyMap { - const plans = new Map(); - if (prevFocusedId !== nextFocusedId) return plans; - - for (const [id, props] of this.logsConsoleById) { - const prev = this.logsScrollSnapshotById.get(id); - if (!prev) continue; - - const instanceId = this.logsConsoleInstanceIdById.get(id); - if (instanceId === undefined || instanceId !== prev.instanceId) continue; - - const rect = this.rectById.get(id); - if (!rect || rect.w <= 0 || rect.h <= 0) continue; - - const geometry = this.resolveLogsConsoleRenderGeometry(rect, props, nextFocusedId); - const contentRect = geometry.contentRect; - if (contentRect.w <= 0 || contentRect.h <= 0) continue; - - const levelFilterRef = (props.levelFilter ?? EMPTY_STRING_ARRAY) as readonly string[]; - const sourceFilterRef = (props.sourceFilter ?? EMPTY_STRING_ARRAY) as readonly string[]; - const searchQuery = props.searchQuery ? props.searchQuery : null; - const expandedEntriesRef = (props.expandedEntries ?? EMPTY_STRING_ARRAY) as readonly string[]; - const showTimestamps = props.showTimestamps !== false; - const showSource = props.showSource !== false; - const filteredLength = this.readLogsFilteredLength(props); - - if ( - prev.entriesRef !== props.entries || - prev.levelFilterRef !== levelFilterRef || - prev.sourceFilterRef !== sourceFilterRef || - prev.searchQuery !== searchQuery || - prev.expandedEntriesRef !== expandedEntriesRef || - prev.showTimestamps !== showTimestamps || - prev.showSource !== showSource || - prev.focusConfigRef !== props.focusConfig || - prev.focusedStyleRef !== props.focusedStyle || - prev.scrollbarVariant !== props.scrollbarVariant || - prev.scrollbarStyleRef !== props.scrollbarStyle || - prev.focused !== geometry.focused || - prev.showFocusRing !== geometry.showFocusRing || - prev.filteredLength !== filteredLength || - !rectEquals(prev.rect, rect) || - !rectEquals(prev.contentRect, contentRect) - ) { - continue; - } - - const prevScrollTop = clampIndexScrollTopForRows( - prev.scrollTop, - filteredLength, - contentRect.h, - ); - const nextScrollTop = clampIndexScrollTopForRows( - props.scrollTop, - filteredLength, - contentRect.h, - ); - const delta = nextScrollTop - prevScrollTop; - if (delta === 0) continue; - - const deltaAbs = Math.abs(delta); - if (deltaAbs >= contentRect.h) continue; - - const hasScrollbar = filteredLength > contentRect.h; - const scrollbarWidth = hasScrollbar ? 1 : 0; - const blitW = contentRect.w - scrollbarWidth; - const blitH = contentRect.h - deltaAbs; - if (blitW <= 0 || blitH <= 0) continue; - if (this.hasUnsafeAncestorClipForBlit(instanceId, contentRect)) continue; - if (this.hasPaintedOverlapAfterInstance(instanceId, contentRect)) continue; - - const srcX = contentRect.x; - const dstX = contentRect.x; - const srcY = delta > 0 ? contentRect.y + delta : contentRect.y; - const dstY = delta > 0 ? contentRect.y : contentRect.y + deltaAbs; - const stripY = delta > 0 ? contentRect.y + blitH : contentRect.y; - const damageRects: Rect[] = [ - { - x: contentRect.x, - y: stripY, - w: blitW, - h: deltaAbs, - }, - ]; - if (hasScrollbar) { - damageRects.push({ - x: contentRect.x + contentRect.w - 1, - y: contentRect.y, - w: 1, - h: contentRect.h, - }); - } - - plans.set( - instanceId, - Object.freeze({ - instanceId, - srcX, - srcY, - w: blitW, - h: blitH, - dstX, - dstY, - damageRects: Object.freeze(damageRects), - }), - ); - } - - return plans; - } - - private snapshotLogsScrollState(focusedId: string | null): void { - const nextById = new Map(); - - for (const [id, props] of this.logsConsoleById) { - const rect = this.rectById.get(id); - const instanceId = this.logsConsoleInstanceIdById.get(id); - if (!rect || rect.w <= 0 || rect.h <= 0 || instanceId === undefined) continue; - - const geometry = this.resolveLogsConsoleRenderGeometry(rect, props, focusedId); - const contentRect = geometry.contentRect; - if (contentRect.w <= 0 || contentRect.h <= 0) continue; - - const levelFilterRef = (props.levelFilter ?? EMPTY_STRING_ARRAY) as readonly string[]; - const sourceFilterRef = (props.sourceFilter ?? EMPTY_STRING_ARRAY) as readonly string[]; - const expandedEntriesRef = (props.expandedEntries ?? EMPTY_STRING_ARRAY) as readonly string[]; - - nextById.set( - id, - Object.freeze({ - instanceId, - rect, - contentRect, - scrollTop: props.scrollTop, - filteredLength: this.readLogsFilteredLength(props), - entriesRef: props.entries, - levelFilterRef, - sourceFilterRef, - searchQuery: props.searchQuery ? props.searchQuery : null, - expandedEntriesRef, - showTimestamps: props.showTimestamps !== false, - showSource: props.showSource !== false, - focusConfigRef: props.focusConfig, - focusedStyleRef: props.focusedStyle, - scrollbarVariant: props.scrollbarVariant, - scrollbarStyleRef: props.scrollbarStyle, - focused: geometry.focused, - showFocusRing: geometry.showFocusRing, - }), - ); - } - - this.logsScrollSnapshotById.clear(); - for (const [id, snapshot] of nextById) { - this.logsScrollSnapshotById.set(id, snapshot); - } - } - /** * Execute view function, commit tree, compute layout, and render to drawlist. * @@ -2777,11 +2682,19 @@ export class WidgetRenderer { const prevZoneMetaByIdBeforeSubmit = this.zoneMetaById; const prevCommittedRoot = this.committedRoot; const prevLayoutTree = this.layoutTree; + let prevLayoutSubtreeByInstanceId: ReadonlyMap | null = null; + if (doCommit && prevCommittedRoot && prevLayoutTree) { + this.collectLayoutSubtreeByInstanceId( + prevCommittedRoot, + prevLayoutTree, + this._pooledPrevLayoutSubtreeByInstanceId, + ); + prevLayoutSubtreeByInstanceId = this._pooledPrevLayoutSubtreeByInstanceId; + } const hadRoutingWidgets = this.hadRoutingWidgets; let hasRoutingWidgets = hadRoutingWidgets; let didRoutingRebuild = false; let identityDamageFromCommit: IdentityDiffDamageResult | null = null; - let layoutDirtyVNodeSet: Set | null = null; if (doCommit) { let commitReadViewport = false; @@ -2858,12 +2771,31 @@ export class WidgetRenderer { doLayout = true; } } - this.cleanupUnmountedInstanceIds(commitRes.unmountedInstanceIds); + if (!doLayout && this.layoutTree !== null) { + // Defensive guard: never render a newly committed runtime tree against + // a stale layout tree with different shape/kinds. + if (findRuntimeLayoutShapeMismatch(this.committedRoot, this.layoutTree) !== null) { + doLayout = true; + } + } + let deferredExitCleanupIds: Set | null = null; + if (commitRes.pendingExitAnimations.length > 0) { + deferredExitCleanupIds = new Set(); + for (const pending of commitRes.pendingExitAnimations) { + for (const id of pending.subtreeInstanceIds) { + deferredExitCleanupIds.add(id); + } + } + } + + this.cleanupUnmountedInstanceIds( + commitRes.unmountedInstanceIds, + deferredExitCleanupIds ? { skipIds: deferredExitCleanupIds } : undefined, + ); this.scheduleExitAnimations( commitRes.pendingExitAnimations, frameNowMs, - prevCommittedRoot, - prevLayoutTree, + prevLayoutSubtreeByInstanceId, ); this.cancelExitTransitionsForReappearedKeys(this.committedRoot); @@ -2893,21 +2825,14 @@ export class WidgetRenderer { this._lastRenderedViewport.rows !== viewport.rows || this._lastRenderedThemeRef !== theme; - if (doLayout && doCommit && commitRes !== null && !forceFullRelayout) { - this.collectSelfDirtyInstanceIds(this.committedRoot, this._pooledDirtyLayoutInstanceIds); - const dirtyInstanceIds = computeDirtyLayoutSet( - this.committedRoot, - commitRes.mountedInstanceIds, - this._pooledDirtyLayoutInstanceIds, - ); - layoutDirtyVNodeSet = instanceDirtySetToVNodeDirtySet(this.committedRoot, dirtyInstanceIds); - } - if (doLayout) { const rootPad = this.rootPadding; const rootW = Math.max(0, viewport.cols - rootPad * 2); const rootH = Math.max(0, viewport.rows - rootPad * 2); const layoutToken = perfMarkStart("layout"); + // Force a cold layout pass whenever layout is requested; partial cache reuse can + // produce runtime/layout shape divergence under complex composite updates. + this._layoutTreeCache = new WeakMap(); const layoutRootVNode = this.scrollOverrides.size > 0 ? this.applyScrollOverridesToVNode(this.committedRoot.vnode) @@ -2922,13 +2847,89 @@ export class WidgetRenderer { "column", this._layoutMeasureCache, this._layoutTreeCache, - layoutDirtyVNodeSet, + null, ); perfMarkEnd("layout", layoutToken); if (!layoutRes.ok) { return { ok: false, code: layoutRes.fatal.code, detail: layoutRes.fatal.detail }; } - const nextLayoutTree = layoutRes.value; + let nextLayoutTree = layoutRes.value; + let shapeMismatch = doCommit + ? findRuntimeLayoutShapeMismatch(this.committedRoot, nextLayoutTree) + : null; + if (doCommit && shapeMismatch !== null) { + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit("widgetRenderer", "layout.shape_mismatch", { + reason: "post-layout-cache-hit", + path: shapeMismatch.path, + depth: shapeMismatch.depth, + mismatchKind: shapeMismatch.reason, + runtimeKind: shapeMismatch.runtimeKind, + layoutKind: shapeMismatch.layoutKind, + runtimeChildCount: shapeMismatch.runtimeChildCount, + layoutChildCount: shapeMismatch.layoutChildCount, + runtimeTrail: shapeMismatch.runtimeTrail, + layoutTrail: shapeMismatch.layoutTrail, + }); + } + // Cache can become stale under structural changes; force a cold relayout. + this._layoutTreeCache = new WeakMap(); + const fallbackLayoutRes = layout( + layoutRootVNode, + rootPad, + rootPad, + rootW, + rootH, + "column", + this._layoutMeasureCache, + this._layoutTreeCache, + null, + ); + if (!fallbackLayoutRes.ok) { + return { + ok: false, + code: fallbackLayoutRes.fatal.code, + detail: fallbackLayoutRes.fatal.detail, + }; + } + nextLayoutTree = fallbackLayoutRes.value; + shapeMismatch = findRuntimeLayoutShapeMismatch(this.committedRoot, nextLayoutTree); + if (shapeMismatch !== null && layoutRootVNode !== this.committedRoot.vnode) { + const directLayoutRes = layout( + this.committedRoot.vnode, + rootPad, + rootPad, + rootW, + rootH, + "column", + this._layoutMeasureCache, + this._layoutTreeCache, + null, + ); + if (!directLayoutRes.ok) { + return { + ok: false, + code: directLayoutRes.fatal.code, + detail: directLayoutRes.fatal.detail, + }; + } + nextLayoutTree = directLayoutRes.value; + shapeMismatch = findRuntimeLayoutShapeMismatch(this.committedRoot, nextLayoutTree); + } + if (shapeMismatch !== null && FRAME_AUDIT_ENABLED) { + emitFrameAudit("widgetRenderer", "layout.shape_mismatch.persisted", { + path: shapeMismatch.path, + depth: shapeMismatch.depth, + mismatchKind: shapeMismatch.reason, + runtimeKind: shapeMismatch.runtimeKind, + layoutKind: shapeMismatch.layoutKind, + runtimeChildCount: shapeMismatch.runtimeChildCount, + layoutChildCount: shapeMismatch.layoutChildCount, + runtimeTrail: shapeMismatch.runtimeTrail, + layoutTrail: shapeMismatch.layoutTrail, + }); + } + } this.layoutTree = nextLayoutTree; this.emitDevLayoutWarnings(nextLayoutTree, viewport); @@ -3050,7 +3051,6 @@ export class WidgetRenderer { this.diffViewerById.clear(); this.toolApprovalDialogById.clear(); this.logsConsoleById.clear(); - this.logsConsoleInstanceIdById.clear(); // Rebuild overlay routing state using pooled collections. this.layerRegistry.clear(); @@ -3161,7 +3161,6 @@ export class WidgetRenderer { case "logsConsole": { const p = v.props as LogsConsoleProps; this.logsConsoleById.set(p.id, p); - this.logsConsoleInstanceIdById.set(p.id, cur.instanceId); break; } case "toastContainer": { @@ -3795,35 +3794,23 @@ export class WidgetRenderer { let runtimeDamageMode: RuntimeBreadcrumbDamageMode = "none"; let runtimeDamageRectCount = 0; let runtimeDamageArea = 0; - const prevFocusedId = this._lastRenderedFocusedId; - const nextFocusedId = this.focusState.focusedId; if (this.shouldAttemptIncrementalRender(doLayout, viewport, theme)) { if (!doCommit) { - this.markTransientDirtyNodes(this.committedRoot, prevFocusedId, nextFocusedId, true); + this.markTransientDirtyNodes( + this.committedRoot, + this._lastRenderedFocusedId, + this.focusState.focusedId, + true, + ); } this._pooledDamageRects.length = 0; let missingDamageRect = false; - const logsScrollBlitPlansByInstanceId = doCommit - ? this.buildLogsScrollBlitPlans(prevFocusedId, nextFocusedId) - : new Map(); - const activeLogsScrollBlitPlans: LogsScrollBlitPlan[] = doCommit - ? Array.from(logsScrollBlitPlansByInstanceId.values()) - : []; - for (const logsBlitPlan of activeLogsScrollBlitPlans) { - for (const rect of logsBlitPlan.damageRects) { - this._pooledDamageRects.push(rect); - } - } if (doCommit) { if (!identityDamageFromCommit) { missingDamageRect = true; } else { for (const instanceId of identityDamageFromCommit.changedInstanceIds) { - const logsBlitPlan = logsScrollBlitPlansByInstanceId.get(instanceId); - if (logsBlitPlan) { - continue; - } if (!this.appendDamageRectForInstanceId(instanceId)) { missingDamageRect = true; break; @@ -3842,6 +3829,8 @@ export class WidgetRenderer { this.collectSpinnerDamageRects(this.committedRoot, this.layoutTree); } + const prevFocusedId = this._lastRenderedFocusedId; + const nextFocusedId = this.focusState.focusedId; if (!missingDamageRect && prevFocusedId !== nextFocusedId) { if (prevFocusedId !== null && !this.appendDamageRectForId(prevFocusedId)) { missingDamageRect = true; @@ -3863,10 +3852,7 @@ export class WidgetRenderer { } if (!missingDamageRect) { - const preserveDamageRectSeparation = activeLogsScrollBlitPlans.length > 0; - const damageRects = preserveDamageRectSeparation - ? this.clipDamageRectsWithoutMerge(viewport) - : this.normalizeDamageRects(viewport); + const damageRects = this.normalizeDamageRects(viewport); if (damageRects.length > 0 && !this.isDamageAreaTooLarge(viewport)) { if (captureRuntimeBreadcrumbs) { runtimeDamageMode = "incremental"; @@ -3876,16 +3862,6 @@ export class WidgetRenderer { runtimeDamageArea += damageRect.w * damageRect.h; } } - for (const logsBlitPlan of activeLogsScrollBlitPlans) { - this.builder.blitRect( - logsBlitPlan.srcX, - logsBlitPlan.srcY, - logsBlitPlan.w, - logsBlitPlan.h, - logsBlitPlan.dstX, - logsBlitPlan.dstY, - ); - } for (const damageRect of damageRects) { this.builder.fillRect( damageRect.x, @@ -3981,16 +3957,43 @@ export class WidgetRenderer { } perfMarkEnd("render", renderToken); + let submittedBytes: Uint8Array = new Uint8Array(0); + let inFlight: Promise | null = null; const buildToken = perfMarkStart("drawlist_build"); - const built = this.builder.build(); - perfMarkEnd("drawlist_build", buildToken); - if (!built.ok) { - return { - ok: false, - code: "ZRUI_DRAWLIST_BUILD_ERROR", - detail: `${built.error.code}: ${built.error.detail}`, - }; + const beginFrame = ( + this.backend as RuntimeBackend & + Partial> + )[BACKEND_BEGIN_FRAME_MARKER]; + if (typeof beginFrame === "function") { + const frameWriter = beginFrame(); + if (frameWriter) { + const builtInto = this.builder.buildInto(frameWriter.buf); + if (!builtInto.ok) { + frameWriter.abort(); + perfMarkEnd("drawlist_build", buildToken); + return { + ok: false, + code: "ZRUI_DRAWLIST_BUILD_ERROR", + detail: `${builtInto.error.code}: ${builtInto.error.detail}`, + }; + } + submittedBytes = builtInto.bytes; + inFlight = frameWriter.commit(submittedBytes.byteLength); + } } + if (!inFlight) { + const built = this.builder.build(); + if (!built.ok) { + perfMarkEnd("drawlist_build", buildToken); + return { + ok: false, + code: "ZRUI_DRAWLIST_BUILD_ERROR", + detail: `${built.error.code}: ${built.error.detail}`, + }; + } + submittedBytes = built.bytes; + } + perfMarkEnd("drawlist_build", buildToken); this.clearRuntimeDirtyNodes(this.committedRoot); if (captureRuntimeBreadcrumbs) { this.updateRuntimeBreadcrumbSnapshot({ @@ -4011,7 +4014,6 @@ export class WidgetRenderer { doLayout, focusAnnouncement, ); - this.snapshotLogsScrollState(this.focusState.focusedId); // Render hooks are for preventing re-entrant app API calls during user render. hooks.exitRender(); @@ -4033,8 +4035,74 @@ export class WidgetRenderer { try { const backendToken = PERF_ENABLED ? perfMarkStart("backend_request") : 0; try { - const inFlight = this.backend.requestFrame(built.bytes); - return { ok: true, inFlight }; + const fingerprint = FRAME_AUDIT_ENABLED ? drawlistFingerprint(submittedBytes) : null; + if (fingerprint !== null) { + emitFrameAudit("widgetRenderer", "drawlist.built", { + tick, + commit: doCommit, + layout: doLayout, + incremental: usedIncrementalRender, + damageMode: runtimeDamageMode, + damageRectCount: runtimeDamageRectCount, + damageArea: runtimeDamageArea, + ...fingerprint, + }); + if (FRAME_AUDIT_TREE_ENABLED) { + emitFrameAudit( + "widgetRenderer", + "runtime.tree.summary", + Object.freeze({ + tick, + ...summarizeRuntimeTreeForAudit(this.committedRoot, this.layoutTree), + }), + ); + } + } + if (!inFlight) { + inFlight = this.backend.requestFrame(submittedBytes); + } + const inflightPromise = inFlight; + if (fingerprint !== null) { + emitFrameAudit("widgetRenderer", "backend.request", { + tick, + hash32: fingerprint.hash32, + prefixHash32: fingerprint.prefixHash32, + byteLen: fingerprint.byteLen, + }); + const acceptedAck = ( + inflightPromise as Promise & + Partial>> + )[FRAME_ACCEPTED_ACK_MARKER]; + if (acceptedAck !== undefined) { + void acceptedAck.then( + () => + emitFrameAudit("widgetRenderer", "backend.accepted", { + tick, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("widgetRenderer", "backend.accepted_error", { + tick, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } + void inflightPromise.then( + () => + emitFrameAudit("widgetRenderer", "backend.completed", { + tick, + hash32: fingerprint.hash32, + }), + (err: unknown) => + emitFrameAudit("widgetRenderer", "backend.completed_error", { + tick, + hash32: fingerprint.hash32, + detail: describeThrown(err), + }), + ); + } + return { ok: true, inFlight: inflightPromise }; } finally { if (PERF_ENABLED) perfMarkEnd("backend_request", backendToken); } diff --git a/packages/core/src/app/widgetRenderer/damageTracking.ts b/packages/core/src/app/widgetRenderer/damageTracking.ts index 6ca906d5..5ae6f7eb 100644 --- a/packages/core/src/app/widgetRenderer/damageTracking.ts +++ b/packages/core/src/app/widgetRenderer/damageTracking.ts @@ -234,7 +234,6 @@ export function propagateDirtyFromPredicate( isNodeDirty: (node: RuntimeInstance) => boolean, pooledRuntimeStack: RuntimeInstance[], pooledPrevRuntimeStack: RuntimeInstance[], - markSelfDirty = true, ): void { pooledRuntimeStack.length = 0; pooledPrevRuntimeStack.length = 0; @@ -253,9 +252,9 @@ export function propagateDirtyFromPredicate( for (let i = pooledPrevRuntimeStack.length - 1; i >= 0; i--) { const node = pooledPrevRuntimeStack[i]; if (!node) continue; - const predicateDirty = isNodeDirty(node); - if (markSelfDirty && predicateDirty) node.selfDirty = true; - let dirty = node.dirty || predicateDirty; + const markedSelfDirty = isNodeDirty(node); + if (markedSelfDirty) node.selfDirty = true; + let dirty = node.dirty || markedSelfDirty; for (const child of node.children) { if (child.dirty) { dirty = true; @@ -279,7 +278,6 @@ export function markLayoutDirtyNodes(params: MarkLayoutDirtyNodesParams): void { }, params.pooledRuntimeStack, params.pooledPrevRuntimeStack, - false, ); } @@ -397,7 +395,40 @@ export function computeIdentityDiffDamage( const prevNode = params.pooledPrevRuntimeStack.pop(); const nextNode = params.pooledRuntimeStack.pop(); if (!prevNode || !nextNode) continue; - if (prevNode === nextNode) continue; + + if (prevNode === nextNode) { + if (!nextNode.dirty) continue; + + const nextKind = nextNode.vnode.kind; + if (nextNode.selfDirty) { + routingRelevantChanged = + collectSubtreeDamageAndRouting( + nextNode, + params.pooledChangedRenderInstanceIds, + params.pooledDamageRuntimeStack, + ) || routingRelevantChanged; + continue; + } + + if (isRoutingRelevantKind(nextKind)) routingRelevantChanged = true; + if (nextNode.children.length === 0) { + params.pooledChangedRenderInstanceIds.push(nextNode.instanceId); + continue; + } + + let pushedDirtyChild = false; + for (let i = nextNode.children.length - 1; i >= 0; i--) { + const child = nextNode.children[i]; + if (!child || !child.dirty) continue; + pushedDirtyChild = true; + params.pooledPrevRuntimeStack.push(child); + params.pooledRuntimeStack.push(child); + } + if (!pushedDirtyChild) { + params.pooledChangedRenderInstanceIds.push(nextNode.instanceId); + } + continue; + } const prevKind = prevNode.vnode.kind; const nextKind = nextNode.vnode.kind; @@ -433,7 +464,14 @@ export function computeIdentityDiffDamage( for (let i = sharedCount - 1; i >= 0; i--) { const prevChild = prevChildren[i]; const nextChild = nextChildren[i]; - if (!prevChild || !nextChild || prevChild === nextChild) continue; + if (!prevChild || !nextChild) continue; + if (prevChild === nextChild) { + if (!nextChild.dirty) continue; + hadChildChanges = true; + params.pooledPrevRuntimeStack.push(prevChild); + params.pooledRuntimeStack.push(nextChild); + continue; + } hadChildChanges = true; params.pooledPrevRuntimeStack.push(prevChild); params.pooledRuntimeStack.push(nextChild); diff --git a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts index fbc1d7ae..4cab49a5 100644 --- a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts @@ -1,4 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DEF_BLOB, + OP_DEF_STRING, + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + parseCommandHeaders, +} from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../builder.js"; import type { DrawlistBuildResult } from "../types.js"; @@ -8,41 +15,30 @@ const HEADER = { CMD_BYTES: 20, CMD_COUNT: 24, STRINGS_SPAN_OFFSET: 28, - STRINGS_COUNT: 32, STRINGS_BYTES_OFFSET: 36, - STRINGS_BYTES_LEN: 40, BLOBS_SPAN_OFFSET: 44, - BLOBS_COUNT: 48, BLOBS_BYTES_OFFSET: 52, - BLOBS_BYTES_LEN: 56, SIZE: 64, } as const; -const CMD = { - SIZE: 4, - HEADER_SIZE: 8, -} as const; - -const SPAN_SIZE = 8; - type ParsedHeader = Readonly<{ totalSize: number; cmdOffset: number; cmdBytes: number; cmdCount: number; stringsSpanOffset: number; - stringsCount: number; stringsBytesOffset: number; - stringsBytesLen: number; blobsSpanOffset: number; - blobsCount: number; blobsBytesOffset: number; - blobsBytesLen: number; }>; -function align4(n: number): number { - return (n + 3) & ~3; -} +type DefString = Readonly<{ + offset: number; + size: number; + id: number; + byteLen: number; + payloadStart: number; +}>; function toView(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -56,13 +52,9 @@ function parseHeader(bytes: Uint8Array): ParsedHeader { cmdBytes: dv.getUint32(HEADER.CMD_BYTES, true), cmdCount: dv.getUint32(HEADER.CMD_COUNT, true), stringsSpanOffset: dv.getUint32(HEADER.STRINGS_SPAN_OFFSET, true), - stringsCount: dv.getUint32(HEADER.STRINGS_COUNT, true), stringsBytesOffset: dv.getUint32(HEADER.STRINGS_BYTES_OFFSET, true), - stringsBytesLen: dv.getUint32(HEADER.STRINGS_BYTES_LEN, true), blobsSpanOffset: dv.getUint32(HEADER.BLOBS_SPAN_OFFSET, true), - blobsCount: dv.getUint32(HEADER.BLOBS_COUNT, true), blobsBytesOffset: dv.getUint32(HEADER.BLOBS_BYTES_OFFSET, true), - blobsBytesLen: dv.getUint32(HEADER.BLOBS_BYTES_LEN, true), }; } @@ -72,32 +64,20 @@ function expectOk(result: DrawlistBuildResult): Uint8Array { return result.bytes; } -function readStringSpan( - dv: DataView, - stringsSpanOffset: number, - index: number, -): { off: number; len: number } { - const spanOff = stringsSpanOffset + index * SPAN_SIZE; - return { - off: dv.getUint32(spanOff, true), - len: dv.getUint32(spanOff + 4, true), - }; -} - -function commandStarts(bytes: Uint8Array, h: ParsedHeader): readonly number[] { - const dv = toView(bytes); - if (h.cmdCount === 0) return []; - - let cursor = h.cmdOffset; - const starts: number[] = []; - for (let i = 0; i < h.cmdCount; i++) { - starts.push(cursor); - const size = dv.getUint32(cursor + CMD.SIZE, true); - assert.equal(size >= CMD.HEADER_SIZE, true, `command ${i} has invalid size`); - cursor += align4(size); - } - assert.equal(cursor, h.cmdOffset + h.cmdBytes); - return starts; +function readDefStrings(bytes: Uint8Array): readonly DefString[] { + const headers = parseCommandHeaders(bytes); + return headers + .filter((cmd) => cmd.opcode === OP_DEF_STRING) + .map((cmd) => { + const dv = toView(bytes); + return { + offset: cmd.offset, + size: cmd.size, + id: dv.getUint32(cmd.offset + 8, true), + byteLen: dv.getUint32(cmd.offset + 12, true), + payloadStart: cmd.offset + 16, + }; + }); } describe("DrawlistBuilder - alignment and padding", () => { @@ -116,7 +96,7 @@ describe("DrawlistBuilder - alignment and padding", () => { assert.equal(h.blobsBytesOffset, 0); }); - test("near-empty clear drawlist keeps command start and section layout aligned", () => { + test("near-empty clear drawlist keeps command section aligned", () => { const b = createDrawlistBuilder(); b.clear(); const bytes = expectOk(b.build()); @@ -126,8 +106,6 @@ describe("DrawlistBuilder - alignment and padding", () => { assert.equal((h.cmdOffset & 3) === 0, true); assert.equal((h.cmdBytes & 3) === 0, true); assert.equal(h.cmdCount, 1); - assert.equal(h.stringsCount, 0); - assert.equal(h.blobsCount, 0); }); test("all command starts are 4-byte aligned in a mixed stream", () => { @@ -137,158 +115,135 @@ describe("DrawlistBuilder - alignment and padding", () => { b.drawText(1, 1, "abc"); b.pushClip(0, 0, 3, 2); b.popClip(); - const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const starts = commandStarts(bytes, h); - assert.equal(starts.length, h.cmdCount); - for (const start of starts) { - assert.equal((start & 3) === 0, true); + const bytes = expectOk(b.build()); + const headers = parseCommandHeaders(bytes); + for (const cmd of headers) { + assert.equal((cmd.offset & 3) === 0, true); + assert.equal((cmd.size & 3) === 0, true); } }); - test("walking command sizes lands exactly on cmdOffset + cmdBytes", () => { + test("mixed text/blob frame emits DEF_STRING and DEF_BLOB before draw commands", () => { const b = createDrawlistBuilder(); - const blobIndex = b.addBlob(new Uint8Array([1, 2, 3, 4])); + const blobIndex = b.addBlob(new Uint8Array([9, 8, 7, 6])); assert.equal(blobIndex, 0); b.clear(); b.drawText(0, 0, "x"); b.drawTextRun(2, 1, 0); - const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const starts = commandStarts(bytes, h); - assert.equal(starts.length, 3); - assert.equal(starts[0], HEADER.SIZE); - }); - - test("section offsets are aligned and ordered when strings and blobs exist", () => { - const b = createDrawlistBuilder(); - b.drawText(0, 0, "abc"); - const blobIndex = b.addBlob(new Uint8Array([9, 8, 7, 6])); - assert.equal(blobIndex, 0); - b.drawTextRun(1, 0, 0); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); + const headers = parseCommandHeaders(bytes); + const opcodes = headers.map((cmd) => cmd.opcode); - assert.equal((h.cmdOffset & 3) === 0, true); - assert.equal((h.stringsSpanOffset & 3) === 0, true); - assert.equal((h.stringsBytesOffset & 3) === 0, true); - assert.equal((h.blobsSpanOffset & 3) === 0, true); - assert.equal((h.blobsBytesOffset & 3) === 0, true); - - assert.equal(h.stringsSpanOffset, HEADER.SIZE + h.cmdBytes); - assert.equal(h.stringsBytesOffset, h.stringsSpanOffset + h.stringsCount * SPAN_SIZE); - assert.equal(h.blobsSpanOffset, h.stringsBytesOffset + h.stringsBytesLen); - assert.equal(h.blobsBytesOffset, h.blobsSpanOffset + h.blobsCount * SPAN_SIZE); + assert.equal(opcodes.indexOf(OP_DEF_STRING) >= 0, true); + assert.equal(opcodes.indexOf(OP_DEF_BLOB) >= 0, true); + assert.equal(opcodes.indexOf(OP_DEF_STRING) < opcodes.lastIndexOf(OP_DRAW_TEXT), true); + assert.equal(opcodes.indexOf(OP_DEF_BLOB) < opcodes.lastIndexOf(OP_DRAW_TEXT_RUN), true); }); test("odd-length text: 1-byte string gets 3 zero padding bytes", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "a"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(span.off, 0); - assert.equal(span.len, 1); - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0); - assert.equal(bytes[h.stringsBytesOffset + 2], 0); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.id, 1); + assert.equal(def.byteLen, 1); + assert.equal(def.size, 20); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0); + assert.equal(bytes[def.payloadStart + 2], 0); + assert.equal(bytes[def.payloadStart + 3], 0); }); test("odd-length text: 2-byte string gets 2 zero padding bytes", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "ab"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(span.off, 0); - assert.equal(span.len, 2); - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0x62); - assert.equal(bytes[h.stringsBytesOffset + 2], 0); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 2); + assert.equal(def.size, 20); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0x62); + assert.equal(bytes[def.payloadStart + 2], 0); + assert.equal(bytes[def.payloadStart + 3], 0); }); test("odd-length text: 3-byte string gets 1 zero padding byte", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(span.off, 0); - assert.equal(span.len, 3); - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0x62); - assert.equal(bytes[h.stringsBytesOffset + 2], 0x63); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 3); + assert.equal(def.size, 20); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0x62); + assert.equal(bytes[def.payloadStart + 2], 0x63); + assert.equal(bytes[def.payloadStart + 3], 0); }); - test("empty string still has aligned string section with zero raw bytes", () => { + test("empty string emits DEF_STRING with zero payload bytes", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, ""); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const span = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(h.stringsCount, 1); - assert.equal((h.stringsSpanOffset & 3) === 0, true); - assert.equal((h.stringsBytesOffset & 3) === 0, true); - assert.equal(h.stringsBytesOffset, h.stringsSpanOffset + SPAN_SIZE); - assert.equal(span.off, 0); - assert.equal(span.len, 0); - assert.equal(h.stringsBytesLen, 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 0); + assert.equal(def.size, 16); }); - test("multiple odd-length strings keep contiguous raw spans and aligned tail padding", () => { + test("multiple odd-length strings keep per-command payload and tail padding correct", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "a"); b.drawText(0, 1, "bb"); b.drawText(0, 2, "ccc"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - const dv = toView(bytes); - const arena = readStringSpan(dv, h.stringsSpanOffset, 0); - - assert.equal(h.stringsCount, 1); - assert.equal(arena.off, 0); - assert.equal(arena.len, 6); - - assert.equal(h.stringsBytesLen, 8); - assert.equal( - String.fromCharCode(...bytes.subarray(h.stringsBytesOffset, h.stringsBytesOffset + 6)), - "abbccc", - ); - assert.equal(bytes[h.stringsBytesOffset + 6], 0); - assert.equal(bytes[h.stringsBytesOffset + 7], 0); + const defs = readDefStrings(bytes); + + assert.equal(defs.length, 3); + const d0 = defs[0]; + const d1 = defs[1]; + const d2 = defs[2]; + if (!d0 || !d1 || !d2) return; + + assert.equal(d0.byteLen, 1); + assert.equal(d0.size, 20); + assert.equal(bytes[d0.payloadStart + 1], 0); + assert.equal(bytes[d0.payloadStart + 2], 0); + assert.equal(bytes[d0.payloadStart + 3], 0); + + assert.equal(d1.byteLen, 2); + assert.equal(d1.size, 20); + assert.equal(bytes[d1.payloadStart + 2], 0); + assert.equal(bytes[d1.payloadStart + 3], 0); + + assert.equal(d2.byteLen, 3); + assert.equal(d2.size, 20); + assert.equal(bytes[d2.payloadStart + 3], 0); }); test("reuseOutputBuffer keeps odd-string padding zeroed across reset/build cycles", () => { const b = createDrawlistBuilder({ reuseOutputBuffer: true }); - b.drawText(0, 0, "abcd"); expectOk(b.build()); b.reset(); b.drawText(0, 0, "a"); const bytes = expectOk(b.build()); - const h = parseHeader(bytes); - - assert.equal(h.stringsBytesLen, 4); - assert.equal(bytes[h.stringsBytesOffset], 0x61); - assert.equal(bytes[h.stringsBytesOffset + 1], 0); - assert.equal(bytes[h.stringsBytesOffset + 2], 0); - assert.equal(bytes[h.stringsBytesOffset + 3], 0); + const def = readDefStrings(bytes)[0]; + if (!def) throw new Error("missing DEF_STRING"); + + assert.equal(def.byteLen, 1); + assert.equal(bytes[def.payloadStart], 0x61); + assert.equal(bytes[def.payloadStart + 1], 0); + assert.equal(bytes[def.payloadStart + 2], 0); + assert.equal(bytes[def.payloadStart + 3], 0); }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.golden.test.ts b/packages/core/src/drawlist/__tests__/builder.golden.test.ts index 22f2ea69..73125ba9 100644 --- a/packages/core/src/drawlist/__tests__/builder.golden.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.golden.test.ts @@ -115,14 +115,14 @@ describe("DrawlistBuilder (ZRDL v1) - golden byte fixtures", () => { assertBytesEqual(res.bytes, expected, "draw_text_interned.bin"); assertHeader(res.bytes, { - totalSize: 268, + totalSize: 292, cmdOffset: 64, - cmdBytes: 180, - cmdCount: 3, - stringsSpanOffset: 244, - stringsCount: 1, - stringsBytesOffset: 252, - stringsBytesLen: 16, + cmdBytes: 228, + cmdCount: 5, + stringsSpanOffset: 0, + stringsCount: 0, + stringsBytesOffset: 0, + stringsBytesLen: 0, blobsSpanOffset: 0, blobsCount: 0, blobsBytesOffset: 0, diff --git a/packages/core/src/drawlist/__tests__/builder.graphics.test.ts b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts index 406b0010..a9930bc7 100644 --- a/packages/core/src/drawlist/__tests__/builder.graphics.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.graphics.test.ts @@ -5,6 +5,9 @@ const OP_DRAW_TEXT = 3; const OP_DRAW_TEXT_RUN = 6; const OP_DRAW_CANVAS = 8; const OP_DRAW_IMAGE = 9; +const OP_DEF_STRING = 10; +const OP_DEF_BLOB = 12; +const LINK_MAX_BYTES = 2083; function u8(bytes: Uint8Array, off: number): number { return bytes[off] ?? 0; @@ -34,14 +37,6 @@ type Header = Readonly<{ cmdOffset: number; cmdBytes: number; cmdCount: number; - stringsSpanOffset: number; - stringsCount: number; - stringsBytesOffset: number; - stringsBytesLen: number; - blobsSpanOffset: number; - blobsCount: number; - blobsBytesOffset: number; - blobsBytesLen: number; }>; type Command = Readonly<{ @@ -57,14 +52,6 @@ function readHeader(bytes: Uint8Array): Header { cmdOffset: u32(bytes, 16), cmdBytes: u32(bytes, 20), cmdCount: u32(bytes, 24), - stringsSpanOffset: u32(bytes, 28), - stringsCount: u32(bytes, 32), - stringsBytesOffset: u32(bytes, 36), - stringsBytesLen: u32(bytes, 40), - blobsSpanOffset: u32(bytes, 44), - blobsCount: u32(bytes, 48), - blobsBytesOffset: u32(bytes, 52), - blobsBytesLen: u32(bytes, 56), }; } @@ -92,13 +79,36 @@ function parseCommands(bytes: Uint8Array): readonly Command[] { return Object.freeze(out); } -function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string { - const spanOff = h.stringsSpanOffset + stringIndex * 8; - const byteOff = u32(bytes, spanOff); - const byteLen = u32(bytes, spanOff + 4); - const start = h.stringsBytesOffset + byteOff; - const end = start + byteLen; - return new TextDecoder().decode(bytes.subarray(start, end)); +type DefTables = Readonly<{ + stringsById: ReadonlyMap; + blobsById: ReadonlyMap; +}>; + +function decodeDefs(bytes: Uint8Array): DefTables { + const stringsById = new Map(); + const blobsById = new Map(); + const decoder = new TextDecoder(); + + for (const cmd of parseCommands(bytes)) { + if (cmd.opcode === OP_DEF_STRING) { + const stringId = u32(bytes, cmd.payloadOff + 0); + const byteLen = u32(bytes, cmd.payloadOff + 4); + const data = bytes.subarray(cmd.payloadOff + 8, cmd.payloadOff + 8 + byteLen); + stringsById.set(stringId, decoder.decode(data)); + continue; + } + if (cmd.opcode === OP_DEF_BLOB) { + const blobId = u32(bytes, cmd.payloadOff + 0); + const byteLen = u32(bytes, cmd.payloadOff + 4); + const data = bytes.slice(cmd.payloadOff + 8, cmd.payloadOff + 8 + byteLen); + blobsById.set(blobId, data); + } + } + + return Object.freeze({ + stringsById, + blobsById, + }); } function assertBadParams( @@ -131,10 +141,10 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u32(built.bytes, 0), ZRDL_MAGIC); assert.equal(u32(built.bytes, 4), 1); - assert.deepEqual( - parseCommands(built.bytes).map((cmd) => cmd.opcode), - [OP_DRAW_TEXT, OP_DRAW_CANVAS, OP_DRAW_IMAGE], - ); + const drawOps = parseCommands(built.bytes) + .filter((cmd) => cmd.opcode !== OP_DEF_STRING && cmd.opcode !== OP_DEF_BLOB) + .map((cmd) => cmd.opcode); + assert.deepEqual(drawOps, [OP_DRAW_TEXT, OP_DRAW_CANVAS, OP_DRAW_IMAGE]); }); test("setLink state is encoded into drawText style ext references", () => { @@ -145,8 +155,8 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) return; - const h = readHeader(built.bytes); - const cmd = parseCommands(built.bytes)[0]; + const defs = decodeDefs(built.bytes); + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); if (!cmd) throw new Error("missing drawText command"); assert.equal(cmd.opcode, OP_DRAW_TEXT); assert.equal(cmd.flags, 0); @@ -158,8 +168,8 @@ describe("DrawlistBuilder graphics/link commands", () => { const linkIdRef = u32(built.bytes, cmd.payloadOff + 44); assert.equal(linkUriRef > 0, true); assert.equal(linkIdRef > 0, true); - assert.equal(decodeString(built.bytes, h, linkUriRef), "https://example.com"); - assert.equal(decodeString(built.bytes, h, linkIdRef), "docs"); + assert.equal(defs.stringsById.get(linkUriRef) ?? "", "https://example.com"); + assert.equal(defs.stringsById.get(linkIdRef) ?? "", "docs"); }); test("setLink(null) clears hyperlink refs for subsequent text", () => { @@ -183,6 +193,39 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u32(built.bytes, second.payloadOff + 44), 0); }); + test("setLink rejects empty URI (URI must be non-empty or null)", () => { + const builder = createDrawlistBuilder(); + builder.setLink("", "docs"); + + assertBadParams(builder.build()); + }); + + test("setLink enforces URI/ID max byte caps using UTF-8 byte length", () => { + const oversizedUtf8 = "é".repeat(1042); + assert.equal(new TextEncoder().encode(oversizedUtf8).byteLength, LINK_MAX_BYTES + 1); + + const tooLongUriBuilder = createDrawlistBuilder(); + tooLongUriBuilder.setLink(oversizedUtf8); + assertBadParams(tooLongUriBuilder.build()); + + const tooLongIdBuilder = createDrawlistBuilder(); + tooLongIdBuilder.setLink("https://example.com/docs", oversizedUtf8); + assertBadParams(tooLongIdBuilder.build()); + }); + + test("setLink accepts URI/ID exactly at native byte limits", () => { + const uri = `${"é".repeat(1041)}a`; + const id = `${"é".repeat(1041)}b`; + assert.equal(new TextEncoder().encode(uri).byteLength, LINK_MAX_BYTES); + assert.equal(new TextEncoder().encode(id).byteLength, LINK_MAX_BYTES); + + const builder = createDrawlistBuilder(); + builder.setLink(uri, id); + builder.drawText(0, 0, "x"); + const built = builder.build(); + assert.equal(built.ok, true); + }); + test("encodes DRAW_CANVAS payload fields and blob offset/length", () => { const builder = createDrawlistBuilder(); const blob0 = builder.addBlob(new Uint8Array([1, 2, 3, 4])); @@ -196,8 +239,8 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) return; - const h = readHeader(built.bytes); - const cmd = parseCommands(built.bytes)[0]; + const defs = decodeDefs(built.bytes); + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_CANVAS); if (!cmd) throw new Error("missing command"); assert.equal(cmd.opcode, OP_DRAW_CANVAS); @@ -209,15 +252,14 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u16(built.bytes, cmd.payloadOff + 6), 2); assert.equal(u16(built.bytes, cmd.payloadOff + 8), 6); assert.equal(u16(built.bytes, cmd.payloadOff + 10), 6); - assert.equal(u32(built.bytes, cmd.payloadOff + 12), 4); - assert.equal(u32(built.bytes, cmd.payloadOff + 16), 6 * 6 * 4); + assert.equal(u32(built.bytes, cmd.payloadOff + 12), 2); + assert.equal(u32(built.bytes, cmd.payloadOff + 16), 0); assert.equal(u8(built.bytes, cmd.payloadOff + 20), 3); assert.equal(u8(built.bytes, cmd.payloadOff + 21), 0); assert.equal(u16(built.bytes, cmd.payloadOff + 22), 0); - assert.equal(h.blobsCount, 2); - assert.equal(u32(built.bytes, h.blobsSpanOffset + 8), 4); - assert.equal(u32(built.bytes, h.blobsSpanOffset + 12), 6 * 6 * 4); + assert.equal(defs.blobsById.get(1)?.byteLength ?? 0, 4); + assert.equal(defs.blobsById.get(2)?.byteLength ?? 0, 6 * 6 * 4); }); test("encodes DRAW_IMAGE payload fields", () => { @@ -231,7 +273,7 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) return; - const cmd = parseCommands(built.bytes)[0]; + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_IMAGE); if (!cmd) throw new Error("missing command"); assert.equal(cmd.opcode, OP_DRAW_IMAGE); @@ -243,8 +285,8 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(u16(built.bytes, cmd.payloadOff + 6), 8); assert.equal(u16(built.bytes, cmd.payloadOff + 8), 2); assert.equal(u16(built.bytes, cmd.payloadOff + 10), 2); - assert.equal(u32(built.bytes, cmd.payloadOff + 12), 0); - assert.equal(u32(built.bytes, cmd.payloadOff + 16), 16); + assert.equal(u32(built.bytes, cmd.payloadOff + 12), 1); + assert.equal(u32(built.bytes, cmd.payloadOff + 16), 0); assert.equal(u32(built.bytes, cmd.payloadOff + 20), 42); assert.equal(u8(built.bytes, cmd.payloadOff + 24), 0); assert.equal(u8(built.bytes, cmd.payloadOff + 25), 1); @@ -278,6 +320,14 @@ describe("DrawlistBuilder graphics/link commands", () => { assertBadParams(builder.build()); } + { + const builder = createDrawlistBuilder(); + const blobIndex = builder.addBlob(new Uint8Array(10)); + if (blobIndex === null) throw new Error("blob index was null"); + builder.drawImage(0, 0, 1, 1, blobIndex, "rgba", "auto", 0, "contain", 0); + assertBadParams(builder.build()); + } + { const builder = createDrawlistBuilder(); const blobIndex = builder.addBlob(new Uint8Array([1, 2, 3, 4])); @@ -339,12 +389,13 @@ describe("DrawlistBuilder graphics/link commands", () => { { const builder = createDrawlistBuilder(); - assert.equal(builder.addBlob(new Uint8Array([1, 2, 3])), null); + // @ts-expect-error runtime invalid param coverage + assert.equal(builder.addBlob("not-bytes"), null); assertBadParams(builder.build()); } }); - test("encodes underline style + underline RGB in drawText v3 style fields", () => { + test("encodes underline style + numeric underline RGB in drawText v3 style fields", () => { const builder = createDrawlistBuilder(); builder.drawText(0, 0, "x", { underline: true, @@ -354,7 +405,7 @@ describe("DrawlistBuilder graphics/link commands", () => { const built = builder.build(); assert.equal(built.ok, true); if (!built.ok) throw new Error("build failed"); - const cmd = parseCommands(built.bytes)[0]; + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); if (!cmd) throw new Error("missing command"); const reserved = u32(built.bytes, cmd.payloadOff + 32); const underlineRgb = u32(built.bytes, cmd.payloadOff + 36); @@ -372,7 +423,7 @@ describe("DrawlistBuilder graphics/link commands", () => { const built = builder.build(); assert.equal(built.ok, true); if (!built.ok) throw new Error("build failed"); - const cmd = parseCommands(built.bytes)[0]; + const cmd = parseCommands(built.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); if (!cmd) throw new Error("missing command"); const underlineRgb = u32(built.bytes, cmd.payloadOff + 36); assert.equal(underlineRgb, 0); @@ -397,11 +448,12 @@ describe("DrawlistBuilder graphics/link commands", () => { assert.equal(built.ok, true); if (!built.ok) throw new Error("build failed"); - const h = readHeader(built.bytes); - const blobOffset = u32(built.bytes, h.blobsSpanOffset); - const segmentOff = h.blobsBytesOffset + blobOffset + 4; - const reserved = u32(built.bytes, segmentOff + 12); - const underlineRgb = u32(built.bytes, segmentOff + 16); + const defs = decodeDefs(built.bytes); + const blob = defs.blobsById.get(1); + if (!blob) throw new Error("missing text run blob"); + const segmentOff = 4; + const reserved = u32(blob, segmentOff + 12); + const underlineRgb = u32(blob, segmentOff + 16); assert.equal(reserved & 0x7, 5); assert.equal(underlineRgb, 0x010203); }); diff --git a/packages/core/src/drawlist/__tests__/builder.limits.test.ts b/packages/core/src/drawlist/__tests__/builder.limits.test.ts index ee7d161d..91711345 100644 --- a/packages/core/src/drawlist/__tests__/builder.limits.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.limits.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseDrawTextCommands, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../builder.js"; import type { DrawlistBuildErrorCode, DrawlistBuildResult } from "../types.js"; @@ -18,9 +19,9 @@ const HEADER = { SIZE: 64, } as const; -const SPAN_SIZE = 8; const CMD_SIZE_CLEAR = 8; -const CMD_SIZE_DRAW_TEXT = 60; +const CMD_SIZE_DRAW_TEXT = 8 + 52; +const CMD_SIZE_DEF_STRING_BASE = 16; type ParsedHeader = Readonly<{ totalSize: number; @@ -96,74 +97,66 @@ describe("DrawlistBuilder - limits boundaries", () => { expectError(b.build(), "ZRDL_TOO_LARGE"); }); - test("maxStrings: exactly at limit for persistent strings succeeds", () => { + test("maxStrings: exactly at limit with unique strings succeeds", () => { const b = createDrawlistBuilder({ maxStrings: 2 }); - b.setLink("uri-a", "id-a"); - b.drawText(0, 0, "x"); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); - assert.equal(h.stringsCount, 3); // arena + 2 persistent strings - assert.equal(h.cmdCount, 1); + assert.deepEqual(parseInternedStrings(bytes), ["a", "b"]); + assert.equal(h.cmdCount, 4); }); - test("maxStrings: duplicate persistent strings do not consume extra slots", () => { + test("maxStrings: interned duplicates do not consume extra slots", () => { const b = createDrawlistBuilder({ maxStrings: 1 }); - b.setLink("same"); - b.drawText(0, 0, "x"); - b.setLink("same"); - b.drawText(2, 0, "y"); + b.drawText(0, 0, "same"); + b.drawText(2, 0, "same"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); - assert.equal(h.stringsCount, 2); // arena + 1 persistent string - assert.equal(h.cmdCount, 2); + assert.deepEqual(parseInternedStrings(bytes), ["same"]); + assert.equal(h.cmdCount, 3); }); - test("maxStrings: overflow on next unique persistent string fails", () => { + test("maxStrings: overflow on next unique string fails", () => { const b = createDrawlistBuilder({ maxStrings: 1 }); - b.setLink("a"); - b.drawText(0, 0, "x"); - b.setLink("b"); - b.drawText(0, 1, "y"); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); expectError(b.build(), "ZRDL_TOO_LARGE"); }); - test("maxStringBytes: exactly-at-limit ASCII persistent payload succeeds", () => { + test("maxStringBytes: exactly-at-limit ASCII payload succeeds", () => { const b = createDrawlistBuilder({ maxStringBytes: 3 }); - b.setLink("abc"); - b.drawText(0, 0, "x"); + b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); - const dv = toView(bytes); - const spanLen = dv.getUint32(h.stringsSpanOffset + 8 + 4, true); + const drawText = parseDrawTextCommands(bytes); - assert.equal(h.stringsCount, 2); - assert.equal(spanLen, 3); - assert.equal(h.stringsBytesLen, align4(1 + 3)); + assert.deepEqual(parseInternedStrings(bytes), ["abc"]); + assert.equal(drawText.length, 1); + assert.equal(drawText[0]?.byteLen, 3); + assert.equal(h.cmdBytes, align4(CMD_SIZE_DEF_STRING_BASE + 3) + CMD_SIZE_DRAW_TEXT); }); - test("maxStringBytes: exactly-at-limit UTF-8 persistent payload succeeds", () => { + test("maxStringBytes: exactly-at-limit UTF-8 payload succeeds", () => { const text = "éa"; const utf8Len = new TextEncoder().encode(text).byteLength; const b = createDrawlistBuilder({ maxStringBytes: utf8Len }); - b.setLink(text); - b.drawText(0, 0, "x"); + b.drawText(0, 0, text); const bytes = expectOk(b.build()); const h = parseHeader(bytes); - const dv = toView(bytes); - const spanLen = dv.getUint32(h.stringsSpanOffset + 8 + 4, true); + const drawText = parseDrawTextCommands(bytes); assert.equal(utf8Len, 3); - assert.equal(spanLen, utf8Len); - assert.equal(h.stringsBytesLen, align4(1 + utf8Len)); + assert.equal(drawText[0]?.byteLen, utf8Len); + assert.equal(h.cmdBytes, align4(CMD_SIZE_DEF_STRING_BASE + utf8Len) + CMD_SIZE_DRAW_TEXT); }); - test("maxStringBytes: overflow on persistent strings fails", () => { + test("maxStringBytes: overflow fails", () => { const b = createDrawlistBuilder({ maxStringBytes: 3 }); - b.setLink("abcd"); - b.drawText(0, 0, "x"); + b.drawText(0, 0, "abcd"); expectError(b.build(), "ZRDL_TOO_LARGE"); }); @@ -188,20 +181,21 @@ describe("DrawlistBuilder - limits boundaries", () => { test("maxDrawlistBytes: exact text drawlist boundary succeeds", () => { const textBytes = 3; - const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); + const exactLimit = + HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit }); b.drawText(0, 0, "abc"); const bytes = expectOk(b.build()); const h = parseHeader(bytes); assert.equal(h.totalSize, exactLimit); - assert.equal(h.cmdBytes, CMD_SIZE_DRAW_TEXT); - assert.equal(h.stringsBytesLen, 4); + assert.equal(h.cmdBytes, exactLimit - HEADER.SIZE); }); test("maxDrawlistBytes: one byte below text drawlist boundary fails", () => { const textBytes = 3; - const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); + const exactLimit = + HEADER.SIZE + CMD_SIZE_DRAW_TEXT + align4(CMD_SIZE_DEF_STRING_BASE + textBytes); const b = createDrawlistBuilder({ maxDrawlistBytes: exactLimit - 1 }); b.drawText(0, 0, "abc"); @@ -237,8 +231,8 @@ describe("DrawlistBuilder - limits boundaries", () => { const bytes = expectOk(b.build()); const h = parseHeader(bytes); - assert.equal(h.cmdCount, 64); - assert.equal(h.stringsCount, 1); + assert.equal(h.cmdCount, 128); + assert.equal(parseInternedStrings(bytes).length, 64); assert.equal(h.totalSize <= 1_000_000, true); assert.equal((h.totalSize & 3) === 0, true); }); diff --git a/packages/core/src/drawlist/__tests__/builder.reset.test.ts b/packages/core/src/drawlist/__tests__/builder.reset.test.ts index adb1e8ef..e7bddd39 100644 --- a/packages/core/src/drawlist/__tests__/builder.reset.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.reset.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; const HEADER_SIZE = 64; @@ -8,8 +9,8 @@ const OP_CLEAR = 1; const OP_FILL_RECT = 2; const OP_DRAW_TEXT = 3; const OP_SET_CURSOR = 7; - -const decoder = new TextDecoder(); +const OP_FREE_STRING = 11; +const OP_FREE_BLOB = 13; type Header = Readonly<{ totalSize: number; @@ -49,10 +50,6 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -function align4(n: number): number { - return (n + 3) & ~3; -} - function readHeader(bytes: Uint8Array): Header { return { totalSize: u32(bytes, 12), @@ -91,14 +88,6 @@ function parseCommands(bytes: Uint8Array): readonly CmdHeader[] { return out; } -function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string { - const spanOff = h.stringsSpanOffset + stringIndex * 8; - const off = u32(bytes, spanOff); - const len = u32(bytes, spanOff + 4); - const start = h.stringsBytesOffset + off; - return decoder.decode(bytes.subarray(start, start + len)); -} - describe("DrawlistBuilder reset behavior", () => { test("v1 reset clears prior commands/strings/blobs for next frame", () => { const b = createDrawlistBuilder(); @@ -113,9 +102,8 @@ describe("DrawlistBuilder reset behavior", () => { if (!first.ok) return; const h1 = readHeader(first.bytes); - assert.equal(h1.cmdCount, 2); - assert.equal(h1.stringsCount, 1); - assert.equal(h1.blobsCount, 1); + assert.equal(h1.cmdCount, 6); + assert.deepEqual(parseInternedStrings(first.bytes), ["A", "B", "frame0"]); b.reset(); b.clear(); @@ -124,11 +112,20 @@ describe("DrawlistBuilder reset behavior", () => { if (!second.ok) return; const h2 = readHeader(second.bytes); - assert.equal(h2.cmdCount, 1); - assert.equal(h2.cmdBytes, 8); + assert.equal(h2.cmdCount, 5); + assert.equal(h2.cmdBytes, 56); assert.equal(h2.stringsCount, 0); assert.equal(h2.blobsCount, 0); - assert.equal(h2.totalSize, 72); + assert.equal(h2.totalSize, 120); + + const opcodes = parseCommands(second.bytes).map((cmd) => cmd.opcode); + assert.deepEqual(opcodes, [ + OP_CLEAR, + OP_FREE_STRING, + OP_FREE_STRING, + OP_FREE_STRING, + OP_FREE_BLOB, + ]); }); test("v2 reset drops cursor and string state before next frame", () => { @@ -140,8 +137,8 @@ describe("DrawlistBuilder reset behavior", () => { if (!first.ok) return; const h1 = readHeader(first.bytes); - assert.equal(h1.cmdCount, 2); - assert.equal(h1.stringsCount, 1); + assert.equal(h1.cmdCount, 3); + assert.deepEqual(parseInternedStrings(first.bytes), ["persist"]); b.reset(); b.fillRect(0, 0, 1, 1); @@ -150,28 +147,33 @@ describe("DrawlistBuilder reset behavior", () => { if (!second.ok) return; const h2 = readHeader(second.bytes); - assert.equal(h2.cmdCount, 1); + assert.equal(h2.cmdCount, 2); assert.equal(h2.stringsCount, 0); assert.equal(h2.blobsCount, 0); const cmds = parseCommands(second.bytes); - const cmd = cmds[0]; - if (!cmd) return; - assert.equal(cmd.opcode, OP_FILL_RECT); + assert.deepEqual( + cmds.map((cmd) => cmd.opcode), + [OP_FILL_RECT, OP_FREE_STRING], + ); }); test("v1 reset clears sticky failure state and restores successful builds", () => { - const b = createDrawlistBuilder({ maxDrawlistBytes: 80 }); - b.fillRect(0, 0, 10, 10); + const b = createDrawlistBuilder({ maxStrings: 1 }); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); const failed = b.build(); assert.equal(failed.ok, false); if (failed.ok) return; assert.equal(failed.error.code, "ZRDL_TOO_LARGE"); b.reset(); - b.clear(); + b.drawText(0, 0, "ok"); const recovered = b.build(); assert.equal(recovered.ok, true); + if (!recovered.ok) return; + assert.equal(readHeader(recovered.bytes).cmdCount, 2); + assert.deepEqual(parseInternedStrings(recovered.bytes), ["ok"]); }); test("v2 reset clears sticky failure state and allows cursor commands again", () => { @@ -210,27 +212,26 @@ describe("DrawlistBuilder reset behavior", () => { if (!res.ok) return; const h = readHeader(res.bytes); - assert.equal(h.cmdCount, 2); - assert.equal(h.cmdBytes, 68); - assert.equal(h.stringsCount, 1); + assert.equal(h.cmdCount, 3); + assert.equal(h.cmdBytes, 88); + assert.equal(h.stringsCount, 0); assert.equal(h.blobsCount, 0); - assert.equal(h.stringsBytesLen, align4(text.length)); - assert.equal(h.totalSize, HEADER_SIZE + 68 + 8 + align4(text.length)); + assert.equal(h.totalSize, HEADER_SIZE + 88); const cmds = parseCommands(res.bytes); - const clear = cmds[0]; - const draw = cmds[1]; + const clear = cmds.find((cmd) => cmd.opcode === OP_CLEAR); + const draw = cmds.find((cmd) => cmd.opcode === OP_DRAW_TEXT); + assert.equal(clear !== undefined, true); + assert.equal(draw !== undefined, true); if (!clear || !draw) return; - assert.equal(clear.opcode, OP_CLEAR); - assert.equal(draw.opcode, OP_DRAW_TEXT); assert.equal(draw.flags, 0); assert.equal(draw.size, 60); const stringIndex = u32(res.bytes, draw.payloadOff + 8); const byteLen = u32(res.bytes, draw.payloadOff + 16); - assert.equal(stringIndex, 0); + assert.equal(stringIndex, 1); assert.equal(byteLen, text.length); - assert.equal(decodeString(res.bytes, h, 0), text); + assert.equal(parseInternedStrings(res.bytes).includes(text), true); } }); diff --git a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts index 2d1450b5..45c7b800 100644 --- a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts @@ -1,19 +1,21 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_CLEAR, + OP_DEF_STRING, + OP_DRAW_TEXT, + OP_FILL_RECT, + OP_POP_CLIP, + OP_PUSH_CLIP, + OP_SET_CURSOR, + parseCommandHeaders, + parseDrawTextCommands, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; const HEADER_SIZE = 64; const INT32_MAX = 2147483647; -const OP_CLEAR = 1; -const OP_FILL_RECT = 2; -const OP_DRAW_TEXT = 3; -const OP_PUSH_CLIP = 4; -const OP_POP_CLIP = 5; -const OP_DRAW_TEXT_RUN = 6; -const OP_SET_CURSOR = 7; - -const decoder = new TextDecoder(); - type Header = Readonly<{ magic: number; version: number; @@ -41,7 +43,15 @@ type CmdHeader = Readonly<{ payloadOff: number; }>; -type PackedStyle = Readonly<{ fg: number; bg: number; attrs: number; reserved0: number }>; +type PackedStyle = Readonly<{ + fg: number; + bg: number; + attrs: number; + reserved0: number; + underlineRgb: number; + linkUriRef: number; + linkIdRef: number; +}>; function u8(bytes: Uint8Array, off: number): number { return bytes[off] ?? 0; @@ -62,10 +72,6 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -function align4(n: number): number { - return (n + 3) & ~3; -} - function readHeader(bytes: Uint8Array): Header { return { magic: u32(bytes, 0), @@ -87,81 +93,14 @@ function readHeader(bytes: Uint8Array): Header { }; } -function assertAligned4(label: string, value: number): void { - assert.equal(value % 4, 0, `${label} must be 4-byte aligned`); -} - -function assertHeaderLayout(bytes: Uint8Array, h: Header): void { - assert.equal(h.headerSize, HEADER_SIZE); - assert.equal(h.totalSize, bytes.byteLength); - assert.equal(h.reserved0, 0); - - let cursor = HEADER_SIZE; - - if (h.cmdCount === 0) { - assert.equal(h.cmdOffset, 0); - assert.equal(h.cmdBytes, 0); - } else { - assert.equal(h.cmdOffset, cursor); - assertAligned4("cmdOffset", h.cmdOffset); - assertAligned4("cmdBytes", h.cmdBytes); - cursor += h.cmdBytes; - } - - if (h.stringsCount === 0) { - assert.equal(h.stringsSpanOffset, 0); - assert.equal(h.stringsBytesOffset, 0); - assert.equal(h.stringsBytesLen, 0); - } else { - assert.equal(h.stringsSpanOffset, cursor); - assertAligned4("stringsSpanOffset", h.stringsSpanOffset); - cursor += h.stringsCount * 8; - - assert.equal(h.stringsBytesOffset, cursor); - assertAligned4("stringsBytesOffset", h.stringsBytesOffset); - assertAligned4("stringsBytesLen", h.stringsBytesLen); - cursor += h.stringsBytesLen; - } - - if (h.blobsCount === 0) { - assert.equal(h.blobsSpanOffset, 0); - assert.equal(h.blobsBytesOffset, 0); - assert.equal(h.blobsBytesLen, 0); - } else { - assert.equal(h.blobsSpanOffset, cursor); - assertAligned4("blobsSpanOffset", h.blobsSpanOffset); - cursor += h.blobsCount * 8; - - assert.equal(h.blobsBytesOffset, cursor); - assertAligned4("blobsBytesOffset", h.blobsBytesOffset); - assertAligned4("blobsBytesLen", h.blobsBytesLen); - cursor += h.blobsBytesLen; - } - - assert.equal(cursor, h.totalSize); -} - function parseCommands(bytes: Uint8Array): readonly CmdHeader[] { - const h = readHeader(bytes); - if (h.cmdCount === 0) return []; - - const out: CmdHeader[] = []; - let off = h.cmdOffset; - - for (let i = 0; i < h.cmdCount; i++) { - const size = u32(bytes, off + 4); - out.push({ - off, - opcode: u16(bytes, off), - flags: u16(bytes, off + 2), - size, - payloadOff: off + 8, - }); - off += size; - } - - assert.equal(off, h.cmdOffset + h.cmdBytes); - return out; + return parseCommandHeaders(bytes).map((cmd) => ({ + off: cmd.offset, + opcode: cmd.opcode, + flags: u16(bytes, cmd.offset + 2), + size: cmd.size, + payloadOff: cmd.payloadOffset, + })); } function readStyle(bytes: Uint8Array, off: number): PackedStyle { @@ -170,25 +109,12 @@ function readStyle(bytes: Uint8Array, off: number): PackedStyle { bg: u32(bytes, off + 4), attrs: u32(bytes, off + 8), reserved0: u32(bytes, off + 12), + underlineRgb: u32(bytes, off + 16), + linkUriRef: u32(bytes, off + 20), + linkIdRef: u32(bytes, off + 24), }; } -function decodeStringSlice( - bytes: Uint8Array, - h: Header, - stringIndex: number, - byteOff: number, - byteLen: number, -): string { - const spanOff = h.stringsSpanOffset + stringIndex * 8; - const strOff = u32(bytes, spanOff); - const strLen = u32(bytes, spanOff + 4); - assert.equal(byteOff + byteLen <= strLen, true); - - const start = h.stringsBytesOffset + strOff + byteOff; - return decoder.decode(bytes.subarray(start, start + byteLen)); -} - function readSetCursorCommand(bytes: Uint8Array, cmd: CmdHeader) { assert.equal(cmd.opcode, OP_SET_CURSOR); assert.equal(cmd.size, 20); @@ -203,7 +129,7 @@ function readSetCursorCommand(bytes: Uint8Array, cmd: CmdHeader) { } describe("DrawlistBuilder round-trip binary readback", () => { - test("v1 header magic/version/counts/offsets/byte sizes are exact for mixed commands", () => { + test("header and command stream layout are exact for mixed commands", () => { const b = createDrawlistBuilder(); b.clear(); b.fillRect(1, 2, 3, 4, { @@ -223,23 +149,33 @@ describe("DrawlistBuilder round-trip binary readback", () => { const h = readHeader(res.bytes); assert.equal(h.magic, ZRDL_MAGIC); assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); - assert.equal(h.cmdOffset, 64); - assert.equal(h.cmdBytes, 152); - assert.equal(h.cmdCount, 5); - assert.equal(h.stringsSpanOffset, 216); - assert.equal(h.stringsCount, 1); - assert.equal(h.stringsBytesOffset, 224); - assert.equal(h.stringsBytesLen, 4); + assert.equal(h.headerSize, HEADER_SIZE); + assert.equal(h.totalSize, res.bytes.byteLength); + assert.equal(h.cmdOffset, HEADER_SIZE); + assert.equal(h.cmdCount, 6); + assert.equal(h.stringsSpanOffset, 0); + assert.equal(h.stringsCount, 0); + assert.equal(h.stringsBytesOffset, 0); + assert.equal(h.stringsBytesLen, 0); assert.equal(h.blobsSpanOffset, 0); assert.equal(h.blobsCount, 0); assert.equal(h.blobsBytesOffset, 0); assert.equal(h.blobsBytesLen, 0); - assert.equal(h.totalSize, 228); + assert.equal(h.reserved0, 0); - assertHeaderLayout(res.bytes, h); + const cmds = parseCommands(res.bytes); + assert.equal(cmds.length, h.cmdCount); + assert.equal( + cmds.reduce((acc, cmd) => acc + cmd.size, 0), + h.cmdBytes, + ); + assert.deepEqual( + cmds.map((cmd) => cmd.opcode), + [OP_DEF_STRING, OP_CLEAR, OP_FILL_RECT, OP_PUSH_CLIP, OP_DRAW_TEXT, OP_POP_CLIP], + ); }); - test("v1 fillRect command readback preserves geometry and packed style", () => { + test("fillRect command readback preserves geometry and packed style", () => { const b = createDrawlistBuilder(); b.fillRect(-3, 9, 11, 13, { fg: (1 << 16) | (2 << 8) | 3, @@ -253,9 +189,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const cmds = parseCommands(res.bytes); - assert.equal(cmds.length, 1); - const cmd = cmds[0]; + const cmd = parseCommands(res.bytes)[0]; if (!cmd) return; assert.equal(cmd.opcode, OP_FILL_RECT); @@ -271,9 +205,12 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(style.bg, 0x0004_0506); assert.equal(style.attrs, (1 << 0) | (1 << 2) | (1 << 4)); assert.equal(style.reserved0, 0); + assert.equal(style.underlineRgb, 0); + assert.equal(style.linkUriRef, 0); + assert.equal(style.linkIdRef, 0); }); - test("v1 drawText command readback resolves string span and style fields", () => { + test("drawText command readback resolves interned text and style payload", () => { const b = createDrawlistBuilder(); b.drawText(7, 9, "hello", { fg: (255 << 16) | (128 << 8) | 1, @@ -286,37 +223,35 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const h = readHeader(res.bytes); - const cmds = parseCommands(res.bytes); - assert.equal(cmds.length, 1); - - const cmd = cmds[0]; + const cmd = parseCommands(res.bytes).find((entry) => entry.opcode === OP_DRAW_TEXT); + assert.equal(cmd !== undefined, true); if (!cmd) return; - assert.equal(cmd.opcode, OP_DRAW_TEXT); + assert.equal(cmd.size, 60); + assert.equal(i32(res.bytes, cmd.payloadOff + 0), 7); + assert.equal(i32(res.bytes, cmd.payloadOff + 4), 9); + assert.equal(u32(res.bytes, cmd.payloadOff + 8), 1); + assert.equal(u32(res.bytes, cmd.payloadOff + 12), 0); + assert.equal(u32(res.bytes, cmd.payloadOff + 16), 5); - const x = i32(res.bytes, cmd.payloadOff + 0); - const y = i32(res.bytes, cmd.payloadOff + 4); - const stringIndex = u32(res.bytes, cmd.payloadOff + 8); - const byteOff = u32(res.bytes, cmd.payloadOff + 12); - const byteLen = u32(res.bytes, cmd.payloadOff + 16); const style = readStyle(res.bytes, cmd.payloadOff + 20); - const reserved0 = u32(res.bytes, cmd.payloadOff + 48); - - assert.equal(x, 7); - assert.equal(y, 9); - assert.equal(stringIndex, 0); - assert.equal(byteOff, 0); - assert.equal(byteLen, 5); assert.equal(style.fg, 0x00ff_8001); assert.equal(style.bg, 0x0002_0304); assert.equal(style.attrs, (1 << 1) | (1 << 3)); assert.equal(style.reserved0, 0); - assert.equal(reserved0, 0); - assert.equal(decodeStringSlice(res.bytes, h, stringIndex, byteOff, byteLen), "hello"); + assert.equal(style.underlineRgb, 0); + assert.equal(style.linkUriRef, 0); + assert.equal(style.linkIdRef, 0); + assert.equal(u32(res.bytes, cmd.payloadOff + 48), 0); + + assert.deepEqual(parseInternedStrings(res.bytes), ["hello"]); + const drawText = parseDrawTextCommands(res.bytes)[0]; + assert.equal(drawText?.stringId, 1); + assert.equal(drawText?.byteLen, 5); + assert.equal(drawText?.text, "hello"); }); - test("v1 clip push/pop commands round-trip with exact payload sizes", () => { + test("clip push/pop commands round-trip with exact payload sizes", () => { const b = createDrawlistBuilder(); b.pushClip(2, 3, 4, 5); b.popClip(); @@ -342,7 +277,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(pop.size, 8); }); - test("v1 repeated text uses interned string indices deterministically", () => { + test("repeated text uses deterministic 1-based string ids", () => { const b = createDrawlistBuilder(); b.drawText(0, 0, "same"); b.drawText(0, 1, "same"); @@ -352,53 +287,15 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const h = readHeader(res.bytes); - assert.equal(h.stringsCount, 1); - assert.equal(h.cmdCount, 3); - assert.equal(h.cmdBytes, 180); - assert.equal(h.stringsSpanOffset, 244); - assert.equal(h.stringsBytesOffset, 252); - assert.equal(h.stringsBytesLen, 16); - assert.equal(h.totalSize, 268); - assertHeaderLayout(res.bytes, h); - - const cmds = parseCommands(res.bytes); - const c0 = cmds[0]; - const c1 = cmds[1]; - const c2 = cmds[2]; - if (!c0 || !c1 || !c2) return; - - const idx0 = u32(res.bytes, c0.payloadOff + 8); - const idx1 = u32(res.bytes, c1.payloadOff + 8); - const idx2 = u32(res.bytes, c2.payloadOff + 8); - assert.equal(idx0, 0); - assert.equal(idx1, 0); - assert.equal(idx2, 0); - assert.equal(decodeStringSlice(res.bytes, h, idx0, 0, 4), "same"); - assert.equal(decodeStringSlice(res.bytes, h, idx1, 4, 4), "same"); - assert.equal(decodeStringSlice(res.bytes, h, idx2, 8, 5), "other"); - }); - - test("v2 header uses version 2 and correct cmd byte/count totals", () => { - const b = createDrawlistBuilder(); - b.clear(); - b.setCursor({ x: 10, y: 5, shape: 1, visible: true, blink: false }); - - const res = b.build(); - assert.equal(res.ok, true); - if (!res.ok) return; - - const h = readHeader(res.bytes); - assert.equal(h.magic, ZRDL_MAGIC); - assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); - assert.equal(h.cmdOffset, 64); - assert.equal(h.cmdBytes, 28); - assert.equal(h.cmdCount, 2); - assert.equal(h.totalSize, 92); - assertHeaderLayout(res.bytes, h); + const drawText = parseDrawTextCommands(res.bytes); + assert.deepEqual( + drawText.map((cmd) => cmd.stringId), + [1, 1, 2], + ); + assert.deepEqual(parseInternedStrings(res.bytes), ["same", "other"]); }); - test("v2 setCursor readback preserves payload fields and reserved byte", () => { + test("setCursor readback preserves payload fields and reserved byte", () => { const b = createDrawlistBuilder(); b.setCursor({ x: -1, y: 123, shape: 2, visible: false, blink: true }); @@ -406,9 +303,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const cmds = parseCommands(res.bytes); - assert.equal(cmds.length, 1); - const cmd = cmds[0]; + const cmd = parseCommands(res.bytes)[0]; if (!cmd) return; const cursor = readSetCursorCommand(res.bytes, cmd); @@ -420,7 +315,7 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(cursor.reserved0, 0); }); - test("v2 multiple cursor commands are emitted in-order", () => { + test("multiple cursor commands are emitted in order", () => { const b = createDrawlistBuilder(); b.setCursor({ x: 1, y: 2, shape: 0, visible: true, blink: true }); b.setCursor({ x: 3, y: 4, shape: 1, visible: true, blink: false }); @@ -430,19 +325,11 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(res.ok, true); if (!res.ok) return; - const h = readHeader(res.bytes); - assert.equal(h.cmdCount, 3); - assert.equal(h.cmdBytes, 60); - const cmds = parseCommands(res.bytes); - const c0 = cmds[0]; - const c1 = cmds[1]; - const c2 = cmds[2]; - if (!c0 || !c1 || !c2) return; - - const s0 = readSetCursorCommand(res.bytes, c0); - const s1 = readSetCursorCommand(res.bytes, c1); - const s2 = readSetCursorCommand(res.bytes, c2); + assert.equal(cmds.length, 3); + const s0 = readSetCursorCommand(res.bytes, cmds[0] as CmdHeader); + const s1 = readSetCursorCommand(res.bytes, cmds[1] as CmdHeader); + const s2 = readSetCursorCommand(res.bytes, cmds[2] as CmdHeader); assert.equal(s0.x, 1); assert.equal(s0.y, 2); @@ -463,43 +350,27 @@ describe("DrawlistBuilder round-trip binary readback", () => { assert.equal(s2.blink, 0); }); - test("cursor edge position (0,0) round-trips exactly", () => { - const b = createDrawlistBuilder(); - b.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); - - const res = b.build(); - assert.equal(res.ok, true); - if (!res.ok) return; - - const cmd = parseCommands(res.bytes)[0]; - if (!cmd) return; - const cursor = readSetCursorCommand(res.bytes, cmd); - assert.equal(cursor.x, 0); - assert.equal(cursor.y, 0); - assert.equal(cursor.shape, 0); - assert.equal(cursor.visible, 1); - assert.equal(cursor.blink, 1); + test("cursor edge positions round-trip exactly", () => { + const b0 = createDrawlistBuilder(); + b0.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); + const r0 = b0.build(); + assert.equal(r0.ok, true); + if (!r0.ok) return; + const c0 = readSetCursorCommand(r0.bytes, parseCommands(r0.bytes)[0] as CmdHeader); + assert.equal(c0.x, 0); + assert.equal(c0.y, 0); + + const b1 = createDrawlistBuilder(); + b1.setCursor({ x: INT32_MAX, y: INT32_MAX, shape: 2, visible: true, blink: false }); + const r1 = b1.build(); + assert.equal(r1.ok, true); + if (!r1.ok) return; + const c1 = readSetCursorCommand(r1.bytes, parseCommands(r1.bytes)[0] as CmdHeader); + assert.equal(c1.x, INT32_MAX); + assert.equal(c1.y, INT32_MAX); }); - test("cursor edge position (large int32) round-trips exactly", () => { - const b = createDrawlistBuilder(); - b.setCursor({ x: INT32_MAX, y: INT32_MAX, shape: 2, visible: true, blink: false }); - - const res = b.build(); - assert.equal(res.ok, true); - if (!res.ok) return; - - const cmd = parseCommands(res.bytes)[0]; - if (!cmd) return; - const cursor = readSetCursorCommand(res.bytes, cmd); - assert.equal(cursor.x, INT32_MAX); - assert.equal(cursor.y, INT32_MAX); - assert.equal(cursor.shape, 2); - assert.equal(cursor.visible, 1); - assert.equal(cursor.blink, 0); - }); - - test("v2 mixed frame keeps aligned sections and expected total byte size", () => { + test("mixed frame keeps aligned command stream and expected opcodes", () => { const b = createDrawlistBuilder(); b.clear(); b.pushClip(0, 0, 80, 24); @@ -513,19 +384,29 @@ describe("DrawlistBuilder round-trip binary readback", () => { if (!res.ok) return; const h = readHeader(res.bytes); + const cmds = parseCommands(res.bytes); assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); - assert.equal(h.cmdCount, 6); + assert.equal(h.cmdCount, 7); + assert.equal(cmds.length, 7); assert.equal( + cmds.reduce((acc, cmd) => acc + cmd.size, 0), h.cmdBytes, - 8 + // clear - 24 + // push clip - 52 + // fill rect - 60 + // draw text - 20 + // set cursor - 8, // pop clip ); - assert.equal(h.stringsCount, 1); - assert.equal(h.stringsBytesLen, align4(2)); - assertHeaderLayout(res.bytes, h); + assert.deepEqual( + cmds.map((cmd) => cmd.opcode), + [ + OP_DEF_STRING, + OP_CLEAR, + OP_PUSH_CLIP, + OP_FILL_RECT, + OP_DRAW_TEXT, + OP_SET_CURSOR, + OP_POP_CLIP, + ], + ); + for (const cmd of cmds) { + assert.equal((cmd.off & 3) === 0, true); + assert.equal((cmd.size & 3) === 0, true); + } }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts index ecca77fa..14bea871 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts @@ -1,7 +1,44 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseDrawTextCommands, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; -function countCalls(calls: readonly string[], value: string): number { +type BuildResult = + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; error: Readonly<{ code: string; detail: string }> }>; + +type BuilderLike = Readonly<{ + drawText(x: number, y: number, text: string, style?: unknown): void; + build(): BuildResult; + reset(): void; +}>; + +type BuilderOpts = Readonly<{ + encodedStringCacheCap?: number; +}>; + +const FACTORIES: readonly Readonly<{ + name: string; + create(opts?: BuilderOpts): BuilderLike; +}>[] = [{ name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }]; + +type DrawTextEntry = Readonly<{ stringId: number; byteLen: number }>; + +function readDrawTextEntries(bytes: Uint8Array): DrawTextEntry[] { + return parseDrawTextCommands(bytes).map((cmd) => ({ + stringId: cmd.stringId, + byteLen: cmd.byteLen, + })); +} + +function buildOk(builder: BuilderLike, label: string): Uint8Array { + const res = builder.build(); + if (!res.ok) { + throw new Error(`${label}: build should succeed (${res.error.code}: ${res.error.detail})`); + } + return res.bytes; +} + +function encodeCallCount(calls: readonly string[], value: string): number { let count = 0; for (const call of calls) { if (call === value) count++; @@ -9,162 +46,308 @@ function countCalls(calls: readonly string[], value: string): number { return count; } -function withTextEncoderSpy( - run: (calls: Readonly<{ encode: readonly string[]; encodeInto: readonly string[] }>) => T, -): T { +function withTextEncoderSpy(run: (calls: string[]) => T): T { const OriginalTextEncoder = globalThis.TextEncoder; - assert.equal(typeof OriginalTextEncoder, "function", "TextEncoder should exist in runtime"); - - const encode: string[] = []; - const encodeInto: string[] = []; + assert.equal( + typeof OriginalTextEncoder, + "function", + "TextEncoder should exist in the test runtime", + ); + const calls: string[] = []; class SpyTextEncoder { private readonly encoder = new OriginalTextEncoder(); encode(input: string): Uint8Array { - encode.push(input); + calls.push(input); return this.encoder.encode(input); } - - encodeInto(input: string, destination: Uint8Array): { read: number; written: number } { - encodeInto.push(input); - return this.encoder.encodeInto(input, destination); - } } (globalThis as { TextEncoder: typeof TextEncoder }).TextEncoder = SpyTextEncoder as unknown as typeof TextEncoder; try { - return run({ encode, encodeInto }); + return run(calls); } finally { (globalThis as { TextEncoder: typeof TextEncoder }).TextEncoder = OriginalTextEncoder; } } -describe("drawlist encoded string cache + text arena counters", () => { - test("cap=0 re-encodes persistent link strings every frame", () => { - const uri = "https://example.com/dócş"; - const id = "dócş"; +describe("drawlist encoded string cache", () => { + test("cap=0 fallback re-encodes the same string every frame", () => { + const text = "cache-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 0 }); - withTextEncoderSpy((calls) => { - const b = createDrawlistBuilder({ encodedStringCacheCap: 0 }); + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 1`); + b.reset(); - for (let frame = 0; frame < 3; frame++) { + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 2`); b.reset(); - b.setLink(uri, id); - b.drawText(0, frame, "x"); - const built = b.build(); - assert.equal(built.ok, true, `frame ${String(frame)} should build`); - } - - assert.equal(countCalls(calls.encode, uri), 3, "uri should re-encode each frame"); - assert.equal(countCalls(calls.encode, id), 3, "id should re-encode each frame"); - }); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 3`); + + assert.equal( + encodeCallCount(calls, text), + 3, + `${factory.name}: cap=0 should not cache encoded bytes`, + ); + }); + } }); - test("cap>0 caches persistent link strings across frames", () => { - const uri = "https://example.com/dócş"; - const id = "dócş"; + test("cap>0 cache hit avoids re-encode across frames", () => { + const text = "hit-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 8 }); - withTextEncoderSpy((calls) => { - const b = createDrawlistBuilder({ encodedStringCacheCap: 8 }); + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 1`); + b.reset(); - for (let frame = 0; frame < 3; frame++) { + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 2`); b.reset(); - b.setLink(uri, id); - b.drawText(0, frame, "x"); - const built = b.build(); - assert.equal(built.ok, true, `frame ${String(frame)} should build`); - } - - assert.equal(countCalls(calls.encode, uri), 1, "uri should encode once with cache"); - assert.equal(countCalls(calls.encode, id), 1, "id should encode once with cache"); - }); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 3`); + + assert.equal( + encodeCallCount(calls, text), + 1, + `${factory.name}: cached string should encode once`, + ); + }); + } }); - test("capacity=1 eviction clears old cached persistent strings", () => { - const a = "https://example.com/á"; - const bText = "https://example.com/ß"; + test("cache hit still produces correct command byte_len and decoded string data", () => { + const text = "roundtrip 😀 e\u0301 漢字"; + const expectedLen = new TextEncoder().encode(text).byteLength; - withTextEncoderSpy((calls) => { - const b = createDrawlistBuilder({ encodedStringCacheCap: 1 }); + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 4 }); - b.setLink(a); - b.drawText(0, 0, "a"); - assert.equal(b.build().ok, true); + b.drawText(0, 0, text); + const frame1 = buildOk(b, `${factory.name} frame 1`); + b.reset(); - b.reset(); - b.setLink(bText); - b.drawText(0, 0, "b"); - assert.equal(b.build().ok, true); + b.drawText(0, 0, text); + const frame2 = buildOk(b, `${factory.name} frame 2`); - b.reset(); - b.setLink(a); - b.drawText(0, 0, "a"); - assert.equal(b.build().ok, true); + const f1Entry = readDrawTextEntries(frame1)[0]; + const f2Entry = readDrawTextEntries(frame2)[0]; - assert.equal(countCalls(calls.encode, a), 2, "a should miss after eviction"); - assert.equal(countCalls(calls.encode, bText), 1, "b should encode once"); - }); + assert.equal(f1Entry?.byteLen, expectedLen, `${factory.name}: frame1 byte_len`); + assert.equal(f2Entry?.byteLen, expectedLen, `${factory.name}: frame2 byte_len`); + assert.deepEqual(parseInternedStrings(frame1), [text], `${factory.name}: frame1 decode`); + assert.deepEqual(parseInternedStrings(frame2), [text], `${factory.name}: frame2 decode`); + assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: one encode with hit`); + }); + } }); - test("within a frame duplicate setLink values encode once", () => { - const uri = "https://example.com/óncé"; + test("eviction at capacity=1 causes prior entry misses", () => { + const a = "A-é"; + const bText = "B-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 1 }); - withTextEncoderSpy((calls) => { - const b = createDrawlistBuilder({ encodedStringCacheCap: 0 }); - b.setLink(uri); - b.drawText(0, 0, "a"); - b.setLink(uri); - b.drawText(0, 1, "b"); + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame A1`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame B`); + b.reset(); - const built = b.build(); - assert.equal(built.ok, true); - assert.equal(countCalls(calls.encode, uri), 1, "intern map dedupes within frame"); - }); + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame A2`); + + assert.equal(encodeCallCount(calls, a), 2, `${factory.name}: A re-encoded after eviction`); + assert.equal(encodeCallCount(calls, bText), 1, `${factory.name}: B encoded once`); + }); + } }); - test("drawText path uses encodeInto and reports text arena counters", () => { - withTextEncoderSpy((calls) => { - const b = createDrawlistBuilder(); - b.drawText(0, 0, "A"); - b.drawText(0, 1, ""); - const blobIndex = b.addTextRunBlob([{ text: "BC" }, { text: "D" }]); - assert.equal(blobIndex, 0); - if (blobIndex === null) return; - b.drawTextRun(0, 2, blobIndex); - - const built = b.build(); - assert.equal(built.ok, true); - - const counters = b.getTextPerfCounters?.(); - assert.ok(counters !== undefined, "counters should be available"); - if (!counters) return; - - assert.equal(counters.textSegments, 4, "2 drawText + 2 text-run segments"); - assert.equal(counters.textArenaBytes, 4, "A + BC + D"); - assert.equal(counters.textEncoderCalls >= 3, true, "non-empty segments encode at least once"); - assert.equal(calls.encodeInto.length >= 3, true, "encodeInto used for transient text"); - }); + test("capacity=2 keeps existing entries until a third unique string is inserted", () => { + const a = "a-é"; + const bText = "b-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 2 }); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame a`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame b`); + b.reset(); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame a hit`); + + assert.equal(encodeCallCount(calls, a), 1, `${factory.name}: A should hit cache`); + assert.equal(encodeCallCount(calls, bText), 1, `${factory.name}: B encoded once`); + }); + } }); - test("reset clears text arena counters", () => { - const b = createDrawlistBuilder(); - b.drawText(0, 0, "hello"); - assert.equal(b.build().ok, true); - - const before = b.getTextPerfCounters?.(); - assert.ok(before !== undefined); - if (!before) return; - assert.equal(before.textArenaBytes > 0, true); - - b.reset(); - const after = b.getTextPerfCounters?.(); - assert.ok(after !== undefined); - if (!after) return; - assert.equal(after.textArenaBytes, 0); - assert.equal(after.textSegments, 0); - assert.equal(after.textEncoderCalls, 0); + test("capacity=2 insertion of third unique string clears cache and evicts old entries", () => { + const a = "aa-é"; + const bText = "bb-é"; + const c = "cc-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 2 }); + + b.drawText(0, 0, a); + b.drawText(0, 1, bText); + buildOk(b, `${factory.name} frame ab`); + b.reset(); + + b.drawText(0, 0, c); + buildOk(b, `${factory.name} frame c`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame b again`); + + assert.equal( + encodeCallCount(calls, a), + 1, + `${factory.name}: A encoded only in first frame`, + ); + assert.equal(encodeCallCount(calls, c), 1, `${factory.name}: C encoded once`); + assert.equal( + encodeCallCount(calls, bText), + 2, + `${factory.name}: B should miss after clear`, + ); + }); + } + }); + + test("post-eviction re-encode still emits correct UTF-8 bytes", () => { + const a = "post-evict 😀 e\u0301"; + const bText = "other-é"; + const expectedLen = new TextEncoder().encode(a).byteLength; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 1 }); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame a1`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame b`); + b.reset(); + + b.drawText(0, 0, a); + const frameA2 = buildOk(b, `${factory.name} frame a2`); + + assert.equal(encodeCallCount(calls, a), 2, `${factory.name}: A should be re-encoded`); + assert.equal( + readDrawTextEntries(frameA2)[0]?.byteLen, + expectedLen, + `${factory.name}: byte_len`, + ); + assert.deepEqual(parseInternedStrings(frameA2), [a], `${factory.name}: decoded string`); + }); + } + }); + + test("reset/new frame semantics: string index restarts at 0 even when cache hits", () => { + const text = "index-reset-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 8 }); + + b.drawText(0, 0, text); + const frame1 = buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, text); + const frame2 = buildOk(b, `${factory.name} frame 2`); + + assert.equal(readDrawTextEntries(frame1)[0]?.stringId, 1, `${factory.name}: frame1 index`); + assert.equal(readDrawTextEntries(frame2)[0]?.stringId, 1, `${factory.name}: frame2 index`); + assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: cache hit across frames`); + }); + } + }); + + test("reset/new frame has no stale string table data", () => { + const first = "first-é"; + const second = "second-é"; + + for (const factory of FACTORIES) { + const b = factory.create({ encodedStringCacheCap: 8 }); + + b.drawText(0, 0, first); + const frame1 = buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, second); + const frame2 = buildOk(b, `${factory.name} frame 2`); + + assert.deepEqual(parseInternedStrings(frame1), [first], `${factory.name}: frame1 strings`); + assert.deepEqual(parseInternedStrings(frame2), [second], `${factory.name}: frame2 strings`); + } + }); + + test("within a frame, duplicate strings dedupe independently of cache state", () => { + const text = "intra-frame-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 0 }); + b.drawText(0, 0, text); + b.drawText(0, 1, text); + const frame = buildOk(b, `${factory.name} intra-frame`); + + const entries = readDrawTextEntries(frame); + assert.equal(entries.length, 2, `${factory.name}: two drawText commands`); + assert.equal(entries[0]?.stringId, 1, `${factory.name}: first id`); + assert.equal(entries[1]?.stringId, 1, `${factory.name}: duplicate id`); + assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: one encode within frame`); + }); + } + }); + + test("cap=0 fallback still preserves correct decoded output across frame changes", () => { + const first = "cap0-first-é"; + const second = "cap0-second-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 0 }); + + b.drawText(0, 0, first); + const frame1 = buildOk(b, `${factory.name} cap0 frame 1`); + b.reset(); + + b.drawText(0, 0, second); + const frame2 = buildOk(b, `${factory.name} cap0 frame 2`); + + assert.deepEqual(parseInternedStrings(frame1), [first], `${factory.name}: frame1 decode`); + assert.deepEqual(parseInternedStrings(frame2), [second], `${factory.name}: frame2 decode`); + assert.equal(encodeCallCount(calls, first), 1, `${factory.name}: first encoded once`); + assert.equal(encodeCallCount(calls, second), 1, `${factory.name}: second encoded once`); + }); + } }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts index 5f7e1660..90632d40 100644 --- a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts @@ -1,8 +1,7 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseDrawTextCommands, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; -const OP_DRAW_TEXT = 3; - type BuildResult = | Readonly<{ ok: true; bytes: Uint8Array }> | Readonly<{ ok: false; error: Readonly<{ code: string; detail: string }> }>; @@ -16,6 +15,7 @@ type BuilderLike = Readonly<{ type BuilderOpts = Readonly<{ maxStrings?: number; maxStringBytes?: number; + encodedStringCacheCap?: number; }>; const FACTORIES: readonly Readonly<{ @@ -23,82 +23,13 @@ const FACTORIES: readonly Readonly<{ create(opts?: BuilderOpts): BuilderLike; }>[] = [{ name: "current", create: (opts?: BuilderOpts) => createDrawlistBuilder(opts) }]; -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -type Header = Readonly<{ - cmdOffset: number; - cmdBytes: number; - cmdCount: number; - stringsSpanOffset: number; - stringsCount: number; - stringsBytesOffset: number; - stringsBytesLen: number; -}>; - -type DrawTextEntry = Readonly<{ stringIndex: number; byteOff: number; byteLen: number }>; - -function readHeader(bytes: Uint8Array): Header { - return { - cmdOffset: u32(bytes, 16), - cmdBytes: u32(bytes, 20), - cmdCount: u32(bytes, 24), - stringsSpanOffset: u32(bytes, 28), - stringsCount: u32(bytes, 32), - stringsBytesOffset: u32(bytes, 36), - stringsBytesLen: u32(bytes, 40), - }; -} - -function readArenaSpan(bytes: Uint8Array, h: Header): Readonly<{ off: number; len: number }> { - if (h.stringsCount === 0) return Object.freeze({ off: 0, len: 0 }); - return Object.freeze({ - off: u32(bytes, h.stringsSpanOffset), - len: u32(bytes, h.stringsSpanOffset + 4), - }); -} - -function decodeArenaSlice(bytes: Uint8Array, h: Header, byteOff: number, byteLen: number): string { - if (byteLen === 0) return ""; - assert.equal(h.stringsCount > 0, true, "arena span required when byteLen > 0"); - - const arena = readArenaSpan(bytes, h); - assert.equal(byteOff + byteLen <= arena.len, true, "arena slice bounds"); - - const start = h.stringsBytesOffset + arena.off + byteOff; - const end = start + byteLen; - return new TextDecoder().decode(bytes.subarray(start, end)); -} +type DrawTextEntry = Readonly<{ stringId: number; byteLen: number }>; function readDrawTextEntries(bytes: Uint8Array): DrawTextEntry[] { - const h = readHeader(bytes); - const out: DrawTextEntry[] = []; - - let off = h.cmdOffset; - for (let i = 0; i < h.cmdCount; i++) { - const opcode = u16(bytes, off + 0); - const size = u32(bytes, off + 4); - if (opcode === OP_DRAW_TEXT) { - out.push( - Object.freeze({ - stringIndex: u32(bytes, off + 16), - byteOff: u32(bytes, off + 20), - byteLen: u32(bytes, off + 24), - }), - ); - } - off += size; - } - - assert.equal(off, h.cmdOffset + h.cmdBytes, "command stream should end at cmdOffset + cmdBytes"); - return out; + return parseDrawTextCommands(bytes).map((cmd) => ({ + stringId: cmd.stringId, + byteLen: cmd.byteLen, + })); } function buildOk(builder: BuilderLike, label: string): Uint8Array { @@ -109,70 +40,92 @@ function buildOk(builder: BuilderLike, label: string): Uint8Array { return res.bytes; } -describe("drawlist text arena slices", () => { - test("duplicate strings emit distinct arena slices (no per-frame interning)", () => { +describe("drawlist string interning", () => { + test("duplicate strings share the same string table index", () => { for (const factory of FACTORIES) { const b = factory.create(); b.drawText(0, 0, "dup"); b.drawText(0, 1, "dup"); const bytes = buildOk(b, `${factory.name} duplicate strings`); - const h = readHeader(bytes); const drawText = readDrawTextEntries(bytes); + const strings = parseInternedStrings(bytes); - assert.equal(h.stringsCount, 1, `${factory.name}: one arena span`); assert.equal(drawText.length, 2, `${factory.name}: expected 2 drawText commands`); - assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first string index`); - assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: second string index`); - assert.equal(drawText[0]?.byteOff, 0, `${factory.name}: first byte off`); - assert.equal(drawText[1]?.byteOff, 3, `${factory.name}: second byte off`); - assert.equal(decodeArenaSlice(bytes, h, 0, 3), "dup", `${factory.name}: first decode`); - assert.equal(decodeArenaSlice(bytes, h, 3, 3), "dup", `${factory.name}: second decode`); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: first string id`); + assert.equal(drawText[1]?.stringId, 1, `${factory.name}: duplicate string id`); + assert.deepEqual(strings, ["dup"], `${factory.name}: string table should dedupe`); } }); - test("distinct strings get sequential arena slices", () => { + test("distinct strings get distinct indices", () => { for (const factory of FACTORIES) { const b = factory.create(); b.drawText(0, 0, "alpha"); b.drawText(0, 1, "beta"); const bytes = buildOk(b, `${factory.name} distinct strings`); - const h = readHeader(bytes); const drawText = readDrawTextEntries(bytes); + const strings = parseInternedStrings(bytes); + + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: alpha id`); + assert.equal(drawText[1]?.stringId, 2, `${factory.name}: beta id`); + assert.deepEqual(strings, ["alpha", "beta"], `${factory.name}: expected two strings`); + } + }); + + test("interning is based on text value only (style and coordinates do not matter)", () => { + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(10, 20, "same", { bold: true }); + b.drawText(-1, 999, "same", { underline: true, fg: { r: 1, g: 2, b: 3 } }); - assert.equal(drawText[0]?.byteOff, 0, `${factory.name}: alpha offset`); - assert.equal(drawText[0]?.byteLen, 5, `${factory.name}: alpha len`); - assert.equal(drawText[1]?.byteOff, 5, `${factory.name}: beta offset`); - assert.equal(drawText[1]?.byteLen, 4, `${factory.name}: beta len`); - assert.equal(decodeArenaSlice(bytes, h, 0, 5), "alpha", `${factory.name}: alpha decode`); - assert.equal(decodeArenaSlice(bytes, h, 5, 4), "beta", `${factory.name}: beta decode`); + const bytes = buildOk(b, `${factory.name} value-based interning`); + const drawText = readDrawTextEntries(bytes); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: first id`); + assert.equal(drawText[1]?.stringId, 1, `${factory.name}: second id`); + assert.deepEqual( + parseInternedStrings(bytes), + ["same"], + `${factory.name}: one interned string`, + ); } }); - test("empty string keeps zero-length slices and still emits an arena span", () => { + test("empty string interns once with zero byte length", () => { for (const factory of FACTORIES) { const b = factory.create(); b.drawText(0, 0, ""); b.drawText(0, 1, ""); const bytes = buildOk(b, `${factory.name} empty string`); - const h = readHeader(bytes); const drawText = readDrawTextEntries(bytes); - const arena = readArenaSpan(bytes, h); - - assert.equal(h.stringsCount, 1, `${factory.name}: empty text still has arena span`); - assert.equal(arena.off, 0, `${factory.name}: arena off`); - assert.equal(arena.len, 0, `${factory.name}: arena len`); - assert.equal(h.stringsBytesLen, 0, `${factory.name}: aligned bytes len`); - assert.equal(drawText[0]?.byteOff, 0, `${factory.name}: first byte off`); - assert.equal(drawText[1]?.byteOff, 0, `${factory.name}: second byte off`); - assert.equal(drawText[0]?.byteLen, 0, `${factory.name}: first byte len`); - assert.equal(drawText[1]?.byteLen, 0, `${factory.name}: second byte len`); + const strings = parseInternedStrings(bytes); + + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: first empty id`); + assert.equal(drawText[1]?.stringId, 1, `${factory.name}: second empty id`); + assert.equal(drawText[0]?.byteLen, 0, `${factory.name}: empty byte len in command`); + assert.deepEqual(strings, [""], `${factory.name}: one empty string in table`); } }); - test("unicode strings preserve UTF-8 byte lengths and decode from slices", () => { + test("very long strings (10k+) are interned and round-trip correctly", () => { + const longText = "L".repeat(10_123); + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, longText); + + const bytes = buildOk(b, `${factory.name} long string`); + const drawText = readDrawTextEntries(bytes); + const strings = parseInternedStrings(bytes); + + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: long string id`); + assert.equal(drawText[0]?.byteLen, longText.length, `${factory.name}: long byte len`); + assert.equal(strings[0], longText, `${factory.name}: long round-trip text`); + } + }); + + test("unicode string with emoji/combining marks/CJK round-trips with correct UTF-8 length", () => { const text = "emoji😀 + combining e\u0301 + CJK漢字"; const expectedByteLen = new TextEncoder().encode(text).byteLength; @@ -181,19 +134,15 @@ describe("drawlist text arena slices", () => { b.drawText(0, 0, text); const bytes = buildOk(b, `${factory.name} unicode round-trip`); - const h = readHeader(bytes); const drawText = readDrawTextEntries(bytes); + const strings = parseInternedStrings(bytes); assert.equal(drawText[0]?.byteLen, expectedByteLen, `${factory.name}: utf8 byte len`); - assert.equal( - decodeArenaSlice(bytes, h, drawText[0]?.byteOff ?? 0, drawText[0]?.byteLen ?? 0), - text, - `${factory.name}: unicode round-trip`, - ); + assert.equal(strings[0], text, `${factory.name}: unicode round-trip`); } }); - test("normalization variants are preserved as distinct slices", () => { + test("normalization variants are treated as distinct keys", () => { const nfc = "\u00E9"; const nfd = "e\u0301"; @@ -203,20 +152,41 @@ describe("drawlist text arena slices", () => { b.drawText(0, 1, nfd); const bytes = buildOk(b, `${factory.name} unicode normalization`); - const h = readHeader(bytes); const drawText = readDrawTextEntries(bytes); + const strings = parseInternedStrings(bytes); - const d0 = drawText[0]; - const d1 = drawText[1]; - if (!d0 || !d1) throw new Error("missing drawText entries"); + assert.equal(drawText[0]?.stringId, 1, `${factory.name}: nfc id`); + assert.equal(drawText[1]?.stringId, 2, `${factory.name}: nfd id`); + assert.deepEqual(strings, [nfc, nfd], `${factory.name}: both forms are preserved`); + } + }); - assert.equal(decodeArenaSlice(bytes, h, d0.byteOff, d0.byteLen), nfc, `${factory.name}: nfc`); - assert.equal(decodeArenaSlice(bytes, h, d1.byteOff, d1.byteLen), nfd, `${factory.name}: nfd`); - assert.equal(d0.byteOff !== d1.byteOff, true, `${factory.name}: distinct offsets`); + test("string table decode round-trips unique values in first-seen order", () => { + const input = ["", "hello", "😀", "漢字", "hello", "world", "😀", "e\u0301"]; + const expectedUnique = ["", "hello", "😀", "漢字", "world", "e\u0301"]; + const expectedIndices = [0, 1, 2, 3, 1, 4, 2, 5]; + + for (const factory of FACTORIES) { + const b = factory.create(); + for (let i = 0; i < input.length; i++) { + b.drawText(0, i, input[i] ?? ""); + } + + const bytes = buildOk(b, `${factory.name} round-trip decode`); + const drawText = readDrawTextEntries(bytes); + const actualIds = drawText.map((entry) => entry.stringId); + const actualIndices = actualIds.map((id) => id - 1); + + assert.deepEqual(actualIndices, expectedIndices, `${factory.name}: index assignment`); + assert.deepEqual( + parseInternedStrings(bytes), + expectedUnique, + `${factory.name}: unique decode`, + ); } }); - test("many strings preserve deterministic slice ordering", () => { + test("many unique strings produce sequential indices and full string table", () => { const unique = Array.from({ length: 256 }, (_, i) => `u-${i.toString().padStart(3, "0")}`); for (const factory of FACTORIES) { @@ -225,63 +195,56 @@ describe("drawlist text arena slices", () => { b.drawText(0, i, unique[i] ?? ""); } - const bytes = buildOk(b, `${factory.name} many strings`); - const h = readHeader(bytes); + const bytes = buildOk(b, `${factory.name} many unique strings`); const drawText = readDrawTextEntries(bytes); - assert.equal(drawText.length, unique.length, `${factory.name}: drawText count`); for (let i = 0; i < drawText.length; i++) { - const cmd = drawText[i]; - if (!cmd) continue; - assert.equal(cmd.stringIndex, 0, `${factory.name}: string index ${i}`); - assert.equal( - decodeArenaSlice(bytes, h, cmd.byteOff, cmd.byteLen), - unique[i], - `${factory.name}: decode ${i}`, - ); + assert.equal(drawText[i]?.stringId, i + 1, `${factory.name}: id ${i + 1}`); } + assert.deepEqual( + parseInternedStrings(bytes), + unique, + `${factory.name}: decoded string table`, + ); } }); - test("reset starts a new frame with a fresh arena", () => { + test("reset starts a new frame with a fresh string table and reindexed strings", () => { for (const factory of FACTORIES) { const b = factory.create(); b.drawText(0, 0, "first"); b.drawText(0, 1, "second"); - buildOk(b, `${factory.name} frame 1`); + const frame1 = buildOk(b, `${factory.name} frame 1`); b.reset(); b.drawText(0, 0, "second"); const frame2 = buildOk(b, `${factory.name} frame 2`); - const h2 = readHeader(frame2); - const entries = readDrawTextEntries(frame2); - const first = entries[0]; - if (!first) throw new Error("missing drawText entry"); + const frame1Ids = readDrawTextEntries(frame1).map((entry) => entry.stringId); + const frame2Ids = readDrawTextEntries(frame2).map((entry) => entry.stringId); - assert.equal(first.byteOff, 0, `${factory.name}: frame2 starts at offset 0`); - assert.equal(decodeArenaSlice(frame2, h2, first.byteOff, first.byteLen), "second"); + assert.deepEqual(frame1Ids, [1, 2], `${factory.name}: frame 1 ids`); + assert.deepEqual(frame2Ids, [1], `${factory.name}: frame 2 ids restart`); + assert.deepEqual( + parseInternedStrings(frame2), + ["second"], + `${factory.name}: no stale strings`, + ); } }); - test("maxStrings cap does not block transient arena text", () => { + test("maxStrings cap rejects too many unique interned strings", () => { for (const factory of FACTORIES) { - const b = factory.create({ maxStrings: 1 }); + const b = factory.create({ maxStrings: 3 }); b.drawText(0, 0, "a"); b.drawText(0, 1, "b"); + b.drawText(0, 2, "c"); + b.drawText(0, 3, "d"); const res = b.build(); - assert.equal(res.ok, true, `${factory.name}: transient text bypasses maxStrings`); - } - }); - - test("maxStringBytes cap does not block transient arena text", () => { - for (const factory of FACTORIES) { - const b = factory.create({ maxStringBytes: 1 }); - b.drawText(0, 0, "ab"); - - const res = b.build(); - assert.equal(res.ok, true, `${factory.name}: transient text bypasses maxStringBytes`); + assert.equal(res.ok, false, `${factory.name}: should fail when maxStrings exceeded`); + if (res.ok) continue; + assert.equal(res.error.code, "ZRDL_TOO_LARGE", `${factory.name}: error code`); } }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts index 37bfbc07..d8e4a63a 100644 --- a/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.style-encoding.test.ts @@ -1,32 +1,48 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + parseBlobById, + parseCommandHeaders, +} from "../../__tests__/drawlistDecode.js"; import { type TextStyle, createDrawlistBuilder } from "../../index.js"; import { DEFAULT_BASE_STYLE, mergeTextStyle } from "../../renderer/renderToDrawlist/textStyle.js"; -import { DRAW_TEXT_SIZE } from "../writers.gen.js"; function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint32(off, true); } -function firstCommandOffset(bytes: Uint8Array): number { - return u32(bytes, 16); +function firstOpcodeOffset(bytes: Uint8Array, opcode: number): number { + const cmd = parseCommandHeaders(bytes).find((entry) => entry.opcode === opcode); + assert.equal(cmd !== undefined, true, `missing opcode ${String(opcode)}`); + if (!cmd) return 0; + return cmd.offset; } function drawTextFg(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 28); + return u32(bytes, firstOpcodeOffset(bytes, OP_DRAW_TEXT) + 28); } function drawTextBg(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 32); + return u32(bytes, firstOpcodeOffset(bytes, OP_DRAW_TEXT) + 32); } function drawTextAttrs(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 36); + return u32(bytes, firstOpcodeOffset(bytes, OP_DRAW_TEXT) + 36); +} + +function firstTextRunBlob(bytes: Uint8Array): Uint8Array { + const drawTextRunOff = firstOpcodeOffset(bytes, OP_DRAW_TEXT_RUN); + const blobId = u32(bytes, drawTextRunOff + 16); + const blob = parseBlobById(bytes, blobId); + assert.equal(blob !== null, true, `missing blob id ${String(blobId)} for DRAW_TEXT_RUN`); + return blob ?? new Uint8Array(); } function textRunField(bytes: Uint8Array, segmentIndex: number, fieldOffset: number): number { - const blobsBytesOffset = u32(bytes, 52); - return u32(bytes, blobsBytesOffset + 4 + segmentIndex * 28 + fieldOffset); + const blob = firstTextRunBlob(bytes); + return u32(blob, 4 + segmentIndex * 40 + fieldOffset); } function textRunFg(bytes: Uint8Array, segmentIndex: number): number { @@ -278,14 +294,16 @@ describe("style merge stress encodes fg/bg/attrs bytes deterministically", () => assert.equal(res.ok, true); if (!res.ok) throw new Error("build failed"); - const cmdOffset = u32(res.bytes, 16); - const cmdCount = u32(res.bytes, 24); - assert.equal(cmdCount, expected.length); + const drawTextCommands = parseCommandHeaders(res.bytes).filter( + (cmd) => cmd.opcode === OP_DRAW_TEXT, + ); + assert.equal(drawTextCommands.length, expected.length); for (let i = 0; i < expected.length; i++) { const exp = expected[i]; - if (!exp) continue; - const off = cmdOffset + i * DRAW_TEXT_SIZE; + const cmd = drawTextCommands[i]; + if (!exp || !cmd) continue; + const off = cmd.offset; assert.equal(u32(res.bytes, off + 28), exp.fg, `fg mismatch at cmd #${String(i)}`); assert.equal(u32(res.bytes, off + 32), exp.bg, `bg mismatch at cmd #${String(i)}`); assert.equal(u32(res.bytes, off + 36), exp.attrs, `attrs mismatch at cmd #${String(i)}`); diff --git a/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts b/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts index fbf60501..32c52ec5 100644 --- a/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.text-arena.equivalence.test.ts @@ -4,6 +4,8 @@ import { createDrawlistBuilder } from "../../index.js"; const OP_CLEAR = 1; const OP_DRAW_TEXT = 3; const OP_DRAW_TEXT_RUN = 6; +const OP_DEF_STRING = 10; +const OP_DEF_BLOB = 12; const TEXT_RUN_SEGMENT_SIZE = 40; type Op = @@ -24,6 +26,11 @@ type Header = Readonly<{ blobsBytesLen: number; }>; +type ResourceTables = Readonly<{ + stringsById: ReadonlyMap; + blobsById: ReadonlyMap; +}>; + function u16(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint16(off, true); @@ -87,24 +94,64 @@ function gridToLines(grid: string[][]): readonly string[] { return Object.freeze(grid.map((row) => row.join(""))); } +function collectResourceTables(bytes: Uint8Array, h: Header): ResourceTables { + const stringsById = new Map(); + const blobsById = new Map(); + + const cmdEnd = h.cmdOffset + h.cmdBytes; + let off = h.cmdOffset; + for (let i = 0; i < h.cmdCount; i++) { + assert.equal(off < cmdEnd, true, "command cursor in bounds"); + const opcode = u16(bytes, off); + const size = u32(bytes, off + 4); + assert.equal(size >= 8, true, "command size includes header"); + + if (opcode === OP_DEF_STRING || opcode === OP_DEF_BLOB) { + const id = u32(bytes, off + 8); + const byteLen = u32(bytes, off + 12); + assert.equal(16 + byteLen <= size, true, "resource payload in bounds"); + const payloadStart = off + 16; + const payloadEnd = payloadStart + byteLen; + const payload = bytes.slice(payloadStart, payloadEnd); + if (opcode === OP_DEF_STRING) { + stringsById.set(id, payload); + } else { + blobsById.set(id, payload); + } + } + + off += size; + } + + assert.equal(off, cmdEnd, "command stream fully consumed"); + return Object.freeze({ stringsById, blobsById }); +} + function decodeTextSlice( bytes: Uint8Array, h: Header, + resources: ResourceTables, stringIndex: number, byteOff: number, byteLen: number, ): string { if (byteLen === 0) return ""; - assert.equal(stringIndex >= 0 && stringIndex < h.stringsCount, true, "string index in bounds"); - - const span = h.stringsSpanOffset + stringIndex * 8; - const spanOff = u32(bytes, span); - const spanLen = u32(bytes, span + 4); - assert.equal(byteOff + byteLen <= spanLen, true, "string slice in bounds"); + if (h.stringsCount > 0) { + assert.equal(stringIndex >= 0 && stringIndex < h.stringsCount, true, "string index in bounds"); + const span = h.stringsSpanOffset + stringIndex * 8; + const spanOff = u32(bytes, span); + const spanLen = u32(bytes, span + 4); + assert.equal(byteOff + byteLen <= spanLen, true, "string slice in bounds"); + const start = h.stringsBytesOffset + spanOff + byteOff; + const end = start + byteLen; + return new TextDecoder().decode(bytes.subarray(start, end)); + } - const start = h.stringsBytesOffset + spanOff + byteOff; - const end = start + byteLen; - return new TextDecoder().decode(bytes.subarray(start, end)); + const payload = resources.stringsById.get(stringIndex); + assert.equal(payload !== undefined, true, "string id in bounds"); + if (!payload) return ""; + assert.equal(byteOff + byteLen <= payload.byteLength, true, "string slice in bounds"); + return new TextDecoder().decode(payload.subarray(byteOff, byteOff + byteLen)); } function executeDrawlistToGrid( @@ -113,6 +160,7 @@ function executeDrawlistToGrid( height: number, ): readonly string[] { const h = readHeader(bytes); + const resources = collectResourceTables(bytes, h); const grid = createGrid(width, height); const cmdEnd = h.cmdOffset + h.cmdBytes; @@ -130,29 +178,43 @@ function executeDrawlistToGrid( const stringIndex = u32(bytes, off + 16); const byteOff = u32(bytes, off + 20); const byteLen = u32(bytes, off + 24); - const text = decodeTextSlice(bytes, h, stringIndex, byteOff, byteLen); + const text = decodeTextSlice(bytes, h, resources, stringIndex, byteOff, byteLen); writeText(grid, x, y, text); } else if (opcode === OP_DRAW_TEXT_RUN) { const x = i32(bytes, off + 8); const y = i32(bytes, off + 12); const blobIndex = u32(bytes, off + 16); - assert.equal(blobIndex < h.blobsCount, true, "blob index in bounds"); - const blobSpan = h.blobsSpanOffset + blobIndex * 8; - const blobOff = h.blobsBytesOffset + u32(bytes, blobSpan); - const blobLen = u32(bytes, blobSpan + 4); - const blobEnd = blobOff + blobLen; - const segCount = u32(bytes, blobOff); + let blobBytes: Uint8Array; + if (h.blobsCount > 0) { + assert.equal(blobIndex < h.blobsCount, true, "blob index in bounds"); + const blobSpan = h.blobsSpanOffset + blobIndex * 8; + const blobOff = h.blobsBytesOffset + u32(bytes, blobSpan); + const blobLen = u32(bytes, blobSpan + 4); + blobBytes = bytes.subarray(blobOff, blobOff + blobLen); + } else { + const payload = resources.blobsById.get(blobIndex); + assert.equal(payload !== undefined, true, "blob id in bounds"); + if (!payload) { + off += size; + continue; + } + blobBytes = payload; + } + + const blobDv = new DataView(blobBytes.buffer, blobBytes.byteOffset, blobBytes.byteLength); + const blobLen = blobBytes.byteLength; + const segCount = blobDv.getUint32(0, true); let cursor = x; for (let seg = 0; seg < segCount; seg++) { - const segOff = blobOff + 4 + seg * TEXT_RUN_SEGMENT_SIZE; - assert.equal(segOff + TEXT_RUN_SEGMENT_SIZE <= blobEnd, true, "segment in bounds"); + const segOff = 4 + seg * TEXT_RUN_SEGMENT_SIZE; + assert.equal(segOff + TEXT_RUN_SEGMENT_SIZE <= blobLen, true, "segment in bounds"); - const stringIndex = u32(bytes, segOff + 28); - const byteOff = u32(bytes, segOff + 32); - const byteLen = u32(bytes, segOff + 36); - const text = decodeTextSlice(bytes, h, stringIndex, byteOff, byteLen); + const stringIndex = blobDv.getUint32(segOff + 28, true); + const byteOff = blobDv.getUint32(segOff + 32, true); + const byteLen = blobDv.getUint32(segOff + 36, true); + const text = decodeTextSlice(bytes, h, resources, stringIndex, byteOff, byteLen); cursor = writeText(grid, cursor, y, text); } } @@ -363,25 +425,64 @@ describe("drawlist text arena equivalence", () => { assert.equal(counters.textSegments, segmentCount); const h = readHeader(built.bytes); - assert.equal(h.stringsCount >= 1, true, "arena span present"); - const arenaLen = u32(built.bytes, h.stringsSpanOffset + 4); - - assert.equal(h.blobsCount, 1, "one blob"); - const blobOff = h.blobsBytesOffset + u32(built.bytes, h.blobsSpanOffset); - const blobLen = u32(built.bytes, h.blobsSpanOffset + 4); - const blobEnd = blobOff + blobLen; + const resources = collectResourceTables(built.bytes, h); + const hasArenaSpan = h.stringsCount >= 1; + const hasDefStrings = resources.stringsById.size >= 1; + assert.equal(hasArenaSpan || hasDefStrings, true, "arena span present"); + + let blobBytes: Uint8Array | null = null; + if (h.blobsCount > 0) { + assert.equal(h.blobsCount, 1, "one blob"); + const blobOff = h.blobsBytesOffset + u32(built.bytes, h.blobsSpanOffset); + const blobLen = u32(built.bytes, h.blobsSpanOffset + 4); + blobBytes = built.bytes.subarray(blobOff, blobOff + blobLen); + } else { + assert.equal(resources.blobsById.size, 1, "one blob"); + const first = resources.blobsById.values().next(); + blobBytes = first.done ? null : first.value; + } + assert.ok(blobBytes !== null, "blob bytes present"); + if (!blobBytes) return; - const segCount = u32(built.bytes, blobOff); + const blobDv = new DataView(blobBytes.buffer, blobBytes.byteOffset, blobBytes.byteLength); + const blobLen = blobBytes.byteLength; + const segCount = blobDv.getUint32(0, true); assert.equal(segCount, segmentCount); for (let i = 0; i < segmentCount; i++) { - const segOff = blobOff + 4 + i * TEXT_RUN_SEGMENT_SIZE; - assert.equal(segOff + TEXT_RUN_SEGMENT_SIZE <= blobEnd, true, "segment bounds"); - const byteOff = u32(built.bytes, segOff + 32); - const byteLen = u32(built.bytes, segOff + 36); - assert.equal(byteOff + byteLen <= arenaLen, true, "arena slice bounds"); + const segOff = 4 + i * TEXT_RUN_SEGMENT_SIZE; + assert.equal(segOff + TEXT_RUN_SEGMENT_SIZE <= blobLen, true, "segment bounds"); + const stringIndex = blobDv.getUint32(segOff + 28, true); + const byteOff = blobDv.getUint32(segOff + 32, true); + const byteLen = blobDv.getUint32(segOff + 36, true); + if (h.stringsCount > 0) { + const span = h.stringsSpanOffset + stringIndex * 8; + const spanLen = u32(built.bytes, span + 4); + assert.equal(byteOff + byteLen <= spanLen, true, "arena slice bounds"); + } else { + const payload = resources.stringsById.get(stringIndex); + assert.equal(payload !== undefined, true, "segment string id in bounds"); + if (!payload) continue; + assert.equal(byteOff + byteLen <= payload.byteLength, true, "arena slice bounds"); + } } }); + test("text perf counters report TextEncoder calls for unique non-ASCII strings", () => { + const b = createDrawlistBuilder(); + b.drawText(0, 0, "α0"); + b.drawText(0, 1, "α1"); + + const built = b.build(); + assert.equal(built.ok, true, "build should succeed"); + if (!built.ok) return; + + const counters = b.getTextPerfCounters?.(); + assert.ok(counters !== undefined, "counters should exist"); + if (!counters) return; + assert.equal(counters.textArenaBytes > 0, true); + assert.equal(counters.textEncoderCalls >= 2, true); + }); + test("property: random text + random slicing matches baseline framebuffer", () => { const width = 48; const height = 16; diff --git a/packages/core/src/drawlist/__tests__/builder.text-run.test.ts b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts index 3d6df05d..326d307a 100644 --- a/packages/core/src/drawlist/__tests__/builder.text-run.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.text-run.test.ts @@ -1,11 +1,15 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_CLEAR, + OP_DEF_BLOB, + OP_DEF_STRING, + OP_DRAW_TEXT_RUN, + parseBlobById, + parseCommandHeaders, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1, createDrawlistBuilder } from "../../index.js"; -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint32(off, true); @@ -17,7 +21,7 @@ function i32(bytes: Uint8Array, off: number): number { } describe("DrawlistBuilder (ZRDL v1) - DRAW_TEXT_RUN", () => { - test("emits blob span + DRAW_TEXT_RUN command referencing it", () => { + test("emits DEF_STRING/DEF_BLOB resources and DRAW_TEXT_RUN references blob id", () => { const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ @@ -36,76 +40,60 @@ describe("DrawlistBuilder (ZRDL v1) - DRAW_TEXT_RUN", () => { const bytes = res.bytes; - // Header fields (see docs-user/abi/drawlist-v1.md) assert.equal(u32(bytes, 0), ZRDL_MAGIC); assert.equal(u32(bytes, 4), ZR_DRAWLIST_VERSION_V1); assert.equal(u32(bytes, 8), 64); - assert.equal(u32(bytes, 12), 204); - assert.equal(u32(bytes, 16), 64); // cmd_offset - assert.equal(u32(bytes, 20), 32); // cmd_bytes - assert.equal(u32(bytes, 24), 2); // cmd_count - assert.equal(u32(bytes, 28), 96); // strings_span_offset - assert.equal(u32(bytes, 32), 1); // strings_count (arena span only) - assert.equal(u32(bytes, 36), 104); // strings_bytes_offset - assert.equal(u32(bytes, 40), 8); // strings_bytes_len (4-byte aligned) - assert.equal(u32(bytes, 44), 112); // blobs_span_offset - assert.equal(u32(bytes, 48), 1); // blobs_count - assert.equal(u32(bytes, 52), 120); // blobs_bytes_offset - assert.equal(u32(bytes, 56), 84); // blobs_bytes_len - assert.equal(u32(bytes, 60), 0); // reserved0 - - // Command 0: CLEAR at offset 64 - assert.equal(u16(bytes, 64 + 0), 1); - assert.equal(u16(bytes, 64 + 2), 0); - assert.equal(u32(bytes, 64 + 4), 8); - - // Command 1: DRAW_TEXT_RUN at offset 72 - assert.equal(u16(bytes, 72 + 0), 6); - assert.equal(u16(bytes, 72 + 2), 0); - assert.equal(u32(bytes, 72 + 4), 24); - assert.equal(i32(bytes, 72 + 8), 1); // x - assert.equal(i32(bytes, 72 + 12), 2); // y - assert.equal(u32(bytes, 72 + 16), 0); // blob_index - assert.equal(u32(bytes, 72 + 20), 0); // reserved0 - - // String spans: one arena entry at offset 96 - assert.equal(u32(bytes, 96 + 0), 0); - assert.equal(u32(bytes, 96 + 4), 6); - - // String bytes: arena "ABCDEF" at offset 104 (padded to 4-byte alignment). - assert.equal(String.fromCharCode(...bytes.subarray(104, 110)), "ABCDEF"); - - // Blob span: single entry at offset 112 - assert.equal(u32(bytes, 112 + 0), 0); - assert.equal(u32(bytes, 112 + 4), 84); - - // Blob bytes: seg_count=2 + two segments (40 bytes each) - const blobOff = 120; - assert.equal(u32(bytes, blobOff + 0), 2); - - // Segment 0 - assert.equal(u32(bytes, blobOff + 4 + 0), 0x00ff0000); // fg - assert.equal(u32(bytes, blobOff + 4 + 4), 0); // bg - assert.equal(u32(bytes, blobOff + 4 + 8), 1); // attrs (bold) - assert.equal(u32(bytes, blobOff + 4 + 12), 0); // reserved0 - assert.equal(u32(bytes, blobOff + 4 + 16), 0); // underline_rgb - assert.equal(u32(bytes, blobOff + 4 + 20), 0); // link_uri_ref - assert.equal(u32(bytes, blobOff + 4 + 24), 0); // link_id_ref - assert.equal(u32(bytes, blobOff + 4 + 28), 0); // string_index - assert.equal(u32(bytes, blobOff + 4 + 32), 0); // byte_off - assert.equal(u32(bytes, blobOff + 4 + 36), 3); // byte_len - - // Segment 1 - const seg1 = blobOff + 4 + 40; - assert.equal(u32(bytes, seg1 + 0), 0x0000ff00); // fg - assert.equal(u32(bytes, seg1 + 4), 0); // bg - assert.equal(u32(bytes, seg1 + 8), 1 << 2); // attrs (underline) - assert.equal(u32(bytes, seg1 + 12), 0); // reserved0 - assert.equal(u32(bytes, seg1 + 16), 0); // underline_rgb - assert.equal(u32(bytes, seg1 + 20), 0); // link_uri_ref - assert.equal(u32(bytes, seg1 + 24), 0); // link_id_ref - assert.equal(u32(bytes, seg1 + 28), 0); // string_index - assert.equal(u32(bytes, seg1 + 32), 3); // byte_off - assert.equal(u32(bytes, seg1 + 36), 3); // byte_len + assert.equal(u32(bytes, 12), bytes.byteLength); + assert.equal(u32(bytes, 16), 64); + assert.equal(u32(bytes, 24), 5); + assert.equal(u32(bytes, 28), 0); + assert.equal(u32(bytes, 32), 0); + assert.equal(u32(bytes, 44), 0); + assert.equal(u32(bytes, 48), 0); + + const headers = parseCommandHeaders(bytes); + assert.deepEqual( + headers.map((h) => h.opcode), + [OP_DEF_STRING, OP_DEF_STRING, OP_DEF_BLOB, OP_CLEAR, OP_DRAW_TEXT_RUN], + ); + + const drawTextRun = headers.find((h) => h.opcode === OP_DRAW_TEXT_RUN); + assert.equal(drawTextRun !== undefined, true); + if (!drawTextRun) return; + assert.equal(i32(bytes, drawTextRun.offset + 8), 1); + assert.equal(i32(bytes, drawTextRun.offset + 12), 2); + assert.equal(u32(bytes, drawTextRun.offset + 16), 1); + assert.equal(u32(bytes, drawTextRun.offset + 20), 0); + + assert.deepEqual(parseInternedStrings(bytes), ["ABC", "DEF"]); + + const blob = parseBlobById(bytes, 1); + assert.equal(blob !== null, true); + if (!blob) return; + assert.equal(u32(blob, 0), 2); + + const seg0 = 4; + assert.equal(u32(blob, seg0 + 0), 0x00ff_0000); + assert.equal(u32(blob, seg0 + 4), 0); + assert.equal(u32(blob, seg0 + 8), 1); + assert.equal(u32(blob, seg0 + 12), 0); + assert.equal(u32(blob, seg0 + 16), 0); + assert.equal(u32(blob, seg0 + 20), 0); + assert.equal(u32(blob, seg0 + 24), 0); + assert.equal(u32(blob, seg0 + 28), 1); + assert.equal(u32(blob, seg0 + 32), 0); + assert.equal(u32(blob, seg0 + 36), 3); + + const seg1 = seg0 + 40; + assert.equal(u32(blob, seg1 + 0), 0x0000_ff00); + assert.equal(u32(blob, seg1 + 4), 0); + assert.equal(u32(blob, seg1 + 8), 1 << 2); + assert.equal(u32(blob, seg1 + 12), 0); + assert.equal(u32(blob, seg1 + 16), 0); + assert.equal(u32(blob, seg1 + 20), 0); + assert.equal(u32(blob, seg1 + 24), 0); + assert.equal(u32(blob, seg1 + 28), 2); + assert.equal(u32(blob, seg1 + 32), 0); + assert.equal(u32(blob, seg1 + 36), 3); }); }); diff --git a/packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts b/packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts index 8e69071d..9f532452 100644 --- a/packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts +++ b/packages/core/src/drawlist/__tests__/builder.validate-caps.test.ts @@ -75,12 +75,10 @@ describe("DrawlistBuilder (ZRDL v1) - validation and caps", () => { assert.equal(res2.ok, true); }); - test("cap: maxStrings (persistent strings) -> ZRDL_TOO_LARGE (and reset restores usability)", () => { + test("cap: maxStrings -> ZRDL_TOO_LARGE (and reset restores usability)", () => { const b = createDrawlistBuilder({ maxStrings: 1 }); - b.setLink("a"); - b.drawText(0, 0, "x"); - b.setLink("b"); - b.drawText(0, 1, "y"); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); const res = b.build(); assert.equal(res.ok, false); @@ -88,16 +86,14 @@ describe("DrawlistBuilder (ZRDL v1) - validation and caps", () => { assert.equal(res.error.code, "ZRDL_TOO_LARGE"); b.reset(); - b.setLink("a"); - b.drawText(0, 0, "x"); + b.drawText(0, 0, "a"); const res2 = b.build(); assert.equal(res2.ok, true); }); - test("cap: maxStringBytes (persistent strings) -> ZRDL_TOO_LARGE (and reset restores usability)", () => { + test("cap: maxStringBytes -> ZRDL_TOO_LARGE (and reset restores usability)", () => { const b = createDrawlistBuilder({ maxStringBytes: 1 }); - b.setLink("ab"); - b.drawText(0, 0, "x"); + b.drawText(0, 0, "ab"); // 2 bytes in UTF-8 const res = b.build(); assert.equal(res.ok, false); @@ -105,8 +101,7 @@ describe("DrawlistBuilder (ZRDL v1) - validation and caps", () => { assert.equal(res.error.code, "ZRDL_TOO_LARGE"); b.reset(); - b.setLink("a"); - b.drawText(0, 0, "x"); + b.drawText(0, 0, "a"); const res2 = b.build(); assert.equal(res2.ok, true); }); @@ -125,24 +120,4 @@ describe("DrawlistBuilder (ZRDL v1) - validation and caps", () => { const res2 = b.build(); assert.equal(res2.ok, true); }); - - test("cap: reserveTextArena preflights maxDrawlistBytes before allocating", () => { - const b = createDrawlistBuilder({ maxDrawlistBytes: 128 }); - b.reserveTextArena?.(10_000_000); - - const res = b.build(); - assert.equal(res.ok, false); - if (res.ok) return; - assert.equal(res.error.code, "ZRDL_TOO_LARGE"); - }); - - test("cap: oversized transient drawText fails with ZRDL_TOO_LARGE", () => { - const b = createDrawlistBuilder({ maxDrawlistBytes: 128 }); - b.drawText(0, 0, "x".repeat(10_000)); - - const res = b.build(); - assert.equal(res.ok, false); - if (res.ok) return; - assert.equal(res.error.code, "ZRDL_TOO_LARGE"); - }); }); diff --git a/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts b/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts index 69500a66..0740ff4a 100644 --- a/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_style_attrs.test.ts @@ -1,4 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + parseBlobById, + parseCommandHeaders, +} from "../../__tests__/drawlistDecode.js"; import { createDrawlistBuilder } from "../../index.js"; function u32(bytes: Uint8Array, off: number): number { @@ -7,16 +13,25 @@ function u32(bytes: Uint8Array, off: number): number { } function textRunAttrs(bytes: Uint8Array, segmentIndex: number): number { - const blobsBytesOffset = u32(bytes, 52); - return u32(bytes, blobsBytesOffset + 4 + segmentIndex * 40 + 8); + const drawTextRun = parseCommandHeaders(bytes).find((cmd) => cmd.opcode === OP_DRAW_TEXT_RUN); + assert.equal(drawTextRun !== undefined, true); + if (!drawTextRun) return 0; + const blobId = u32(bytes, drawTextRun.offset + 16); + const blob = parseBlobById(bytes, blobId); + assert.equal(blob !== null, true); + if (!blob) return 0; + return u32(blob, 4 + segmentIndex * 40 + 8); } -function firstCommandOffset(bytes: Uint8Array): number { - return u32(bytes, 16); +function firstDrawTextOffset(bytes: Uint8Array): number { + const drawText = parseCommandHeaders(bytes).find((cmd) => cmd.opcode === OP_DRAW_TEXT); + assert.equal(drawText !== undefined, true); + if (!drawText) return 0; + return drawText.offset; } function drawTextAttrs(bytes: Uint8Array): number { - return u32(bytes, firstCommandOffset(bytes) + 36); + return u32(bytes, firstDrawTextOffset(bytes) + 36); } describe("drawlist style attrs encode dim", () => { diff --git a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts index 712dd74b..4a4a38bc 100644 --- a/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts +++ b/packages/core/src/drawlist/__tests__/builder_v6_resources.test.ts @@ -1,6 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { createDrawlistBuilder } from "../../index.js"; +function u32(bytes: Uint8Array, off: number): number { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(off, true); +} + function expectOk( result: ReturnType["build"]>, ): Uint8Array { @@ -22,6 +26,29 @@ describe("DrawlistBuilder resource inputs", () => { assert.equal(bytes.byteLength > 0, true); }); + test("keeps DEF_BLOB byteLen/data exact for non-4-byte blob payloads", () => { + const b = createDrawlistBuilder(); + const payload = new Uint8Array([0xde, 0xad, 0xbe]); + const blobIndex = b.addBlob(payload); + assert.equal(blobIndex, 0); + if (blobIndex === null) throw new Error("missing blob index"); + + const bytes = expectOk(b.build()); + const cmdOffset = u32(bytes, 16); + const cmdCount = u32(bytes, 24); + assert.equal(cmdCount >= 1, true); + + assert.equal(bytes[cmdOffset], 12); // DEF_BLOB + assert.equal(u32(bytes, cmdOffset + 4), 20); // 16-byte header + 3-byte payload + 1 pad + assert.equal(u32(bytes, cmdOffset + 8), 1); + assert.equal(u32(bytes, cmdOffset + 12), 3); + assert.deepEqual( + Array.from(bytes.subarray(cmdOffset + 16, cmdOffset + 19)), + [0xde, 0xad, 0xbe], + ); + assert.equal(bytes[cmdOffset + 19], 0); + }); + test("accepts text-run blobs", () => { const b = createDrawlistBuilder(); const blobIndex = b.addTextRunBlob([ diff --git a/packages/core/src/drawlist/__tests__/writers.gen.test.ts b/packages/core/src/drawlist/__tests__/writers.gen.test.ts index 95d021e6..6bafbfcb 100644 --- a/packages/core/src/drawlist/__tests__/writers.gen.test.ts +++ b/packages/core/src/drawlist/__tests__/writers.gen.test.ts @@ -4,6 +4,8 @@ import type { EncodedStyle } from "../types.js"; import { BLIT_RECT_SIZE, CLEAR_SIZE, + DEF_BLOB_BASE_SIZE, + DEF_STRING_BASE_SIZE, DRAW_CANVAS_SIZE, DRAW_IMAGE_SIZE, DRAW_TEXT_RUN_SIZE, @@ -14,6 +16,8 @@ import { SET_CURSOR_SIZE, writeBlitRect, writeClear, + writeDefBlob, + writeDefString, writeDrawCanvas, writeDrawImage, writeDrawText, @@ -321,7 +325,11 @@ function buildReferenceDrawlist(): Uint8Array { const stringBytes = new TextEncoder().encode("OK"); const blobBytes = new Uint8Array([1, 2, 3, 4]); + const defStringSize = align4(DEF_STRING_BASE_SIZE + stringBytes.byteLength); + const defBlobSize = align4(DEF_BLOB_BASE_SIZE + blobBytes.byteLength); const cmdBytes = + defStringSize + + defBlobSize + CLEAR_SIZE + FILL_RECT_SIZE + DRAW_TEXT_SIZE + @@ -331,19 +339,8 @@ function buildReferenceDrawlist(): Uint8Array { DRAW_IMAGE_SIZE + PUSH_CLIP_SIZE + POP_CLIP_SIZE; - const stringsCount = 1; - const stringsSpanBytes = stringsCount * 8; - const stringsBytesLen = align4(stringBytes.byteLength); - const blobsCount = 1; - const blobsSpanBytes = blobsCount * 8; - const blobsBytesLen = align4(blobBytes.byteLength); - const cmdOffset = HEADER_SIZE; - const stringsSpanOffset = cmdOffset + cmdBytes; - const stringsBytesOffset = stringsSpanOffset + stringsSpanBytes; - const blobsSpanOffset = stringsBytesOffset + stringsBytesLen; - const blobsBytesOffset = blobsSpanOffset + blobsSpanBytes; - const totalSize = blobsBytesOffset + blobsBytesLen; + const totalSize = cmdOffset + cmdBytes; const out = new Uint8Array(totalSize); const dv = view(out); @@ -354,57 +351,31 @@ function buildReferenceDrawlist(): Uint8Array { dv.setUint32(12, totalSize, true); dv.setUint32(16, cmdOffset, true); dv.setUint32(20, cmdBytes, true); - dv.setUint32(24, 9, true); - dv.setUint32(28, stringsSpanOffset, true); - dv.setUint32(32, stringsCount, true); - dv.setUint32(36, stringsBytesOffset, true); - dv.setUint32(40, stringsBytesLen, true); - dv.setUint32(44, blobsSpanOffset, true); - dv.setUint32(48, blobsCount, true); - dv.setUint32(52, blobsBytesOffset, true); - dv.setUint32(56, blobsBytesLen, true); + dv.setUint32(24, 11, true); + dv.setUint32(28, 0, true); + dv.setUint32(32, 0, true); + dv.setUint32(36, 0, true); + dv.setUint32(40, 0, true); + dv.setUint32(44, 0, true); + dv.setUint32(48, 0, true); + dv.setUint32(52, 0, true); + dv.setUint32(56, 0, true); dv.setUint32(60, 0, true); let pos = cmdOffset; + pos = writeDefString(out, dv, pos, 1, stringBytes.byteLength, stringBytes); + pos = writeDefBlob(out, dv, pos, 1, blobBytes.byteLength, blobBytes); pos = legacyWriteClear(out, dv, pos); pos = legacyWriteFillRect(out, dv, pos, 1, 2, 3, 4, ZERO_STYLE); - pos = legacyWriteDrawText(out, dv, pos, 5, 6, 0, 0, stringBytes.byteLength, ZERO_STYLE, 0); - pos = legacyWriteDrawTextRun(out, dv, pos, 7, 8, 0, 0); + pos = legacyWriteDrawText(out, dv, pos, 5, 6, 1, 0, stringBytes.byteLength, ZERO_STYLE, 0); + pos = legacyWriteDrawTextRun(out, dv, pos, 7, 8, 1, 0); pos = legacyWriteSetCursor(out, dv, pos, 9, 10, 2, 1, 0, 0); - pos = legacyWriteDrawCanvas(out, dv, pos, 11, 12, 1, 1, 1, 1, 0, blobBytes.byteLength, 6, 0, 0); - pos = legacyWriteDrawImage( - out, - dv, - pos, - 13, - 14, - 1, - 1, - 1, - 1, - 0, - blobBytes.byteLength, - 99, - 0, - 1, - -1, - 1, - 0, - 0, - 0, - ); + pos = legacyWriteDrawCanvas(out, dv, pos, 11, 12, 1, 1, 1, 1, 1, 0, 6, 0, 0); + pos = legacyWriteDrawImage(out, dv, pos, 13, 14, 1, 1, 1, 1, 1, 0, 99, 0, 1, -1, 1, 0, 0, 0); pos = legacyWritePushClip(out, dv, pos, 0, 0, 20, 10); pos = legacyWritePopClip(out, dv, pos); assert.equal(pos, cmdOffset + cmdBytes); - dv.setUint32(stringsSpanOffset + 0, 0, true); - dv.setUint32(stringsSpanOffset + 4, stringBytes.byteLength, true); - out.set(stringBytes, stringsBytesOffset); - - dv.setUint32(blobsSpanOffset + 0, 0, true); - dv.setUint32(blobsSpanOffset + 4, blobBytes.byteLength, true); - out.set(blobBytes, blobsBytesOffset); - return out; } @@ -680,12 +651,14 @@ describe("writers.gen - round trip integration", () => { assert.equal(u32(bytes, 4), ZR_DRAWLIST_VERSION_V1); assert.equal(u32(bytes, 8), HEADER_SIZE); assert.equal(u32(bytes, 12), bytes.byteLength); - assert.equal(u32(bytes, 24), 9); + assert.equal(u32(bytes, 24), 11); const cmds = parseCommands(bytes); assert.deepEqual( cmds.map((c) => c.size), [ + align4(DEF_STRING_BASE_SIZE + 2), + align4(DEF_BLOB_BASE_SIZE + 4), CLEAR_SIZE, FILL_RECT_SIZE, DRAW_TEXT_SIZE, diff --git a/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts b/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts index 76daacdf..e055a768 100644 --- a/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts +++ b/packages/core/src/drawlist/__tests__/writers.gen.v6.test.ts @@ -78,6 +78,22 @@ describe("writers.gen v6", () => { assert.equal(u8(bytes, 19), 0); }); + test("DEF_STRING honors declared byteLen (does not force bytes.byteLength)", () => { + const bytes = new Uint8Array(64); + bytes.fill(0xcc); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const payload = new Uint8Array([0x41, 0x42, 0x43, 0x44, 0x45]); + const end = writeDefString(bytes, dv, 0, 9, 3, payload); + + assert.equal(end, 20); + assert.equal(u32(bytes, 4), 20); + assert.equal(u32(bytes, 12), 3); + assert.deepEqual(Array.from(bytes.subarray(16, 19)), [0x41, 0x42, 0x43]); + assert.equal(u8(bytes, 19), 0); + assert.equal(u8(bytes, 20), 0xcc); + }); + test("FREE_* write id payload only", () => { const bytes = new Uint8Array(32); const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -171,4 +187,22 @@ describe("writers.gen v6", () => { assert.deepEqual(Array.from(bytes.subarray(24, 27)), [1, 2, 3]); assert.equal(u8(bytes, 27), 0); }); + + test("DEF_BLOB honors declared byteLen (does not force bytes.byteLength)", () => { + const bytes = new Uint8Array(64); + bytes.fill(0xcc); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const payload = new Uint8Array([1, 2, 3, 4, 5]); + + const end = writeDefBlob(bytes, dv, 0, 7, 3, payload); + + assert.equal(end, 20); + assert.equal(u8(bytes, 0), 12); + assert.equal(u32(bytes, 4), 20); + assert.equal(u32(bytes, 8), 7); + assert.equal(u32(bytes, 12), 3); + assert.deepEqual(Array.from(bytes.subarray(16, 19)), [1, 2, 3]); + assert.equal(u8(bytes, 19), 0); + assert.equal(u8(bytes, 20), 0xcc); + }); }); diff --git a/packages/core/src/drawlist/builder.ts b/packages/core/src/drawlist/builder.ts index 83f27736..9997d4c8 100644 --- a/packages/core/src/drawlist/builder.ts +++ b/packages/core/src/drawlist/builder.ts @@ -1,6 +1,11 @@ -import { ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; +import { ZRDL_MAGIC, ZR_DRAWLIST_VERSION_V1 } from "../abi.js"; import type { TextStyle } from "../widgets/style.js"; -import { DrawlistBuilderBase, type DrawlistBuilderBaseOpts } from "./builderBase.js"; +import { + DrawlistBuilderBase, + type DrawlistBuilderBaseOpts, + HEADER_SIZE, + align4, +} from "./builderBase.js"; import type { CursorState, DrawlistBuildResult, @@ -9,26 +14,36 @@ import type { DrawlistImageFit, DrawlistImageFormat, DrawlistImageProtocol, + DrawlistTextPerfCounters, + DrawlistTextRunSegment, EncodedStyle, } from "./types.js"; import { BLIT_RECT_SIZE, CLEAR_SIZE, + DEF_BLOB_BASE_SIZE, + DEF_STRING_BASE_SIZE, DRAW_CANVAS_SIZE, DRAW_IMAGE_SIZE, DRAW_TEXT_RUN_SIZE, DRAW_TEXT_SIZE, FILL_RECT_SIZE, + FREE_BLOB_SIZE, + FREE_STRING_SIZE, POP_CLIP_SIZE, PUSH_CLIP_SIZE, SET_CURSOR_SIZE, writeBlitRect, writeClear, + writeDefBlob, + writeDefString, writeDrawCanvas, writeDrawImage, writeDrawText, writeDrawTextRun, writeFillRect, + writeFreeBlob, + writeFreeString, writePopClip, writePushClip, writeSetCursor, @@ -66,8 +81,20 @@ const IMAGE_FIT_CODE: Readonly> = Object.freeze type LinkRefs = Readonly<{ uriRef: number; idRef: number }>; type CanvasPixelSize = Readonly<{ pxWidth: number; pxHeight: number }>; +type ResourceBuildPlan = Readonly<{ + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsCount: number; + blobsCount: number; + freeStringsCount: number; + freeBlobsCount: number; + totalSize: number; +}>; const MAX_U16 = 0xffff; +const LINK_URI_MAX_BYTES = 2083; +const LINK_ID_MAX_BYTES = 2083; const BLITTER_SUBCELL_RESOLUTION: Readonly< Record, Readonly<{ subW: number; subH: number }>> @@ -174,6 +201,9 @@ export function createDrawlistBuilder(opts: DrawlistBuilderOpts = {}): DrawlistB class DrawlistBuilderImpl extends DrawlistBuilderBase implements DrawlistBuilder { private activeLinkUriRef = 0; private activeLinkIdRef = 0; + private prevBuiltStringsCount = 0; + private prevBuiltBlobsCount = 0; + private textPerfSegments = 0; constructor(opts: DrawlistBuilderOpts) { super(opts, "DrawlistBuilder"); @@ -218,6 +248,59 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D this.setCursor({ x: -1, y: -1, shape: 0, visible: false, blink: false }); } + blitRect(srcX: number, srcY: number, w: number, h: number, dstX: number, dstY: number): void { + if (this.error) return; + + const srcXi = this.validateParams ? this.requireI32("blitRect", "srcX", srcX) : srcX | 0; + const srcYi = this.validateParams ? this.requireI32("blitRect", "srcY", srcY) : srcY | 0; + const wi = this.validateParams ? this.requireI32NonNeg("blitRect", "w", w) : w | 0; + const hi = this.validateParams ? this.requireI32NonNeg("blitRect", "h", h) : h | 0; + const dstXi = this.validateParams ? this.requireI32("blitRect", "dstX", dstX) : dstX | 0; + const dstYi = this.validateParams ? this.requireI32("blitRect", "dstY", dstY) : dstY | 0; + if (this.error) return; + if ( + srcXi === null || + srcYi === null || + wi === null || + hi === null || + dstXi === null || + dstYi === null + ) { + return; + } + + if (!this.beginCommandWrite("blitRect", BLIT_RECT_SIZE)) return; + this.cmdLen = writeBlitRect( + this.cmdBuf, + this.cmdDv, + this.cmdLen, + srcXi, + srcYi, + wi, + hi, + dstXi, + dstYi, + ); + this.cmdCount += 1; + this.maybeFailTooLargeAfterWrite(); + } + + override addTextRunBlob(segments: readonly DrawlistTextRunSegment[]): number | null { + const blobIndex = super.addTextRunBlob(segments); + if (blobIndex !== null) { + this.textPerfSegments += segments.length; + } + return blobIndex; + } + + getTextPerfCounters(): DrawlistTextPerfCounters { + return Object.freeze({ + textEncoderCalls: this.getTextEncoderCallCount(), + textArenaBytes: this.stringBytesLen, + textSegments: this.textPerfSegments, + }); + } + setLink(uri: string | null, id?: string): void { if (this.error) return; @@ -232,22 +315,23 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return; } - if (uri !== null) { - if (typeof uri !== "string") { - this.fail("ZRDL_BAD_PARAMS", "setLink: uri must be a string or null"); - return; - } - const idx = this.internString(uri); - if (this.error || idx === null) return; - this.activeLinkUriRef = idx >>> 0; - - if (id !== undefined) { - const idIdx = this.internString(id); - if (this.error || idIdx === null) return; - this.activeLinkIdRef = idIdx >>> 0; - } else { - this.activeLinkIdRef = 0; - } + if (typeof uri !== "string") { + this.fail("ZRDL_BAD_PARAMS", "setLink: uri must be a string or null"); + return; + } + if (!this.validateLinkString("uri", uri, LINK_URI_MAX_BYTES, true)) return; + if (id !== undefined && !this.validateLinkString("id", id, LINK_ID_MAX_BYTES, false)) return; + + const idx = this.internString(uri); + if (this.error || idx === null) return; + this.activeLinkUriRef = (idx + 1) >>> 0; + + if (id !== undefined) { + const idIdx = this.internString(id); + if (this.error || idIdx === null) return; + this.activeLinkIdRef = (idIdx + 1) >>> 0; + } else { + this.activeLinkIdRef = 0; } } @@ -287,9 +371,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return; } - const blobOff = this.blobSpanOffs[bi]; const blobLen = this.blobSpanLens[bi]; - if (blobOff === undefined || blobLen === undefined) { + if (blobLen === undefined) { this.fail("ZRDL_INTERNAL", "drawCanvas: blob span table is inconsistent"); return; } @@ -354,8 +437,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D hi, resolvedPxW, resolvedPxH, - blobOff, - blobLen, + bi + 1, + 0, blitterCode, 0, 0, @@ -431,9 +514,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return; } - const blobOff = this.blobSpanOffs[bi]; const blobLen = this.blobSpanLens[bi]; - if (blobOff === undefined || blobLen === undefined) { + if (blobLen === undefined) { this.fail("ZRDL_INTERNAL", "drawImage: blob span table is inconsistent"); return; } @@ -517,8 +599,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D hi, resolvedPxW, resolvedPxH, - blobOff, - blobLen, + bi + 1, + 0, imageIdU32, formatCode, protocolCode, @@ -534,11 +616,48 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D } buildInto(dst: Uint8Array): DrawlistBuildResult { - return this.buildIntoWithVersion(ZR_DRAWLIST_VERSION_V1, dst); + if (this.error) { + return { ok: false, error: this.error }; + } + if (!(dst instanceof Uint8Array)) { + return { + ok: false, + error: { code: "ZRDL_BAD_PARAMS", detail: "buildInto: dst must be a Uint8Array" }, + }; + } + const planned = this.planResourceStream(); + if (!planned.ok) { + return planned; + } + const plan = planned.plan; + if (dst.byteLength < plan.totalSize) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `buildInto: dst is too small (required=${plan.totalSize}, got=${dst.byteLength})`, + }, + }; + } + return this.writeBuiltStream(dst.subarray(0, plan.totalSize), plan, ZR_DRAWLIST_VERSION_V1); } build(): DrawlistBuildResult { - return this.buildWithVersion(ZR_DRAWLIST_VERSION_V1); + if (this.error) { + return { ok: false, error: this.error }; + } + const planned = this.planResourceStream(); + if (!planned.ok) { + return planned; + } + const plan = planned.plan; + const out = this.reuseOutputBuffer + ? this.ensureOutputCapacity(plan.totalSize) + : new Uint8Array(plan.totalSize); + if (this.error) { + return { ok: false, error: this.error }; + } + return this.writeBuiltStream(out.subarray(0, plan.totalSize), plan, ZR_DRAWLIST_VERSION_V1); } override reset(): void { @@ -547,6 +666,7 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D this.activeLinkUriRef = 0; this.activeLinkIdRef = 0; + this.textPerfSegments = 0; } protected override encodeFillRectStyle(style: TextStyle | undefined): EncodedStyle { @@ -579,7 +699,6 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D x: number, y: number, stringIndex: number, - byteOff: number, byteLen: number, style: EncodedStyle, ): void { @@ -590,8 +709,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D this.cmdLen, x, y, - stringIndex, - byteOff, + stringIndex + 1, + 0, byteLen, style, 0, @@ -605,19 +724,6 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D this.cmdCount += 1; } - protected override appendBlitRectCommand( - srcX: number, - srcY: number, - w: number, - h: number, - dstX: number, - dstY: number, - ): void { - if (!this.beginCommandWrite("blitRect", BLIT_RECT_SIZE)) return; - this.cmdLen = writeBlitRect(this.cmdBuf, this.cmdDv, this.cmdLen, srcX, srcY, w, h, dstX, dstY); - this.cmdCount += 1; - } - protected override appendPopClipCommand(): void { if (!this.beginCommandWrite("popClip", POP_CLIP_SIZE)) return; this.cmdLen = writePopClip(this.cmdBuf, this.cmdDv, this.cmdLen); @@ -626,7 +732,7 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D protected override appendDrawTextRunCommand(x: number, y: number, blobIndex: number): void { if (!this.beginCommandWrite("drawTextRun", DRAW_TEXT_RUN_SIZE)) return; - this.cmdLen = writeDrawTextRun(this.cmdBuf, this.cmdDv, this.cmdLen, x, y, blobIndex, 0); + this.cmdLen = writeDrawTextRun(this.cmdBuf, this.cmdDv, this.cmdLen, x, y, blobIndex + 1, 0); this.cmdCount += 1; } @@ -639,7 +745,6 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D off: number, style: EncodedStyle, stringIndex: number, - byteOff: number, byteLen: number, ): number { dv.setUint32(off + 0, style.fg >>> 0, true); @@ -649,8 +754,8 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D dv.setUint32(off + 16, style.underlineRgb >>> 0, true); dv.setUint32(off + 20, style.linkUriRef >>> 0, true); dv.setUint32(off + 24, style.linkIdRef >>> 0, true); - dv.setUint32(off + 28, stringIndex >>> 0, true); - dv.setUint32(off + 32, byteOff >>> 0, true); + dv.setUint32(off + 28, (stringIndex + 1) >>> 0, true); + dv.setUint32(off + 32, 0, true); dv.setUint32(off + 36, byteLen >>> 0, true); return off + 40; } @@ -685,6 +790,50 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D return v >>> 0; } + private validateLinkString( + field: "uri" | "id", + value: string, + maxBytes: number, + requireNonEmpty: boolean, + ): boolean { + const byteLen = this.utf8ByteLength(value, `setLink: ${field}`); + if (byteLen === null) return false; + + if (requireNonEmpty && byteLen === 0) { + this.fail( + "ZRDL_BAD_PARAMS", + "setLink: uri must be non-empty when provided; use null to clear", + ); + return false; + } + if (byteLen > maxBytes) { + this.fail( + "ZRDL_BAD_PARAMS", + `setLink: ${field} UTF-8 length must be <= ${maxBytes} bytes (got ${byteLen})`, + ); + return false; + } + return true; + } + + private utf8ByteLength(text: string, context: string): number | null { + let asciiOnly = true; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 0x7f) { + asciiOnly = false; + break; + } + } + if (asciiOnly) { + return text.length; + } + if (!this.encoder) { + this.fail("ZRDL_INTERNAL", `${context}: TextEncoder is not available`); + return null; + } + return this.encoder.encode(text).byteLength; + } + private currentLinkRefs(): LinkRefs | null { if (this.activeLinkUriRef === 0) return null; return Object.freeze({ @@ -692,4 +841,176 @@ class DrawlistBuilderImpl extends DrawlistBuilderBase implements D idRef: this.activeLinkIdRef >>> 0, }); } + + private planResourceStream(): + | Readonly<{ ok: true; plan: ResourceBuildPlan }> + | Readonly<{ ok: false; error: { code: "ZRDL_TOO_LARGE" | "ZRDL_INTERNAL"; detail: string } }> { + if ((this.cmdLen & 3) !== 0) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: command stream is not 4-byte aligned" }, + }; + } + + const stringsCount = this.stringSpanOffs.length; + const blobsCount = this.blobSpanOffs.length; + const freeStringsCount = Math.max(0, this.prevBuiltStringsCount - stringsCount); + const freeBlobsCount = Math.max(0, this.prevBuiltBlobsCount - blobsCount); + + let defStringsBytes = 0; + for (let i = 0; i < stringsCount; i++) { + const len = this.stringSpanLens[i]; + if (len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: string span table is inconsistent" }, + }; + } + defStringsBytes += align4(DEF_STRING_BASE_SIZE + len); + } + + let defBlobsBytes = 0; + for (let i = 0; i < blobsCount; i++) { + const len = this.blobSpanLens[i]; + if (len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: blob span table is inconsistent" }, + }; + } + defBlobsBytes += align4(DEF_BLOB_BASE_SIZE + len); + } + + const cmdCount = stringsCount + blobsCount + this.cmdCount + freeStringsCount + freeBlobsCount; + const cmdBytes = + defStringsBytes + + defBlobsBytes + + this.cmdLen + + freeStringsCount * FREE_STRING_SIZE + + freeBlobsCount * FREE_BLOB_SIZE; + const cmdOffset = cmdCount === 0 ? 0 : HEADER_SIZE; + const totalSize = HEADER_SIZE + cmdBytes; + + if (cmdCount > this.maxCmdCount) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `build: maxCmdCount exceeded (count=${cmdCount}, max=${this.maxCmdCount})`, + }, + }; + } + + if ((cmdBytes & 3) !== 0 || (totalSize & 3) !== 0) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: command stream alignment is invalid" }, + }; + } + + if (totalSize > this.maxDrawlistBytes) { + return { + ok: false, + error: { + code: "ZRDL_TOO_LARGE", + detail: `build: maxDrawlistBytes exceeded (total=${totalSize}, max=${this.maxDrawlistBytes})`, + }, + }; + } + + return { + ok: true, + plan: { + cmdOffset, + cmdBytes, + cmdCount, + stringsCount, + blobsCount, + freeStringsCount, + freeBlobsCount, + totalSize, + }, + }; + } + + private writeBuiltStream( + out: Uint8Array, + plan: ResourceBuildPlan, + version: number, + ): DrawlistBuildResult { + const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); + + dv.setUint32(0, ZRDL_MAGIC, true); + dv.setUint32(4, version >>> 0, true); + dv.setUint32(8, HEADER_SIZE, true); + dv.setUint32(12, plan.totalSize >>> 0, true); + dv.setUint32(16, plan.cmdOffset >>> 0, true); + dv.setUint32(20, plan.cmdBytes >>> 0, true); + dv.setUint32(24, plan.cmdCount >>> 0, true); + dv.setUint32(28, 0, true); + dv.setUint32(32, 0, true); + dv.setUint32(36, 0, true); + dv.setUint32(40, 0, true); + dv.setUint32(44, 0, true); + dv.setUint32(48, 0, true); + dv.setUint32(52, 0, true); + dv.setUint32(56, 0, true); + dv.setUint32(60, 0, true); + + let pos = plan.cmdOffset; + + for (let i = 0; i < plan.stringsCount; i++) { + const off = this.stringSpanOffs[i]; + const len = this.stringSpanLens[i]; + if (off === undefined || len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: string span table is inconsistent" }, + }; + } + const bytes = this.stringBytesBuf.subarray(off, off + len); + pos = writeDefString(out, dv, pos, i + 1, len, bytes); + } + + for (let i = 0; i < plan.blobsCount; i++) { + const off = this.blobSpanOffs[i]; + const len = this.blobSpanLens[i]; + if (off === undefined || len === undefined) { + return { + ok: false, + error: { code: "ZRDL_INTERNAL", detail: "build: blob span table is inconsistent" }, + }; + } + const bytes = this.blobBytesBuf.subarray(off, off + len); + pos = writeDefBlob(out, dv, pos, i + 1, len, bytes); + } + + out.set(this.cmdBuf.subarray(0, this.cmdLen), pos); + pos += this.cmdLen; + + for (let i = 0; i < plan.freeStringsCount; i++) { + const id = plan.stringsCount + i + 1; + pos = writeFreeString(out, dv, pos, id); + } + + for (let i = 0; i < plan.freeBlobsCount; i++) { + const id = plan.blobsCount + i + 1; + pos = writeFreeBlob(out, dv, pos, id); + } + + const expectedEnd = plan.cmdOffset + plan.cmdBytes; + if (pos !== expectedEnd) { + return { + ok: false, + error: { + code: "ZRDL_INTERNAL", + detail: `build: command stream size mismatch (expected=${expectedEnd}, got=${pos})`, + }, + }; + } + + this.prevBuiltStringsCount = plan.stringsCount; + this.prevBuiltBlobsCount = plan.blobsCount; + return { ok: true, bytes: out }; + } } diff --git a/packages/core/src/drawlist/builderBase.ts b/packages/core/src/drawlist/builderBase.ts index b87fb8db..816e493f 100644 --- a/packages/core/src/drawlist/builderBase.ts +++ b/packages/core/src/drawlist/builderBase.ts @@ -1,11 +1,9 @@ import { ZRDL_MAGIC } from "../abi.js"; import type { TextStyle } from "../widgets/style.js"; -import { FrameTextArena, type TextArenaCounters, type TextArenaSlice } from "./textArena.js"; import type { DrawlistBuildError, DrawlistBuildErrorCode, DrawlistBuildResult, - DrawlistTextPerfCounters, DrawlistTextRunSegment, } from "./types.js"; @@ -43,18 +41,12 @@ export const OP_POP_CLIP = 5; export const OP_DRAW_TEXT_RUN = 6; export const OP_SET_CURSOR = 7; -type Utf8Encoder = Readonly<{ - encode(input: string): Uint8Array; - encodeInto(input: string, destination: Uint8Array): Readonly<{ read?: number; written?: number }>; -}>; +type Utf8Encoder = Readonly<{ encode(input: string): Uint8Array }>; type Layout = Readonly<{ cmdOffset: number; cmdBytes: number; cmdCount: number; - hasTextArenaSpan: boolean; - textArenaBytesLen: number; - persistentStringsBytesLen: number; stringsSpanOffset: number; stringsCount: number; stringsSpanBytes: number; @@ -106,7 +98,6 @@ export abstract class DrawlistBuilderBase { protected readonly stringSpanLens: number[] = []; protected stringBytesBuf: Uint8Array; protected stringBytesLen = 0; - protected readonly textArena: FrameTextArena; protected readonly blobSpanOffs: number[] = []; protected readonly blobSpanLens: number[] = []; @@ -115,6 +106,7 @@ export abstract class DrawlistBuilderBase { protected outBuf: Uint8Array | null = null; protected readonly encodedStringCache: Map | null; + protected textEncoderCalls = 0; protected error: DrawlistBuildError | undefined; @@ -155,17 +147,10 @@ export abstract class DrawlistBuilderBase { const initialBlobCap = Math.min(1024, this.maxBlobBytes); this.blobBytesBuf = new Uint8Array(initialBlobCap); - const textEncoderCtor = (globalThis as Readonly<{ TextEncoder?: new () => Utf8Encoder }>) - .TextEncoder; - this.encoder = textEncoderCtor ? new textEncoderCtor() : undefined; + this.encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : undefined; if (!this.encoder) { this.fail("ZRDL_INTERNAL", "TextEncoder is not available in this environment"); } - const fallbackEncoder: Utf8Encoder = Object.freeze({ - encode: () => new Uint8Array(), - encodeInto: () => Object.freeze({ read: 0, written: 0 }), - }); - this.textArena = new FrameTextArena(this.encoder ?? fallbackEncoder, 1024); } clear(): void { @@ -205,57 +190,6 @@ export abstract class DrawlistBuilderBase { this.maybeFailTooLargeAfterWrite(); } - reserveTextArena(bytes: number): void { - if (this.error) return; - const bi = this.validateParams - ? this.requireI32NonNeg("reserveTextArena", "bytes", bytes) - : bytes | 0; - if (this.error) return; - if (bi === null) return; - - const nextRequired = this.textArena.byteLength() + bi; - const nextEstimatedTotal = this.estimateTotalSizeWithArenaState( - nextRequired, - this.textArena.segmentCount(), - ); - if (nextEstimatedTotal > this.maxDrawlistBytes) { - this.fail( - "ZRDL_TOO_LARGE", - `reserveTextArena: maxDrawlistBytes exceeded (estimatedTotal=${nextEstimatedTotal}, max=${this.maxDrawlistBytes})`, - ); - return; - } - - this.textArena.reserve(nextRequired); - this.maybeFailTooLargeAfterWrite(); - } - - blitRect(srcX: number, srcY: number, w: number, h: number, dstX: number, dstY: number): void { - if (this.error) return; - - const srcXi = this.validateParams ? this.requireI32("blitRect", "srcX", srcX) : srcX | 0; - const srcYi = this.validateParams ? this.requireI32("blitRect", "srcY", srcY) : srcY | 0; - const wi = this.validateParams ? this.requireI32NonNeg("blitRect", "w", w) : w | 0; - const hi = this.validateParams ? this.requireI32NonNeg("blitRect", "h", h) : h | 0; - const dstXi = this.validateParams ? this.requireI32("blitRect", "dstX", dstX) : dstX | 0; - const dstYi = this.validateParams ? this.requireI32("blitRect", "dstY", dstY) : dstY | 0; - if (this.error) return; - if ( - srcXi === null || - srcYi === null || - wi === null || - hi === null || - dstXi === null || - dstYi === null - ) - return; - const w0 = wi < 0 ? 0 : wi; - const h0 = hi < 0 ? 0 : hi; - - this.appendBlitRectCommand(srcXi, srcYi, w0, h0, dstXi, dstYi); - this.maybeFailTooLargeAfterWrite(); - } - drawText(x: number, y: number, text: string, style?: TextStyle): void { if (this.error) return; @@ -269,12 +203,18 @@ export abstract class DrawlistBuilderBase { return; } - const textSlice = this.allocTextSlice(text); + const stringIndex = this.internString(text); if (this.error) return; - if (textSlice === null) return; + if (stringIndex === null) return; + + const byteLen = this.stringSpanLens[stringIndex]; + if (byteLen === undefined) { + this.fail("ZRDL_INTERNAL", "drawText: interned string has no recorded span length"); + return; + } const encodedStyle = this.encodeDrawTextStyle(style); - this.appendDrawTextCommand(xi, yi, 0, textSlice.off, textSlice.len, encodedStyle); + this.appendDrawTextCommand(xi, yi, stringIndex, byteLen, encodedStyle); this.maybeFailTooLargeAfterWrite(); } @@ -314,10 +254,6 @@ export abstract class DrawlistBuilderBase { this.fail("ZRDL_BAD_PARAMS", "addBlob: bytes.byteLength must be a non-negative integer"); return null; } - if ((byteLen & 3) !== 0) { - this.fail("ZRDL_BAD_PARAMS", "addBlob: blob length must be 4-byte aligned"); - return null; - } const nextIndex = this.blobSpanOffs.length; if (nextIndex + 1 > this.maxBlobs) { @@ -337,11 +273,6 @@ export abstract class DrawlistBuilderBase { return null; } - if ((this.blobBytesLen & 3) !== 0) { - this.fail("ZRDL_INTERNAL", "addBlob: blob cursor is not 4-byte aligned"); - return null; - } - this.ensureBlobBytesCapacity(nextBytesLen); if (this.error) return null; @@ -387,12 +318,18 @@ export abstract class DrawlistBuilderBase { return null; } - const textSlice = this.allocTextSlice(seg0.text); + const stringIndex = this.internString(seg0.text); if (this.error) return null; - if (textSlice === null) return null; + if (stringIndex === null) return null; + + const byteLen = this.stringSpanLens[stringIndex]; + if (byteLen === undefined) { + this.fail("ZRDL_INTERNAL", "addTextRunBlob: interned string has no recorded span length"); + return null; + } const encodedStyle = this.encodeTextRunStyle(seg0.style); - off = this.writeTextRunBlobSegment(dv, off, encodedStyle, 0, textSlice.off, textSlice.len); + off = this.writeTextRunBlobSegment(dv, off, encodedStyle, stringIndex, byteLen); } return this.addBlob(blob); @@ -426,15 +363,6 @@ export abstract class DrawlistBuilderBase { this.maybeFailTooLargeAfterWrite(); } - getTextPerfCounters(): DrawlistTextPerfCounters { - const counters: TextArenaCounters = this.textArena.counters(); - return Object.freeze({ - textEncoderCalls: counters.textEncoderCalls, - textArenaBytes: counters.textArenaBytes, - textSegments: counters.textSegments, - }); - } - abstract build(): DrawlistBuildResult; reset(): void { @@ -451,7 +379,7 @@ export abstract class DrawlistBuilderBase { this.stringSpanOffs.length = 0; this.stringSpanLens.length = 0; this.stringBytesLen = 0; - this.textArena.reset(); + this.textEncoderCalls = 0; this.blobSpanOffs.length = 0; this.blobSpanLens.length = 0; @@ -480,22 +408,12 @@ export abstract class DrawlistBuilderBase { x: number, y: number, stringIndex: number, - byteOff: number, byteLen: number, style: TEncodedStyle, ): void; protected abstract appendPushClipCommand(x: number, y: number, w: number, h: number): void; - protected abstract appendBlitRectCommand( - srcX: number, - srcY: number, - w: number, - h: number, - dstX: number, - dstY: number, - ): void; - protected abstract appendPopClipCommand(): void; protected abstract appendDrawTextRunCommand(x: number, y: number, blobIndex: number): void; @@ -507,7 +425,6 @@ export abstract class DrawlistBuilderBase { off: number, style: TEncodedStyle, stringIndex: number, - byteOff: number, byteLen: number, ): number; @@ -581,18 +498,12 @@ export abstract class DrawlistBuilderBase { const cmdOffset = cmdCount === 0 ? 0 : cursor; cursor += cmdBytes; - const persistentStringsCount = this.stringSpanOffs.length; - const textArenaBytesLen = this.textArena.byteLength(); - const hasTextArenaSpan = - this.textArena.segmentCount() > 0 || textArenaBytesLen > 0 || persistentStringsCount > 0; - const stringsCount = persistentStringsCount + (hasTextArenaSpan ? 1 : 0); + const stringsCount = this.stringSpanOffs.length; const stringsSpanBytes = stringsCount * 8; const stringsSpanOffset = stringsCount === 0 ? 0 : cursor; cursor += stringsSpanBytes; const stringsBytesOffset = stringsCount === 0 ? 0 : cursor; - const persistentStringsBytesLen = this.stringBytesLen; - const stringsBytesRawLen = textArenaBytesLen + persistentStringsBytesLen; - const stringsBytesLen = stringsCount === 0 ? 0 : align4(stringsBytesRawLen); + const stringsBytesLen = stringsCount === 0 ? 0 : align4(this.stringBytesLen); cursor += stringsBytesLen; const blobsCount = this.blobSpanOffs.length; @@ -609,9 +520,6 @@ export abstract class DrawlistBuilderBase { cmdOffset, cmdBytes, cmdCount, - hasTextArenaSpan, - textArenaBytesLen, - persistentStringsBytesLen, stringsSpanOffset, stringsCount, stringsSpanBytes, @@ -664,13 +572,7 @@ export abstract class DrawlistBuilderBase { out.set(this.cmdBuf.subarray(0, layout.cmdBytes), layout.cmdOffset); let spanOff = layout.stringsSpanOffset; - if (layout.hasTextArenaSpan) { - dv.setUint32(spanOff, 0, true); - dv.setUint32(spanOff + 4, layout.textArenaBytesLen >>> 0, true); - spanOff += 8; - } - - for (let i = 0; i < this.stringSpanOffs.length; i++) { + for (let i = 0; i < layout.stringsCount; i++) { const off = this.stringSpanOffs[i]; const len = this.stringSpanLens[i]; if (off === undefined || len === undefined) { @@ -680,19 +582,13 @@ export abstract class DrawlistBuilderBase { }; } - const shiftedOff = layout.textArenaBytesLen + off; - dv.setUint32(spanOff, shiftedOff >>> 0, true); + dv.setUint32(spanOff, off >>> 0, true); dv.setUint32(spanOff + 4, len >>> 0, true); spanOff += 8; } - const textArenaBytes = this.textArena.bytes(); - out.set(textArenaBytes, layout.stringsBytesOffset); - out.set( - this.stringBytesBuf.subarray(0, layout.persistentStringsBytesLen), - layout.stringsBytesOffset + layout.textArenaBytesLen, - ); - const stringsBytesLenRaw = layout.textArenaBytesLen + layout.persistentStringsBytesLen; + const stringsBytesLenRaw = this.stringBytesLen; + out.set(this.stringBytesBuf.subarray(0, stringsBytesLenRaw), layout.stringsBytesOffset); if (layout.stringsBytesLen > stringsBytesLenRaw) { out.fill( 0, @@ -793,61 +689,8 @@ export abstract class DrawlistBuilderBase { this.error = { code, detail }; } - protected allocTextSlice(text: string): TextArenaSlice | null { - if (!this.encoder) { - this.fail("ZRDL_INTERNAL", "drawText: TextEncoder is not available"); - return null; - } - - const utf8Len = this.measureUtf8ByteLengthCapped(text, this.maxDrawlistBytes); - if (utf8Len > this.maxDrawlistBytes) { - this.fail( - "ZRDL_TOO_LARGE", - `drawText: maxDrawlistBytes exceeded (utf8Len=${utf8Len}, max=${this.maxDrawlistBytes})`, - ); - return null; - } - - const nextArenaLen = this.textArena.byteLength() + utf8Len; - const nextEstimatedTotal = this.estimateTotalSizeWithArenaState( - nextArenaLen, - this.textArena.segmentCount() + 1, - ); - if (nextEstimatedTotal > this.maxDrawlistBytes) { - this.fail( - "ZRDL_TOO_LARGE", - `drawText: maxDrawlistBytes exceeded (estimatedTotal=${nextEstimatedTotal}, max=${this.maxDrawlistBytes})`, - ); - return null; - } - - return this.textArena.allocUtf8(text); - } - - private measureUtf8ByteLengthCapped(text: string, cap: number): number { - let total = 0; - for (let i = 0; i < text.length; i++) { - const code = text.charCodeAt(i); - if (code <= 0x7f) { - total += 1; - } else if (code <= 0x7ff) { - total += 2; - } else if (code >= 0xd800 && code <= 0xdbff) { - const next = i + 1 < text.length ? text.charCodeAt(i + 1) : Number.NaN; - if (next >= 0xdc00 && next <= 0xdfff) { - total += 4; - i += 1; - } else { - total += 3; - } - } else { - // Includes lone low surrogates which TextEncoder replaces with U+FFFD (3 bytes). - total += 3; - } - - if (total > cap) return total; - } - return total; + protected getTextEncoderCallCount(): number { + return this.textEncoderCalls; } protected internString(text: string): number | null { @@ -855,7 +698,7 @@ export abstract class DrawlistBuilderBase { if (existing !== undefined) return existing; if (!this.encoder) { - this.fail("ZRDL_INTERNAL", "setLink: TextEncoder is not available"); + this.fail("ZRDL_INTERNAL", "drawText: TextEncoder is not available"); return null; } @@ -863,7 +706,7 @@ export abstract class DrawlistBuilderBase { if (nextIndex + 1 > this.maxStrings) { this.fail( "ZRDL_TOO_LARGE", - `setLink: maxStrings exceeded (count=${nextIndex + 1}, max=${this.maxStrings})`, + `drawText: maxStrings exceeded (count=${nextIndex + 1}, max=${this.maxStrings})`, ); return null; } @@ -884,7 +727,7 @@ export abstract class DrawlistBuilderBase { if (nextBytesLen > this.maxStringBytes) { this.fail( "ZRDL_TOO_LARGE", - `setLink: maxStringBytes exceeded (bytes=${nextBytesLen}, max=${this.maxStringBytes})`, + `drawText: maxStringBytes exceeded (bytes=${nextBytesLen}, max=${this.maxStringBytes})`, ); return null; } @@ -897,12 +740,11 @@ export abstract class DrawlistBuilderBase { this.stringBytesLen = nextBytesLen; this.stringSpanOffs.push(off); this.stringSpanLens.push(byteLen); - const wireIndex = nextIndex + 1; - this.stringIndexByValue.set(text, wireIndex); + this.stringIndexByValue.set(text, nextIndex); this.maybeFailTooLargeAfterWrite(); - return wireIndex; + return nextIndex; } private encodeUtf8(text: string): Uint8Array { @@ -922,6 +764,7 @@ export abstract class DrawlistBuilderBase { return out; } + this.textEncoderCalls += 1; return this.encoder ? this.encoder.encode(text) : new Uint8Array(); } @@ -1144,23 +987,14 @@ export abstract class DrawlistBuilderBase { } protected estimateTotalSize(): number { - return this.estimateTotalSizeWithArenaState( - this.textArena.byteLength(), - this.textArena.segmentCount(), - ); - } - - private estimateTotalSizeWithArenaState(arenaBytes: number, arenaSegments: number): number { const cmdOffset = HEADER_SIZE; const cmdBytes = this.cmdLen; - const persistentStringsCount = this.stringSpanOffs.length; - const hasTextArenaSpan = arenaSegments > 0 || arenaBytes > 0 || persistentStringsCount > 0; - const stringsCount = persistentStringsCount + (hasTextArenaSpan ? 1 : 0); + const stringsCount = this.stringSpanOffs.length; const stringsSpanBytes = stringsCount * 8; const stringsSpanOffset = cmdOffset + cmdBytes; const stringsBytesOffset = stringsSpanOffset + stringsSpanBytes; - const stringsBytesAligned = stringsCount === 0 ? 0 : align4(arenaBytes + this.stringBytesLen); + const stringsBytesAligned = align4(this.stringBytesLen); const blobsCount = this.blobSpanOffs.length; const blobsSpanBytes = blobsCount * 8; @@ -1176,9 +1010,6 @@ export abstract class DrawlistBuilderBase { cmdOffset, cmdBytes, cmdCount, - hasTextArenaSpan, - textArenaBytesLen, - persistentStringsBytesLen, stringsSpanOffset, stringsCount, stringsSpanBytes, @@ -1225,30 +1056,7 @@ export abstract class DrawlistBuilderBase { error: { code: "ZRDL_FORMAT", detail: "build: stringsSpanOffset misaligned" }, }; } - const expectedHasTextArenaSpan = - this.textArena.segmentCount() > 0 || - this.textArena.byteLength() > 0 || - this.stringSpanOffs.length > 0; - if (hasTextArenaSpan !== expectedHasTextArenaSpan) { - return { - ok: false, - error: { code: "ZRDL_INTERNAL", detail: "build: hasTextArenaSpan mismatch" }, - }; - } - if (textArenaBytesLen !== this.textArena.byteLength()) { - return { - ok: false, - error: { code: "ZRDL_INTERNAL", detail: "build: textArenaBytesLen mismatch" }, - }; - } - if (persistentStringsBytesLen !== this.stringBytesLen) { - return { - ok: false, - error: { code: "ZRDL_INTERNAL", detail: "build: persistentStringsBytesLen mismatch" }, - }; - } - const expectedStringsCount = this.stringSpanOffs.length + (expectedHasTextArenaSpan ? 1 : 0); - if (stringsCount !== expectedStringsCount) { + if (stringsCount !== this.stringSpanOffs.length) { return { ok: false, error: { code: "ZRDL_INTERNAL", detail: "build: stringsCount mismatch" }, @@ -1283,9 +1091,7 @@ export abstract class DrawlistBuilderBase { error: { code: "ZRDL_FORMAT", detail: "build: stringsBytesLen misaligned" }, }; } - const expectedStringsBytesLen = - stringsCount === 0 ? 0 : align4(this.textArena.byteLength() + this.stringBytesLen); - if (stringsBytesLen !== expectedStringsBytesLen) { + if (stringsBytesLen !== align4(this.stringBytesLen)) { return { ok: false, error: { code: "ZRDL_INTERNAL", detail: "build: stringsBytesLen mismatch" }, diff --git a/packages/core/src/drawlist/writers.gen.ts b/packages/core/src/drawlist/writers.gen.ts index a05eb767..02d08c87 100644 --- a/packages/core/src/drawlist/writers.gen.ts +++ b/packages/core/src/drawlist/writers.gen.ts @@ -283,7 +283,7 @@ export function writeDefString( byteLen: number, bytes: Uint8Array, ): number { - const payloadBytes = bytes.byteLength >>> 0; + const payloadBytes = byteLen >>> 0; const size = align4(DEF_STRING_BASE_SIZE + payloadBytes); buf[pos + 0] = 10 & 0xff; buf[pos + 1] = 0; @@ -293,7 +293,13 @@ export function writeDefString( dv.setUint32(pos + 8, stringId >>> 0, true); dv.setUint32(pos + 12, payloadBytes >>> 0, true); const dataStart = pos + DEF_STRING_BASE_SIZE; - buf.set(bytes, dataStart); + const copyBytes = Math.min(payloadBytes, bytes.byteLength >>> 0); + if (copyBytes > 0) { + buf.set(bytes.subarray(0, copyBytes), dataStart); + } + if (payloadBytes > copyBytes) { + buf.fill(0, dataStart + copyBytes, dataStart + payloadBytes); + } const payloadEnd = dataStart + payloadBytes; const cmdEnd = pos + size; if (cmdEnd > payloadEnd) { @@ -325,7 +331,7 @@ export function writeDefBlob( byteLen: number, bytes: Uint8Array, ): number { - const payloadBytes = bytes.byteLength >>> 0; + const payloadBytes = byteLen >>> 0; const size = align4(DEF_BLOB_BASE_SIZE + payloadBytes); buf[pos + 0] = 12 & 0xff; buf[pos + 1] = 0; @@ -335,7 +341,13 @@ export function writeDefBlob( dv.setUint32(pos + 8, blobId >>> 0, true); dv.setUint32(pos + 12, payloadBytes >>> 0, true); const dataStart = pos + DEF_BLOB_BASE_SIZE; - buf.set(bytes, dataStart); + const copyBytes = Math.min(payloadBytes, bytes.byteLength >>> 0); + if (copyBytes > 0) { + buf.set(bytes.subarray(0, copyBytes), dataStart); + } + if (payloadBytes > copyBytes) { + buf.fill(0, dataStart + copyBytes, dataStart + payloadBytes); + } const payloadEnd = dataStart + payloadBytes; const cmdEnd = pos + size; if (cmdEnd > payloadEnd) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6e8f1c17..0ad76087 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,7 +18,6 @@ export { ZR_ENGINE_ABI_MINOR, ZR_ENGINE_ABI_PATCH, ZR_DRAWLIST_VERSION_V1, - ZR_DRAWLIST_VERSION_V2, ZR_DRAWLIST_VERSION, ZR_EVENT_BATCH_VERSION_V1, ZR_UNICODE_VERSION_MAJOR, @@ -744,7 +743,6 @@ export type { DrawlistBuildErrorCode, DrawlistBuildResult, DrawlistBuilder, - DrawlistTextPerfCounters, DrawlistImageFit, DrawlistImageFormat, DrawlistImageProtocol, diff --git a/packages/core/src/layout/kinds/overlays.ts b/packages/core/src/layout/kinds/overlays.ts index 8e43e182..00396e51 100644 --- a/packages/core/src/layout/kinds/overlays.ts +++ b/packages/core/src/layout/kinds/overlays.ts @@ -85,9 +85,7 @@ function isFiniteNumber(v: unknown): v is number { function hasFrameBorder(raw: unknown): boolean { if (!raw || typeof raw !== "object") return false; const border = (raw as { border?: unknown }).border; - if (!border || typeof border !== "object") return false; - const rgb = border as { r?: unknown; g?: unknown; b?: unknown }; - return isFiniteNumber(rgb.r) && isFiniteNumber(rgb.g) && isFiniteNumber(rgb.b); + return isFiniteNumber(border); } export function measureOverlays( diff --git a/packages/core/src/perf/frameAudit.ts b/packages/core/src/perf/frameAudit.ts new file mode 100644 index 00000000..6d34f1b1 --- /dev/null +++ b/packages/core/src/perf/frameAudit.ts @@ -0,0 +1,178 @@ +/** + * packages/core/src/perf/frameAudit.ts — Optional drawlist frame-audit logging. + * + * Purpose: + * - Provide deterministic fingerprints for drawlist bytes at core submit points. + * - Emit lightweight NDJSON records only when explicitly enabled. + * + * Enable with: + * REZI_FRAME_AUDIT=1 + */ + +export type DrawlistFingerprint = Readonly<{ + byteLen: number; + hash32: string; + prefixHash32: string; + cmdCount: number | null; + totalSize: number | null; + head16: string; + tail16: string; + opcodeHistogram: Readonly>; + cmdStreamValid: boolean; +}>; + +function envFlag(name: "REZI_FRAME_AUDIT"): boolean { + try { + const g = globalThis as { + process?: { env?: { REZI_FRAME_AUDIT?: string } }; + }; + const raw = g.process?.env?.[name]; + if (raw === undefined) return false; + const value = raw.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; + } catch { + return false; + } +} + +function nowMs(): number { + try { + const g = globalThis as { performance?: { now?: () => number } }; + const fn = g.performance?.now; + if (typeof fn === "function") return fn.call(g.performance); + } catch { + // no-op + } + return Date.now(); +} + +function toHex32(v: number): string { + return `0x${(v >>> 0).toString(16).padStart(8, "0")}`; +} + +function hashFnv1a32(bytes: Uint8Array, end: number): number { + const n = Math.max(0, Math.min(end, bytes.byteLength)); + let h = 0x811c9dc5; + for (let i = 0; i < n; i++) { + h ^= bytes[i] ?? 0; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function sliceHex(bytes: Uint8Array, start: number, end: number): string { + const s = Math.max(0, Math.min(start, bytes.byteLength)); + const e = Math.max(s, Math.min(end, bytes.byteLength)); + let out = ""; + for (let i = s; i < e; i++) { + out += (bytes[i] ?? 0).toString(16).padStart(2, "0"); + } + return out; +} + +function readHeaderU32(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 4 > bytes.byteLength) return null; + try { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); + } catch { + return null; + } +} + +function decodeOpcodeHistogram(bytes: Uint8Array): Readonly<{ + histogram: Readonly>; + valid: boolean; +}> { + const cmdCount = readHeaderU32(bytes, 24); + const cmdOffset = readHeaderU32(bytes, 16); + const cmdBytes = readHeaderU32(bytes, 20); + if (cmdCount === null || cmdOffset === null || cmdBytes === null) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + if (cmdCount === 0) { + return Object.freeze({ histogram: Object.freeze({}), valid: true }); + } + if (cmdOffset < 0 || cmdBytes < 0 || cmdOffset + cmdBytes > bytes.byteLength) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let off = cmdOffset; + const end = cmdOffset + cmdBytes; + const hist: Record = Object.create(null) as Record; + for (let i = 0; i < cmdCount; i++) { + if (off + 8 > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const opcode = dv.getUint16(off + 0, true); + const size = dv.getUint32(off + 4, true); + if (size < 8 || off + size > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const key = String(opcode); + hist[key] = (hist[key] ?? 0) + 1; + off += size; + } + return Object.freeze({ histogram: Object.freeze(hist), valid: off === end }); +} + +export const FRAME_AUDIT_ENABLED = envFlag("REZI_FRAME_AUDIT"); + +export function drawlistFingerprint(bytes: Uint8Array): DrawlistFingerprint { + const byteLen = bytes.byteLength; + const prefixLen = Math.min(4096, byteLen); + const cmdCount = readHeaderU32(bytes, 24); + const totalSize = readHeaderU32(bytes, 12); + const head16 = sliceHex(bytes, 0, Math.min(16, byteLen)); + const tailStart = Math.max(0, byteLen - 16); + const tail16 = sliceHex(bytes, tailStart, byteLen); + const decoded = decodeOpcodeHistogram(bytes); + return Object.freeze({ + byteLen, + hash32: toHex32(hashFnv1a32(bytes, byteLen)), + prefixHash32: toHex32(hashFnv1a32(bytes, prefixLen)), + cmdCount, + totalSize, + head16, + tail16, + opcodeHistogram: decoded.histogram, + cmdStreamValid: decoded.valid, + }); +} + +type AuditFields = Readonly>; + +export function emitFrameAudit(scope: string, stage: string, fields: AuditFields): void { + if (!FRAME_AUDIT_ENABLED) return; + try { + const g = globalThis as { + __reziFrameAuditSink?: (line: string) => void; + __reziFrameAuditContext?: () => Readonly>; + process?: { pid?: number; stderr?: { write?: (text: string) => void } }; + console?: { error?: (msg?: unknown) => void }; + }; + const context = + typeof g.__reziFrameAuditContext === "function" ? g.__reziFrameAuditContext() : null; + const pid = g.process?.pid; + const line = JSON.stringify({ + ts: new Date().toISOString(), + tMs: nowMs(), + pid: typeof pid === "number" && Number.isInteger(pid) ? pid : undefined, + layer: "core", + scope, + stage, + ...(context ?? {}), + ...fields, + }); + if (typeof g.__reziFrameAuditSink === "function") { + g.__reziFrameAuditSink(line); + return; + } + if (typeof g.process?.stderr?.write === "function") { + g.process.stderr.write(`${line}\n`); + return; + } + g.console?.error?.(line); + } catch { + // Never break rendering due to optional diagnostics. + } +} diff --git a/packages/core/src/renderer/__tests__/overlay.edge.test.ts b/packages/core/src/renderer/__tests__/overlay.edge.test.ts index bd190769..0a253025 100644 --- a/packages/core/src/renderer/__tests__/overlay.edge.test.ts +++ b/packages/core/src/renderer/__tests__/overlay.edge.test.ts @@ -1,43 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { renderToDrawlist } from "../renderToDrawlist.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function renderStrings( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }>, diff --git a/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts b/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts index 47b39297..60858122 100644 --- a/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts +++ b/packages/core/src/renderer/__tests__/persistentBlobKeys.test.ts @@ -2,17 +2,29 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { DrawlistBuilder, DrawlistTextRunSegment } from "../../drawlist/types.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; import { DEFAULT_BASE_STYLE } from "../renderToDrawlist/textStyle.js"; -import { renderCanvasWidgets } from "../renderToDrawlist/widgets/renderCanvasWidgets.js"; +import { + addBlobAligned, + renderCanvasWidgets, +} from "../renderToDrawlist/widgets/renderCanvasWidgets.js"; import { drawSegments } from "../renderToDrawlist/widgets/renderTextWidgets.js"; class CountingBuilder implements DrawlistBuilder { readonly blobCount = { value: 0 }; readonly textRunBlobCount = { value: 0 }; + readonly blobByteLens: number[] = []; private nextBlobId = 0; clear(): void {} clearTo(_cols: number, _rows: number): void {} fillRect(_x: number, _y: number, _w: number, _h: number): void {} + blitRect( + _srcX: number, + _srcY: number, + _w: number, + _h: number, + _dstX: number, + _dstY: number, + ): void {} drawText(_x: number, _y: number, _text: string): void {} pushClip(_x: number, _y: number, _w: number, _h: number): void {} popClip(): void {} @@ -46,17 +58,10 @@ class CountingBuilder implements DrawlistBuilder { _pxWidth?: number, _pxHeight?: number, ): void {} - blitRect( - _srcX: number, - _srcY: number, - _w: number, - _h: number, - _dstX: number, - _dstY: number, - ): void {} addBlob(_bytes: Uint8Array): number | null { this.blobCount.value += 1; + this.blobByteLens.push(_bytes.byteLength); const id = this.nextBlobId; this.nextBlobId += 1; return id; @@ -81,6 +86,13 @@ class CountingBuilder implements DrawlistBuilder { } describe("renderer blob usage", () => { + test("addBlobAligned forwards exact payload length", () => { + const builder = new CountingBuilder(); + const blobId = addBlobAligned(builder, new Uint8Array([1, 2, 3])); + assert.equal(blobId, 0); + assert.deepEqual(builder.blobByteLens, [3]); + }); + test("drawSegments uses text-run blobs for multi-segment lines", () => { const segments = [ { text: "left", style: { ...DEFAULT_BASE_STYLE, bold: true } }, @@ -109,8 +121,6 @@ describe("renderer blob usage", () => { children: [], dirty: false, selfDirty: false, - renderPacketKey: 0, - renderPacket: null, }; const imageNode = { @@ -128,8 +138,6 @@ describe("renderer blob usage", () => { children: [], dirty: false, selfDirty: false, - renderPacketKey: 0, - renderPacket: null, }; renderCanvasWidgets( diff --git a/packages/core/src/renderer/__tests__/render.golden.test.ts b/packages/core/src/renderer/__tests__/render.golden.test.ts index 588da217..e812db33 100644 --- a/packages/core/src/renderer/__tests__/render.golden.test.ts +++ b/packages/core/src/renderer/__tests__/render.golden.test.ts @@ -1,4 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { truncateMiddle, truncateWithEllipsis } from "../../layout/textMeasure.js"; @@ -62,34 +63,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function commitTree(vnode: VNode) { const allocator = createInstanceIdAllocator(1); const res = commitVNodeTree(null, vnode, { allocator }); diff --git a/packages/core/src/renderer/__tests__/renderer.damage.test.ts b/packages/core/src/renderer/__tests__/renderer.damage.test.ts index 4ca68f76..c35d442f 100644 --- a/packages/core/src/renderer/__tests__/renderer.damage.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.damage.test.ts @@ -64,6 +64,15 @@ class RecordingBuilder implements DrawlistBuilder { this.ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); } + blitRect( + _srcX: number, + _srcY: number, + _w: number, + _h: number, + _dstX: number, + _dstY: number, + ): void {} + drawText(x: number, y: number, text: string, style?: TextStyle): void { this.ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); } @@ -96,8 +105,6 @@ class RecordingBuilder implements DrawlistBuilder { drawImage(..._args: Parameters): void {} - blitRect(..._args: Parameters): void {} - buildInto(_dst: Uint8Array): DrawlistBuildResult { return this.build(); } @@ -314,6 +321,23 @@ function getRectById(scene: Scene, id: string): Rect { return rect ?? { x: 0, y: 0, w: 0, h: 0 }; } +function getRuntimeNodeById(root: RuntimeInstance, id: string): RuntimeInstance | null { + const stack: RuntimeInstance[] = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + const props = node.vnode.props as Readonly<{ id?: unknown }> | undefined; + if (props?.id === id) { + return node; + } + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child) stack.push(child); + } + } + return null; +} + function renderScene( scene: Scene, focusedId: FocusId, @@ -608,6 +632,33 @@ describe("renderer damage rect behavior", () => { assertFramebuffersEqual(nextFramebuffer, baseFramebuffer); }); + test("clean clipped subtree does not emit no-op clip commands in damage pass", () => { + const scene = buildScene( + ui.row({ id: "clip-root", width: 16, height: 1, overflow: "hidden" }, [ + ui.text("stable-child", { id: "clip-leaf" }), + ]), + viewport, + ); + const rootNode = getRuntimeNodeById(scene.tree, "clip-root"); + const childNode = getRuntimeNodeById(scene.tree, "clip-leaf"); + assert.ok(rootNode, "missing runtime node for clip-root"); + assert.ok(childNode, "missing runtime node for clip-leaf"); + if (!rootNode || !childNode) return; + + // Simulate an incremental pass where this container is visited but no child is renderable. + rootNode.dirty = true; + rootNode.selfDirty = false; + childNode.dirty = false; + childNode.selfDirty = false; + + const damageRect = getRectById(scene, "clip-root"); + const ops = renderScene(scene, null, { damageRect }); + const pushCount = ops.filter((op) => op.kind === "pushClip").length; + const popCount = ops.filter((op) => op.kind === "popClip").length; + assert.equal(pushCount, 0); + assert.equal(popCount, 0); + }); + test("focus update null -> first button matches full render", () => { const vnode = ui.column({ width: 20, height: 6 }, [ ui.button("a", "Alpha"), diff --git a/packages/core/src/renderer/__tests__/renderer.partial.test.ts b/packages/core/src/renderer/__tests__/renderer.partial.test.ts index 5bd664cd..1ae1b0dd 100644 --- a/packages/core/src/renderer/__tests__/renderer.partial.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.partial.test.ts @@ -15,15 +15,6 @@ type RecordedOp = | Readonly<{ kind: "clear" }> | Readonly<{ kind: "clearTo"; cols: number; rows: number; style?: TextStyle }> | Readonly<{ kind: "fillRect"; x: number; y: number; w: number; h: number; style?: TextStyle }> - | Readonly<{ - kind: "blitRect"; - srcX: number; - srcY: number; - w: number; - h: number; - dstX: number; - dstY: number; - }> | Readonly<{ kind: "drawText"; x: number; y: number; text: string; style?: TextStyle }> | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> | Readonly<{ kind: "popClip" }>; @@ -48,9 +39,7 @@ class RecordingBuilder implements DrawlistBuilder { this.ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); } - blitRect(srcX: number, srcY: number, w: number, h: number, dstX: number, dstY: number): void { - this.ops.push({ kind: "blitRect", srcX, srcY, w, h, dstX, dstY }); - } + blitRect(..._args: Parameters): void {} drawText(x: number, y: number, text: string, style?: TextStyle): void { this.ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); @@ -244,37 +233,6 @@ function applyOps(framebuffer: Framebuffer, ops: readonly RecordedOp[]): Framebu } break; } - case "blitRect": { - const copiedChars: string[] = []; - const copiedStyles: string[] = []; - for (let y = 0; y < op.h; y++) { - for (let x = 0; x < op.w; x++) { - const srcX = op.srcX + x; - const srcY = op.srcY + y; - if (inViewport(srcX, srcY)) { - const srcIdx = srcY * out.cols + srcX; - copiedChars.push(out.chars[srcIdx] ?? " "); - copiedStyles.push(out.styles[srcIdx] ?? ""); - } else { - copiedChars.push(" "); - copiedStyles.push(""); - } - } - } - let copiedIdx = 0; - for (let y = 0; y < op.h; y++) { - for (let x = 0; x < op.w; x++) { - writeCell( - op.dstX + x, - op.dstY + y, - copiedChars[copiedIdx] ?? " ", - copiedStyles[copiedIdx] ?? "", - ); - copiedIdx++; - } - } - break; - } case "drawText": { const key = styleKey(op.style); for (let i = 0; i < op.text.length; i++) { @@ -351,13 +309,6 @@ type ScenarioResult = Readonly<{ partialFrame: Framebuffer; }>; -type SequenceScenarioResult = Readonly<{ - fullFrame: Framebuffer; - partialFrame: Framebuffer; - fullOpsByFrame: readonly (readonly RecordedOp[])[]; - partialOpsByFrame: readonly (readonly RecordedOp[])[]; -}>; - function runCommitScenario( viewFn: (snapshot: Readonly) => VNode, initialSnapshot: Readonly, @@ -465,62 +416,6 @@ function runEventScenario( return { fullOps, partialOps, fullFrame, partialFrame }; } -function runCommitSequenceScenario( - viewFn: (snapshot: Readonly) => VNode, - initialSnapshot: Readonly, - updates: readonly Readonly[], - viewport: Viewport, - partialPlan: WidgetRenderPlan, -): SequenceScenarioResult { - const fullBuilder = new RecordingBuilder(); - const partialBuilder = new RecordingBuilder(); - const fullRenderer = new WidgetRenderer({ - backend: createNoopBackend(), - builder: fullBuilder, - }); - const partialRenderer = new WidgetRenderer({ - backend: createNoopBackend(), - builder: partialBuilder, - }); - - const blank = createFramebuffer(viewport); - let fullFrame = applyOps( - blank, - submitOps(fullRenderer, fullBuilder, viewFn, initialSnapshot, viewport, FULL_PLAN), - ); - let partialFrame = applyOps( - blank, - submitOps(partialRenderer, partialBuilder, viewFn, initialSnapshot, viewport, FULL_PLAN), - ); - assertFramebuffersEqual(fullFrame, partialFrame); - - const fullOpsByFrame: Array = []; - const partialOpsByFrame: Array = []; - - for (const nextSnapshot of updates) { - const fullOps = submitOps(fullRenderer, fullBuilder, viewFn, nextSnapshot, viewport, FULL_PLAN); - const partialOps = submitOps( - partialRenderer, - partialBuilder, - viewFn, - nextSnapshot, - viewport, - partialPlan, - ); - fullFrame = applyOps(fullFrame, fullOps); - partialFrame = applyOps(partialFrame, partialOps); - fullOpsByFrame.push(fullOps); - partialOpsByFrame.push(partialOps); - } - - return Object.freeze({ - fullFrame, - partialFrame, - fullOpsByFrame: Object.freeze(fullOpsByFrame), - partialOpsByFrame: Object.freeze(partialOpsByFrame), - }); -} - type EditSnapshot = Readonly<{ editIndex: number }>; function denseListView(snapshot: Readonly, count: number): VNode { const rows: VNode[] = []; @@ -562,104 +457,6 @@ function virtualListView(itemCount: number): VNode { ]); } -function buildLogsEntries(count: number): readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; -}[] { - const out = new Array<{ - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }>(count); - for (let i = 0; i < count; i++) { - out[i] = { - id: `log-${String(i)}`, - timestamp: i * 1000, - level: "info", - source: "bench", - message: `entry ${String(i).padStart(4, "0")} lorem ipsum`, - }; - } - return Object.freeze(out); -} - -function logsConsoleView( - entries: readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }[], - scrollTop: number, - onScroll: (next: number) => void, -): VNode { - return ui.logsConsole({ - id: "logs", - entries, - scrollTop, - onScroll, - }); -} - -function logsConsoleViewWithOverlay( - entries: readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }[], - scrollTop: number, - onScroll: (next: number) => void, -): VNode { - return ui.box({ border: "none", width: "full", height: "full" }, [ - ui.logsConsole({ - id: "logs", - entries, - scrollTop, - onScroll, - }), - ui.box( - { - border: "none", - width: 7, - height: 1, - position: "absolute", - top: 3, - left: 4, - }, - [ui.text("OVERLAY")], - ), - ]); -} - -function logsConsoleViewWithClippedAncestor( - entries: readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }[], - scrollTop: number, - onScroll: (next: number) => void, -): VNode { - return ui.box({ border: "none", width: "full", height: "full", overflow: "hidden", p: 1 }, [ - ui.logsConsole({ - id: "logs", - entries, - scrollTop, - onScroll, - }), - ]); -} - type LayoutSnapshot = Readonly<{ wide: boolean }>; function layoutSensitiveView(snapshot: Readonly): VNode { return ui.row({ gap: 1 }, [ @@ -815,116 +612,4 @@ describe("renderer partial dirty-subtree correctness", () => { assert.equal(pushCount > 0, true, "expected incremental frame to emit clips"); assert.equal(pushCount, popCount, "pushClip/popClip must be balanced"); }); - - test("logsConsole scroll 1 row/frame matches full framebuffer and emits blit", () => { - const entries = buildLogsEntries(320); - const onScroll = (_next: number) => {}; - const updates = Object.freeze( - Array.from({ length: 10 }, (_, i) => Object.freeze({ scrollTop: i + 1 })), - ); - const scenario = runCommitSequenceScenario<{ scrollTop: number }>( - (snapshot) => logsConsoleView(entries, snapshot.scrollTop, onScroll), - Object.freeze({ scrollTop: 0 }), - updates, - viewport, - PARTIAL_COMMIT_NO_STABILITY_PLAN, - ); - assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); - - let blitFrames = 0; - for (const ops of scenario.partialOpsByFrame) { - if (ops.some((op) => op.kind === "blitRect")) blitFrames++; - } - assert.equal(blitFrames > 0, true); - }); - - test("logsConsole multi-row scroll matches full framebuffer", () => { - const entries = buildLogsEntries(320); - const onScroll = (_next: number) => {}; - const updates = Object.freeze([ - Object.freeze({ scrollTop: 3 }), - Object.freeze({ scrollTop: 6 }), - Object.freeze({ scrollTop: 9 }), - Object.freeze({ scrollTop: 12 }), - ]); - const scenario = runCommitSequenceScenario<{ scrollTop: number }>( - (snapshot) => logsConsoleView(entries, snapshot.scrollTop, onScroll), - Object.freeze({ scrollTop: 0 }), - updates, - viewport, - PARTIAL_COMMIT_NO_STABILITY_PLAN, - ); - assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); - }); - - test("logsConsole alternating scroll directions matches full framebuffer", () => { - const entries = buildLogsEntries(320); - const onScroll = (_next: number) => {}; - const updates = Object.freeze([ - Object.freeze({ scrollTop: 6 }), - Object.freeze({ scrollTop: 4 }), - Object.freeze({ scrollTop: 7 }), - Object.freeze({ scrollTop: 5 }), - Object.freeze({ scrollTop: 8 }), - Object.freeze({ scrollTop: 6 }), - ]); - const scenario = runCommitSequenceScenario<{ scrollTop: number }>( - (snapshot) => logsConsoleView(entries, snapshot.scrollTop, onScroll), - Object.freeze({ scrollTop: 2 }), - updates, - viewport, - PARTIAL_COMMIT_NO_STABILITY_PLAN, - ); - assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); - }); - - test("logsConsole with overlapping absolute sibling skips blit and matches full framebuffer", () => { - const entries = buildLogsEntries(320); - const onScroll = (_next: number) => {}; - const updates = Object.freeze([ - Object.freeze({ scrollTop: 1 }), - Object.freeze({ scrollTop: 2 }), - Object.freeze({ scrollTop: 3 }), - Object.freeze({ scrollTop: 4 }), - ]); - const scenario = runCommitSequenceScenario<{ scrollTop: number }>( - (snapshot) => logsConsoleViewWithOverlay(entries, snapshot.scrollTop, onScroll), - Object.freeze({ scrollTop: 0 }), - updates, - viewport, - PARTIAL_COMMIT_NO_STABILITY_PLAN, - ); - assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); - for (const ops of scenario.partialOpsByFrame) { - assert.equal( - ops.some((op) => op.kind === "blitRect"), - false, - ); - } - }); - - test("logsConsole inside clipped ancestor skips blit and matches full framebuffer", () => { - const entries = buildLogsEntries(320); - const onScroll = (_next: number) => {}; - const updates = Object.freeze([ - Object.freeze({ scrollTop: 1 }), - Object.freeze({ scrollTop: 2 }), - Object.freeze({ scrollTop: 3 }), - Object.freeze({ scrollTop: 4 }), - ]); - const scenario = runCommitSequenceScenario<{ scrollTop: number }>( - (snapshot) => logsConsoleViewWithClippedAncestor(entries, snapshot.scrollTop, onScroll), - Object.freeze({ scrollTop: 0 }), - updates, - viewport, - PARTIAL_COMMIT_NO_STABILITY_PLAN, - ); - assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); - for (const ops of scenario.partialOpsByFrame) { - assert.equal( - ops.some((op) => op.kind === "blitRect"), - false, - ); - } - }); }); diff --git a/packages/core/src/renderer/__tests__/renderer.scrollBlit.benchmark.test.ts b/packages/core/src/renderer/__tests__/renderer.scrollBlit.benchmark.test.ts deleted file mode 100644 index 2eb0871d..00000000 --- a/packages/core/src/renderer/__tests__/renderer.scrollBlit.benchmark.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { assert, describe, test } from "@rezi-ui/testkit"; -import { ZREV_MAGIC, ZR_EVENT_BATCH_VERSION_V1 } from "../../abi.js"; -import type { Viewport, WidgetRenderPlan } from "../../app/widgetRenderer.js"; -import { WidgetRenderer } from "../../app/widgetRenderer.js"; -import type { RuntimeBackend } from "../../backend.js"; -import type { BackendEventBatch } from "../../backend.js"; -import { HEADER_SIZE } from "../../drawlist/builderBase.js"; -import type { VNode } from "../../index.js"; -import { ui } from "../../index.js"; -import { DEFAULT_TERMINAL_CAPS, type TerminalCaps } from "../../terminalCaps.js"; -import { defaultTheme } from "../../theme/defaultTheme.js"; - -const OP_BLIT_RECT = 14; -const FULL_PLAN: WidgetRenderPlan = Object.freeze({ - commit: true, - layout: true, - checkLayoutStability: true, -}); -const PARTIAL_PLAN: WidgetRenderPlan = Object.freeze({ - commit: true, - layout: false, - checkLayoutStability: false, -}); -const NOOP_SCROLL = (_next: number) => {}; - -type BenchCounters = Readonly<{ - bytesPerFrame: number; - opsPerFrame: number; - timePerFrameMs: number; - totalBlitOps: number; -}>; - -class CountingBackend implements RuntimeBackend { - private readonly frameBytes: number[] = []; - private readonly frameOps: number[] = []; - private readonly frameBlitOps: number[] = []; - - async start(): Promise {} - - async stop(): Promise {} - - dispose(): void {} - - postUserEvent(_tag: number, _payload: Uint8Array): void {} - - async getCaps(): Promise { - return DEFAULT_TERMINAL_CAPS; - } - - async requestFrame(drawlist: Uint8Array): Promise { - const parsed = parseDrawlistStats(drawlist); - this.frameBytes.push(drawlist.byteLength); - this.frameOps.push(parsed.cmdCount); - this.frameBlitOps.push(parsed.blitCount); - } - - pollEvents(): Promise { - return Promise.resolve(emptyEventBatch()); - } - - clear(): void { - this.frameBytes.length = 0; - this.frameOps.length = 0; - this.frameBlitOps.length = 0; - } - - counters(): BenchCounters { - return Object.freeze({ - bytesPerFrame: average(this.frameBytes), - opsPerFrame: average(this.frameOps), - timePerFrameMs: 0, - totalBlitOps: this.frameBlitOps.reduce((sum, value) => sum + value, 0), - }); - } -} - -function parseDrawlistStats( - drawlist: Uint8Array, -): Readonly<{ cmdCount: number; blitCount: number }> { - if (drawlist.byteLength < HEADER_SIZE) { - return Object.freeze({ cmdCount: 0, blitCount: 0 }); - } - - const dv = new DataView(drawlist.buffer, drawlist.byteOffset, drawlist.byteLength); - const cmdOffset = dv.getUint32(16, true); - const cmdBytes = dv.getUint32(20, true); - const cmdCount = dv.getUint32(24, true); - if ( - cmdOffset >= drawlist.byteLength || - cmdBytes === 0 || - cmdOffset + cmdBytes > drawlist.byteLength - ) { - return Object.freeze({ cmdCount, blitCount: 0 }); - } - - let blitCount = 0; - let off = cmdOffset; - const end = cmdOffset + cmdBytes; - while (off + 8 <= end) { - const opcode = dv.getUint16(off + 0, true); - const size = dv.getUint32(off + 4, true); - if (size < 8 || off + size > end) break; - if (opcode === OP_BLIT_RECT) blitCount++; - off += size; - } - return Object.freeze({ cmdCount, blitCount }); -} - -function emptyEventBatch(): BackendEventBatch { - const bytes = new Uint8Array(24); - const dv = new DataView(bytes.buffer); - dv.setUint32(0, ZREV_MAGIC, true); - dv.setUint32(4, ZR_EVENT_BATCH_VERSION_V1, true); - dv.setUint32(8, 24, true); - dv.setUint32(12, 0, true); - dv.setUint32(16, 0, true); - dv.setUint32(20, 0, true); - return { bytes, droppedBatches: 0, release() {} }; -} - -function average(values: readonly number[]): number { - if (values.length === 0) return 0; - let sum = 0; - for (const value of values) sum += value; - return sum / values.length; -} - -function noRenderHooks(): { enterRender: () => void; exitRender: () => void } { - return { enterRender: () => {}, exitRender: () => {} }; -} - -function buildLogsEntries(count: number): readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; -}[] { - const out = new Array<{ - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }>(count); - for (let i = 0; i < count; i++) { - out[i] = { - id: `log-${String(i)}`, - timestamp: i * 1000, - level: "info", - source: "bench", - message: `entry ${String(i).padStart(5, "0")} lorem ipsum dolor sit amet`, - }; - } - return Object.freeze(out); -} - -function logsView( - entries: readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }[], - scrollTop: number, -): VNode { - return ui.logsConsole({ - id: "logs", - entries, - scrollTop, - onScroll: NOOP_SCROLL, - }); -} - -function runLogsScrollBench( - entries: readonly { - id: string; - timestamp: number; - level: "info"; - source: string; - message: string; - }[], - viewport: Viewport, - updates: readonly number[], - mode: "full" | "partial", -): BenchCounters { - const backend = new CountingBackend(); - const renderer = new WidgetRenderer<{ scrollTop: number }>({ - backend, - }); - const view = (snapshot: Readonly<{ scrollTop: number }>) => logsView(entries, snapshot.scrollTop); - - const initialScrollTop = updates.length > 0 ? Math.max(0, (updates[0] ?? 0) - 1) : 0; - const bootstrap = renderer.submitFrame( - view, - Object.freeze({ scrollTop: initialScrollTop }), - viewport, - defaultTheme, - noRenderHooks(), - FULL_PLAN, - ); - assert.equal(bootstrap.ok, true); - - backend.clear(); - const times: number[] = []; - for (const nextScrollTop of updates) { - const plan = mode === "full" ? FULL_PLAN : PARTIAL_PLAN; - const t0 = performance.now(); - const submitted = renderer.submitFrame( - view, - Object.freeze({ scrollTop: nextScrollTop }), - viewport, - defaultTheme, - noRenderHooks(), - plan, - ); - const t1 = performance.now(); - assert.equal(submitted.ok, true); - times.push(t1 - t0); - } - - const counters = backend.counters(); - return Object.freeze({ - bytesPerFrame: counters.bytesPerFrame, - opsPerFrame: counters.opsPerFrame, - timePerFrameMs: average(times), - totalBlitOps: counters.totalBlitOps, - }); -} - -describe("renderer scroll blit benchmark harness", () => { - const viewport: Viewport = Object.freeze({ cols: 120, rows: 40 }); - const entries = buildLogsEntries(2400); - const maxAllowedSlowdown = 1.1; - - test("collects bytes/frame, ops/frame, and time/frame with lower costs under partial blit", () => { - const scenarios = Object.freeze([ - Object.freeze({ - name: "one-row", - updates: Array.from({ length: 80 }, (_, i) => 40 + i), - }), - Object.freeze({ - name: "multi-row", - updates: Array.from({ length: 40 }, (_, i) => 60 + i * 3), - }), - Object.freeze({ - name: "alternating", - updates: [120, 118, 121, 117, 122, 116, 123, 115, 124, 114], - }), - ]); - - for (const scenario of scenarios) { - const full = runLogsScrollBench(entries, viewport, scenario.updates, "full"); - const partial = runLogsScrollBench(entries, viewport, scenario.updates, "partial"); - - assert.equal( - partial.bytesPerFrame < full.bytesPerFrame, - true, - `${scenario.name}: bytes/frame`, - ); - assert.equal(partial.opsPerFrame < full.opsPerFrame, true, `${scenario.name}: ops/frame`); - assert.equal(partial.totalBlitOps > 0, true, `${scenario.name}: expected blit ops`); - assert.equal( - partial.timePerFrameMs <= full.timePerFrameMs * maxAllowedSlowdown, - true, - `${scenario.name}: time/frame`, - ); - } - }); -}); diff --git a/packages/core/src/renderer/__tests__/renderer.text.test.ts b/packages/core/src/renderer/__tests__/renderer.text.test.ts index b0bab201..1a243dd7 100644 --- a/packages/core/src/renderer/__tests__/renderer.text.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.text.test.ts @@ -1,22 +1,24 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + OP_DEF_BLOB, + OP_DEF_STRING, + OP_DRAW_TEXT, + OP_DRAW_TEXT_RUN, + OP_FREE_BLOB, + OP_FREE_STRING, + OP_POP_CLIP, + OP_PUSH_CLIP, + parseCommandHeaders, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { renderToDrawlist } from "../renderToDrawlist.js"; -const OP_DRAW_TEXT = 3; -const OP_PUSH_CLIP = 4; -const OP_POP_CLIP = 5; -const OP_DRAW_TEXT_RUN = 6; - const decoder = new TextDecoder(); -function u16(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint16(off, true); -} - function u32(bytes: Uint8Array, off: number): number { const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return dv.getUint32(off, true); @@ -27,19 +29,6 @@ function i32(bytes: Uint8Array, off: number): number { return dv.getInt32(off, true); } -type Header = Readonly<{ - cmdOffset: number; - cmdBytes: number; - stringsSpanOffset: number; - stringsCount: number; - stringsBytesOffset: number; - stringsBytesLen: number; - blobsSpanOffset: number; - blobsCount: number; - blobsBytesOffset: number; - blobsBytesLen: number; -}>; - type DrawTextCommand = Readonly<{ x: number; y: number; @@ -88,169 +77,151 @@ type ParsedFrame = Readonly<{ textRunBlobs: readonly TextRunBlob[]; }>; -function readHeader(bytes: Uint8Array): Header { - return { - cmdOffset: u32(bytes, 16), - cmdBytes: u32(bytes, 20), - stringsSpanOffset: u32(bytes, 28), - stringsCount: u32(bytes, 32), - stringsBytesOffset: u32(bytes, 36), - stringsBytesLen: u32(bytes, 40), - blobsSpanOffset: u32(bytes, 44), - blobsCount: u32(bytes, 48), - blobsBytesOffset: u32(bytes, 52), - blobsBytesLen: u32(bytes, 56), - }; -} - -function parseInternedStrings(bytes: Uint8Array, header: Header): readonly string[] { - if (header.stringsCount === 0) return Object.freeze([]); - - const tableEnd = header.stringsBytesOffset + header.stringsBytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - for (let i = 0; i < header.stringsCount; i++) { - const span = header.stringsSpanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - - const start = header.stringsBytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} +type StringResources = ReadonlyMap; function decodeStringSlice( - bytes: Uint8Array, - header: Header, - stringIndex: number, + strings: StringResources, + stringId: number, byteOff: number, byteLen: number, ): string { - assert.ok(stringIndex >= 0 && stringIndex < header.stringsCount, "string index in bounds"); - - const span = header.stringsSpanOffset + stringIndex * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - assert.ok(byteOff + byteLen <= strLen, "string slice must be in-bounds"); - - const start = header.stringsBytesOffset + strOff + byteOff; - const end = start + byteLen; - return decoder.decode(bytes.subarray(start, end)); + const raw = strings.get(stringId); + if (!raw) return ""; + const end = byteOff + byteLen; + if (end > raw.byteLength) return ""; + return decoder.decode(raw.subarray(byteOff, end)); } -function parseTextRunBlobs(bytes: Uint8Array, header: Header): readonly TextRunBlob[] { - if (header.blobsCount === 0) return Object.freeze([]); - - const blobsEnd = header.blobsBytesOffset + header.blobsBytesLen; - assert.ok(blobsEnd <= bytes.byteLength, "blob table must be in-bounds"); - - const out: TextRunBlob[] = []; - for (let i = 0; i < header.blobsCount; i++) { - const span = header.blobsSpanOffset + i * 8; - const blobOff = header.blobsBytesOffset + u32(bytes, span); - const blobLen = u32(bytes, span + 4); - const blobEnd = blobOff + blobLen; - assert.ok(blobEnd <= blobsEnd, "blob span must be in-bounds"); - - const segCount = u32(bytes, blobOff); - const segments: TextRunSegment[] = []; - - let segOff = blobOff + 4; - for (let seg = 0; seg < segCount; seg++) { - assert.ok(segOff + 28 <= blobEnd, "text run segment must be in-bounds"); - const stringIndex = u32(bytes, segOff + 16); - const byteOff = u32(bytes, segOff + 20); - const byteLen = u32(bytes, segOff + 24); - - segments.push({ - fg: u32(bytes, segOff + 0), - bg: u32(bytes, segOff + 4), - attrs: u32(bytes, segOff + 8), - stringIndex, +function decodeTextRunBlob(blob: Uint8Array, strings: StringResources): TextRunBlob { + if (blob.byteLength < 4) return Object.freeze({ segments: Object.freeze([]) }); + const segCount = u32(blob, 0); + const remaining = blob.byteLength - 4; + const stride = segCount > 0 && remaining === segCount * 40 ? 40 : 28; + const stringFieldOffset = stride === 40 ? 28 : 16; + const byteOffFieldOffset = stride === 40 ? 32 : 20; + const byteLenFieldOffset = stride === 40 ? 36 : 24; + + const segments: TextRunSegment[] = []; + let segOff = 4; + for (let i = 0; i < segCount; i++) { + if (segOff + stride > blob.byteLength) break; + const stringId = u32(blob, segOff + stringFieldOffset); + const byteOff = u32(blob, segOff + byteOffFieldOffset); + const byteLen = u32(blob, segOff + byteLenFieldOffset); + segments.push( + Object.freeze({ + fg: u32(blob, segOff + 0), + bg: u32(blob, segOff + 4), + attrs: u32(blob, segOff + 8), + stringIndex: stringId > 0 ? stringId - 1 : -1, byteOff, byteLen, - text: decodeStringSlice(bytes, header, stringIndex, byteOff, byteLen), - }); - - segOff += 28; - } - - out.push({ segments: Object.freeze(segments) }); + text: decodeStringSlice(strings, stringId, byteOff, byteLen), + }), + ); + segOff += stride; } - return Object.freeze(out); + return Object.freeze({ segments: Object.freeze(segments) }); } function parseFrame(bytes: Uint8Array): ParsedFrame { - const header = readHeader(bytes); - const strings = parseInternedStrings(bytes, header); - const textRunBlobs = parseTextRunBlobs(bytes, header); - + const stringsById = new Map(); + const textRunBlobsByIndex: TextRunBlob[] = []; const drawTexts: DrawTextCommand[] = []; const drawTextRuns: DrawTextRunCommand[] = []; const pushClips: PushClipCommand[] = []; let popClipCount = 0; - const cmdEnd = header.cmdOffset + header.cmdBytes; - let off = header.cmdOffset; - - while (off < cmdEnd) { - const opcode = u16(bytes, off); - const size = u32(bytes, off + 4); - assert.ok(size >= 8, "command size must be >= 8"); + for (const cmd of parseCommandHeaders(bytes)) { + const off = cmd.offset; + if (cmd.opcode === OP_DEF_STRING) { + if (cmd.size < 16) continue; + const stringId = u32(bytes, off + 8); + const byteLen = u32(bytes, off + 12); + const dataStart = off + 16; + const dataEnd = dataStart + byteLen; + if (dataEnd <= off + cmd.size) { + stringsById.set(stringId, Uint8Array.from(bytes.subarray(dataStart, dataEnd))); + } + continue; + } + if (cmd.opcode === OP_FREE_STRING) { + if (cmd.size >= 12) stringsById.delete(u32(bytes, off + 8)); + continue; + } + if (cmd.opcode === OP_DEF_BLOB) { + if (cmd.size < 16) continue; + const blobId = u32(bytes, off + 8); + const byteLen = u32(bytes, off + 12); + const dataStart = off + 16; + const dataEnd = dataStart + byteLen; + if (blobId > 0 && dataEnd <= off + cmd.size) { + const blob = Uint8Array.from(bytes.subarray(dataStart, dataEnd)); + textRunBlobsByIndex[blobId - 1] = decodeTextRunBlob(blob, stringsById); + } + continue; + } + if (cmd.opcode === OP_FREE_BLOB) { + const blobId = u32(bytes, off + 8); + if (blobId > 0) + textRunBlobsByIndex[blobId - 1] = Object.freeze({ segments: Object.freeze([]) }); + continue; + } - if (opcode === OP_DRAW_TEXT) { - assert.ok(size >= 48, "DRAW_TEXT command size"); - const stringIndex = u32(bytes, off + 16); + if (cmd.opcode === OP_DRAW_TEXT) { + assert.ok(cmd.size >= 48, "DRAW_TEXT command size"); + const stringId = u32(bytes, off + 16); const byteOff = u32(bytes, off + 20); const byteLen = u32(bytes, off + 24); drawTexts.push({ x: i32(bytes, off + 8), y: i32(bytes, off + 12), - stringIndex, + stringIndex: stringId > 0 ? stringId - 1 : -1, byteOff, byteLen, - text: decodeStringSlice(bytes, header, stringIndex, byteOff, byteLen), + text: decodeStringSlice(stringsById, stringId, byteOff, byteLen), fg: u32(bytes, off + 28), bg: u32(bytes, off + 32), attrs: u32(bytes, off + 36), }); - } else if (opcode === OP_DRAW_TEXT_RUN) { - assert.ok(size >= 24, "DRAW_TEXT_RUN command size"); + continue; + } + + if (cmd.opcode === OP_DRAW_TEXT_RUN) { + assert.ok(cmd.size >= 24, "DRAW_TEXT_RUN command size"); + const blobId = u32(bytes, off + 16); drawTextRuns.push({ x: i32(bytes, off + 8), y: i32(bytes, off + 12), - blobIndex: u32(bytes, off + 16), + blobIndex: blobId > 0 ? blobId - 1 : -1, }); - } else if (opcode === OP_PUSH_CLIP) { - assert.ok(size >= 24, "PUSH_CLIP command size"); + continue; + } + + if (cmd.opcode === OP_PUSH_CLIP) { + assert.ok(cmd.size >= 24, "PUSH_CLIP command size"); pushClips.push({ x: i32(bytes, off + 8), y: i32(bytes, off + 12), w: i32(bytes, off + 16), h: i32(bytes, off + 20), }); - } else if (opcode === OP_POP_CLIP) { - popClipCount++; + continue; } - off += size; + if (cmd.opcode === OP_POP_CLIP) { + popClipCount++; + } } - assert.equal(off, cmdEnd, "commands must parse exactly to cmd end"); - return { - strings, + strings: parseInternedStrings(bytes), drawTexts: Object.freeze(drawTexts), drawTextRuns: Object.freeze(drawTextRuns), pushClips: Object.freeze(pushClips), popClipCount, - textRunBlobs, + textRunBlobs: Object.freeze(textRunBlobsByIndex), }; } diff --git a/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts new file mode 100644 index 00000000..41feb264 --- /dev/null +++ b/packages/core/src/renderer/renderToDrawlist/overflowCulling.ts @@ -0,0 +1,109 @@ +import type { RuntimeInstance } from "../../runtime/commit.js"; + +type OverflowProps = Readonly<{ + overflow?: unknown; + shadow?: unknown; +}>; + +type LayerWrapperProps = Readonly<{ + backdrop?: unknown; + frameStyle?: unknown; +}>; + +function readShadowOffset(raw: unknown, fallback: number): number { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return fallback; + } + const value = Math.trunc(raw); + return Math.abs(value); +} + +function hasBoxShadowOverflow(node: RuntimeInstance): boolean { + if (node.vnode.kind !== "box") { + return false; + } + const shadow = (node.vnode.props as OverflowProps).shadow; + if (shadow === true) { + return true; + } + if (shadow === false || shadow === undefined || shadow === null) { + return false; + } + if (typeof shadow !== "object") { + return false; + } + const config = shadow as Readonly<{ + offsetX?: unknown; + offsetY?: unknown; + blur?: unknown; + spread?: unknown; + }>; + const offsetX = readShadowOffset(config.offsetX, 1); + const offsetY = readShadowOffset(config.offsetY, 1); + const blur = readShadowOffset(config.blur, 0); + const spread = readShadowOffset(config.spread, 0); + return offsetX > 0 || offsetY > 0 || blur > 0 || spread > 0; +} + +function hasVisibleOverflow(node: RuntimeInstance): boolean { + const kind = node.vnode.kind; + if (kind !== "row" && kind !== "column" && kind !== "grid" && kind !== "box") { + return false; + } + if (node.children.length === 0) { + return false; + } + const props = node.vnode.props as OverflowProps; + return props.overflow !== "hidden" && props.overflow !== "scroll"; +} + +function hasFiniteColor(raw: unknown): boolean { + return typeof raw === "number" && Number.isFinite(raw); +} + +function isPassThroughLayer(node: RuntimeInstance): boolean { + if (node.vnode.kind !== "layer") { + return false; + } + const props = node.vnode.props as LayerWrapperProps; + if (props.backdrop === "dim" || props.backdrop === "opaque") { + return false; + } + if (typeof props.frameStyle !== "object" || props.frameStyle === null) { + return true; + } + const frameStyle = props.frameStyle as Readonly<{ background?: unknown; border?: unknown }>; + return !hasFiniteColor(frameStyle.background) && !hasFiniteColor(frameStyle.border); +} + +function isTransparentOverflowWrapper(node: RuntimeInstance): boolean { + const kind = node.vnode.kind; + return ( + kind === "themed" || + kind === "focusZone" || + kind === "focusTrap" || + kind === "layers" || + isPassThroughLayer(node) + ); +} + +export function subtreeCanOverflowBounds(node: RuntimeInstance): boolean { + const stack: RuntimeInstance[] = [node]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + if (hasVisibleOverflow(current) || hasBoxShadowOverflow(current)) { + return true; + } + if (!isTransparentOverflowWrapper(current)) { + continue; + } + for (let i = current.children.length - 1; i >= 0; i--) { + const child = current.children[i]; + if (child) { + stack.push(child); + } + } + } + return false; +} diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 33ec6158..f92cd8fd 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -28,6 +28,7 @@ import { getTotalHeight, resolveVirtualListItemHeightSpec, } from "../../../widgets/virtualList.js"; +import { emitFrameAudit, FRAME_AUDIT_ENABLED } from "../../../perf/frameAudit.js"; import { asTextStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; import { isVisibleRect } from "../indices.js"; @@ -605,6 +606,32 @@ export function renderCollectionWidget( ? Math.min(rowCount, startIndex + visibleRows + overscan) : rowCount; + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit( + "tableWidget", + "table.layout", + Object.freeze({ + tableId: props.id, + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + innerW, + innerH, + bodyY, + bodyH, + rowCount, + headerHeight, + rowHeight: safeRowHeight, + virtualized, + startIndex, + endIndex, + visibleRows, + overscan, + }), + ); + } + if (tableStore) { tableStore.set(props.id, { viewportHeight: bodyH, startIndex, endIndex }); } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 32b3efbc..ad843a97 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -102,6 +102,20 @@ function clipEquals(a: ClipRect | undefined, b: ClipRect): boolean { return a !== undefined && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; } +function pushChildClipIfNeeded( + builder: DrawlistBuilder, + nodeStack: (RuntimeInstance | null)[], + insertIndex: number, + currentClip: ClipRect | undefined, + childClip: ClipRect | undefined, + queuedChildCount: number, +): void { + if (queuedChildCount <= 0 || !childClip || clipEquals(currentClip, childClip)) return; + builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); + // Keep clip pop sentinel below queued children so pop runs after subtree render. + nodeStack.splice(insertIndex, 0, null); +} + function rectIntersects(a: Rect, b: Rect): boolean { if (a.w <= 0 || a.h <= 0 || b.w <= 0 || b.h <= 0) return false; return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; @@ -177,9 +191,9 @@ function pushChildrenWithLayout( skipCleanSubtrees: boolean, forceSubtreeRender: boolean, stackDirection: "row" | "column" | undefined = undefined, -): void { +): number { const childCount = Math.min(node.children.length, layoutNode.children.length); - if (childCount <= 0) return; + if (childCount <= 0) return 0; let rangeStart = 0; let rangeEnd = childCount - 1; @@ -201,13 +215,14 @@ function pushChildrenWithLayout( stackDirection, damageRect, ); - if (!range) return; + if (!range) return 0; rangeStart = range.start; rangeEnd = range.end; } } } + let pushedChildren = 0; for (let i = rangeEnd; i >= rangeStart; i--) { const c = node.children[i]; const lc = layoutNode.children[i]; @@ -228,8 +243,10 @@ function pushChildrenWithLayout( styleStack.push(style); layoutStack.push(lc); clipStack.push(clip); + pushedChildren++; } } + return pushedChildren; } function readShadowDensity(raw: unknown): "light" | "medium" | "dense" | undefined { @@ -578,11 +595,8 @@ export function renderContainerWidget( childClip = viewportWithScrollbars.viewportRect; } - if (childClip && !clipEquals(currentClip, childClip)) { - builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, style, @@ -596,6 +610,14 @@ export function renderContainerWidget( forceChildrenRender, vnode.kind === "row" || vnode.kind === "column" ? vnode.kind : undefined, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "box": { @@ -732,11 +754,8 @@ export function renderContainerWidget( childClip = viewportWithScrollbars.viewportRect; } - if (childClip && !clipEquals(currentClip, childClip)) { - builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, style, @@ -749,6 +768,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "modal": { @@ -795,11 +822,8 @@ export function renderContainerWidget( const ch = clampNonNegative(rect.h - 2); const childClip: ClipRect = { x: cx, y: cy, w: cw, h: ch }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(cx, cy, cw, ch); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, surfaceStyle, @@ -812,6 +836,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "focusZone": @@ -894,11 +926,8 @@ export function renderContainerWidget( h: clampNonNegative(rect.h - borderInset * 2), } : currentClip; - if (childClip && !clipEquals(currentClip, childClip)) { - builder.pushClip(childClip.x, childClip.y, childClip.w, childClip.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, layerStyle, @@ -911,6 +940,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "splitPane": { @@ -921,13 +958,10 @@ export function renderContainerWidget( const dividerStyle = mergeTextStyle(parentStyle, { fg: theme.colors.border }); const childClip: ClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(rect.x, rect.y, rect.w, rect.h); - nodeStack.push(null); - } + const childStackInsertIndex = nodeStack.length; // Render children (handled by layout) - pushChildrenWithLayout( + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, parentStyle, @@ -940,6 +974,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); // Render dividers between panels const childCount = Math.min(node.children.length, layoutNode.children.length); @@ -982,11 +1024,8 @@ export function renderContainerWidget( if (!isVisibleRect(rect)) break; const childClip: ClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(rect.x, rect.y, rect.w, rect.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, parentStyle, @@ -999,6 +1038,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } case "resizablePanel": { @@ -1006,11 +1053,8 @@ export function renderContainerWidget( if (!isVisibleRect(rect)) break; const childClip: ClipRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }; - if (!clipEquals(currentClip, childClip)) { - builder.pushClip(rect.x, rect.y, rect.w, rect.h); - nodeStack.push(null); - } - pushChildrenWithLayout( + const childStackInsertIndex = nodeStack.length; + const queuedChildCount = pushChildrenWithLayout( node, layoutNode, parentStyle, @@ -1023,6 +1067,14 @@ export function renderContainerWidget( skipCleanSubtrees, forceChildrenRender, ); + pushChildClipIfNeeded( + builder, + nodeStack, + childStackInsertIndex, + currentClip, + childClip, + queuedChildCount, + ); break; } default: diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts index 1c2bbd53..bc3924e6 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderCanvasWidgets.ts @@ -222,15 +222,8 @@ export function drawPlaceholderBox( builder.popClip(); } -function align4(value: number): number { - return (value + 3) & ~3; -} - export function addBlobAligned(builder: DrawlistBuilder, bytes: Uint8Array): number | null { - if ((bytes.byteLength & 3) === 0) return builder.addBlob(bytes); - const padded = new Uint8Array(align4(bytes.byteLength)); - padded.set(bytes); - return builder.addBlob(padded); + return builder.addBlob(bytes); } export function rgbToHex(color: ReturnType): string { diff --git a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts index cf935228..8dde3447 100644 --- a/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts +++ b/packages/core/src/runtime/__tests__/commit.fastReuse.regression.test.ts @@ -3,7 +3,7 @@ import { ui } from "../../index.js"; import { commitVNodeTree } from "../commit.js"; import { createInstanceIdAllocator } from "../instance.js"; -test("commit: leaf fast reuse does not ignore textOverflow changes", () => { +test("commit: leaf update applies textOverflow changes in-place", () => { const allocator = createInstanceIdAllocator(1); const v0 = ui.text("hello"); @@ -14,12 +14,14 @@ test("commit: leaf fast reuse does not ignore textOverflow changes", () => { const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); - assert.notEqual(c1.value.root, c0.value.root); + assert.equal(c1.value.root, c0.value.root); const nextProps = c1.value.root.vnode.props as { textOverflow?: unknown }; assert.equal(nextProps.textOverflow, "ellipsis"); + assert.equal(c1.value.root.selfDirty, true); + assert.equal(c1.value.root.dirty, true); }); -test("commit: leaf fast reuse does not ignore text id changes", () => { +test("commit: leaf update applies text id changes in-place", () => { const allocator = createInstanceIdAllocator(1); const v0 = ui.text("hello", { id: "a" }); @@ -30,9 +32,11 @@ test("commit: leaf fast reuse does not ignore text id changes", () => { const c1 = commitVNodeTree(c0.value.root, v1, { allocator }); if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); - assert.notEqual(c1.value.root, c0.value.root); + assert.equal(c1.value.root, c0.value.root); const nextProps = c1.value.root.vnode.props as { id?: unknown }; assert.equal(nextProps.id, "b"); + assert.equal(c1.value.root.selfDirty, true); + assert.equal(c1.value.root.dirty, true); }); test("commit: leaf fast reuse records reusedInstanceIds", () => { @@ -155,3 +159,33 @@ test("commit: semantically equal box transition keeps fast reuse", () => { assert.equal(c1.value.root, c0.value.root); }); + +test("commit: container fast-reuse rewrites parent committed vnode children", () => { + const allocator = createInstanceIdAllocator(1); + + const c0 = commitVNodeTree( + null, + ui.column({}, [ui.text("child", { style: { strikethrough: false } })]), + { allocator }, + ); + if (!c0.ok) assert.fail(`commit failed: ${c0.fatal.code}: ${c0.fatal.detail}`); + + const c1 = commitVNodeTree( + c0.value.root, + ui.column({}, [ui.text("child", { style: { strikethrough: true } })]), + { allocator }, + ); + if (!c1.ok) assert.fail(`commit failed: ${c1.fatal.code}: ${c1.fatal.detail}`); + + assert.equal(c1.value.root, c0.value.root); + + const parentVNode = c1.value.root.vnode as { children?: readonly { props?: unknown }[] }; + const vnodeChild = parentVNode.children?.[0]; + assert.ok(vnodeChild !== undefined); + assert.equal(vnodeChild, c1.value.root.children[0]?.vnode); + + const childProps = (vnodeChild?.props ?? {}) as { + style?: { strikethrough?: unknown }; + }; + assert.equal(childProps.style?.strikethrough, true); +}); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index ba2d9b88..da81a805 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -1334,6 +1334,11 @@ function commitContainer( childOrderStable && canFastReuseContainerSelf(prev.vnode, vnode) ) { + // Even when child RuntimeInstance references are stable, child VNodes may have + // been updated via in-place child commits. Keep the parent VNode's committed + // child wiring in sync so layout traverses the same tree shape as runtime. + const fastReuseCommittedChildren = prev.children.map((child) => child.vnode); + (prev as { vnode: VNode }).vnode = rewriteCommittedVNode(vnode, fastReuseCommittedChildren); // All children are identical references → reuse parent entirely. // Propagate dirty from children: a child may have been mutated in-place // with dirty=true even though it returned the same reference. diff --git a/packages/core/src/theme/__tests__/theme.extend.test.ts b/packages/core/src/theme/__tests__/theme.extend.test.ts index 907cc2a4..f67fae1f 100644 --- a/packages/core/src/theme/__tests__/theme.extend.test.ts +++ b/packages/core/src/theme/__tests__/theme.extend.test.ts @@ -110,7 +110,8 @@ describe("theme.extend", () => { assert.notEqual(extended.colors, mutableBase.colors); assert.notEqual(extended.colors.bg, mutableBase.colors.bg); - assert.notEqual(extended.colors.bg.base, mutableBase.colors.bg.base); + // Packed Rgb24 primitives are value-equal; verify the inherited value is preserved. + assert.equal(extended.colors.bg.base, mutableBase.colors.bg.base); assert.equal(mutableBase.colors.bg.base, darkTheme.colors.bg.base); }); diff --git a/packages/core/src/theme/__tests__/theme.validation.test.ts b/packages/core/src/theme/__tests__/theme.validation.test.ts index 5d36a8da..06458347 100644 --- a/packages/core/src/theme/__tests__/theme.validation.test.ts +++ b/packages/core/src/theme/__tests__/theme.validation.test.ts @@ -174,63 +174,53 @@ describe("theme.validateTheme", () => { ); }); - test("throws when RGB channel is greater than 255", () => { + test("throws when color value exceeds 0x00FFFFFF", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "r"], 256); + setPath(theme, ["colors", "accent", "primary"], 0x01000000); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.r: channel "r" must be an integer 0..255 (received 256)', + "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received 16777216)", ); }); - test("throws when RGB channel is less than 0", () => { + test("throws when color value is negative", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "g"], -1); + setPath(theme, ["colors", "accent", "primary"], -1); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.g: channel "g" must be an integer 0..255 (received -1)', + "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received -1)", ); }); - test("throws when RGB channel is non-integer", () => { + test("throws when color value is non-integer", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "b"], 1.5); + setPath(theme, ["colors", "accent", "primary"], 1.5); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.b: channel "b" must be an integer 0..255 (received 1.5)', + "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received 1.5)", ); }); - test("throws when RGB channel is not a number", () => { + test("throws when color value is not a number", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "r"], "255"); + setPath(theme, ["colors", "accent", "primary"], "255"); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.r: channel "r" must be an integer 0..255 (received "255")', + 'Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received "255")', ); }); - test("throws when RGB channel is missing", () => { + test("throws when color value is an object", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary", "g"], undefined); + setPath(theme, ["colors", "info"], { r: 0, g: 0, b: 255 }); expectValidationError( theme, - 'Theme validation failed at colors.accent.primary.g: channel "g" must be an integer 0..255 (received undefined)', - ); - }); - - test("throws when a color token is not an RGB object", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "info"], 7); - - expectValidationError( - theme, - "Theme validation failed at colors.info: expected RGB object { r, g, b } (received 7)", + "Theme validation failed at colors.info: expected packed Rgb24 integer 0..0x00FFFFFF (received [object])", ); }); diff --git a/packages/core/src/theme/resolve.ts b/packages/core/src/theme/resolve.ts index 16ac9f28..5d7a665a 100644 --- a/packages/core/src/theme/resolve.ts +++ b/packages/core/src/theme/resolve.ts @@ -68,10 +68,10 @@ export type ResolveColorResult = { ok: true; value: Rgb24 } | { ok: false; error * @example * ```typescript * const color = resolveColorToken(darkTheme, "fg.primary"); - * // { r: 230, g: 225, b: 207 } + * // 0xe6e1cf (packed Rgb24) * * const error = resolveColorToken(darkTheme, "error"); - * // { r: 240, g: 113, b: 120 } + * // 0xf07178 (packed Rgb24) * ``` */ export function resolveColorToken(theme: ThemeDefinition, path: ColorPath): Rgb24; diff --git a/packages/core/src/theme/validate.ts b/packages/core/src/theme/validate.ts index b025a435..cfe4169f 100644 --- a/packages/core/src/theme/validate.ts +++ b/packages/core/src/theme/validate.ts @@ -92,25 +92,11 @@ function throwMissingPaths(theme: unknown): void { } function validateRgb(path: string, value: unknown): void { - if (!isRecord(value)) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 0x00ffffff) { throw new Error( - `Theme validation failed at ${path}: expected RGB object { r, g, b } (received ${formatValue(value)})`, + `Theme validation failed at ${path}: expected packed Rgb24 integer 0..0x00FFFFFF (received ${formatValue(value)})`, ); } - - for (const channel of ["r", "g", "b"] as const) { - const channelValue = value[channel]; - if ( - typeof channelValue !== "number" || - !Number.isInteger(channelValue) || - channelValue < 0 || - channelValue > 255 - ) { - throw new Error( - `Theme validation failed at ${path}.${channel}: channel "${channel}" must be an integer 0..255 (received ${formatValue(channelValue)})`, - ); - } - } } function validateSpacingValue(path: string, value: unknown): void { diff --git a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts index eccc42ed..96fe3414 100644 --- a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts +++ b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts @@ -1,4 +1,8 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { + parseDrawTextCommands as parseDecodedDrawTextCommands, + parseInternedStrings, +} from "../../__tests__/drawlistDecode.js"; import { type DrawlistBuilder, type Theme, @@ -39,32 +43,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table in bounds"); - - const decoder = new TextDecoder(); - const out: string[] = []; - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.ok(end <= tableEnd, "string span in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - type DrawTextCommand = Readonly<{ text: string; fg: number; @@ -77,22 +55,24 @@ type DrawTextCommand = Readonly<{ }>; function parseDrawTextCommands(bytes: Uint8Array): readonly DrawTextCommand[] { - const strings = parseInternedStrings(bytes); + const decoded = parseDecodedDrawTextCommands(bytes); const cmdOffset = u32(bytes, 16); const cmdBytes = u32(bytes, 20); const end = cmdOffset + cmdBytes; const out: DrawTextCommand[] = []; + let drawTextIndex = 0; let off = cmdOffset; while (off < end) { const opcode = u16(bytes, off); const size = u32(bytes, off + 4); if (opcode === 3 && size >= 48) { - const stringIndex = u32(bytes, off + 16); + const decodedCmd = decoded[drawTextIndex]; + drawTextIndex += 1; const isV3 = size >= 60; const reserved = u32(bytes, off + 40); out.push({ - text: strings[stringIndex] ?? "", + text: decodedCmd?.text ?? "", fg: u32(bytes, off + 28), bg: u32(bytes, off + 32), attrs: u32(bytes, off + 36), @@ -552,19 +532,15 @@ describe("basic widgets render to drawlist", () => { assert.equal(docs.underlineColorRgb, 0x010203); }); - test("link encodes hyperlink refs on v3 and degrades on v1", () => { + test("link encodes hyperlink refs", () => { const v3 = renderBytesV3(ui.link("https://example.com", "Docs"), { cols: 40, rows: 4 }); - const v1 = renderBytes(ui.link("https://example.com", "Docs"), { cols: 40, rows: 4 }); assert.equal(parseOpcodes(v3).includes(8), false); - assert.equal(parseOpcodes(v1).includes(8), false); assert.equal(parseInternedStrings(v3).includes("https://example.com"), true); const v3Docs = parseDrawTextCommands(v3).find((cmd) => cmd.text === "Docs"); - const v1Docs = parseDrawTextCommands(v1).find((cmd) => cmd.text === "Docs"); assert.equal((v3Docs?.linkUriRef ?? 0) > 0, true); - assert.equal(v1Docs?.linkUriRef ?? 0, 0); }); - test("codeEditor diagnostics use curly underline + token color on v3", () => { + test("codeEditor diagnostics use curly underline + token color", () => { const theme = createTheme({ colors: { "diagnostic.warning": (1 << 16) | (2 << 8) | 3, @@ -584,8 +560,6 @@ describe("basic widgets render to drawlist", () => { onScroll: () => undefined, }); const v3 = renderBytesV3(vnode, { cols: 30, rows: 4 }, { theme }); - const v1 = renderBytes(vnode, { cols: 30, rows: 4 }, { theme }); - const v3WarnStyles = parseDrawTextCommands(v3).filter((cmd) => cmd.text === "warn"); assert.equal( v3WarnStyles.some((cmd) => { @@ -597,22 +571,6 @@ describe("basic widgets render to drawlist", () => { }), true, ); - - const v1WarnStyles = parseDrawTextCommands(v1).filter((cmd) => cmd.text === "warn"); - assert.equal( - v1WarnStyles.some((cmd) => (cmd.attrs & ATTR_UNDERLINE) !== 0), - true, - ); - assert.equal( - v1WarnStyles.some( - (cmd) => - cmd.underlineStyle !== 0 || - cmd.underlineColorRgb !== 0 || - cmd.linkUriRef !== 0 || - cmd.linkIdRef !== 0, - ), - false, - ); }); test("codeEditor applies syntax token colors for mainstream language presets", () => { diff --git a/packages/core/src/widgets/__tests__/graphics.golden.test.ts b/packages/core/src/widgets/__tests__/graphics.golden.test.ts index bd133f7b..2a043e6d 100644 --- a/packages/core/src/widgets/__tests__/graphics.golden.test.ts +++ b/packages/core/src/widgets/__tests__/graphics.golden.test.ts @@ -1,4 +1,5 @@ import { assert, assertBytesEqual, describe, readFixture, test } from "@rezi-ui/testkit"; +import { parseBlobById } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder, ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -332,19 +333,19 @@ describe("graphics/widgets/style (locked) - zrdl-v1 graphics fixtures", () => { const payloadOff = findCommandPayload(actual, OP_DRAW_TEXT_RUN); assert.equal(payloadOff !== null, true); if (payloadOff === null) return; - const blobIndex = u32(actual, payloadOff + 8); - const blobsSpanOffset = u32(actual, 44); - const blobsBytesOffset = u32(actual, 52); - const blobByteOff = u32(actual, blobsSpanOffset + blobIndex * 8); - const firstSegmentReserved = u32(actual, blobsBytesOffset + blobByteOff + 4 + 12); - const firstSegmentUnderlineRgb = u32(actual, blobsBytesOffset + blobByteOff + 4 + 16); - const thirdSegmentReserved = u32(actual, blobsBytesOffset + blobByteOff + 4 + 12 + 40 * 2); - const thirdSegmentUnderlineRgb = u32(actual, blobsBytesOffset + blobByteOff + 4 + 16 + 40 * 2); + const blobId = u32(actual, payloadOff + 8); + const blob = parseBlobById(actual, blobId); + assert.equal(blob !== null, true); + if (!blob) return; + const firstSegmentReserved = u32(blob, 4 + 12); + const firstSegmentUnderlineRgb = u32(blob, 4 + 16); + const thirdSegmentReserved = u32(blob, 4 + 12 + 40 * 2); + const thirdSegmentUnderlineRgb = u32(blob, 4 + 16 + 40 * 2); const firstDecoded = decodeStyleV3(firstSegmentReserved, firstSegmentUnderlineRgb); const thirdDecoded = decodeStyleV3(thirdSegmentReserved, thirdSegmentUnderlineRgb); assert.deepEqual(firstDecoded, { underlineStyle: 3, - underlineColorRgb: 0xff3366, + underlineColorRgb: 0xffffff, }); assert.deepEqual(thirdDecoded, { underlineStyle: 5, diff --git a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts index f9e05b7f..603f71f7 100644 --- a/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts +++ b/packages/core/src/widgets/__tests__/graphicsWidgets.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseBlobById, parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import type { DrawlistBuilder, VNode } from "../../index.js"; import { createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; @@ -43,26 +44,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - if (count === 0) return Object.freeze([]); - const tableEnd = bytesOffset + bytesLen; - const decoder = new TextDecoder(); - const out: string[] = []; - for (let i = 0; i < count; i++) { - const off = u32(bytes, spanOffset + i * 8); - const len = u32(bytes, spanOffset + i * 8 + 4); - const start = bytesOffset + off; - const end = start + len; - assert.equal(end <= tableEnd, true); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function findCommandPayload(bytes: Uint8Array, opcode: number): number | null { const cmdOffset = u32(bytes, 16); const cmdBytes = u32(bytes, 20); @@ -169,7 +150,7 @@ describe("graphics widgets", () => { test("link encodes hyperlink refs and keeps label text payload", () => { const vnode = ui.link({ url: "https://example.com", label: "Docs", id: "docs-link" }); const bytes = renderBytes(vnode, () => createDrawlistBuilder()); - assert.equal(parseStrings(bytes).includes("Docs"), true); + assert.equal(parseInternedStrings(bytes).includes("Docs"), true); const textPayload = findCommandPayload(bytes, 3); assert.equal(textPayload !== null, true); if (textPayload === null) return; @@ -246,15 +227,16 @@ describe("graphics widgets", () => { const payloadOff = findCommandPayload(bytes, 8); assert.equal(payloadOff !== null, true); if (payloadOff === null) return; - const blobOff = u32(bytes, payloadOff + 12); - const blobLen = u32(bytes, payloadOff + 16); + const blobId = u32(bytes, payloadOff + 12); + const blobReserved = u32(bytes, payloadOff + 16); const blitterCode = u8(bytes, payloadOff + 20); assert.equal(blitterCode, 2); - assert.equal(blobLen, width * 2 * height * 4 * 4); - const blobsBytesOffset = u32(bytes, 52); - const blobsBytesLen = u32(bytes, 56); - assert.equal(blobOff + blobLen <= blobsBytesLen, true); - assert.equal(blobsBytesOffset + blobOff + blobLen <= bytes.byteLength, true); + assert.equal(blobReserved, 0); + assert.equal(blobId > 0, true); + const blob = parseBlobById(bytes, blobId); + assert.equal(blob !== null, true); + if (!blob) return; + assert.equal(blob.length, width * 2 * height * 4 * 4); }); test("image route detects PNG format for DRAW_IMAGE", () => { @@ -349,7 +331,7 @@ describe("graphics widgets", () => { ); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("Logo")), + parseInternedStrings(bytes).some((value) => value.includes("Logo")), true, ); }); @@ -449,7 +431,7 @@ describe("graphics widgets", () => { assert.equal(parseOpcodes(bytes).includes(8), false); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("sourceWidth/sourceHeight")), + parseInternedStrings(bytes).some((value) => value.includes("sourceWidth/sourceHeight")), true, ); }); @@ -469,7 +451,7 @@ describe("graphics widgets", () => { assert.equal(parseOpcodes(bytes).includes(8), false); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("Logo")), + parseInternedStrings(bytes).some((value) => value.includes("Logo")), true, ); }); @@ -481,7 +463,7 @@ describe("graphics widgets", () => { ); assert.equal(parseOpcodes(bytes).includes(8) || parseOpcodes(bytes).includes(9), true); assert.equal( - parseStrings(bytes).some((value) => value.includes("Logo")), + parseInternedStrings(bytes).some((value) => value.includes("Logo")), false, ); }); @@ -501,7 +483,7 @@ describe("graphics widgets", () => { ); assert.equal(parseOpcodes(bytes).includes(9), false); assert.equal( - parseStrings(bytes).some((value) => value.includes("Broken image")), + parseInternedStrings(bytes).some((value) => value.includes("Broken image")), true, ); }); @@ -562,10 +544,10 @@ describe("graphics widgets", () => { const payloadOff = findCommandPayload(bytes, 8); assert.equal(payloadOff !== null, true); if (payloadOff === null) return; - const blobOff = u32(bytes, payloadOff + 12); - const blobLen = u32(bytes, payloadOff + 16); - const blobsBytesOffset = u32(bytes, 52); - const rgba = bytes.subarray(blobsBytesOffset + blobOff, blobsBytesOffset + blobOff + blobLen); + const blobId = u32(bytes, payloadOff + 12); + const rgba = parseBlobById(bytes, blobId); + assert.equal(rgba !== null, true); + if (!rgba) return; const hasVisiblePixel = rgba.some((value, index) => index % 4 === 3 && value !== 0); assert.equal(hasVisiblePixel, true); }); diff --git a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts index 8d10f432..fca30cd4 100644 --- a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts +++ b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import type { RuntimeBreadcrumbSnapshot } from "../../app/runtimeBreadcrumbs.js"; import { type VNode, ZR_CURSOR_SHAPE_BAR, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; @@ -7,38 +8,6 @@ import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { inspectorOverlay } from "../inspectorOverlay.js"; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - - return Object.freeze(out); -} - function renderStrings( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }> = { cols: 100, rows: 30 }, diff --git a/packages/core/src/widgets/__tests__/pagination.test.ts b/packages/core/src/widgets/__tests__/pagination.test.ts index d1ce4396..1b8ab376 100644 --- a/packages/core/src/widgets/__tests__/pagination.test.ts +++ b/packages/core/src/widgets/__tests__/pagination.test.ts @@ -128,7 +128,10 @@ describe("pagination ids and vnode", () => { const zoneNode = children[0]; assert.equal(zoneNode?.kind, "focusZone"); if (zoneNode?.kind !== "focusZone") return; - const ids = zoneNode.children + const controlsRow = zoneNode.children[0]; + assert.equal(controlsRow?.kind, "row"); + if (controlsRow?.kind !== "row") return; + const ids = controlsRow.children .filter((child) => child.kind === "button") .map((child) => (child.kind === "button" ? child.props.id : "")); assert.equal(ids.includes(getPaginationControlId("pages", "first")), true); diff --git a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts index 513e8be2..e171a9e8 100644 --- a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts +++ b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -40,31 +41,6 @@ function parseOpcodes(bytes: Uint8Array): readonly number[] { return Object.freeze(out); } -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table in bounds"); - - const decoder = new TextDecoder(); - const out: string[] = []; - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const strOff = u32(bytes, span); - const strLen = u32(bytes, span + 4); - const start = bytesOffset + strOff; - const end = start + strLen; - assert.ok(end <= tableEnd, "string span in bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - type CommandStyle = Readonly<{ opcode: number; fg: number; diff --git a/packages/core/src/widgets/__tests__/style.inheritance.test.ts b/packages/core/src/widgets/__tests__/style.inheritance.test.ts index f398feeb..b3cef6f3 100644 --- a/packages/core/src/widgets/__tests__/style.inheritance.test.ts +++ b/packages/core/src/widgets/__tests__/style.inheritance.test.ts @@ -32,6 +32,25 @@ function resolveRootBoxRowText( return resolveChain([root, box, row, text]); } +function computeExpectedAttrs(style: Readonly): number { + let attrs = 0; + if (style.bold) attrs |= 1 << 0; + if (style.italic) attrs |= 1 << 1; + if (style.underline || (style.underlineStyle !== undefined && style.underlineStyle !== "none")) { + attrs |= 1 << 2; + } + if (style.inverse) attrs |= 1 << 3; + if (style.dim) attrs |= 1 << 4; + if (style.strikethrough) attrs |= 1 << 5; + if (style.overline) attrs |= 1 << 6; + if (style.blink) attrs |= 1 << 7; + return attrs >>> 0; +} + +function withAttrs(style: T): T & Readonly<{ attrs: number }> { + return { ...style, attrs: computeExpectedAttrs(style) }; +} + describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { test("inherits root fg/bg when Box, Row, and Text are unset", () => { const resolved = resolveRootBoxRowText( @@ -41,11 +60,14 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { - fg: ROOT_FG, - bg: ROOT_BG, - bold: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: true, + }), + ); }); test("Box fg override wins while bg still inherits from Root", () => { @@ -56,10 +78,13 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { - fg: BOX_FG, - bg: ROOT_BG, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: BOX_FG, + bg: ROOT_BG, + }), + ); }); test("Row bg override wins while fg inherits from Box", () => { @@ -70,10 +95,13 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { - fg: BOX_FG, - bg: ROW_BG, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: BOX_FG, + bg: ROW_BG, + }), + ); }); test("Text override wins over Row/Box/Root and unset fields continue inheriting", () => { @@ -84,13 +112,16 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { fg: TEXT_FG, bold: true }, ); - assert.deepEqual(resolved, { - fg: TEXT_FG, - bg: ROOT_BG, - bold: true, - italic: true, - underline: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: TEXT_FG, + bg: ROOT_BG, + bold: true, + italic: true, + underline: true, + }), + ); }); test("Text with no local overrides inherits nearest defined values", () => { @@ -101,13 +132,16 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { {}, ); - assert.deepEqual(resolved, { - fg: ROOT_FG, - bg: ROOT_BG, - bold: true, - dim: false, - italic: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: true, + dim: false, + italic: true, + }), + ); }); test("Explicit false in Box overrides Root true through deeper unset descendants", () => { @@ -118,11 +152,14 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { undefined, ); - assert.deepEqual(resolved, { - fg: ROOT_FG, - bg: ROOT_BG, - underline: false, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + underline: false, + }), + ); }); test("Explicit true in Text overrides Root false", () => { @@ -133,12 +170,15 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { blink: true }, ); - assert.deepEqual(resolved, { - fg: ROOT_FG, - bg: ROOT_BG, - italic: true, - blink: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + italic: true, + blink: true, + }), + ); }); test("fg/bg inheritance stays independent across levels with mixed overrides", () => { @@ -149,12 +189,15 @@ describe("mergeTextStyle Root->Box->Row->Text inheritance", () => { { italic: true }, ); - assert.deepEqual(resolved, { - fg: BOX_FG, - bg: ROW_BG, - dim: true, - italic: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: BOX_FG, + bg: ROW_BG, + dim: true, + italic: true, + }), + ); }); }); @@ -173,11 +216,14 @@ describe("mergeTextStyle deep inheritance chains", () => { assert.equal(boxResolved === rootResolved, true); assert.equal(rowResolved === rootResolved, true); assert.equal(textResolved === rootResolved, true); - assert.deepEqual(textResolved, { - fg: ROOT_FG, - bg: ROOT_BG, - inverse: true, - }); + assert.deepEqual( + textResolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + inverse: true, + }), + ); }); test("8+ level chain resolves nearest ancestor values deterministically", () => { @@ -193,14 +239,17 @@ describe("mergeTextStyle deep inheritance chains", () => { { fg: TEXT_FG }, ]); - assert.deepEqual(resolved, { - fg: TEXT_FG, - bg: DEEP_BG, - bold: false, - dim: true, - italic: true, - underline: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: TEXT_FG, + bg: DEEP_BG, + bold: false, + dim: true, + italic: true, + underline: true, + }), + ); }); test("long undefined tail after deep chain preserves resolved object", () => { @@ -216,11 +265,14 @@ describe("mergeTextStyle deep inheritance chains", () => { } assert.equal(resolved === anchor, true); - assert.deepEqual(resolved, { - fg: ROOT_FG, - bg: ROOT_BG, - overline: true, - }); + assert.deepEqual( + resolved, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + overline: true, + }), + ); }); test("very deep chain (512 levels) remains deterministic without stack/logic regressions", () => { @@ -262,7 +314,7 @@ describe("mergeTextStyle deep inheritance chains", () => { }; if (expectedBlink !== undefined) expected.blink = expectedBlink; - assert.deepEqual(resolved, expected); + assert.deepEqual(resolved, withAttrs(expected)); }); }); @@ -285,11 +337,14 @@ describe("mergeTextStyle recompute after middle style unset", () => { const rowResolved = mergeTextStyle(boxUnset, { italic: true }); const after = mergeTextStyle(rowResolved, undefined); - assert.deepEqual(after, { - fg: ROOT_FG, - bg: ROOT_BG, - italic: true, - }); + assert.deepEqual( + after, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + italic: true, + }), + ); }); test("when Box bg is unset, descendants inherit Root bg after recompute", () => { @@ -299,10 +354,13 @@ describe("mergeTextStyle recompute after middle style unset", () => { { fg: BOX_FG }, undefined, ); - assert.deepEqual(before, { - fg: BOX_FG, - bg: BOX_BG, - }); + assert.deepEqual( + before, + withAttrs({ + fg: BOX_FG, + bg: BOX_BG, + }), + ); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG }, @@ -311,10 +369,13 @@ describe("mergeTextStyle recompute after middle style unset", () => { undefined, ); - assert.deepEqual(after, { - fg: BOX_FG, - bg: ROOT_BG, - }); + assert.deepEqual( + after, + withAttrs({ + fg: BOX_FG, + bg: ROOT_BG, + }), + ); }); test("middle unset recompute keeps Text override but re-inherits Root for unset fields", () => { @@ -324,12 +385,15 @@ describe("mergeTextStyle recompute after middle style unset", () => { { underline: true }, { fg: TEXT_FG }, ); - assert.deepEqual(before, { - fg: TEXT_FG, - bg: BOX_BG, - bold: false, - underline: true, - }); + assert.deepEqual( + before, + withAttrs({ + fg: TEXT_FG, + bg: BOX_BG, + bold: false, + underline: true, + }), + ); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG, bold: true }, @@ -338,12 +402,15 @@ describe("mergeTextStyle recompute after middle style unset", () => { { fg: TEXT_FG }, ); - assert.deepEqual(after, { - fg: TEXT_FG, - bg: ROOT_BG, - bold: true, - underline: true, - }); + assert.deepEqual( + after, + withAttrs({ + fg: TEXT_FG, + bg: ROOT_BG, + bold: true, + underline: true, + }), + ); }); test("middle unset recompute restores higher-ancestor boolean when Text has no local override", () => { @@ -353,12 +420,15 @@ describe("mergeTextStyle recompute after middle style unset", () => { { italic: true }, {}, ); - assert.deepEqual(before, { - fg: ROOT_FG, - bg: ROOT_BG, - bold: false, - italic: true, - }); + assert.deepEqual( + before, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: false, + italic: true, + }), + ); const after = resolveRootBoxRowText( { fg: ROOT_FG, bg: ROOT_BG, bold: true }, @@ -367,11 +437,14 @@ describe("mergeTextStyle recompute after middle style unset", () => { {}, ); - assert.deepEqual(after, { - fg: ROOT_FG, - bg: ROOT_BG, - bold: true, - italic: true, - }); + assert.deepEqual( + after, + withAttrs({ + fg: ROOT_FG, + bg: ROOT_BG, + bold: true, + italic: true, + }), + ); }); }); diff --git a/packages/core/src/widgets/__tests__/style.merge.test.ts b/packages/core/src/widgets/__tests__/style.merge.test.ts index 4d0e3a63..aec7a283 100644 --- a/packages/core/src/widgets/__tests__/style.merge.test.ts +++ b/packages/core/src/widgets/__tests__/style.merge.test.ts @@ -151,6 +151,7 @@ describe("mergeTextStyle boolean merge correctness", () => { assert.deepEqual(merged, { fg: DEFAULT_BASE_STYLE.fg, bg: DEFAULT_BASE_STYLE.bg, + attrs: 246, bold: false, dim: true, italic: true, @@ -169,6 +170,7 @@ describe("mergeTextStyle boolean merge correctness", () => { assert.deepEqual(allFalse, { fg: DEFAULT_BASE_STYLE.fg, bg: DEFAULT_BASE_STYLE.bg, + attrs: 0, bold: false, dim: false, italic: false, diff --git a/packages/core/src/widgets/__tests__/style.utils.test.ts b/packages/core/src/widgets/__tests__/style.utils.test.ts index 2db2c8f4..c1df1f86 100644 --- a/packages/core/src/widgets/__tests__/style.utils.test.ts +++ b/packages/core/src/widgets/__tests__/style.utils.test.ts @@ -73,14 +73,15 @@ describe("style utils contracts", () => { assert.deepEqual(second, { inverse: false, blink: true }); }); - test("sanitizeRgb clamps channels and accepts numeric strings", () => { - const out = sanitizeRgb({ r: "260", g: -2, b: "127.6" }); - assert.deepEqual(out, (255 << 16) | (0 << 8) | 128); + test("sanitizeRgb clamps packed numeric RGB values", () => { + assert.equal(sanitizeRgb(-42), 0); + assert.equal(sanitizeRgb(0x0001_0203), 0x0001_0203); + assert.equal(sanitizeRgb(0x01ff_00ff), 0x00ff_ffff); }); test("sanitizeTextStyle drops invalid fields and coerces booleans", () => { const out = sanitizeTextStyle({ - fg: { r: 1.4, g: "2", b: 3.6 }, + fg: (1 << 16) | (2 << 8) | 4, bg: { r: "bad", g: 10, b: 20 }, bold: "TRUE", italic: "false", @@ -97,12 +98,12 @@ describe("style utils contracts", () => { test("mergeStyles sanitizes incoming style values", () => { const merged = mergeStyles({ fg: (0 << 16) | (0 << 8) | 0, bold: true }, { - fg: { r: 512, g: "-10", b: "3.2" }, + fg: 0x01ff_0013, bold: "false", } as unknown as TextStyle); assert.deepEqual(merged, { - fg: (255 << 16) | (0 << 8) | 3, + fg: 0x00ff_ffff, bold: false, }); }); diff --git a/packages/core/src/widgets/__tests__/styleUtils.test.ts b/packages/core/src/widgets/__tests__/styleUtils.test.ts index 2219cd24..d39d43d2 100644 --- a/packages/core/src/widgets/__tests__/styleUtils.test.ts +++ b/packages/core/src/widgets/__tests__/styleUtils.test.ts @@ -34,13 +34,9 @@ describe("styleUtils", () => { }); test("sanitizeTextStyle preserves underlineColor rgb", () => { - assert.deepEqual( + assert.equal( sanitizeTextStyle({ underlineColor: (1 << 16) | (2 << 8) | 3 }).underlineColor, - { - r: 1, - g: 2, - b: 3, - }, + (1 << 16) | (2 << 8) | 3, ); }); @@ -56,7 +52,7 @@ describe("styleUtils", () => { }); test("sanitizeTextStyle drops invalid underlineColor values", () => { - assert.equal(sanitizeTextStyle({ underlineColor: 42 }).underlineColor, undefined); + assert.equal(sanitizeTextStyle({ underlineColor: {} }).underlineColor, undefined); assert.equal(sanitizeTextStyle({ underlineColor: null }).underlineColor, undefined); assert.equal(sanitizeTextStyle({ underlineColor: "" }).underlineColor, undefined); assert.equal(sanitizeTextStyle({ underlineColor: " " }).underlineColor, undefined); diff --git a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts index 2fa57c3d..6bd2b245 100644 --- a/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts +++ b/packages/core/src/widgets/__tests__/widgetRenderSmoke.test.ts @@ -1,4 +1,5 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { parseInternedStrings } from "../../__tests__/drawlistDecode.js"; import { type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; @@ -8,36 +9,6 @@ import { ui } from "../ui.js"; type TreeNode = Readonly<{ id: string; children: readonly TreeNode[] }>; -function u32(bytes: Uint8Array, off: number): number { - const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return dv.getUint32(off, true); -} - -function parseInternedStrings(bytes: Uint8Array): readonly string[] { - const spanOffset = u32(bytes, 28); - const count = u32(bytes, 32); - const bytesOffset = u32(bytes, 36); - const bytesLen = u32(bytes, 40); - - if (count === 0) return Object.freeze([]); - - const tableEnd = bytesOffset + bytesLen; - assert.ok(tableEnd <= bytes.byteLength, "string table must be in-bounds"); - - const out: string[] = []; - const decoder = new TextDecoder(); - for (let i = 0; i < count; i++) { - const span = spanOffset + i * 8; - const off = u32(bytes, span); - const len = u32(bytes, span + 4); - const start = bytesOffset + off; - const end = start + len; - assert.ok(end <= tableEnd, "string span must be in-bounds"); - out.push(decoder.decode(bytes.subarray(start, end))); - } - return Object.freeze(out); -} - function renderBytes( vnode: VNode, viewport: Readonly<{ cols: number; rows: number }> = { cols: 100, rows: 40 }, diff --git a/packages/core/src/widgets/pagination.ts b/packages/core/src/widgets/pagination.ts index ea202124..6bfa73d4 100644 --- a/packages/core/src/widgets/pagination.ts +++ b/packages/core/src/widgets/pagination.ts @@ -243,6 +243,16 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ }); } + const controlsRow: VNode = { + kind: "row", + props: { + gap: 0, + wrap: true, + items: "center", + }, + children: Object.freeze(controls), + }; + const zone: VNode = { kind: "focusZone", props: { @@ -252,7 +262,7 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ columns: 1, wrapAround: false, }, - children: Object.freeze(controls), + children: Object.freeze([controlsRow]), }; return Object.freeze([zone]); diff --git a/packages/core/src/widgets/ui.ts b/packages/core/src/widgets/ui.ts index 87021b03..5e57a997 100644 --- a/packages/core/src/widgets/ui.ts +++ b/packages/core/src/widgets/ui.ts @@ -355,7 +355,7 @@ function divider(props: DividerProps = {}): VNode { * @example * ```ts * ui.icon("status.check") - * ui.icon("arrow.right", { style: { fg: { r: 0, g: 255, b: 0 } } }) + * ui.icon("arrow.right", { style: { fg: rgb(0, 255, 0) } }) * ui.icon("ui.search", { fallback: true }) * ``` */ @@ -419,7 +419,7 @@ function skeleton(width: number, props: Omit = {}): VNod * @example * ```ts * ui.richText([ - * { text: "Error: ", style: { fg: { r: 255, g: 0, b: 0 }, bold: true } }, + * { text: "Error: ", style: { fg: rgb(255, 0, 0), bold: true } }, * { text: "File not found" }, * ]) * ``` @@ -1314,8 +1314,8 @@ export const ui = { * sortColumn: state.sortCol, * sortDirection: state.sortDir, * onSort: (col, dir) => app.update({ sortCol: col, sortDir: dir }), - * stripeStyle: { odd: { r: 30, g: 33, b: 41 } }, - * borderStyle: { variant: "double", color: { r: 130, g: 140, b: 150 } }, + * stripeStyle: { odd: rgb(30, 33, 41) }, + * borderStyle: { variant: "double", color: rgb(130, 140, 150) }, * }) * ``` */ diff --git a/packages/create-rezi/templates/starship/src/main.ts b/packages/create-rezi/templates/starship/src/main.ts index 730213fa..865dac19 100644 --- a/packages/create-rezi/templates/starship/src/main.ts +++ b/packages/create-rezi/templates/starship/src/main.ts @@ -1,4 +1,9 @@ -import { exit } from "node:process"; +import { + type DebugController, + type DebugRecord, + categoriesToMask, + createDebugController, +} from "@rezi-ui/core"; import { createNodeApp } from "@rezi-ui/node"; import { debugSnapshot } from "./helpers/debug.js"; import { resolveStarshipCommand } from "./helpers/keybindings.js"; @@ -14,10 +19,21 @@ import type { RouteDeps, RouteId, StarshipAction, StarshipState } from "./types. const UI_FPS_CAP = 30; const TICK_MS = 800; const TOAST_PRUNE_MS = 3000; +const DEBUG_TRACE_ENABLED = process.env.REZI_STARSHIP_DEBUG_TRACE === "1"; +const EXECUTION_MODE: "inline" | "worker" = + process.env.REZI_STARSHIP_EXECUTION_MODE === "worker" ? "worker" : "inline"; + +function clampViewportAxis(value: number | undefined, fallback: number): number { + const safeFallback = Math.max(1, Math.trunc(fallback)); + if (!Number.isFinite(value)) return safeFallback; + const raw = Math.trunc(value ?? safeFallback); + if (raw <= 0) return safeFallback; + return raw; +} const initialState = createInitialStateWithViewport(Date.now(), { - cols: process.stdout.columns ?? 120, - rows: process.stdout.rows ?? 40, + cols: clampViewportAxis(process.stdout.columns, 120), + rows: clampViewportAxis(process.stdout.rows, 40), }); const enableHsr = process.argv.includes("--hsr") || process.env.REZI_HSR === "1"; const hasInteractiveTty = Boolean(process.stdin.isTTY && process.stdout.isTTY); @@ -41,6 +57,15 @@ let app!: ReturnType>; let stopping = false; let tickTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; +let debugTraceTimer: ReturnType | null = null; +let debugTraceInFlight = false; +let debugController: DebugController | null = null; +let debugLastRecordId = 0n; +let stopCode = 0; +let stopResolve: (() => void) | null = null; +const stopPromise = new Promise((resolve) => { + stopResolve = resolve; +}); let lastViewport = { cols: initialState.viewportCols, rows: initialState.viewportRows, @@ -68,20 +93,22 @@ function dispatch(action: StarshipAction): void { } function syncViewport(cols: number, rows: number): void { - if (cols === lastViewport.cols && rows === lastViewport.rows) return; - lastViewport = { cols, rows }; + const safeCols = clampViewportAxis(cols, lastViewport.cols); + const safeRows = clampViewportAxis(rows, lastViewport.rows); + if (safeCols === lastViewport.cols && safeRows === lastViewport.rows) return; + lastViewport = { cols: safeCols, rows: safeRows }; debugSnapshot("runtime.viewport", { - cols, - rows, + cols: safeCols, + rows: safeRows, route: currentRouteId(), }); - dispatch({ type: "set-viewport", cols, rows }); + dispatch({ type: "set-viewport", cols: safeCols, rows: safeRows }); } function syncViewportFromStdout(): void { if (!process.stdout.isTTY) return; - const cols = process.stdout.columns ?? lastViewport.cols; - const rows = process.stdout.rows ?? lastViewport.rows; + const cols = clampViewportAxis(process.stdout.columns, lastViewport.cols); + const rows = clampViewportAxis(process.stdout.rows, lastViewport.rows); syncViewport(cols, rows); } @@ -96,6 +123,17 @@ function currentRouteId(): RouteId { return "bridge"; } +const frameAuditGlobal = globalThis as { + __reziFrameAuditContext?: () => Readonly>; +}; +frameAuditGlobal.__reziFrameAuditContext = () => + Object.freeze({ + route: currentRouteId(), + viewportCols: lastViewport.cols, + viewportRows: lastViewport.rows, + executionMode: EXECUTION_MODE, + }); + function navigate(routeId: RouteId): void { const router = app.router; if (!router) return; @@ -126,6 +164,7 @@ function buildRoutes(factory: CreateRoutesFn) { async function stopApp(code = 0): Promise { if (stopping) return; stopping = true; + stopCode = code; if (tickTimer) { clearInterval(tickTimer); @@ -137,18 +176,28 @@ async function stopApp(code = 0): Promise { toastTimer = null; } - try { - await app.stop(); - } catch { - // Ignore stop races. + if (debugTraceTimer) { + clearInterval(debugTraceTimer); + debugTraceTimer = null; + } + + if (debugController) { + try { + await debugController.disable(); + } catch { + // Ignore debug shutdown races. + } + debugController = null; } try { - app.dispose(); + await app.stop(); } catch { - // Ignore disposal races during shutdown. + // Ignore stop races. } - exit(code); + frameAuditGlobal.__reziFrameAuditContext = undefined; + stopResolve?.(); + stopResolve = null; } function applyCommand(command: ReturnType): void { @@ -464,13 +513,172 @@ function bindKeys(): void { app.keys(bindingMap); } +function snapshotDebugRecord(record: DebugRecord): void { + const { header } = record; + if (header.category === "frame") { + if ( + record.payload && + typeof record.payload === "object" && + "drawlistBytes" in record.payload && + "drawlistCmds" in record.payload + ) { + debugSnapshot("runtime.debug.frame", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + route: currentRouteId(), + drawlistBytes: record.payload.drawlistBytes, + drawlistCmds: record.payload.drawlistCmds, + diffBytesEmitted: + "diffBytesEmitted" in record.payload ? record.payload.diffBytesEmitted : null, + dirtyLines: "dirtyLines" in record.payload ? record.payload.dirtyLines : null, + dirtyCells: "dirtyCells" in record.payload ? record.payload.dirtyCells : null, + damageRects: "damageRects" in record.payload ? record.payload.damageRects : null, + usDrawlist: "usDrawlist" in record.payload ? record.payload.usDrawlist : null, + usDiff: "usDiff" in record.payload ? record.payload.usDiff : null, + usWrite: "usWrite" in record.payload ? record.payload.usWrite : null, + }); + return; + } + + debugSnapshot("runtime.debug.frame.raw", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + payloadSize: header.payloadSize, + route: currentRouteId(), + }); + } + + if (header.category === "drawlist") { + if ( + record.payload && + typeof record.payload === "object" && + "totalBytes" in record.payload && + "cmdCount" in record.payload + ) { + debugSnapshot("runtime.debug.drawlist", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + route: currentRouteId(), + totalBytes: record.payload.totalBytes, + cmdCount: record.payload.cmdCount, + validationResult: + "validationResult" in record.payload ? record.payload.validationResult : null, + executionResult: + "executionResult" in record.payload ? record.payload.executionResult : null, + clipStackMaxDepth: + "clipStackMaxDepth" in record.payload ? record.payload.clipStackMaxDepth : null, + textRuns: "textRuns" in record.payload ? record.payload.textRuns : null, + fillRects: "fillRects" in record.payload ? record.payload.fillRects : null, + }); + return; + } + + if ( + record.payload && + typeof record.payload === "object" && + "kind" in record.payload && + record.payload.kind === "drawlistBytes" && + "bytes" in record.payload + ) { + debugSnapshot("runtime.debug.drawlistBytes", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + route: currentRouteId(), + bytes: record.payload.bytes.byteLength, + }); + return; + } + + debugSnapshot("runtime.debug.drawlist.raw", { + recordId: header.recordId.toString(), + frameId: header.frameId.toString(), + code: header.code, + severity: header.severity, + payloadSize: header.payloadSize, + route: currentRouteId(), + }); + } +} + +async function setupDebugTrace(): Promise { + if (!DEBUG_TRACE_ENABLED) return; + try { + debugController = createDebugController({ + backend: app.backend.debug, + terminalCapsProvider: () => app.backend.getCaps(), + maxFrames: 512, + }); + + await debugController.enable({ + minSeverity: "trace", + categoryMask: categoriesToMask(["frame", "drawlist", "error"]), + captureRawEvents: false, + captureDrawlistBytes: false, + }); + await debugController.reset(); + } catch (error) { + debugSnapshot("runtime.debug.enable.error", { + message: error instanceof Error ? error.message : String(error), + route: currentRouteId(), + }); + if (debugController) { + try { + await debugController.disable(); + } catch { + // Ignore debug shutdown races. + } + debugController = null; + } + return; + } + + debugLastRecordId = 0n; + + debugSnapshot("runtime.debug.enable", { + route: currentRouteId(), + viewportCols: lastViewport.cols, + viewportRows: lastViewport.rows, + }); + + const pump = async () => { + if (!debugController || debugTraceInFlight) return; + debugTraceInFlight = true; + try { + const records = await debugController.query({ maxRecords: 256 }); + if (records.length === 0) return; + for (const record of records) { + if (record.header.recordId <= debugLastRecordId) continue; + debugLastRecordId = record.header.recordId; + snapshotDebugRecord(record); + } + } catch (error) { + debugSnapshot("runtime.debug.query.error", { + message: error instanceof Error ? error.message : String(error), + route: currentRouteId(), + }); + } finally { + debugTraceInFlight = false; + } + }; + + debugTraceTimer = setInterval(() => { + void pump(); + }, 250); +} + const routes = buildRoutes(createStarshipRoutes); debugSnapshot("runtime.app.create", { routeCount: routes.length, initialRoute: "bridge", fpsCap: UI_FPS_CAP, - executionMode: "inline", + executionMode: EXECUTION_MODE, }); app = createNodeApp({ @@ -479,7 +687,7 @@ app = createNodeApp({ initialRoute: "bridge", config: { fpsCap: UI_FPS_CAP, - executionMode: "inline", + executionMode: EXECUTION_MODE, }, theme: themeSpec(initialState.themeName).theme, ...(enableHsr @@ -545,5 +753,9 @@ debugSnapshot("runtime.app.start", { viewportRows: lastViewport.rows, }); +await setupDebugTrace(); await app.start(); -await new Promise(() => {}); +await stopPromise; +if (stopCode !== 0) { + process.exitCode = stopCode; +} diff --git a/packages/create-rezi/templates/starship/src/screens/bridge.ts b/packages/create-rezi/templates/starship/src/screens/bridge.ts index a2666219..bb4546b1 100644 --- a/packages/create-rezi/templates/starship/src/screens/bridge.ts +++ b/packages/create-rezi/templates/starship/src/screens/bridge.ts @@ -554,7 +554,7 @@ export function renderBridgeScreen( title: "Bridge Overview", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ BridgeCommandDeck({ key: "bridge-command-deck", state, dispatch: deps.dispatch }), ]), }); diff --git a/packages/create-rezi/templates/starship/src/screens/cargo.ts b/packages/create-rezi/templates/starship/src/screens/cargo.ts index 2d70817d..7cef074d 100644 --- a/packages/create-rezi/templates/starship/src/screens/cargo.ts +++ b/packages/create-rezi/templates/starship/src/screens/cargo.ts @@ -30,8 +30,12 @@ export function renderCargoScreen( title: "Cargo Hold", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - CargoDeck({ state: context.state, dispatch: deps.dispatch }), + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + CargoDeck({ + key: "cargo-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/comms.ts b/packages/create-rezi/templates/starship/src/screens/comms.ts index abd4fa66..8830a08c 100644 --- a/packages/create-rezi/templates/starship/src/screens/comms.ts +++ b/packages/create-rezi/templates/starship/src/screens/comms.ts @@ -462,8 +462,12 @@ export function renderCommsScreen( title: "Communications", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - CommsDeck({ state: context.state, dispatch: deps.dispatch }), + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + CommsDeck({ + key: "comms-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/crew.ts b/packages/create-rezi/templates/starship/src/screens/crew.ts index 37530d9c..0013fd90 100644 --- a/packages/create-rezi/templates/starship/src/screens/crew.ts +++ b/packages/create-rezi/templates/starship/src/screens/crew.ts @@ -7,6 +7,7 @@ import { type RouteRenderContext, type VNode, } from "@rezi-ui/core"; +import { debugSnapshot } from "../helpers/debug.js"; import { resolveLayout } from "../helpers/layout.js"; import { crewCounts, departmentLabel, rankBadge, statusBadge } from "../helpers/formatters.js"; import { selectedCrew, visibleCrew } from "../helpers/state.js"; @@ -193,7 +194,7 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ...tableSkin(tokens), }); - const detailPanel = ui.column({ gap: SPACE.sm }, [ + const detailPanel = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ maybe(selected, (member) => surfacePanel( tokens, @@ -300,8 +301,18 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ), ]); - const manifestBlock = ui.column({ gap: SPACE.sm }, [ - surfacePanel(tokens, "Crew Manifest", [table]), + const manifestBlock = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 10, + overflow: "hidden", + }, + [surfacePanel(tokens, "Crew Manifest", [table])], + ), ui.pagination({ id: ctx.id("crew-pagination"), page, @@ -313,126 +324,185 @@ const CrewDeck = defineWidget((props, ctx): VNode => { const deckLayout = layout.wide ? showDetailPane - ? ui.masterDetail({ - id: ctx.id("crew-master-detail"), - masterWidth: layout.crewMasterWidth, - master: manifestBlock, - detail: detailPanel, - }) - : manifestBlock - : showDetailPane - ? ui.column({ gap: SPACE.sm }, [manifestBlock, detailPanel]) - : manifestBlock; - - const operationsPanel = surfacePanel( - tokens, - "Crew Operations", - [ - sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), - ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ - ui.box( + ? ui.row( { - border: "none", - p: 0, + id: ctx.id("crew-master-detail"), gap: SPACE.sm, - ...(layout.wide ? { flex: 2 } : {}), + width: "100%", + height: "100%", + items: "stretch", + wrap: false, }, [ - ui.row({ gap: SPACE.sm, wrap: true }, [ - ui.badge(`Total ${counts.total}`, { variant: "info" }), - ui.badge(`Active ${counts.active}`, { variant: "success" }), - ui.badge(`Away ${counts.away}`, { variant: "warning" }), - ui.badge(`Injured ${counts.injured}`, { variant: "error" }), - ]), - ui.form([ - ui.field({ - label: "Search Crew", - hint: "Filter by name, rank, or department", - children: ui.input({ - id: ctx.id("crew-search"), - value: props.state.crewSearchQuery, - placeholder: "Type to filter", - onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), - }), - }), - ]), - ui.actions([ - ui.button({ - id: ctx.id("crew-new-assignment"), - label: "New Assignment", - intent: "primary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ui.button({ - id: ctx.id("crew-edit-selected"), - label: "Edit Selected", - intent: "secondary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ]), + ui.box( + { + border: "none", + p: 0, + width: layout.crewMasterWidth, + height: "100%", + overflow: "hidden", + }, + [manifestBlock], + ), + ui.box( + { + border: "none", + p: 0, + flex: 1, + height: "100%", + overflow: "hidden", + }, + [detailPanel], + ), ], - ), - ...(layout.wide - ? [ - ui.box( - { - border: "none", - p: 0, - flex: 1, - }, - [ - surfacePanel( - tokens, - "Crew Snapshot", + ) + : manifestBlock + : showDetailPane + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [manifestBlock], + ), + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [detailPanel], + ), + ]) + : manifestBlock; + + const operationsPanelMaxHeight = Math.max(12, Math.min(22, Math.floor(layout.height * 0.34))); + debugSnapshot("crew.render", { + viewportCols: props.state.viewportCols, + viewportRows: props.state.viewportRows, + visibleCount: visible.length, + sortedCount: sorted.length, + page, + totalPages, + pageDataCount: pageData.length, + showDetailPane, + operationsPanelMaxHeight, + editingCrew: props.state.editingCrew, + }); + const operationsPanel = ui.box( + { + border: "none", + p: 0, + width: "100%", + height: operationsPanelMaxHeight, + overflow: "scroll", + }, + [ + surfacePanel( + tokens, + "Crew Operations", + [ + sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), + ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ + ui.box( + { + border: "none", + p: 0, + gap: SPACE.sm, + ...(layout.wide ? { flex: 2 } : {}), + }, + [ + ui.row({ gap: SPACE.sm, wrap: true }, [ + ui.badge(`Total ${counts.total}`, { variant: "info" }), + ui.badge(`Active ${counts.active}`, { variant: "success" }), + ui.badge(`Away ${counts.away}`, { variant: "warning" }), + ui.badge(`Injured ${counts.injured}`, { variant: "error" }), + ]), + ui.form([ + ui.field({ + label: "Search Crew", + hint: "Filter by name, rank, or department", + children: ui.input({ + id: ctx.id("crew-search"), + value: props.state.crewSearchQuery, + placeholder: "Type to filter", + onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), + }), + }), + ]), + ui.actions([ + ui.button({ + id: ctx.id("crew-new-assignment"), + label: "New Assignment", + intent: "primary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ui.button({ + id: ctx.id("crew-edit-selected"), + label: "Edit Selected", + intent: "secondary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ]), + ], + ), + ...(layout.wide + ? [ + ui.box( + { + border: "none", + p: 0, + flex: 1, + }, [ - selected - ? ui.column({ gap: SPACE.xs }, [ - ui.text(selected.name, { variant: "label" }), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(rankBadge(selected.rank).text, { - variant: rankBadge(selected.rank).variant, + surfacePanel( + tokens, + "Crew Snapshot", + [ + selected + ? ui.column({ gap: SPACE.xs }, [ + ui.text(selected.name, { variant: "label" }), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(rankBadge(selected.rank).text, { + variant: rankBadge(selected.rank).variant, + }), + ui.badge(statusBadge(selected.status).text, { + variant: statusBadge(selected.status).variant, + }), + ui.tag(departmentLabel(selected.department), { variant: "info" }), + ]), + ]) + : ui.text("No crew selected", { + variant: "caption", + style: { fg: tokens.text.muted, dim: true }, }), - ui.badge(statusBadge(selected.status).text, { - variant: statusBadge(selected.status).variant, + ui.divider(), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(`Visible ${sorted.length}`, { variant: "info" }), + ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), + ]), + staffingError + ? ui.callout("Critical staffing below minimum.", { + title: "Guardrail", + variant: "warning", + }) + : ui.callout("Critical staffing thresholds healthy.", { + title: "Guardrail", + variant: "success", }), - ui.tag(departmentLabel(selected.department), { variant: "info" }), - ]), - ]) - : ui.text("No crew selected", { - variant: "caption", - style: { fg: tokens.text.muted, dim: true }, - }), - ui.divider(), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(`Visible ${sorted.length}`, { variant: "info" }), - ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), - ]), - staffingError - ? ui.callout("Critical staffing below minimum.", { - title: "Guardrail", - variant: "warning", - }) - : ui.callout("Critical staffing thresholds healthy.", { - title: "Guardrail", - variant: "success", - }), + ], + { + tone: "inset", + p: SPACE.sm, + gap: SPACE.sm, + }, + ), ], - { - tone: "inset", - p: SPACE.sm, - gap: SPACE.sm, - }, ), - ], - ), - ] - : []), - ]), + ] + : []), + ]), + ], + { tone: "base" }, + ), ], - { tone: "base" }, ); - return ui.column({ gap: SPACE.md, width: "100%" }, [ + return ui.column({ gap: SPACE.md, width: "100%", height: "100%" }, [ operationsPanel, show( asyncCrew.loading, @@ -443,7 +513,20 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ui.skeleton(44, { variant: "text" }), ], { tone: "inset" }), ), - show(!asyncCrew.loading, deckLayout), + show( + !asyncCrew.loading, + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 12, + overflow: "hidden", + }, + [deckLayout], + ), + ), ]); }); @@ -452,8 +535,12 @@ export function renderCrewScreen(context: RouteRenderContext, dep title: "Crew Manifest", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - CrewDeck({ state: context.state, dispatch: deps.dispatch }), + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + CrewDeck({ + key: "crew-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/engineering.ts b/packages/create-rezi/templates/starship/src/screens/engineering.ts index 9173a487..016b2158 100644 --- a/packages/create-rezi/templates/starship/src/screens/engineering.ts +++ b/packages/create-rezi/templates/starship/src/screens/engineering.ts @@ -371,16 +371,27 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = }), ]); - const leftPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - reactorPanel, - ...(showSecondaryPanels ? [treePanel] : []), - ]); - const rightPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - powerPanel, - ...(showSecondaryPanels ? [thermalPanel, diagnosticsPanel] : []), - ]); + const leftPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12 }, [reactorPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + treePanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [reactorPanel]); + const rightPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12, overflow: "hidden" }, [ + powerPanel, + ]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10 }, [thermalPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + diagnosticsPanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [powerPanel]); - const responsiveDeckHeight = Math.max( + const responsiveDeckMinHeight = Math.max( 16, contentRows - (showControlsSummary ? 12 : 10) - (showSecondaryPanels ? 0 : 2), ); @@ -395,7 +406,8 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = border: "none", p: 0, width: "100%", - height: responsiveDeckHeight, + flex: 1, + minHeight: responsiveDeckMinHeight, overflow: "scroll", }, [responsiveDeckBody], @@ -491,7 +503,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = includeResponsiveDeck: renderMode === "full", responsiveDeckMode: useWideRow ? "row" : "column", forceStackViaEnv, - responsiveDeckHeight, + responsiveDeckMinHeight, }); if (veryCompactHeight) { @@ -502,7 +514,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = return ui.column({ gap: SPACE.sm, width: "100%" }, [controlsPanel, reactorPanel]); } - return ui.column({ gap: SPACE.sm, width: "100%" }, [ + return ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ controlsRegion, responsiveDeck, ]); @@ -516,8 +528,12 @@ export function renderEngineeringScreen( title: "Engineering Deck", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ - EngineeringDeck({ state: context.state, dispatch: deps.dispatch }), + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + EngineeringDeck({ + key: "engineering-deck", + state: context.state, + dispatch: deps.dispatch, + }), ]), }); } diff --git a/packages/create-rezi/templates/starship/src/screens/settings.ts b/packages/create-rezi/templates/starship/src/screens/settings.ts index eb8bf888..e18b31da 100644 --- a/packages/create-rezi/templates/starship/src/screens/settings.ts +++ b/packages/create-rezi/templates/starship/src/screens/settings.ts @@ -209,7 +209,7 @@ function settingsRightRail(state: StarshipState, deps: RouteDeps): VNode { subtitle: activeTheme.label, actions: [ui.badge("Preview", { variant: "info" })], }), - body: ui.column({ gap: SPACE.xs }, [ + body: ui.column({ gap: SPACE.xs, width: "100%", height: "100%" }, [ ui.breadcrumb({ items: [{ label: "Bridge" }, { label: "Settings" }, { label: "Theme Preview" }], }), @@ -277,8 +277,9 @@ export function renderSettingsScreen( title: "Ship Settings", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ SettingsDeck({ + key: "settings-deck", state: context.state, dispatch: deps.dispatch, }), diff --git a/packages/create-rezi/templates/starship/src/theme.ts b/packages/create-rezi/templates/starship/src/theme.ts index a0f84ae7..df0e53c7 100644 --- a/packages/create-rezi/templates/starship/src/theme.ts +++ b/packages/create-rezi/templates/starship/src/theme.ts @@ -1,12 +1,12 @@ import { type BadgeVariant, - type Rgb, + type Rgb24, type TextStyle, type ThemeDefinition, - darkTheme, draculaTheme, extendTheme, nordTheme, + rgb, } from "@rezi-ui/core"; import type { AlertLevel, ThemeName } from "./types.js"; @@ -42,52 +42,52 @@ const DAY_SHIFT_THEME = extendTheme(nordTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 118, g: 208, b: 255 }, + focusRingColor: rgb(118, 208, 255), }, colors: { bg: { - base: { r: 29, g: 42, b: 58 }, - elevated: { r: 40, g: 57, b: 76 }, - overlay: { r: 52, g: 72, b: 94 }, - subtle: { r: 34, g: 49, b: 67 }, + base: rgb(29, 42, 58), + elevated: rgb(40, 57, 76), + overlay: rgb(52, 72, 94), + subtle: rgb(34, 49, 67), }, fg: { - primary: { r: 236, g: 243, b: 255 }, - secondary: { r: 190, g: 217, b: 242 }, - muted: { r: 108, g: 138, b: 168 }, - inverse: { r: 20, g: 30, b: 42 }, + primary: rgb(236, 243, 255), + secondary: rgb(190, 217, 242), + muted: rgb(108, 138, 168), + inverse: rgb(20, 30, 42), }, border: { - subtle: { r: 74, g: 98, b: 126 }, - default: { r: 104, g: 136, b: 168 }, - strong: { r: 137, g: 171, b: 206 }, + subtle: rgb(74, 98, 126), + default: rgb(104, 136, 168), + strong: rgb(137, 171, 206), }, accent: { - primary: { r: 106, g: 195, b: 255 }, - secondary: { r: 129, g: 217, b: 255 }, - tertiary: { r: 180, g: 231, b: 164 }, + primary: rgb(106, 195, 255), + secondary: rgb(129, 217, 255), + tertiary: rgb(180, 231, 164), }, - info: { r: 118, g: 208, b: 255 }, - success: { r: 166, g: 228, b: 149 }, - warning: { r: 255, g: 211, b: 131 }, - error: { r: 239, g: 118, b: 132 }, + info: rgb(118, 208, 255), + success: rgb(166, 228, 149), + warning: rgb(255, 211, 131), + error: rgb(239, 118, 132), selected: { - bg: { r: 68, g: 105, b: 139 }, - fg: { r: 236, g: 243, b: 255 }, + bg: rgb(68, 105, 139), + fg: rgb(236, 243, 255), }, disabled: { - fg: { r: 95, g: 121, b: 149 }, - bg: { r: 39, g: 55, b: 72 }, + fg: rgb(95, 121, 149), + bg: rgb(39, 55, 72), }, diagnostic: { - error: { r: 239, g: 118, b: 132 }, - warning: { r: 255, g: 211, b: 131 }, - info: { r: 118, g: 208, b: 255 }, - hint: { r: 149, g: 187, b: 228 }, + error: rgb(239, 118, 132), + warning: rgb(255, 211, 131), + info: rgb(118, 208, 255), + hint: rgb(149, 187, 228), }, focus: { - ring: { r: 118, g: 208, b: 255 }, - bg: { r: 63, g: 96, b: 126 }, + ring: rgb(118, 208, 255), + bg: rgb(63, 96, 126), }, }, }); @@ -105,52 +105,52 @@ const NIGHT_SHIFT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 176, g: 133, b: 255 }, + focusRingColor: rgb(176, 133, 255), }, colors: { bg: { - base: { r: 19, g: 22, b: 33 }, - elevated: { r: 28, g: 31, b: 46 }, - overlay: { r: 37, g: 41, b: 60 }, - subtle: { r: 24, g: 27, b: 40 }, + base: rgb(19, 22, 33), + elevated: rgb(28, 31, 46), + overlay: rgb(37, 41, 60), + subtle: rgb(24, 27, 40), }, fg: { - primary: { r: 244, g: 246, b: 252 }, - secondary: { r: 202, g: 185, b: 252 }, - muted: { r: 131, g: 146, b: 186 }, - inverse: { r: 19, g: 22, b: 33 }, + primary: rgb(244, 246, 252), + secondary: rgb(202, 185, 252), + muted: rgb(131, 146, 186), + inverse: rgb(19, 22, 33), }, accent: { - primary: { r: 176, g: 133, b: 255 }, - secondary: { r: 129, g: 235, b: 255 }, - tertiary: { r: 119, g: 255, b: 196 }, + primary: rgb(176, 133, 255), + secondary: rgb(129, 235, 255), + tertiary: rgb(119, 255, 196), }, - info: { r: 129, g: 235, b: 255 }, - success: { r: 110, g: 249, b: 174 }, - warning: { r: 255, g: 207, b: 124 }, - error: { r: 255, g: 118, b: 132 }, + info: rgb(129, 235, 255), + success: rgb(110, 249, 174), + warning: rgb(255, 207, 124), + error: rgb(255, 118, 132), selected: { - bg: { r: 68, g: 76, b: 112 }, - fg: { r: 244, g: 246, b: 252 }, + bg: rgb(68, 76, 112), + fg: rgb(244, 246, 252), }, disabled: { - fg: { r: 99, g: 111, b: 146 }, - bg: { r: 28, g: 31, b: 46 }, + fg: rgb(99, 111, 146), + bg: rgb(28, 31, 46), }, diagnostic: { - error: { r: 255, g: 118, b: 132 }, - warning: { r: 255, g: 207, b: 124 }, - info: { r: 129, g: 235, b: 255 }, - hint: { r: 206, g: 158, b: 255 }, + error: rgb(255, 118, 132), + warning: rgb(255, 207, 124), + info: rgb(129, 235, 255), + hint: rgb(206, 158, 255), }, focus: { - ring: { r: 176, g: 133, b: 255 }, - bg: { r: 64, g: 57, b: 96 }, + ring: rgb(176, 133, 255), + bg: rgb(64, 57, 96), }, border: { - subtle: { r: 43, g: 49, b: 71 }, - default: { r: 72, g: 81, b: 116 }, - strong: { r: 104, g: 115, b: 156 }, + subtle: rgb(43, 49, 71), + default: rgb(72, 81, 116), + strong: rgb(104, 115, 156), }, }, }); @@ -168,52 +168,52 @@ const RED_ALERT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 255, g: 112, b: 112 }, + focusRingColor: rgb(255, 112, 112), }, colors: { bg: { - base: { r: 24, g: 12, b: 19 }, - elevated: { r: 34, g: 15, b: 24 }, - overlay: { r: 46, g: 21, b: 32 }, - subtle: { r: 29, g: 13, b: 22 }, + base: rgb(24, 12, 19), + elevated: rgb(34, 15, 24), + overlay: rgb(46, 21, 32), + subtle: rgb(29, 13, 22), }, fg: { - primary: { r: 255, g: 238, b: 242 }, - secondary: { r: 244, g: 190, b: 205 }, - muted: { r: 170, g: 122, b: 139 }, - inverse: { r: 24, g: 12, b: 19 }, + primary: rgb(255, 238, 242), + secondary: rgb(244, 190, 205), + muted: rgb(170, 122, 139), + inverse: rgb(24, 12, 19), }, accent: { - primary: { r: 255, g: 114, b: 144 }, - secondary: { r: 255, g: 182, b: 120 }, - tertiary: { r: 255, g: 220, b: 146 }, + primary: rgb(255, 114, 144), + secondary: rgb(255, 182, 120), + tertiary: rgb(255, 220, 146), }, - success: { r: 134, g: 247, b: 176 }, - warning: { r: 255, g: 181, b: 112 }, - error: { r: 255, g: 93, b: 117 }, - info: { r: 255, g: 141, b: 153 }, + success: rgb(134, 247, 176), + warning: rgb(255, 181, 112), + error: rgb(255, 93, 117), + info: rgb(255, 141, 153), selected: { - bg: { r: 82, g: 34, b: 52 }, - fg: { r: 255, g: 238, b: 242 }, + bg: rgb(82, 34, 52), + fg: rgb(255, 238, 242), }, disabled: { - fg: { r: 142, g: 96, b: 112 }, - bg: { r: 34, g: 15, b: 24 }, + fg: rgb(142, 96, 112), + bg: rgb(34, 15, 24), }, diagnostic: { - error: { r: 255, g: 93, b: 117 }, - warning: { r: 255, g: 181, b: 112 }, - info: { r: 255, g: 141, b: 153 }, - hint: { r: 255, g: 203, b: 133 }, + error: rgb(255, 93, 117), + warning: rgb(255, 181, 112), + info: rgb(255, 141, 153), + hint: rgb(255, 203, 133), }, focus: { - ring: { r: 255, g: 112, b: 112 }, - bg: { r: 76, g: 35, b: 50 }, + ring: rgb(255, 112, 112), + bg: rgb(76, 35, 50), }, border: { - subtle: { r: 86, g: 45, b: 61 }, - default: { r: 124, g: 65, b: 86 }, - strong: { r: 172, g: 86, b: 112 }, + subtle: rgb(86, 45, 61), + default: rgb(124, 65, 86), + strong: rgb(172, 86, 112), }, }, }); @@ -244,18 +244,38 @@ function clampChannel(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -function blend(a: Rgb, b: Rgb, weight: number): Rgb { +type ColorInput = Rgb24; + +function packRgb(value: Rgb24): Rgb24 { + return (Math.round(value) >>> 0) & 0x00ff_ffff; +} + +function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { + return (value >>> shift) & 0xff; +} + +function unpackRgb(value: ColorInput): Readonly<{ r: number; g: number; b: number }> { + return Object.freeze({ + r: rgbChannel(value, 16), + g: rgbChannel(value, 8), + b: rgbChannel(value, 0), + }); +} + +function blend(a: ColorInput, b: ColorInput, weight: number): Rgb24 { const safe = Math.max(0, Math.min(1, weight)); - return { - r: clampChannel(a.r + (b.r - a.r) * safe), - g: clampChannel(a.g + (b.g - a.g) * safe), - b: clampChannel(a.b + (b.b - a.b) * safe), - }; + const left = unpackRgb(a); + const right = unpackRgb(b); + return ( + ((clampChannel(left.r + (right.r - left.r) * safe) & 0xff) << 16) | + ((clampChannel(left.g + (right.g - left.g) * safe) & 0xff) << 8) | + (clampChannel(left.b + (right.b - left.b) * safe) & 0xff) + ); } -export function toHex(color: Rgb): string { +export function toHex(color: Rgb24): string { const channel = (value: number) => clampChannel(value).toString(16).padStart(2, "0"); - return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`; + return `#${channel(rgbChannel(color, 16))}${channel(rgbChannel(color, 8))}${channel(rgbChannel(color, 0))}`; } export function cycleThemeName(current: ThemeName): ThemeName { @@ -285,52 +305,52 @@ export type StarshipStyles = Readonly<{ export type StarshipThemeTokens = Readonly<{ bg: Readonly<{ - app: Rgb; + app: Rgb24; panel: Readonly<{ - base: Rgb; - inset: Rgb; - elevated: Rgb; + base: Rgb24; + inset: Rgb24; + elevated: Rgb24; }>; - modal: Rgb; + modal: Rgb24; }>; border: Readonly<{ - default: Rgb; - muted: Rgb; - focus: Rgb; - danger: Rgb; + default: Rgb24; + muted: Rgb24; + focus: Rgb24; + danger: Rgb24; }>; text: Readonly<{ - primary: Rgb; - muted: Rgb; - dim: Rgb; + primary: Rgb24; + muted: Rgb24; + dim: Rgb24; }>; accent: Readonly<{ - info: Rgb; - success: Rgb; - warn: Rgb; - danger: Rgb; - brand: Rgb; + info: Rgb24; + success: Rgb24; + warn: Rgb24; + danger: Rgb24; + brand: Rgb24; }>; state: Readonly<{ - selectedBg: Rgb; - selectedText: Rgb; - hoverBg: Rgb; - focusRing: Rgb; + selectedBg: Rgb24; + selectedText: Rgb24; + hoverBg: Rgb24; + focusRing: Rgb24; }>; progress: Readonly<{ - track: Rgb; - fill: Rgb; + track: Rgb24; + fill: Rgb24; }>; table: Readonly<{ - headerBg: Rgb; - rowAltBg: Rgb; - rowHoverBg: Rgb; - rowSelectedBg: Rgb; + headerBg: Rgb24; + rowAltBg: Rgb24; + rowHoverBg: Rgb24; + rowSelectedBg: Rgb24; }>; log: Readonly<{ - info: Rgb; - warn: Rgb; - error: Rgb; + info: Rgb24; + warn: Rgb24; + error: Rgb24; }>; }>; @@ -384,37 +404,37 @@ export function themeTokens(themeName: ThemeName): StarshipThemeTokens { : blend(colors.accent.primary, colors.accent.secondary, mode === "day" ? 0.18 : 0.1); return Object.freeze({ bg: Object.freeze({ - app: colors.bg.base, + app: packRgb(colors.bg.base), panel: Object.freeze({ base: panelBase, inset: panelInset, elevated: panelElevated, }), - modal: colors.bg.overlay, + modal: packRgb(colors.bg.overlay), }), border: Object.freeze({ - default: colors.border.default, - muted: colors.border.subtle, - focus: mode === "alert" ? colors.error : colors.focus.ring, - danger: colors.error, + default: packRgb(colors.border.default), + muted: packRgb(colors.border.subtle), + focus: mode === "alert" ? packRgb(colors.error) : packRgb(colors.focus.ring), + danger: packRgb(colors.error), }), text: Object.freeze({ - primary: colors.fg.primary, - muted: colors.fg.secondary, - dim: colors.fg.muted, + primary: packRgb(colors.fg.primary), + muted: packRgb(colors.fg.secondary), + dim: packRgb(colors.fg.muted), }), accent: Object.freeze({ - info: colors.info, - success: colors.success, - warn: colors.warning, - danger: colors.error, - brand: colors.accent.primary, + info: packRgb(colors.info), + success: packRgb(colors.success), + warn: packRgb(colors.warning), + danger: packRgb(colors.error), + brand: packRgb(colors.accent.primary), }), state: Object.freeze({ selectedBg, - selectedText: colors.selected.fg, + selectedText: packRgb(colors.selected.fg), hoverBg, - focusRing: mode === "alert" ? colors.error : colors.focus.ring, + focusRing: mode === "alert" ? packRgb(colors.error) : packRgb(colors.focus.ring), }), progress: Object.freeze({ track: blend( diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index 248bcf6c..f053875e 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -55,15 +55,6 @@ export type InkRenderOp = | Readonly<{ kind: "clear" }> | Readonly<{ kind: "clearTo"; cols: number; rows: number; style?: TextStyle }> | Readonly<{ kind: "fillRect"; x: number; y: number; w: number; h: number; style?: TextStyle }> - | Readonly<{ - kind: "blitRect"; - srcX: number; - srcY: number; - w: number; - h: number; - dstX: number; - dstY: number; - }> | Readonly<{ kind: "drawText"; x: number; y: number; text: string; style?: TextStyle }> | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> | Readonly<{ kind: "popClip" }>; @@ -143,10 +134,6 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { this._ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); } - blitRect(srcX: number, srcY: number, w: number, h: number, dstX: number, dstY: number): void { - this._ops.push({ kind: "blitRect", srcX, srcY, w, h, dstX, dstY }); - } - drawText(x: number, y: number, text: string, style?: TextStyle): void { this._ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); } @@ -194,6 +181,15 @@ class RecordingDrawlistBuilder implements DrawlistBuilder { hideCursor(): void {} + blitRect( + _srcX: number, + _srcY: number, + _w: number, + _h: number, + _dstX: number, + _dstY: number, + ): void {} + setLink(_uri: string | null, _id?: string): void {} drawCanvas( @@ -337,50 +333,6 @@ function fillGridRect( } } -function blitGridRect( - grid: string[][], - viewport: InkRendererViewport, - clipStack: readonly ClipRect[], - op: Readonly<{ - srcX: number; - srcY: number; - w: number; - h: number; - dstX: number; - dstY: number; - }>, -): void { - if (op.w <= 0 || op.h <= 0) return; - - const srcCells: string[][] = []; - for (let dy = 0; dy < op.h; dy++) { - const row: string[] = []; - const srcY = op.srcY + dy; - for (let dx = 0; dx < op.w; dx++) { - const srcX = op.srcX + dx; - if (srcX < 0 || srcX >= viewport.cols || srcY < 0 || srcY >= viewport.rows) { - row.push(" "); - continue; - } - row.push(grid[srcY]?.[srcX] ?? " "); - } - srcCells.push(row); - } - - for (let dy = 0; dy < op.h; dy++) { - const dstY = op.dstY + dy; - if (dstY < 0 || dstY >= viewport.rows) continue; - const dstRow = grid[dstY]; - if (!dstRow) continue; - - for (let dx = 0; dx < op.w; dx++) { - const dstX = op.dstX + dx; - if (dstX < 0 || dstX >= viewport.cols || !inClipStack(dstX, dstY, clipStack)) continue; - dstRow[dstX] = srcCells[dy]?.[dx] ?? " "; - } - } -} - function opsToText(ops: readonly InkRenderOp[], viewport: InkRendererViewport): string { const grid: string[][] = []; for (let y = 0; y < viewport.rows; y++) { @@ -401,10 +353,6 @@ function opsToText(ops: readonly InkRenderOp[], viewport: InkRendererViewport): fillGridRect(grid, viewport, clipStack, op); continue; } - if (op.kind === "blitRect") { - blitGridRect(grid, viewport, clipStack, op); - continue; - } if (op.kind === "drawText") { drawTextToGrid(grid, viewport, clipStack, op.x, op.y, op.text); continue; @@ -432,7 +380,6 @@ function createZeroOpCounts(): Record { clear: 0, clearTo: 0, fillRect: 0, - blitRect: 0, drawText: 0, pushClip: 0, popClip: 0, diff --git a/packages/native/README.md b/packages/native/README.md index ac58a206..2699e7b1 100644 --- a/packages/native/README.md +++ b/packages/native/README.md @@ -18,11 +18,19 @@ Smoke test: npm -w @rezi-ui/native run test:native:smoke ``` +Vendoring integrity check: + +```bash +npm run check:native-vendor +``` + ## Design and constraints - Engine placement is controlled by `@rezi-ui/node` `executionMode` (`auto` | `worker` | `inline`). - `executionMode: "auto"` selects inline when `fpsCap <= 30`, worker otherwise. - All buffers across the boundary are caller-owned; binary formats are validated strictly. +- Native compilation reads `packages/native/vendor/zireael` (not `vendor/zireael`). +- `packages/native/vendor/VENDOR_COMMIT.txt` must match the `vendor/zireael` gitlink commit. See: diff --git a/packages/native/src/lib.rs b/packages/native/src/lib.rs index 7d3f4534..ccf946a4 100644 --- a/packages/native/src/lib.rs +++ b/packages/native/src/lib.rs @@ -113,9 +113,9 @@ mod ffi { pub _pad2: [u8; 3], } - #[repr(C)] - #[derive(Copy, Clone)] - pub struct zr_terminal_caps_t { + #[repr(C)] + #[derive(Copy, Clone)] + pub struct zr_terminal_caps_t { pub color_mode: u8, pub supports_mouse: u8, pub supports_bracketed_paste: u8, @@ -134,23 +134,25 @@ mod ffi { pub cap_flags: u32, pub cap_force_flags: u32, pub cap_suppress_flags: u32, - } - - #[repr(C)] - #[derive(Copy, Clone)] - pub struct plat_caps_t { - pub color_mode: u8, - pub supports_mouse: u8, - pub supports_bracketed_paste: u8, - pub supports_focus_events: u8, - pub supports_osc52: u8, - pub supports_sync_update: u8, - pub supports_scroll_region: u8, - pub supports_cursor_shape: u8, - pub supports_output_wait_writable: u8, - pub _pad0: [u8; 3], - pub sgr_attrs_supported: u32, - } + } + + #[repr(C)] + #[derive(Copy, Clone)] + pub struct plat_caps_t { + pub color_mode: u8, + pub supports_mouse: u8, + pub supports_bracketed_paste: u8, + pub supports_focus_events: u8, + pub supports_osc52: u8, + pub supports_sync_update: u8, + pub supports_scroll_region: u8, + pub supports_cursor_shape: u8, + pub supports_output_wait_writable: u8, + pub supports_underline_styles: u8, + pub supports_colored_underlines: u8, + pub supports_hyperlinks: u8, + pub sgr_attrs_supported: u32, + } #[repr(C)] #[derive(Copy, Clone)] @@ -159,6 +161,8 @@ mod ffi { pub bg_rgb: u32, pub attrs: u32, pub reserved: u32, + pub underline_rgb: u32, + pub link_ref: u32, } #[repr(C)] @@ -186,6 +190,21 @@ mod ffi { pub cols: u32, pub rows: u32, pub cells: *mut zr_cell_t, + pub links: *mut zr_fb_link_t, + pub links_len: u32, + pub links_cap: u32, + pub link_bytes: *mut u8, + pub link_bytes_len: u32, + pub link_bytes_cap: u32, + } + + #[repr(C)] + #[derive(Copy, Clone)] + pub struct zr_fb_link_t { + pub uri_off: u32, + pub uri_len: u32, + pub id_off: u32, + pub id_len: u32, } #[repr(C)] @@ -216,7 +235,7 @@ mod ffi { pub cursor_visible: u8, pub cursor_shape: u8, pub cursor_blink: u8, - pub _pad0: u8, + pub flags: u8, pub style: zr_style_t, } @@ -318,6 +337,23 @@ mod ffi { pub fn zr_fb_release(fb: *mut zr_fb_t); pub fn zr_fb_cell(fb: *mut zr_fb_t, x: u32, y: u32) -> *mut zr_cell_t; pub fn zr_fb_clear(fb: *mut zr_fb_t, style: *const zr_style_t) -> ZrResultT; + pub fn zr_fb_links_clone_from(dst: *mut zr_fb_t, src: *const zr_fb_t) -> ZrResultT; + pub fn zr_fb_link_intern( + fb: *mut zr_fb_t, + uri: *const u8, + uri_len: usize, + id: *const u8, + id_len: usize, + out_link_ref: *mut u32, + ) -> ZrResultT; + pub fn zr_fb_link_lookup( + fb: *const zr_fb_t, + link_ref: u32, + out_uri: *mut *const u8, + out_uri_len: *mut usize, + out_id: *mut *const u8, + out_id_len: *mut usize, + ) -> ZrResultT; pub fn zr_fb_painter_begin( p: *mut zr_fb_painter_t, fb: *mut zr_fb_t, @@ -1542,6 +1578,8 @@ mod tests { bg_rgb: 0, attrs, reserved: 0, + underline_rgb: 0, + link_ref: 0, } } @@ -1551,6 +1589,8 @@ mod tests { bg_rgb: 0, attrs: 0, reserved: 0, + underline_rgb: 0, + link_ref: 0, } } @@ -1564,6 +1604,12 @@ mod tests { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let rc = unsafe { ffi::zr_fb_init(&mut raw as *mut _, 1, 1) }; @@ -1600,6 +1646,12 @@ mod tests { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let rc = unsafe { ffi::zr_fb_init(&mut raw as *mut _, cols, rows) }; assert_eq!(rc, ffi::ZR_OK, "zr_fb_init must succeed for test framebuffer"); @@ -1627,6 +1679,20 @@ mod tests { (*cell).style = style; } } + + fn set_cell_link_ref(&mut self, x: u32, y: u32, link_ref: u32) { + let cell = unsafe { ffi::zr_fb_cell(&mut self.raw as *mut _, x, y) }; + assert!(!cell.is_null(), "zr_fb_cell({x},{y}) must return a valid pointer"); + unsafe { + (*cell).style.link_ref = link_ref; + } + } + + fn cell_link_ref(&mut self, x: u32, y: u32) -> u32 { + let cell = unsafe { ffi::zr_fb_cell(&mut self.raw as *mut _, x, y) }; + assert!(!cell.is_null(), "zr_fb_cell({x},{y}) must return a valid pointer"); + unsafe { (*cell).style.link_ref } + } } impl Drop for TestFramebuffer { @@ -1640,19 +1706,21 @@ mod tests { next: &ffi::zr_fb_t, initial_style: ffi::zr_style_t, ) -> Vec { - let caps = ffi::plat_caps_t { - color_mode: 3, - supports_mouse: 0, - supports_bracketed_paste: 0, - supports_focus_events: 0, - supports_osc52: 0, - supports_sync_update: 0, - supports_scroll_region: 0, - supports_cursor_shape: 1, - supports_output_wait_writable: 0, - _pad0: [0, 0, 0], - sgr_attrs_supported: u32::MAX, - }; + let caps = ffi::plat_caps_t { + color_mode: 3, + supports_mouse: 0, + supports_bracketed_paste: 0, + supports_focus_events: 0, + supports_osc52: 0, + supports_sync_update: 0, + supports_scroll_region: 0, + supports_cursor_shape: 1, + supports_output_wait_writable: 0, + supports_underline_styles: 0, + supports_colored_underlines: 0, + supports_hyperlinks: 0, + sgr_attrs_supported: u32::MAX, + }; let limits = unsafe { ffi::zr_engine_config_default() }.limits; let initial_term_state = ffi::zr_term_state_t { cursor_x: 0, @@ -1660,7 +1728,7 @@ mod tests { cursor_visible: 1, cursor_shape: 0, cursor_blink: 0, - _pad0: 0, + flags: 0, style: initial_style, }; let desired_cursor_state = ffi::zr_cursor_state_t { @@ -1721,12 +1789,197 @@ mod tests { unsafe { ((*cell).glyph[0], (*cell).width) } } + #[test] + fn fb_links_clone_from_failure_has_no_partial_effects() { + let mut dst = TestFramebuffer::new(2, 1); + let uri = b"https://example.test/rezi"; + let mut link_ref = 0u32; + let intern_rc = unsafe { + ffi::zr_fb_link_intern( + &mut dst.raw as *mut _, + uri.as_ptr(), + uri.len(), + std::ptr::null(), + 0, + &mut link_ref as *mut _, + ) + }; + assert_eq!(intern_rc, ffi::ZR_OK, "zr_fb_link_intern must seed destination link state"); + assert_eq!(link_ref, 1u32); + + let before_links_ptr = dst.raw.links; + let before_links_len = dst.raw.links_len; + let before_links_cap = dst.raw.links_cap; + let before_link_bytes_ptr = dst.raw.link_bytes; + let before_link_bytes_len = dst.raw.link_bytes_len; + let before_link_bytes_cap = dst.raw.link_bytes_cap; + assert!(!before_links_ptr.is_null(), "seeded links pointer must be non-null"); + assert!(!before_link_bytes_ptr.is_null(), "seeded link-bytes pointer must be non-null"); + + let before_first_link = unsafe { *before_links_ptr }; + let before_link_bytes = + unsafe { std::slice::from_raw_parts(before_link_bytes_ptr, before_link_bytes_len as usize).to_vec() }; + + let invalid_src = ffi::zr_fb_t { + cols: dst.raw.cols, + rows: dst.raw.rows, + cells: dst.raw.cells, + links: std::ptr::null_mut(), + links_len: 1, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: before_link_bytes_len, + link_bytes_cap: 0, + }; + let clone_rc = unsafe { ffi::zr_fb_links_clone_from(&mut dst.raw as *mut _, &invalid_src as *const _) }; + assert_eq!(clone_rc, ffi::ZR_ERR_INVALID_ARGUMENT); + + assert_eq!(dst.raw.links, before_links_ptr); + assert_eq!(dst.raw.links_len, before_links_len); + assert_eq!(dst.raw.links_cap, before_links_cap); + assert_eq!(dst.raw.link_bytes, before_link_bytes_ptr); + assert_eq!(dst.raw.link_bytes_len, before_link_bytes_len); + assert_eq!(dst.raw.link_bytes_cap, before_link_bytes_cap); + + let after_first_link = unsafe { *dst.raw.links }; + assert_eq!(after_first_link.uri_off, before_first_link.uri_off); + assert_eq!(after_first_link.uri_len, before_first_link.uri_len); + assert_eq!(after_first_link.id_off, before_first_link.id_off); + assert_eq!(after_first_link.id_len, before_first_link.id_len); + + let after_link_bytes = + unsafe { std::slice::from_raw_parts(dst.raw.link_bytes, dst.raw.link_bytes_len as usize) }; + assert_eq!(after_link_bytes, before_link_bytes.as_slice()); + } + + #[test] + fn fb_link_intern_compacts_stale_refs_and_bounds_growth() { + const LINK_ENTRY_MAX_BYTES: u32 = 2083 + 2083; + let mut fb = TestFramebuffer::new(2, 1); + let persistent_uri = b"https://example.test/persistent"; + + let mut persistent_ref = 0u32; + let seed_rc = unsafe { + ffi::zr_fb_link_intern( + &mut fb.raw as *mut _, + persistent_uri.as_ptr(), + persistent_uri.len(), + std::ptr::null(), + 0, + &mut persistent_ref as *mut _, + ) + }; + assert_eq!(seed_rc, ffi::ZR_OK); + assert_ne!(persistent_ref, 0); + fb.set_cell_link_ref(0, 0, persistent_ref); + + let mut peak_links_len = fb.raw.links_len; + let mut peak_link_bytes_len = fb.raw.link_bytes_len; + + for i in 0..64u32 { + let uri = format!("https://example.test/ephemeral/{i}"); + let mut ref_i = 0u32; + let rc = unsafe { + ffi::zr_fb_link_intern( + &mut fb.raw as *mut _, + uri.as_ptr(), + uri.len(), + std::ptr::null(), + 0, + &mut ref_i as *mut _, + ) + }; + assert_eq!(rc, ffi::ZR_OK, "zr_fb_link_intern failed at iteration {i}"); + assert!(ref_i >= 1 && ref_i <= fb.raw.links_len); + + fb.set_cell_link_ref(1, 0, ref_i); + + let live_ref0 = fb.cell_link_ref(0, 0); + let live_ref1 = fb.cell_link_ref(1, 0); + assert!(live_ref0 >= 1 && live_ref0 <= fb.raw.links_len, "cell(0,0) link_ref must remain valid"); + assert!(live_ref1 >= 1 && live_ref1 <= fb.raw.links_len, "cell(1,0) link_ref must remain valid"); + + peak_links_len = peak_links_len.max(fb.raw.links_len); + peak_link_bytes_len = peak_link_bytes_len.max(fb.raw.link_bytes_len); + } + + assert!( + peak_links_len <= 5, + "link table must stay bounded for 2-cell framebuffer (peak={peak_links_len})", + ); + assert!( + peak_link_bytes_len <= 5 * LINK_ENTRY_MAX_BYTES, + "link byte arena must stay bounded for 2-cell framebuffer (peak={peak_link_bytes_len})", + ); + + let mut uri_ptr: *const u8 = std::ptr::null(); + let mut uri_len: usize = 0; + let mut id_ptr: *const u8 = std::ptr::null(); + let mut id_len: usize = 0; + let persistent_cell_ref = fb.cell_link_ref(0, 0); + let lookup_rc = unsafe { + ffi::zr_fb_link_lookup( + &fb.raw as *const _, + persistent_cell_ref, + &mut uri_ptr as *mut _, + &mut uri_len as *mut _, + &mut id_ptr as *mut _, + &mut id_len as *mut _, + ) + }; + assert_eq!(lookup_rc, ffi::ZR_OK); + assert_eq!(id_len, 0); + assert!(id_ptr.is_null()); + assert!(!uri_ptr.is_null()); + + let resolved_uri = unsafe { std::slice::from_raw_parts(uri_ptr, uri_len) }; + assert_eq!(resolved_uri, persistent_uri); + } + + #[test] + fn ffi_layout_matches_vendored_headers() { + use std::mem::{align_of, size_of}; + use std::ptr::addr_of; + + assert_eq!(size_of::(), 24); + assert_eq!(align_of::(), 4); + assert_eq!(size_of::(), 60); + assert_eq!(size_of::(), 36); + assert_eq!(size_of::(), 16); + assert_eq!(align_of::(), 4); + + let caps = std::mem::MaybeUninit::::uninit(); + let base = caps.as_ptr(); + unsafe { + assert_eq!(addr_of!((*base).color_mode) as usize - base as usize, 0); + assert_eq!(addr_of!((*base).supports_output_wait_writable) as usize - base as usize, 8); + assert_eq!(addr_of!((*base).supports_underline_styles) as usize - base as usize, 9); + assert_eq!(addr_of!((*base).supports_colored_underlines) as usize - base as usize, 10); + assert_eq!(addr_of!((*base).supports_hyperlinks) as usize - base as usize, 11); + assert_eq!(addr_of!((*base).sgr_attrs_supported) as usize - base as usize, 12); + } + + if cfg!(target_pointer_width = "64") { + assert_eq!(size_of::(), 48); + assert_eq!(align_of::(), 8); + } else if cfg!(target_pointer_width = "32") { + assert_eq!(size_of::(), 36); + assert_eq!(align_of::(), 4); + } + } + #[test] fn clip_edge_write_over_continuation_cleans_lead_pair() { let mut fb = ffi::zr_fb_t { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let init_rc = unsafe { ffi::zr_fb_init(&mut fb as *mut _, 4, 1) }; assert_eq!(init_rc, ffi::ZR_OK); @@ -1824,6 +2077,12 @@ mod tests { cols: 0, rows: 0, cells: std::ptr::null_mut(), + links: std::ptr::null_mut(), + links_len: 0, + links_cap: 0, + link_bytes: std::ptr::null_mut(), + link_bytes_len: 0, + link_bytes_cap: 0, }; let init_rc = unsafe { ffi::zr_fb_init(&mut fb as *mut _, 4, 1) }; assert_eq!(init_rc, ffi::ZR_OK); diff --git a/packages/native/vendor/VENDOR_COMMIT.txt b/packages/native/vendor/VENDOR_COMMIT.txt index 12edbb28..ea32f6a9 100644 --- a/packages/native/vendor/VENDOR_COMMIT.txt +++ b/packages/native/vendor/VENDOR_COMMIT.txt @@ -1 +1 @@ -9e9ea6f54e3b83b60d6457b06646d5c488b94156 +c0849ae29483322623d4ab564877a8940896affb diff --git a/packages/native/vendor/zireael/include/zr/zr_caps.h b/packages/native/vendor/zireael/include/zr/zr_caps.h index cb27c3f9..933870c1 100644 --- a/packages/native/vendor/zireael/include/zr/zr_caps.h +++ b/packages/native/vendor/zireael/include/zr/zr_caps.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_CAPS_H_INCLUDED #define ZR_ZR_CAPS_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_result.h" #include @@ -46,4 +50,8 @@ zr_limits_t zr_limits_default(void); /* Validate limit values and relationships. */ zr_result_t zr_limits_validate(const zr_limits_t* limits); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_CAPS_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_config.h b/packages/native/vendor/zireael/include/zr/zr_config.h index b751f787..4e7ddb95 100644 --- a/packages/native/vendor/zireael/include/zr/zr_config.h +++ b/packages/native/vendor/zireael/include/zr/zr_config.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_CONFIG_H_INCLUDED #define ZR_ZR_CONFIG_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_caps.h" #include "zr/zr_platform_types.h" #include "zr/zr_result.h" @@ -92,4 +96,8 @@ zr_result_t zr_engine_config_validate(const zr_engine_config_t* cfg); /* Validate runtime config for engine_set_config. */ zr_result_t zr_engine_runtime_config_validate(const zr_engine_runtime_config_t* cfg); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_CONFIG_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_debug.h b/packages/native/vendor/zireael/include/zr/zr_debug.h index 7bd7b38f..6f920b65 100644 --- a/packages/native/vendor/zireael/include/zr/zr_debug.h +++ b/packages/native/vendor/zireael/include/zr/zr_debug.h @@ -14,6 +14,10 @@ #ifndef ZR_ZR_DEBUG_H_INCLUDED #define ZR_ZR_DEBUG_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_result.h" #include @@ -220,4 +224,8 @@ typedef struct zr_debug_stats_t { */ zr_debug_config_t zr_debug_config_default(void); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_DEBUG_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_drawlist.h b/packages/native/vendor/zireael/include/zr/zr_drawlist.h index 4ceffb80..3860a11c 100644 --- a/packages/native/vendor/zireael/include/zr/zr_drawlist.h +++ b/packages/native/vendor/zireael/include/zr/zr_drawlist.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_DRAWLIST_H_INCLUDED #define ZR_ZR_DRAWLIST_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include /* ABI-facing types (little-endian on-wire). */ @@ -260,4 +264,8 @@ typedef struct zr_dl_cmd_free_resource_t { uint32_t id; } zr_dl_cmd_free_resource_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_DRAWLIST_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_engine.h b/packages/native/vendor/zireael/include/zr/zr_engine.h index 1af9dfb8..dfa5c20c 100644 --- a/packages/native/vendor/zireael/include/zr/zr_engine.h +++ b/packages/native/vendor/zireael/include/zr/zr_engine.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_ENGINE_H_INCLUDED #define ZR_ZR_ENGINE_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_config.h" #include "zr/zr_debug.h" #include "zr/zr_metrics.h" @@ -199,4 +203,8 @@ int32_t engine_debug_export(zr_engine_t* e, uint8_t* out_buf, size_t out_cap); /* Clear trace records while keeping tracing enabled. */ void engine_debug_reset(zr_engine_t* e); +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_ENGINE_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_event.h b/packages/native/vendor/zireael/include/zr/zr_event.h index fbf3a7e5..90f2f804 100644 --- a/packages/native/vendor/zireael/include/zr/zr_event.h +++ b/packages/native/vendor/zireael/include/zr/zr_event.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_EVENT_H_INCLUDED #define ZR_ZR_EVENT_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_version.h" #include @@ -188,4 +192,8 @@ typedef struct zr_ev_user_t { uint32_t reserved1; } zr_ev_user_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_EVENT_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_metrics.h b/packages/native/vendor/zireael/include/zr/zr_metrics.h index 7b0d5407..13ca8d6a 100644 --- a/packages/native/vendor/zireael/include/zr/zr_metrics.h +++ b/packages/native/vendor/zireael/include/zr/zr_metrics.h @@ -9,6 +9,10 @@ #ifndef ZR_ZR_METRICS_H_INCLUDED #define ZR_ZR_METRICS_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include /* @@ -64,8 +68,12 @@ typedef struct zr_metrics_t { /* --- Damage summary (last frame) --- */ uint32_t damage_rects_last_frame; uint32_t damage_cells_last_frame; - uint8_t damage_full_frame; - uint8_t _pad2[3]; + uint8_t damage_full_frame; + uint8_t _pad2[3]; } zr_metrics_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_METRICS_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_platform_types.h b/packages/native/vendor/zireael/include/zr/zr_platform_types.h index cb88a2d9..939b3d86 100644 --- a/packages/native/vendor/zireael/include/zr/zr_platform_types.h +++ b/packages/native/vendor/zireael/include/zr/zr_platform_types.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_PLATFORM_TYPES_H_INCLUDED #define ZR_ZR_PLATFORM_TYPES_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include /* @@ -69,4 +73,8 @@ typedef struct plat_config_t { uint8_t _pad[3]; } plat_config_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_PLATFORM_TYPES_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_result.h b/packages/native/vendor/zireael/include/zr/zr_result.h index e4b40e19..fa585f70 100644 --- a/packages/native/vendor/zireael/include/zr/zr_result.h +++ b/packages/native/vendor/zireael/include/zr/zr_result.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_RESULT_H_INCLUDED #define ZR_ZR_RESULT_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + typedef int zr_result_t; /* Success. */ @@ -25,4 +29,8 @@ typedef int zr_result_t; #define ZR_ERR_FORMAT ((zr_result_t) - 5) #define ZR_ERR_PLATFORM ((zr_result_t) - 6) +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_RESULT_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h b/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h index 5355b81c..6b4aadef 100644 --- a/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h +++ b/packages/native/vendor/zireael/include/zr/zr_terminal_caps.h @@ -9,6 +9,10 @@ #ifndef ZR_ZR_TERMINAL_CAPS_H_INCLUDED #define ZR_ZR_TERMINAL_CAPS_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + #include "zr/zr_platform_types.h" #include @@ -157,4 +161,8 @@ typedef struct zr_terminal_caps_t { zr_terminal_cap_flags_t cap_suppress_flags; } zr_terminal_caps_t; +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_TERMINAL_CAPS_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/include/zr/zr_version.h b/packages/native/vendor/zireael/include/zr/zr_version.h index f6261076..fce007e1 100644 --- a/packages/native/vendor/zireael/include/zr/zr_version.h +++ b/packages/native/vendor/zireael/include/zr/zr_version.h @@ -8,6 +8,10 @@ #ifndef ZR_ZR_VERSION_H_INCLUDED #define ZR_ZR_VERSION_H_INCLUDED +#ifdef __cplusplus +extern "C" { +#endif + /* NOTE: These version pins are part of the determinism contract. They must not be overridden by downstream builds. @@ -35,4 +39,8 @@ /* Packed event batch binary format versions. */ #define ZR_EVENT_BATCH_VERSION_V1 (1u) +#ifdef __cplusplus +} +#endif + #endif /* ZR_ZR_VERSION_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/core/zr_cursor.h b/packages/native/vendor/zireael/src/core/zr_cursor.h index 169b62f7..267c9ac8 100644 --- a/packages/native/vendor/zireael/src/core/zr_cursor.h +++ b/packages/native/vendor/zireael/src/core/zr_cursor.h @@ -12,9 +12,9 @@ #include typedef uint8_t zr_cursor_shape_t; -#define ZR_CURSOR_SHAPE_BLOCK ((zr_cursor_shape_t)0u) +#define ZR_CURSOR_SHAPE_BLOCK ((zr_cursor_shape_t)0u) #define ZR_CURSOR_SHAPE_UNDERLINE ((zr_cursor_shape_t)1u) -#define ZR_CURSOR_SHAPE_BAR ((zr_cursor_shape_t)2u) +#define ZR_CURSOR_SHAPE_BAR ((zr_cursor_shape_t)2u) /* zr_cursor_state_t: @@ -32,4 +32,3 @@ typedef struct zr_cursor_state_t { } zr_cursor_state_t; #endif /* ZR_CORE_ZR_CURSOR_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/core/zr_damage.c b/packages/native/vendor/zireael/src/core/zr_damage.c index 58b37b99..1e83aa13 100644 --- a/packages/native/vendor/zireael/src/core/zr_damage.c +++ b/packages/native/vendor/zireael/src/core/zr_damage.c @@ -46,6 +46,7 @@ static void zr_damage_mark_full(zr_damage_t* d) { d->rects[0].y0 = 0u; d->rects[0].x1 = d->cols - 1u; d->rects[0].y1 = d->rows - 1u; + d->rects[0]._link = UINT32_MAX; d->rect_count = 1u; } @@ -97,6 +98,7 @@ void zr_damage_add_span(zr_damage_t* d, uint32_t y, uint32_t x0, uint32_t x1) { r->y0 = y; r->x1 = x1; r->y1 = y; + r->_link = UINT32_MAX; } uint32_t zr_damage_cells(const zr_damage_t* d) { diff --git a/packages/native/vendor/zireael/src/core/zr_damage.h b/packages/native/vendor/zireael/src/core/zr_damage.h index 342c4072..c819b964 100644 --- a/packages/native/vendor/zireael/src/core/zr_damage.h +++ b/packages/native/vendor/zireael/src/core/zr_damage.h @@ -16,22 +16,30 @@ typedef struct zr_damage_rect_t { uint32_t y0; uint32_t x1; uint32_t y1; + /* + Scratch link field for allocation-free damage coalescing. + + Why: The diff renderer's indexed damage-walk needs per-rectangle "next" + pointers but must not clobber the rectangle coordinates because the engine + can reuse the computed rectangles after diff emission (e.g. for fb_prev + resync on partial presents). + */ + uint32_t _link; } zr_damage_rect_t; typedef struct zr_damage_t { zr_damage_rect_t* rects; - uint32_t rect_cap; - uint32_t rect_count; - uint32_t cols; - uint32_t rows; - uint8_t full_frame; - uint8_t _pad0[3]; + uint32_t rect_cap; + uint32_t rect_count; + uint32_t cols; + uint32_t rows; + uint8_t full_frame; + uint8_t _pad0[3]; } zr_damage_t; -void zr_damage_begin_frame(zr_damage_t* d, zr_damage_rect_t* storage, uint32_t storage_cap, uint32_t cols, - uint32_t rows); -void zr_damage_add_span(zr_damage_t* d, uint32_t y, uint32_t x0, uint32_t x1); +void zr_damage_begin_frame(zr_damage_t* d, zr_damage_rect_t* storage, uint32_t storage_cap, uint32_t cols, + uint32_t rows); +void zr_damage_add_span(zr_damage_t* d, uint32_t y, uint32_t x0, uint32_t x1); uint32_t zr_damage_cells(const zr_damage_t* d); #endif /* ZR_CORE_ZR_DAMAGE_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/core/zr_diff.c b/packages/native/vendor/zireael/src/core/zr_diff.c index 63ace7b0..4b44fc4d 100644 --- a/packages/native/vendor/zireael/src/core/zr_diff.c +++ b/packages/native/vendor/zireael/src/core/zr_diff.c @@ -311,6 +311,90 @@ static bool zr_row_eq_exact(const zr_fb_t* a, uint32_t ay, const zr_fb_t* b, uin return memcmp(pa, pb, row_bytes) == 0; } +static bool zr_fb_links_eq_exact(const zr_fb_t* a, const zr_fb_t* b) { + if (!a || !b) { + return false; + } + if (a->links_len != b->links_len || a->link_bytes_len != b->link_bytes_len) { + return false; + } + if (a->links_len == 0u && a->link_bytes_len == 0u) { + return true; + } + const bool links_ptr_bad = (a->links_len != 0u && (!a->links || !b->links)); + const bool bytes_ptr_bad = (a->link_bytes_len != 0u && (!a->link_bytes || !b->link_bytes)); + if (links_ptr_bad || bytes_ptr_bad) { + return false; + } + + if (a->links_len != 0u) { + size_t links_bytes = 0u; + if (!zr_checked_mul_size((size_t)a->links_len, sizeof(zr_fb_link_t), &links_bytes)) { + return false; + } + if (memcmp(a->links, b->links, links_bytes) != 0) { + return false; + } + } + if (a->link_bytes_len != 0u) { + if (memcmp(a->link_bytes, b->link_bytes, (size_t)a->link_bytes_len) != 0) { + return false; + } + } + + return true; +} + +/* + Compare hyperlink targets for corresponding cells across framebuffer domains. + + Why: row hashing and exact byte compares do not include hyperlink payloads + (URI/ID). Two rows can be byte-identical while mapping the same link_ref + indices to different targets, so row-cache decisions must validate targets. +*/ +static bool zr_row_links_targets_eq(const zr_fb_t* a, uint32_t ay, const zr_fb_t* b, uint32_t by) { + if (!a || !b || a->cols != b->cols) { + return false; + } + if (ay >= a->rows || by >= b->rows) { + return false; + } + if (a->cols == 0u) { + return true; + } + if (a->links_len == 0u && b->links_len == 0u) { + return true; + } + + const zr_cell_t* arow = (const zr_cell_t*)zr_fb_row_ptr(a, ay); + const zr_cell_t* brow = (const zr_cell_t*)zr_fb_row_ptr(b, by); + if (!arow || !brow) { + return false; + } + + uint32_t last_a_ref = 0u; + uint32_t last_b_ref = 0u; + for (uint32_t x = 0u; x < a->cols; x++) { + const uint32_t a_ref = arow[x].style.link_ref; + const uint32_t b_ref = brow[x].style.link_ref; + if (a_ref == 0u && b_ref == 0u) { + last_a_ref = 0u; + last_b_ref = 0u; + continue; + } + if (a_ref == last_a_ref && b_ref == last_b_ref) { + continue; + } + if (!zr_link_targets_eq(a, a_ref, b, b_ref)) { + return false; + } + last_a_ref = a_ref; + last_b_ref = b_ref; + } + + return true; +} + static uint64_t zr_hash_bytes_fnv1a64(const uint8_t* bytes, size_t n) { if (!bytes && n != 0u) { return 0u; @@ -1123,6 +1207,7 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr ctx->has_row_cache = true; const bool reuse_prev_hashes = (scratch->prev_hashes_valid != 0u); + const bool links_exact_equal = zr_fb_links_eq_exact(ctx->prev, ctx->next); for (uint32_t y = 0u; y < ctx->next->rows; y++) { uint64_t prev_hash = 0u; @@ -1142,6 +1227,8 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr /* Collision guard: equal hash must still pass exact row-byte compare. */ dirty = 1u; ctx->stats.collision_guard_hits++; + } else if (!links_exact_equal && !zr_row_links_targets_eq(ctx->prev, y, ctx->next, y)) { + dirty = 1u; } ctx->dirty_rows[y] = dirty; @@ -1153,7 +1240,10 @@ static void zr_diff_prepare_row_cache(zr_diff_ctx_t* ctx, zr_diff_scratch_t* scr /* Compare full framebuffer rows for scroll-shift detection (full width). */ static bool zr_row_eq(const zr_fb_t* a, uint32_t ay, const zr_fb_t* b, uint32_t by) { - return zr_row_eq_exact(a, ay, b, by); + if (!zr_row_eq_exact(a, ay, b, by)) { + return false; + } + return zr_row_links_targets_eq(a, ay, b, by); } /* Deterministic preference order for competing scroll candidates. */ @@ -1711,7 +1801,7 @@ static void zr_diff_row_heads_reset(uint64_t* row_heads, uint32_t rows) { } /* - Use rect.y0 as a temporary intrusive "next" index while coalescing. + Use rect._link as a temporary intrusive "next" index while coalescing. Why: Indexed coalescing must stay allocation-free in the present hot path. Damage rectangles are frame-local scratch, so temporary link reuse is safe. @@ -1720,14 +1810,14 @@ static uint32_t zr_diff_rect_link_get(const zr_damage_rect_t* r) { if (!r) { return ZR_DIFF_RECT_INDEX_NONE; } - return r->y0; + return r->_link; } static void zr_diff_rect_link_set(zr_damage_rect_t* r, uint32_t next_idx) { if (!r) { return; } - r->y0 = next_idx; + r->_link = next_idx; } typedef struct zr_diff_active_rects_t { diff --git a/packages/native/vendor/zireael/src/core/zr_drawlist.c b/packages/native/vendor/zireael/src/core/zr_drawlist.c index b8dd850f..df077861 100644 --- a/packages/native/vendor/zireael/src/core/zr_drawlist.c +++ b/packages/native/vendor/zireael/src/core/zr_drawlist.c @@ -1642,17 +1642,17 @@ static zr_result_t zr_dl_validate_blit_rect_bounds(const zr_fb_t* fb, const zr_d return ZR_ERR_INVALID_ARGUMENT; } if (cmd->w <= 0 || cmd->h <= 0 || cmd->src_x < 0 || cmd->src_y < 0 || cmd->dst_x < 0 || cmd->dst_y < 0) { - return ZR_ERR_INVALID_ARGUMENT; + return ZR_ERR_FORMAT; } if (!zr_checked_add_u32((uint32_t)cmd->src_x, (uint32_t)cmd->w, &src_x_end) || !zr_checked_add_u32((uint32_t)cmd->src_y, (uint32_t)cmd->h, &src_y_end) || !zr_checked_add_u32((uint32_t)cmd->dst_x, (uint32_t)cmd->w, &dst_x_end) || !zr_checked_add_u32((uint32_t)cmd->dst_y, (uint32_t)cmd->h, &dst_y_end)) { - return ZR_ERR_INVALID_ARGUMENT; + return ZR_ERR_FORMAT; } if (src_x_end > fb->cols || src_y_end > fb->rows || dst_x_end > fb->cols || dst_y_end > fb->rows) { - return ZR_ERR_INVALID_ARGUMENT; + return ZR_ERR_FORMAT; } return ZR_OK; diff --git a/packages/native/vendor/zireael/src/core/zr_engine.c b/packages/native/vendor/zireael/src/core/zr_engine.c index 44e66405..83f5b923 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine.c +++ b/packages/native/vendor/zireael/src/core/zr_engine.c @@ -78,6 +78,9 @@ struct zr_engine_t { zr_term_state_t term_state; zr_cursor_state_t cursor_desired; + /* True when fb_prev is a byte-identical copy of fb_next (submit rollback fast-path). */ + uint8_t fb_next_synced_to_prev; + uint8_t _pad_fb_sync0[3]; /* --- Image sideband state (DRAW_IMAGE staging + protocol cache) --- */ zr_image_frame_t image_frame_next; @@ -501,7 +504,6 @@ static zr_result_t zr_engine_fb_copy(const zr_fb_t* src, zr_fb_t* dst) { if (n != 0u && src->cells && dst->cells) { memcpy(dst->cells, src->cells, n); } - zr_fb_links_reset(dst); return zr_fb_links_clone_from(dst, src); } @@ -615,6 +617,10 @@ static zr_result_t zr_engine_resize_framebuffers(zr_engine_t* e, uint32_t cols, e->term_state.flags &= (uint8_t) ~(ZR_TERM_STATE_STYLE_VALID | ZR_TERM_STATE_CURSOR_POS_VALID | ZR_TERM_STATE_SCREEN_VALID); + /* After resize, prev/next are newly allocated and cleared identically. */ + e->fb_next_synced_to_prev = 1u; + memset(e->_pad_fb_sync0, 0, sizeof(e->_pad_fb_sync0)); + return ZR_OK; } @@ -1500,12 +1506,37 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt return rc; } + /* + Snapshot fb_next for rollback when fb_prev does not currently match it. + + Why: The submit path executes in-place into fb_next; to preserve the + no-partial-effects contract we need a rollback source that represents the + pre-submit fb_next contents (not necessarily fb_prev when debug overlays + or multi-submit scenarios are in play). + */ + bool have_fb_next_snapshot = false; + if (e->fb_next_synced_to_prev == 0u) { + zr_result_t snap_rc = zr_engine_fb_copy_noalloc(&e->fb_next, &e->fb_stage); + if (snap_rc == ZR_ERR_LIMIT) { + snap_rc = zr_engine_fb_copy(&e->fb_next, &e->fb_stage); + } + if (snap_rc != ZR_OK) { + zr_dl_resources_release(&preflight_resources); + zr_dl_resources_release(&e->dl_resources_stage); + zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, + v.hdr.version, ZR_OK, snap_rc); + return snap_rc; + } + have_fb_next_snapshot = true; + } + zr_image_frame_reset(&e->image_frame_stage); rc = zr_dl_preflight_resources(&v, &e->fb_next, &e->image_frame_stage, &e->cfg_runtime.limits, &e->term_profile, &preflight_resources); zr_dl_resources_release(&preflight_resources); if (rc != ZR_OK) { - const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); + const zr_fb_t* rollback_src = have_fb_next_snapshot ? &e->fb_stage : &e->fb_prev; + const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(rollback_src, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); zr_dl_resources_release(&e->dl_resources_stage); if (rollback_rc != ZR_OK) { @@ -1523,7 +1554,8 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt rc = zr_dl_execute(&v, &e->fb_next, &e->cfg_runtime.limits, e->cfg_runtime.tab_width, e->cfg_runtime.width_policy, &blit_caps, &e->term_profile, &e->image_frame_stage, &e->dl_resources_stage, &cursor_stage); if (rc != ZR_OK) { - const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(&e->fb_prev, &e->fb_next); + const zr_fb_t* rollback_src = have_fb_next_snapshot ? &e->fb_stage : &e->fb_prev; + const zr_result_t rollback_rc = zr_engine_fb_copy_noalloc(rollback_src, &e->fb_next); zr_image_frame_reset(&e->image_frame_stage); zr_dl_resources_release(&e->dl_resources_stage); if (rollback_rc != ZR_OK) { @@ -1541,6 +1573,7 @@ zr_result_t engine_submit_drawlist(zr_engine_t* e, const uint8_t* bytes, int byt zr_dl_resources_swap(&e->dl_resources_next, &e->dl_resources_stage); zr_dl_resources_release(&e->dl_resources_stage); e->cursor_desired = cursor_stage; + e->fb_next_synced_to_prev = 0u; zr_engine_trace_drawlist(e, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, bytes, (uint32_t)bytes_len, v.hdr.cmd_count, v.hdr.version, ZR_OK, ZR_OK); diff --git a/packages/native/vendor/zireael/src/core/zr_engine_present.inc b/packages/native/vendor/zireael/src/core/zr_engine_present.inc index 57989889..2dcffe80 100644 --- a/packages/native/vendor/zireael/src/core/zr_engine_present.inc +++ b/packages/native/vendor/zireael/src/core/zr_engine_present.inc @@ -320,6 +320,35 @@ static void zr_engine_trace_diff_telemetry(zr_engine_t* e, uint64_t frame_id, co zr_engine_now_us(), &rec, (uint32_t)sizeof(rec)); } +static bool zr_fb_links_prefix_equal(const zr_fb_t* prefix, const zr_fb_t* full) { + if (!prefix || !full) { + return false; + } + if (prefix->links_len > full->links_len || prefix->link_bytes_len > full->link_bytes_len) { + return false; + } + if (prefix->links_len != 0u && (!prefix->links || !full->links)) { + return false; + } + if (prefix->link_bytes_len != 0u && (!prefix->link_bytes || !full->link_bytes)) { + return false; + } + + if (prefix->links_len != 0u) { + const size_t links_bytes = (size_t)prefix->links_len * sizeof(zr_fb_link_t); + if (memcmp(prefix->links, full->links, links_bytes) != 0) { + return false; + } + } + if (prefix->link_bytes_len != 0u) { + if (memcmp(prefix->link_bytes, full->link_bytes, (size_t)prefix->link_bytes_len) != 0) { + return false; + } + } + + return true; +} + static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_t out_len, const zr_term_state_t* final_ts, const zr_diff_stats_t* stats, const zr_image_state_t* image_state_stage, uint32_t diff_us, uint32_t write_us) { @@ -327,11 +356,15 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ return; } bool invalidate_prev_hashes = false; + bool invalidate_screen_valid = false; + bool fb_next_synced_to_prev = (presented_stage == false); const uint64_t frame_id_presented = zr_engine_trace_frame_id(e); const zr_fb_t* presented_fb = presented_stage ? &e->fb_stage : &e->fb_next; - const bool use_damage_rect_copy = - (stats->path_damage_used != 0u) && (stats->damage_full_frame == 0u) && (stats->damage_rects <= e->damage_rect_cap); + const bool links_compatible_for_damage_copy = zr_fb_links_prefix_equal(&e->fb_prev, presented_fb); + const bool use_damage_rect_copy = links_compatible_for_damage_copy && (stats->path_damage_used != 0u) && + (stats->damage_full_frame == 0u) && (stats->damage_rects != 0u) && + (stats->damage_rects <= e->damage_rect_cap); /* Resync fb_prev to the framebuffer that was actually presented. @@ -342,6 +375,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ if (!e->fb_prev.cells || !presented_fb->cells || e->fb_prev.cols != presented_fb->cols || e->fb_prev.rows != presented_fb->rows) { invalidate_prev_hashes = true; + fb_next_synced_to_prev = false; } else { bool links_synced = false; if (!use_damage_rect_copy) { @@ -350,8 +384,7 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ memcpy(e->fb_prev.cells, presented_fb->cells, n); } } else { - const zr_result_t rc = - zr_fb_copy_damage_rects(&e->fb_prev, presented_fb, e->damage_rects, stats->damage_rects); + const zr_result_t rc = zr_fb_copy_damage_rects(&e->fb_prev, presented_fb, e->damage_rects, stats->damage_rects); if (rc != ZR_OK) { const size_t n = zr_engine_cells_bytes(presented_fb); if (n != 0u) { @@ -363,8 +396,14 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ } } - if (!links_synced && zr_fb_links_clone_from(&e->fb_prev, presented_fb) != ZR_OK) { - invalidate_prev_hashes = true; + if (!links_synced) { + const zr_result_t links_rc = zr_fb_links_clone_from(&e->fb_prev, presented_fb); + if (links_rc != ZR_OK) { + invalidate_prev_hashes = true; + fb_next_synced_to_prev = false; + invalidate_screen_valid = true; + (void)zr_fb_clear(&e->fb_prev, NULL); + } } } @@ -373,7 +412,13 @@ static void zr_engine_present_commit(zr_engine_t* e, bool presented_stage, size_ e->diff_prev_hashes_valid = 0u; } e->term_state = *final_ts; + if (invalidate_screen_valid) { + e->term_state.flags &= + (uint8_t) ~(ZR_TERM_STATE_STYLE_VALID | ZR_TERM_STATE_CURSOR_POS_VALID | ZR_TERM_STATE_SCREEN_VALID); + } e->image_state = *image_state_stage; + e->fb_next_synced_to_prev = fb_next_synced_to_prev ? 1u : 0u; + memset(e->_pad_fb_sync0, 0, sizeof(e->_pad_fb_sync0)); /* --- Update public metrics snapshot --- */ e->metrics.frame_index++; diff --git a/packages/native/vendor/zireael/src/core/zr_event_pack.c b/packages/native/vendor/zireael/src/core/zr_event_pack.c index da95adce..d88d2d4c 100644 --- a/packages/native/vendor/zireael/src/core/zr_event_pack.c +++ b/packages/native/vendor/zireael/src/core/zr_event_pack.c @@ -70,9 +70,8 @@ zr_result_t zr_evpack_begin(zr_evpack_writer_t* w, uint8_t* out_buf, size_t out_ } /* Write placeholder header; patched by zr_evpack_finish(). */ - if (!zr__write_u32le(w, ZR_EV_MAGIC) || !zr__write_u32le(w, ZR_EVENT_BATCH_VERSION_V1) || - !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u) || - !zr__write_u32le(w, 0u)) { + if (!zr__write_u32le(w, ZR_EV_MAGIC) || !zr__write_u32le(w, ZR_EVENT_BATCH_VERSION_V1) || !zr__write_u32le(w, 0u) || + !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u) || !zr__write_u32le(w, 0u)) { /* Should be unreachable due to pre-check. */ memset(w, 0, sizeof(*w)); return ZR_ERR_LIMIT; @@ -82,15 +81,14 @@ zr_result_t zr_evpack_begin(zr_evpack_writer_t* w, uint8_t* out_buf, size_t out_ return ZR_OK; } -bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* payload, size_t payload_len) { +bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* payload, size_t payload_len) { return zr_evpack_append_record2(w, type, time_ms, flags, payload, payload_len, NULL, 0u); } /* Append event record with two payload chunks; sets TRUNCATED flag if no space. */ -bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* p1, size_t n1, const void* p2, - size_t n2) { +bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* p1, size_t n1, const void* p2, size_t n2) { if (!w || !w->started) { return false; } diff --git a/packages/native/vendor/zireael/src/core/zr_event_pack.h b/packages/native/vendor/zireael/src/core/zr_event_pack.h index e662f82d..29e7be3e 100644 --- a/packages/native/vendor/zireael/src/core/zr_event_pack.h +++ b/packages/native/vendor/zireael/src/core/zr_event_pack.h @@ -50,8 +50,8 @@ zr_result_t zr_evpack_begin(zr_evpack_writer_t* w, uint8_t* out_buf, size_t out_ - zr_evpack_begin() must have succeeded. - payload may be NULL only if payload_len == 0. */ -bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* payload, size_t payload_len); +bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* payload, size_t payload_len); /* zr_evpack_append_record2: @@ -59,9 +59,8 @@ bool zr_evpack_append_record(zr_evpack_writer_t* w, zr_event_type_t type, uint32 - Useful for variable-length payload records like PASTE and USER ({hdr}{bytes}). */ -bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, - uint32_t flags, const void* p1, size_t n1, const void* p2, - size_t n2); +bool zr_evpack_append_record2(zr_evpack_writer_t* w, zr_event_type_t type, uint32_t time_ms, uint32_t flags, + const void* p1, size_t n1, const void* p2, size_t n2); /* zr_evpack_finish: diff --git a/packages/native/vendor/zireael/src/core/zr_framebuffer.c b/packages/native/vendor/zireael/src/core/zr_framebuffer.c index 053f06ed..548cea91 100644 --- a/packages/native/vendor/zireael/src/core/zr_framebuffer.c +++ b/packages/native/vendor/zireael/src/core/zr_framebuffer.c @@ -37,6 +37,7 @@ enum { ZR_FB_UTF8_C1_MAX_EXCL = 0xA0u, ZR_FB_LINKS_INITIAL_CAP = 8u, ZR_FB_LINK_BYTES_INITIAL_CAP = 256u, + ZR_FB_LINK_ENTRY_MAX_BYTES = ZR_FB_LINK_URI_MAX_BYTES + ZR_FB_LINK_ID_MAX_BYTES, }; static bool zr_fb_utf8_grapheme_bytes_safe_for_terminal(const uint8_t* bytes, size_t len) { @@ -183,12 +184,13 @@ zr_result_t zr_fb_links_clone_from(zr_fb_t* dst, const zr_fb_t* src) { if (!dst || !src) { return ZR_ERR_INVALID_ARGUMENT; } - zr_fb_links_reset(dst); - if (src->links_len == 0u) { + zr_fb_links_reset(dst); return ZR_OK; } - + if (!src->links || (src->link_bytes_len != 0u && !src->link_bytes)) { + return ZR_ERR_INVALID_ARGUMENT; + } zr_result_t rc = zr_fb_links_ensure_cap(dst, src->links_len); if (rc != ZR_OK) { return rc; @@ -473,6 +475,155 @@ const zr_cell_t* zr_fb_cell_const(const zr_fb_t* fb, uint32_t x, uint32_t y) { return &fb->cells[idx]; } +static uint32_t zr_fb_links_slots_limit(const zr_fb_t* fb) { + if (!fb) { + return 1u; + } + + uint32_t cell_count = 0u; + if (!zr_checked_mul_u32(fb->cols, fb->rows, &cell_count)) { + return UINT32_MAX; + } + + /* + * Allow a bounded transient window while callers replace existing links: + * max slots = (live cells * 2) + 1. + */ + if (cell_count > ((UINT32_MAX - 1u) / 2u)) { + return UINT32_MAX; + } + cell_count = (cell_count * 2u) + 1u; + return (cell_count == 0u) ? 1u : cell_count; +} + +static uint32_t zr_fb_link_bytes_limit(uint32_t slots_limit) { + uint32_t bytes_limit = 0u; + if (!zr_checked_mul_u32(slots_limit, (uint32_t)ZR_FB_LINK_ENTRY_MAX_BYTES, &bytes_limit)) { + return UINT32_MAX; + } + return bytes_limit; +} + +/* + * Reclaim stale link entries by keeping only refs currently referenced by cells. + * + * Why: draw workloads can churn many temporary link targets while mutating a + * fixed-size framebuffer. Compacting live refs prevents unbounded table growth. + */ +static zr_result_t zr_fb_links_compact_live(zr_fb_t* fb) { + if (!fb) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (fb->links_len == 0u) { + fb->link_bytes_len = 0u; + return ZR_OK; + } + if (!fb->links || (fb->link_bytes_len != 0u && !fb->link_bytes)) { + return ZR_ERR_INVALID_ARGUMENT; + } + if (!zr_fb_has_backing(fb)) { + zr_fb_links_reset(fb); + return ZR_OK; + } + + size_t cell_count = 0u; + if (!zr_checked_mul_size((size_t)fb->cols, (size_t)fb->rows, &cell_count)) { + return ZR_ERR_LIMIT; + } + if (cell_count == 0u) { + zr_fb_links_reset(fb); + return ZR_OK; + } + + size_t remap_count = 0u; + if (!zr_checked_add_size((size_t)fb->links_len, 1u, &remap_count)) { + return ZR_ERR_LIMIT; + } + size_t remap_bytes = 0u; + if (!zr_checked_mul_size(remap_count, sizeof(uint32_t), &remap_bytes)) { + return ZR_ERR_LIMIT; + } + uint32_t* remap = (uint32_t*)malloc(remap_bytes); + if (!remap) { + return ZR_ERR_OOM; + } + memset(remap, 0, remap_bytes); + + /* Mark live refs and sanitize any out-of-range link refs in cells. */ + for (size_t i = 0u; i < cell_count; i++) { + const uint32_t ref = fb->cells[i].style.link_ref; + if (ref == 0u) { + continue; + } + if (ref <= fb->links_len) { + remap[(size_t)ref] = UINT32_MAX; + continue; + } + fb->cells[i].style.link_ref = 0u; + } + + const uint32_t old_links_len = fb->links_len; + uint32_t new_links_len = 0u; + uint32_t new_link_bytes_len = 0u; + + for (uint32_t i = 0u; i < old_links_len; i++) { + const uint32_t old_ref = i + 1u; + if (remap[(size_t)old_ref] != UINT32_MAX) { + continue; + } + + const zr_fb_link_t src = fb->links[i]; + uint32_t span_len = 0u; + if (!zr_checked_add_u32(src.uri_len, src.id_len, &span_len)) { + free(remap); + return ZR_ERR_FORMAT; + } + if ((size_t)src.uri_off + (size_t)src.uri_len > (size_t)fb->link_bytes_len || + (size_t)src.id_off + (size_t)src.id_len > (size_t)fb->link_bytes_len) { + free(remap); + return ZR_ERR_FORMAT; + } + if (src.id_off != src.uri_off + src.uri_len) { + free(remap); + return ZR_ERR_FORMAT; + } + if (span_len != 0u && src.uri_off != new_link_bytes_len) { + memmove(fb->link_bytes + new_link_bytes_len, fb->link_bytes + src.uri_off, (size_t)span_len); + } + + zr_fb_link_t dst; + dst.uri_off = new_link_bytes_len; + dst.uri_len = src.uri_len; + dst.id_off = new_link_bytes_len + src.uri_len; + dst.id_len = src.id_len; + + fb->links[new_links_len] = dst; + remap[(size_t)old_ref] = new_links_len + 1u; + new_links_len++; + if (!zr_checked_add_u32(new_link_bytes_len, span_len, &new_link_bytes_len)) { + free(remap); + return ZR_ERR_LIMIT; + } + } + + for (size_t i = 0u; i < cell_count; i++) { + const uint32_t ref = fb->cells[i].style.link_ref; + if (ref == 0u) { + continue; + } + if (ref > old_links_len) { + fb->cells[i].style.link_ref = 0u; + continue; + } + fb->cells[i].style.link_ref = remap[(size_t)ref]; + } + + fb->links_len = new_links_len; + fb->link_bytes_len = new_link_bytes_len; + free(remap); + return ZR_OK; +} + /* * Intern a framebuffer-owned hyperlink target and return a 1-based reference. * @@ -515,6 +666,28 @@ zr_result_t zr_fb_link_intern(zr_fb_t* fb, const uint8_t* uri, size_t uri_len, c return ZR_ERR_LIMIT; } + const uint32_t slots_limit = zr_fb_links_slots_limit(fb); + const uint32_t bytes_limit = zr_fb_link_bytes_limit(slots_limit); + const bool would_grow = need_links > fb->links_cap || need_bytes > fb->link_bytes_cap; + if (would_grow || need_links > slots_limit || need_bytes > bytes_limit) { + zr_result_t compact_rc = zr_fb_links_compact_live(fb); + if (compact_rc != ZR_OK) { + return compact_rc; + } + + if (!zr_checked_add_u32(fb->links_len, 1u, &need_links)) { + return ZR_ERR_LIMIT; + } + if (!zr_checked_add_u32(fb->link_bytes_len, (uint32_t)uri_len, &need_bytes) || + !zr_checked_add_u32(need_bytes, (uint32_t)id_len, &need_bytes)) { + return ZR_ERR_LIMIT; + } + } + + if (need_links > slots_limit || need_bytes > bytes_limit) { + return ZR_ERR_LIMIT; + } + zr_result_t rc = zr_fb_links_ensure_cap(fb, need_links); if (rc != ZR_OK) { return rc; @@ -1243,6 +1416,19 @@ zr_result_t zr_fb_blit_rect(zr_fb_painter_t* p, zr_rect_t dst, zr_rect_t src) { continue; } + /* + * Prevent wide-glyph leads from writing outside the effective rectangle. + * + * Why: BLIT_RECT is specified as a rectangle copy. Wide glyphs must be + * kept invariant-safe, so when a wide lead does not fully fit inside the + * src/dst effective span, replace deterministically rather than touching + * a neighbor cell outside the rectangle. + */ + if (c->width == 2u && (ox + 1) >= w) { + (void)zr_fb_put_grapheme(p, dx, dy, ZR_UTF8_REPLACEMENT, ZR_UTF8_REPLACEMENT_LEN, 1u, &c->style); + continue; + } + (void)zr_fb_put_grapheme(p, dx, dy, c->glyph, (size_t)c->glyph_len, c->width, &c->style); } } diff --git a/packages/native/vendor/zireael/src/core/zr_metrics.c b/packages/native/vendor/zireael/src/core/zr_metrics.c index 671daba2..6d585704 100644 --- a/packages/native/vendor/zireael/src/core/zr_metrics.c +++ b/packages/native/vendor/zireael/src/core/zr_metrics.c @@ -25,7 +25,9 @@ zr_metrics_t zr_metrics__default_snapshot(void) { return m; } -static size_t zr_min_size(size_t a, size_t b) { return (a < b) ? a : b; } +static size_t zr_min_size(size_t a, size_t b) { + return (a < b) ? a : b; +} /* Prefix-copy a snapshot into out_metrics without overruns (append-only ABI). */ zr_result_t zr_metrics__copy_out(zr_metrics_t* out_metrics, const zr_metrics_t* snapshot) { diff --git a/packages/native/vendor/zireael/src/core/zr_placeholder.c b/packages/native/vendor/zireael/src/core/zr_placeholder.c index 36dcb225..87a28d46 100644 --- a/packages/native/vendor/zireael/src/core/zr_placeholder.c +++ b/packages/native/vendor/zireael/src/core/zr_placeholder.c @@ -4,4 +4,5 @@ Why: Keeps the CMake scaffolding building before real engine sources land. */ -void zireael__placeholder(void) {} +void zireael__placeholder(void) { +} diff --git a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c index ef100cc5..2bf98362 100644 --- a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c +++ b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.c @@ -23,7 +23,8 @@ typedef HANDLE zr_win32_hpc_t; -typedef HRESULT(WINAPI* zr_win32_create_pseudoconsole_fn)(COORD size, HANDLE h_in, HANDLE h_out, DWORD flags, zr_win32_hpc_t* out_hpc); +typedef HRESULT(WINAPI* zr_win32_create_pseudoconsole_fn)(COORD size, HANDLE h_in, HANDLE h_out, DWORD flags, + zr_win32_hpc_t* out_hpc); typedef void(WINAPI* zr_win32_close_pseudoconsole_fn)(zr_win32_hpc_t hpc); /* --- ConPTY runner limits --- */ @@ -45,8 +46,7 @@ static void zr_win32_strcpy_reason(char* dst, size_t cap, const char* s) { } static bool zr_win32_conpty_load(zr_win32_create_pseudoconsole_fn* out_create, - zr_win32_close_pseudoconsole_fn* out_close, - char* out_skip_reason, + zr_win32_close_pseudoconsole_fn* out_close, char* out_skip_reason, size_t out_skip_reason_cap) { if (!out_create || !out_close) { return false; @@ -60,10 +60,13 @@ static bool zr_win32_conpty_load(zr_win32_create_pseudoconsole_fn* out_create, return false; } - zr_win32_create_pseudoconsole_fn create_fn = (zr_win32_create_pseudoconsole_fn)(void*)GetProcAddress(k32, "CreatePseudoConsole"); - zr_win32_close_pseudoconsole_fn close_fn = (zr_win32_close_pseudoconsole_fn)(void*)GetProcAddress(k32, "ClosePseudoConsole"); + zr_win32_create_pseudoconsole_fn create_fn = + (zr_win32_create_pseudoconsole_fn)(void*)GetProcAddress(k32, "CreatePseudoConsole"); + zr_win32_close_pseudoconsole_fn close_fn = + (zr_win32_close_pseudoconsole_fn)(void*)GetProcAddress(k32, "ClosePseudoConsole"); if (!create_fn || !close_fn) { - zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, "ConPTY APIs not available (CreatePseudoConsole/ClosePseudoConsole)"); + zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, + "ConPTY APIs not available (CreatePseudoConsole/ClosePseudoConsole)"); return false; } @@ -170,12 +173,8 @@ static char* zr_win32_build_cmdline(const char* exe_path, const char* child_args return cmd; } -zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, - uint8_t* out_bytes, - size_t out_cap, - size_t* out_len, - uint32_t* out_exit_code, - char* out_skip_reason, +zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, uint8_t* out_bytes, size_t out_cap, + size_t* out_len, uint32_t* out_exit_code, char* out_skip_reason, size_t out_skip_reason_cap) { if (!out_len || !out_exit_code || !out_skip_reason || out_skip_reason_cap == 0u) { return ZR_ERR_INVALID_ARGUMENT; @@ -224,7 +223,8 @@ zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, size.Y = 25; hr = create_pc(size, conpty_in_r, conpty_out_w, 0u, &hpc); if (FAILED(hr) || !hpc) { - zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, "CreatePseudoConsole failed (ConPTY unavailable or blocked)"); + zr_win32_strcpy_reason(out_skip_reason, out_skip_reason_cap, + "CreatePseudoConsole failed (ConPTY unavailable or blocked)"); r = ZR_ERR_UNSUPPORTED; goto cleanup; } @@ -244,13 +244,8 @@ zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, r = ZR_ERR_PLATFORM; goto cleanup; } - if (!UpdateProcThreadAttribute(si.lpAttributeList, - 0u, - (DWORD_PTR)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, - hpc, - sizeof(hpc), - NULL, - NULL)) { + if (!UpdateProcThreadAttribute(si.lpAttributeList, 0u, (DWORD_PTR)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hpc, + sizeof(hpc), NULL, NULL)) { r = ZR_ERR_PLATFORM; goto cleanup; } @@ -272,16 +267,7 @@ zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, goto cleanup; } - ok = CreateProcessA(NULL, - cmdline, - NULL, - NULL, - FALSE, - EXTENDED_STARTUPINFO_PRESENT, - NULL, - NULL, - &si.StartupInfo, - &pi); + ok = CreateProcessA(NULL, cmdline, NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT, NULL, NULL, &si.StartupInfo, &pi); HeapFree(GetProcessHeap(), 0u, cmdline); cmdline = NULL; if (!ok) { diff --git a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h index af378b76..eb566434 100644 --- a/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h +++ b/packages/native/vendor/zireael/src/platform/win32/zr_win32_conpty_test.h @@ -20,12 +20,8 @@ - On unsupported environments, returns ZR_ERR_UNSUPPORTED and writes a stable skip reason string. */ -zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, - uint8_t* out_bytes, - size_t out_cap, - size_t* out_len, - uint32_t* out_exit_code, - char* out_skip_reason, +zr_result_t zr_win32_conpty_run_self_capture(const char* child_args, uint8_t* out_bytes, size_t out_cap, + size_t* out_len, uint32_t* out_exit_code, char* out_skip_reason, size_t out_skip_reason_cap); #endif /* ZR_PLATFORM_WIN32_ZR_WIN32_CONPTY_TEST_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/unicode/zr_grapheme.h b/packages/native/vendor/zireael/src/unicode/zr_grapheme.h index a60e1e44..67f27401 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_grapheme.h +++ b/packages/native/vendor/zireael/src/unicode/zr_grapheme.h @@ -21,8 +21,8 @@ typedef struct zr_grapheme_t { typedef struct zr_grapheme_iter_t { const uint8_t* bytes; - size_t len; - size_t off; + size_t len; + size_t off; } zr_grapheme_iter_t; /* @@ -41,4 +41,3 @@ void zr_grapheme_iter_init(zr_grapheme_iter_t* it, const uint8_t* bytes, size_t bool zr_grapheme_next(zr_grapheme_iter_t* it, zr_grapheme_t* out); #endif /* ZR_UNICODE_ZR_GRAPHEME_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h b/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h index 7b85af7b..ef74eaf9 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h +++ b/packages/native/vendor/zireael/src/unicode/zr_unicode_data.h @@ -30,8 +30,8 @@ typedef enum zr_gcb_class_t { } zr_gcb_class_t; zr_gcb_class_t zr_unicode_gcb_class(uint32_t scalar); -bool zr_unicode_is_extended_pictographic(uint32_t scalar); -bool zr_unicode_is_emoji_presentation(uint32_t scalar); -bool zr_unicode_is_eaw_wide(uint32_t scalar); +bool zr_unicode_is_extended_pictographic(uint32_t scalar); +bool zr_unicode_is_emoji_presentation(uint32_t scalar); +bool zr_unicode_is_eaw_wide(uint32_t scalar); #endif /* ZR_UNICODE_ZR_UNICODE_DATA_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h b/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h index 98e7ddc7..45bb13c0 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h +++ b/packages/native/vendor/zireael/src/unicode/zr_unicode_pins.h @@ -37,4 +37,3 @@ static inline zr_unicode_version_t zr_unicode_version(void) { } #endif /* ZR_UNICODE_ZR_UNICODE_PINS_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_utf8.h b/packages/native/vendor/zireael/src/unicode/zr_utf8.h index 94531ad4..f169e1cc 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_utf8.h +++ b/packages/native/vendor/zireael/src/unicode/zr_utf8.h @@ -41,4 +41,3 @@ typedef struct zr_utf8_decode_result_t { zr_utf8_decode_result_t zr_utf8_decode_one(const uint8_t* s, size_t len); #endif /* ZR_UNICODE_ZR_UTF8_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_width.h b/packages/native/vendor/zireael/src/unicode/zr_width.h index c9eb8a67..432c9a33 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_width.h +++ b/packages/native/vendor/zireael/src/unicode/zr_width.h @@ -13,10 +13,7 @@ #include #include -typedef enum zr_width_policy_t { - ZR_WIDTH_EMOJI_NARROW = 0, - ZR_WIDTH_EMOJI_WIDE = 1 -} zr_width_policy_t; +typedef enum zr_width_policy_t { ZR_WIDTH_EMOJI_NARROW = 0, ZR_WIDTH_EMOJI_WIDE = 1 } zr_width_policy_t; static inline zr_width_policy_t zr_width_policy_default(void) { return (zr_width_policy_t)ZR_WIDTH_POLICY_DEFAULT; @@ -38,4 +35,3 @@ uint8_t zr_width_codepoint(uint32_t scalar); uint8_t zr_width_grapheme_utf8(const uint8_t* bytes, size_t len, zr_width_policy_t policy); #endif /* ZR_UNICODE_ZR_WIDTH_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/unicode/zr_wrap.h b/packages/native/vendor/zireael/src/unicode/zr_wrap.h index 1fcf0d49..f41799a7 100644 --- a/packages/native/vendor/zireael/src/unicode/zr_wrap.h +++ b/packages/native/vendor/zireael/src/unicode/zr_wrap.h @@ -41,8 +41,7 @@ zr_result_t zr_measure_utf8(const uint8_t* bytes, size_t len, zr_width_policy_t and returns ZR_OK */ zr_result_t zr_wrap_greedy_utf8(const uint8_t* bytes, size_t len, uint32_t max_cols, zr_width_policy_t policy, - uint32_t tab_stop, size_t* out_offsets, size_t out_offsets_cap, - size_t* out_count, bool* out_truncated); + uint32_t tab_stop, size_t* out_offsets, size_t out_offsets_cap, size_t* out_count, + bool* out_truncated); #endif /* ZR_UNICODE_ZR_WRAP_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_arena.h b/packages/native/vendor/zireael/src/util/zr_arena.h index bb97db64..496f43ab 100644 --- a/packages/native/vendor/zireael/src/util/zr_arena.h +++ b/packages/native/vendor/zireael/src/util/zr_arena.h @@ -33,14 +33,13 @@ typedef struct zr_arena_mark_t { - max_total_bytes == 0 is treated as 1 byte. */ zr_result_t zr_arena_init(zr_arena_t* a, size_t initial_bytes, size_t max_total_bytes); -void zr_arena_reset(zr_arena_t* a); -void zr_arena_release(zr_arena_t* a); +void zr_arena_reset(zr_arena_t* a); +void zr_arena_release(zr_arena_t* a); void* zr_arena_alloc(zr_arena_t* a, size_t size, size_t align); void* zr_arena_alloc_zeroed(zr_arena_t* a, size_t size, size_t align); zr_arena_mark_t zr_arena_mark(const zr_arena_t* a); -void zr_arena_rewind(zr_arena_t* a, zr_arena_mark_t mark); +void zr_arena_rewind(zr_arena_t* a, zr_arena_mark_t mark); #endif /* ZR_UTIL_ZR_ARENA_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_ring.h b/packages/native/vendor/zireael/src/util/zr_ring.h index 92ac5465..f58b33a2 100644 --- a/packages/native/vendor/zireael/src/util/zr_ring.h +++ b/packages/native/vendor/zireael/src/util/zr_ring.h @@ -24,14 +24,14 @@ typedef struct zr_ring_t { } zr_ring_t; zr_result_t zr_ring_init(zr_ring_t* r, void* backing_buf, size_t cap_elems, size_t elem_size); -void zr_ring_reset(zr_ring_t* r); +void zr_ring_reset(zr_ring_t* r); -size_t zr_ring_len(const zr_ring_t* r); -size_t zr_ring_cap(const zr_ring_t* r); -bool zr_ring_is_empty(const zr_ring_t* r); -bool zr_ring_is_full(const zr_ring_t* r); +size_t zr_ring_len(const zr_ring_t* r); +size_t zr_ring_cap(const zr_ring_t* r); +bool zr_ring_is_empty(const zr_ring_t* r); +bool zr_ring_is_full(const zr_ring_t* r); zr_result_t zr_ring_push(zr_ring_t* r, const void* elem); -bool zr_ring_pop(zr_ring_t* r, void* out_elem); +bool zr_ring_pop(zr_ring_t* r, void* out_elem); #endif /* ZR_UTIL_ZR_RING_H_INCLUDED */ diff --git a/packages/native/vendor/zireael/src/util/zr_string_builder.h b/packages/native/vendor/zireael/src/util/zr_string_builder.h index 8223b665..a706f47e 100644 --- a/packages/native/vendor/zireael/src/util/zr_string_builder.h +++ b/packages/native/vendor/zireael/src/util/zr_string_builder.h @@ -19,10 +19,10 @@ typedef struct zr_sb_t { bool truncated; } zr_sb_t; -void zr_sb_init(zr_sb_t* sb, uint8_t* buf, size_t cap); -void zr_sb_reset(zr_sb_t* sb); +void zr_sb_init(zr_sb_t* sb, uint8_t* buf, size_t cap); +void zr_sb_reset(zr_sb_t* sb); size_t zr_sb_len(const zr_sb_t* sb); -bool zr_sb_truncated(const zr_sb_t* sb); +bool zr_sb_truncated(const zr_sb_t* sb); bool zr_sb_write_bytes(zr_sb_t* sb, const void* bytes, size_t len); bool zr_sb_write_u8(zr_sb_t* sb, uint8_t v); @@ -31,4 +31,3 @@ bool zr_sb_write_u32le(zr_sb_t* sb, uint32_t v); bool zr_sb_write_u64le(zr_sb_t* sb, uint64_t v); #endif /* ZR_UTIL_ZR_STRING_BUILDER_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_string_view.h b/packages/native/vendor/zireael/src/util/zr_string_view.h index 66b8b73b..12f5b6fe 100644 --- a/packages/native/vendor/zireael/src/util/zr_string_view.h +++ b/packages/native/vendor/zireael/src/util/zr_string_view.h @@ -42,4 +42,3 @@ static inline bool zr_sv_eq(zr_string_view_t a, zr_string_view_t b) { } #endif /* ZR_UTIL_ZR_STRING_VIEW_H_INCLUDED */ - diff --git a/packages/native/vendor/zireael/src/util/zr_vec.h b/packages/native/vendor/zireael/src/util/zr_vec.h index 734a7946..41482206 100644 --- a/packages/native/vendor/zireael/src/util/zr_vec.h +++ b/packages/native/vendor/zireael/src/util/zr_vec.h @@ -21,12 +21,12 @@ typedef struct zr_vec_t { } zr_vec_t; zr_result_t zr_vec_init(zr_vec_t* v, void* backing_buf, size_t cap_elems, size_t elem_size); -void zr_vec_reset(zr_vec_t* v); +void zr_vec_reset(zr_vec_t* v); -size_t zr_vec_len(const zr_vec_t* v); -size_t zr_vec_cap(const zr_vec_t* v); +size_t zr_vec_len(const zr_vec_t* v); +size_t zr_vec_cap(const zr_vec_t* v); -void* zr_vec_at(zr_vec_t* v, size_t idx); +void* zr_vec_at(zr_vec_t* v, size_t idx); const void* zr_vec_at_const(const zr_vec_t* v, size_t idx); zr_result_t zr_vec_push(zr_vec_t* v, const void* elem); diff --git a/packages/node/src/backend/nodeBackend.ts b/packages/node/src/backend/nodeBackend.ts index afe8a9ea..eeb52eff 100644 --- a/packages/node/src/backend/nodeBackend.ts +++ b/packages/node/src/backend/nodeBackend.ts @@ -21,6 +21,7 @@ import type { TerminalProfile, } from "@rezi-ui/core"; import { + BACKEND_BEGIN_FRAME_MARKER, BACKEND_DRAWLIST_VERSION_MARKER, BACKEND_FPS_CAP_MARKER, BACKEND_MAX_EVENT_BYTES_MARKER, @@ -38,6 +39,12 @@ import { setTextMeasureEmojiPolicy, severityToNum, } from "@rezi-ui/core"; +import type { BackendBeginFrame } from "@rezi-ui/core/backend"; +import { + createFrameAuditLogger, + drawlistFingerprint, + maybeDumpDrawlistBytes, +} from "../frameAudit.js"; import { type EngineCreateConfig, FRAME_SAB_CONTROL_CONSUMED_SEQ_WORD, @@ -154,6 +161,23 @@ type SabFrameTransport = Readonly<{ nextSlot: { value: number }; }>; +type FrameAuditEntry = { + frameSeq: number; + submitAtMs: number; + submitPath: "requestFrame" | "beginFrame"; + transport: typeof FRAME_TRANSPORT_TRANSFER_V1 | typeof FRAME_TRANSPORT_SAB_V1; + byteLen: number; + hash32: string; + prefixHash32: string; + cmdCount: number | null; + totalSize: number | null; + head16: string; + tail16: string; + slotIndex?: number; + slotToken?: number; + acceptedLogged?: boolean; +}; + const WIDTH_POLICY_KEY = "widthPolicy" as const; const DEFAULT_NATIVE_LIMITS: Readonly> = Object.freeze({ @@ -379,6 +403,24 @@ function acquireSabSlot(t: SabFrameTransport): number { return -1; } +function acquireSabFreeSlot(t: SabFrameTransport): number { + const start = t.nextSlot.value % t.slotCount; + for (let i = 0; i < t.slotCount; i++) { + const slot = (start + i) % t.slotCount; + const prev = Atomics.compareExchange( + t.states, + slot, + FRAME_SAB_SLOT_STATE_FREE, + FRAME_SAB_SLOT_STATE_WRITING, + ); + if (prev === FRAME_SAB_SLOT_STATE_FREE) { + t.nextSlot.value = (slot + 1) % t.slotCount; + return slot; + } + } + return -1; +} + function publishSabFrame( t: SabFrameTransport, frameSeq: number, @@ -393,6 +435,7 @@ function publishSabFrame( } export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): NodeBackend { + const frameAudit = createFrameAuditLogger("backend"); const cfg = opts.config ?? {}; const fpsCap = parseBoundedPositiveIntOrThrow( "fpsCap", @@ -481,6 +524,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N let nextFrameSeq = 1; const frameAcceptedWaiters = new Map>(); const frameCompletionWaiters = new Map>(); + const frameAuditBySeq = new Map(); const eventQueue: Array< Readonly<{ batch: ArrayBuffer; byteLen: number; droppedSinceLast: number }> @@ -541,10 +585,89 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N waiter.reject(err); } frameCompletionWaiters.clear(); + if (frameAudit.enabled) { + for (const [seq, meta] of frameAuditBySeq.entries()) { + frameAudit.emit("frame.aborted", { + reason: err.message, + ageMs: Math.max(0, Date.now() - meta.submitAtMs), + ...meta, + }); + } + frameAuditBySeq.clear(); + } + } + + function registerFrameAudit( + frameSeq: number, + submitPath: "requestFrame" | "beginFrame", + transport: typeof FRAME_TRANSPORT_TRANSFER_V1 | typeof FRAME_TRANSPORT_SAB_V1, + bytes: Uint8Array, + slotIndex?: number, + slotToken?: number, + ): void { + if (!frameAudit.enabled) return; + const fp = drawlistFingerprint(bytes); + const meta: FrameAuditEntry = { + frameSeq, + submitAtMs: Date.now(), + submitPath, + transport, + byteLen: fp.byteLen, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + head16: fp.head16, + tail16: fp.tail16, + ...(slotIndex === undefined ? {} : { slotIndex }), + ...(slotToken === undefined ? {} : { slotToken }), + }; + frameAuditBySeq.set(frameSeq, meta); + maybeDumpDrawlistBytes("backend", submitPath, frameSeq, bytes); + frameAudit.emit("frame.submitted", meta); + } + + function markAcceptedFramesUpTo(acceptedSeq: number): void { + if (!frameAudit.enabled) return; + for (const [seq, meta] of frameAuditBySeq.entries()) { + if (seq > acceptedSeq) continue; + if (meta.acceptedLogged === true) continue; + frameAudit.emit("frame.accepted", { + acceptedSeq, + ageMs: Math.max(0, Date.now() - meta.submitAtMs), + ...meta, + }); + meta.acceptedLogged = true; + } + } + + function markCoalescedFramesBefore(acceptedSeq: number): void { + if (!frameAudit.enabled) return; + for (const [seq, meta] of frameAuditBySeq.entries()) { + if (seq >= acceptedSeq) continue; + frameAudit.emit("frame.coalesced", { + acceptedSeq, + ageMs: Math.max(0, Date.now() - meta.submitAtMs), + ...meta, + }); + frameAuditBySeq.delete(seq); + } + } + + function markCompletedFrame(frameSeq: number, completedResult: number): void { + if (!frameAudit.enabled) return; + const meta = frameAuditBySeq.get(frameSeq); + frameAudit.emit("frame.completed", { + completedResult, + ageMs: meta ? Math.max(0, Date.now() - meta.submitAtMs) : null, + ...(meta ?? {}), + }); + frameAuditBySeq.delete(frameSeq); } function resolveAcceptedFramesUpTo(acceptedSeq: number): void { if (!Number.isInteger(acceptedSeq) || acceptedSeq <= 0) return; + markAcceptedFramesUpTo(acceptedSeq); for (const [seq, waiter] of frameAcceptedWaiters.entries()) { if (seq > acceptedSeq) continue; frameAcceptedWaiters.delete(seq); @@ -554,6 +677,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N function resolveCoalescedCompletionFramesUpTo(acceptedSeq: number): void { if (!Number.isInteger(acceptedSeq) || acceptedSeq <= 0) return; + markCoalescedFramesBefore(acceptedSeq); for (const [seq, waiter] of frameCompletionWaiters.entries()) { if (seq >= acceptedSeq) continue; frameCompletionWaiters.delete(seq); @@ -562,6 +686,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N } function settleCompletedFrame(frameSeq: number, completedResult: number): void { + markCompletedFrame(frameSeq, completedResult); const waiter = frameCompletionWaiters.get(frameSeq); if (waiter === undefined) return; frameCompletionWaiters.delete(frameSeq); @@ -577,6 +702,30 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N waiter.resolve(undefined); } + function reserveFramePromise( + frameSeq: number, + ): Promise & Partial>> { + const frameAcceptedDef = deferred(); + frameAcceptedWaiters.set(frameSeq, frameAcceptedDef); + const frameCompletionDef = deferred(); + frameCompletionWaiters.set(frameSeq, frameCompletionDef); + const framePromise = frameCompletionDef.promise as Promise & + Partial>>; + Object.defineProperty(framePromise, FRAME_ACCEPTED_ACK_MARKER, { + value: frameAcceptedDef.promise, + configurable: false, + enumerable: false, + writable: false, + }); + return framePromise; + } + + function releaseFrameReservation(frameSeq: number): void { + frameAcceptedWaiters.delete(frameSeq); + frameCompletionWaiters.delete(frameSeq); + if (frameAudit.enabled) frameAuditBySeq.delete(frameSeq); + } + function failAll(err: Error): void { while (eventWaiters.length > 0) eventWaiters.shift()?.reject(err); while (capsWaiters.length > 0) capsWaiters.shift()?.reject(err); @@ -635,6 +784,13 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N } case "frameStatus": { + if (frameAudit.enabled) { + frameAudit.emit("worker.frameStatus", { + acceptedSeq: msg.acceptedSeq, + completedSeq: msg.completedSeq ?? null, + completedResult: msg.completedResult ?? null, + }); + } if (!Number.isInteger(msg.acceptedSeq) || msg.acceptedSeq <= 0) { fatal = new ZrUiError( "ZRUI_BACKEND_ERROR", @@ -969,6 +1125,13 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N ? undefined : { nativeShimModule: opts.nativeShimModule }; worker = new Worker(entry, { workerData }); + if (frameAudit.enabled) { + frameAudit.emit("worker.spawn", { + frameTransport: frameTransportWire.kind, + frameSabSlotCount: frameSabSlotCount, + frameSabSlotBytes: frameSabSlotBytes, + }); + } exitDef = deferred(); worker.on("message", handleWorkerMessage); worker.on("error", (err) => { @@ -1041,33 +1204,45 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N if (worker === null) return Promise.reject(new Error("NodeBackend: worker not available")); const frameSeq = nextFrameSeq++; - const frameAcceptedDef = deferred(); - frameAcceptedWaiters.set(frameSeq, frameAcceptedDef); - const frameCompletionDef = deferred(); - frameCompletionWaiters.set(frameSeq, frameCompletionDef); - const framePromise = frameCompletionDef.promise as Promise & - Partial>>; - Object.defineProperty(framePromise, FRAME_ACCEPTED_ACK_MARKER, { - value: frameAcceptedDef.promise, - configurable: false, - enumerable: false, - writable: false, - }); + const framePromise = reserveFramePromise(frameSeq); if (sabFrameTransport !== null && drawlist.byteLength <= sabFrameTransport.slotBytes) { const slotIndex = acquireSabSlot(sabFrameTransport); if (slotIndex >= 0) { const slotToken = frameSeqToSlotToken(frameSeq); + registerFrameAudit( + frameSeq, + "requestFrame", + FRAME_TRANSPORT_SAB_V1, + drawlist, + slotIndex, + slotToken, + ); const slotOffset = slotIndex * sabFrameTransport.slotBytes; sabFrameTransport.dataBytes.set(drawlist, slotOffset); Atomics.store(sabFrameTransport.tokens, slotIndex, slotToken); Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_READY); publishSabFrame(sabFrameTransport, frameSeq, slotIndex, slotToken, drawlist.byteLength); + if (frameAudit.enabled) { + frameAudit.emit("frame.sab.publish", { + frameSeq, + slotIndex, + slotToken, + byteLen: drawlist.byteLength, + }); + } // SAB consumers wake on futex notify instead of per-frame // MessagePort frameKick round-trips. Atomics.notify(sabFrameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, 1); return framePromise; } + if (frameAudit.enabled) { + frameAudit.emit("frame.sab.fallback_transfer", { + frameSeq, + byteLen: drawlist.byteLength, + reason: "no-slot-available", + }); + } } // Transfer fallback participates in the same ACK model: @@ -1075,6 +1250,7 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N // - completion promise settles on worker completion/coalescing status const buf = new ArrayBuffer(drawlist.byteLength); copyInto(buf, drawlist); + registerFrameAudit(frameSeq, "requestFrame", FRAME_TRANSPORT_TRANSFER_V1, drawlist); try { send( { @@ -1087,10 +1263,21 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N [buf], ); } catch (err) { - frameAcceptedWaiters.delete(frameSeq); - frameCompletionWaiters.delete(frameSeq); + releaseFrameReservation(frameSeq); + if (frameAudit.enabled) { + frameAudit.emit("frame.transfer.publish_error", { + frameSeq, + detail: safeErr(err).message, + }); + } return Promise.reject(safeErr(err)); } + if (frameAudit.enabled) { + frameAudit.emit("frame.transfer.publish", { + frameSeq, + byteLen: drawlist.byteLength, + }); + } return framePromise; }, @@ -1344,13 +1531,116 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N }), }; + const beginFrame: BackendBeginFrame | null = + sabFrameTransport === null + ? null + : (minCapacity?: number) => { + if (disposed) return null; + if (fatal !== null) return null; + if (stopRequested || !started || worker === null) return null; + + const required = + typeof minCapacity === "number" && Number.isInteger(minCapacity) && minCapacity > 0 + ? minCapacity + : 0; + if (required > sabFrameTransport.slotBytes) return null; + + const slotIndex = acquireSabFreeSlot(sabFrameTransport); + if (slotIndex < 0) return null; + const slotOffset = slotIndex * sabFrameTransport.slotBytes; + const buf = sabFrameTransport.dataBytes.subarray( + slotOffset, + slotOffset + sabFrameTransport.slotBytes, + ); + let finalized = false; + + return { + buf, + commit: (byteLen: number) => { + if (finalized) { + return Promise.reject( + new Error("NodeBackend: beginFrame writer already finalized"), + ); + } + finalized = true; + if (disposed) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(new Error("NodeBackend: disposed")); + } + if (fatal !== null) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(fatal); + } + if (stopRequested || !started || worker === null) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject(new Error("NodeBackend: stopped")); + } + if ( + !Number.isInteger(byteLen) || + byteLen < 0 || + byteLen > sabFrameTransport.slotBytes + ) { + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + return Promise.reject( + new Error("NodeBackend: beginFrame commit byteLen out of range"), + ); + } + + const frameSeq = nextFrameSeq++; + const framePromise = reserveFramePromise(frameSeq); + const slotToken = frameSeqToSlotToken(frameSeq); + registerFrameAudit( + frameSeq, + "beginFrame", + FRAME_TRANSPORT_SAB_V1, + buf.subarray(0, byteLen), + slotIndex, + slotToken, + ); + Atomics.store(sabFrameTransport.tokens, slotIndex, slotToken); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_READY); + publishSabFrame(sabFrameTransport, frameSeq, slotIndex, slotToken, byteLen); + if (frameAudit.enabled) { + frameAudit.emit("frame.beginFrame.publish", { + frameSeq, + slotIndex, + slotToken, + byteLen, + }); + } + Atomics.notify( + sabFrameTransport.controlHeader, + FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, + 1, + ); + return framePromise; + }, + abort: () => { + if (finalized) return; + finalized = true; + if (frameAudit.enabled) { + frameAudit.emit("frame.beginFrame.abort", { + slotIndex, + }); + } + Atomics.store(sabFrameTransport.tokens, slotIndex, 0); + Atomics.store(sabFrameTransport.states, slotIndex, FRAME_SAB_SLOT_STATE_FREE); + }, + }; + }; + const out = Object.assign(backend, { debug, perf }) as NodeBackend & Record< + | typeof BACKEND_BEGIN_FRAME_MARKER | typeof BACKEND_DRAWLIST_VERSION_MARKER | typeof BACKEND_MAX_EVENT_BYTES_MARKER | typeof BACKEND_FPS_CAP_MARKER | typeof BACKEND_RAW_WRITE_MARKER, - boolean | number | BackendRawWrite + boolean | number | BackendRawWrite | BackendBeginFrame >; Object.defineProperties(out, { [BACKEND_DRAWLIST_VERSION_MARKER]: { @@ -1385,5 +1675,13 @@ export function createNodeBackendInternal(opts: NodeBackendInternalOpts = {}): N configurable: false, }, }); + if (beginFrame !== null) { + Object.defineProperty(out, BACKEND_BEGIN_FRAME_MARKER, { + value: beginFrame, + writable: false, + enumerable: false, + configurable: false, + }); + } return out; } diff --git a/packages/node/src/backend/nodeBackendInline.ts b/packages/node/src/backend/nodeBackendInline.ts index 0cac702c..e7bd48c1 100644 --- a/packages/node/src/backend/nodeBackendInline.ts +++ b/packages/node/src/backend/nodeBackendInline.ts @@ -36,6 +36,11 @@ import { setTextMeasureEmojiPolicy, severityToNum, } from "@rezi-ui/core"; +import { + createFrameAuditLogger, + drawlistFingerprint, + maybeDumpDrawlistBytes, +} from "../frameAudit.js"; import { applyEmojiWidthPolicy, resolveBackendEmojiWidthPolicy } from "./emojiWidthPolicy.js"; import type { NodeBackend, @@ -358,6 +363,7 @@ async function loadNative(shimModule: string | undefined): Promise { } export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = {}): NodeBackend { + const frameAudit = createFrameAuditLogger("backend-inline"); const cfg = opts.config ?? {}; const requestedDrawlistVersion = ZR_DRAWLIST_VERSION_V1; const fpsCap = parseBoundedPositiveIntOrThrow( @@ -420,6 +426,7 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = const perfSamples: PerfSample[] = []; let cachedCaps: TerminalCaps | null = null; + let nextFrameSeq = 1; function perfRecord(phase: string, durationMs: number): void { if (!PERF_ENABLED) return; @@ -486,6 +493,9 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } function failWith(where: string, code: number, detail: string): void { + if (frameAudit.enabled) { + frameAudit.emit("fatal", { where, code, detail }); + } const err = new ZrUiError("ZRUI_BACKEND_ERROR", `${where} (${String(code)}): ${detail}`); fatal = err; rejectWaiters(err); @@ -704,6 +714,14 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } engineId = id; started = true; + if (frameAudit.enabled) { + frameAudit.emit("engine.ready", { + engineId: id, + executionMode: "inline", + fpsCap, + maxEventBytes, + }); + } cachedCaps = null; eventQueue = []; eventPool = []; @@ -804,8 +822,36 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = } try { + const frameSeq = nextFrameSeq++; + const fp = frameAudit.enabled ? drawlistFingerprint(drawlist) : null; + maybeDumpDrawlistBytes("backend-inline", "requestFrame", frameSeq, drawlist); + if (fp !== null) { + frameAudit.emit("frame.submitted", { + frameSeq, + submitPath: "requestFrame", + transport: "inline-v1", + ...fp, + }); + frameAudit.emit("frame.submit.payload", { + frameSeq, + transport: "inline-v1", + ...fp, + }); + } const submitRc = native.engineSubmitDrawlist(engineId, drawlist); + if (frameAudit.enabled) { + frameAudit.emit("frame.submit.result", { + frameSeq, + submitResult: submitRc, + }); + } if (submitRc < 0) { + if (frameAudit.enabled) { + frameAudit.emit("frame.completed", { + frameSeq, + completedResult: submitRc, + }); + } return Promise.reject( new ZrUiError( "ZRUI_BACKEND_ERROR", @@ -813,13 +859,37 @@ export function createNodeBackendInlineInternal(opts: NodeBackendInternalOpts = ), ); } + if (frameAudit.enabled) { + frameAudit.emit("frame.accepted", { frameSeq }); + } const presentRc = native.enginePresent(engineId); + if (frameAudit.enabled) { + frameAudit.emit("frame.present.result", { + frameSeq, + presentResult: presentRc, + }); + } if (presentRc < 0) { + if (frameAudit.enabled) { + frameAudit.emit("frame.completed", { + frameSeq, + completedResult: presentRc, + }); + } return Promise.reject( new ZrUiError("ZRUI_BACKEND_ERROR", `engine_present failed: code=${String(presentRc)}`), ); } + if (frameAudit.enabled) { + frameAudit.emit("frame.completed", { + frameSeq, + completedResult: 0, + }); + } } catch (err) { + if (frameAudit.enabled) { + frameAudit.emit("frame.throw", { detail: safeDetail(err) }); + } return Promise.reject(safeErr(err)); } return RESOLVED_SYNC_FRAME_ACK; diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts new file mode 100644 index 00000000..63953f08 --- /dev/null +++ b/packages/node/src/frameAudit.ts @@ -0,0 +1,301 @@ +/** + * packages/node/src/frameAudit.ts — Optional end-to-end frame audit utilities. + * + * Enable with: + * REZI_FRAME_AUDIT=1 + * + * Optional: + * REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson + * REZI_FRAME_AUDIT_NATIVE=1 + * REZI_FRAME_AUDIT_NATIVE_RING= + * + * Defaults: + * - When REZI_FRAME_AUDIT=1 and REZI_FRAME_AUDIT_LOG is unset, records are + * written to /tmp/rezi-frame-audit.ndjson to avoid polluting terminal output. + */ + +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; + +export type DrawlistFingerprint = Readonly<{ + byteLen: number; + hash32: string; + prefixHash32: string; + cmdCount: number | null; + totalSize: number | null; + head16: string; + tail16: string; + opcodeHistogram: Readonly>; + cmdStreamValid: boolean; +}>; + +type AuditRecord = Readonly>; + +function readEnv(name: string): string | null { + const raw = process.env[name]; + if (typeof raw !== "string") return null; + const value = raw.trim(); + return value.length > 0 ? value : null; +} + +function envFlag(name: string, fallback = false): boolean { + const value = readEnv(name); + if (value === null) return fallback; + const norm = value.toLowerCase(); + return norm === "1" || norm === "true" || norm === "yes" || norm === "on"; +} + +function envPositiveInt(name: string, fallback: number): number { + const value = readEnv(name); + if (value === null) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function toHex32(v: number): string { + return `0x${(v >>> 0).toString(16).padStart(8, "0")}`; +} + +function hashFnv1a32(bytes: Uint8Array, end: number): number { + const n = Math.max(0, Math.min(end, bytes.byteLength)); + let h = 0x811c9dc5; + for (let i = 0; i < n; i++) { + h ^= bytes[i] ?? 0; + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function sliceHex(bytes: Uint8Array, start: number, end: number): string { + const s = Math.max(0, Math.min(start, bytes.byteLength)); + const e = Math.max(s, Math.min(end, bytes.byteLength)); + let out = ""; + for (let i = s; i < e; i++) { + out += (bytes[i] ?? 0).toString(16).padStart(2, "0"); + } + return out; +} + +function readHeaderU32(bytes: Uint8Array, offset: number): number | null { + if (offset < 0 || offset + 4 > bytes.byteLength) return null; + try { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); + } catch { + return null; + } +} + +function decodeOpcodeHistogram(bytes: Uint8Array): Readonly<{ + histogram: Readonly>; + valid: boolean; +}> { + const cmdCount = readHeaderU32(bytes, 24); + const cmdOffset = readHeaderU32(bytes, 16); + const cmdBytes = readHeaderU32(bytes, 20); + if (cmdCount === null || cmdOffset === null || cmdBytes === null) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + if (cmdCount === 0) { + return Object.freeze({ histogram: Object.freeze({}), valid: true }); + } + if (cmdOffset < 0 || cmdBytes < 0 || cmdOffset + cmdBytes > bytes.byteLength) { + return Object.freeze({ histogram: Object.freeze({}), valid: false }); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let off = cmdOffset; + const end = cmdOffset + cmdBytes; + const hist: Record = Object.create(null) as Record; + for (let i = 0; i < cmdCount; i++) { + if (off + 8 > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const opcode = dv.getUint16(off + 0, true); + const size = dv.getUint32(off + 4, true); + if (size < 8 || off + size > end) { + return Object.freeze({ histogram: Object.freeze(hist), valid: false }); + } + const key = String(opcode); + hist[key] = (hist[key] ?? 0) + 1; + off += size; + } + return Object.freeze({ histogram: Object.freeze(hist), valid: off === end }); +} + +function nowUs(): number { + return Math.round(performance.now() * 1000); +} + +const FRAME_AUDIT_ENABLED = envFlag("REZI_FRAME_AUDIT", false); +const FRAME_AUDIT_LOG_PATH = + readEnv("REZI_FRAME_AUDIT_LOG") ?? (FRAME_AUDIT_ENABLED ? "/tmp/rezi-frame-audit.ndjson" : null); +const FRAME_AUDIT_STDERR_MIRROR = envFlag("REZI_FRAME_AUDIT_STDERR_MIRROR", false); +const FRAME_AUDIT_DUMP_DIR = readEnv("REZI_FRAME_AUDIT_DUMP_DIR"); +const FRAME_AUDIT_DUMP_ROUTE = readEnv("REZI_FRAME_AUDIT_DUMP_ROUTE"); +const FRAME_AUDIT_DUMP_MAX = envPositiveInt("REZI_FRAME_AUDIT_DUMP_MAX", 0); +let frameAuditDumpCount = 0; + +export { FRAME_AUDIT_ENABLED }; +export const FRAME_AUDIT_NATIVE_ENABLED = + FRAME_AUDIT_ENABLED && envFlag("REZI_FRAME_AUDIT_NATIVE", true); +export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt( + "REZI_FRAME_AUDIT_NATIVE_RING", + 4 << 20, +); + +// Match zr_debug category/code values. +export const ZR_DEBUG_CAT_FRAME = 1; +export const ZR_DEBUG_CAT_DRAWLIST = 3; +export const ZR_DEBUG_CAT_PERF = 6; +export const ZR_DEBUG_CODE_FRAME_BEGIN = 0x0100; +export const ZR_DEBUG_CODE_FRAME_SUBMIT = 0x0101; +export const ZR_DEBUG_CODE_FRAME_PRESENT = 0x0102; +export const ZR_DEBUG_CODE_FRAME_RESIZE = 0x0103; +export const ZR_DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; +export const ZR_DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; +export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; +export const ZR_DEBUG_CODE_PERF_TIMING = 0x0600; +export const ZR_DEBUG_CODE_PERF_DIFF_PATH = 0x0601; + +function readContextRoute(): string | null { + const g = globalThis as { + __reziFrameAuditContext?: () => Readonly<{ route?: unknown }>; + }; + if (typeof g.__reziFrameAuditContext !== "function") return null; + try { + const ctx = g.__reziFrameAuditContext(); + const route = ctx.route; + return typeof route === "string" && route.length > 0 ? route : null; + } catch { + return null; + } +} + +function sanitizeFileToken(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +export function maybeDumpDrawlistBytes( + scope: string, + stage: string, + frameSeq: number, + bytes: Uint8Array, +): void { + if (!FRAME_AUDIT_ENABLED) return; + if (FRAME_AUDIT_DUMP_DIR === null) return; + if (FRAME_AUDIT_DUMP_MAX <= 0) return; + if (frameAuditDumpCount >= FRAME_AUDIT_DUMP_MAX) return; + const route = readContextRoute(); + if (FRAME_AUDIT_DUMP_ROUTE !== null && route !== FRAME_AUDIT_DUMP_ROUTE) return; + try { + mkdirSync(FRAME_AUDIT_DUMP_DIR, { recursive: true }); + const seq = Number.isInteger(frameSeq) && frameSeq > 0 ? frameSeq : 0; + const routeToken = route ? sanitizeFileToken(route) : "unknown"; + const scopeToken = sanitizeFileToken(scope); + const stageToken = sanitizeFileToken(stage); + const stamp = `${Date.now()}`.padStart(13, "0"); + const base = `${stamp}-pid${process.pid}-seq${seq}-${routeToken}-${scopeToken}-${stageToken}`; + writeFileSync(join(FRAME_AUDIT_DUMP_DIR, `${base}.bin`), bytes); + const meta = JSON.stringify( + { + ts: new Date().toISOString(), + pid: process.pid, + frameSeq: seq, + route, + scope, + stage, + byteLen: bytes.byteLength, + hash32: toHex32(hashFnv1a32(bytes, bytes.byteLength)), + }, + null, + 2, + ); + writeFileSync(join(FRAME_AUDIT_DUMP_DIR, `${base}.json`), `${meta}\n`, "utf8"); + frameAuditDumpCount += 1; + } catch { + // Optional diagnostics must never affect runtime behavior. + } +} + +export function drawlistFingerprint(bytes: Uint8Array): DrawlistFingerprint { + const byteLen = bytes.byteLength; + const prefixLen = Math.min(4096, byteLen); + const cmdCount = readHeaderU32(bytes, 24); + const totalSize = readHeaderU32(bytes, 12); + const head16 = sliceHex(bytes, 0, Math.min(16, byteLen)); + const tailStart = Math.max(0, byteLen - 16); + const tail16 = sliceHex(bytes, tailStart, byteLen); + const decoded = decodeOpcodeHistogram(bytes); + return Object.freeze({ + byteLen, + hash32: toHex32(hashFnv1a32(bytes, byteLen)), + prefixHash32: toHex32(hashFnv1a32(bytes, prefixLen)), + cmdCount, + totalSize, + head16, + tail16, + opcodeHistogram: decoded.histogram, + cmdStreamValid: decoded.valid, + }); +} + +export type FrameAuditLogger = Readonly<{ + enabled: boolean; + emit: (stage: string, fields?: AuditRecord) => void; +}>; + +export function createFrameAuditLogger(scope: string): FrameAuditLogger { + if (!FRAME_AUDIT_ENABLED) { + return Object.freeze({ + enabled: false, + emit: () => {}, + }); + } + + const writeLine = (line: string): void => { + try { + if (FRAME_AUDIT_LOG_PATH !== null) { + appendFileSync(FRAME_AUDIT_LOG_PATH, `${line}\n`, "utf8"); + if (!FRAME_AUDIT_STDERR_MIRROR) { + return; + } + } else if (!FRAME_AUDIT_STDERR_MIRROR) { + return; + } + process.stderr.write(`${line}\n`); + } catch { + // Optional diagnostics must never affect runtime behavior. + } + }; + const g = globalThis as { + __reziFrameAuditSink?: (line: string) => void; + __reziFrameAuditContext?: () => Readonly>; + }; + if (typeof g.__reziFrameAuditSink !== "function") { + g.__reziFrameAuditSink = writeLine; + } + + return Object.freeze({ + enabled: true, + emit: (stage: string, fields: AuditRecord = Object.freeze({})) => { + try { + const context = + typeof g.__reziFrameAuditContext === "function" ? g.__reziFrameAuditContext() : null; + const line = JSON.stringify({ + ts: new Date().toISOString(), + tUs: nowUs(), + pid: process.pid, + layer: "node", + scope, + stage, + ...(context ?? {}), + ...fields, + }); + writeLine(line); + } catch { + // Optional diagnostics must never affect runtime behavior. + } + }, + }); +} diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index e6d70b37..4d62620d 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -8,6 +8,24 @@ import { performance } from "node:perf_hooks"; import { parentPort, workerData } from "node:worker_threads"; +import { + FRAME_AUDIT_NATIVE_ENABLED, + FRAME_AUDIT_NATIVE_RING_BYTES, + ZR_DEBUG_CAT_FRAME, + ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_PERF, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_SUBMIT, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, + ZR_DEBUG_CODE_DRAWLIST_CMD, + ZR_DEBUG_CODE_DRAWLIST_EXECUTE, + ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + ZR_DEBUG_CODE_PERF_TIMING, + ZR_DEBUG_CODE_PERF_DIFF_PATH, + createFrameAuditLogger, + drawlistFingerprint, +} from "../frameAudit.js"; import { EVENT_POOL_SIZE, FRAME_SAB_CONTROL_CONSUMED_SEQ_WORD, @@ -307,6 +325,40 @@ const DEBUG_HEADER_BYTES = 40; const DEBUG_QUERY_MIN_HEADERS_CAP = DEBUG_HEADER_BYTES; const DEBUG_QUERY_MAX_HEADERS_CAP = 1 << 20; // 1 MiB const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); +const DEBUG_DRAWLIST_RECORD_BYTES = 48; +const DEBUG_FRAME_RECORD_BYTES = 56; +const DEBUG_PERF_RECORD_BYTES = 24; +const DEBUG_DIFF_PATH_RECORD_BYTES = 56; +const NATIVE_FRAME_AUDIT_CATEGORY_MASK = + (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); + +function nativeFrameCodeName(code: number): string { + if (code === ZR_DEBUG_CODE_FRAME_BEGIN) return "frame.begin"; + if (code === ZR_DEBUG_CODE_FRAME_SUBMIT) return "frame.submit"; + if (code === ZR_DEBUG_CODE_FRAME_PRESENT) return "frame.present"; + if (code === ZR_DEBUG_CODE_FRAME_RESIZE) return "frame.resize"; + return "frame.unknown"; +} + +function nativePerfPhaseName(phase: number): string { + if (phase === 0) return "poll"; + if (phase === 1) return "submit"; + if (phase === 2) return "present"; + return "unknown"; +} + +type FrameAuditMeta = { + frameSeq: number; + enqueuedAtMs: number; + transport: typeof FRAME_TRANSPORT_TRANSFER_V1 | typeof FRAME_TRANSPORT_SAB_V1; + byteLen: number; + slotIndex?: number; + slotToken?: number; + hash32?: string; + prefixHash32?: string; + cmdCount?: number | null; + totalSize?: number | null; +}; let engineId: number | null = null; let running = false; @@ -314,6 +366,10 @@ let haveSubmittedDrawlist = false; let pendingFrame: PendingFrame | null = null; let lastConsumedSabPublishedSeq = 0; let frameTransport: WorkerFrameTransport = Object.freeze({ kind: FRAME_TRANSPORT_TRANSFER_V1 }); +const frameAudit = createFrameAuditLogger("worker"); +const frameAuditBySeq = new Map(); +let nativeFrameAuditEnabled = false; +let nativeFrameAuditNextRecordId = 1n; let eventPool: ArrayBuffer[] = []; let discardBuffer: ArrayBuffer | null = null; @@ -327,6 +383,315 @@ let maxIdleDelayMs = 0; let sabWakeArmed = false; let sabWakeEpoch = 0; +function u64FromView(v: DataView, offset: number): bigint { + const lo = BigInt(v.getUint32(offset, true)); + const hi = BigInt(v.getUint32(offset + 4, true)); + return (hi << 32n) | lo; +} + +function setFrameAuditMeta( + frameSeq: number, + patch: Readonly>>, +): void { + if (!frameAudit.enabled) return; + const prev = frameAuditBySeq.get(frameSeq); + const next: FrameAuditMeta = { + frameSeq, + enqueuedAtMs: prev?.enqueuedAtMs ?? Date.now(), + transport: prev?.transport ?? FRAME_TRANSPORT_TRANSFER_V1, + byteLen: prev?.byteLen ?? 0, + ...(prev ?? {}), + ...patch, + }; + frameAuditBySeq.set(frameSeq, next); +} + +function emitFrameAudit( + stage: string, + frameSeq: number, + fields: Readonly> = {}, +): void { + if (!frameAudit.enabled) return; + const meta = frameAuditBySeq.get(frameSeq); + frameAudit.emit(stage, { + frameSeq, + ageMs: meta ? Math.max(0, Date.now() - meta.enqueuedAtMs) : null, + ...(meta ?? {}), + ...fields, + }); +} + +function deleteFrameAudit(frameSeq: number): void { + if (!frameAudit.enabled) return; + frameAuditBySeq.delete(frameSeq); +} + +function maybeEnableNativeFrameAudit(): void { + if (!frameAudit.enabled) return; + if (!FRAME_AUDIT_NATIVE_ENABLED) return; + if (engineId === null) return; + let rc = -1; + try { + rc = native.engineDebugEnable(engineId, { + enabled: true, + ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, + minSeverity: 0, + categoryMask: 0xffff_ffff, + captureRawEvents: false, + captureDrawlistBytes: true, + }); + } catch (err) { + frameAudit.emit("native.debug.enable_error", { detail: safeDetail(err) }); + nativeFrameAuditEnabled = false; + return; + } + nativeFrameAuditEnabled = rc >= 0; + nativeFrameAuditNextRecordId = 1n; + frameAudit.emit("native.debug.enable", { + rc, + enabled: nativeFrameAuditEnabled, + ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, + }); +} + +function drainNativeFrameAudit(reason: string): void { + if (!frameAudit.enabled || !nativeFrameAuditEnabled) return; + if (engineId === null) return; + const headersCap = DEBUG_HEADER_BYTES * 64; + const headersBuf = new Uint8Array(headersCap); + for (let iter = 0; iter < 8; iter++) { + let result: DebugQueryResultNative; + try { + result = native.engineDebugQuery( + engineId, + { + minRecordId: nativeFrameAuditNextRecordId, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, + minSeverity: 0, + maxRecords: Math.floor(headersCap / DEBUG_HEADER_BYTES), + }, + headersBuf, + ); + } catch (err) { + frameAudit.emit("native.debug.query_error", { reason, detail: safeDetail(err) }); + return; + } + const recordsReturned = + Number.isInteger(result.recordsReturned) && result.recordsReturned > 0 + ? Math.min(result.recordsReturned, Math.floor(headersCap / DEBUG_HEADER_BYTES)) + : 0; + if (recordsReturned <= 0) return; + + let advanced = false; + for (let i = 0; i < recordsReturned; i++) { + const off = i * DEBUG_HEADER_BYTES; + const dv = new DataView(headersBuf.buffer, headersBuf.byteOffset + off, DEBUG_HEADER_BYTES); + const recordId = u64FromView(dv, 0); + const timestampUs = u64FromView(dv, 8); + const frameId = u64FromView(dv, 16); + const category = dv.getUint32(24, true); + const severity = dv.getUint32(28, true); + const code = dv.getUint32(32, true); + const payloadSize = dv.getUint32(36, true); + if (recordId < nativeFrameAuditNextRecordId) { + continue; + } + if (recordId === 0n && frameId === 0n && category === 0 && code === 0 && payloadSize === 0) { + continue; + } + advanced = true; + nativeFrameAuditNextRecordId = recordId + 1n; + + frameAudit.emit("native.debug.header", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + timestampUs: timestampUs.toString(), + category, + severity, + code, + payloadSize, + }); + + if (code === ZR_DEBUG_CODE_DRAWLIST_CMD && payloadSize > 0) { + const cap = Math.min(Math.max(payloadSize, 1), 4096); + const payload = new Uint8Array(cap); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote > 0) { + const view = payload.subarray(0, Math.min(wrote, payload.byteLength)); + const fp = drawlistFingerprint(view); + frameAudit.emit("native.drawlist.payload", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + payloadSize: view.byteLength, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + head16: fp.head16, + tail16: fp.tail16, + }); + } + continue; + } + + if ( + (code === ZR_DEBUG_CODE_DRAWLIST_VALIDATE || code === ZR_DEBUG_CODE_DRAWLIST_EXECUTE) && + payloadSize >= DEBUG_DRAWLIST_RECORD_BYTES + ) { + const payload = new Uint8Array(DEBUG_DRAWLIST_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_DRAWLIST_RECORD_BYTES) { + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DRAWLIST_RECORD_BYTES, + ); + frameAudit.emit("native.drawlist.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + totalBytes: dvPayload.getUint32(8, true), + cmdCount: dvPayload.getUint32(12, true), + version: dvPayload.getUint32(16, true), + validationResult: dvPayload.getInt32(20, true), + executionResult: dvPayload.getInt32(24, true), + clipStackMaxDepth: dvPayload.getUint32(28, true), + textRuns: dvPayload.getUint32(32, true), + fillRects: dvPayload.getUint32(36, true), + }); + } + continue; + } + + if ( + (code === ZR_DEBUG_CODE_FRAME_BEGIN || + code === ZR_DEBUG_CODE_FRAME_SUBMIT || + code === ZR_DEBUG_CODE_FRAME_PRESENT || + code === ZR_DEBUG_CODE_FRAME_RESIZE) && + payloadSize >= DEBUG_FRAME_RECORD_BYTES + ) { + const payload = new Uint8Array(DEBUG_FRAME_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_FRAME_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_FRAME_RECORD_BYTES); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_PERF_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_PERF_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_DIFF_PATH_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_DIFF_PATH_RECORD_BYTES); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); + } + } + } + if (!advanced) return; + } +} + function writeResizeBatchV1(buf: ArrayBuffer, cols: number, rows: number): number { // Batch header (24) + RESIZE record (32) = 56 bytes. const totalSize = 56; @@ -489,6 +854,9 @@ function startTickLoop(fpsCap: number): void { } function fatal(where: string, code: number, detail: string): void { + if (frameAudit.enabled) { + frameAudit.emit("fatal", { where, code, detail }); + } postToMain({ type: "fatal", where, code, detail }); } @@ -515,6 +883,8 @@ function releasePendingFrame(frame: PendingFrame, expectedSabState: number): voi function postFrameStatus(frameSeq: number, completedResult: number): void { if (!Number.isInteger(frameSeq) || frameSeq <= 0) return; + emitFrameAudit("frame.completed", frameSeq, { completedResult }); + deleteFrameAudit(frameSeq); postToMain({ type: "frameStatus", acceptedSeq: frameSeq, @@ -526,6 +896,7 @@ function postFrameStatus(frameSeq: number, completedResult: number): void { function postFrameAccepted(frameSeq: number): void { if (!Number.isInteger(frameSeq) || frameSeq <= 0) return; + emitFrameAudit("frame.accepted", frameSeq); postToMain({ type: "frameStatus", acceptedSeq: frameSeq, @@ -587,9 +958,22 @@ function syncPendingSabFrameFromMailbox(): void { const latest = readLatestSabFrame(); if (latest === null) return; if (pendingFrame !== null) { + emitFrameAudit("frame.overwritten", pendingFrame.frameSeq, { reason: "mailbox-latest-wins" }); + deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); } pendingFrame = latest; + setFrameAuditMeta(latest.frameSeq, { + transport: latest.transport, + byteLen: latest.byteLen, + slotIndex: latest.slotIndex, + slotToken: latest.slotToken, + }); + emitFrameAudit("frame.mailbox.latest", latest.frameSeq, { + slotIndex: latest.slotIndex, + slotToken: latest.slotToken, + byteLen: latest.byteLen, + }); } function destroyEngineBestEffort(): void { @@ -607,9 +991,17 @@ function shutdownNow(): void { running = false; stopTickLoop(); if (pendingFrame !== null) { + emitFrameAudit("frame.dropped", pendingFrame.frameSeq, { reason: "shutdown" }); + deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); pendingFrame = null; } + if (frameAudit.enabled) { + for (const [seq] of frameAuditBySeq.entries()) { + emitFrameAudit("frame.dropped", seq, { reason: "shutdown_pending" }); + } + frameAuditBySeq.clear(); + } destroyEngineBestEffort(); shutdownComplete(); @@ -637,12 +1029,32 @@ function tick(): void { if (pendingFrame !== null) { const f = pendingFrame; pendingFrame = null; + emitFrameAudit("frame.submit.begin", f.frameSeq, { + transport: f.transport, + byteLen: f.byteLen, + ...(f.transport === FRAME_TRANSPORT_SAB_V1 + ? { slotIndex: f.slotIndex, slotToken: f.slotToken } + : {}), + }); let res = -1; let sabInUse = false; let staleSabFrame = false; try { if (f.transport === FRAME_TRANSPORT_TRANSFER_V1) { - res = native.engineSubmitDrawlist(engineId, new Uint8Array(f.buf, 0, f.byteLen)); + const view = new Uint8Array(f.buf, 0, f.byteLen); + if (frameAudit.enabled) { + const fp = drawlistFingerprint(view); + setFrameAuditMeta(f.frameSeq, { + transport: f.transport, + byteLen: f.byteLen, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + }); + emitFrameAudit("frame.submit.payload", f.frameSeq, fp); + } + res = native.engineSubmitDrawlist(engineId, view); } else { if (frameTransport.kind !== FRAME_TRANSPORT_SAB_V1) { throw new Error("SAB frame transport unavailable"); @@ -673,13 +1085,29 @@ function tick(): void { sabInUse = true; const offset = f.slotIndex * frameTransport.slotBytes; const view = frameTransport.data.subarray(offset, offset + f.byteLen); + if (frameAudit.enabled) { + const fp = drawlistFingerprint(view); + setFrameAuditMeta(f.frameSeq, { + transport: f.transport, + byteLen: f.byteLen, + slotIndex: f.slotIndex, + slotToken: f.slotToken, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + }); + emitFrameAudit("frame.submit.payload", f.frameSeq, fp); + } res = native.engineSubmitDrawlist(engineId, view); } } } } catch (err) { releasePendingFrame(f, sabInUse ? FRAME_SAB_SLOT_STATE_IN_USE : FRAME_SAB_SLOT_STATE_READY); + emitFrameAudit("frame.submit.throw", f.frameSeq, { detail: safeDetail(err) }); postFrameStatus(f.frameSeq, -1); + drainNativeFrameAudit("submit-throw"); fatal("engineSubmitDrawlist", -1, `engine_submit_drawlist threw: ${safeDetail(err)}`); running = false; return; @@ -688,6 +1116,8 @@ function tick(): void { // This frame was superseded in the shared mailbox before submit. // Keep latest-wins behavior without surfacing a fatal protocol error. didFrameWork = true; + emitFrameAudit("frame.submit.stale", f.frameSeq, { reason: "slot-token-mismatch" }); + deleteFrameAudit(f.frameSeq); syncPendingSabFrameFromMailbox(); // Continue with present/event processing on this tick. } else { @@ -695,6 +1125,8 @@ function tick(): void { haveSubmittedDrawlist = haveSubmittedDrawlist || didSubmitDrawlistThisTick; didFrameWork = true; releasePendingFrame(f, FRAME_SAB_SLOT_STATE_IN_USE); + emitFrameAudit("frame.submit.result", f.frameSeq, { submitResult: res }); + drainNativeFrameAudit("post-submit"); if (res < 0) { postFrameStatus(f.frameSeq, res); fatal("engineSubmitDrawlist", res, "engine_submit_drawlist failed"); @@ -717,21 +1149,30 @@ function tick(): void { try { pres = native.enginePresent(engineId); } catch (err) { + if (submittedFrameSeq !== null) + emitFrameAudit("frame.present.throw", submittedFrameSeq, { detail: safeDetail(err) }); if (submittedFrameSeq !== null) postFrameStatus(submittedFrameSeq, -1); + drainNativeFrameAudit("present-throw"); fatal("enginePresent", -1, `engine_present threw: ${safeDetail(err)}`); running = false; return; } if (pres < 0) { + if (submittedFrameSeq !== null) + emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); if (submittedFrameSeq !== null) postFrameStatus(submittedFrameSeq, pres); + drainNativeFrameAudit("present-failed"); fatal("enginePresent", pres, "engine_present failed"); running = false; return; } + if (submittedFrameSeq !== null) + emitFrameAudit("frame.present.result", submittedFrameSeq, { presentResult: pres }); } if (submittedFrameSeq !== null) { postFrameStatus(submittedFrameSeq, 0); + drainNativeFrameAudit("frame-complete"); } // 3) drain events (bounded) @@ -859,6 +1300,9 @@ function onMessage(msg: MainToWorkerMessage): void { running = true; pendingFrame = null; lastConsumedSabPublishedSeq = 0; + frameAuditBySeq.clear(); + nativeFrameAuditEnabled = false; + nativeFrameAuditNextRecordId = 1n; if (frameTransport.kind === FRAME_TRANSPORT_SAB_V1) { Atomics.store(frameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SEQ_WORD, 0); Atomics.store(frameTransport.controlHeader, FRAME_SAB_CONTROL_PUBLISHED_SLOT_WORD, 0); @@ -891,6 +1335,16 @@ function onMessage(msg: MainToWorkerMessage): void { maybeInjectInitialResize(maxEventBytes); } + if (frameAudit.enabled) { + frameAudit.emit("engine.ready", { + engineId: id, + frameTransport: frameTransport.kind, + maxEventBytes, + fpsCap: parsePositiveInt(msg.config.fpsCap) ?? 60, + }); + } + maybeEnableNativeFrameAudit(); + postToMain({ type: "ready", engineId: id }); const fpsCap = parsePositiveInt(msg.config.fpsCap) ?? 60; @@ -903,6 +1357,10 @@ function onMessage(msg: MainToWorkerMessage): void { // latest-wins overwrite for transfer-path fallback. if (pendingFrame !== null) { + emitFrameAudit("frame.overwritten", pendingFrame.frameSeq, { + reason: "message-latest-wins", + }); + deleteFrameAudit(pendingFrame.frameSeq); releasePendingFrame(pendingFrame, FRAME_SAB_SLOT_STATE_READY); } @@ -943,6 +1401,18 @@ function onMessage(msg: MainToWorkerMessage): void { slotToken: msg.slotToken as number, byteLen: msg.byteLen, }; + setFrameAuditMeta(msg.frameSeq, { + transport: FRAME_TRANSPORT_SAB_V1, + byteLen: msg.byteLen, + slotIndex: msg.slotIndex as number, + slotToken: msg.slotToken as number, + }); + emitFrameAudit("frame.received", msg.frameSeq, { + transport: FRAME_TRANSPORT_SAB_V1, + byteLen: msg.byteLen, + slotIndex: msg.slotIndex as number, + slotToken: msg.slotToken as number, + }); } else { if (!(msg.drawlist instanceof ArrayBuffer)) { fatal("frame", -1, "invalid transfer frame payload: missing drawlist"); @@ -964,6 +1434,21 @@ function onMessage(msg: MainToWorkerMessage): void { buf: msg.drawlist, byteLen: msg.byteLen, }; + if (frameAudit.enabled) { + const fp = drawlistFingerprint(new Uint8Array(msg.drawlist, 0, msg.byteLen)); + setFrameAuditMeta(msg.frameSeq, { + transport: FRAME_TRANSPORT_TRANSFER_V1, + byteLen: msg.byteLen, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + cmdCount: fp.cmdCount, + totalSize: fp.totalSize, + }); + emitFrameAudit("frame.received", msg.frameSeq, { + transport: FRAME_TRANSPORT_TRANSFER_V1, + ...fp, + }); + } } idleDelayMs = tickIntervalMs; scheduleTickNow(); @@ -1073,6 +1558,14 @@ function onMessage(msg: MainToWorkerMessage): void { fatal("engineDebugEnable", -1, `engine_debug_enable threw: ${safeDetail(err)}`); return; } + if (frameAudit.enabled) { + frameAudit.emit("native.debug.enable.user", { + rc, + captureDrawlistBytes: msg.config.captureDrawlistBytes ?? false, + }); + } + nativeFrameAuditEnabled = rc >= 0 && frameAudit.enabled; + if (nativeFrameAuditEnabled) nativeFrameAuditNextRecordId = 1n; postToMain({ type: "debug:enableResult", result: rc }); return; } @@ -1086,6 +1579,10 @@ function onMessage(msg: MainToWorkerMessage): void { fatal("engineDebugDisable", -1, `engine_debug_disable threw: ${safeDetail(err)}`); return; } + nativeFrameAuditEnabled = false; + if (frameAudit.enabled) { + frameAudit.emit("native.debug.disable.user", { rc }); + } postToMain({ type: "debug:disableResult", result: rc }); return; } @@ -1213,6 +1710,10 @@ function onMessage(msg: MainToWorkerMessage): void { fatal("engineDebugReset", -1, `engine_debug_reset threw: ${safeDetail(err)}`); return; } + if (frameAudit.enabled && rc >= 0) { + nativeFrameAuditNextRecordId = 1n; + frameAudit.emit("native.debug.reset", { rc }); + } postToMain({ type: "debug:resetResult", result: rc }); return; } diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin index 655ab364..16d25887 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/canvas_primitives.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/canvas_primitives.bin index e5244fba..6d8fbc82 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/canvas_primitives.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/canvas_primitives.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/heatmap_plasma.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/heatmap_plasma.bin index 85ae2cde..59be15e1 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/heatmap_plasma.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/heatmap_plasma.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_png_contain.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_png_contain.bin index ae8e63d8..d46b6d07 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_png_contain.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_png_contain.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_rgba_sixel_cover.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_rgba_sixel_cover.bin index a75a80de..e2d7ec91 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_rgba_sixel_cover.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/image_rgba_sixel_cover.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/line_chart.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/line_chart.bin index 6a9804f8..196afd18 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/line_chart.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/line_chart.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin index 19e8c82c..61c7e173 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin index ccd19365..bef6d42a 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/scatter_plot.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/scatter_plot.bin index 88e4bc78..45f8d990 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/scatter_plot.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/scatter_plot.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin index f2d451be..b9ac2074 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/golden/draw_text_interned.bin b/packages/testkit/fixtures/zrdl-v1/golden/draw_text_interned.bin index f6a43024..36e8ca92 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/golden/draw_text_interned.bin and b/packages/testkit/fixtures/zrdl-v1/golden/draw_text_interned.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin b/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin index b564d956..37950384 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/divider_horizontal.bin b/packages/testkit/fixtures/zrdl-v1/widgets/divider_horizontal.bin index f59f566b..e522b7b6 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/divider_horizontal.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/divider_horizontal.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin b/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin index af7a6398..8dcf92f6 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin index 1d048577..2013050c 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin index 0e56dd6e..bbc5da31 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin index bd866899..c05c69a3 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin b/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin index 396fceac..5ec07ce8 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin b/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin index 86a4aef8..56723e00 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin index 7cb6e6c4..537471a4 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin index a38d88e5..0da30e73 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin differ diff --git a/scripts/__tests__/check-native-vendor-integrity.test.mjs b/scripts/__tests__/check-native-vendor-integrity.test.mjs new file mode 100644 index 00000000..14ee256c --- /dev/null +++ b/scripts/__tests__/check-native-vendor-integrity.test.mjs @@ -0,0 +1,136 @@ +/** + * Tests for check-native-vendor-integrity.mjs + */ + +import { strict as assert } from "node:assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, test } from "node:test"; +import { checkNativeVendorIntegrity } from "../check-native-vendor-integrity.mjs"; + +const COMMIT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const COMMIT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function writeUtf8(path, text) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text, "utf8"); +} + +function makeFixtureRoot() { + return mkdtempSync(join(tmpdir(), "rezi-native-vendor-")); +} + +function writeBaseFixture(root, commit = COMMIT_A) { + writeUtf8( + join(root, "packages/native/build.rs"), + [ + "fn main() {", + ' let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));', + ' let _vendor = manifest_dir.join("vendor").join("zireael");', + ' println!("cargo:rerun-if-changed=vendor/VENDOR_COMMIT.txt");', + "}", + "", + ].join("\n"), + ); + writeUtf8(join(root, "packages/native/vendor/VENDOR_COMMIT.txt"), `${commit}\n`); + mkdirSync(join(root, "packages/native/vendor/zireael/include"), { recursive: true }); + mkdirSync(join(root, "packages/native/vendor/zireael/src"), { recursive: true }); +} + +describe("check-native-vendor-integrity", () => { + test("passes when pin matches gitlink and build.rs uses native vendor path", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + assert.equal( + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => null, + }).success, + true, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when commit pin format is invalid", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, "not-a-commit"); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => null, + }), + /VENDOR_COMMIT\.txt must contain exactly one 40-hex commit hash/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when build.rs does not use packages/native/vendor/zireael", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + writeUtf8( + join(root, "packages/native/build.rs"), + [ + "fn main() {", + ' let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));', + ' let _vendor = manifest_dir.join("..").join("vendor").join("zireael");', + ' println!("cargo:rerun-if-changed=vendor/VENDOR_COMMIT.txt");', + "}", + "", + ].join("\n"), + ); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => null, + }), + /build\.rs must compile Zireael from packages\/native\/vendor\/zireael/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when pinned commit and gitlink commit differ", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_B, + resolveSubmoduleHead: () => null, + }), + /native vendor commit pin mismatch/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails when checked-out submodule HEAD differs from gitlink pointer", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + assert.throws( + () => + checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + resolveSubmoduleHead: () => COMMIT_B, + }), + /checked-out vendor\/zireael is out of sync with repo gitlink pointer/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/check-native-vendor-integrity.mjs b/scripts/check-native-vendor-integrity.mjs new file mode 100644 index 00000000..df2c85ed --- /dev/null +++ b/scripts/check-native-vendor-integrity.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +/** + * check-native-vendor-integrity.mjs + * + * Guardrails for the dual Zireael vendor layout used by @rezi-ui/native. + * + * Invariants: + * - build.rs compiles from packages/native/vendor/zireael + * - packages/native/vendor/VENDOR_COMMIT.txt is exactly one 40-hex commit + * - the commit pin matches the repo gitlink pointer at vendor/zireael + * - if vendor/zireael is checked out locally, its HEAD matches that pointer + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +function isHex40(value) { + return /^[0-9a-fA-F]{40}$/.test(value); +} + +function runGit(cwd, args) { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (err) { + const stderr = String(err?.stderr ?? "").trim(); + const detail = stderr.length > 0 ? `: ${stderr}` : ""; + throw new Error(`git ${args.join(" ")} failed${detail}`); + } +} + +function normalizeCommitPin(rawPin) { + const lines = rawPin + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length !== 1 || !isHex40(lines[0])) { + throw new Error( + "packages/native/vendor/VENDOR_COMMIT.txt must contain exactly one 40-hex commit hash", + ); + } + return lines[0].toLowerCase(); +} + +function readGitlinkCommit(rootDir) { + const commit = runGit(rootDir, ["rev-parse", "HEAD:vendor/zireael"]).toLowerCase(); + if (!isHex40(commit)) { + throw new Error( + `gitlink pointer for vendor/zireael is not a 40-hex commit (got: ${JSON.stringify(commit)})`, + ); + } + return commit; +} + +function readSubmoduleHead(rootDir) { + const submoduleDir = join(rootDir, "vendor/zireael"); + if (!existsSync(submoduleDir)) return null; + + try { + if (!statSync(submoduleDir).isDirectory()) return null; + } catch { + return null; + } + + try { + const head = runGit(submoduleDir, ["rev-parse", "HEAD"]).toLowerCase(); + return isHex40(head) ? head : null; + } catch { + return null; + } +} + +function assertDir(path, label) { + if (!existsSync(path)) { + throw new Error(`${label} is missing: ${path}`); + } + if (!statSync(path).isDirectory()) { + throw new Error(`${label} must be a directory: ${path}`); + } +} + +function validateBuildRs(buildRsText) { + const compilePathNeedle = 'manifest_dir.join("vendor").join("zireael")'; + if (!buildRsText.includes(compilePathNeedle)) { + throw new Error( + "packages/native/build.rs must compile Zireael from packages/native/vendor/zireael", + ); + } + + const rerunCommitNeedle = 'println!("cargo:rerun-if-changed=vendor/VENDOR_COMMIT.txt");'; + if (!buildRsText.includes(rerunCommitNeedle)) { + throw new Error( + "packages/native/build.rs must include rerun-if-changed for vendor/VENDOR_COMMIT.txt", + ); + } +} + +export function checkNativeVendorIntegrity(rootDir, options = {}) { + const root = rootDir ?? join(dirname(fileURLToPath(import.meta.url)), ".."); + + const buildRsPath = join(root, "packages/native/build.rs"); + const nativeVendorRoot = join(root, "packages/native/vendor/zireael"); + const vendorCommitPath = join(root, "packages/native/vendor/VENDOR_COMMIT.txt"); + + if (!existsSync(buildRsPath)) { + throw new Error(`missing build script: ${buildRsPath}`); + } + if (!existsSync(vendorCommitPath)) { + throw new Error(`missing vendor commit pin file: ${vendorCommitPath}`); + } + + validateBuildRs(readFileSync(buildRsPath, "utf8")); + + assertDir(nativeVendorRoot, "native vendor root"); + assertDir(join(nativeVendorRoot, "include"), "native vendor include directory"); + assertDir(join(nativeVendorRoot, "src"), "native vendor source directory"); + + const pinnedCommit = normalizeCommitPin(readFileSync(vendorCommitPath, "utf8")); + + const resolveGitlinkCommit = options.resolveGitlinkCommit ?? readGitlinkCommit; + const resolveSubmoduleHead = options.resolveSubmoduleHead ?? readSubmoduleHead; + + const gitlinkCommitRaw = resolveGitlinkCommit(root); + const gitlinkCommit = String(gitlinkCommitRaw).toLowerCase(); + if (!isHex40(gitlinkCommit)) { + throw new Error(`resolved gitlink commit is invalid: ${JSON.stringify(gitlinkCommitRaw)}`); + } + + if (pinnedCommit !== gitlinkCommit) { + throw new Error( + [ + "native vendor commit pin mismatch:", + `- packages/native/vendor/VENDOR_COMMIT.txt: ${pinnedCommit}`, + `- gitlink HEAD:vendor/zireael: ${gitlinkCommit}`, + "Update the pin file (or submodule pointer) so both commits match.", + ].join("\n"), + ); + } + + const submoduleHeadRaw = resolveSubmoduleHead(root); + if (submoduleHeadRaw !== null && submoduleHeadRaw !== undefined) { + const submoduleHead = String(submoduleHeadRaw).toLowerCase(); + if (!isHex40(submoduleHead)) { + throw new Error( + `resolved checked-out vendor/zireael HEAD is invalid: ${JSON.stringify(submoduleHeadRaw)}`, + ); + } + if (submoduleHead !== gitlinkCommit) { + throw new Error( + [ + "checked-out vendor/zireael is out of sync with repo gitlink pointer:", + `- vendor/zireael HEAD: ${submoduleHead}`, + `- repo gitlink HEAD:vendor/zireael: ${gitlinkCommit}`, + "Run: git submodule update --init --recursive vendor/zireael", + ].join("\n"), + ); + } + } + + return { + success: true, + pinnedCommit, + gitlinkCommit, + }; +} + +const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : null; +const selfPath = realpathSync(fileURLToPath(import.meta.url)); +if (invokedPath && invokedPath === selfPath) { + try { + const result = checkNativeVendorIntegrity(); + process.stdout.write( + `check-native-vendor-integrity: OK (pin=${result.pinnedCommit.slice(0, 12)})\n`, + ); + process.exit(0); + } catch (err) { + process.stderr.write(`check-native-vendor-integrity: FAIL\n${String(err?.stack ?? err)}\n`); + process.exit(1); + } +} diff --git a/scripts/frame-audit-report.mjs b/scripts/frame-audit-report.mjs new file mode 100755 index 00000000..1c13e416 --- /dev/null +++ b/scripts/frame-audit-report.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * scripts/frame-audit-report.mjs — Quick analyzer for REZI_FRAME_AUDIT NDJSON logs. + * + * Usage: + * node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson + */ + +import fs from "node:fs"; + +function usage() { + process.stderr.write( + "Usage: node scripts/frame-audit-report.mjs [--pid=|--latest-pid]\n", + ); +} + +const argv = process.argv.slice(2); +const file = argv[0]; +if (!file) { + usage(); + process.exit(1); +} + +let pidFilter = null; +let latestPidOnly = false; +for (const arg of argv.slice(1)) { + if (arg === "--latest-pid") { + latestPidOnly = true; + continue; + } + if (arg.startsWith("--pid=")) { + const n = Number(arg.slice("--pid=".length)); + if (Number.isInteger(n) && n > 0) { + pidFilter = n; + continue; + } + } + usage(); + process.exit(1); +} + +let text = ""; +try { + text = fs.readFileSync(file, "utf8"); +} catch (err) { + process.stderr.write(`Failed to read ${file}: ${String(err)}\n`); + process.exit(1); +} + +const lines = text.split(/\r?\n/); +let records = []; +for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const rec = JSON.parse(trimmed); + if (rec && typeof rec === "object") records.push(rec); + } catch { + // ignore malformed lines + } +} + +const recordsByPid = new Map(); +for (const rec of records) { + if (!Number.isInteger(rec.pid)) continue; + const pid = rec.pid; + const state = recordsByPid.get(pid) ?? { + count: 0, + firstTs: null, + lastTs: null, + modes: new Set(), + }; + state.count += 1; + if (typeof rec.ts === "string") { + if (state.firstTs === null || rec.ts < state.firstTs) state.firstTs = rec.ts; + if (state.lastTs === null || rec.ts > state.lastTs) state.lastTs = rec.ts; + } + if (typeof rec.executionMode === "string" && rec.executionMode.length > 0) { + state.modes.add(rec.executionMode); + } + recordsByPid.set(pid, state); +} + +if (latestPidOnly && pidFilter === null) { + let latest = null; + for (let i = records.length - 1; i >= 0; i--) { + const rec = records[i]; + if (Number.isInteger(rec.pid)) { + latest = rec.pid; + break; + } + } + pidFilter = latest; +} + +if (pidFilter !== null) { + records = records.filter((rec) => rec.pid === pidFilter); +} + +const OPCODE_NAMES = Object.freeze({ + 0: "INVALID", + 1: "CLEAR", + 2: "FILL_RECT", + 3: "DRAW_TEXT", + 4: "PUSH_CLIP", + 5: "POP_CLIP", + 6: "DRAW_TEXT_RUN", + 7: "SET_CURSOR", + 8: "DRAW_CANVAS", + 9: "DRAW_IMAGE", + 10: "DEF_STRING", + 11: "FREE_STRING", + 12: "DEF_BLOB", + 13: "FREE_BLOB", + 14: "BLIT_RECT", +}); + +function routeId(rec, fallback = null) { + if (typeof rec.route === "string" && rec.route.length > 0) return rec.route; + if (typeof fallback === "string" && fallback.length > 0) return fallback; + return ""; +} + +function seqKey(rec) { + if (!Number.isInteger(rec.frameSeq)) return null; + const pid = Number.isInteger(rec.pid) ? rec.pid : "nopid"; + return `${pid}:${rec.frameSeq}`; +} + +function addOpcodeHistogram(dst, hist) { + if (typeof hist !== "object" || hist === null) return; + for (const [k, v] of Object.entries(hist)) { + const count = Number(v); + if (!Number.isFinite(count) || count <= 0) continue; + dst.set(k, (dst.get(k) ?? 0) + count); + } +} + +function createRouteSummary() { + return { + framesSubmitted: 0, + framesCompleted: 0, + bytesTotal: 0, + cmdsTotal: 0, + cmdsSamples: 0, + invalidCmdStreams: 0, + opcodeCounts: new Map(), + }; +} + +const backendSubmitted = new Map(); +const submitPayloadBySeq = new Map(); +const completedBySeq = new Map(); +const acceptedBySeq = new Map(); +const coreBuilt = []; +const coreCompleted = []; +const nativePayload = []; +const nativeSummaries = []; +const nativeHeaders = []; +const stageCounts = new Map(); +const globalOpcodeCounts = new Map(); +const routeSummaries = new Map(); +const routeBySeq = new Map(); +const submittedFramesSeen = new Set(); +const completedFramesSeen = new Set(); + +for (const rec of records) { + const stage = typeof rec.stage === "string" ? rec.stage : ""; + stageCounts.set(stage, (stageCounts.get(stage) ?? 0) + 1); + + if ((rec.scope === "backend" || rec.scope === "backend-inline") && stage === "frame.submitted") { + const key = seqKey(rec); + if (key !== null) backendSubmitted.set(key, rec); + const route = routeId(rec); + if (route !== "" && key !== null) routeBySeq.set(key, route); + } + + if ( + (rec.scope === "worker" || rec.scope === "backend-inline") && + stage === "frame.submit.payload" + ) { + const key = seqKey(rec); + if (key !== null) submitPayloadBySeq.set(key, rec); + addOpcodeHistogram(globalOpcodeCounts, rec.opcodeHistogram); + const route = routeId(rec, key !== null ? routeBySeq.get(key) : null); + const summary = routeSummaries.get(route) ?? createRouteSummary(); + addOpcodeHistogram(summary.opcodeCounts, rec.opcodeHistogram); + routeSummaries.set(route, summary); + } + + if ((rec.scope === "worker" || rec.scope === "backend-inline") && stage === "frame.accepted") { + const key = seqKey(rec); + if (key !== null) acceptedBySeq.set(key, rec); + } + + if ((rec.scope === "worker" || rec.scope === "backend-inline") && stage === "frame.completed") { + const key = seqKey(rec); + if (key !== null) completedBySeq.set(key, rec); + } + + if (rec.scope === "worker" && stage === "native.drawlist.payload") { + nativePayload.push(rec); + } + if (rec.scope === "worker" && stage === "native.drawlist.summary") { + nativeSummaries.push(rec); + } + if (rec.scope === "worker" && stage === "native.debug.header") { + nativeHeaders.push(rec); + } + + if (rec.scope === "core" && stage === "drawlist.built") { + coreBuilt.push(rec); + } + + if (rec.scope === "core" && stage === "backend.completed") { + coreCompleted.push(rec); + } + + if (stage === "frame.submitted") { + const key = seqKey(rec); + if (key !== null && submittedFramesSeen.has(key)) { + continue; + } + if (key !== null) submittedFramesSeen.add(key); + const route = routeId(rec, key !== null ? routeBySeq.get(key) : null); + const summary = routeSummaries.get(route) ?? createRouteSummary(); + summary.framesSubmitted += 1; + if (Number.isFinite(rec.byteLen)) summary.bytesTotal += Number(rec.byteLen); + if (Number.isFinite(rec.cmdCount)) { + summary.cmdsTotal += Number(rec.cmdCount); + summary.cmdsSamples += 1; + } + if (rec.cmdStreamValid === false) summary.invalidCmdStreams += 1; + routeSummaries.set(route, summary); + } + + if (stage === "frame.completed") { + const key = seqKey(rec); + if (key !== null && completedFramesSeen.has(key)) { + continue; + } + if (key !== null) completedFramesSeen.add(key); + const route = routeId(rec, key !== null ? routeBySeq.get(key) : null); + const summary = routeSummaries.get(route) ?? createRouteSummary(); + summary.framesCompleted += 1; + routeSummaries.set(route, summary); + } +} + +let missingWorkerPayload = 0; +let hashMismatch = 0; +let missingAccepted = 0; +let missingCompleted = 0; +let firstMismatch = null; + +for (const [key, backend] of backendSubmitted.entries()) { + const worker = submitPayloadBySeq.get(key); + if (!worker) { + missingWorkerPayload++; + continue; + } + + if ( + typeof backend.hash32 === "string" && + typeof worker.hash32 === "string" && + backend.hash32 !== worker.hash32 + ) { + hashMismatch++; + if (firstMismatch === null) { + firstMismatch = { + frameKey: key, + backendHash: backend.hash32, + workerHash: worker.hash32, + backendByteLen: backend.byteLen, + workerByteLen: worker.byteLen, + }; + } + } + + if (!acceptedBySeq.has(key)) missingAccepted++; + if (!completedBySeq.has(key)) missingCompleted++; +} + +process.stdout.write(`records=${records.length}\n`); +if (pidFilter !== null) { + process.stdout.write(`pid_filter=${pidFilter}\n`); +} +if (recordsByPid.size > 0) { + process.stdout.write(`pids=${recordsByPid.size}\n`); + process.stdout.write("pid_sessions:\n"); + const sessionRows = [...recordsByPid.entries()].sort((a, b) => a[0] - b[0]); + for (const [pid, s] of sessionRows) { + const modes = [...s.modes].sort().join("|"); + process.stdout.write( + ` pid=${pid} records=${s.count} firstTs=${s.firstTs ?? "-"} lastTs=${s.lastTs ?? "-"} modes=${modes}\n`, + ); + } +} +process.stdout.write(`backend_submitted=${backendSubmitted.size}\n`); +process.stdout.write(`worker_payload=${submitPayloadBySeq.size}\n`); +process.stdout.write(`worker_accepted=${acceptedBySeq.size}\n`); +process.stdout.write(`worker_completed=${completedBySeq.size}\n`); +process.stdout.write(`native_payload_records=${nativePayload.length}\n`); +process.stdout.write(`native_summary_records=${nativeSummaries.length}\n`); +process.stdout.write(`native_header_records=${nativeHeaders.length}\n`); +process.stdout.write(`missing_worker_payload=${missingWorkerPayload}\n`); +process.stdout.write(`hash_mismatch_backend_vs_worker=${hashMismatch}\n`); +process.stdout.write(`missing_worker_accepted=${missingAccepted}\n`); +process.stdout.write(`missing_worker_completed=${missingCompleted}\n`); + +if (backendSubmitted.size === 0 && (coreBuilt.length > 0 || coreCompleted.length > 0)) { + process.stdout.write( + "hint=core-only audit records detected; backend likely running a path without frame-level backend instrumentation (for example old build or inline mode without updated backend).\n", + ); +} + +if (firstMismatch) { + process.stdout.write(`first_mismatch=${JSON.stringify(firstMismatch)}\n`); +} + +const topOpcodes = [...globalOpcodeCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); +process.stdout.write("top_opcodes:\n"); +for (const [opcode, count] of topOpcodes) { + const name = OPCODE_NAMES[Number(opcode)] ?? `OP_${opcode}`; + process.stdout.write(` ${name}(${opcode}): ${count}\n`); +} + +const routes = [...routeSummaries.entries()].sort( + (a, b) => b[1].framesSubmitted - a[1].framesSubmitted, +); +process.stdout.write("route_summary:\n"); +for (const [route, summary] of routes) { + const avgBytes = summary.framesSubmitted > 0 ? summary.bytesTotal / summary.framesSubmitted : 0; + const avgCmds = summary.cmdsSamples > 0 ? summary.cmdsTotal / summary.cmdsSamples : 0; + const topRouteOps = [...summary.opcodeCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([opcode, count]) => `${OPCODE_NAMES[Number(opcode)] ?? `OP_${opcode}`}=${count}`) + .join(","); + process.stdout.write( + ` ${route}: submitted=${summary.framesSubmitted} completed=${summary.framesCompleted} avgBytes=${avgBytes.toFixed(1)} avgCmds=${avgCmds.toFixed(1)} invalidCmdStreams=${summary.invalidCmdStreams} topOps=${topRouteOps}\n`, + ); +} + +const sortedStages = [...stageCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); +process.stdout.write("top_stages:\n"); +for (const [stage, count] of sortedStages) { + process.stdout.write(` ${stage}: ${count}\n`); +} diff --git a/scripts/generate-drawlist-writers.ts b/scripts/generate-drawlist-writers.ts index 26d4a977..a1f31669 100644 --- a/scripts/generate-drawlist-writers.ts +++ b/scripts/generate-drawlist-writers.ts @@ -108,7 +108,7 @@ function emitFunction(command: DrawlistCommandSpec): Emission { const body: string[] = []; if (command.hasTrailingBytes) { - body.push("const payloadBytes = bytes.byteLength >>> 0;"); + body.push("const payloadBytes = byteLen >>> 0;"); body.push(`const size = align4(${command.name}_BASE_SIZE + payloadBytes);`); body.push(`buf[pos + 0] = ${command.opcode} & 0xff;`); body.push("buf[pos + 1] = 0;"); @@ -132,7 +132,13 @@ function emitFunction(command: DrawlistCommandSpec): Emission { if (command.hasTrailingBytes) { body.push(`const dataStart = pos + ${command.name}_BASE_SIZE;`); - body.push("buf.set(bytes, dataStart);"); + body.push("const copyBytes = Math.min(payloadBytes, bytes.byteLength >>> 0);"); + body.push("if (copyBytes > 0) {"); + body.push(" buf.set(bytes.subarray(0, copyBytes), dataStart);"); + body.push("}"); + body.push("if (payloadBytes > copyBytes) {"); + body.push(" buf.fill(0, dataStart + copyBytes, dataStart + payloadBytes);"); + body.push("}"); body.push("const payloadEnd = dataStart + payloadBytes;"); body.push("const cmdEnd = pos + size;"); body.push("if (cmdEnd > payloadEnd) {"); diff --git a/vendor/zireael b/vendor/zireael index 435d28bc..c0849ae2 160000 --- a/vendor/zireael +++ b/vendor/zireael @@ -1 +1 @@ -Subproject commit 435d28bcd59dd07b78be0f28661ae159cefde753 +Subproject commit c0849ae29483322623d4ab564877a8940896affb