diff --git a/packages/exojs-bench/baselines/structural.json b/packages/exojs-bench/baselines/structural.json index 4638e7791..16e252515 100644 --- a/packages/exojs-bench/baselines/structural.json +++ b/packages/exojs-bench/baselines/structural.json @@ -1,6 +1,6 @@ { "recorded": { - "at": "2026-09-08T17:20:19.719Z", + "at": "2026-09-11T04:46:48.402Z", "engineVersion": "0.17.0", "adapter": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)" }, @@ -35,6 +35,16 @@ "textureBinds": 0, "bufferUploads": 1 }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "dynamic-all", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, { "engine": "exojs", "config": "current", @@ -165,6 +175,26 @@ "textureBinds": 0, "bufferUploads": 0 }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "particles-draw", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "particles-lifecycle", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, { "engine": "exojs", "config": "current", @@ -205,6 +235,26 @@ "textureBinds": 0, "bufferUploads": 0 }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "tilemap-edit", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, + { + "engine": "exojs", + "config": "current", + "backend": "webgl2", + "archetype": "tilemap-scroll", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 0 + }, { "engine": "exojs", "config": "retained", @@ -235,6 +285,16 @@ "textureBinds": 0, "bufferUploads": 19 }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "dynamic-all", + "nodeCount": 200, + "drawCalls": 4, + "textureBinds": 0, + "bufferUploads": 7 + }, { "engine": "exojs", "config": "retained", @@ -365,6 +425,26 @@ "textureBinds": 0, "bufferUploads": 0 }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "particles-draw", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "particles-lifecycle", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, { "engine": "exojs", "config": "retained", @@ -404,6 +484,26 @@ "drawCalls": 4, "textureBinds": 0, "bufferUploads": 12 + }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "tilemap-edit", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 1 + }, + { + "engine": "exojs", + "config": "retained", + "backend": "webgl2", + "archetype": "tilemap-scroll", + "nodeCount": 200, + "drawCalls": 1, + "textureBinds": 0, + "bufferUploads": 0 } ] } diff --git a/packages/exojs-bench/competitors/package.json b/packages/exojs-bench/competitors/package.json index f4e71b832..1d88bb78a 100644 --- a/packages/exojs-bench/competitors/package.json +++ b/packages/exojs-bench/competitors/package.json @@ -5,6 +5,7 @@ "description": "Pinned exact-version competitor libraries for @codexo/exojs-bench (Pixi, Phaser, Excalibur, matter-js, planck, nape-js, rapier2d-compat arms). Deliberately NOT a member of the repository workspace: a plain root `pnpm install` never resolves or downloads anything here, so a normal contributor pays zero weight for competitor libraries. Only `pnpm --filter @codexo/exojs-bench bench:setup` installs this folder - as its own workspace root (see pnpm-workspace.yaml beside this file), which applies the same minimumReleaseAge supply-chain quarantine as the repository workspace - and links the results into ../node_modules so the adapters' plain `import 'pixi.js'` (etc.) resolve unmodified.", "dependencies": { "pixi.js": "8.19.0", + "@pixi/tilemap": "5.0.2", "phaser": "4.2.1", "excalibur": "0.32.0", "matter-js": "0.20.0", diff --git a/packages/exojs-bench/competitors/pnpm-lock.yaml b/packages/exojs-bench/competitors/pnpm-lock.yaml index 039c1c61b..39a5e96da 100644 --- a/packages/exojs-bench/competitors/pnpm-lock.yaml +++ b/packages/exojs-bench/competitors/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@newkrok/nape-js': specifier: 3.42.0 version: 3.42.0 + '@pixi/tilemap': + specifier: 5.0.2 + version: 5.0.2(pixi.js@8.19.0) '@types/matter-js': specifier: 0.20.2 version: 0.20.2 @@ -45,6 +48,11 @@ packages: '@pixi/colord@2.9.6': resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@pixi/tilemap@5.0.2': + resolution: {integrity: sha512-J+K1eU7uB58WPVD33d7usXrhKA03IGq3IfPvXBFr1027Bb9ScxrGhH0NomR9VpVVUPiHkK7FcTGuOtJMVGAv6A==} + peerDependencies: + pixi.js: '>=8.5.0' + '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} @@ -111,6 +119,10 @@ snapshots: '@pixi/colord@2.9.6': {} + '@pixi/tilemap@5.0.2(pixi.js@8.19.0)': + dependencies: + pixi.js: 8.19.0 + '@types/earcut@3.0.0': {} '@types/matter-js@0.20.2': {} diff --git a/packages/exojs-bench/package.json b/packages/exojs-bench/package.json index 5f6e6760a..2ff036e36 100644 --- a/packages/exojs-bench/package.json +++ b/packages/exojs-bench/package.json @@ -7,6 +7,7 @@ "scripts": { "bench:setup": "pnpm install --dir competitors --frozen-lockfile && node competitors/link.ts", "bench": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", + "bench:reference": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts --suite=reference --domain=all", "perf:baseline": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", "gate:timing": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/runTimingGate.ts", "gate:structural": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/runStructuralGate.ts", @@ -20,7 +21,9 @@ "devDependencies": { "@codexo/exojs": "workspace:*", "@codexo/exojs-config": "workspace:*", + "@codexo/exojs-particles": "workspace:*", "@codexo/exojs-physics": "workspace:*", + "@codexo/exojs-tilemap": "workspace:*", "@types/node": "^26.4.0", "@webgpu/types": "^0.1.69", "playwright": "^1.62.1", diff --git a/packages/exojs-bench/src/comparison/aggregate.ts b/packages/exojs-bench/src/comparison/aggregate.ts index 694972e86..f5a961b73 100644 --- a/packages/exojs-bench/src/comparison/aggregate.ts +++ b/packages/exojs-bench/src/comparison/aggregate.ts @@ -5,6 +5,7 @@ import type { Provenance } from '../rendering/driver'; import type { ReportData } from '../rendering/report'; import { exceedsFrameBudget } from '../shared/frameBudget'; import type { LibraryProvenance, PlatformVersionStamp, PrereleaseStamp } from '../shared/provenance'; +import { mergeTimerChecks } from '../shared/timerCheck'; import { median } from '../shared/timing'; import type { BackendComparison, ComparisonCell, ComparisonRow, ComparisonSection, ExcludedRow } from './build'; import { buildPhysicsComparison, buildRenderingComparison } from './build'; @@ -242,6 +243,12 @@ const NO_SPREAD: RunSpread = { minMs: Number.NaN, maxMs: Number.NaN, ratio: Numb const measured = (value: number | null): value is number => value !== null && Number.isFinite(value); +const pooledOptional = (values: ReadonlyArray): number | null => { + const measuredValues = values.filter((value): value is number => value !== null && value !== undefined && Number.isFinite(value)); + + return measuredValues.length > 0 ? median(measuredValues) : null; +}; + /** * Pool one arm pair across the runs that produced it. * @@ -272,15 +279,22 @@ const aggregateCell = (perRun: readonly ComparisonCell[], runCount: number): Agg // pooled across runs, not a tail across the pooled samples, which no // published artifact retains. referenceP95Ms: referenceP95s.length > 0 ? median(referenceP95s) : null, + referenceGpuMs: pooledOptional(perRun.map(cell => cell.referenceGpuMs)), // Recomputed from the POOLED median rather than carried over from a run: the // published number is the one the mark has to describe, and a run's own mark // can disagree with it near the line. referenceOverFrameBudget: exceedsFrameBudget(referenceMs), competitorMs, competitorP95Ms: competitorP95s.length > 0 ? median(competitorP95s) : null, + competitorGpuMs: pooledOptional(perRun.map(cell => cell.competitorGpuMs)), competitorOverFrameBudget: exceedsFrameBudget(competitorMs), verdict: stable ? pooled : UNSTABLE_VERDICT, mechanism: first.mechanism, + // Merged from the per-run checks rather than recomputed from the pooled + // medians: a limitation any run established stands for the pooled figure, + // and pooling the durations first would let a well-resolved repetition carry + // a limited one past the threshold. + timer: mergeTimerChecks(perRun.map(cell => cell.timer)), aggregate: { runs: perRun.length, reference: references.length > 0 ? spreadOf(references) : NO_SPREAD, @@ -310,13 +324,11 @@ const groupBy = (values: readonly T[], key: (value: T) => string): Map { const first = perRun[0]!; @@ -325,7 +337,7 @@ const aggregateRow = (perRun: readonly ComparisonRow[], runCount: number, domain if (counts.length > 1) { throw new IncomparableRunsError( domain, - `${domain} runs measured '${first.archetype}' at different counts (${counts.map(String).join(', ')}), so their medians describe different scenes. Re-measure: a row's count only moves when an arm failed to produce a valid cell.`, + `${domain} runs measured '${first.archetype}' at different counts under one load id (${counts.map(String).join(', ')}), so their medians describe different scenes.`, ); } @@ -334,11 +346,20 @@ const aggregateRow = (perRun: readonly ComparisonRow[], runCount: number, domain cell => cell.competitor, ); - return { archetype: first.archetype, category: first.category, count: first.count, cells: [...cells.values()].map(group => aggregateCell(group, runCount)) }; + return { + archetype: first.archetype, + category: first.category, + count: first.count, + loadId: first.loadId, + unit: first.unit, + primary: first.primary, + ...(first.label !== undefined && { label: first.label }), + cells: [...cells.values()].map(group => aggregateCell(group, runCount)), + }; }; const aggregateRows = (perRun: ReadonlyArray, runCount: number, domain: string): readonly AggregatedRow[] => - [...groupBy(perRun.flat(), row => row.archetype).values()].map(group => aggregateRow(group, runCount, domain)); + [...groupBy(perRun.flat(), row => `${row.archetype}|${row.loadId}`).values()].map(group => aggregateRow(group, runCount, domain)); const aggregateSection = (perRun: readonly ComparisonSection[], runCount: number, domain: string): AggregatedSection => ({ title: perRun[0]!.title, @@ -470,7 +491,7 @@ export const aggregatePhysicsRuns = (runs: readonly PhysicsReportData[]): Aggreg runs: runs.map(run => run.provenance), libraries: runs[0]!.libraries, section: aggregateSection( - runs.map(run => buildPhysicsComparison(run.results)), + runs.map(run => buildPhysicsComparison(run.results, run.provenance.clock)), runs.length, 'physics', ), diff --git a/packages/exojs-bench/src/comparison/build.ts b/packages/exojs-bench/src/comparison/build.ts index 168c065b3..4219ffe39 100644 --- a/packages/exojs-bench/src/comparison/build.ts +++ b/packages/exojs-bench/src/comparison/build.ts @@ -2,7 +2,12 @@ import { PHYSICS_ARCHETYPES } from '../physics/archetypes'; import type { PhysicsCellResult } from '../physics/PhysicsAdapter'; import { ARCHETYPES } from '../rendering/archetypes'; import type { ArchetypeCategory, Backend, CellResult, StructuralCounters } from '../rendering/EngineAdapter'; +import type { ClockReport } from '../shared/clock'; import { exceedsFrameBudget } from '../shared/frameBudget'; +import type { TimerCheck } from '../shared/timerCheck'; +import { mergeTimerChecks, timerCheckOfRun } from '../shared/timerCheck'; +import type { LoadUnit } from '../suite/catalog'; +import { loadIdFor, scenariosFor } from '../suite/catalog'; import { physicsMechanism, renderingMechanism } from './mechanism'; import type { Verdict } from './verdict'; import { compareMedians } from './verdict'; @@ -17,12 +22,17 @@ import { compareMedians } from './verdict'; * * - No aggregation across archetypes, anywhere. Categories are section headings, * never rows, because any mean over a category hides its worst cell. - * - A row's node or body count is chosen from the archetype LADDERS before any - * timing is read (see {@link chooseRowCount}), so it can never be picked to - * suit the outcome. A rendering block goes further and puts every row on one - * count (see {@link chooseHeadlineCount}); the physics block cannot, and - * states each row's count on the row instead. + * - A row is one archetype at one LOAD, and every load the run measured becomes + * its own row. Nothing picks a load after the fact: the plan fixed which loads + * would be measured before the run started, and a row states the load it + * belongs to. * - A row with no evidenced mechanism does not enter the table. + * + * The rendering block used to collapse onto a single table-wide node count. It + * cannot any more: the published page offers the reader a load to pick, and a + * single count discards every other measurement before the profile is even + * written. The property that count protected - that no load is chosen to suit an + * outcome - is kept by the plan, which fixes the loads in advance. */ /** The reference arm every comparison is drawn against: ExoJS on its default path. */ @@ -42,6 +52,8 @@ const CATEGORY_ORDER: readonly ArchetypeCategory[] = [ 'text', 'render-targets', 'camera-and-world', + 'tilemaps', + 'particles', 'submission', ]; @@ -53,6 +65,8 @@ const CATEGORY_TITLES: Readonly> = { text: 'Text', 'render-targets': 'Render targets', 'camera-and-world': 'Camera and world', + tilemaps: 'Tilemaps', + particles: 'Particles', submission: 'Submission paths', }; @@ -78,6 +92,8 @@ export interface ComparisonCell { readonly referenceMs: number | null; /** Reference arm's 95th-percentile CPU time (ms) over the same timed window, or `null`. */ readonly referenceP95Ms: number | null; + /** Reference arm's measured GPU frame time, when the backend exposed a timer. */ + readonly referenceGpuMs?: number | null; /** * True when {@link referenceMs} is past a whole 60 fps frame; see * {@link '../shared/frameBudget'.FRAME_BUDGET_MS}. @@ -91,29 +107,48 @@ export interface ComparisonCell { readonly competitorMs: number | null; /** Competitor's 95th-percentile CPU time (ms) over the same timed window, or `null`. */ readonly competitorP95Ms: number | null; + /** Competitor arm's measured GPU frame time, when the backend exposed a timer. */ + readonly competitorGpuMs?: number | null; /** True when {@link competitorMs} is past a whole 60 fps frame; see {@link referenceOverFrameBudget}. */ readonly competitorOverFrameBudget: boolean; /** Computed ladder outcome, from the two medians. */ readonly verdict: Verdict; /** Evidenced mechanism, or `null` when the counters carry none. */ readonly mechanism: string | null; + /** + * What the clock this run read the two durations on established about them. + * + * Evaluated here, against the grid of the very session that produced each + * cell, because that is the only place both are known together. The pooling + * stage merges the runs' results; it never re-runs the check against pooled + * medians, which would let a well-resolved repetition carry a limited one. + */ + readonly timer: TimerCheck; } -/** One published row: an archetype at the count it was measured at, across every competitor. */ +/** + * One published row: an archetype at one load, across every competitor. + * + * An archetype measured at several loads produces several rows. A reader + * compares the arms WITHIN a row, which is like for like by construction, and + * never two rows against each other: two loads are two different scenes, as are + * two archetypes. + */ export interface ComparisonRow { - /** Archetype id - the row's identity. */ + /** Archetype id. Together with {@link ComparisonRow.loadId} it identifies the row. */ readonly archetype: string; /** The category section this row sits under. */ readonly category: string; - /** - * Node or body count this row was measured at. - * - * In a rendering block every row carries the block's single headline count. In - * the physics block the rows carry their OWN counts, because the physics - * archetypes have per-archetype ladders; two physics rows are therefore never - * comparable with each other, only the arms within one row are. - */ + /** Node or body count this row was measured at. */ readonly count: number; + /** Catalog load id for {@link ComparisonRow.count}, e.g. `10k`. */ + readonly loadId: string; + /** Unit the count is quoted in, so a figure is never published without one. */ + readonly unit: LoadUnit; + /** Whether this is the scenario's headline load - the one a card shows first. */ + readonly primary: boolean; + /** Display label for a load the count/unit pair cannot state, e.g. a resolution. */ + readonly label?: string; /** One entry per competitor arm, in a stable order. */ readonly cells: readonly ComparisonCell[]; } @@ -156,12 +191,43 @@ export interface BackendComparison { /** Key identifying one arm's cell within a backend. */ const cellKey = (engine: string, config: string, archetype: string, count: number): string => `${engine}|${config}|${archetype}|${count}`; +/** + * The catalog identity of one load: its id, the unit it is counted in, whether + * it is the scenario's headline, and a label where the pair cannot state it. + * + * A scenario the catalog does not carry - an ExoJS-internal probe measured under + * `full` - still gets a well-formed identity, so every row in the model can be + * addressed the same way. It is quoted in scene nodes, which is what those + * probes count. + */ +const loadIdentity = (scenarioId: string, count: number): { loadId: string; unit: LoadUnit; primary: boolean; label?: string } => { + const scenario = + scenariosFor('rendering').find(entry => entry.scenarioId === scenarioId) ?? scenariosFor('physics').find(entry => entry.scenarioId === scenarioId); + const load = scenario?.loads.find(entry => entry.value === count); + + return { + loadId: loadIdFor(count), + unit: scenario?.unit ?? 'nodes', + primary: load?.primary === true, + ...(load?.label !== undefined && { label: load.label }), + }; +}; + /** Identity used to preserve the old CPU-only Phaser block while new WebGL2 profiles migrate. */ const armKeyOf = (result: { readonly spec: { readonly engine: string; readonly config: string } }): string => `${result.spec.engine}|${result.spec.config}`; /** Whether a result can be compared at all: it measured, and it measured something. */ const isComparable = (result: { status: string; note?: string }): boolean => result.status === 'ok'; +/** + * The duration the clock actually bracketed for one physics cell: the median + * sample, which covers `stepsPerSample` steps. + * + * The harness batches steps precisely so a sample clears the clock's grid, so + * the batch is the reading and the per-step quotient is a derived figure. + */ +const batchMsOf = (result: PhysicsCellResult): number => result.stepMsMedian * result.stepsPerSample; + /** * The count one row is published at: the largest rung of its own LADDER at which * every arm produced a valid cell. @@ -252,73 +318,83 @@ const buildBackend = (backend: Backend, results: readonly CellResult[]): Backend : 'ExoJS-internal structural probe: a competitor arm renders a different scene here, so a wall-clock comparison would not be like for like', })); + /** Loads the reference arm actually measured for one archetype, ascending. */ + const measuredLoads = (archetype: string): readonly number[] => + [ + ...new Set( + onBackend + .filter(result => result.spec.engine === REFERENCE_ENGINE && result.spec.config === REFERENCE_CONFIG && result.spec.archetype === archetype) + .map(result => result.spec.nodeCount), + ), + ].sort((a, b) => a - b); + const sections: ComparisonSection[] = []; for (const category of CATEGORY_ORDER) { const rows: ComparisonRow[] = []; for (const archetype of comparable.filter(candidate => candidate.category === category)) { - // The headline count is one number for the whole table; an archetype whose - // ladder does not contain it is reported in the full sweep instead of being - // given a count of its own. - if (headlineCount === null || !archetype.nodeCounts.includes(headlineCount)) { - excluded.push({ - archetype: archetype.id, - reason: - headlineCount === null - ? 'no single node count qualified for the headline table on this backend' - : `its ladder does not contain the headline node count (${headlineCount}); see the full sweep`, - }); - - continue; - } - - const reference = byKey.get(cellKey(REFERENCE_ENGINE, REFERENCE_CONFIG, archetype.id, headlineCount)); - const cells: ComparisonCell[] = []; - - for (const competitor of competitors) { - const competitorCell = onBackend.find( - result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.nodeCount === headlineCount, - ); + const loads = measuredLoads(archetype.id); + let published = 0; + + for (const count of loads) { + const reference = byKey.get(cellKey(REFERENCE_ENGINE, REFERENCE_CONFIG, archetype.id, count)); + const cells: ComparisonCell[] = []; + + for (const competitor of competitors) { + const competitorCell = onBackend.find( + result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.nodeCount === count, + ); + + if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { + continue; + } + + const counters = (result: CellResult): StructuralCounters | null => (result.structural.drawCalls > 0 ? result.structural : null); + const mechanism = renderingMechanism(counters(reference), counters(competitorCell)); + + cells.push({ + competitor, + referenceMs: reference.cpuMsMedian, + referenceP95Ms: reference.cpuMsP95, + referenceGpuMs: reference.frameMsMedian, + referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), + competitorMs: competitorCell.cpuMsMedian, + competitorP95Ms: competitorCell.cpuMsP95, + competitorGpuMs: competitorCell.frameMsMedian, + competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), + verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), + mechanism, + // The frame is the bracket the clock read on this backend, so the + // per-frame medians are the durations the check applies to. Each arm + // is checked against the grid of the session that measured it: the + // arms run in separate browser sessions, which need not share one. + timer: mergeTimerChecks([ + timerCheckOfRun([reference.cpuMsMedian], reference.clock?.resolutionMs ?? null), + timerCheckOfRun([competitorCell.cpuMsMedian], competitorCell.clock?.resolutionMs ?? null), + ]), + }); + } - if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { + // The mechanism rule: a row where NO competitor comparison could be + // evidenced does not enter the table. + if (cells.every(cell => cell.mechanism === null)) { continue; } - const counters = (result: CellResult): StructuralCounters | null => (result.structural.drawCalls > 0 ? result.structural : null); - const mechanism = renderingMechanism(counters(reference), counters(competitorCell)); - - cells.push({ - competitor, - referenceMs: reference.cpuMsMedian, - referenceP95Ms: reference.cpuMsP95, - referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), - competitorMs: competitorCell.cpuMsMedian, - competitorP95Ms: competitorCell.cpuMsP95, - competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), - verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), - mechanism, - }); + rows.push({ archetype: archetype.id, category: CATEGORY_TITLES[category], count, ...loadIdentity(archetype.id, count), cells }); + published += 1; } - // The mechanism rule: a row where NO competitor comparison could be - // evidenced does not enter the table. - if (cells.length === 0) { - excluded.push({ archetype: archetype.id, reason: 'no arm pair produced a comparable cell at the headline node count' }); - - continue; - } - - if (cells.every(cell => cell.mechanism === null)) { + if (published === 0) { excluded.push({ archetype: archetype.id, - reason: 'no structural mechanism could be evidenced for any arm pair (the arm reported no counters), so the row would be a number without a cause', + reason: + loads.length === 0 + ? 'not measured in this run' + : 'no arm pair produced a comparable cell with an evidenced structural mechanism at any measured load, so every row would be a number without a cause', }); - - continue; } - - rows.push({ archetype: archetype.id, category: CATEGORY_TITLES[category], count: headlineCount, cells }); } if (rows.length > 0) { @@ -354,17 +430,29 @@ const buildBackend = (backend: Backend, results: readonly CellResult[]): Backend competitor, referenceMs: reference.cpuMsMedian, referenceP95Ms: reference.cpuMsP95, + referenceGpuMs: reference.frameMsMedian, referenceOverFrameBudget: exceedsFrameBudget(reference.cpuMsMedian), competitorMs: competitorCell.cpuMsMedian, competitorP95Ms: competitorCell.cpuMsP95, + competitorGpuMs: competitorCell.frameMsMedian, competitorOverFrameBudget: exceedsFrameBudget(competitorCell.cpuMsMedian), verdict: compareMedians(reference.cpuMsMedian, competitorCell.cpuMsMedian), mechanism: null, + timer: mergeTimerChecks([ + timerCheckOfRun([reference.cpuMsMedian], reference.clock?.resolutionMs ?? null), + timerCheckOfRun([competitorCell.cpuMsMedian], competitorCell.clock?.resolutionMs ?? null), + ]), }); } if (cells.length > 0) { - webgl1.push({ archetype: archetype.id, category: CATEGORY_TITLES[archetype.category], count: headlineCount, cells }); + webgl1.push({ + archetype: archetype.id, + category: CATEGORY_TITLES[archetype.category], + count: headlineCount, + ...loadIdentity(archetype.id, headlineCount), + cells, + }); } } } @@ -398,7 +486,7 @@ export const buildRenderingComparison = (results: readonly CellResult[]): readon * suit an outcome, and that is still prevented: a row's count comes from its * ladder, and the timings only decide whether the largest rung survives. */ -export const buildPhysicsComparison = (results: readonly PhysicsCellResult[]): ComparisonSection => { +export const buildPhysicsComparison = (results: readonly PhysicsCellResult[], clock: ClockReport | null = null): ComparisonSection => { const competitors = [...new Set(results.map(result => result.spec.engine))].filter(engine => engine !== 'exojs-physics').sort(); // As in the rendering block: an archetype the run did not measure is absent // rather than empty, or every subset run would produce an empty table. @@ -407,49 +495,49 @@ export const buildPhysicsComparison = (results: readonly PhysicsCellResult[]): C const rows: ComparisonRow[] = []; for (const archetype of comparable) { - const count = chooseRowCount(archetype.bodyCounts, candidate => { - const cells = results.filter(result => result.spec.archetype === archetype.id && result.spec.bodyCount === candidate); - - // A count with NO cell for this archetype is not a valid candidate. - // Testing only "every cell is ok" would accept it, since an empty set - // satisfies that vacuously - which is how a run at one body count ended up - // choosing another and publishing nothing. - return cells.some(result => result.spec.engine === 'exojs-physics') && cells.every(result => isComparable(result)); - }); - - if (count === null) { - continue; - } + const loads = [ + ...new Set( + results.filter(result => result.spec.engine === 'exojs-physics' && result.spec.archetype === archetype.id).map(result => result.spec.bodyCount), + ), + ].sort((a, b) => a - b); + + for (const count of loads) { + const reference = results.find( + result => result.spec.engine === 'exojs-physics' && result.spec.archetype === archetype.id && result.spec.bodyCount === count, + ); + const cells: ComparisonCell[] = []; - const reference = results.find( - result => result.spec.engine === 'exojs-physics' && result.spec.archetype === archetype.id && result.spec.bodyCount === count, - ); - const cells: ComparisonCell[] = []; + for (const competitor of competitors) { + const competitorCell = results.find( + result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.bodyCount === count, + ); - for (const competitor of competitors) { - const competitorCell = results.find( - result => result.spec.engine === competitor && result.spec.archetype === archetype.id && result.spec.bodyCount === count, - ); + if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { + continue; + } - if (reference === undefined || competitorCell === undefined || !isComparable(reference) || !isComparable(competitorCell)) { - continue; + cells.push({ + competitor, + referenceMs: reference.stepMsMedian, + referenceP95Ms: reference.stepMsP95, + referenceOverFrameBudget: exceedsFrameBudget(reference.stepMsMedian), + competitorMs: competitorCell.stepMsMedian, + competitorP95Ms: competitorCell.stepMsP95, + competitorOverFrameBudget: exceedsFrameBudget(competitorCell.stepMsMedian), + verdict: compareMedians(reference.stepMsMedian, competitorCell.stepMsMedian), + mechanism: physicsMechanism(reference.structural, competitorCell.structural), + // The clock bracketed a BATCH of steps, not one step: the published + // ms/step is that bracket divided by the batch size. Checking the + // quotient would compare a derived number against a grid it was never + // read on, and would mark a well-resolved cell as unresolved purely + // because the harness batched it. + timer: timerCheckOfRun([batchMsOf(reference), batchMsOf(competitorCell)], clock?.resolutionMs ?? null), + }); } - cells.push({ - competitor, - referenceMs: reference.stepMsMedian, - referenceP95Ms: reference.stepMsP95, - referenceOverFrameBudget: exceedsFrameBudget(reference.stepMsMedian), - competitorMs: competitorCell.stepMsMedian, - competitorP95Ms: competitorCell.stepMsP95, - competitorOverFrameBudget: exceedsFrameBudget(competitorCell.stepMsMedian), - verdict: compareMedians(reference.stepMsMedian, competitorCell.stepMsMedian), - mechanism: physicsMechanism(reference.structural, competitorCell.structural), - }); - } - - if (cells.some(cell => cell.mechanism !== null)) { - rows.push({ archetype: archetype.id, category: 'Physics', count, cells }); + if (cells.some(cell => cell.mechanism !== null)) { + rows.push({ archetype: archetype.id, category: 'Physics', count, ...loadIdentity(archetype.id, count), cells }); + } } } diff --git a/packages/exojs-bench/src/physics/driver.ts b/packages/exojs-bench/src/physics/driver.ts index 24dc7405a..87f503e82 100644 --- a/packages/exojs-bench/src/physics/driver.ts +++ b/packages/exojs-bench/src/physics/driver.ts @@ -15,6 +15,7 @@ import { } from '../shared/provenance'; import type { ViteDevServer } from '../shared/viteServer'; import { PHYSICS_LIBRARY_ARMS, startViteServer as startPageServer } from '../shared/viteServer'; +import type { RunPlan } from '../suite/plan'; import { buildPhysicsMatrix, STEP_DELTA } from './archetypes'; import type { PhysicsArmReport, PhysicsClockReport } from './page/contract'; import type { PhysicsCellResult, PhysicsCellSpec } from './PhysicsAdapter'; @@ -127,6 +128,21 @@ const applyFilter = (cells: readonly PhysicsCellSpec[], filter: Partial entries.every(([key, value]) => cell[key as keyof PhysicsCellSpec] === value)); }; +/** + * Keeps only the cells one resolved suite plan selects. + * + * A pure filter, unlike the rendering side's re-emitting counterpart: physics + * seeds fold the body count in (`seedFor`), so a rung outside the archetype's + * own ladder would be a DIFFERENT world under the same name. The catalog's + * physics loads are therefore always ladder rungs, and a plan naming one that is + * not simply selects nothing for that scenario rather than inventing a scene. + */ +const applyPhysicsPlan = (cells: readonly PhysicsCellSpec[], plan: RunPlan): PhysicsCellSpec[] => { + const selected = new Set(plan.workloads.map(workload => `${workload.scenarioId}/${String(workload.value)}`)); + + return cells.filter(cell => selected.has(`${cell.archetype}/${String(cell.bodyCount)}`)); +}; + /** A cell that could not be measured: zeroed timings/structure, `unavailable` status, and an explanatory note. */ const unavailableCell = (spec: PhysicsCellSpec, note: string): PhysicsCellResult => ({ spec, @@ -261,6 +277,8 @@ export const runPhysicsMatrix = async ( * every platform, and both are part of a published profile's file name. */ platform?: PlatformDeclaration; + /** Resolved suite plan restricting the matrix to the loads it selects, applied before `filter`. */ + plan?: RunPlan; filter?: Partial; /** Forces every selected cell's timed-step count to this value (smoke/spot-check knob; never a reportable run). */ timedStepsOverride?: number; @@ -290,7 +308,8 @@ export const runPhysicsMatrix = async ( const reasons = new Map(arms.map(arm => [`${arm.engine}/${arm.config}`, arm.reason])); const allCells = buildPhysicsMatrix(arms); - const filtered = options.filter ? applyFilter(allCells, options.filter) : allCells; + const planned = options.plan ? applyPhysicsPlan(allCells, options.plan) : allCells; + const filtered = options.filter ? applyFilter(planned, options.filter) : planned; const cells = options.timedStepsOverride === undefined ? filtered : filtered.map(cell => ({ ...cell, timedSteps: options.timedStepsOverride! })); if (cells.length === 0) { diff --git a/packages/exojs-bench/src/profile/document.ts b/packages/exojs-bench/src/profile/document.ts index eb2817903..48c2a32b3 100644 --- a/packages/exojs-bench/src/profile/document.ts +++ b/packages/exojs-bench/src/profile/document.ts @@ -41,6 +41,7 @@ const toRenderingStamp = (provenance: Provenance): RenderingStamp => ({ os: provenance.os, platformVersion: { ...provenance.platformVersion }, prerelease: { ...provenance.prerelease }, + clock: provenance.clock === null ? null : { ...provenance.clock }, flags: [...provenance.flags], headless: provenance.headless, software: provenance.software, diff --git a/packages/exojs-bench/src/profile/schema.ts b/packages/exojs-bench/src/profile/schema.ts index be3352803..811cd3fd2 100644 --- a/packages/exojs-bench/src/profile/schema.ts +++ b/packages/exojs-bench/src/profile/schema.ts @@ -1,6 +1,7 @@ import type { AggregatedBackendComparison, AggregatedSection } from '../comparison/pooled'; import type { PhysicsClockReport } from '../physics/page/contract'; import type { Backend } from '../rendering/EngineAdapter'; +import type { ClockReport } from '../shared/clock'; import type { PlatformVersionStamp, PrereleaseStamp, RenderingBrowser } from '../shared/provenance'; /** @@ -29,7 +30,7 @@ import type { PlatformVersionStamp, PrereleaseStamp, RenderingBrowser } from '.. */ /** Schema version `bench:compare` stamps into a new document. */ -export const BENCH_PROFILE_SCHEMA_VERSION = 6; +export const BENCH_PROFILE_SCHEMA_VERSION = 7; /** * Schema versions a reader accepts. A document carrying anything else is @@ -53,8 +54,19 @@ export const BENCH_PROFILE_SCHEMA_VERSION = 6; * shared body count; its physics numbers were additionally taken on ladders that * have since moved, and because the per-cell seed folds the body count in, a * moved rung is a different scene rather than the same one measured again. + * + * Version 6 is still read, and every figure it publishes still means what it + * meant. Version 7 adds four things beside those figures rather than changing + * any of them: the GPU frame time next to each arm's CPU time; the load each row + * was measured at, with the unit it is counted in, so an archetype measured at + * several loads publishes a row per load instead of one row at a single + * table-wide count; what the measuring page's clock resolved to, recorded per + * rendering stamp; and, per published comparison, what the timer check made of + * the two durations behind it. A version 6 document carries none of those, so a + * reader offers one load per row and states that the clock was not recorded, + * rather than inferring either. */ -export const SUPPORTED_BENCH_PROFILE_SCHEMA_VERSIONS: readonly number[] = [BENCH_PROFILE_SCHEMA_VERSION]; +export const SUPPORTED_BENCH_PROFILE_SCHEMA_VERSIONS: readonly number[] = [6, BENCH_PROFILE_SCHEMA_VERSION]; /** Characters a slug may be built from. */ const SLUG_CHARACTERS = /^[a-z0-9-]+$/; @@ -198,6 +210,16 @@ export interface RenderingStamp { readonly software: boolean; /** Resolved WebGPU sprite-batch texture-slot tier; absent for a backend that negotiates none. */ readonly slotTier?: number; + /** + * What the clock of this backend's first measuring page resolved to, or `null` + * where no session opened. + * + * Provenance for the reader: a run opens one browser session per arm, so this + * describes the conditions rather than qualifying any one comparison. What + * qualifies a comparison is the per-cell check the published model carries, + * which was taken against the grid of the session that actually timed it. + */ + readonly clock: ClockReport | null; /** Engine version under test. */ readonly engineVersion: string; /** ISO-8601 timestamp of the run. */ diff --git a/packages/exojs-bench/src/rendering/EngineAdapter.ts b/packages/exojs-bench/src/rendering/EngineAdapter.ts index 6fbf67281..2b4ec2a63 100644 --- a/packages/exojs-bench/src/rendering/EngineAdapter.ts +++ b/packages/exojs-bench/src/rendering/EngineAdapter.ts @@ -1,3 +1,4 @@ +import type { ClockReport } from '../shared/clock'; import type { BaseCellResult } from '../shared/result'; /** Rendering backend under test. */ @@ -7,8 +8,14 @@ export type Backend = 'webgl2' | 'webgpu'; export type ArchetypeId = | 'static-heavy' | 'dynamic-heavy' + | 'dynamic-all' | 'deep-hierarchy' | 'overdraw' + | 'fill-layers' + | 'tilemap-scroll' + | 'tilemap-edit' + | 'particles-draw' + | 'particles-lifecycle' | 'batch-breaking' | 'batch-breaking-atlased' | 'split-screen' @@ -35,7 +42,8 @@ export type ArchetypeId = * several archetypes into one number, and any average over them hides the worst * cell. Nothing in the report aggregates across archetypes. */ -export type ArchetypeCategory = 'node-scaling' | 'fill-and-state' | 'material-variety' | 'text' | 'render-targets' | 'camera-and-world' | 'submission'; +export type ArchetypeCategory = + 'node-scaling' | 'fill-and-state' | 'material-variety' | 'text' | 'render-targets' | 'camera-and-world' | 'submission' | 'tilemaps' | 'particles'; /** Structural definition of a scene archetype, independent of any engine or backend. */ export interface ArchetypeSpec { @@ -237,6 +245,47 @@ export interface ArchetypeSpec { * to them. */ readonly churn?: boolean; + /** + * When `true`, every leaf is stretched to the whole viewport and stacked at + * the origin, so the scene's cost is fill rather than node count. + * + * Read through {@link '../rendering/traits'.hasFullViewportLeaves} rather than + * by testing the archetype id in each arm, so every arm lays the scene out the + * same way. + */ + readonly fullViewportLeaves?: boolean; + /** + * Alpha every leaf carries, or `undefined` for opaque leaves. + * + * Meaningful together with {@link fullViewportLeaves}: a stack of + * viewport-sized quads is a blend workload only while each of them is + * translucent. Opaque, the cost depends on whatever occlusion policy each arm + * happens to have, which is a different comparison. + */ + readonly leafAlpha?: number; + /** + * Renders a tilemap instead of a sprite scene, and whether the scene also + * edits tiles. + * + * `'scroll'` scrolls a fully-populated map past a fixed viewport; `'edit'` does + * the same and additionally replaces a fixed number of VISIBLE tile ids every + * frame, so the arm has to submit the change before it draws. The node count is + * the map's total tile count, not its visible one - a large world with a small + * window is the point, and `tilemap.ts` maps the count onto the map's + * dimensions. + */ + readonly tilemap?: 'scroll' | 'edit'; + /** + * Renders particles instead of a sprite scene, and whether the scene also + * simulates them. + * + * `'draw'` submits a fixed set of quads through the arm's particle draw path + * and advances nothing; `'lifecycle'` runs a steady effect - ageing, movement, + * fading, respawning - on top of that same path. The two answer different + * questions and a figure from one says nothing about the other, which is why + * they are separate archetypes. See `particles.ts` for the shared scene. + */ + readonly particles?: 'draw' | 'lifecycle'; /** * Number of chained post-process filters applied to the scene root, or * `undefined` for the unfiltered scene every other archetype builds. @@ -356,6 +405,16 @@ export interface CellResult extends BaseCellResult { readonly queueMsP95: number | null; /** Structural draw-call counters gathered while measuring this cell. */ readonly structural: StructuralCounters; + /** + * What the clock of the page this cell was measured in resolved to, or `null` + * where no page produced the cell. + * + * Recorded per cell rather than per backend because a rendering run opens one + * browser session per arm: a grid read from whichever page happened to be open + * would qualify cells it never timed. The comparison builder checks each + * measured duration against the grid it was actually read on. + */ + readonly clock: ClockReport | null; } /** Neutral contract an engine arm implements so the harness can drive it identically across arms. */ diff --git a/packages/exojs-bench/src/rendering/adapters/excalibur.ts b/packages/exojs-bench/src/rendering/adapters/excalibur.ts index 3b4564013..fb0e08f0f 100644 --- a/packages/exojs-bench/src/rendering/adapters/excalibur.ts +++ b/packages/exojs-bench/src/rendering/adapters/excalibur.ts @@ -3,7 +3,8 @@ import * as ex from 'excalibur'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; import { createDigitAtlasCanvas, createDistinctTextureCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, DIGIT_CELL_WIDTH, TEXT_FONT_SIZE } from '../sceneAssets'; -import { isChurning, isTextArchetype, isTextUpdating, textForLeaf, usesRenderTargets } from '../traits'; +import { isTilemap } from '../tilemap'; +import { hasFullViewportLeaves, isChurning, isTextArchetype, isTextUpdating, leafAlpha, textForLeaf, usesRenderTargets } from '../traits'; import { GRID_MARGIN, gridLayout, gridPosition, isScrolling, SPRITE_SIZE, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from '../world'; /** @@ -127,7 +128,15 @@ export const createExcaliburAdapter = (): EngineAdapter => { // equivalent API at all: its `PostProcessor` chain is a full-SCREEN pass // rather than a filtered subtree, and it ships no mask source, so those // cells could only be approximated - which the fairness rule forbids. - return !isScrolling(spec) && !usesRenderTargets(spec); + // + // The tilemap archetypes are sat out for a capability reason rather than a + // policy one. Excalibur 0.32's `TileMap` is a grid of `Tile`s each holding + // its own graphics list, drawn through the ordinary graphics path - there + // is no dedicated tile submission path of the kind the other three arms + // are being compared on, so its cell would answer a different question + // than the row it sat in. Building one here would mean writing the arm's + // missing feature rather than adapting to it. + return !isScrolling(spec) && !usesRenderTargets(spec) && !isTilemap(spec); }, async init(canvas: HTMLCanvasElement, target: Backend): Promise { @@ -192,7 +201,8 @@ export const createExcaliburAdapter = (): EngineAdapter => { // the position `world.ts` computes, so a change to the layout cannot move // one arm's scene without moving every arm's. const layout = gridLayout(nodeCount, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Shared, canonical mutation selection - the SAME helper every arm routes // through, so all arms select the byte-for-byte identical index set and the @@ -251,6 +261,12 @@ export const createExcaliburAdapter = (): EngineAdapter => { sprite.destSize = { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }; } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + sprite.opacity = alpha; + } + actor.graphics.use(sprite); return { actor, text: null }; diff --git a/packages/exojs-bench/src/rendering/adapters/exojs.ts b/packages/exojs-bench/src/rendering/adapters/exojs.ts index e6a02521f..db14eaacd 100644 --- a/packages/exojs-bench/src/rendering/adapters/exojs.ts +++ b/packages/exojs-bench/src/rendering/adapters/exojs.ts @@ -1,5 +1,9 @@ +import { AlphaFadeOverLifetime, Curve, particlesExtension, ParticleSystem } from '@codexo/exojs-particles'; +import { TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, TileMapNode, TileSet } from '@codexo/exojs-tilemap'; + import { Application } from '#core/Application'; import { Color } from '#core/Color'; +import type { Seconds } from '#core/units'; import { Matrix } from '#math/Matrix'; import { Rectangle } from '#math/Rectangle'; import { CallbackRenderPass } from '#rendering/CallbackRenderPass'; @@ -22,14 +26,48 @@ import { Sprite } from '#rendering/sprite/Sprite'; import { Text } from '#rendering/text/Text'; import { RenderTexture } from '#rendering/texture/RenderTexture'; import { Texture } from '#rendering/texture/Texture'; +import { TextureRegion } from '#rendering/texture/TextureRegion'; import { BlendModes } from '#rendering/types'; import { View } from '#rendering/View'; import type { WebGpuBackend } from '#rendering/webgpu/WebGpuBackend'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDistinctTextureCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; -import { compositeBlurRadius, filterChainDepth, hasMaskMotion, isChurning, isTextArchetype, isTextUpdating, maskDepth, textForLeaf } from '../traits'; +import { + isParticleLifecycle, + isParticles, + PARTICLE_ALPHA, + PARTICLE_LIFETIME, + PARTICLE_PREROLL_STEPS, + PARTICLE_STEP, + PARTICLE_TINT, + particleSeedAt, +} from '../particles'; +import { createDistinctTextureCanvas, createParticleCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import type { TilemapExtent } from '../tilemap'; +import { + isTilemap, + isTilemapEditing, + TILE_SIZE, + TILE_VARIANTS, + tileIdAt, + tilemapCameraAt, + tilemapCameraFrameFor, + tilemapEditsAt, + tilemapExtent, +} from '../tilemap'; +import { + compositeBlurRadius, + filterChainDepth, + hasFullViewportLeaves, + hasMaskMotion, + isChurning, + isTextArchetype, + isTextUpdating, + leafAlpha, + maskDepth, + textForLeaf, +} from '../traits'; import { BLOOM_DOWNSCALE, cameraCenterAt, @@ -456,6 +494,199 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E views = []; }; + /** The tile layer the tilemap scenes paint into, or `null` for every other archetype. */ + let tileLayer: TileLayer | null = null; + + /** The tileset the tile layer draws from, kept so teardown releases its texture. */ + let tileTexture: Texture | null = null; + + /** The tilemap scene's root node, and the map extent its camera is bounded by. */ + let tilemapNode: TileMapNode | null = null; + let tilemapSpec: ArchetypeSpec | null = null; + let tilemapMapExtent: TilemapExtent = { width: 0, height: 0 }; + + /** + * Build the tilemap scene: one fully-populated layer over a single-page + * tileset, rendered through the package's own chunk renderer. + * + * Every tile is written at build time, outside the timed window - the scenes + * compare drawing and editing a populated map, not populating one. The camera + * is the View's, as it is for `scrolling-world`: the engine has a real camera, + * and its rect is what the chunk culling and the retained products are keyed + * on. + */ + const buildTilemapScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const extent = tilemapExtent(nodeCount); + const texture = new Texture(createTileAtlasCanvas()); + const tileset = new TileSet({ + name: 'tiles', + texture: new TextureRegion(texture, { x: 0, y: 0, width: TILE_SIZE * TILE_VARIANTS, height: TILE_SIZE }), + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + tileCount: TILE_VARIANTS, + }); + const layer = new TileLayer({ + id: 1, + name: 'ground', + width: extent.width, + height: extent.height, + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + tilesets: [tileset], + }); + + for (let y = 0; y < extent.height; y += 1) { + for (let x = 0; x < extent.width; x += 1) { + layer.setTileAt(x, y, { tileset, localTileId: tileIdAt(x, y), transform: TILE_TRANSFORM_IDENTITY }); + } + } + + const map = new TileMap({ + name: 'benchmark', + width: extent.width, + height: extent.height, + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + tilesets: [tileset], + layers: [layer], + }); + + root = new Container(); + tilemapNode = new TileMapNode(map); + root.addChild(tilemapNode); + + tileLayer = layer; + tileTexture = texture; + tilemapSpec = spec; + tilemapMapExtent = extent; + + const start = tilemapCameraAt(0, extent); + + app!.rendering.view.setCenter(start.x + VIEWPORT_WIDTH / 2, start.y + VIEWPORT_HEIGHT / 2); + }; + + /** Drop the tilemap scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseTilemap = (): void => { + tilemapNode?.destroy(); + tilemapNode = null; + tileTexture?.destroy(); + tileTexture = null; + tileLayer = null; + tilemapSpec = null; + }; + + /** The particle system the particle scenes draw, or `null` for every other archetype. */ + let particleSystem: ParticleSystem | null = null; + let particleTexture: Texture | null = null; + let particleSpec: ArchetypeSpec | null = null; + + /** + * Fill the system up to its capacity, giving each particle the shared scene's + * deterministic state. + * + * `emit()` returns `null` once the pool is full, which is what holds the live + * count at the node count: the scene tops the pool up every frame rather than + * spawning at a rate and hoping the two balance out. + */ + const fillParticles = (count: number, initial: boolean): void => { + for (let filled = 0; filled < count; filled += 1) { + const particle = particleSystem!.emit(); + + if (particle === null) { + return; + } + + // The cursor walks the whole seed set rather than restarting at zero, so a + // respawn lands on the next unused layout instead of piling every + // replacement onto the same handful of positions - which is what turned + // the pool into a few dense clusters while the arms beside it stayed + // evenly spread. + const index = particleCursor % count; + + particleCursor += 1; + + const seed = particleSeedAt(index, count); + + particle.position.set(seed.x, seed.y); + particle.velocity.set(seed.velocityX, seed.velocityY); + // The sprite is already PARTICLE_SIZE square, and `scale` is a factor: + // setting it to the size would draw a quad four times too large. + particle.scale.set(1, 1); + particle.color = PARTICLE_TINT; + if (particleLifetime === null) { + // The draw-only scene never advances, so its particles must not expire: + // a finite life would shrink the pool over a long cell for no reason the + // scene is measuring. + particle.lifetime = Number.MAX_SAFE_INTEGER; + } else { + // On the first fill each particle gets what is LEFT of one lifetime, so + // the pool starts evenly aged and its respawns land on different frames + // instead of arriving as one burst. + particle.lifetime = initial ? Math.max(PARTICLE_STEP, particleLifetime - seed.age) : particleLifetime; + } + } + }; + + /** Next seed index a spawn takes; see {@link fillParticles}. */ + let particleCursor = 0; + + /** Seconds a particle lives, or `null` in the draw-only scene, which never ages one. */ + let particleLifetime: number | null = null; + + /** + * Build the particle scene: a system at the node count's capacity, filled + * once, and - for the lifecycle scene - advanced through one full lifetime so + * the timed window sees a steady pool rather than a settling one. + */ + const buildParticleScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const texture = new Texture(createParticleCanvas()); + const system = new ParticleSystem(texture, { capacity: nodeCount }); + + particleSystem = system; + particleTexture = texture; + particleSpec = spec; + particleLifetime = isParticleLifecycle(spec) ? PARTICLE_LIFETIME : null; + particleCursor = 0; + + system.setBlendMode(BlendModes.Normal); + + if (particleLifetime !== null) { + // Linear fade over the life, the shared scene's one update rule. Every arm + // applies the same one, so no arm is paying for an effect the others skip. + // From the scene's alpha to zero. The module's default curve starts at 1, + // which would make this arm's particles twice as bright as every other + // arm's for the whole of their lives. + system.addUpdateModule( + new AlphaFadeOverLifetime( + new Curve([ + { t: 0, v: PARTICLE_ALPHA }, + { t: 1, v: 0 }, + ]), + ), + ); + } + + root = new Container(); + root.addChild(system); + + fillParticles(nodeCount, true); + + for (let step = 0; step < (particleLifetime === null ? 0 : PARTICLE_PREROLL_STEPS); step += 1) { + system.update(PARTICLE_STEP as Seconds); + fillParticles(nodeCount, false); + } + }; + + /** Drop the particle scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseParticles = (): void => { + particleSystem?.destroy(); + particleSystem = null; + particleTexture?.destroy(); + particleTexture = null; + particleSpec = null; + particleLifetime = null; + }; + return { engine: 'exojs', config, @@ -480,6 +711,11 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E backend: { type: backend }, clearColor: Color.black, hello: false, + // Registered for every cell, not only the tilemap ones: the extension + // contributes renderer bindings rather than scene work, and an engine + // configured differently per archetype would make two archetypes' + // numbers describe two engines. + extensions: [tilemapExtension, particlesExtension], }); // Boot the full production init path (awaits the backend's async @@ -506,9 +742,27 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E releaseBatchScene(); releaseComposite(); + releaseTilemap(); + releaseParticles(); sharedMeshGeometry?.destroy(); sharedMeshGeometry = null; + // The tilemap scenes leave the sprite path behind: the leaves are tiles in + // a packed layer rather than nodes, so nothing below applies to them. + if (isTilemap(spec)) { + buildTilemapScene(spec, nodeCount); + + return; + } + + // The particle scenes leave the sprite path behind too: their leaves live + // in the system's own storage rather than in the scene graph. + if (isParticles(spec)) { + buildParticleScene(spec, nodeCount); + + return; + } + // `instanced-batch` leaves the scene graph behind entirely: nodeCount // instances are laid out on the same grid every other archetype uses, but // submitted as ceil(nodeCount / batchSize) explicit drawBatch calls over one @@ -570,7 +824,8 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E // the size of the viewport, i.e. the pre-existing layout unchanged. const world = worldExtent(spec, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); const layout = gridLayout(nodeCount, world.width, world.height, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Canonical, shared mutation selection: draw one RNG value per leaf in // index order and select when below `mutationFraction`. Using the shared @@ -670,6 +925,12 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E leaf.height = VIEWPORT_HEIGHT; } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + leaf.tint.a = alpha; + } + const { x, y } = leafPosition(i); leaf.setPosition(x, y); @@ -767,6 +1028,39 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E }, mutate(frame: number): void { + // Particle scenes: the lifecycle one advances the simulation and tops the + // pool back up, both inside the bracket, because ageing, moving, fading and + // respawning ARE the per-frame work it measures. The draw-only scene + // advances nothing - it submits the same quads every frame by design. + if (particleSpec !== null && particleSystem !== null) { + if (particleLifetime !== null) { + particleSystem.update(PARTICLE_STEP as Seconds); + fillParticles(particleSystem.capacity, false); + } + + return; + } + + // Tilemap scenes: move the window, then submit this frame's tile changes. + // Both belong in the bracket - scrolling and editing ARE the per-frame work + // these scenes do, and an edit an arm defers past the draw would not be an + // edit the frame paid for. + if (tilemapSpec !== null && app !== null && tileLayer !== null) { + const camera = tilemapCameraAt(tilemapCameraFrameFor(tilemapSpec, frame), tilemapMapExtent); + + app.rendering.view.setCenter(camera.x + VIEWPORT_WIDTH / 2, camera.y + VIEWPORT_HEIGHT / 2); + + if (isTilemapEditing(tilemapSpec)) { + const tileset = tileLayer.tilesets[0]!; + + for (const edit of tilemapEditsAt(frame, tilemapMapExtent)) { + tileLayer.setTileAt(edit.x, edit.y, { tileset, localTileId: edit.tileId, transform: TILE_TRANSFORM_IDENTITY }); + } + } + + return; + } + // Camera step for a scrolling archetype. Both this and the wobble below // run inside the harness's CPU bracket, which is correct: moving the // camera IS the per-frame work such a scene does. @@ -899,6 +1193,8 @@ export const createExoJsAdapter = (backendFilter?: readonly Backend[], config: E teardown(): void { releaseBatchScene(); releaseComposite(); + releaseTilemap(); + releaseParticles(); if (root !== null) { root.destroy(); diff --git a/packages/exojs-bench/src/rendering/adapters/phaser.ts b/packages/exojs-bench/src/rendering/adapters/phaser.ts index c9ac89a2e..5a054ea94 100644 --- a/packages/exojs-bench/src/rendering/adapters/phaser.ts +++ b/packages/exojs-bench/src/rendering/adapters/phaser.ts @@ -2,8 +2,20 @@ import * as Phaser from 'phaser'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDigitAtlasCanvas, createDistinctTextureCanvas, DIGIT_ALPHABET, DIGIT_CELL_HEIGHT, DIGIT_CELL_WIDTH, TEXT_FONT_SIZE } from '../sceneAssets'; -import { isChurning, isTextArchetype, isTextUpdating, textForLeaf, usesRenderTargets } from '../traits'; +import { isParticleLifecycle, isParticles, PARTICLE_ALPHA, PARTICLE_LIFETIME, PARTICLE_STEP, particleSeedAt } from '../particles'; +import { + createDigitAtlasCanvas, + createDistinctTextureCanvas, + createParticleCanvas, + createTileAtlasCanvas, + DIGIT_ALPHABET, + DIGIT_CELL_HEIGHT, + DIGIT_CELL_WIDTH, + TEXT_FONT_SIZE, +} from '../sceneAssets'; +import type { TilemapExtent } from '../tilemap'; +import { isTilemap, isTilemapEditing, TILE_SIZE, tileIdAt, tilemapCameraAt, tilemapCameraFrameFor, tilemapEditsAt, tilemapExtent } from '../tilemap'; +import { hasFullViewportLeaves, isChurning, isTextArchetype, isTextUpdating, leafAlpha, textForLeaf, usesRenderTargets } from '../traits'; import { GRID_MARGIN, gridLayout, gridPosition, isScrolling, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from '../world'; /** @@ -112,6 +124,158 @@ export const createPhaserAdapter = (): EngineAdapter => { /** Characters per text leaf of the built archetype; `0` when it has no text. */ let textGlyphs = 0; + /** The tilemap scene's layer and map, or `null` for every other archetype. */ + let tileLayer: Phaser.Tilemaps.TilemapGPULayer | null = null; + let tileMap: Phaser.Tilemaps.Tilemap | null = null; + let tilemapSpec: ArchetypeSpec | null = null; + let tileExtent: TilemapExtent = { width: 0, height: 0 }; + + /** + * Build the tilemap scene on Phaser's GPU tile layer. + * + * The GPU layer is the arm's fastest path for exactly this shape of work - one + * tileset, one orthographic grid, no per-tile game objects - and it is what a + * Phaser project would use here, so it is what the comparison measures. It + * renders the whole layer as a single quad over a data texture, which is why + * an edit has to be followed by regenerating that texture rather than being + * picked up on its own. + */ + const buildTilemapScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const extent = tilemapExtent(nodeCount); + const key = `${SCENE_KEY}-tiles`; + + if (game!.textures.exists(key)) { + game!.textures.remove(key); + } + + game!.textures.addCanvas(key, createTileAtlasCanvas()); + + const data: number[][] = []; + + for (let y = 0; y < extent.height; y += 1) { + const row = new Array(extent.width); + + for (let x = 0; x < extent.width; x += 1) { + row[x] = tileIdAt(x, y); + } + + data.push(row); + } + + const map = scene!.make.tilemap({ data, tileWidth: TILE_SIZE, tileHeight: TILE_SIZE }); + const tileset = map.addTilesetImage('tiles', key, TILE_SIZE, TILE_SIZE, 0, 0)!; + const layer = map.createLayer(0, tileset, 0, 0, true) as Phaser.Tilemaps.TilemapGPULayer; + + tileMap = map; + tileLayer = layer; + tilemapSpec = spec; + tileExtent = extent; + + const camera = tilemapCameraAt(0, extent); + + scene!.cameras.main.setScroll(camera.x, camera.y); + }; + + /** Drop the tilemap scene so a rebuild (or teardown) leaks nothing. */ + const releaseTilemap = (): void => { + tileLayer?.destroy(); + tileMap?.destroy(); + tileLayer = null; + tileMap = null; + tilemapSpec = null; + }; + + /** The particle scene's emitter, or `null` for every other archetype. */ + let particleEmitter: Phaser.GameObjects.Particles.ParticleEmitter | null = null; + let particleSpec: ArchetypeSpec | null = null; + /** Milliseconds of emitter time already simulated, so `preUpdate` gets a monotonic clock. */ + let particleClockMs = 0; + + /** + * Build the particle scene on Phaser's own emitter. + * + * The draw-only scene emits the whole pool at once and then never steps the + * emitter: the harness drives rendering alone, so an unstepped emitter holds + * its particles exactly where they were put - which is the resting simulation + * this scene asks every arm for. Their positions are overwritten from the + * shared layout, so this arm draws the identical picture rather than its own + * random spread. + * + * The lifecycle scene keeps Phaser's emitter doing the simulating, configured + * to the shared contract: a two second life, a steady rate that holds the pool + * at the node count, a linear drift and a linear fade. Its particles do not + * land on the same coordinates as the other arms' - the emitter draws its own + * randoms - and they are not supposed to: the comparison is of equal work, not + * of identical pixels, and forcing the positions would replace the emitter + * under test with harness code. + */ + const buildParticleScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const key = `${SCENE_KEY}-particle`; + + if (game!.textures.exists(key)) { + game!.textures.remove(key); + } + + game!.textures.addCanvas(key, createParticleCanvas()); + + particleClockMs = 0; + + if (isParticleLifecycle(spec)) { + const emitter = scene!.add.particles(0, 0, key, { + lifespan: PARTICLE_LIFETIME * 1000, + speed: { min: 20, max: 60 }, + angle: { min: 0, max: 360 }, + alpha: { start: PARTICLE_ALPHA, end: 0 }, + // One emission per frame of the share of the pool that expires in it, so + // the live count holds at the node count instead of oscillating. + frequency: 0, + quantity: Math.max(1, Math.round(nodeCount / (PARTICLE_LIFETIME / PARTICLE_STEP))), + maxAliveParticles: nodeCount, + // Spread over the viewport through the emitter's own per-particle x/y + // ranges, so the pool fills the frame the way every other arm's does. + x: { min: 0, max: VIEWPORT_WIDTH }, + y: { min: 0, max: VIEWPORT_HEIGHT }, + }); + + // One full lifetime before the timed window, like every other arm's + // preroll: the pool reaches its steady state outside the measurement. + emitter.fastForward(PARTICLE_LIFETIME * 1000, PARTICLE_STEP * 1000); + particleClockMs = PARTICLE_LIFETIME * 1000; + particleEmitter = emitter; + } else { + const emitter = scene!.add.particles(0, 0, key, { + lifespan: Number.MAX_SAFE_INTEGER, + speed: 0, + alpha: PARTICLE_ALPHA, + emitting: false, + maxAliveParticles: nodeCount, + }); + + emitter.explode(nodeCount); + + let index = 0; + + emitter.forEachAlive(particle => { + const seed = particleSeedAt(index, nodeCount); + + index += 1; + particle.x = seed.x; + particle.y = seed.y; + }, null); + + particleEmitter = emitter; + } + + particleSpec = spec; + }; + + /** Drop the particle scene so a rebuild (or teardown) leaks nothing. */ + const releaseParticles = (): void => { + particleEmitter?.destroy(); + particleEmitter = null; + particleSpec = null; + }; + return { engine: 'phaser', config: 'webgl2', @@ -188,6 +352,23 @@ export const createPhaserAdapter = (): EngineAdapter => { throw new Error('buildScene was called before init.'); } + releaseTilemap(); + releaseParticles(); + + if (isParticles(spec)) { + buildParticleScene(spec, nodeCount); + + return; + } + + // The tilemap scenes leave the sprite path behind: the leaves are tiles in + // a layer rather than game objects, so nothing below applies to them. + if (isTilemap(spec)) { + buildTilemapScene(spec, nodeCount); + + return; + } + const textures = game.textures; textureKeys = []; @@ -227,7 +408,8 @@ export const createPhaserAdapter = (): EngineAdapter => { // the position `world.ts` computes, so a change to the layout cannot move // one arm's scene without moving every arm's. const layout = gridLayout(nodeCount, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Shared, canonical mutation selection - the SAME helper every arm routes // through, so all arms select the byte-for-byte identical index set and the @@ -283,6 +465,12 @@ export const createPhaserAdapter = (): EngineAdapter => { sprite.setDisplaySize(VIEWPORT_WIDTH, VIEWPORT_HEIGHT); } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + sprite.setAlpha(alpha); + } + sprite.setPosition(x, y); return sprite; @@ -316,6 +504,39 @@ export const createPhaserAdapter = (): EngineAdapter => { }, mutate(frame: number): void { + // Particle scenes: the lifecycle one steps Phaser's emitter, which is the + // simulation under comparison; the draw-only one steps nothing, so its + // particles stay where the build put them. + if (particleSpec !== null && particleEmitter !== null) { + if (isParticleLifecycle(particleSpec)) { + particleClockMs += PARTICLE_STEP * 1000; + particleEmitter.preUpdate(particleClockMs, PARTICLE_STEP * 1000); + } + + return; + } + + // Tilemap scenes: scroll the camera, then submit this frame's tile changes. + // The GPU layer reads its tiles from a data texture that does not follow a + // tile write on its own, so regenerating that texture is what actually + // submits the edit - and it belongs in the bracket for the same reason the + // other arms' repacking does. + if (tilemapSpec !== null && tileLayer !== null && tileMap !== null && scene !== null) { + const camera = tilemapCameraAt(tilemapCameraFrameFor(tilemapSpec, frame), tileExtent); + + scene.cameras.main.setScroll(camera.x, camera.y); + + if (isTilemapEditing(tilemapSpec)) { + for (const edit of tilemapEditsAt(frame, tileExtent)) { + tileMap.putTileAt(edit.tileId, edit.x, edit.y, false, tileLayer as unknown as Phaser.Tilemaps.TilemapLayer); + } + + tileLayer.generateLayerDataTexture(); + } + + return; + } + // Structural churn: destroy each selected leaf and build its replacement in // the same place. Phaser's `destroy` removes the object from its parent // container itself, so nothing detaches it first. @@ -353,7 +574,7 @@ export const createPhaserAdapter = (): EngineAdapter => { }, renderFrame(): void { - if (game === null || root === null) { + if (game === null || (root === null && tileLayer === null && particleEmitter === null)) { throw new Error('renderFrame was called before buildScene.'); } @@ -369,6 +590,9 @@ export const createPhaserAdapter = (): EngineAdapter => { }, teardown(): void { + releaseTilemap(); + releaseParticles(); + if (game !== null) { // `destroy` only FLAGS pending destruction (normally consumed by the next // game step); since the loop is stopped, drive one explicit `step` - which diff --git a/packages/exojs-bench/src/rendering/adapters/pixi.ts b/packages/exojs-bench/src/rendering/adapters/pixi.ts index ff74f78a4..44e463c76 100644 --- a/packages/exojs-bench/src/rendering/adapters/pixi.ts +++ b/packages/exojs-bench/src/rendering/adapters/pixi.ts @@ -1,3 +1,4 @@ +import { settings as tilemapSettings, Tilemap } from '@pixi/tilemap'; import { Application, BitmapText, @@ -7,17 +8,45 @@ import { Culler, type Filter, Graphics, + Particle, + ParticleContainer, + Rectangle, RendererType, RenderTexture, Sprite, Texture, + type TextureSource, type WebGPURenderer, } from 'pixi.js'; import { mutationSignature, selectMutationIndices, wobbleOffsetAt } from '../../shared/mutation'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../EngineAdapter'; -import { createDistinctTextureCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; -import { compositeBlurRadius, filterChainDepth, hasMaskMotion, isChurning, isTextArchetype, isTextUpdating, maskDepth, textForLeaf } from '../traits'; +import { isParticleLifecycle, isParticles, PARTICLE_ALPHA, PARTICLE_LIFETIME, PARTICLE_PREROLL_STEPS, PARTICLE_STEP, particleSeedAt } from '../particles'; +import { createDistinctTextureCanvas, createParticleCanvas, createTileAtlasCanvas, TEXT_FONT_SIZE } from '../sceneAssets'; +import type { TilemapExtent } from '../tilemap'; +import { + isTilemap, + isTilemapEditing, + TILE_SIZE, + TILE_VARIANTS, + tileIdAt, + tilemapCameraAt, + tilemapCameraFrameFor, + tilemapEditsAt, + tilemapExtent, +} from '../tilemap'; +import { + compositeBlurRadius, + filterChainDepth, + hasFullViewportLeaves, + hasMaskMotion, + isChurning, + isTextArchetype, + isTextUpdating, + leafAlpha, + maskDepth, + textForLeaf, +} from '../traits'; import { BLOOM_DOWNSCALE, cameraCenterAt, @@ -206,6 +235,292 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine bloom = null; }; + /** + * Tiles per side of one painted chunk. + * + * Pixi's `Tilemap` is an imperatively painted quad buffer with no culling of + * its own, so a single map-wide instance would submit every world tile every + * frame. Chunking it is the documented way to draw a large map, and 32 matches + * the chunk size the ExoJS tile renderer uses by default, so neither arm draws + * a visible window cut into a different number of pieces. + */ + const TILE_CHUNK = 32; + + /** One painted chunk of the tilemap scene. */ + interface TileChunk { + tilemap: Tilemap; + readonly originX: number; + readonly originY: number; + readonly width: number; + readonly height: number; + } + + let tileChunks: TileChunk[] = []; + let tileTextures: Texture[] = []; + let tileWorld: Container | null = null; + /** The rendered root of the tilemap scene; it stays at identity, see {@link buildTilemapScene}. */ + let tileRoot: Container | null = null; + let tilemapSpec: ArchetypeSpec | null = null; + let tileExtent: TilemapExtent = { width: 0, height: 0 }; + /** Tile ids as this arm currently holds them, so an edited chunk can be repainted from one source. */ + let tileIds: Uint8Array = new Uint8Array(0); + /** The tile atlas every chunk draws from, kept so a rebuilt chunk gets the identical tileset. */ + let tileAtlas: TextureSource | null = null; + + /** Paint one chunk from {@link tileIds} into its (empty) tilemap. */ + const paintChunk = (chunk: TileChunk): void => { + for (let y = 0; y < chunk.height; y += 1) { + for (let x = 0; x < chunk.width; x += 1) { + const worldX = chunk.originX + x; + const worldY = chunk.originY + y; + + chunk.tilemap.tile(tileTextures[tileIds[worldY * tileExtent.width + worldX]!]!, x * TILE_SIZE, y * TILE_SIZE); + } + } + }; + + /** + * Rebuild one chunk after its tile data changed. + * + * A fresh `Tilemap` rather than `clear()` on the existing one: measured, the + * second `clear()` + repaint of one instance renders nothing at all, so a + * scene that edits the same chunk on consecutive frames goes blank. Replacing + * the instance is also the honest shape of this API's edit cost - it offers no + * per-tile update, so a changed tile means rebuilding the chunk's quad buffer + * either way. + */ + const repaintChunk = (index: number): void => { + const chunk = tileChunks[index]!; + const replacement = new Tilemap(tileAtlas!); + + replacement.position.set(chunk.originX * TILE_SIZE, chunk.originY * TILE_SIZE); + replacement.visible = chunk.tilemap.visible; + + tileWorld?.addChild(replacement); + chunk.tilemap.destroy(); + chunk.tilemap = replacement; + + paintChunk(chunk); + }; + + /** + * Build the tilemap scene: one painted `Tilemap` per chunk, all parented and + * shown or hidden per frame by the visible window. + * + * Painting happens once, at build time and outside the timed window, as it does + * on every other arm. What the timed window sees is the scroll - and, in the + * editing scene, the repaint an edited chunk needs, which is the real cost of + * this API: `Tilemap` exposes no per-tile update, so a changed tile means + * rebuilding the quad buffer of the chunk that holds it. + */ + const buildTilemapScene = (spec: ArchetypeSpec, nodeCount: number): void => { + // Lifts the 16-bit index ceiling, which would otherwise cap a single painted + // buffer at about 16k tiles. The chunking above keeps every buffer far below + // that, so this only removes a limit from the comparison rather than + // changing how the arm draws. + tilemapSettings.use32bitIndex = true; + + const extent = tilemapExtent(nodeCount); + const atlas = Texture.from(createTileAtlasCanvas()); + + tileAtlas = atlas.source; + + tileTextures = Array.from( + { length: TILE_VARIANTS }, + (_, index) => new Texture({ source: atlas.source, frame: new Rectangle(index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE) }), + ); + tileIds = new Uint8Array(extent.width * extent.height); + + for (let y = 0; y < extent.height; y += 1) { + for (let x = 0; x < extent.width; x += 1) { + tileIds[y * extent.width + x] = tileIdAt(x, y); + } + } + + // The scrolled container is a CHILD of the rendered root rather than the + // root itself. Measured: with the chunks parented directly to the rendered + // root, moving that root scrolled this scene at twice the requested rate - + // the tile pipe combines the root's transform with the one it applies + // itself. Ordinary sprite scenes do not (`scrolling-world` moves its + // rendered root and tracks the ExoJS arm exactly), so this is specific to + // the tile path. Keeping the root at identity avoids it either way. + const outerRoot = new Container(); + const world = new Container(); + const chunks: TileChunk[] = []; + + outerRoot.addChild(world); + + for (let originY = 0; originY < extent.height; originY += TILE_CHUNK) { + for (let originX = 0; originX < extent.width; originX += TILE_CHUNK) { + const tilemap = new Tilemap(atlas.source); + + tilemap.position.set(originX * TILE_SIZE, originY * TILE_SIZE); + + const chunk: TileChunk = { + tilemap, + originX, + originY, + width: Math.min(TILE_CHUNK, extent.width - originX), + height: Math.min(TILE_CHUNK, extent.height - originY), + }; + + chunks.push(chunk); + world.addChild(tilemap); + } + } + + tileExtent = extent; + tileChunks = chunks; + + for (const chunk of chunks) { + paintChunk(chunk); + } + + tileWorld = world; + tileRoot = outerRoot; + tilemapSpec = spec; + + const camera = tilemapCameraAt(0, extent); + + world.position.set(-camera.x, -camera.y); + showVisibleChunks(camera.x, camera.y); + }; + + /** + * Show the chunks the window overlaps and hide the rest. + * + * Visibility rather than reparenting: a hidden container costs Pixi a flag test, + * while adding and removing children every frame would measure the scene + * graph's bookkeeping instead of the tile path. + */ + const showVisibleChunks = (cameraX: number, cameraY: number): void => { + const left = cameraX / TILE_SIZE; + const top = cameraY / TILE_SIZE; + const right = left + VIEWPORT_WIDTH / TILE_SIZE; + const bottom = top + VIEWPORT_HEIGHT / TILE_SIZE; + + for (const chunk of tileChunks) { + chunk.tilemap.visible = chunk.originX < right && chunk.originX + chunk.width > left && chunk.originY < bottom && chunk.originY + chunk.height > top; + } + }; + + /** Drop the tilemap scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseTilemap = (): void => { + tileRoot?.destroy({ children: true }); + tileRoot = null; + tileWorld = null; + tileChunks = []; + tileTextures = []; + tileIds = new Uint8Array(0); + tileAtlas = null; + tilemapSpec = null; + }; + + /** The particle scene's container, or `null` for every other archetype. */ + let particleContainer: ParticleContainer | null = null; + let particleList: Particle[] = []; + let particleTexture: Texture | null = null; + let particleSpec: ArchetypeSpec | null = null; + /** Per-particle simulation state the lifecycle scene advances; empty in the draw-only scene. */ + let particleVelocityX: Float32Array = new Float32Array(0); + let particleVelocityY: Float32Array = new Float32Array(0); + let particleAge: Float32Array = new Float32Array(0); + + /** + * Advance the lifecycle scene by one fixed step: age, move, fade, and respawn + * whatever reached the end of its life. + * + * This is HARNESS code, not a Pixi feature, and the comparison says so. + * `ParticleContainer` is a draw path - it has no emitter, no ageing and no + * respawn - so the lifecycle scene pairs it with exactly the shared update + * rule every other arm runs, written out here. It sits inside the measured + * bracket, because it is work this arm genuinely performs. + */ + const advanceParticles = (): void => { + const count = particleList.length; + + for (let index = 0; index < count; index += 1) { + const particle = particleList[index]!; + let age = particleAge[index]! + PARTICLE_STEP; + + if (age >= PARTICLE_LIFETIME) { + const seed = particleSeedAt(index, count); + + age = 0; + particle.x = seed.x; + particle.y = seed.y; + particleVelocityX[index] = seed.velocityX; + particleVelocityY[index] = seed.velocityY; + } else { + particle.x += particleVelocityX[index]! * PARTICLE_STEP; + particle.y += particleVelocityY[index]! * PARTICLE_STEP; + } + + particleAge[index] = age; + particle.alpha = PARTICLE_ALPHA * (1 - age / PARTICLE_LIFETIME); + } + }; + + /** + * Build the particle scene: one `ParticleContainer` holding the node count's + * worth of particles. + * + * The draw-only scene declares every property static, which is the fastest + * shape this API offers and the one a project drawing a fixed set of quads + * would use. The lifecycle scene declares position and colour dynamic, + * because it changes both every frame - declaring them static there would + * measure a scene whose motion never reaches the GPU. + */ + const buildParticleScene = (spec: ArchetypeSpec, nodeCount: number): void => { + const texture = Texture.from(createParticleCanvas()); + const lifecycle = isParticleLifecycle(spec); + const container = new ParticleContainer({ + dynamicProperties: { position: lifecycle, rotation: false, vertex: false, uvs: false, color: lifecycle }, + }); + + particleList = new Array(nodeCount); + particleVelocityX = new Float32Array(lifecycle ? nodeCount : 0); + particleVelocityY = new Float32Array(lifecycle ? nodeCount : 0); + particleAge = new Float32Array(lifecycle ? nodeCount : 0); + + for (let index = 0; index < nodeCount; index += 1) { + const seed = particleSeedAt(index, nodeCount); + // Centred, because the ExoJS particle storage positions a particle by its + // centre; left at Pixi's top-left default the two arms would draw the same + // scene half a particle apart. + const particle = new Particle({ texture, x: seed.x, y: seed.y, alpha: PARTICLE_ALPHA, anchorX: 0.5, anchorY: 0.5 }); + + if (lifecycle) { + particleVelocityX[index] = seed.velocityX; + particleVelocityY[index] = seed.velocityY; + // Evenly aged at the start, so respawns land on different frames + // instead of arriving as one burst. + particleAge[index] = seed.age; + } + + particleList[index] = particle; + container.addParticle(particle); + } + + particleContainer = container; + particleTexture = texture; + particleSpec = spec; + + for (let step = 0; step < (lifecycle ? PARTICLE_PREROLL_STEPS : 0); step += 1) { + advanceParticles(); + } + }; + + /** Drop the particle scene so a rebuild (or teardown) leaks no GPU resources. */ + const releaseParticles = (): void => { + particleContainer?.destroy({ children: true }); + particleContainer = null; + particleList = []; + particleTexture?.destroy(true); + particleTexture = null; + particleSpec = null; + }; + return { engine: 'pixi', config, @@ -217,7 +532,12 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine coversArchetype(spec: ArchetypeSpec): boolean { // The stock arm runs everywhere; the culled variant only where culling can // actually remove something. - return config === 'default' || spec.cullingEnabled; + // + // The tilemap scenes are the exception among the culling-enabled ones: the + // tile path decides chunk visibility itself, and `Culler.shared.cull` acts + // on `.cullable` scene nodes it never sees. The variant would therefore + // measure the stock arm a second time under another name. + return config === 'default' || (spec.cullingEnabled && !isTilemap(spec)); }, async init(canvas: HTMLCanvasElement, target: Backend): Promise { @@ -266,12 +586,31 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine throw new Error('buildScene was called before init.'); } + releaseTilemap(); + releaseParticles(); + textures = []; for (let t = 0; t < spec.textureCount; t++) { textures.push(createDistinctTexture(t, spec.textureCount)); } + // The tilemap scenes leave the sprite path behind: the leaves are tiles in + // a painted quad buffer rather than nodes, so nothing below applies. + if (isTilemap(spec)) { + buildTilemapScene(spec, nodeCount); + root = tileRoot; + + return; + } + + if (isParticles(spec)) { + buildParticleScene(spec, nodeCount); + root = particleContainer; + + return; + } + // Nested-container spine of depth `nestingDepth`, exactly as the ExoJS arm // builds it. Pixi has no separate retained/immediate tier here, so this one // arm is the whole Pixi comparison; the spine still exercises deep transform @@ -304,7 +643,8 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine // the shared mutation selection below. const world = worldExtent(spec, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); const layout = gridLayout(nodeCount, world.width, world.height, GRID_MARGIN); - const overdraw = spec.id === 'overdraw'; + const overdraw = hasFullViewportLeaves(spec); + const alpha = leafAlpha(spec); // Shared, canonical mutation selection - the SAME helper the ExoJS arm // routes through, so both arms select the byte-for-byte identical index set @@ -369,6 +709,12 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine sprite.height = VIEWPORT_HEIGHT; } + // A fixed leaf alpha is what makes a stack of full-viewport quads a + // blend workload: every layer has to be composited rather than skipped. + if (alpha < 1) { + sprite.alpha = alpha; + } + const { x, y } = leafPosition(i); sprite.position.set(x, y); @@ -471,6 +817,42 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine }, mutate(frame: number): void { + // Particle scenes: the lifecycle one advances the shared update rule; the + // draw-only one submits the same quads every frame by design. + if (particleSpec !== null) { + if (isParticleLifecycle(particleSpec)) { + advanceParticles(); + } + + return; + } + + // Tilemap scenes: move the window, reveal the chunks it now overlaps, and + // submit this frame's tile changes. An edit means repainting the chunk that + // holds it - `Tilemap` has no per-tile update - and that repaint is the + // work this arm genuinely does, so it belongs inside the bracket. + if (tilemapSpec !== null && tileWorld !== null) { + const camera = tilemapCameraAt(tilemapCameraFrameFor(tilemapSpec, frame), tileExtent); + + tileWorld.position.set(-camera.x, -camera.y); + showVisibleChunks(camera.x, camera.y); + + if (isTilemapEditing(tilemapSpec)) { + const dirty = new Set(); + + for (const edit of tilemapEditsAt(frame, tileExtent)) { + tileIds[edit.y * tileExtent.width + edit.x] = edit.tileId; + dirty.add(Math.floor(edit.y / TILE_CHUNK) * Math.ceil(tileExtent.width / TILE_CHUNK) + Math.floor(edit.x / TILE_CHUNK)); + } + + for (const index of dirty) { + repaintChunk(index); + } + } + + return; + } + if (scrollingSpec !== null && root !== null) { const centre = cameraCenterAt(scrollingSpec, frame, VIEWPORT_WIDTH, VIEWPORT_HEIGHT); @@ -575,6 +957,8 @@ export const createPixiAdapter = (config: PixiAdapterConfig = 'default'): Engine teardown(): void { releaseBloom(); + releaseTilemap(); + releaseParticles(); if (root !== null) { root.destroy({ children: true }); diff --git a/packages/exojs-bench/src/rendering/archetypes.ts b/packages/exojs-bench/src/rendering/archetypes.ts index 664fd7861..a2579955c 100644 --- a/packages/exojs-bench/src/rendering/archetypes.ts +++ b/packages/exojs-bench/src/rendering/archetypes.ts @@ -19,6 +19,53 @@ const GPU_BOUND_COUNTS = [1_000, 5_000, 25_000] as const; */ const TEXT_COUNTS = [200, 1_000, 5_000] as const; +/** + * Node counts for `dynamic-all`. Three steps spanning 100x, sharing 1k and 100k + * with the sprite ladder so the row can be read against `static-heavy` and + * `dynamic-heavy` at both ends of the sweep. The intermediate rungs the sprite + * ladder carries would only refine a slope this archetype states plainly. + */ +const DYNAMIC_ALL_COUNTS = [1_000, 10_000, 100_000] as const; + +/** + * Layer counts for `fill-layers`. The load is full-screen layers, not scene + * nodes, so the ladder is three steps of a few dozen rather than thousands: 8 is + * an ordinary parallax stack, 32 a heavy one, and 128 past anything that ships. + * At 1280x720 the top step already resolves the viewport 128 times over. + */ +const FILL_LAYER_COUNTS = [8, 32, 128] as const; + +/** + * World tile totals for the tilemap scenes. The visible window is the same at + * every rung - 1280x720 over 32 px tiles is about 41x23 tiles - so the ladder + * sweeps how large a map an arm can hold rather than how much of it it draws. + * `tilemap.ts` maps each count onto the map's dimensions. + */ +const TILEMAP_COUNTS = [10_000, 100_000, 1_000_000] as const; + +/** + * World tile totals for the editing scene. It stops below the million the + * scrolling scene reaches: an edit is submitted per frame, and at a million tiles + * an arm that has to repack or re-upload a whole map's worth of data would be + * measured on its allocator rather than on its tile path. + */ +const TILEMAP_EDIT_COUNTS = [10_000, 100_000] as const; + +/** + * Particle counts for the draw-only scene. Four steps spanning 1000x: the top + * one is a million quads submitted in a frame, which is where a particle draw + * path either holds up or does not. + */ +const PARTICLE_DRAW_COUNTS = [1_000, 10_000, 100_000, 1_000_000] as const; + +/** + * Particle counts for the lifecycle scene. It stops below the million the + * draw-only scene reaches: a million simulated particles measures each arm's + * update loop rather than the effect, and no effect anything ships keeps that + * many alive at once. + */ +const PARTICLE_LIFECYCLE_COUNTS = [1_000, 10_000, 100_000] as const; + /** * Characters per text leaf across both text archetypes. Twelve is the length of * an ordinary label (a score, a name, a damage number) - long enough that layout @@ -73,6 +120,25 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ mutationFraction: 0.075, cullingEnabled: false, }, + // EVERY leaf moves, every frame. `dynamic-heavy` builds the identical scene + // and moves 7.5 % of it, which is the shape a real scene has - a few actors + // over a mostly still background - so the delta between the two rows is what + // the remaining 92.5 % costs once it stops being still. + // + // It is a separate archetype rather than a raised `mutationFraction` on + // `dynamic-heavy` because the two answer different questions and both are + // worth publishing; changing the existing one would also silently invalidate + // every number measured under its name. + { + id: 'dynamic-all', + category: 'node-scaling', + crossArm: true, + nodeCounts: DYNAMIC_ALL_COUNTS, + nestingDepth: 4, + textureCount: 1, + mutationFraction: 1, + cullingEnabled: false, + }, { id: 'deep-hierarchy', category: 'node-scaling', @@ -99,6 +165,31 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ textureCount: 1, mutationFraction: 0, cullingEnabled: false, + fullViewportLeaves: true, + }, + // The same geometry as `overdraw` at a workload a real scene reaches: a + // handful of translucent full-screen layers rather than thousands of them. + // `overdraw` sweeps 1k to 25k viewport-sized quads, which is a fill-rate + // ceiling probe and not something anything ships; this sweeps 8 to 128, the + // range a parallax background, a weather pass and a few tint overlays add up + // to. + // + // `leafAlpha: 0.05` is what makes it a blend workload: every layer has to be + // composited, and none of them can be skipped by an occlusion policy the way + // an opaque top layer could. The load is the LAYER COUNT rather than a node + // count, which is why it carries its own short ladder instead of the sprite + // one. + { + id: 'fill-layers', + category: 'fill-and-state', + crossArm: true, + nodeCounts: FILL_LAYER_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: false, + fullViewportLeaves: true, + leafAlpha: 0.05, }, // 40 textures: must exceed EVERY sprite-batcher slot ceiling any granted // backend/tier reaches, or the archetype silently stops breaking batches on @@ -475,6 +566,83 @@ export const ARCHETYPES: readonly ArchetypeSpec[] = [ // `mask-clip` with every rect moving each frame: the delta against the row // above is what an effect change alone costs the retained products around // it, which is the shape of every scrolling clip. + // TILEMAPS. The one scene shape practically every 2D game has and that no + // sprite archetype describes: a world far larger than the viewport, drawn + // through a dedicated tile path rather than one node per tile. + // + // `scrolling-world` is NOT this test. It lays independent sprite nodes over a + // world a few viewports across, and its per-node cost is the finding; here the + // world is a hundred thousand tiles, the visible window never changes size, and + // what is being compared is each arm's tile path - an instanced chunk renderer, + // an imperatively painted quad buffer, a shader over a data texture. + // + // `nodeCount` is the WORLD tile total, so the ladder says how large a map an + // arm can hold, not how much of it is on screen. The visible tile count is + // fixed by the viewport at every rung, which is exactly what makes the two + // questions separable. See `tilemap.ts` for the shared map, camera and edits. + { + id: 'tilemap-scroll', + category: 'tilemaps', + crossArm: true, + nodeCounts: TILEMAP_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: true, + tilemap: 'scroll', + }, + // The same map and the same camera, with visible tile ids replaced every frame. + // The delta against the row above is what submitting a tile change costs - + // which is where the three tile paths differ most sharply, because a shader + // reading a data texture has to re-upload it, a chunked quad buffer has to + // repack the affected chunk, and an instanced renderer has to invalidate the + // chunk's cached geometry. + { + id: 'tilemap-edit', + category: 'tilemaps', + crossArm: true, + nodeCounts: TILEMAP_EDIT_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: true, + tilemap: 'edit', + }, + // PARTICLES. Two scenes on one draw path, kept apart because they answer + // different questions and a figure from one would be read as the other. + // + // `particles-draw` submits a fixed set of small translucent quads and + // simulates nothing, so it measures the submission path alone: an arm's + // particle renderer, Pixi's `ParticleContainer`, a Phaser emitter whose + // simulation is not stepped. A million quads here is a million quads drawn, + // NOT a million interactive sprites, and nothing in the published figure may + // suggest otherwise. + { + id: 'particles-draw', + category: 'particles', + crossArm: true, + nodeCounts: PARTICLE_DRAW_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: false, + particles: 'draw', + }, + // The same draw path with a steady effect on top: every particle ages, moves, + // fades and respawns at the end of its life, with the pool held at the node + // count. The delta against the row above is what the simulation costs, which + // is the half a bare container never pays. + { + id: 'particles-lifecycle', + category: 'particles', + crossArm: true, + nodeCounts: PARTICLE_LIFECYCLE_COUNTS, + nestingDepth: 1, + textureCount: 1, + mutationFraction: 0, + cullingEnabled: false, + particles: 'lifecycle', + }, { id: 'mask-clip-animated', category: 'render-targets', diff --git a/packages/exojs-bench/src/rendering/driver.ts b/packages/exojs-bench/src/rendering/driver.ts index 4b6b6d553..f7348f5c6 100644 --- a/packages/exojs-bench/src/rendering/driver.ts +++ b/packages/exojs-bench/src/rendering/driver.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import type { Browser } from 'playwright'; import { chromium, webkit } from 'playwright'; +import type { ClockReport } from '../shared/clock'; import type { BaseProvenance, LibraryProvenance, PlatformDeclaration, PlatformVersionStamp, PrereleaseStamp, RenderingBrowser } from '../shared/provenance'; import { classifyPrerelease, @@ -15,12 +16,13 @@ import { } from '../shared/provenance'; import type { ViteDevServer } from '../shared/viteServer'; import { readEngineVersion, RENDERING_LIBRARY_ARMS, startViteServer as startPageServer } from '../shared/viteServer'; +import type { RunPlan } from '../suite/plan'; import { buildMatrix } from './archetypes'; import type { ArchetypeSpec, Backend, CellResult, CellSpec, EngineAdapter } from './EngineAdapter'; import type { MatrixSelection } from './selection'; -import { applySelection } from './selection'; +import { applyPlan, applySelection } from './selection'; import { usesRenderTargets } from './traits'; -import { isScrolling } from './world'; +import { isScrolling, VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from './world'; // Re-exported so the rendering barrel and the CLI keep importing the selection // surface from `driver` unchanged. It lives in its own module because the tests @@ -73,6 +75,15 @@ export interface Provenance extends BaseProvenance { readonly headless: boolean; /** True when the adapter is a software rasterizer - timings are then untrusted. */ readonly software: boolean; + /** + * The clock grid the run's FIRST session observed, or `null` where no session + * opened. + * + * Provenance only. A run opens one session per arm, so this describes the + * conditions rather than qualifying any particular measurement; each cell + * carries the grid of the session that actually timed it. + */ + readonly clock: ClockReport | null; /** * Resolved WebGPU sprite-batch texture-slot tier for this run's adapter (8 / * 16 / 32), or `undefined` for the WebGL2 backend (whose batcher uses a fixed @@ -394,8 +405,15 @@ export const readWebGpuAdapter = async (page: import('playwright').Page, browser return { adapter, usable: true, note: '', slotTier }; }; -/** A cell that could not be measured: zeroed timings/structure, `unavailable` status, and an explanatory note. */ -const unavailableCell = (spec: CellSpec, note: string): CellResult => ({ +/** + * A cell that could not be measured: zeroed timings/structure, `unavailable` + * status, and an explanatory note. + * + * `clock` carries the session's grid where a session existed, so a cell that + * failed inside a live page is still attributed to the page it failed in. It is + * `null` only where no page produced the cell at all. + */ +const unavailableCell = (spec: CellSpec, note: string, clock: ClockReport | null = null): CellResult => ({ spec, cpuMsMedian: 0, cpuMsP95: 0, @@ -404,6 +422,7 @@ const unavailableCell = (spec: CellSpec, note: string): CellResult => ({ queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 0, textureBinds: 0, bufferUploads: 0 }, + clock, status: 'unavailable', note, }); @@ -434,9 +453,9 @@ export type CellResultSink = (result: CellResult) => void; * discipline is untouched - means a failing cell costs only itself: it becomes * an `unavailable` datapoint carrying the error, and the run continues. */ -const runCellInPage = async (page: import('playwright').Page, spec: CellSpec): Promise => { +const runCellInPage = async (page: import('playwright').Page, spec: CellSpec, hold: boolean): Promise => { try { - return await page.evaluate(cell => globalThis.__runBaselineCell!(cell), spec); + return await page.evaluate(args => globalThis.__runBaselineCell!(args.cell, args.hold), { cell: spec, hold }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -468,13 +487,13 @@ const CELL_WEDGED = Symbol('cell-wedged'); * closes; its rejection is swallowed so it never surfaces as an unhandled * rejection (`runCellInPage` already never rejects on a normal cell error). */ -const runCellOrWedge = async (page: import('playwright').Page, spec: CellSpec): Promise => { +const runCellOrWedge = async (page: import('playwright').Page, spec: CellSpec, hold = false): Promise => { let timer: ReturnType | undefined; const timeout = new Promise(resolvePromise => { timer = setTimeout(() => resolvePromise(CELL_WEDGED), CELL_TIMEOUT_MS); }); - const run = runCellInPage(page, spec).then(result => { + const run = runCellInPage(page, spec, hold).then(result => { if (timer !== undefined) { clearTimeout(timer); } @@ -508,6 +527,9 @@ const runCellOrWedge = async (page: import('playwright').Page, spec: CellSpec): * For WebGPU the adapter identity is read once; a null or software adapter emits * every cell as `unavailable` rather than measuring a software rasterizer. */ +/** File stem of one cell's captured frame - the cell's identity, safe for a file name. */ +const captureName = (cell: CellSpec): string => `${cell.backend}-${cell.archetype}-${String(cell.nodeCount)}-${cell.engine}-${cell.config}`; + const runBackend = async (options: { baseUrl: string; backend: Backend; @@ -519,8 +541,10 @@ const runBackend = async (options: { platform?: PlatformDeclaration; /** Replaces the per-backend launch flags; see {@link runMatrix}. */ launchFlags?: readonly string[]; + /** Directory to capture each measured cell's last frame into; see {@link runMatrix}. */ + captureDir?: string; }): Promise<{ provenance: Provenance; results: CellResult[] }> => { - const { baseUrl, backend, browser: browserName, cells, engineVersion, onCellResult } = options; + const { baseUrl, backend, browser: browserName, cells, engineVersion, onCellResult, captureDir } = options; const flags = resolveLaunchFlags(browserName, backend, options.launchFlags); // Group cells by arm (engine|config) in first-seen order so each arm runs in its @@ -553,6 +577,10 @@ const runBackend = async (options: { // read once and reused across every arm's session (same GPU, same flags). let renderer: string | null = null; let webgpuIdentity: WebGpuIdentity | null = null; + // The first session's grid, kept for the backend's provenance line. It + // describes the run's conditions and is NOT what qualifies a cell: each cell + // carries the grid of the session that produced it. + let clock: ClockReport | null = null; // Read off the first session and reused: every session in a run launches the // same build, and a run with no session at all reports the absence rather than // an invented version. @@ -576,12 +604,19 @@ const runBackend = async (options: { await page.goto(baseUrl, { waitUntil: 'load' }); await page.waitForFunction(() => typeof globalThis.__runBaselineCell === 'function'); + // Probed in THIS page, before its cells run: the grid belongs to the + // browsing context, and this run opens one session per arm, so a value + // read from another session would qualify cells it never timed. + const sessionClock = await page.evaluate(() => globalThis.__probeClock!()); + + clock ??= sessionClock; + if (backend === 'webgpu') { webgpuIdentity ??= await readWebGpuAdapter(page, browserName); if (!webgpuIdentity.usable) { for (const cell of remaining) { - collect(unavailableCell(cell, webgpuIdentity.note)); + collect(unavailableCell(cell, webgpuIdentity.note, sessionClock)); } remaining = []; @@ -590,13 +625,14 @@ const runBackend = async (options: { while (remaining.length > 0) { const cell = remaining[0]!; - const outcome = await runCellOrWedge(page, cell); + const outcome = await runCellOrWedge(page, cell, captureDir !== undefined); if (outcome === CELL_WEDGED) { collect( unavailableCell( cell, `cell wedged the browser (no result after ${CELL_TIMEOUT_MS}ms — a mid-frame GPU-driver stall the in-page guards cannot interrupt); isolated as unavailable, browser relaunched for the arm's remaining cells`, + sessionClock, ), ); remaining = remaining.slice(1); @@ -606,6 +642,20 @@ const runBackend = async (options: { collect(outcome); + // Frame capture: the canvas as the measured cell left it. The cell was + // asked to HOLD its scene for exactly this, because some arms blank the + // canvas when they release their context and a capture taken after + // teardown would compare teardown policies rather than scenes. + // `page.screenshot` rather than a canvas readback: a WebGPU canvas + // never yields its contents to `drawImage`. + if (captureDir !== undefined && outcome.status === 'ok') { + await page.screenshot({ + path: resolve(captureDir, `${captureName(cell)}.png`), + clip: { x: 0, y: 0, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, + }); + await page.evaluate(() => globalThis.__disposeHeldCell!()); + } + if (backend === 'webgl2' && renderer === null && outcome.status === 'ok') { renderer = await readRendererInPage(page); } @@ -625,6 +675,7 @@ const runBackend = async (options: { const platform = { browser: browserName, browserVersion, + clock, os: readOsRelease(), platformVersion: readPlatformVersion(options.platform), prerelease: classifyPrerelease({ browserVersion, declared: declaredPrereleaseOf(options.platform) }), @@ -881,6 +932,9 @@ export const profileCell = async (options: { prerelease: classifyPrerelease({ browserVersion, declared: declaredPrereleaseOf(options.platform) }), flags, headless: true, + // A CPU profile reports attributed self time, never a wall-clock + // figure anyone compares, so no grid is probed for it. + clock: null, engineVersion, timestamp: new Date().toISOString(), software: isSoftwareRenderer(adapter), @@ -923,6 +977,33 @@ export interface MatrixOutcome { * `onCellResult` (optional) fires after every cell so the caller can persist it * immediately; the returned {@link MatrixOutcome} is the same set aggregated. */ +/** The cell selection one matrix invocation would measure, resolved without touching a browser. */ +export interface MatrixCellSelection { + readonly backends: readonly Backend[]; + readonly plan?: RunPlan; + readonly filter?: Partial; + readonly selection?: MatrixSelection; + readonly timedFramesOverride?: number; +} + +/** + * Resolve the cells a run would measure: the capability-gated matrix narrowed by + * the suite plan, then by the free filter, then by the multi-value selection. + * + * Exported so `--dry-run` can report the real planned workload - cell count, + * frame budgets, arms - from the same code path the run itself uses. A dry run + * derived from a second, parallel enumeration would be a description of a run + * nobody performs. + */ +export const resolveMatrixCells = (options: MatrixCellSelection): CellSpec[] => { + const allCells = buildMatrix([...ADAPTER_CAPABILITIES, ...requestedCalibrationArms(options.selection)], options.backends); + const planned = options.plan ? applyPlan(allCells, options.plan) : allCells; + const filtered = options.filter ? applyFilter(planned, options.filter) : planned; + const selected = options.selection ? applySelection(filtered, options.selection) : filtered; + + return options.timedFramesOverride === undefined ? selected : selected.map(cell => ({ ...cell, timedFrames: options.timedFramesOverride! })); +}; + export const runMatrix = async (options: { backends: readonly Backend[]; /** @@ -942,6 +1023,13 @@ export const runMatrix = async (options: { * is detected from its own version string and needs no declaration. */ platform?: PlatformDeclaration; + /** + * Resolved suite plan restricting the matrix to the loads that plan selects, + * applied BEFORE `filter` and `selection` so a free filter narrows within the + * plan. Omitted, the run covers each archetype's own full ladder, which is + * what an unqualified `bench` invocation has always meant. + */ + plan?: RunPlan; filter?: Partial; /** Multi-value selection applied after `filter`; see {@link MatrixSelection}. */ selection?: MatrixSelection; @@ -966,13 +1054,20 @@ export const runMatrix = async (options: { * GPU number. */ launchFlags?: readonly string[]; + /** + * Directory each measured cell's final frame is captured into, as + * `----.png`. + * + * For checking that two arms asked to render one scene actually rendered it - + * a cell that draws nothing measures a frame nobody would ship, and its number + * looks like a win. Off unless asked for: a capture costs a full readback per + * cell, which has no place in a reportable run. + */ + captureDir?: string; }): Promise => { const engineVersion = readEngineVersion(); const libraries = readLibraryProvenance(RENDERING_LIBRARY_ARMS); - const allCells = buildMatrix([...ADAPTER_CAPABILITIES, ...requestedCalibrationArms(options.selection)], options.backends); - const filtered = options.filter ? applyFilter(allCells, options.filter) : allCells; - const selected = options.selection ? applySelection(filtered, options.selection) : filtered; - const cells = options.timedFramesOverride === undefined ? selected : selected.map(cell => ({ ...cell, timedFrames: options.timedFramesOverride! })); + const cells = resolveMatrixCells(options); if (cells.length === 0) { throw new Error('The baseline matrix is empty: no adapter supports the requested backends/filter.'); @@ -1007,6 +1102,7 @@ export const runMatrix = async (options: { onCellResult, ...(options.platform !== undefined && { platform: options.platform }), ...(options.launchFlags !== undefined && { launchFlags: options.launchFlags }), + ...(options.captureDir !== undefined && { captureDir: options.captureDir }), }); provenance.push(outcome.provenance); diff --git a/packages/exojs-bench/src/rendering/page/harness.ts b/packages/exojs-bench/src/rendering/page/harness.ts index 1a7a4600e..ff2ef7fbe 100644 --- a/packages/exojs-bench/src/rendering/page/harness.ts +++ b/packages/exojs-bench/src/rendering/page/harness.ts @@ -3,6 +3,8 @@ // engine module instances) but never runs during a matrix cell. import './timerProbe'; +import type { ClockReport } from '../../shared/clock'; +import { probeClock } from '../../shared/clock'; import { mutationSignature, selectMutationIndices } from '../../shared/mutation'; import { createCpuTimer, median, percentile, shouldAbort } from '../../shared/timing'; import { createExoJsAdapter } from '../adapters/exojs'; @@ -136,6 +138,18 @@ const HARD_FRAME_BUDGET_MS = FRAME_BUDGET_MS * 10; * remainder means the harness has a bug (a fractional draw call is nonsense), * so the raw totals are surfaced instead and flagged via the returned note. */ +/** + * This page's clock grid, probed once and reused by every cell the session + * measures. + * + * Probing per cell would cost a five-figure loop before each measurement and + * answer the same question every time: the coarsening is a property of the + * browsing context, which does not change between two cells of one session. + */ +let probedClock: ClockReport | null = null; + +const pageClock = (): ClockReport => (probedClock ??= probeClock()); + const perFrameStructural = (totals: StructuralCounters, frames: number): { structural: StructuralCounters; note: string | null } => { const draws = totals.drawCalls / frames; const binds = totals.textureBinds / frames; @@ -436,15 +450,36 @@ export const runCell = async (adapter: EngineAdapter, spec: CellSpec, canvas: HT queueMsMedian, queueMsP95, structural, + clock: pageClock(), status: exceeded ? 'exceeded' : 'ok', ...(note !== null && { note }), }; } finally { probe.detach(); - adapter.teardown(); + + // A held cell keeps its scene on the canvas so the driver can capture the + // frame it just measured. Tearing down first leaves an arm-dependent canvas + // - some engines keep the last frame, others release the context and blank + // it - so a capture taken afterwards would compare teardown policies rather + // than scenes. + if (heldAdapter === null) { + adapter.teardown(); + } } }; +/** + * The arm whose scene is being kept alive for a capture, or `null` when no cell + * is held. Disposed by {@link disposeHeldCell} before the next cell runs. + */ +let heldAdapter: EngineAdapter | null = null; + +/** Tear down the scene {@link runCell} was asked to hold. Safe to call when nothing is held. */ +const disposeHeldCell = (): void => { + heldAdapter?.teardown(); + heldAdapter = null; +}; + /** Registry key uniquely identifying an engine arm by its engine + config labels. */ const adapterKey = (engine: string, config: string): string => `${engine}${config}`; @@ -512,10 +547,19 @@ const resolveAdapter = async (engine: string, config: string): Promise => { +const runBaselineCell = async (cell: CellSpec, hold = false): Promise => { + // Whatever the previous cell was asked to hold is released here rather than + // after the capture, so a capture failure can never leave an arm's scene alive + // underneath the next cell's measurement. + disposeHeldCell(); + const canvas = freshStageCanvas(); const adapter = await resolveAdapter(cell.engine, cell.config); + if (hold) { + heldAdapter = adapter; + } + return runCell(adapter, cell, canvas); }; @@ -582,13 +626,28 @@ const profileDispose = (): void => { }; declare global { - var __runBaselineCell: ((cell: CellSpec) => Promise) | undefined; + /** + * Measures one cell. `hold` keeps the scene on the canvas afterwards, for the + * driver's frame capture; the next call releases it. + */ + var __runBaselineCell: ((cell: CellSpec, hold?: boolean) => Promise) | undefined; + /** Releases a held cell's scene without measuring another. */ + var __disposeHeldCell: (() => void) | undefined; + /** + * Reports what THIS page's clock resolves to. Exposed from the page rather + * than evaluated as a driver-side function so the probe runs in the same + * browsing context as the cells it qualifies: the coarsening depends on the + * context, and one session's grid says nothing about another's. + */ + var __probeClock: (() => ClockReport) | undefined; var __profileSetup: ((cell: CellSpec, warmupFrames: number) => Promise) | undefined; var __profileFrames: ((count: number) => number) | undefined; var __profileDispose: (() => void) | undefined; } globalThis.__runBaselineCell = runBaselineCell; +globalThis.__probeClock = probeClock; +globalThis.__disposeHeldCell = disposeHeldCell; globalThis.__profileSetup = profileSetup; globalThis.__profileFrames = profileFrames; globalThis.__profileDispose = profileDispose; diff --git a/packages/exojs-bench/src/rendering/particles.ts b/packages/exojs-bench/src/rendering/particles.ts new file mode 100644 index 000000000..9faec3174 --- /dev/null +++ b/packages/exojs-bench/src/rendering/particles.ts @@ -0,0 +1,101 @@ +import { createRng } from '../shared/rng'; +import type { ArchetypeSpec } from './EngineAdapter'; +import { VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from './world'; + +/** + * The particle scenes' shared definition: how large a particle is, where it + * starts, how it moves and how long it lives. + * + * The two scenes ask different questions and must not be confused. `particles-draw` + * submits a fixed set of quads through each arm's particle draw path and + * simulates nothing; `particles-lifecycle` runs a steady effect - ageing, + * movement, fading and respawning - on top of that same draw path. A figure + * from one says nothing about the other, which is why they are separate + * archetypes rather than one with a flag. + */ + +/** Edge length of a particle quad, in pixels. Small, so the scenes are submission-bound rather than fill-bound. */ +export const PARTICLE_SIZE = 4; + +/** Alpha every particle carries, so the scenes exercise a blended draw rather than an opaque one. */ +export const PARTICLE_ALPHA = 0.5; + +/** + * Packed `0xAABBGGRR` tint every particle carries: white at + * {@link PARTICLE_ALPHA}. + * + * Stated as the packed word because that is the form the storage takes, and + * deriving it in each arm is how two arms end up a rounding step apart on the + * alpha channel. + */ +export const PARTICLE_TINT = ((Math.round(PARTICLE_ALPHA * 255) << 24) | 0x00_ff_ff_ff) >>> 0; + +/** Seconds a particle lives in the lifecycle scene. */ +export const PARTICLE_LIFETIME = 2; + +/** Fixed simulation step, in seconds. The lifecycle scene advances by exactly this much per frame on every arm. */ +export const PARTICLE_STEP = 1 / 60; + +/** + * Simulation steps the lifecycle scene runs before it is measured. + * + * Exactly one lifetime, so the pool reaches the steady state it is supposed to + * be measured in: every particle has been through a respawn, and the respawns + * are spread evenly across the frames instead of arriving as one burst. Run at + * build time, outside the timed window, like every other arm's scene + * construction. + */ +export const PARTICLE_PREROLL_STEPS = 120; + +/** Seed the particle layout is drawn from. */ +const PARTICLE_SEED = 0xc0_ff_ee; + +/** One particle's initial state, derived from its index alone. */ +export interface ParticleSeedState { + readonly x: number; + readonly y: number; + readonly velocityX: number; + readonly velocityY: number; + /** Seconds this particle has already lived at preroll time, spread across one lifetime. */ + readonly age: number; +} + +/** + * Deterministic sample stream for particle `index`. + * + * Seeded per particle from the shared RNG rather than hashed inline, so an arm + * can fill its storage in whatever order it wants and still produce the + * identical scene - and so neighbouring indices decorrelate, which a + * hand-rolled integer mix does not reliably do: an earlier one here laid + * consecutive particles along visible lines. + */ +const sampler = (index: number): (() => number) => createRng((PARTICLE_SEED + Math.imul(index, 0x9e_37_79_b1)) >>> 0); + +/** + * The initial state of particle `index` of `total`. + * + * Positions fill the viewport and velocities are a modest drift, so the + * lifecycle scene stays inside the frame for its whole life and no arm spends + * its time on particles nothing can see. Ages are spread evenly over one + * lifetime, which is what makes respawns land on different frames rather than + * all at once. + */ +export const particleSeedAt = (index: number, total: number): ParticleSeedState => { + const random = sampler(index); + const angle = random() * Math.PI * 2; + const speed = 20 + random() * 40; + + return { + x: random() * (VIEWPORT_WIDTH - PARTICLE_SIZE), + y: random() * (VIEWPORT_HEIGHT - PARTICLE_SIZE), + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + age: total > 0 ? (index / total) * PARTICLE_LIFETIME : 0, + }; +}; + +/** Whether the archetype renders particles rather than a sprite scene. */ +export const isParticles = (spec: ArchetypeSpec): boolean => spec.particles !== undefined; + +/** Whether the archetype simulates its particles rather than only drawing them. */ +export const isParticleLifecycle = (spec: ArchetypeSpec): boolean => spec.particles === 'lifecycle'; diff --git a/packages/exojs-bench/src/rendering/sceneAssets.ts b/packages/exojs-bench/src/rendering/sceneAssets.ts index 4f3acfa43..5c62d7316 100644 --- a/packages/exojs-bench/src/rendering/sceneAssets.ts +++ b/packages/exojs-bench/src/rendering/sceneAssets.ts @@ -1,3 +1,5 @@ +import { PARTICLE_SIZE } from './particles'; +import { TILE_SIZE, TILE_VARIANTS } from './tilemap'; import { SPRITE_SIZE } from './world'; /** @@ -49,6 +51,67 @@ export const createDistinctTextureCanvas = (index: number, total: number): HTMLC return canvas; }; +/** + * The particle sprite: one flat white {@link PARTICLE_SIZE} square. + * + * White and opaque, so the per-particle tint and alpha every arm applies are + * what decide the drawn colour - a coloured or pre-faded source would let one + * arm's tint handling show up as a brightness difference rather than as the + * cost it is. + */ +export const createParticleCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + + canvas.width = PARTICLE_SIZE; + canvas.height = PARTICLE_SIZE; + + const context = canvas.getContext('2d'); + + if (context === null) { + throw new Error('A 2D context is required to generate the benchmark particle sprite.'); + } + + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, PARTICLE_SIZE, PARTICLE_SIZE); + + return canvas; +}; + +/** + * The tile atlas: one row of {@link TILE_VARIANTS} cells of {@link TILE_SIZE} + * pixels, each a flat distinct colour with a one-pixel darker border. + * + * One row and one page, so every arm resolves its tiles out of a single texture + * and none of them pays a texture-slot or page-switch cost the others avoid. The + * border is what makes a captured frame readable as a tile grid rather than as a + * colour field, which is how a scroll or an edit is checked by eye; nothing + * measures it. + */ +export const createTileAtlasCanvas = (): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + + canvas.width = TILE_SIZE * TILE_VARIANTS; + canvas.height = TILE_SIZE; + + const context = canvas.getContext('2d'); + + if (context === null) { + throw new Error('A 2D context is required to generate the benchmark tile atlas.'); + } + + for (let index = 0; index < TILE_VARIANTS; index += 1) { + const hue = Math.round((index / TILE_VARIANTS) * 360); + + context.fillStyle = `hsl(${hue}, 65%, 52%)`; + context.fillRect(index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE); + context.fillStyle = `hsl(${hue}, 65%, 32%)`; + context.fillRect(index * TILE_SIZE, 0, TILE_SIZE, 1); + context.fillRect(index * TILE_SIZE, 0, 1, TILE_SIZE); + } + + return canvas; +}; + /** * The digit glyph sheet: one row of {@link DIGIT_ALPHABET}.length cells, white on * transparent. diff --git a/packages/exojs-bench/src/rendering/selection.ts b/packages/exojs-bench/src/rendering/selection.ts index dbaa09167..75dda969c 100644 Binary files a/packages/exojs-bench/src/rendering/selection.ts and b/packages/exojs-bench/src/rendering/selection.ts differ diff --git a/packages/exojs-bench/src/rendering/structuralGate.ts b/packages/exojs-bench/src/rendering/structuralGate.ts index 766d5c9f1..134a456fd 100644 --- a/packages/exojs-bench/src/rendering/structuralGate.ts +++ b/packages/exojs-bench/src/rendering/structuralGate.ts @@ -36,6 +36,8 @@ export const UNGUARDED_ARCHETYPES: Readonly> 'text-dynamic': 'the cell aborts on a software rasterizer as too slow, so its counters cover an unpredictable number of frames', overdraw: 'its fill cost dominates the gate wall clock in software (stacked full-viewport quads are hundreds of millions of shaded pixels per frame) while its draw structure is a single call that static-heavy already guards', + 'fill-layers': + 'the same stacked full-viewport geometry as overdraw, and unguarded for the same reason: enormous fill in software, no draw structure static-heavy does not already guard', }; /** Identity of one guarded cell. */ diff --git a/packages/exojs-bench/src/rendering/tilemap.ts b/packages/exojs-bench/src/rendering/tilemap.ts new file mode 100644 index 000000000..55a947e42 --- /dev/null +++ b/packages/exojs-bench/src/rendering/tilemap.ts @@ -0,0 +1,187 @@ +import { createRng } from '../shared/rng'; +import type { ArchetypeSpec } from './EngineAdapter'; +import { VIEWPORT_HEIGHT, VIEWPORT_WIDTH } from './world'; + +/** + * The tilemap scenes' shared definition: map size, tile content, camera path and + * per-frame edits, derived once so every arm paints the identical world. + * + * Each arm expresses a tilemap through its own API - an instanced chunk renderer, + * an imperatively painted quad buffer, a shader over a data texture - and those + * are the implementations under comparison. What must not differ is the map they + * are given, which is why nothing here is an arm's business to decide. + */ + +/** Tile edge length in pixels. Square, and the same in every arm. */ +export const TILE_SIZE = 32; + +/** + * Distinct tiles in the tileset. + * + * Enough that the map is not one repeated tile - which a renderer could collapse + * - and few enough to sit in a single atlas page on every arm, so no arm pays a + * texture-slot cost the others avoid. The comparison is about tile throughput, + * not about batching across tilesets. + */ +export const TILE_VARIANTS = 16; + +/** Camera travel per frame in pixels, along the map diagonal. */ +export const TILEMAP_CAMERA_SPEED = 8; + +/** Unique visible tiles whose id is replaced each frame by the editing scene. */ +export const TILEMAP_EDITS_PER_FRAME = 64; + +/** Seed the tile content is drawn from; fixed, so every arm and every run paints one map. */ +export const TILEMAP_SEED = 0xc0_ff_ee; + +/** Map dimensions in tiles. */ +export interface TilemapExtent { + readonly width: number; + readonly height: number; +} + +/** + * Map dimensions for a tile total. + * + * Tabulated rather than derived from a square root: the published loads are + * 100x100, 400x250 and 1000x1000, and the middle one is deliberately not square + * - a map wider than it is tall is the ordinary shape of a side-scrolling level, + * and a square-only ladder would never exercise a chunk grid's minor axis + * differently from its major one. + */ +export const tilemapExtent = (tileCount: number): TilemapExtent => { + switch (tileCount) { + case 10_000: + return { width: 100, height: 100 }; + case 100_000: + return { width: 400, height: 250 }; + case 1_000_000: + return { width: 1_000, height: 1_000 }; + default: { + // An off-ladder count (a spot check, a smoke run) still gets a real map: + // the widest rectangle whose area does not exceed the count, in the same + // 16:10 proportion as the published middle rung. + const height = Math.max(1, Math.round(Math.sqrt(tileCount / 1.6))); + + return { width: Math.max(1, Math.floor(tileCount / height)), height }; + } + } +}; + +/** + * Tile id at map coordinate `(x, y)`, in `0..TILE_VARIANTS - 1`. + * + * Derived from the coordinate rather than from a stream, so an arm can fill its + * map in whatever order its storage wants and still paint the identical world. + * The hash is the one the shared RNG uses, seeded per tile. + */ +export const tileIdAt = (x: number, y: number): number => { + const seed = (TILEMAP_SEED ^ (x * 0x9e_37_79_b1) ^ (y * 0x85_eb_ca_6b)) >>> 0; + + return Math.floor(createRng(seed)() * TILE_VARIANTS) % TILE_VARIANTS; +}; + +/** Top-left corner of the visible window, in pixels. */ +export interface TilemapCamera { + readonly x: number; + readonly y: number; +} + +/** + * Camera position at `frame`: a diagonal that reflects off the map edges. + * + * Reflection rather than a wrap, because a wrap teleports the window across the + * map and hands every arm a full chunk turnover on that one frame, which is a + * spike in the middle of a steady-state measurement rather than a property of + * the scroll. + */ +export const tilemapCameraAt = (frame: number, extent: TilemapExtent): TilemapCamera => ({ + x: reflect(frame * TILEMAP_CAMERA_SPEED, Math.max(0, extent.width * TILE_SIZE - VIEWPORT_WIDTH)), + y: reflect(frame * TILEMAP_CAMERA_SPEED, Math.max(0, extent.height * TILE_SIZE - VIEWPORT_HEIGHT)), +}); + +/** Reflect `value` into `0..span`, so the camera turns around at the map edge instead of jumping. */ +const reflect = (value: number, span: number): number => { + if (span <= 0) { + return 0; + } + + const cycle = value % (span * 2); + + return cycle <= span ? cycle : span * 2 - cycle; +}; + +/** One tile the editing scene replaces. */ +export interface TilemapEdit { + readonly x: number; + readonly y: number; + readonly tileId: number; +} + +/** + * The tiles `tilemap-edit` replaces on `frame`: {@link TILEMAP_EDITS_PER_FRAME} + * distinct coordinates inside the visible window, each given a tile id different + * from the one it held. + * + * Inside the window on purpose. An edit outside it is a data change an arm may + * legitimately defer until the chunk is next drawn, so a scene editing off-screen + * tiles would measure how long each arm defers rather than what an edit costs. + */ +export const tilemapEditsAt = (frame: number, extent: TilemapExtent): readonly TilemapEdit[] => { + const camera = tilemapCameraAt(EDIT_CAMERA_FRAME, extent); + const originX = Math.floor(camera.x / TILE_SIZE); + const originY = Math.floor(camera.y / TILE_SIZE); + const columns = Math.min(extent.width, Math.ceil(VIEWPORT_WIDTH / TILE_SIZE)); + const rows = Math.min(extent.height, Math.ceil(VIEWPORT_HEIGHT / TILE_SIZE)); + const edits: TilemapEdit[] = []; + const visited = new Set(); + + // A stride coprime with the window's tile count walks distinct cells without + // rejection sampling, so the set is exactly the requested size on every frame + // rather than "usually" it. + const cells = columns * rows; + const stride = 61; + + for (let index = 0; index < TILEMAP_EDITS_PER_FRAME && visited.size < cells; index += 1) { + const cell = (index * stride) % cells; + + if (visited.has(cell)) { + continue; + } + + visited.add(cell); + + const x = Math.min(extent.width - 1, originX + (cell % columns)); + const y = Math.min(extent.height - 1, originY + Math.floor(cell / columns)); + + edits.push({ x, y, tileId: (tileIdAt(x, y) + 1 + (frame % (TILE_VARIANTS - 1))) % TILE_VARIANTS }); + } + + return edits; +}; + +/** Whether the archetype renders a tilemap rather than a sprite scene. */ +export const isTilemap = (spec: ArchetypeSpec): boolean => spec.tilemap !== undefined; + +/** + * The frame the editing scene's camera is fixed at. + * + * The editing scene does not scroll, and that is a determinism requirement + * rather than a simplification. The harness may cut an arm's warmup short on + * wall clock, so two arms can reach the timed window having run a different + * number of frames; with a moving camera the edited cells move too, and the two + * arms would then be drawing worlds that differ by whatever the extra warmup + * frames edited - a difference that looks like a rendering bug and is not. + * + * Holding the camera makes each frame set the SAME cells to a value derived from + * that frame alone, so the visible world depends only on the last frame drawn. + * The cost of scrolling is what `tilemap-scroll` measures; keeping it out of + * here also makes the delta between the two scenes the edit cost alone. + */ +export const EDIT_CAMERA_FRAME = 0; + +/** The frame whose camera position an archetype draws at. */ +export const tilemapCameraFrameFor = (spec: ArchetypeSpec, frame: number): number => (isTilemapEditing(spec) ? EDIT_CAMERA_FRAME : frame); + +/** Whether the archetype replaces visible tile ids every frame. */ +export const isTilemapEditing = (spec: ArchetypeSpec): boolean => spec.tilemap === 'edit'; diff --git a/packages/exojs-bench/src/rendering/traits.ts b/packages/exojs-bench/src/rendering/traits.ts index d1ebd1638..b1a67ad3f 100644 --- a/packages/exojs-bench/src/rendering/traits.ts +++ b/packages/exojs-bench/src/rendering/traits.ts @@ -14,6 +14,19 @@ import type { ArchetypeSpec } from './EngineAdapter'; /** Whether the archetype's leaves are text nodes rather than sprites. */ export const isTextArchetype = (spec: ArchetypeSpec): boolean => (spec.textGlyphsPerNode ?? 0) > 0; +/** + * Whether every leaf is stretched to the whole viewport and stacked at the + * origin, which makes the scene fill-bound rather than node-bound. + * + * A predicate rather than an archetype-id check in each adapter: the arms used + * to test the id separately, and an archetype added with the same geometry under + * a different name would have been laid out four different ways. + */ +export const hasFullViewportLeaves = (spec: ArchetypeSpec): boolean => spec.fullViewportLeaves === true; + +/** Per-leaf alpha the archetype fixes, or `1` where it leaves the leaves opaque. */ +export const leafAlpha = (spec: ArchetypeSpec): number => spec.leafAlpha ?? 1; + /** Whether the per-frame mutation re-sets each selected text leaf's string. */ export const isTextUpdating = (spec: ArchetypeSpec): boolean => isTextArchetype(spec) && spec.textUpdate === true; diff --git a/packages/exojs-bench/src/run.ts b/packages/exojs-bench/src/run.ts index 4d427cbd5..03ed52f39 100644 --- a/packages/exojs-bench/src/run.ts +++ b/packages/exojs-bench/src/run.ts @@ -4,23 +4,35 @@ import { resolve } from 'node:path'; // graph is loaded lazily via a dynamic `import()` inside `runPhysicsDomain`, so // a rendering run never pays for it. import type { PhysicsCellResult, PhysicsCellSpec } from './physics'; +import { PHYSICS_ARCHETYPES } from './physics/archetypes'; import type { ArchetypeId, Backend, CellResult, MatrixSelection, RenderingBrowser } from './rendering'; import { isHitching, parseRenderingBrowser, profileCell, runMatrix, writeReport } from './rendering'; +import { ARCHETYPES } from './rendering/archetypes'; +import { resolveMatrixCells } from './rendering/driver'; import { parseArgs } from './shared/args'; import { createCheckpointWriter } from './shared/checkpoint'; import type { PlatformDeclaration } from './shared/provenance'; import { parsePlatformDeclaration, PLATFORM_DECLARATION_SYNTAX, readPlatformVersion } from './shared/provenance'; +import { PHYSICS_LIBRARY_ARMS } from './shared/viteServer'; +import type { RunPlan } from './suite/plan'; +import { parseSuite, resolveSuitePlan } from './suite/plan'; /** Domains this CLI can drive. Each has its own archetypes + arms; the shared layer (timing, provenance, checkpoint, report skeleton) is reused across both. */ const DOMAINS = ['rendering', 'physics'] as const; type Domain = (typeof DOMAINS)[number]; +/** `--domain` accepts a single domain or `all`, which runs both SERIALLY. */ +type DomainSelector = Domain | 'all'; + /** Default output directory for the rendering report artifacts (gitignored). */ const DEFAULT_OUT_DIR = '.workspace/output/baseline/'; /** Default output directory for the physics report artifacts (gitignored). */ const DEFAULT_PHYSICS_OUT_DIR = '.workspace/output/physics/'; +/** Default parent directory for a `--domain=all` run; each domain gets a subdirectory of it. */ +const DEFAULT_ALL_OUT_DIR = '.workspace/output/all/'; + /** Backends run when `--backend` is not given. `buildMatrix` gates each to the adapters that support it. */ const DEFAULT_BACKENDS: readonly Backend[] = ['webgl2', 'webgpu']; @@ -112,16 +124,105 @@ const requirePlatformVersion = (declared: PlatformDeclaration | undefined, isSub }; /** Parse and validate the `--domain` selector (defaults to `rendering`). */ -const resolveDomain = (raw: string | undefined): Domain => { +const resolveDomain = (raw: string | undefined): DomainSelector => { if (raw === undefined) { return 'rendering'; } - if ((DOMAINS as readonly string[]).includes(raw)) { - return raw as Domain; + if (raw === 'all' || (DOMAINS as readonly string[]).includes(raw)) { + return raw as DomainSelector; + } + + throw new Error(`--domain must be one of [${DOMAINS.join(', ')}, all] (got '${raw}').`); +}; + +/** + * Output directory for one domain of a run. + * + * `--domain=all` gives each domain its own SUBDIRECTORY of the requested output + * rather than letting the second domain's `results.json` overwrite the first's. + * A single-domain run keeps writing exactly where it always did. + */ +const outDirFor = (args: Map, domain: Domain, selector: DomainSelector): string => { + const requested = args.get('out'); + + if (selector !== 'all') { + return resolve(requested ?? (domain === 'rendering' ? DEFAULT_OUT_DIR : DEFAULT_PHYSICS_OUT_DIR)); + } + + return resolve(requested ?? DEFAULT_ALL_OUT_DIR, domain); +}; + +/** Resolve the suite plan for one domain from the shared `--suite` / `--extreme` flags. */ +const resolvePlanFor = (args: Map, domain: Domain, exploratory: boolean): RunPlan => + resolveSuitePlan({ + suite: parseSuite(args.get('suite')), + domain, + extreme: args.has('extreme'), + exploratory, + ladders: laddersFor(domain), + }); + +/** + * Every archetype a domain implements, mapped to its own load ladder. + * + * This is what a catalog scenario is checked against, and what `full` falls back + * to for the ExoJS-internal probes the published catalog deliberately omits. + */ +const laddersFor = (domain: Domain): ReadonlyMap => + new Map( + domain === 'rendering' + ? ARCHETYPES.map(archetype => [archetype.id as string, archetype.nodeCounts]) + : PHYSICS_ARCHETYPES.map(archetype => [archetype.id as string, archetype.bodyCounts]), + ); + +/** + * Print the resolved plan without measuring anything. + * + * States the planned workload - scenarios, loads, cells and sampling budget - + * and the capability gaps the catalog still has. Deliberately prints no wall + * clock estimate: how long a cell takes is a property of the machine, and a + * number invented here would be quoted as if it had been measured. + */ +const printDryRun = (plan: RunPlan, backends: readonly Backend[]): void => { + const scenarios = new Set(plan.workloads.map(workload => workload.scenarioId)); + + console.log( + `\n=== Plan: ${plan.meta.planId} suite=${plan.meta.suite} rev=${String(plan.meta.planRevision)} hash=${plan.meta.planHash} domain=${plan.meta.domain} ===`, + ); + console.log( + ` ${String(scenarios.size)} scenarios, ${String(plan.workloads.length)} scenario/load pairs${plan.meta.extreme ? ' (extreme loads included)' : ''}`, + ); + + if (plan.meta.domain === 'rendering') { + const cells = resolveMatrixCells({ backends, plan }); + const frames = cells.reduce((total, cell) => total + cell.timedFrames, 0); + const arms = new Set(cells.map(cell => `${cell.engine} ${cell.config}`)); + + console.log(` ${String(cells.length)} cells over ${String(arms.size)} arms and backends [${backends.join(', ')}]`); + console.log( + ` ${String(frames)} timed frames planned, plus ${String(cells.reduce((total, cell) => total + cell.warmupFrames, 0))} discarded warmup frames`, + ); + + for (const backend of backends) { + console.log(` ${backend}: ${String(cells.filter(cell => cell.backend === backend).length)} cells`); + } + } else { + const armCount = PHYSICS_LIBRARY_ARMS.length + 1; + + console.log(` ${String(plan.workloads.length * armCount)} cells at most, over ${String(armCount)} arms (exojs plus ${PHYSICS_LIBRARY_ARMS.join(', ')})`); + console.log(' Arm availability is decided by the measuring browser, so the built matrix may be smaller; a missing arm is recorded, never dropped.'); + } + + for (const workload of plan.workloads) { + console.log( + ` ${workload.scenarioId.padEnd(22)} ${workload.loadId.padStart(5)} = ${String(workload.value).padStart(9)} ${workload.unit}${workload.primary ? ' [headline]' : ''}${workload.extreme ? ' [extreme]' : ''}`, + ); } - throw new Error(`--domain must be one of [${DOMAINS.join(', ')}] (got '${raw}').`); + if (plan.missingScenarios.length > 0) { + console.warn(`\n CAPABILITY GAP — catalog scenarios with no archetype behind them: ${plan.missingScenarios.join(', ')}`); + } }; /** @@ -186,13 +287,13 @@ const runProfileMode = async ( }; /** Run the rendering benchmark domain end-to-end and write its report artifacts. */ -const runRenderingDomain = async (args: Map): Promise => { +const runRenderingDomain = async (args: Map, selector: DomainSelector): Promise => { const backendArg = args.get('backend'); const archetypeArg = args.get('archetype'); const nodesArg = args.get('nodes'); const framesArg = args.get('frames'); const engineArg = args.get('engine'); - const outDir = resolve(args.get('out') ?? DEFAULT_OUT_DIR); + const outDir = outDirFor(args, 'rendering', selector); // `--browser` selects the engine the run is measured in and is stamped into // every provenance block, so a WebKit number can never be read as a Chromium @@ -268,7 +369,18 @@ const runRenderingDomain = async (args: Map): Promise => { return; } - const isSubset = backendArg !== undefined || hasSelection || timedFramesOverride !== undefined; + // A plan resolved WHOLE is a publishable contract however few cells it holds; + // only a free filter makes the run exploratory. `--backend` is a free filter + // in that sense too: it publishes one backend's block under a plan that names + // both. + const isSubset = backendArg !== undefined || hasSelection || timedFramesOverride !== undefined || args.has('capture'); + const plan = resolvePlanFor(args, 'rendering', isSubset); + + if (args.has('dry-run')) { + printDryRun(plan, backends); + + return; + } if (isSubset) { console.warn('SUBSET RUN — not a reportable comparison (see the same-session rule).'); @@ -285,9 +397,17 @@ const runRenderingDomain = async (args: Map): Promise => { // probe was the observed one - can never discard the cells already measured. const checkpoint = createCheckpointWriter(outDir); + // `--capture=`: write each measured cell's final frame there, for + // checking by eye that two arms asked for one scene rendered it. A capture + // costs a readback per cell, so it belongs to a spot check and never to a + // reportable run - which is why it also marks the run a subset. + const captureDir = args.get('capture'); + const data = await runMatrix({ backends, browser, + plan, + ...(captureDir !== undefined && { captureDir: resolve(captureDir) }), ...(platform !== undefined && { platform }), ...(hasSelection && { selection }), ...(timedFramesOverride !== undefined && { timedFramesOverride }), @@ -349,14 +469,12 @@ const runRenderingDomain = async (args: Map): Promise => { * analogue), `--frames` overrides the timed-step count for a fast spot-check * (never a reportable run). */ -const runPhysicsDomain = async (args: Map): Promise => { - const { runPhysicsMatrix, writePhysicsReport } = await import('./physics'); - +const runPhysicsDomain = async (args: Map, selector: DomainSelector): Promise => { const archetypeArg = args.get('archetype'); const bodiesArg = args.get('bodies'); const framesArg = args.get('frames'); const engineArg = args.get('engine'); - const outDir = resolve(args.get('out') ?? DEFAULT_PHYSICS_OUT_DIR); + const outDir = outDirFor(args, 'physics', selector); const browser = parseRenderingBrowser(args.get('browser')); const platform = resolvePlatform(args); @@ -404,6 +522,15 @@ const runPhysicsDomain = async (args: Map): Promise => { } const isSubset = archetypeArg !== undefined || bodiesArg !== undefined || engineArg !== undefined || timedStepsOverride !== undefined; + const plan = resolvePlanFor(args, 'physics', isSubset); + + if (args.has('dry-run')) { + printDryRun(plan, []); + + return; + } + + const { runPhysicsMatrix, writePhysicsReport } = await import('./physics'); if (isSubset) { console.warn('SUBSET RUN — not a reportable comparison (see the same-run rule).'); @@ -421,6 +548,7 @@ const runPhysicsDomain = async (args: Map): Promise => { const data = await runPhysicsMatrix({ browser, + plan, ...(platform !== undefined && { platform }), ...(isSubset && { filter }), ...(timedStepsOverride !== undefined && { timedStepsOverride }), @@ -463,13 +591,15 @@ const main = async (): Promise => { const args = parseArgs(process.argv.slice(2)); const domain = resolveDomain(args.get('domain')); - switch (domain) { - case 'rendering': - await runRenderingDomain(args); - break; - case 'physics': - await runPhysicsDomain(args); - break; + // Serial on purpose: the two domains contend for the same CPU (and the + // rendering one for the GPU), so running them concurrently would measure the + // contention rather than either domain. + if (domain === 'rendering' || domain === 'all') { + await runRenderingDomain(args, domain); + } + + if (domain === 'physics' || domain === 'all') { + await runPhysicsDomain(args, domain); } }; diff --git a/packages/exojs-bench/src/shared/clock.ts b/packages/exojs-bench/src/shared/clock.ts new file mode 100644 index 000000000..b4eb953c6 --- /dev/null +++ b/packages/exojs-bench/src/shared/clock.ts @@ -0,0 +1,61 @@ +/** + * What the page's own clock resolved, measured in the page that will be timed. + * + * `performance.now()` is coarsened by the engine, and by how much depends on + * the browsing context rather than on the workload - notably on whether the + * page reached a cross-origin-isolated context. A benchmark that reports + * milliseconds without recording that grid cannot tell a fast cell from an + * unresolved one, so the grid is measured alongside the cells and travels with + * them into the profile. + * + * The probe must run in the page under measurement. A value read in the driver + * process describes Node's clock and says nothing about the browser's. + */ + +/** How many readings the probe takes looking for the smallest positive step. */ +const PROBE_ITERATIONS = 10_000; + +/** What one page's clock was observed to do. */ +export interface ClockReport { + /** + * Smallest positive difference the probe observed between two consecutive + * readings, or `null` where it observed none. + * + * This is the step size the probe saw, not a calibrated error bound on any + * later measurement: engines may coarsen and jitter their timestamps, so a + * single observed minimum bounds neither the error of one sample nor the + * confidence of a comparison built from many. `null` means the probe + * established nothing and must never be read as a fine clock - it is the + * absence of the observation, not an observation of zero. + */ + readonly resolutionMs: number | null; + /** Whether the page reached a cross-origin-isolated context, which is what lifts the coarsest clamping. */ + readonly crossOriginIsolated: boolean; +} + +/** Measure the clock's observed grid in the current page. */ +export const probeClock = (): ClockReport => { + let smallest = Number.POSITIVE_INFINITY; + let previous = performance.now(); + + for (let index = 0; index < PROBE_ITERATIONS; index += 1) { + const now = performance.now(); + const delta = now - previous; + + if (delta > 0) { + smallest = Math.min(smallest, delta); + previous = now; + } + } + + return { + resolutionMs: Number.isFinite(smallest) ? smallest : null, + crossOriginIsolated: globalThis.crossOriginIsolated === true, + }; +}; + +/** How a clock report reads in a caveat or a provenance line. */ +export const describeClock = (clock: ClockReport): string => + clock.resolutionMs === null + ? `The page's performance.now() resolution could not be established (cross-origin isolated: ${String(clock.crossOriginIsolated)}); readings near it cannot be told apart from it.` + : `The page's performance.now() resolved to ${(clock.resolutionMs * 1000).toFixed(1)}us (cross-origin isolated: ${String(clock.crossOriginIsolated)}).`; diff --git a/packages/exojs-bench/src/shared/timerCheck.ts b/packages/exojs-bench/src/shared/timerCheck.ts new file mode 100644 index 000000000..f8b5f437a --- /dev/null +++ b/packages/exojs-bench/src/shared/timerCheck.ts @@ -0,0 +1,66 @@ +/** + * Whether the clock a measurement was read on was fine enough for a comparison + * to be drawn from it. + * + * A duration only a few steps above the grid it was read on carries a + * quantisation error of the same order as the difference a comparison would + * claim, so the check runs before any factor is published and travels with the + * cell into the profile. It is evaluated per RUN, against that run's own clock, + * and only then merged: pooling the durations first lets a well-resolved + * repetition carry a limited one past the threshold, which is the reading this + * check exists to prevent. + */ + +/** + * How many of the clock's observed steps a duration has to span before a + * comparison built from it publishes a factor. + * + * A guard, chosen to be safely clear of the one- and two-step readings that a + * coarse clock produces, and applied to every library, browser and profile + * alike. It is not a standard and not a precision claim: clearing it means this + * one check did not trip, never that the comparison is accurate or + * statistically established. Every other check a cell passes still applies. + */ +export const MIN_RESOLVED_STEPS = 10; + +/** What the timer check established about a comparison. */ +export type TimerCheck = 'resolved' | 'limited' | 'unknown'; + +/** + * Whether both durations of one run stand far enough above the step that run's + * clock was observed to deliver. + * + * Each duration is checked on its own - the question is how large a reading is + * against the grid it was read on, not how far the two arms are apart. A run + * with no recorded step yields `unknown`, which is the absence of the check and + * never a pass. + * + * The durations must be the ones the clock actually BRACKETED. Where a harness + * times a batch of steps and divides, the batch duration is the reading and the + * per-step quotient is not: checking the quotient would compare a derived + * number against a grid it was never read on. + */ +export const timerCheckOfRun = (durations: ReadonlyArray, resolutionMs: number | null): TimerCheck => { + if (resolutionMs === null || !Number.isFinite(resolutionMs) || resolutionMs <= 0) return 'unknown'; + + const floor = resolutionMs * MIN_RESOLVED_STEPS; + const measured = durations.filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + + return measured.some(ms => ms < floor) ? 'limited' : 'resolved'; +}; + +/** + * One verdict for a comparison from the verdicts of the runs behind it. + * + * A limitation any run established stands for the pooled figure, and a run + * whose clock was never recorded cannot lift it: missing information does not + * cancel an established one. Only a comparison whose every run cleared the + * check is reported as resolved. + */ +export const mergeTimerChecks = (checks: readonly TimerCheck[]): TimerCheck => { + if (checks.length === 0) return 'unknown'; + if (checks.includes('limited')) return 'limited'; + if (checks.includes('unknown')) return 'unknown'; + + return 'resolved'; +}; diff --git a/packages/exojs-bench/src/shared/viteServer.ts b/packages/exojs-bench/src/shared/viteServer.ts index 62551007d..633dd1a55 100644 --- a/packages/exojs-bench/src/shared/viteServer.ts +++ b/packages/exojs-bench/src/shared/viteServer.ts @@ -21,6 +21,59 @@ const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); /** The engine's TypeScript source root every harness page benchmarks (`/src`). */ const ENGINE_SRC = resolve(REPO_ROOT, 'src'); +/** + * Engine and official-extension package entries, mapped to their SOURCE. + * + * The engine's `@codexo/exojs-source` condition only redirects a package's own + * `#*` imports; the package ENTRY still resolves through `exports`, which points + * at `dist`. Without these aliases an extension arm would load the built engine + * while the adapter beside it loads the source, so one cell would measure a + * different tree from the rest of the matrix - and the two copies of a class + * fail every `instanceof` across the boundary, which is how a `ParticleSystem` + * silently kept its default capacity instead of the one the cell asked for. + */ +const SOURCE_PACKAGE_ALIASES: ReadonlyArray<{ find: string; replacement: string }> = [ + { find: '@codexo/exojs-particles', replacement: resolve(REPO_ROOT, 'packages/exojs-particles/src/index.ts') }, + { find: '@codexo/exojs-tilemap', replacement: resolve(REPO_ROOT, 'packages/exojs-tilemap/src/index.ts') }, + { find: '@codexo/exojs/renderer-sdk', replacement: resolve(ENGINE_SRC, 'renderer-sdk.ts') }, + { find: '@codexo/exojs/extensions', replacement: resolve(ENGINE_SRC, 'extensions/index.ts') }, + { find: '@codexo/exojs', replacement: resolve(ENGINE_SRC, 'index.ts') }, +]; + +/** + * Source roots whose `#*` specifiers belong to the package they sit in rather + * than to the engine - the official extensions the harness measures through. + * The benchmark package itself is deliberately absent: its adapters use `#*` to + * reach engine modules. + */ +const EXTENSION_SOURCE_ROOTS: readonly string[] = [resolve(REPO_ROOT, 'packages/exojs-particles'), resolve(REPO_ROOT, 'packages/exojs-tilemap')]; + +/** + * Resolve `#...` specifiers to the ENGINE source - but only for importers + * outside the extension packages. + * + * Each extension package has its own `#*` map pointing at its own `src`, and a + * blanket alias would send `#gpu/ParticleGpuState` into the engine tree, where + * no such module exists. Declining here leaves those specifiers to Vite's normal + * `imports` resolution, which the source conditions already steer to the + * package's own sources. + */ +const engineHashImports = () => ({ + name: 'exojs-bench-engine-hash-imports', + enforce: 'pre', + resolveId(source: string, importer: string | undefined) { + if (!source.startsWith('#') || source.endsWith('.vert') || source.endsWith('.frag')) { + return null; + } + + if (importer !== undefined && EXTENSION_SOURCE_ROOTS.some(root => resolve(importer).startsWith(root))) { + return null; + } + + return `${resolve(ENGINE_SRC, source.slice(1))}.ts`; + }, +}); + /** WebGl2Shader extensions the engine imports as text. */ const SHADER_EXTENSIONS = ['.vert', '.frag', '.glsl', '.wgsl'] as const; @@ -232,7 +285,7 @@ export const startViteServer = async (options: StartViteServerOptions): Promise< // condition below, so the engine graph is measured exactly as it ships. // `.vert`/`.frag` specifiers carry their extension and are handled by // `realShaderPlugin`'s transform. - resolve: { alias: [{ find: /^#(.*)$/, replacement: `${ENGINE_SRC}/$1` }], conditions: srcConditions }, + resolve: { alias: [...SOURCE_PACKAGE_ALIASES, { find: /^#(.*)\.(vert|frag)$/, replacement: `${ENGINE_SRC}/$1.$2` }], conditions: srcConditions }, ssr: { resolve: { conditions: srcConditions } }, // `noDiscovery` keeps the automatic dep scanner OFF - it runs esbuild over // the whole import graph, which would choke on the engine's `.vert`/`.frag` @@ -248,7 +301,7 @@ export const startViteServer = async (options: StartViteServerOptions): Promise< // pre-bundled. optimizeDeps: { noDiscovery: true, include: resolvableCompetitors(libraryArms) }, define: { __DEV__: String(ENGINE_DEV_BUILD), __VERSION__: JSON.stringify(version), __REVISION__: JSON.stringify('baseline'), ...extraDefine }, - plugins: [realShaderPlugin, devGlobalsPlugin(version), ...extraPlugins], + plugins: [engineHashImports(), realShaderPlugin, devGlobalsPlugin(version), ...extraPlugins], }); await server.listen(); diff --git a/packages/exojs-bench/src/suite/catalog.ts b/packages/exojs-bench/src/suite/catalog.ts new file mode 100644 index 000000000..6aa9e5243 --- /dev/null +++ b/packages/exojs-bench/src/suite/catalog.ts @@ -0,0 +1,428 @@ +/** + * Published workload catalog: which loads of which scenario belong to a + * comparison the project publishes, and which belong to the development matrix. + * + * The domain matrices (`buildMatrix`, `buildPhysicsMatrix`) stay the source of + * truth for what a scenario IS - its scene shape, budgets and capability gates. + * This module only states which of their rungs a given suite selects, plus the + * unit a load is quoted in, which is what lets a reader tell a million world + * tiles apart from a million visible particles. + */ + +/** Test plans a run can be resolved against. */ +export type SuiteKind = 'reference' | 'full'; + +/** + * Unit a scenario's load is quoted in. + * + * The number alone is ambiguous across the catalog - 100 000 means scene nodes + * in one scenario and world tiles in another, and those are not the same claim - + * so every load carries the unit it is counted in and the published page quotes + * it beside the figure. + */ +export type LoadUnit = 'sprites' | 'nodes' | 'labels' | 'tiles' | 'layers' | 'particles' | 'widgets' | 'rects' | 'bodies' | 'viewport'; + +/** One selectable load of one scenario. */ +export interface LoadSpec { + /** Stable identifier, unique within its scenario, used in cell and card keys. */ + readonly loadId: string; + /** + * Scalar handed to the domain matrix for this load - node count for rendering + * cells, body count for physics cells. For a scenario whose load is not a + * count of scene nodes (tiles, layers, particles, widgets, viewport rows) the + * domain archetype derives its scene parameters from this same scalar, so one + * number keeps identifying one cell. + */ + readonly value: number; + /** + * Display label overriding the formatted `value` + {@link ScenarioLoads.unit} + * pair. Only for loads the pair cannot state - a render resolution reads as + * `1280 x 720`, not as `720 viewport`. + */ + readonly label?: string; + /** Whether the `reference` plan selects this load. `full` selects every load. */ + readonly reference: boolean; + /** + * Whether this load is the scenario's headline - the one a card shows before + * the reader picks another. Exactly one load per scenario sets it. + */ + readonly primary?: boolean; + /** + * Whether this load runs only under `--extreme`. An extreme load is never part + * of `reference`: it exists to find where a scenario stops being viable, and + * that is a development question rather than a published comparison. + */ + readonly extreme?: boolean; +} + +/** Every selectable load of one scenario, in ascending order. */ +export interface ScenarioLoads { + /** Archetype id in the scenario's own domain. */ + readonly scenarioId: string; + /** Unit the loads are counted in; see {@link LoadUnit}. */ + readonly unit: LoadUnit; + /** Loads, smallest first. */ + readonly loads: readonly LoadSpec[]; +} + +/** Build a load list from a compact `[value, flags]` description. */ +const loads = (entries: ReadonlyArray): readonly LoadSpec[] => + entries.map(([value, flags, label]) => ({ + loadId: loadIdFor(value), + value, + ...(label !== undefined && { label }), + reference: flags.includes('r'), + ...(flags.includes('*') && { primary: true }), + ...(flags.includes('x') && { extreme: true }), + })); + +/** + * Canonical id for a load value: `1000` is `1k`, `100000` is `100k`, `1000000` + * is `1m`, anything else is its own digits. Ids appear in cell keys, so they are + * derived rather than hand-written - a hand-written id can disagree with the + * value it names, and the two would then identify different cells under one key. + */ +export const loadIdFor = (value: number): string => { + if (value >= 1_000_000 && value % 1_000_000 === 0) return `${String(value / 1_000_000)}m`; + if (value >= 1_000 && value % 1_000 === 0) return `${String(value / 1_000)}k`; + + return String(value); +}; + +/** + * Rendering scenarios and their loads. + * + * `reference` keeps one load for most scenarios and a short ladder for the few + * whose scaling is itself the finding, which is what makes a published run + * finish without dropping a comparison. `full` keeps every rung the development + * matrix has always had, so no historical cell stops being reachable. + * + * ExoJS-internal probes (`overdraw`, `split-screen`, the material and mesh rows, + * `instanced-batch`) are absent on purpose: a competitor arm renders some other + * scene on those, so they are development rows and never a published comparison. + * They keep running under `full` through the domain ladder. + */ +export const RENDERING_SCENARIOS: readonly ScenarioLoads[] = [ + { + scenarioId: 'static-heavy', + unit: 'sprites', + loads: loads([ + [1_000, 'r'], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, 'r'], + [1_000_000, 'x'], + ]), + }, + { + scenarioId: 'dynamic-heavy', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, ''], + ]), + }, + { + scenarioId: 'dynamic-all', + unit: 'sprites', + loads: loads([ + [1_000, 'r'], + [10_000, 'r*'], + [100_000, 'r'], + ]), + }, + { + scenarioId: 'deep-hierarchy', + unit: 'nodes', + loads: loads([ + [1_000, ''], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, ''], + ]), + }, + { + scenarioId: 'lifecycle-churn', + unit: 'nodes', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'batch-breaking', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'batch-breaking-atlased', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'mixed-blend', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'text-static', + unit: 'labels', + loads: loads([ + [200, ''], + [1_000, 'r*'], + [5_000, 'r'], + ]), + }, + { + scenarioId: 'text-dynamic', + unit: 'labels', + loads: loads([ + [200, ''], + [1_000, 'r*'], + [5_000, 'r'], + ]), + }, + { + scenarioId: 'filter-chain-1', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'filter-chain-2', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'filter-chain-4', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'composite', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'mask-clip', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'mask-clip-animated', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, 'r*'], + [25_000, ''], + ]), + }, + { + scenarioId: 'scrolling-world', + unit: 'sprites', + loads: loads([ + [1_000, ''], + [5_000, ''], + [10_000, 'r*'], + [25_000, ''], + [100_000, ''], + ]), + }, + { + scenarioId: 'fill-layers', + unit: 'layers', + loads: loads([ + [8, ''], + [32, 'r*'], + [128, ''], + ]), + }, + { + scenarioId: 'tilemap-scroll', + unit: 'tiles', + loads: loads([ + [10_000, 'r'], + [100_000, 'r*'], + [1_000_000, 'x'], + ]), + }, + { + scenarioId: 'tilemap-edit', + unit: 'tiles', + loads: loads([ + [10_000, ''], + [100_000, 'r*'], + ]), + }, + { + scenarioId: 'particles-draw', + unit: 'particles', + loads: loads([ + [1_000, 'r'], + [10_000, 'r*'], + [100_000, 'r'], + [1_000_000, 'x'], + ]), + }, + { + scenarioId: 'particles-lifecycle', + unit: 'particles', + loads: loads([ + [1_000, ''], + [10_000, 'r*'], + [100_000, ''], + ]), + }, + { + scenarioId: 'ui-layout-update', + unit: 'widgets', + loads: loads([ + [100, ''], + [1_000, 'r*'], + [5_000, ''], + ]), + }, + { + scenarioId: 'interaction-picking', + unit: 'rects', + loads: loads([ + [1_000, ''], + [10_000, 'r*'], + [100_000, ''], + ]), + }, + { + scenarioId: 'fx-blur', + unit: 'viewport', + loads: loads([ + [360, '', '640 x 360'], + [720, 'r*', '1280 x 720'], + [1_080, '', '1920 x 1080'], + ]), + }, +]; + +/** + * Physics scenarios and their loads. + * + * Each ladder is the domain's own, unchanged; `reference` selects the middle + * rung, which is the one that straddles the 60 fps frame (see the ladder notes + * in `physics/archetypes.ts`). Nothing here re-derives a scene: a rung that + * moved would be a different world under the same name. + */ +export const PHYSICS_SCENARIOS: readonly ScenarioLoads[] = [ + { + scenarioId: 'box-stack', + unit: 'bodies', + loads: loads([ + [3_000, ''], + [5_500, 'r*'], + [10_000, ''], + ]), + }, + { + scenarioId: 'many-dynamic', + unit: 'bodies', + loads: loads([ + [800, ''], + [1_500, 'r*'], + [2_200, ''], + ]), + }, + { + scenarioId: 'mixed-static-dynamic', + unit: 'bodies', + loads: loads([ + [900, ''], + [1_700, 'r*'], + [3_200, ''], + ]), + }, + { + scenarioId: 'raycast', + unit: 'bodies', + loads: loads([ + [900, ''], + [1_700, 'r*'], + [3_200, ''], + ]), + }, + { + scenarioId: 'body-churn', + unit: 'bodies', + loads: loads([ + [800, ''], + [1_500, 'r*'], + [2_400, ''], + ]), + }, + { + scenarioId: 'joints', + unit: 'bodies', + loads: loads([ + [4_500, ''], + [9_000, 'r*'], + [15_000, ''], + ]), + }, + { + scenarioId: 'settling-pile', + unit: 'bodies', + loads: loads([ + [1_500, ''], + [3_000, 'r*'], + [5_800, ''], + ]), + }, +]; + +/** Scenario catalog for one benchmark domain. */ +export const scenariosFor = (domain: 'rendering' | 'physics'): readonly ScenarioLoads[] => (domain === 'rendering' ? RENDERING_SCENARIOS : PHYSICS_SCENARIOS); + +/** The scenario's headline load, i.e. the one a published card shows first. */ +export const primaryLoadOf = (scenario: ScenarioLoads): LoadSpec => { + const primary = scenario.loads.find(load => load.primary === true); + + if (primary === undefined) { + throw new Error(`Scenario '${scenario.scenarioId}' names no primary load; exactly one load must carry it.`); + } + + return primary; +}; diff --git a/packages/exojs-bench/src/suite/plan.ts b/packages/exojs-bench/src/suite/plan.ts new file mode 100644 index 000000000..8e7da9cdb --- /dev/null +++ b/packages/exojs-bench/src/suite/plan.ts @@ -0,0 +1,216 @@ +import { createHash } from 'node:crypto'; + +import type { LoadUnit, ScenarioLoads, SuiteKind } from './catalog'; +import { loadIdFor, scenariosFor } from './catalog'; + +/** Benchmark domains a plan can be resolved for. */ +export type PlanDomain = 'rendering' | 'physics'; + +/** One scenario load the plan selected, with everything a cell key needs. */ +export interface WorkloadSpec { + /** Archetype id in its own domain. */ + readonly scenarioId: string; + /** Load identifier within the scenario. */ + readonly loadId: string; + /** Scalar the domain matrix builds the cell from - node count or body count. */ + readonly value: number; + /** Unit the load is counted in; carried so a figure is never quoted without it. */ + readonly unit: LoadUnit; + /** Display label for loads the value/unit pair cannot state, e.g. a resolution. */ + readonly label?: string; + /** Whether this is the scenario's headline load. */ + readonly primary: boolean; + /** Whether this load was admitted by `--extreme` rather than by the suite itself. */ + readonly extreme: boolean; +} + +/** Identity of one resolved plan, stamped into every artifact the run writes. */ +export interface RunPlanMeta { + /** Plan the run was resolved against. */ + readonly suite: SuiteKind; + /** Stable name of the plan family. */ + readonly planId: 'exojs-comparison'; + /** + * Revision of the catalog's published contract. Bumped when a reference load, + * a scenario's membership or a unit changes, so two runs that resolved + * different contracts can never be pooled under one identity. + */ + readonly planRevision: number; + /** Digest over the resolved workloads; see {@link computePlanHash}. */ + readonly planHash: string; + /** Domain this plan covers. */ + readonly domain: PlanDomain; + /** Whether extreme loads were admitted. */ + readonly extreme: boolean; + /** + * Whether the run narrowed the plan with free filters (`--archetype`, + * `--engine`, `--nodes`, `--frames`). + * + * An exploratory run is never a published comparison: its cells do not match + * the plan the comparison builder expects, so pooling it with a full run would + * mix two different questions. A plan resolved WHOLE is publishable regardless + * of how few cells it has - a smaller published contract is still a contract. + */ + readonly exploratory: boolean; +} + +/** A resolved plan: its identity and the workloads it selected. */ +export interface RunPlan { + readonly meta: RunPlanMeta; + readonly workloads: readonly WorkloadSpec[]; + /** + * Catalog scenarios with no archetype behind them yet, i.e. named capability + * gaps. Reported rather than dropped: a comparison that quietly lost a + * scenario reads exactly like one that never promised it. + */ + readonly missingScenarios: readonly string[]; +} + +/** Current revision of the published catalog contract; see {@link RunPlanMeta.planRevision}. */ +export const PLAN_REVISION = 1; + +/** + * Digest over a plan's semantic content only - suite, domain, and each selected + * workload's scenario, load and unit. + * + * Deliberately excludes timestamps, output paths and host data: the hash answers + * "did these two runs measure the same contract", and a path difference is not a + * contract difference. A changed load value or unit is. + */ +export const computePlanHash = (suite: SuiteKind, domain: PlanDomain, workloads: readonly WorkloadSpec[]): string => { + const body = workloads.map(workload => `${workload.scenarioId}/${workload.loadId}/${String(workload.value)}/${workload.unit}`).join('\n'); + + return createHash('sha256') + .update(`exojs-bench-plan/v1\n${suite}\n${domain}\n${String(PLAN_REVISION)}\n${body}`) + .digest('hex') + .slice(0, 16); +}; + +/** + * Canonical key of one measured cell. + * + * Carries the arm, its configuration, the backend, the scenario revision and the + * load, because each of those changes what was measured. Two results may only be + * pooled when their keys match; anything coarser pools a WebGPU cell with a + * WebGL2 one, or this revision of a scene with the previous one. + */ +export const cellKey = (parts: { + readonly domain: PlanDomain; + readonly engine: string; + readonly config: string; + readonly backend: string; + readonly scenarioId: string; + readonly loadId: string; +}): string => `${parts.domain}|${parts.engine}|${parts.config}|${parts.backend}|${parts.scenarioId}|${parts.loadId}`; + +/** Whether a scenario's load belongs to the given suite, once `--extreme` is accounted for. */ +const admits = (suite: SuiteKind, extreme: boolean, load: ScenarioLoads['loads'][number]): boolean => { + if (load.extreme === true) { + return suite === 'full' && extreme; + } + + return suite === 'full' || load.reference; +}; + +/** + * Resolve a suite into the workloads it selects. + * + * `ladders` maps every archetype id the domain implements to its own load + * ladder. Two things follow from taking the whole domain rather than only the + * catalog: + * + * - A catalog scenario with no archetype behind it is reported in + * {@link RunPlan.missingScenarios} rather than planned, so a capability gap is + * visible in the dry run instead of surfacing as a column of failed cells. + * - `full` keeps every archetype the domain has, catalog or not, on its own + * ladder. The development matrix is what `full` means, and the ExoJS-internal + * probes deliberately absent from the published catalog must not disappear + * with it. + */ +export const resolveSuitePlan = (options: { + readonly suite: SuiteKind; + readonly domain: PlanDomain; + readonly extreme?: boolean; + readonly exploratory?: boolean; + readonly ladders: ReadonlyMap; +}): RunPlan => { + const extreme = options.extreme ?? false; + + if (extreme && options.suite !== 'full') { + throw new Error('--extreme is only valid with --suite=full: an extreme load is a development probe and is never part of a published reference comparison.'); + } + + const workloads: WorkloadSpec[] = []; + const missingScenarios: string[] = []; + const catalogued = new Set(); + + for (const scenario of scenariosFor(options.domain)) { + catalogued.add(scenario.scenarioId); + + if (!options.ladders.has(scenario.scenarioId)) { + missingScenarios.push(scenario.scenarioId); + continue; + } + + for (const load of scenario.loads) { + if (!admits(options.suite, extreme, load)) { + continue; + } + + workloads.push({ + scenarioId: scenario.scenarioId, + loadId: load.loadId, + value: load.value, + unit: scenario.unit, + ...(load.label !== undefined && { label: load.label }), + primary: load.primary === true, + extreme: load.extreme === true, + }); + } + } + + if (options.suite === 'full') { + const unit: LoadUnit = options.domain === 'rendering' ? 'nodes' : 'bodies'; + + for (const [scenarioId, ladder] of options.ladders) { + if (catalogued.has(scenarioId)) { + continue; + } + + for (const value of ladder) { + workloads.push({ scenarioId, loadId: loadIdFor(value), value, unit, primary: false, extreme: false }); + } + } + } + + return { + meta: { + suite: options.suite, + planId: 'exojs-comparison', + planRevision: PLAN_REVISION, + planHash: computePlanHash(options.suite, options.domain, workloads), + domain: options.domain, + extreme, + exploratory: options.exploratory ?? false, + }, + workloads, + missingScenarios, + }; +}; + +/** Load values the plan selected for one scenario, ascending. */ +export const loadValuesFor = (plan: RunPlan, scenarioId: string): readonly number[] => + plan.workloads.filter(workload => workload.scenarioId === scenarioId).map(workload => workload.value); + +/** Parse and validate the `--suite` selector. */ +export const parseSuite = (raw: string | undefined): SuiteKind => { + if (raw === undefined || raw === 'full') { + return 'full'; + } + + if (raw === 'reference') { + return 'reference'; + } + + throw new Error(`--suite must be one of [reference, full] (got '${raw}').`); +}; diff --git a/packages/exojs-bench/test/archetypes.test.ts b/packages/exojs-bench/test/archetypes.test.ts index 3289d7680..10b010d41 100644 --- a/packages/exojs-bench/test/archetypes.test.ts +++ b/packages/exojs-bench/test/archetypes.test.ts @@ -1,6 +1,7 @@ import { createExoJsAdapter } from '../src/rendering/adapters/exojs'; import { ARCHETYPES, buildMatrix, createRng, timedFramesFor, warmupFramesFor } from '../src/rendering/archetypes'; import type { Backend, EngineAdapter } from '../src/rendering/EngineAdapter'; +import { hasFullViewportLeaves, leafAlpha } from '../src/rendering/traits'; describe('createRng', () => { test('is deterministic for a given seed', () => { @@ -71,6 +72,35 @@ describe('ARCHETYPES', () => { expect(byId['dynamic-heavy']!.mutationFraction).toBeGreaterThan(0); }); + test('dynamic-all moves every leaf while dynamic-heavy keeps moving only a few', () => { + const byId = Object.fromEntries(ARCHETYPES.map(a => [a.id, a])); + const all = byId['dynamic-all']!; + const heavy = byId['dynamic-heavy']!; + + expect(all.mutationFraction).toBe(1); + // The delta between the two rows is only the mutated fraction, so every + // other field has to stay identical. + expect(heavy.mutationFraction).toBe(0.075); + expect(all.nestingDepth).toBe(heavy.nestingDepth); + expect(all.textureCount).toBe(heavy.textureCount); + expect(all.cullingEnabled).toBe(heavy.cullingEnabled); + }); + + test('fill-layers stacks translucent viewport-sized layers on a ladder of its own', () => { + const byId = Object.fromEntries(ARCHETYPES.map(a => [a.id, a])); + const layers = byId['fill-layers']!; + + expect(hasFullViewportLeaves(layers)).toBe(true); + expect(leafAlpha(layers)).toBeLessThan(1); + // The load is full-screen layers, not scene nodes, so the sprite ladder + // would describe a workload nothing ships. + expect(layers.nodeCounts).toEqual([8, 32, 128]); + // `overdraw` shares the geometry and is deliberately opaque: it probes the + // fill ceiling, where this one probes an ordinary layered scene. + expect(hasFullViewportLeaves(byId['overdraw']!)).toBe(true); + expect(leafAlpha(byId['overdraw']!)).toBe(1); + }); + test('separates retained-recordable mesh switches from array-mesh repacking', () => { const byId = Object.fromEntries(ARCHETYPES.map(a => [a.id, a])); const staticMesh = byId['mixed-sprite-mesh-static']!; diff --git a/packages/exojs-bench/test/comparison.test.ts b/packages/exojs-bench/test/comparison.test.ts index 74cfba887..9a8ce76c1 100644 --- a/packages/exojs-bench/test/comparison.test.ts +++ b/packages/exojs-bench/test/comparison.test.ts @@ -10,6 +10,14 @@ import type { PhysicsReportData } from '../src/physics/report'; import type { Provenance } from '../src/rendering/driver'; import type { ArchetypeId, Backend, CellResult } from '../src/rendering/EngineAdapter'; import type { ReportData } from '../src/rendering/report'; +import type { ClockReport } from '../src/shared/clock'; +import type { TimerCheck } from '../src/shared/timerCheck'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; /** A measured rendering cell, with everything the comparison does not read left at a neutral value. */ const cell = (options: { @@ -40,6 +48,7 @@ const cell = (options: { queueMsMedian: null, queueMsP95: null, structural: { drawCalls: options.drawCalls ?? 1, textureBinds: options.textureBinds ?? 1, bufferUploads: options.bufferUploads ?? 1 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', }); @@ -66,6 +75,7 @@ const stamp = (engineVersion = '0.17.0', overrides: Partial = {}): P browser: 'chromium', browserVersion: '151.0.7922.34', os: 'win32 10.0.26200', + clock: FINE_CLOCK, platformVersion: { major: 11, source: 'detected', evidence: "os.release() reported '10.0.26200'" }, prerelease: { value: false, source: 'assumed-stable', evidence: 'no marker, none declared' }, flags: ['--force-device-scale-factor=1'], @@ -271,25 +281,55 @@ describe('buildRenderingComparison', () => { expect(blocks.map(block => block.backend)).toEqual(['webgl2']); }); - test('picks one count for the whole table and applies it to every row', () => { + test('publishes a row per measured load rather than collapsing onto one count', () => { const [block] = buildRenderingComparison(fixture()); - const counts = new Set(block!.sections.flatMap(section => section.rows.map(row => row.count))); + const rows = block!.sections.flatMap(section => section.rows); - expect(block!.headlineCount).toBe(5_000); - expect([...counts]).toEqual([5_000]); + expect(rows.filter(row => row.archetype === 'static-heavy').map(row => row.count)).toEqual([1_000, 5_000]); + expect(rows.filter(row => row.archetype === 'static-heavy').map(row => row.loadId)).toEqual(['1k', '5k']); + }); + + test('states the unit each load is counted in and marks the catalog headline', () => { + const [block] = buildRenderingComparison(fixture()); + const rows = block!.sections.flatMap(section => section.rows); + + expect(rows.find(row => row.archetype === 'static-heavy')!.unit).toBe('sprites'); + expect(rows.find(row => row.archetype === 'text-static')!.unit).toBe('labels'); + expect(rows.find(row => row.archetype === 'text-static' && row.count === 1_000)!.primary).toBe(true); + expect(rows.find(row => row.archetype === 'text-static' && row.count === 5_000)!.primary).toBe(false); }); test('computes each verdict from the two medians', () => { const [block] = buildRenderingComparison(fixture()); const rows = block!.sections.flatMap(section => section.rows); - const scaling = rows.find(row => row.archetype === 'static-heavy')!; - const text = rows.find(row => row.archetype === 'text-static')!; + const scaling = rows.find(row => row.archetype === 'static-heavy' && row.count === 5_000)!; + const text = rows.find(row => row.archetype === 'text-static' && row.count === 5_000)!; expect(scaling.cells[0]!.verdict.side).toBe('exojs'); expect(scaling.cells[0]!.verdict.structural).toBe(true); expect(text.cells[0]!.verdict.side).toBe('neither'); }); + test('qualifies each cell against the grid its own session read it on', () => { + const coarse: ClockReport = { resolutionMs: 0.5, crossOriginIsolated: false }; + const [block] = buildRenderingComparison([ + cell({ engine: 'exojs', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 1 }), + { ...cell({ engine: 'pixi', config: 'default', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 40, drawCalls: 50 }), clock: coarse }, + ]); + const row = block!.sections.flatMap(section => section.rows).find(entry => entry.archetype === 'static-heavy')!; + + // 40 ms clears a 0.5 ms grid, but the ExoJS arm's 1 ms does not stand ten + // steps above it, so the pair publishes no factor. + expect(row.cells[0]!.timer).toBe('resolved'); + + const [limited] = buildRenderingComparison([ + { ...cell({ engine: 'exojs', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 1 }), clock: coarse }, + cell({ engine: 'pixi', config: 'default', archetype: 'static-heavy', nodeCount: 5_000, cpuMsMedian: 40, drawCalls: 50 }), + ]); + + expect(limited!.sections.flatMap(section => section.rows)[0]!.cells[0]!.timer).toBe('limited'); + }); + test('files rows under category sections and never emits a category row', () => { const [block] = buildRenderingComparison(fixture()); @@ -361,35 +401,48 @@ describe('buildPhysicsComparison', () => { physicsCell({ engine: 'matter-js', archetype, bodyCount, stepMsMedian: competitorMs, contactCount: 50 }), ]); - test('publishes a row per archetype at the top of that archetype own ladder', () => { + test('publishes a row per measured rung of the archetype own ladder', () => { const section = buildPhysicsComparison(sweep('box-stack', 1, 3)); - expect(section.rows).toHaveLength(1); - expect(section.rows[0]!.count).toBe(ladderOf('box-stack').at(-1)); - expect(section.rows[0]!.cells[0]!.verdict.side).toBe('exojs'); + expect(section.rows.map(row => row.count)).toEqual([...ladderOf('box-stack')]); + expect(section.rows.every(row => row.cells[0]!.verdict.side === 'exojs')).toBe(true); }); - test('gives each archetype the count from its OWN ladder, never one archetype the count of another', () => { + test('gives each archetype the counts from its OWN ladder, never one archetype the counts of another', () => { const section = buildPhysicsComparison([...sweep('box-stack', 1, 3), ...sweep('joints', 1, 3)]); - const counts = new Map(section.rows.map(row => [row.archetype, row.count])); + const counts = (archetype: string): number[] => section.rows.filter(row => row.archetype === archetype).map(row => row.count); - expect(counts.get('box-stack')).toBe(ladderOf('box-stack').at(-1)); - expect(counts.get('joints')).toBe(ladderOf('joints').at(-1)); + expect(counts('box-stack')).toEqual([...ladderOf('box-stack')]); + expect(counts('joints')).toEqual([...ladderOf('joints')]); // The two ladders share no rung, so a single table-wide count would have had // to publish one of these rows at the other's size, or publish neither. - expect(counts.get('box-stack')).not.toBe(counts.get('joints')); + expect(counts('box-stack').some(count => counts('joints').includes(count))).toBe(false); }); - test('lowers one archetype to its next rung without moving any other archetype', () => { + test('drops only the rung an arm failed at, leaving every other rung of every archetype', () => { const ladder = ladderOf('box-stack'); const results = [...sweep('box-stack', 1, 3), ...sweep('joints', 1, 3)].map(result => result.spec.archetype === 'box-stack' && result.spec.bodyCount === ladder.at(-1) ? { ...result, status: 'exceeded' as const } : result, ); const section = buildPhysicsComparison(results); - const counts = new Map(section.rows.map(row => [row.archetype, row.count])); + const counts = (archetype: string): number[] => section.rows.filter(row => row.archetype === archetype).map(row => row.count); + + expect(counts('box-stack')).toEqual(ladder.slice(0, -1)); + expect(counts('joints')).toEqual([...ladderOf('joints')]); + }); + + test('checks the timer against the batch the clock bracketed, not the per-step quotient', () => { + const coarse: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: false }; + // 0.002 ms per step is two steps of the grid and would read as limited on its + // own; the harness timed 20 of them at once, so the bracket was 0.040 ms. + const batched = sweep('box-stack', 0.002, 0.002).map(result => ({ ...result, stepsPerSample: 20 })); + + expect(buildPhysicsComparison(batched, coarse).rows[0]!.cells[0]!.timer).toBe('resolved'); + expect(buildPhysicsComparison(sweep('box-stack', 0.002, 0.002), coarse).rows[0]!.cells[0]!.timer).toBe('limited'); + }); - expect(counts.get('box-stack')).toBe(ladder.at(-2)); - expect(counts.get('joints')).toBe(ladderOf('joints').at(-1)); + test('reports an unrecorded clock as unknown rather than as a pass', () => { + expect(buildPhysicsComparison(sweep('box-stack', 1, 3)).rows[0]!.cells[0]!.timer).toBe('unknown'); }); test('publishes each arm p95 beside its median, and computes the verdict from the medians alone', () => { @@ -572,20 +625,25 @@ describe('aggregatePhysicsRuns', () => { expect(() => aggregatePhysicsRuns([run(2), older])).toThrow(/different machine/); }); - test('pools each archetype at the count its own ladder produced, and publishes its p95', () => { - const pooled = aggregatePhysicsRuns([run(3), run(3), run(3)]).section.rows[0]!; + test('pools every rung the ladder produced, keeping each load apart, and publishes its p95', () => { + const section = aggregatePhysicsRuns([run(3), run(3), run(3)]).section; - expect(pooled.count).toBe(boxStackLadder.at(-1)); - expect(pooled.cells[0]!.referenceP95Ms).toBe(1.2); - expect(pooled.cells[0]!.competitorP95Ms).toBeCloseTo(3.6, 10); + expect(section.rows.map(row => row.count)).toEqual([...boxStackLadder]); + expect(section.rows[0]!.cells[0]!.referenceP95Ms).toBe(1.2); + expect(section.rows[0]!.cells[0]!.competitorP95Ms).toBeCloseTo(3.6, 10); }); - test('rejects runs whose rows landed on different counts, which would pool medians of different scenes', () => { + test('drops a rung one run could not measure instead of pooling it with the rungs that stayed', () => { const lowered = physicsRun( run(3).results.map(result => (result.spec.bodyCount === boxStackLadder.at(-1) ? { ...result, status: 'exceeded' as const } : result)), ); + const section = aggregatePhysicsRuns([run(3), run(3), lowered]).section; + const top = section.rows.find(row => row.count === boxStackLadder.at(-1))!; - expect(() => aggregatePhysicsRuns([run(3), run(3), lowered])).toThrow(/at different counts/); + // The rung is still published - two runs measured it - but it can no longer + // claim a verdict, because one run placed the pair on no rung at all. + expect(top.cells[0]!.aggregate.runs).toBe(2); + expect(top.cells[0]!.aggregate.stable).toBe(false); }); test('recomputes the frame-budget mark from the pooled median, not from any one run', () => { @@ -680,10 +738,12 @@ describe('renderComparison', () => { ), ), }); - const row = document.split('\n').find(line => line.startsWith('| `box-stack`'))!; + const rows = document.split('\n').filter(line => line.startsWith('| `box-stack`')); + const row = rows.at(-1)!; expect(document).toContain('own body-count ladder'); expect(document).toContain('rows are not comparable with one another'); + expect(rows).toHaveLength(ladder.length); expect(row).toContain(`| ${String(ladder.at(-1))} |`); expect(row).toContain('20.000 ms'); expect(row).toContain('over the 16.7 ms frame'); diff --git a/packages/exojs-bench/test/profile.test.ts b/packages/exojs-bench/test/profile.test.ts index c45b05553..8416a5c94 100644 --- a/packages/exojs-bench/test/profile.test.ts +++ b/packages/exojs-bench/test/profile.test.ts @@ -26,6 +26,7 @@ const renderingStamp = (adapter: string, backend: RenderingStamp['backend'] = 'w os: '', platformVersion: WINDOWS_11, prerelease: { value: false, source: 'assumed-stable', evidence: 'no marker, none declared' }, + clock: { resolutionMs: 0.001, crossOriginIsolated: true }, flags: [], headless: true, software: false, diff --git a/packages/exojs-bench/test/report-merge.test.ts b/packages/exojs-bench/test/report-merge.test.ts index 4a9c0cdf0..aed039669 100644 --- a/packages/exojs-bench/test/report-merge.test.ts +++ b/packages/exojs-bench/test/report-merge.test.ts @@ -10,8 +10,15 @@ import { type PhysicsReportData, writePhysicsReport } from '../src/physics/repor import type { Provenance } from '../src/rendering/driver'; import type { ArchetypeId, Backend, CellResult } from '../src/rendering/EngineAdapter'; import { type ReportData, writeReport } from '../src/rendering/report'; +import type { ClockReport } from '../src/shared/clock'; import { mergeCellResults, mergeLibraries } from '../src/shared/report'; +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; + const cell = (options: { archetype: ArchetypeId; nodeCount: number; @@ -36,6 +43,7 @@ const cell = (options: { queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 1, textureBinds: 1, bufferUploads: 1 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', }); @@ -45,6 +53,7 @@ const stamp = (backend: Backend, timestamp: string): Provenance => ({ browser: 'chromium', browserVersion: '151.0.7922.34', os: 'win32 10.0.26200', + clock: FINE_CLOCK, platformVersion: { major: 11, source: 'detected', evidence: "os.release() reported '10.0.26200'" }, prerelease: { value: false, source: 'assumed-stable', evidence: 'no marker, none declared' }, flags: [], diff --git a/packages/exojs-bench/test/scrolling-world.test.ts b/packages/exojs-bench/test/scrolling-world.test.ts index 5243aaf4b..47e436786 100644 --- a/packages/exojs-bench/test/scrolling-world.test.ts +++ b/packages/exojs-bench/test/scrolling-world.test.ts @@ -1,5 +1,6 @@ import { ARCHETYPES, buildMatrix } from '../src/rendering/archetypes'; import type { ArchetypeSpec, Backend, EngineAdapter } from '../src/rendering/EngineAdapter'; +import { isTilemap } from '../src/rendering/tilemap'; import { cameraCenterAt, GRID_MARGIN, isScrolling, SPRITE_SIZE, VIEWPORT_HEIGHT, VIEWPORT_WIDTH, visibleLeafCount, worldExtent } from '../src/rendering/world'; const scrollingWorld = ARCHETYPES.find(archetype => archetype.id === 'scrolling-world')!; @@ -8,9 +9,12 @@ const scrollingWorld = ARCHETYPES.find(archetype => archetype.id === 'scrolling- const SAMPLE_FRAMES = [0, 1, 7, 60, 120, 199, 331, 512, 900, 1_337]; describe('scrolling-world archetype', () => { - test('is the only archetype with off-screen content and a moving camera', () => { + test('is the only SPRITE archetype with off-screen content and a moving camera', () => { const scrolling = ARCHETYPES.filter(isScrolling); - const culling = ARCHETYPES.filter(archetype => archetype.cullingEnabled); + // The tilemap scenes also hold more world than they show, but they express + // their camera through the tile path rather than through `cameraSpeed`, so + // only their culling flag overlaps with this one. + const culling = ARCHETYPES.filter(archetype => archetype.cullingEnabled && !isTilemap(archetype)); expect(scrolling.map(archetype => archetype.id)).toEqual(['scrolling-world']); expect(culling.map(archetype => archetype.id)).toEqual(['scrolling-world']); @@ -135,7 +139,7 @@ describe('arm coverage', () => { }); test('the culled Pixi variant is measured only where culling can remove something', () => { - const cells = buildMatrix([fakeAdapter('pixi', 'culled', spec => spec.cullingEnabled)], ['webgl2']); + const cells = buildMatrix([fakeAdapter('pixi', 'culled', spec => spec.cullingEnabled && !isTilemap(spec))], ['webgl2']); expect(new Set(cells.map(cell => cell.archetype))).toEqual(new Set(['scrolling-world'])); expect(cells.map(cell => cell.nodeCount)).toEqual([...scrollingWorld.nodeCounts]); diff --git a/packages/exojs-bench/test/selection-and-hitching.test.ts b/packages/exojs-bench/test/selection-and-hitching.test.ts index ea76dfd71..59735cda8 100644 --- a/packages/exojs-bench/test/selection-and-hitching.test.ts +++ b/packages/exojs-bench/test/selection-and-hitching.test.ts @@ -1,6 +1,13 @@ import type { CellResult, CellSpec } from '../src/rendering/EngineAdapter'; import { isHitching } from '../src/rendering/report'; import { applySelection } from '../src/rendering/selection'; +import type { ClockReport } from '../src/shared/clock'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; const cell = (overrides: Partial = {}): CellSpec => ({ engine: 'exojs', @@ -22,6 +29,7 @@ const result = (cpuMsMedian: number, cpuMsP95: number): CellResult => ({ queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 0, textureBinds: 0, bufferUploads: 0 }, + clock: FINE_CLOCK, status: 'ok', }); diff --git a/packages/exojs-bench/test/structural-gate.test.ts b/packages/exojs-bench/test/structural-gate.test.ts index 3e0873579..0a2ff1158 100644 --- a/packages/exojs-bench/test/structural-gate.test.ts +++ b/packages/exojs-bench/test/structural-gate.test.ts @@ -13,6 +13,13 @@ import { recordBaseline, UNGUARDED_ARCHETYPES, } from '../src/rendering/structuralGate'; +import type { ClockReport } from '../src/shared/clock'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; /** A measured cell with the counters the gate reads. */ const cell = (options: { @@ -40,6 +47,7 @@ const cell = (options: { queueMsMedian: null, queueMsP95: null, structural: { drawCalls: options.drawCalls, textureBinds: options.textureBinds ?? 0, bufferUploads: options.bufferUploads ?? 0 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', ...(options.note !== undefined && { note: options.note }), }); diff --git a/packages/exojs-bench/test/suite-plan.test.ts b/packages/exojs-bench/test/suite-plan.test.ts new file mode 100644 index 000000000..f7688dc4f --- /dev/null +++ b/packages/exojs-bench/test/suite-plan.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; + +import { PHYSICS_ARCHETYPES } from '../src/physics/archetypes'; +import { ARCHETYPES } from '../src/rendering/archetypes'; +import type { CellSpec } from '../src/rendering/EngineAdapter'; +import { applyPlan } from '../src/rendering/selection'; +import type { ScenarioLoads } from '../src/suite/catalog'; +import { PHYSICS_SCENARIOS, primaryLoadOf, RENDERING_SCENARIOS } from '../src/suite/catalog'; +import type { PlanDomain } from '../src/suite/plan'; +import { cellKey, parseSuite, resolveSuitePlan } from '../src/suite/plan'; + +const RENDERING_LADDERS = new Map(ARCHETYPES.map(archetype => [archetype.id as string, archetype.nodeCounts])); +const PHYSICS_LADDERS = new Map(PHYSICS_ARCHETYPES.map(archetype => [archetype.id as string, archetype.bodyCounts])); + +const planFor = (suite: 'reference' | 'full', domain: PlanDomain, extreme = false): ReturnType => + resolveSuitePlan({ suite, domain, extreme, ladders: domain === 'rendering' ? RENDERING_LADDERS : PHYSICS_LADDERS }); + +const scenarioIds = (plan: ReturnType): Set => new Set(plan.workloads.map(workload => workload.scenarioId)); + +/** + * ExoJS-internal probes: archetypes the published catalog deliberately omits + * because a competitor arm renders some other scene on them. They must survive + * `full` and must never reach `reference`. + */ +const INTERNAL_PROBES = [ + 'split-screen', + 'instanced-batch', + 'mixed-material', + 'mixed-material-atlased', + 'mixed-sprite-mesh-static', + 'mixed-sprite-mesh-array', + 'overdraw', +]; + +describe('published workload catalog', () => { + const catalogs: readonly (readonly [string, readonly ScenarioLoads[]])[] = [ + ['rendering', RENDERING_SCENARIOS], + ['physics', PHYSICS_SCENARIOS], + ]; + + for (const [domain, scenarios] of catalogs) { + it(`${domain}: every scenario names exactly one headline load, and it is part of the reference plan`, () => { + const offenders = scenarios.filter(scenario => scenario.loads.filter(load => load.primary === true).length !== 1 || !primaryLoadOf(scenario).reference); + + expect(offenders.map(scenario => scenario.scenarioId)).toStrictEqual([]); + }); + + it(`${domain}: load ids and values are unique and ascending within a scenario`, () => { + const offenders = scenarios.filter(scenario => { + const values = scenario.loads.map(load => load.value); + const ascending = values.every((value, index) => index === 0 || value > values[index - 1]!); + + return !ascending || new Set(scenario.loads.map(load => load.loadId)).size !== scenario.loads.length; + }); + + expect(offenders.map(scenario => scenario.scenarioId)).toStrictEqual([]); + }); + + it(`${domain}: no extreme load is part of the reference plan`, () => { + for (const scenario of scenarios) { + for (const load of scenario.loads.filter(entry => entry.extreme === true)) { + expect(load.reference).toBe(false); + } + } + }); + } + + it('physics loads are ladder rungs, because a physics seed folds the body count in', () => { + const offRungs = PHYSICS_SCENARIOS.flatMap(scenario => + scenario.loads + .filter(load => PHYSICS_LADDERS.get(scenario.scenarioId)?.includes(load.value) !== true) + .map(load => `${scenario.scenarioId}/${load.loadId}`), + ); + + expect(offRungs).toStrictEqual([]); + }); +}); + +describe('suite resolution', () => { + it('reference selects the headline load of every catalogued scenario and no internal probe', () => { + const plan = planFor('reference', 'rendering'); + const selected = scenarioIds(plan); + + for (const probe of INTERNAL_PROBES) { + expect(selected).not.toContain(probe); + } + + for (const scenario of RENDERING_SCENARIOS) { + if (!RENDERING_LADDERS.has(scenario.scenarioId)) { + continue; + } + + const headline = primaryLoadOf(scenario); + + expect(plan.workloads.some(workload => workload.scenarioId === scenario.scenarioId && workload.loadId === headline.loadId && workload.primary)).toBe( + true, + ); + } + }); + + it('reference carries no extreme load and no million-scale cell', () => { + const plan = planFor('reference', 'rendering'); + + expect(plan.workloads.some(workload => workload.extreme)).toBe(false); + expect(plan.workloads.some(workload => workload.value >= 1_000_000)).toBe(false); + }); + + it('full keeps every archetype the domain implements, including the internal probes', () => { + const selected = scenarioIds(planFor('full', 'rendering')); + + expect(ARCHETYPES.map(archetype => archetype.id).filter(id => !selected.has(id))).toStrictEqual([]); + }); + + it('full is a superset of reference, load by load', () => { + const reference = planFor('reference', 'rendering'); + const full = new Set(planFor('full', 'rendering').workloads.map(workload => `${workload.scenarioId}/${workload.loadId}`)); + + for (const workload of reference.workloads) { + expect(full).toContain(`${workload.scenarioId}/${workload.loadId}`); + } + }); + + it('full keeps every rung of every existing development ladder, extreme loads aside', () => { + const full = new Set(planFor('full', 'rendering').workloads.map(workload => `${workload.scenarioId}/${String(workload.value)}`)); + const extreme = new Set( + planFor('full', 'rendering', true) + .workloads.filter(workload => workload.extreme) + .map(workload => `${workload.scenarioId}/${String(workload.value)}`), + ); + const missing = ARCHETYPES.flatMap(archetype => + archetype.nodeCounts.map(nodeCount => `${archetype.id}/${String(nodeCount)}`).filter(key => !full.has(key) && !extreme.has(key)), + ); + + expect(missing).toStrictEqual([]); + }); + + it('extreme loads are admitted only by full plus the flag', () => { + expect(planFor('full', 'rendering').workloads.some(workload => workload.extreme)).toBe(false); + expect(planFor('full', 'rendering', true).workloads.some(workload => workload.extreme)).toBe(true); + expect(() => planFor('reference', 'rendering', true)).toThrow(/only valid with --suite=full/); + }); + + it('names a catalogued scenario with no archetype behind it instead of dropping it', () => { + const plan = resolveSuitePlan({ suite: 'reference', domain: 'rendering', ladders: new Map([['static-heavy', [10_000]]]) }); + + expect(plan.missingScenarios).toContain('tilemap-scroll'); + expect(scenarioIds(plan)).toStrictEqual(new Set(['static-heavy'])); + }); + + it('parseSuite defaults to full so an unqualified run keeps meaning the development matrix', () => { + expect(parseSuite(undefined)).toBe('full'); + expect(parseSuite('reference')).toBe('reference'); + expect(() => parseSuite('quick')).toThrow(/--suite must be one of/); + }); +}); + +describe('plan identity', () => { + it('is stable for the same contract and differs across suites and domains', () => { + expect(planFor('reference', 'rendering').meta.planHash).toBe(planFor('reference', 'rendering').meta.planHash); + expect(planFor('reference', 'rendering').meta.planHash).not.toBe(planFor('full', 'rendering').meta.planHash); + expect(planFor('reference', 'rendering').meta.planHash).not.toBe(planFor('reference', 'physics').meta.planHash); + }); + + it('changes when a planned load moves and not when an unmeasured detail does', () => { + // An uncatalogued scenario, whose loads `full` takes from the ladder: the + // one case where moving a rung moves the plan itself. A catalogued + // scenario's loads come from the catalog, so its hash is deliberately + // insensitive to the development ladder underneath it. + const base = resolveSuitePlan({ suite: 'full', domain: 'rendering', ladders: new Map([['split-screen', [1_000]]]) }); + const same = resolveSuitePlan({ suite: 'full', domain: 'rendering', exploratory: true, ladders: new Map([['split-screen', [1_000]]]) }); + const moved = resolveSuitePlan({ suite: 'full', domain: 'rendering', ladders: new Map([['split-screen', [1_200]]]) }); + + expect(same.meta.planHash).toBe(base.meta.planHash); + expect(moved.meta.planHash).not.toBe(base.meta.planHash); + }); + + it('cell keys separate backend, arm configuration and load', () => { + const base = { domain: 'rendering' as const, engine: 'exojs', config: 'current', backend: 'webgl2', scenarioId: 'static-heavy', loadId: '10k' }; + + expect(cellKey(base)).toBe(cellKey({ ...base })); + expect(cellKey(base)).not.toBe(cellKey({ ...base, backend: 'webgpu' })); + expect(cellKey(base)).not.toBe(cellKey({ ...base, config: 'retained' })); + expect(cellKey(base)).not.toBe(cellKey({ ...base, loadId: '100k' })); + }); +}); + +describe('applyPlan', () => { + const cell = (archetype: string, nodeCount: number): CellSpec => + ({ engine: 'exojs', config: 'current', backend: 'webgl2', archetype, nodeCount, timedFrames: 1, warmupFrames: 1 }) as CellSpec; + + it('emits the plan loads for a named scenario, including rungs the ladder lacks', () => { + const plan = resolveSuitePlan({ suite: 'reference', domain: 'rendering', ladders: RENDERING_LADDERS }); + const kept = applyPlan([cell('static-heavy', 1_000), cell('static-heavy', 25_000), cell('split-screen', 1_000)], plan); + + expect(kept.map(entry => entry.nodeCount).sort((a, b) => a - b)).toStrictEqual([1_000, 10_000, 100_000]); + expect(kept.every(entry => entry.archetype === 'static-heavy')).toBe(true); + }); + + it('rebudgets frames per emitted load rather than inheriting the source cell', () => { + const plan = resolveSuitePlan({ suite: 'reference', domain: 'rendering', ladders: RENDERING_LADDERS }); + const kept = applyPlan([cell('static-heavy', 1_000)], plan); + const largest = kept.find(entry => entry.nodeCount === 100_000); + + expect(largest?.timedFrames).toBe(30); + expect(largest?.warmupFrames).toBe(40); + }); +}); diff --git a/packages/exojs-bench/test/timer-check.test.ts b/packages/exojs-bench/test/timer-check.test.ts new file mode 100644 index 000000000..4af313dbc --- /dev/null +++ b/packages/exojs-bench/test/timer-check.test.ts @@ -0,0 +1,80 @@ +/** + * The published-comparison guard against a clock that did not separate the two + * arms. + * + * A duration only a step or two above the grid `performance.now()` delivers on + * carries no ratio worth printing: the same scene measured again lands on the + * neighbouring step, and the factor built from it moves by a whole multiple. The + * check therefore refuses the comparison rather than the measurement - both + * times stay readable, and only the figure, the bar and the winner go. + * + * Clearing the check is not a precision claim. It establishes that this one + * guard did not trip; every other check a cell passes still applies. + */ + +import { describe, expect, it } from 'vitest'; + +import type { TimerCheck } from '../src/shared/timerCheck'; +import { mergeTimerChecks, timerCheckOfRun } from '../src/shared/timerCheck'; + +/** The step the WebKit build behind the published macOS profiles was observed to deliver. */ +const COARSE_STEP = 0.02; + +describe('timerCheckOfRun', () => { + it('refuses a run whose two durations sit on the same single step', () => { + expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP], COARSE_STEP)).toBe('limited'); + }); + + it('refuses one step against two, which is a rounding artefact rather than a doubling', () => { + expect(timerCheckOfRun([COARSE_STEP, COARSE_STEP * 2], COARSE_STEP)).toBe('limited'); + }); + + it('refuses a run where only one arm sits near the step, however far the other is above it', () => { + expect(timerCheckOfRun([COARSE_STEP, 13.38], COARSE_STEP)).toBe('limited'); + expect(timerCheckOfRun([13.38, COARSE_STEP], COARSE_STEP)).toBe('limited'); + }); + + it('reports an unrecorded step as unknown rather than as a pass', () => { + expect(timerCheckOfRun([0.5, 1.5], null)).toBe('unknown'); + }); + + it('treats a zero or negative step as unknown, never as a clock of unlimited precision', () => { + expect(timerCheckOfRun([0.001, 0.002], 0)).toBe('unknown'); + expect(timerCheckOfRun([0.001, 0.002], -1)).toBe('unknown'); + }); + + it('passes a run whose durations both stand clear of its own step', () => { + expect(timerCheckOfRun([0.5, 1.5], COARSE_STEP)).toBe('resolved'); + }); +}); + +describe('mergeTimerChecks', () => { + /** + * Pooling the durations first would hide this: the pooled median is 0.300 ms + * and the coarsest step 0.020 ms, which clears the threshold, while the first + * run stood nine steps above its own clock and did not. + */ + it('refuses a comparison whose first run was limited even though the pooled figure would clear the coarsest step', () => { + const perRun = [timerCheckOfRun([0.18, 5], 0.02), timerCheckOfRun([0.3, 5], 0.005), timerCheckOfRun([0.3, 5], 0.005)]; + + expect(perRun).toStrictEqual(['limited', 'resolved', 'resolved']); + expect(mergeTimerChecks(perRun)).toBe('limited'); + }); + + it('does not let a run with no recorded step lift a limitation another run established', () => { + expect(mergeTimerChecks(['limited', 'unknown'])).toBe('limited'); + expect(mergeTimerChecks(['unknown', 'limited', 'resolved'])).toBe('limited'); + }); + + it('reports a comparison with any unrecorded run as unknown rather than resolved', () => { + expect(mergeTimerChecks(['resolved', 'unknown', 'resolved'])).toBe('unknown'); + }); + + it('reports resolved only where every run cleared the check', () => { + expect(mergeTimerChecks(['resolved', 'resolved', 'resolved'])).toBe('resolved'); + }); + + it('reports no runs at all as unknown', () => { + expect(mergeTimerChecks([])).toBe('unknown'); + }); +}); diff --git a/packages/exojs-bench/test/timing-gate.test.ts b/packages/exojs-bench/test/timing-gate.test.ts index ec0f63fb8..1b9635430 100644 --- a/packages/exojs-bench/test/timing-gate.test.ts +++ b/packages/exojs-bench/test/timing-gate.test.ts @@ -12,6 +12,13 @@ import { TIMING_THRESHOLD, timingCellId, } from '../src/rendering/timingGate'; +import type { ClockReport } from '../src/shared/clock'; + +/** + * A clock fine enough that no fixture duration trips the timer check, so a test + * asserts on the comparison it is about rather than on the grid it was read on. + */ +const FINE_CLOCK: ClockReport = { resolutionMs: 0.001, crossOriginIsolated: true }; /** A measured cell with the timings the gate reads. */ const cell = (options: { archetype: ArchetypeId; cpuMsMedian: number; cpuMsP95?: number; status?: 'ok' | 'exceeded' }): CellResult => ({ @@ -31,6 +38,7 @@ const cell = (options: { archetype: ArchetypeId; cpuMsMedian: number; cpuMsP95?: queueMsMedian: null, queueMsP95: null, structural: { drawCalls: 1, textureBinds: 0, bufferUploads: 0 }, + clock: FINE_CLOCK, status: options.status ?? 'ok', }); diff --git a/packages/exojs-bench/tsconfig.json b/packages/exojs-bench/tsconfig.json index 629cae92c..8988018cf 100644 --- a/packages/exojs-bench/tsconfig.json +++ b/packages/exojs-bench/tsconfig.json @@ -1,14 +1,16 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "_comment": "The rendering domain benchmarks the engine through its internal `#*` subpath imports (resolved in the browser page by the driver's Vite alias, never in the Node CLI); the `#* -> ../../src/*` path reproduces the root package.json wildcard for the type-checker. The physics domain instead imports the `@codexo/exojs-physics` package (which itself imports the `@codexo/exojs` barrel), and neither exposes a `@codexo/exojs-source` export condition, so those specifiers are mapped to source here — mirroring the runtime aliasing in scripts/glsl-loader.ts and vitest.config.ts's aliasConfig.", + "_comment": "The rendering domain benchmarks the engine through its internal `#*` subpath imports (resolved in the browser page by the driver's Vite alias, never in the Node CLI); the `#* -> ../../src/*` path reproduces the root package.json wildcard for the type-checker. The physics domain instead imports the `@codexo/exojs-physics` package (which itself imports the `@codexo/exojs` barrel), and the rendering extension arms import `@codexo/exojs-particles` and `@codexo/exojs-tilemap`; none of those expose a `@codexo/exojs-source` export condition, and their package entries point at `dist`, which the type-check runs without, so their entries are mapped to source here; `@codexo/exojs-particles-source` is in `customConditions` for the same reason one level down, since `@codexo/exojs-particles` resolves its own `#*` imports through a package `imports` map that otherwise lands on stale `dist` declarations — mirroring the runtime aliasing in scripts/glsl-loader.ts and vitest.config.ts's aliasConfig.", "extends": "@codexo/exojs-config/typescript/test.json", "compilerOptions": { "noEmit": true, - "customConditions": ["@codexo/exojs-source"], + "customConditions": ["@codexo/exojs-source", "@codexo/exojs-particles-source"], "types": ["vitest/globals", "node", "@webgpu/types"], "paths": { "#*": ["../../src/*"], + "@codexo/exojs-particles": ["../exojs-particles/src/index.ts"], "@codexo/exojs-physics": ["../exojs-physics/src/index.ts"], + "@codexo/exojs-tilemap": ["../exojs-tilemap/src/index.ts"], "@codexo/exojs": ["../../src/index.ts"], "@codexo/exojs/extensions": ["../../src/extensions/index.ts"], "@codexo/exojs/renderer-sdk": ["../../src/renderer-sdk.ts"], diff --git a/packages/exojs-config/vitest/index.js b/packages/exojs-config/vitest/index.js index f187fb2e6..f6b803e38 100644 --- a/packages/exojs-config/vitest/index.js +++ b/packages/exojs-config/vitest/index.js @@ -41,14 +41,17 @@ export const workerTransformPlugin = createWorkerPlugin(); * the function; it does not otherwise change how V8 collects. Note this is a * top-level test option in Vitest 4 - under `poolOptions.forks` it is silently * ignored. - * @param {{ name: string, include: string[], exclude?: string[], setupFiles?: string[], alias?: NonNullable['alias'] }} opts + * `plugins` are prepended to the shared shader/worklet/worker transforms, so a + * project that needs its own resolution step keeps the transforms it would + * otherwise have to reproduce. + * @param {{ name: string, include: string[], exclude?: string[], setupFiles?: string[], alias?: NonNullable['alias'], plugins?: import('vitest/config').ViteUserConfig['plugins'] }} opts */ export function createJsdomTestProject(opts) { - const { name, include, exclude, setupFiles = ['./test/setup-env.vitest.ts'], alias } = opts; + const { name, include, exclude, setupFiles = ['./test/setup-env.vitest.ts'], alias, plugins = [] } = opts; return { resolve: { alias, conditions: srcConditions }, ssr: { resolve: { conditions: srcConditions } }, - plugins: [createShaderPlugin(), workletTransformPlugin, workerTransformPlugin], + plugins: [...plugins, createShaderPlugin(), workletTransformPlugin, workerTransformPlugin], define: { __DEV__: JSON.stringify(true), __VERSION__: JSON.stringify('0.0.0'), __REVISION__: JSON.stringify('test') }, test: { name, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28876d831..8bb88ee73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,10 +52,10 @@ importers: version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) '@vitest/browser-webdriverio': specifier: ^4.1.10 - version: 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0) + version: 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0(supports-color@7.2.0)) '@vitest/coverage-istanbul': specifier: ^4.1.7 - version: 4.1.11(vitest@4.1.11) + version: 4.1.11(supports-color@7.2.0)(vitest@4.1.11) '@webgpu/types': specifier: ^0.1.69 version: 0.1.72 @@ -64,22 +64,22 @@ importers: version: 0.28.2 eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-security: specifier: ^4.0.0 version: 4.0.1 eslint-plugin-simple-import-sort: specifier: ^14.0.0 - version: 14.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 14.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unicorn: specifier: ^74.0.0 - version: 74.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 74.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) globals: specifier: ^17.11.0 version: 17.11.0 @@ -112,7 +112,7 @@ importers: version: 4.63.1 rollup-plugin-esbuild: specifier: ^6.2.1 - version: 6.2.1(esbuild@0.28.2)(rollup@4.63.1) + version: 6.2.1(esbuild@0.28.2)(rollup@4.63.1)(supports-color@7.2.0) size-limit: specifier: ^13.0.3 version: 13.0.3 @@ -127,7 +127,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ~8.68.0 - version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vitest: specifier: ^4.1.7 version: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/browser-webdriverio@4.1.11)(@vitest/coverage-istanbul@4.1.11)(jsdom@29.1.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -154,7 +154,7 @@ importers: version: 26.4.1 eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: specifier: ~6.0.3 version: 6.0.3 @@ -185,9 +185,15 @@ importers: '@codexo/exojs-config': specifier: workspace:* version: link:../exojs-config + '@codexo/exojs-particles': + specifier: workspace:* + version: link:../exojs-particles '@codexo/exojs-physics': specifier: workspace:* version: link:../exojs-physics + '@codexo/exojs-tilemap': + specifier: workspace:* + version: link:../exojs-tilemap '@types/node': specifier: ^26.4.0 version: 26.4.1 @@ -246,28 +252,28 @@ importers: version: link:../exojs-build '@eslint-react/eslint-plugin': specifier: ^5.9.0 - version: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + version: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) + version: 10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@vitest/eslint-plugin': specifier: ^1.6.20 - version: 1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11) + version: 1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.11) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.9.1(jiti@2.7.0)) + version: 7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-security: specifier: ^4.0.0 version: 4.0.1 eslint-plugin-simple-import-sort: specifier: ^14.0.0 - version: 14.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 14.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unicorn: specifier: ^74.0.0 - version: 74.0.0(eslint@10.9.1(jiti@2.7.0)) + version: 74.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) globals: specifier: ^17.11.0 version: 17.11.0 @@ -276,7 +282,7 @@ importers: version: 1.2.7 typescript-eslint: specifier: ~8.68.0 - version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + version: 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) packages/exojs-ldtk: devDependencies: @@ -352,10 +358,10 @@ importers: version: 19.2.18 eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) packages/exojs-tiled: devDependencies: @@ -403,10 +409,10 @@ importers: dependencies: '@astrojs/mdx': specifier: ^7.0.5 - version: 7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(supports-color@7.2.0) '@astrojs/react': specifier: ^6.0.2 - version: 6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) + version: 6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(supports-color@7.2.0)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) '@codexo/exojs': specifier: workspace:* version: link:.. @@ -440,7 +446,7 @@ importers: version: 0.9.10(prettier@3.9.6)(typescript@6.0.3) '@codecov/astro-plugin': specifier: ^2.0.1 - version: 2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) '@codexo/exojs-config': specifier: workspace:* version: link:../packages/exojs-config @@ -458,10 +464,10 @@ importers: version: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) eslint: specifier: ~10.9.1 - version: 10.9.1(jiti@2.7.0) + version: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) pagefind: specifier: ^1.5.2 version: 1.5.2 @@ -896,156 +902,312 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -2903,6 +3065,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -4766,10 +4933,6 @@ packages: resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -4915,6 +5078,46 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5498,7 +5701,7 @@ snapshots: transitivePeerDependencies: - typescript - '@astrojs/markdown-remark@7.2.4': + '@astrojs/markdown-remark@7.2.4(supports-color@7.2.0)': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/prism': 4.0.2 @@ -5508,8 +5711,8 @@ snapshots: mdast-util-definitions: 6.0.0 rehype-raw: 7.0.0 rehype-stringify: 10.0.1 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 + remark-gfm: 4.0.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 remark-smartypants: 3.0.3 unified: 11.0.5 @@ -5527,11 +5730,11 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@astrojs/mdx@7.0.8(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(supports-color@7.2.0)': dependencies: '@astrojs/internal-helpers': 0.10.4 - '@astrojs/markdown-remark': 7.2.4 - '@mdx-js/mdx': 3.1.1 + '@astrojs/markdown-remark': 7.2.4(supports-color@7.2.0) + '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) acorn: 8.18.0 astro: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) es-module-lexer: 2.3.2 @@ -5539,7 +5742,7 @@ snapshots: hast-util-to-html: 9.0.5 piccolore: 0.1.3 rehype-raw: 7.0.0 - remark-gfm: 4.0.1 + remark-gfm: 4.0.1(supports-color@7.2.0) remark-smartypants: 3.0.3 source-map: 0.7.6 unist-util-visit: 5.1.0 @@ -5551,12 +5754,12 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)': + '@astrojs/react@6.0.5(@types/node@26.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.7)(supports-color@7.2.0)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.11.0 '@types/react': 19.2.18 '@types/react-dom': 19.2.7(@types/react@19.2.18) - '@vitejs/plugin-react': 5.2.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitejs/plugin-react': 5.2.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) devalue: 5.9.2 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -5596,20 +5799,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5634,19 +5837,19 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5671,14 +5874,14 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -5689,7 +5892,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.8(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -5697,7 +5900,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -5764,10 +5967,10 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@codecov/astro-plugin@2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@codecov/astro-plugin@2.0.1(astro@7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 - '@codecov/vite-plugin': 2.0.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) + '@codecov/vite-plugin': 2.0.1(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) astro: 7.2.10(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.4.1)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) unplugin: 1.16.1 transitivePeerDependencies: @@ -5788,11 +5991,11 @@ snapshots: rollup: 4.63.1 unplugin: 1.16.1 - '@codecov/vite-plugin@2.0.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@codecov/vite-plugin@2.0.1(vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 unplugin: 1.16.1 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0) '@csstools/color-helpers@6.0.2': {} @@ -5862,182 +6065,260 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint-react/ast@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/ast@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) string-ts: 2.3.1 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/core@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/core@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/eslint-plugin@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/eslint-plugin@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) - eslint-plugin-react-dom: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-jsx: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-naming-convention: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-rsc: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-web-api: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-react-x: 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-react-dom: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-jsx: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-naming-convention: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-rsc: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-web-api: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-react-x: 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/eslint@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/eslint@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/jsx@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/jsx@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint-react/shared@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/shared@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 zod: 4.4.3 transitivePeerDependencies: - supports-color - '@eslint-react/var@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@eslint-react/var@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -6055,9 +6336,9 @@ snapshots: mdn-data: 2.29.0 source-map-js: 1.2.1 - '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) '@eslint/object-schema@3.0.5': {} @@ -6216,7 +6497,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -6238,9 +6519,9 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@mdx-js/mdx@3.1.1': + '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -6252,14 +6533,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.1 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.18.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + rehype-recma: 1.0.0(supports-color@7.2.0) + remark-mdx: 3.1.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -6443,12 +6724,12 @@ snapshots: dependencies: spacetrim: 0.11.59 - '@puppeteer/browsers@2.13.2': + '@puppeteer/browsers@2.13.2(supports-color@7.2.0)': dependencies: - debug: 4.4.3 - extract-zip: 2.0.1 + debug: 4.4.3(supports-color@7.2.0) + extract-zip: 2.0.1(supports-color@7.2.0) progress: 2.0.3 - proxy-agent: 6.5.0 + proxy-agent: 6.5.0(supports-color@7.2.0) semver: 7.8.5 tar-fs: 3.1.3 yargs: 17.7.3 @@ -6837,15 +7118,15 @@ snapshots: '@types/node': 26.4.1 optional: true - '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.68.0 - '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.68.0 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -6853,41 +7134,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.68.0 - debug: 4.4.3 - eslint: 10.9.1(jiti@2.7.0) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.63.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.63.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.67.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) '@typescript-eslint/types': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.68.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.68.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) '@typescript-eslint/types': 8.68.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -6919,25 +7200,25 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -6949,13 +7230,13 @@ snapshots: '@typescript-eslint/types@8.68.0': {} - '@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.63.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.63.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 '@typescript-eslint/visitor-keys': 8.63.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6964,13 +7245,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.67.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.67.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.67.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) '@typescript-eslint/types': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6979,13 +7260,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.68.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.68.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.68.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) '@typescript-eslint/types': 8.68.0 '@typescript-eslint/visitor-keys': 8.68.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -6994,35 +7275,35 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.63.0 '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.68.0 '@typescript-eslint/types': 8.68.0 - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7044,11 +7325,11 @@ snapshots: '@ungap/structured-clone@1.4.0': {} - '@vitejs/plugin-react@5.2.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitejs/plugin-react@5.2.0(supports-color@7.2.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -7069,11 +7350,11 @@ snapshots: - utf-8-validate - vite - '@vitest/browser-webdriverio@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0)': + '@vitest/browser-webdriverio@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0(supports-color@7.2.0))': dependencies: '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) vitest: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/browser-webdriverio@4.1.11)(@vitest/coverage-istanbul@4.1.11)(jsdom@29.1.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) - webdriverio: 9.30.0 + webdriverio: 9.30.0(supports-color@7.2.0) transitivePeerDependencies: - bufferutil - msw @@ -7097,9 +7378,9 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-istanbul@4.1.11(vitest@4.1.11)': + '@vitest/coverage-istanbul@4.1.11(supports-color@7.2.0)(vitest@4.1.11)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@istanbuljs/schema': 0.1.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -7113,13 +7394,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11)': + '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.11)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) typescript: 6.0.3 vitest: 4.1.11(@types/node@26.4.1)(@vitest/browser-playwright@4.1.11)(@vitest/browser-webdriverio@4.1.11)(@vitest/coverage-istanbul@4.1.11)(jsdom@29.1.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0)) transitivePeerDependencies: @@ -7216,11 +7497,11 @@ snapshots: '@vscode/l10n@0.0.18': {} - '@wdio/config@9.30.0': + '@wdio/config@9.30.0(supports-color@7.2.0)': dependencies: '@wdio/logger': 9.29.1 '@wdio/types': 9.29.1 - '@wdio/utils': 9.30.0 + '@wdio/utils': 9.30.0(supports-color@7.2.0) deepmerge-ts: 7.1.6 glob: 10.5.0 import-meta-resolve: 4.2.0 @@ -7249,22 +7530,22 @@ snapshots: dependencies: '@types/node': 20.19.43 - '@wdio/utils@9.30.0': + '@wdio/utils@9.30.0(supports-color@7.2.0)': dependencies: - '@puppeteer/browsers': 2.13.2 + '@puppeteer/browsers': 2.13.2(supports-color@7.2.0) '@wdio/logger': 9.29.1 '@wdio/types': 9.29.1 decamelize: 6.0.1 deepmerge-ts: 7.1.6 - edgedriver: 6.3.0 - geckodriver: 6.1.1 + edgedriver: 6.3.0(supports-color@7.2.0) + geckodriver: 6.1.1(supports-color@7.2.0) get-port: 7.2.0 import-meta-resolve: 4.2.0 locate-app: 2.5.0 mitt: 3.0.1 safaridriver: 1.0.1 split2: 4.2.0 - wait-port: 1.1.0 + wait-port: 1.1.0(supports-color@7.2.0) transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -7739,9 +8020,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decamelize@6.0.1: {} @@ -7814,15 +8097,15 @@ snapshots: '@types/which': 2.0.2 which: 2.0.2 - edgedriver@6.3.0: + edgedriver@6.3.0(supports-color@7.2.0): dependencies: '@wdio/logger': 9.29.1 '@zip.js/zip.js': 2.10.0 decamelize: 6.0.1 edge-paths: 3.0.5 fast-xml-parser: 5.11.1 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) which: 6.0.1 transitivePeerDependencies: - supports-color @@ -7877,6 +8160,35 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -7920,108 +8232,108 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-react-dom@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-dom@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.7 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react-jsx@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-jsx@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-naming-convention@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-naming-convention@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-rsc@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-rsc@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-web-api@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-web-api@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) birecord: 0.1.2 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) ts-pattern: 5.9.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - eslint-plugin-react-x@5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-react-x@5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@eslint-react/ast': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/core': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/eslint': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/jsx': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/shared': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@eslint-react/var': 5.9.5(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.63.0 - '@typescript-eslint/type-utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.63.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) compare-versions: 6.1.1 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) string-ts: 2.3.1 ts-api-utils: 2.5.0(typescript@6.0.3) ts-pattern: 5.9.0 @@ -8033,13 +8345,13 @@ snapshots: dependencies: safe-regex: 2.1.1 - eslint-plugin-simple-import-sort@14.0.0(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-simple-import-sort@14.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-unicorn@74.0.0(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-unicorn@74.0.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@eslint/css-tree': 4.0.5 browserslist: 4.28.8 change-case: 5.4.4 @@ -8047,7 +8359,7 @@ snapshots: core-js-compat: 3.50.0 detect-indent: 7.0.2 entities: 8.0.0 - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) find-up-simple: 1.0.1 globals: 17.11.0 indent-string: 5.0.0 @@ -8061,11 +8373,11 @@ snapshots: strip-indent: 4.1.1 yaml: 2.9.0 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.9.1(jiti@2.7.0) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) eslint-scope@9.1.2: dependencies: @@ -8078,11 +8390,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.9.1(jiti@2.7.0): + eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -8092,7 +8404,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -8186,9 +8498,9 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: + extract-zip@2.0.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -8240,6 +8552,10 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8285,13 +8601,13 @@ snapshots: function-timeout@1.0.2: {} - geckodriver@6.1.1: + geckodriver@6.1.1(supports-color@7.2.0): dependencies: '@wdio/logger': 9.29.1 '@zip.js/zip.js': 2.10.0 decamelize: 6.0.1 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) modern-tar: 0.7.7 transitivePeerDependencies: - supports-color @@ -8316,11 +8632,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: + get-uri@6.0.5(supports-color@7.2.0): dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8413,7 +8729,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3: + hast-util-to-estree@3.1.3(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -8423,9 +8739,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -8448,7 +8764,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 @@ -8457,9 +8773,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -8526,17 +8842,17 @@ snapshots: http-cache-semantics@4.2.0: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8663,12 +8979,12 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.1 + lru-cache: 11.5.2 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.28.0 + undici: 7.29.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -8874,14 +9190,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -8899,67 +9215,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 @@ -8967,7 +9283,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -8976,23 +9292,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -9280,10 +9596,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@7.2.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -9424,16 +9740,16 @@ snapshots: p-timeout@7.0.1: {} - pac-proxy-agent@7.2.0: + pac-proxy-agent@7.2.0(supports-color@7.2.0): dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + get-uri: 6.0.5(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 + socks-proxy-agent: 8.0.5(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9574,16 +9890,16 @@ snapshots: property-information@7.2.0: {} - proxy-agent@6.5.0: + proxy-agent@6.5.0(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 + pac-proxy-agent: 7.2.0(supports-color@7.2.0) proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 + socks-proxy-agent: 8.0.5(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9692,11 +10008,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0: + rehype-recma@1.0.0(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.5 - hast-util-to-estree: 3.1.3 + hast-util-to-estree: 3.1.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9706,28 +10022,28 @@ snapshots: hast-util-to-html: 9.0.5 unified: 11.0.5 - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@7.2.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1: + remark-mdx@3.1.1(supports-color@7.2.0): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@7.2.0) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -9832,9 +10148,9 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 - rollup-plugin-esbuild@6.2.1(esbuild@0.28.2)(rollup@4.63.1): + rollup-plugin-esbuild@6.2.1(esbuild@0.28.2)(rollup@4.63.1)(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 esbuild: 0.28.2 get-tsconfig: 4.14.0 @@ -10011,10 +10327,10 @@ snapshots: smol-toml@1.8.0: {} - socks-proxy-agent@8.0.5: + socks-proxy-agent@8.0.5(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) socks: 2.8.10 transitivePeerDependencies: - supports-color @@ -10175,7 +10491,7 @@ snapshots: terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -10201,8 +10517,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.1: {} @@ -10269,13 +10585,13 @@ snapshots: dependencies: semver: 7.8.5 - typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.9.1(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -10300,8 +10616,6 @@ snapshots: undici@6.28.0: {} - undici@7.28.0: {} - undici@7.29.0: {} undici@8.10.1: {} @@ -10422,6 +10736,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@6.4.3(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.33.0 + sass: 1.101.7 + terser: 5.48.0 + tsx: 4.23.13 + yaml: 2.9.0 + vite@7.3.6(@types/node@26.4.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: esbuild: 0.28.2 @@ -10486,8 +10818,8 @@ snapshots: optionalDependencies: '@types/node': 26.4.1 '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11) - '@vitest/browser-webdriverio': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0) - '@vitest/coverage-istanbul': 4.1.11(vitest@4.1.11) + '@vitest/browser-webdriverio': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.101.7)(terser@5.48.0)(tsx@4.23.13)(yaml@2.9.0))(vitest@4.1.11)(webdriverio@9.30.0(supports-color@7.2.0)) + '@vitest/coverage-istanbul': 4.1.11(supports-color@7.2.0)(vitest@4.1.11) jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -10602,11 +10934,11 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wait-port@1.1.0: + wait-port@1.1.0(supports-color@7.2.0): dependencies: chalk: 4.1.2 commander: 9.5.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -10614,17 +10946,17 @@ snapshots: web-worker@1.5.0: {} - webdriver@9.30.0: + webdriver@9.30.0(supports-color@7.2.0): dependencies: '@types/node': 20.19.43 '@types/ws': 8.18.1 - '@wdio/config': 9.30.0 + '@wdio/config': 9.30.0(supports-color@7.2.0) '@wdio/logger': 9.29.1 '@wdio/protocols': 9.30.0 '@wdio/types': 9.29.1 - '@wdio/utils': 9.30.0 + '@wdio/utils': 9.30.0(supports-color@7.2.0) deepmerge-ts: 7.1.6 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@7.2.0) undici: 6.28.0 ws: 8.21.3 transitivePeerDependencies: @@ -10635,16 +10967,16 @@ snapshots: - supports-color - utf-8-validate - webdriverio@9.30.0: + webdriverio@9.30.0(supports-color@7.2.0): dependencies: '@types/node': 20.19.43 '@types/sinonjs__fake-timers': 8.1.5 - '@wdio/config': 9.30.0 + '@wdio/config': 9.30.0(supports-color@7.2.0) '@wdio/logger': 9.29.1 '@wdio/protocols': 9.30.0 '@wdio/repl': 9.16.2 '@wdio/types': 9.29.1 - '@wdio/utils': 9.30.0 + '@wdio/utils': 9.30.0(supports-color@7.2.0) archiver: 7.0.1 aria-query: 5.3.2 cheerio: 1.2.0 @@ -10661,7 +10993,7 @@ snapshots: rgb2hex: 0.2.5 serialize-error: 12.0.0 urlpattern-polyfill: 10.1.0 - webdriver: 9.30.0 + webdriver: 9.30.0(supports-color@7.2.0) transitivePeerDependencies: - bare-abort-controller - bare-buffer diff --git a/scripts/verify-bench-results.ts b/scripts/verify-bench-results.ts index 15e184540..d619cf166 100644 --- a/scripts/verify-bench-results.ts +++ b/scripts/verify-bench-results.ts @@ -78,6 +78,9 @@ const missingFields = (subject: unknown, fields: readonly string[], where: strin // `flags` is deliberately absent: a browser that takes no launch arguments // records an empty set, and demanding content there would push a run into // claiming flags it never passed. +/** First schema version whose rendering stamps record the clock their session read on. */ +const RENDERING_CLOCK_SINCE = 7; + const RENDERING_STAMP_FIELDS = [ 'backend', 'adapter', @@ -188,6 +191,48 @@ const HOST_FIELDS = ['cpu', 'cpuCount', 'os', 'platformVersion', 'arch'] as cons const PROFILE_FIELDS = ['slug', 'gpu', 'os', 'browser', 'platform', 'engineVersion', 'measuredAt', 'runs'] as const; const PLATFORM_FIELDS = ['name', 'version', 'versionSource', 'prerelease'] as const; +/** Fields every published row of a version 7 document carries; see {@link ROW_FIELDS_SINCE}. */ +const ROW_FIELDS = ['archetype', 'category', 'count', 'loadId', 'unit', 'primary', 'cells'] as const; + +/** Fields every published cell of a version 7 document carries. */ +const CELL_FIELDS = ['competitor', 'referenceMs', 'competitorMs', 'verdict', 'timer'] as const; + +/** + * First schema version whose rows state their load and whose cells state what + * the clock established about them. + * + * Checked rather than assumed: the fields are produced by the aggregation stage, + * and a stage that silently stopped emitting one would publish a document whose + * loads the page cannot tell apart and whose comparisons carry no timer + * qualification - which reads exactly like a document that was never meant to. + */ +const ROW_FIELDS_SINCE = 7; + +/** Every row and cell of one published section carries the fields its schema version promises. */ +const checkModelRows = (section: unknown, where: string, problems: string[]): void => { + const rows = isRecord(section) ? section['rows'] : undefined; + + if (!Array.isArray(rows)) { + problems.push(`${where}.rows is missing`); + + return; + } + + for (const [index, row] of rows.entries()) { + problems.push(...missingFields(row, [...ROW_FIELDS], `${where}.rows[${String(index)}]`)); + + const cells = isRecord(row) ? row['cells'] : undefined; + + if (!Array.isArray(cells)) { + continue; + } + + for (const [cellIndex, cell] of cells.entries()) { + problems.push(...missingFields(cell, [...CELL_FIELDS], `${where}.rows[${String(index)}].cells[${String(cellIndex)}]`)); + } + } +}; + /** * The operating system the file name claims. * @@ -343,6 +388,10 @@ const checkProfile = (path: string): string[] => { problems.push(...missingFields(stamp, [...RENDERING_STAMP_FIELDS], where)); checkRenderingStampShape(stamp, where, problems); + if (version >= RENDERING_CLOCK_SINCE && isRecord(stamp) && !('clock' in stamp)) { + problems.push(`${where}.clock is missing, so nothing states what the page's clock resolved to`); + } + if (isRecord(stamp) && typeof stamp['engineVersion'] === 'string') { engineVersions.add(stamp['engineVersion']); } @@ -351,6 +400,14 @@ const checkProfile = (path: string): string[] => { if (!Array.isArray(rendering['backends']) || rendering['backends'].length === 0) { problems.push('rendering.backends is missing or empty'); + } else if (version >= ROW_FIELDS_SINCE) { + for (const [index, block] of rendering['backends'].entries()) { + const sections = isRecord(block) ? block['sections'] : undefined; + + for (const [sectionIndex, section] of (Array.isArray(sections) ? sections : []).entries()) { + checkModelRows(section, `rendering.backends[${String(index)}].sections[${String(sectionIndex)}]`, problems); + } + } } armVersions.push(...checkLibraries(rendering, 'rendering', problems)); @@ -377,6 +434,8 @@ const checkProfile = (path: string): string[] => { if (!isRecord(physics['section'])) { problems.push('physics.section is missing'); + } else if (version >= ROW_FIELDS_SINCE) { + checkModelRows(physics['section'], 'physics.section', problems); } armVersions.push(...checkLibraries(physics, 'physics', problems)); diff --git a/site/src/components/BenchComparisonTable.astro b/site/src/components/BenchComparisonTable.astro index b55efdf0d..979e4b9f9 100644 --- a/site/src/components/BenchComparisonTable.astro +++ b/site/src/components/BenchComparisonTable.astro @@ -10,12 +10,12 @@ * 700px of room inside a 1400px window, and a matrix squeezed into that is a * matrix nobody can read. Measurements never scroll sideways at any width. * - * A cell's first glance carries three things and no more: which way the pair - * went and by how much, the two medians, and a disclosure. Everything a reader - * needs only when verifying a row - p95, the range the pooled runs observed, - * the spread factor, the structural evidence, what each run concluded - sits - * behind that disclosure. It is not hidden, it is ranked: printing all of it in - * primary type is what made the previous table unreadable. + * A cell carries three things and no more: which way the pair went and by how + * much, the two medians, and the bar. Everything a reader needs only when + * verifying a row - p95, the range the pooled runs observed, the spread factor, + * the structural evidence, what each run concluded - sits behind one disclosure + * per archetype rather than one per comparison: a row of five pairs otherwise + * carried five toggles that each opened the same six terms. * * The bar is a diverging one on a fixed log axis shared by every column and * every table on the page, so a length means the same thing wherever it is @@ -45,6 +45,7 @@ import { frameShare, FRAME_BUDGET_MS, isWideSpread, + measuredMs, outcomeOf, pooledFactor, ratioBand, @@ -61,7 +62,7 @@ interface Props { } const { table, label } = Astro.props; -const { columns, rows, unit, countColumn } = table; +const { columns, rows, unit, countColumn, defaultColumn } = table; /** * Half-width of the axis in decades, fixed for every column and every table. @@ -131,13 +132,50 @@ const winnerOf = (cell: ProfileCell): string => { ---
+ {/* + * Hiding a group narrows the matrix to one question at a time. It is an + * enhancement and never a gate: without the script every column is shown, + * which is also what the printed and indexed page carries. + */} + {columns.length > 1 && ( +
+ {groups.length > 1 && ( +
+ + {groups.map(group => ( + + ))} +
+ )} + {/* Stacked, a card would otherwise carry every comparison of its archetype one under the other; picked, it carries one. */} + +
+ )} + {/* A fixed layout takes its widths from the first row, which is the group row and carries colspans rather than columns; declared on the cells, every width here would be ignored. */} + + + {countColumn && } + {columns.map((column, index) => ( + + ))} + {groups.length > 0 && ( ))} @@ -146,8 +184,8 @@ const winnerOf = (cell: ProfileCell): string => { {countColumn && } - {columns.map(column => ( - ); } const outcome = outcomeOf(cell); const mixed = outcome === 'unstable'; + const level = outcome === 'level'; + // A pair the clock never separated publishes neither a figure nor a bar; only its two times survive. + const unresolved = outcome === 'timer-limited' || outcome === 'timer-unknown'; const band = mixed ? ratioBand(cell) : null; const factor = mixed ? pooledFactor(cell) : cell.verdict.factor; const ratio = cell.verdict.ratio; - const plotted = !mixed && ratio !== null && Number.isFinite(ratio) && ratio > 0; + const plotted = !mixed && !unresolved && ratio !== null && Number.isFinite(ratio) && ratio > 0; + const referenceMs = measuredMs(cell, cell.referenceMs); + const competitorMs = measuredMs(cell, cell.competitorMs); return ( - + ); + })} + + {/* One disclosure for the archetype, not one per comparison: a matrix row carried five toggles that each opened the same six terms. */} + + - ); - })} + + + ); + })} + + + ))}
{groups.map(group => ( - + {group.label}
Archetype{unit === 'bodies' ? 'Bodies' : 'Nodes'} + {columns.map((column, index) => ( + {column.overline !== '' && {column.overline}} vs @@ -168,6 +206,7 @@ const winnerOf = (cell: ProfileCell): string => {
{row.archetype} + {row.description !== undefined && {row.description}} {row.count !== null && !countColumn && ( {row.count} {unit} @@ -189,31 +228,32 @@ const winnerOf = (cell: ProfileCell): string => { : `No shared cell at ${String(entry.count)} ${unit}: the arm sits this archetype out, or exceeded the run budget there.`; return ( - + - -
- Details -

{reason}

-
+ - {band !== null && ( + {!unresolved && band !== null && ( { - {mixed ? (factor === null ? '-' : formatApproximate(factor)) : formatFactor(factor)} - {mixed ? 'no clear lead' : winnerOf(cell)} + {/* A pair inside the noise band is named, not scored: its factor is pinned to 1 by the ladder, so printing 1.00x beside two medians that differ would claim a precision the ladder never measured. */} + {unresolved ? '-' : mixed ? (factor === null ? '-' : formatApproximate(factor)) : level ? 'similar' : formatFactor(factor)} + {/* The word alone; what it means is stated once in the legend rather than in every cell that carries it. */} + {outcome === 'timer-limited' ? 'below the timer' : outcome === 'timer-unknown' ? '' : mixed ? 'no clear lead' : level ? '' : winnerOf(cell)} - {formatMs(cell.referenceMs)} + {formatMs(referenceMs)} vs - {formatMs(cell.competitorMs)} ms + {formatMs(competitorMs)} ms -
- Details + +
+
+ {/* The row already names its archetype; the label repeats it only where a reader reaches the summary without that context. */} + Measurement detail +
+ {row.entries.map((entry, column) => { + const cell = entry.cell; + + if (cell === null) { + return ( +
+

{labelOf(column)}

+

+ {entry.count === null + ? 'Not measured on this backend.' + : `No shared cell at ${String(entry.count)} ${unit}: the arm sits this archetype out, or exceeded the run budget there.`} +

+
+ ); + } + + const band = outcomeOf(cell) === 'unstable' ? ratioBand(cell) : null; + const referenceMs = measuredMs(cell, cell.referenceMs); + const competitorMs = measuredMs(cell, cell.competitorMs); + const hasGpuTiming = cell.referenceGpuMs !== undefined || cell.competitorGpuMs !== undefined; + + return ( +
+

{labelOf(column)}

p95
- {formatMs(cell.referenceP95Ms)} vs {formatMs(cell.competitorP95Ms)} ms + {formatMs(measuredMs(cell, cell.referenceP95Ms))} vs {formatMs(measuredMs(cell, cell.competitorP95Ms))} ms
@@ -256,13 +332,7 @@ const winnerOf = (cell: ProfileCell): string => { {rangeOf(cell.aggregate.competitor)} {' '} - ms - -
-
-
spread
-
- {formatSpread(cell.aggregate.reference)} vs {formatSpread(cell.aggregate.competitor)} + ms, spread {formatSpread(cell.aggregate.reference)} vs {formatSpread(cell.aggregate.competitor)}
{band !== null && ( @@ -272,27 +342,30 @@ const winnerOf = (cell: ProfileCell): string => {
)}
-
60 fps frame
+
{FRAME_BUDGET_MS} ms frame
{[ - { name: 'ExoJS', share: frameShare(cell.referenceMs) }, - { name: armLabel(cell.competitor), share: frameShare(cell.competitorMs) }, - ].map(entry => ( + { name: 'ExoJS', share: frameShare(referenceMs) }, + { name: armLabel(cell.competitor), share: frameShare(competitorMs) }, + ].map(share => ( - {entry.name} - - 100 && 'over']} - style={`width:${String(Math.min(100, entry.share ?? 0))}%`} - /> - - 100 && 'warn']}> - {entry.share === null ? '-' : `${entry.share.toFixed(1)}%`} + {share.name} + 100 && 'warn']}> + {share.share === null ? '-' : `${share.share.toFixed(1)}%`} ))}
+ {hasGpuTiming && ( +
+
GPU
+
+ {formatMs(measuredMs(cell, cell.referenceGpuMs ?? null))} vs{' '} + {formatMs(measuredMs(cell, cell.competitorGpuMs ?? null))} ms of GPU execution +
+
+ )}
runs
@@ -310,17 +383,73 @@ const winnerOf = (cell: ProfileCell): string => {
{cell.mechanism}
)} - -
- -
+ +
Bars share one log axis across the whole page: the centre is 1.00x and each side reaches 100x, with anything past that drawn cut. A hollow bar is a pair whose pooled runs landed on different rungs; it spans the interval they produced, publishes no verdict, and prints the two medians' own factor @@ -399,16 +528,76 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } + .filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2) var(--s-4); + margin-bottom: var(--s-3); + } + + .chips { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + } + + /* The matrix shows a column per comparison already, so picking one there would hide the reading the matrix exists for. */ + .pick { + display: none; + } + + .pick select { + margin-left: var(--s-2); + padding: 0.2rem 0.4rem; + border: 1px solid var(--line-soft); + border-radius: var(--r-2); + background: var(--bg-elevated); + color: var(--fg); + font-family: var(--f-mono); + font-size: 0.7rem; + } + + .filters button { + padding: 0.2rem 0.65rem; + border: 1px solid var(--line-soft); + border-radius: var(--r-pill); + background: none; + font-family: var(--f-mono); + font-size: 0.7rem; + color: var(--fg-muted); + cursor: pointer; + } + + .filters button:hover { + color: var(--fg); + border-color: var(--line); + } + + /* The chosen filter is filled as well as tinted, so it is not colour alone that says which one is on. */ + .filters button[aria-pressed='true'] { + border-color: var(--line); + background: var(--bg-elevated); + color: var(--fg); + font-weight: 600; + } + + /* Stacked cards give td a display of their own, which the UA rule behind `hidden` does not outrank. */ + [data-group][hidden] { + display: none !important; + } + .col-archetype { - width: 12rem; + width: 14rem; } .col-count { width: 5rem; } + /* Left to share what the two fixed columns do not take, so the slack lands on the comparisons rather than beside a four-digit count. */ .col-arm { - width: 20rem; + width: auto; } tbody th { @@ -445,12 +634,20 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } - /* The text track is sized to its longest line; the plot takes whatever the column has left, so the axis is as wide as the layout allows. */ + /* + * Text and bar each take a full-width lane rather than sharing a row. + * Beside a ten-rem text track, a column of a five-pair table leaves the plot + * about sixty pixels, on which every ratio short of 10x is a few pixels of + * bar and the axis stops carrying meaning; the intrinsic minimum of that + * two-column layout is also wider than such a column, so the table pushed + * past the viewport. Stacked, the axis is as wide as the column, and since + * the columns are equal width the axes still line up down the table. + */ .tracks { display: grid; - grid-template-columns: 10.5rem minmax(4rem, 1fr); + grid-template-columns: minmax(0, 1fr); align-items: start; - gap: var(--s-3); + gap: var(--s-2); } .track { @@ -458,12 +655,6 @@ const winnerOf = (cell: ProfileCell): string => { order: 1; } - /* The disclosure spans the cell rather than the text track: its rows are prose and wrap badly in ten rem. */ - .more { - grid-column: 1 / -1; - order: 3; - } - .plot { position: relative; display: block; @@ -472,13 +663,14 @@ const winnerOf = (cell: ProfileCell): string => { margin-top: 0.2rem; } + /* Every bar grows outward from this line, so a bar means nothing without it: it is the 1.00x the length is read against. */ .axis { position: absolute; left: 50%; - top: -0.55rem; - bottom: -0.85rem; + top: -5px; + bottom: -5px; width: 1px; - background: var(--line); + background: color-mix(in oklab, var(--fg-faint), transparent 45%); } .fill { @@ -589,60 +781,96 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--bench-mark); } - .more summary { + /* The detail row is a full-width strip under its archetype, not a cell: it belongs to the row, and its terms read as prose rather than as a column. */ + tr.detail > td { + padding: 0 0.7rem 0.5rem; + border-bottom: 1px solid var(--line-soft); + } + + .row-more summary { cursor: pointer; font-family: var(--f-mono); + font-size: 0.7rem; + color: var(--fg-faint); + } + + .row-more summary:hover { + color: var(--fg-muted); + } + + .row-more summary code { + font-size: inherit; + } + + .arms { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--s-3) var(--s-5); + margin-top: var(--s-3); + } + + .arm-detail h4 { + margin: 0 0 0.3rem; + font-family: var(--f-mono); + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fg-muted); + } + + .arm-detail.absent p { + margin: 0; + font-family: var(--f-mono); font-size: 0.68rem; + line-height: 1.5; color: var(--fg-faint); } - .more summary:hover { + .row-description { + display: block; + max-width: 34rem; + margin-top: var(--s-1); color: var(--fg-muted); + font-family: var(--f-sans); + font-size: 0.72rem; + font-weight: 400; + line-height: 1.35; } - .more dl { + .arm-detail dl { display: grid; - grid-template-columns: 6.5rem minmax(0, 1fr); + grid-template-columns: 5.5rem minmax(0, 1fr); column-gap: var(--s-3); - margin: 0.4rem 0 0; + margin: 0; font-family: var(--f-mono); font-size: 0.68rem; line-height: 1.5; color: var(--fg-muted); } - .more dl > div { + .arm-detail dl > div { display: contents; } - .more dt { + .arm-detail dt { color: var(--fg-faint); } - .more dd { - margin: 0 0 0.35rem; + .arm-detail dd { + margin: 0 0 0.25rem; overflow-wrap: anywhere; } - .empty-more p { - max-width: 32rem; - margin: 0.4rem 0 0; - font-family: var(--f-mono); - font-size: 0.68rem; - line-height: 1.5; - color: var(--fg-muted); - } - /* * A millisecond figure only means something against the frame it has to fit * in. Nothing is derived from the share - no verdict, no capacity figure - * it restates the published time in the unit a reader already thinks in. */ .budget-row { - display: grid; - grid-template-columns: 4.5rem minmax(3rem, 1fr) 3rem; - align-items: center; - gap: 0.4rem; + display: inline-flex; + gap: 0.35rem; + margin-right: var(--s-3); } .budget-row em { @@ -650,27 +878,8 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } - .budget { - display: block; - height: 5px; - border-radius: 3px; - background: color-mix(in oklab, var(--fg-faint), transparent 85%); - } - - .budget i { - display: block; - height: 100%; - border-radius: 3px; - background: var(--fg-muted); - } - - .budget i.over { - background: var(--bench-mark); - } - .budget-row b { font-weight: 400; - text-align: right; } /* @@ -733,12 +942,22 @@ const winnerOf = (cell: ProfileCell): string => { display: none; } - tbody tr:not(.section) { + /* An archetype and its detail row are two table rows but one card, so the border opens on the first and closes on the second. */ + tbody tr:not(.section):not(.detail) { + border: 1px solid var(--line-soft); + border-bottom: 0; + border-radius: var(--r-3) var(--r-3) 0 0; + background: var(--bg-elevated); + padding: var(--s-3) var(--s-4) 0; + } + + tbody tr.detail { margin-bottom: var(--s-3); border: 1px solid var(--line-soft); - border-radius: var(--r-3); + border-top: 0; + border-radius: 0 0 var(--r-3) var(--r-3); background: var(--bg-elevated); - padding: var(--s-3) var(--s-4); + padding: 0 var(--s-4) var(--s-3); } tbody tr.section td { @@ -788,20 +1007,43 @@ const winnerOf = (cell: ProfileCell): string => { color: var(--fg-faint); } - /* Stacked, the bar leads the comparison full width: there is no column of bars left to align down. */ - .tracks { - grid-template-columns: minmax(0, 1fr); - gap: var(--s-2); - } - + /* Stacked into cards there is no column of bars left to align down, so the bar leads its comparison instead of trailing it. */ .plot { order: 0; margin-top: 0; } - .axis { - top: -2px; - bottom: -2px; + /* + * An arm with no cell keeps its place in the matrix, where the gap is + * visible against the columns beside it. Stacked, that same gap becomes + * a labelled block with a rule and a dash and the height of a result - + * so the card lists only what was measured, and the detail row below + * still names every arm that was not. + */ + .cell.empty { + display: none; + } + + /* The card carries the row's border; a second one under the disclosure would draw a line across the middle of it. */ + tr.detail > td { + padding: var(--s-2) 0 0; + border-bottom: 0; + } + + .arms { + grid-template-columns: minmax(0, 1fr); + } + + /* One comparison at a time is the readable unit here, and the picker names the pair the chips could only narrow to a group. */ + .pick { + display: block; + font-family: var(--f-mono); + font-size: 0.7rem; + color: var(--fg-muted); + } + + .chips { + display: none; } } diff --git a/site/src/components/BenchLocalProbe.astro b/site/src/components/BenchLocalProbe.astro new file mode 100644 index 000000000..abd881c84 --- /dev/null +++ b/site/src/components/BenchLocalProbe.astro @@ -0,0 +1,206 @@ +--- +/** A deliberately separate, browser-only probe. It never changes published profile data. */ +--- + +
+
+

Local, not published

+

Run on this device

+

+ Run a short WebGL2 workload in this browser. It reports this device's local frame cost and capabilities; it is not mixed into the published + cross-library comparisons. +

+
+
+ + Idle +
+ + +
+ + + + diff --git a/site/src/components/BenchProfileReport.astro b/site/src/components/BenchProfileReport.astro index 7c38076d4..46f0324d2 100644 --- a/site/src/components/BenchProfileReport.astro +++ b/site/src/components/BenchProfileReport.astro @@ -20,6 +20,7 @@ import BenchComparisonTable from './BenchComparisonTable.astro'; import BenchComparisonSummary from './BenchComparisonSummary.astro'; import { BACKEND_LABELS, + type BenchDomain, comparisonTallies, formatDay, formatRunTime, @@ -31,6 +32,12 @@ import { physicsComparison, renderingComparison, webgl1Comparison } from '../lib interface Props { document: BenchProfileDocument; + /** + * The domain this view shows. Rendering and physics answer different + * questions and share no row, so putting both on one page only made a + * reader scroll past the one they did not come for. + */ + domain: BenchDomain; /** Heading level the block starts at, so a collapsed further machine does not outrank the reference. */ headingLevel?: 2 | 3; /** Set false where the surrounding markup already names the machine, such as a collapsed further machine. */ @@ -39,15 +46,18 @@ interface Props { primary?: boolean; } -const { document: profileDocument, headingLevel = 2, showTitle = true, primary = false } = Astro.props; +const { document: profileDocument, domain, headingLevel = 2, showTitle = true, primary = false } = Astro.props; const { profile, rendering, physics } = profileDocument; const Heading = `h${String(headingLevel)}` as 'h2' | 'h3'; -const SubHeading = `h${String(headingLevel + 1)}` as 'h3' | 'h4'; -const tallies = comparisonTallies(profileDocument); +const isRendering = domain === 'rendering'; +const tallies = comparisonTallies(profileDocument, domain); + +/** True where no cell of this view carries a timer verdict, which is what a profile written before the clock was recorded looks like. */ +const timerUnrecorded = tallies.length > 0 && tallies.every(entry => entry.counts['timer-unknown'] === entry.total); const renderingTable = renderingComparison(profileDocument); const physicsTable = physicsComparison(profileDocument); -const backends = rendering?.backends ?? []; +const backends = isRendering ? rendering?.backends ?? [] : []; const webgl1Blocks = backends.flatMap(backend => { const table = webgl1Comparison(backend); @@ -72,8 +82,8 @@ const renderingCountNote = const excluded = backends.flatMap(backend => backend.excluded.map(entry => ({ backend: backend.backend, ...entry }))); /** The machine is one across the pooled runs, so the first run's stamps name it for all of them. */ -const renderingStamps = rendering?.runs[0]?.provenance ?? []; -const physicsHost = physics?.runs[0]; +const renderingStamps = isRendering ? rendering?.runs[0]?.provenance ?? [] : []; +const physicsHost = isRendering ? undefined : physics?.runs[0]; /** * When each pooled run was taken, per domain. @@ -84,64 +94,81 @@ const physicsHost = physics?.runs[0]; * them is a median over several measurements and not a single lucky one. */ const runTimes = [ - ...(rendering === undefined ? [] : [{ domain: 'Rendering', times: rendering.runs.map(run => run.provenance[0]?.timestamp ?? null) }]), - ...(physics === undefined ? [] : [{ domain: 'Physics', times: physics.runs.map(run => run.timestamp) }]), + ...(!isRendering || rendering === undefined ? [] : [{ domain: 'Rendering', times: rendering.runs.map(run => run.provenance[0]?.timestamp ?? null) }]), + ...(isRendering || physics === undefined ? [] : [{ domain: 'Physics', times: physics.runs.map(run => run.timestamp) }]), ].map(entry => ({ domain: entry.domain, times: entry.times.filter((time): time is string => time !== null).map(formatRunTime), })); -const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? [])].map(library => `${library.name} ${library.version}`); +/** Only the libraries this view's numbers came from: a physics table is not evidence about the renderer's opponents. */ +const libraries = (isRendering ? rendering?.libraries ?? [] : physics?.libraries ?? []).map(library => `${library.name} ${library.version}`); ---
{showTitle && {profile.gpu} / {profile.os} / {profile.browser}} -
- -
- + {/* Only what a bar's colour means, which the table beside it needs. Everything the key had to explain about itself moved into the tally below the results. */}

ExoJS ahead the arm ahead - level, inside the noise band - no clear lead: the pooled runs landed on different verdict bands + similar + no clear lead past the {FRAME_BUDGET_MS} ms frame, or a spread of {WIDE_SPREAD_RATIO}x and up

-

- A bar is as wide as the arm's row count, so the unfilled part is coverage the matrix does not have rather than a result. Nothing is summed across - bars and no bar is ranked against another: the arms answer different questions, and this page publishes no score and no overall winner. -

- {renderingTable.rows.length > 0 && ( + {/* + * Stated once for the profile rather than in each of its cells: it is a + * fact about how the run was recorded, identical in every row. + */} + {timerUnrecorded && ( +

+ This profile was measured before the harness recorded what its clock resolved, so no comparison here is published as a factor. The times each + arm produced are shown as measured; what cannot be established is whether the clock separated them well enough for a ratio to mean anything. +

+ )} + + {/* The view already names its domain and its unit, so the block carries only what the tab cannot say: the size the rows were measured at. */} + {isRendering && renderingTable.rows.length > 0 && (
- Rendering -

- {renderingCountNote}, chosen from the archetype ladders before any timing was read. CPU milliseconds per frame, pooled over {profile.runs} - {' '}separate runs as the median of their per-run medians; lower is better. -

+

{renderingCountNote}, chosen from the archetype ladders before any timing was read.

)} - {physicsTable !== null && physicsTable.rows.length > 0 && ( + {!isRendering && physicsTable !== null && physicsTable.rows.length > 0 && (
- Physics

Each archetype sits on its own body-count ladder, so every row states the count it was measured at and rows are not comparable with one - another - only the arms within one row are. CPU milliseconds per fixed step, pooled the same way. + another - only the arms within one row are.

)} + {/* + * The tally sits under the results rather than over them. Read first it is + * the hardest thing on the page - coverage, five outcome states, a strip + * whose unfilled part is not a result, and a paragraph on how not to read + * it - and it asks all of that before a reader has seen a single number. + * Read after, it is a summary of rows already met. + */} +
+ How the {tallies.length} comparisons came out, per arm +

+ A bar is as wide as the arm's row count, so the unfilled part is coverage the matrix does not have rather than a result. Nothing is summed + across bars and no bar is ranked against another: the arms answer different questions, and this page publishes no score and no overall winner. +

+ +
+ {webgl1Blocks.map(block => (
WebGL1 arms, CPU time only ({block.table.rows.length} rows)

These arms render through a WebGL1 context, so a gap against them can be caused by the backend generation as much as by the engine, and the structural probe cannot attach to say which. The rows compare CPU time and carry no mechanism, which makes them an observation rather than a - finding. + finding, and the comparison count above therefore leaves them out.

@@ -160,7 +187,7 @@ const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? [] )} - {physicsHost !== undefined && physicsHost.caveats.length > 0 && ( + {!isRendering && physicsHost !== undefined && physicsHost.caveats.length > 0 && (
Caveats the physics runs disclosed ({physicsHost.caveats.length})
    @@ -238,6 +265,16 @@ const libraries = [...(rendering?.libraries ?? []), ...(physics?.libraries ?? [] margin-top: var(--s-7); } + .notice { + margin: 0 0 var(--s-4); + padding: var(--s-3) var(--s-4); + border: 1px solid var(--line-soft); + border-left: 3px solid var(--bench-mark); + border-radius: var(--r-3); + color: var(--fg-muted); + font-size: 0.85rem; + } + .note { max-width: none; margin: 0 0 var(--s-3); diff --git a/site/src/components/BenchResultCard.astro b/site/src/components/BenchResultCard.astro new file mode 100644 index 000000000..110a704b1 --- /dev/null +++ b/site/src/components/BenchResultCard.astro @@ -0,0 +1,230 @@ +--- +/** + * One scenario's result card: what the scene is, how heavy it was, and how long + * each library took. + * + * Times are drawn on a LINEAR scale from zero within the card, so a bar's length + * is the time it shows and two bars can be read against each other directly. + * There is no log scale and no ratio axis: both require the reader to learn the + * axis before they can read the result, and both make a small difference look + * like a large one. + * + * A card compares the arms WITHIN one load. It never compares two loads or two + * cards against each other - those are different scenes, and the harness makes + * no claim about them. + */ + +import { archetypeDescription, archetypeTitle, formatMs, OUTCOME_LABELS } from '../lib/bench-profiles'; +import type { BenchCard } from '../lib/bench-cards'; +import { openingLoad } from '../lib/bench-cards'; + +interface Props { + card: BenchCard; + /** Anchor prefix, so a rendering and a physics card of one name stay distinct. */ + domain: 'rendering' | 'physics'; +} + +const { card, domain } = Astro.props; +const opening = openingLoad(card); +const description = archetypeDescription(card.id); + +/** + * Bar width for one figure, as a percentage of the card's slowest arm. + * + * A measured figure always gets at least a sliver, so a very fast arm stays + * visible - but the sliver is deliberately narrow enough that nobody reads it as + * a quantity. An absent figure gets no bar at all rather than a zero-length one: + * zero would say the arm took no time, which is the opposite of what happened. + */ +const widthOf = (ms: number | null, maxMs: number): number | null => (ms === null || !Number.isFinite(ms) || maxMs <= 0 ? null : Math.max(1.5, (ms / maxMs) * 100)); +--- + +
    +
    +

    {archetypeTitle(card.id)}

    + {description !== undefined &&

    {description}

    } +
    + + {card.loads.length > 1 && ( +
    + {card.loads.map(load => ( + + ))} +
    + )} + + {card.loads.map(load => ( + + ))} +
    + + + + diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro index 33d435486..db57d0e84 100644 --- a/site/src/components/pages/BenchmarksPage.astro +++ b/site/src/components/pages/BenchmarksPage.astro @@ -1,33 +1,44 @@ --- /** - * BenchmarksPage - the published cross-library measurements. + * BenchmarksPage - the published cross-library measurements, rendering and + * physics on one page. * * The page is generated at build time from the machine profiles under * `packages/exojs-bench/results/` and from nothing else, so it cannot drift from - * the harness: a re-measurement rewrites the scoreboard, the tables and the + * the harness: a re-measurement rewrites the cards, the tables and the * provenance together. Losses are published on the same terms as wins, nothing * is aggregated into a score or an overall winner, and the rows the harness left * out of a comparison are listed with their reasons. * - * The numbers come first and the practices behind them last. Methodology, - * reproduction and fairness are what a reader consults once a row surprises - * them, so they sit under the tables rather than in front of them - and they - * stay on the page when the directory holds no profile at all, which is the - * state a fresh clone and a release branch before its reference run are in. + * Results lead and the practices behind them follow. Which scenarios open each + * section is fixed in `bench-cards.ts` before any run happens, so the top of the + * page cannot become a selection of whatever ExoJS won; the rest stay one + * disclosure away in the same section. */ import BenchProfileReport from '../BenchProfileReport.astro'; +import BenchResultCard from '../BenchResultCard.astro'; +import BenchLocalProbe from '../BenchLocalProbe.astro'; import DocsLayout from '../../layouts/DocsLayout.astro'; import EnglishFallbackNotice from '../EnglishFallbackNotice.astro'; import { appInfo } from '../../lib/app-info'; -import { formatDay, FRAME_BUDGET_MS, furtherProfiles, olderThanReference, profileScope, referenceProfile } from '../../lib/bench-profiles'; +import { PHYSICS_HEADLINE_SCENARIOS, physicsCards, RENDERING_HEADLINE_SCENARIOS, renderingCards, selectCards } from '../../lib/bench-cards'; +import { + BACKEND_LABELS, + coversDomain, + formatDay, + FRAME_BUDGET_MS, + furtherProfiles, + olderThanReference, + type ProfileBackendName, + referenceProfile, +} from '../../lib/bench-profiles'; interface Props { locale: 'en' | 'de'; } const { locale } = Astro.props; -const scope = referenceProfile === undefined ? '' : profileScope(referenceProfile); /** * Runs the reproduction below pools. A published profile states the number it @@ -39,50 +50,44 @@ const REPRODUCTION_RUNS = 3; const pooledRuns = referenceProfile?.profile.runs ?? REPRODUCTION_RUNS; const machine = referenceProfile?.profile; -/** - * The sections the on-page navigation offers. Only the ones that exist are - * listed: a link to an empty anchor is worse than a shorter bar. - */ -const sections = [ - ...(referenceProfile === undefined ? [] : [{ id: 'summary', label: 'Overview' }]), - ...(referenceProfile?.rendering === undefined ? [] : [{ id: 'rendering', label: 'Rendering' }]), - ...(referenceProfile?.physics === undefined ? [] : [{ id: 'physics', label: 'Physics' }]), - ...(furtherProfiles.length === 0 ? [] : [{ id: 'further', label: 'Other machines' }]), - { id: 'practices', label: 'Methodology' }, -]; +/** Cards per rendering backend, so the section switches without a second request. */ +const backends: readonly ProfileBackendName[] = ['webgl2', 'webgpu']; +const rendering = backends + .map(backend => ({ + backend, + ...selectCards(referenceProfile === undefined ? [] : renderingCards(referenceProfile, backend), RENDERING_HEADLINE_SCENARIOS, 6), + })) + .filter(entry => entry.headline.length > 0); + +const physics = selectCards(referenceProfile === undefined ? [] : physicsCards(referenceProfile), PHYSICS_HEADLINE_SCENARIOS, 4); + +/** Only the further machines that measured something, so no disclosure opens onto nothing. */ +const furtherMachines = furtherProfiles.filter(document => coversDomain(document, 'rendering') || coversDomain(document, 'physics')); --- - +
    {locale === 'de' && }

    Benchmarks

    - Cross-library measurements for rendering and physics. Lower is better. Every published value is pooled from {pooledRuns} independent runs. + How long one frame of rendering, and one fixed step of physics, costs on the CPU. Lower is better, and every published value is the median + of {pooledRuns} independent runs.

    - {machine !== undefined && ( -

    - - {machine.gpu} · {machine.os} · {machine.browser} - - - ExoJS {machine.engineVersion} · measured {formatDay(machine.measuredAt)} · {machine.runs} pooled runs - - {scope !== '' && {scope}} -

    - )}
    - + {machine !== undefined && ( +

    + + {machine.gpu} · {machine.os} · {machine.browser} + + + ExoJS {machine.engineVersion} · measured {formatDay(machine.measuredAt)} · {machine.runs} pooled runs + + Details and methodology +

    + )} {referenceProfile === undefined ? (

    @@ -90,16 +95,110 @@ const sections = [ pooled into a single profile file in the repository; until then there is nothing here to read. The practices below already apply.

    ) : ( - + <> +
    +
    +

    Rendering

    + {rendering.length > 1 && ( +
    + {rendering.map((entry, index) => ( + + ))} +
    + )} +
    + + {rendering.length === 0 ? ( +

    This machine published no rendering measurement.

    + ) : ( + rendering.map((entry, index) => ( + + )) + )} +
    + +
    +
    +

    Physics

    +
    + + {physics.headline.length === 0 ? ( +

    This machine published no physics measurement.

    + ) : ( + <> +
    + {physics.headline.map(card => ( + + ))} +
    + + {physics.rest.length > 0 && ( +
    + All physics tests ({physics.rest.length} more) +
    + {physics.rest.map(card => ( + + ))} +
    +
    + )} + + )} +
    + +
    +

    Full data

    +

    + Every measured row with its spread, its p95, the mechanism behind each difference and the rows the harness left out - the same + numbers the cards above are drawn from, in full. +

    + + {coversDomain(referenceProfile, 'rendering') && ( +
    + Rendering: every row, spread and omission + +
    + )} + + {coversDomain(referenceProfile, 'physics') && ( +
    + Physics: every row, spread and omission + +
    + )} +
    + )} - {furtherProfiles.length > 0 && ( + + + {furtherMachines.length > 0 && (

    Further machines

    - Contributed profiles for other hardware, newest engine version first: do the ratios hold on another GPU, driver and JavaScript engine? + Contributed profiles measured on other hardware, newest engine version first: do the ratios hold on another GPU, driver and JavaScript + engine?

    - {furtherProfiles.map(document => { + {furtherMachines.map(document => { const newer = olderThanReference(document); return ( @@ -118,7 +217,10 @@ const sections = [ reference profile with that in mind.

    )} - + {coversDomain(document, 'rendering') && ( + + )} + {coversDomain(document, 'physics') && }
); })} @@ -299,6 +401,27 @@ pnpm bench:compare -- \ + + + + diff --git a/site/src/lib/bench-cards.ts b/site/src/lib/bench-cards.ts new file mode 100644 index 000000000..2b030f756 --- /dev/null +++ b/site/src/lib/bench-cards.ts @@ -0,0 +1,208 @@ +/** + * The card model the benchmarks page is built from. + * + * A card is one scenario: the loads it was measured at, and for each load the + * time every arm took. The page leads with these rather than with a table + * because the question a reader arrives with is "how does ExoJS do on the work I + * am about to do", and a table answers that only after they have learned how to + * read it. + * + * Nothing here computes a timing or a verdict. The loads, their order and which + * one opens a card are all decided by the harness before a run starts, and this + * module only groups what the profile already carries - so no card can be + * assembled to suit the numbers inside it. + */ + +import type { BenchProfileDocument, ProfileBackendName, ProfileCell, ProfileRow, ProfileSection } from './bench-profiles'; +import { armLabel, formatLoad, outcomeOf } from './bench-profiles'; + +/** One arm's time on one load of one scenario. */ +export interface CardArm { + /** Arm id, e.g. `pixi`. */ + readonly id: string; + /** Human label, e.g. `PixiJS`. */ + readonly label: string; + /** Milliseconds, or `null` where the arm produced no comparable figure. */ + readonly ms: number | null; + /** 95th percentile of the same window, or `null`. */ + readonly p95Ms: number | null; + /** True when this arm is ExoJS itself rather than a competitor. */ + readonly reference: boolean; + /** True when the figure is past a whole 60 fps frame. */ + readonly overFrameBudget: boolean; + /** What the comparison this arm belongs to could establish; see `outcomeOf`. */ + readonly outcome: ReturnType; +} + +/** One selectable load of one scenario. */ +export interface CardLoad { + /** Stable id within the scenario, used as the control's value. */ + readonly id: string; + /** How the load reads beside the figures, e.g. `10,000 sprites`. */ + readonly label: string; + /** Whether this is the load the card opens on. */ + readonly primary: boolean; + /** ExoJS first, then the competitors in the profile's own order. */ + readonly arms: readonly CardArm[]; + /** Largest measured figure on this load, for scaling the bars. */ + readonly maxMs: number; +} + +/** One scenario's card. */ +export interface BenchCard { + /** Archetype id - the card's identity and its anchor. */ + readonly id: string; + /** The section the scenario is filed under. */ + readonly category: string; + /** Loads, in the order the harness measured them. */ + readonly loads: readonly CardLoad[]; +} + +/** + * Scenarios the rendering section opens with, in this order. + * + * Chosen for the spread of work they represent - sprites moving, sprites still, + * text changing, a large map scrolling, an effect running, clipping - and fixed + * here rather than derived from the results, so the opening of the page cannot + * become a selection of whatever ExoJS happened to win. + */ +export const RENDERING_HEADLINE_SCENARIOS: readonly string[] = [ + 'dynamic-all', + 'static-heavy', + 'text-dynamic', + 'tilemap-scroll', + 'particles-lifecycle', + 'mask-clip', +]; + +/** Scenarios the physics section opens with, chosen on the same terms. */ +export const PHYSICS_HEADLINE_SCENARIOS: readonly string[] = ['many-dynamic', 'box-stack', 'raycast', 'joints']; + +/** + * Fallback order for a profile that carries none of the headline scenarios - + * an older measurement taken before they existed. + * + * A page that simply showed nothing there would report the absence of the + * scenarios as an absence of results, so the section opens on whatever the + * profile does carry, in its own order. + */ +const headlineOrFirst = (cards: readonly BenchCard[], preferred: readonly string[], count: number): readonly BenchCard[] => { + const chosen = preferred.map(id => cards.find(card => card.id === id)).filter((card): card is BenchCard => card !== undefined); + const taken = new Set(chosen.map(card => card.id)); + + // Topped up in the profile's own order, never by result: an older profile + // carries only some of the preferred scenarios, and a section that showed + // three cards because three names happened to match would report the age of + // the measurement as a shortage of tests. + for (const card of cards) { + if (chosen.length >= count) { + break; + } + + if (!taken.has(card.id)) { + chosen.push(card); + taken.add(card.id); + } + } + + return chosen; +}; + +/** ExoJS's own figure, which every cell of a row repeats because every pair shares it. */ +const referenceArm = (cells: readonly ProfileCell[]): CardArm | null => { + const first = cells[0]; + + if (first === undefined) { + return null; + } + + return { + id: 'exojs', + label: armLabel('exojs'), + ms: first.referenceMs, + p95Ms: first.referenceP95Ms, + reference: true, + overFrameBudget: first.referenceOverFrameBudget, + outcome: outcomeOf(first), + }; +}; + +const competitorArm = (cell: ProfileCell): CardArm => ({ + id: cell.competitor, + label: armLabel(cell.competitor), + ms: cell.competitorMs, + p95Ms: cell.competitorP95Ms, + reference: false, + overFrameBudget: cell.competitorOverFrameBudget, + outcome: outcomeOf(cell), +}); + +/** One row becomes one selectable load. */ +const loadOf = (row: ProfileRow): CardLoad | null => { + const reference = referenceArm(row.cells); + + if (reference === null) { + return null; + } + + const arms = [reference, ...row.cells.map(competitorArm)]; + const measured = arms.map(arm => arm.ms).filter((ms): ms is number => ms !== null && Number.isFinite(ms)); + + return { + id: row.loadId ?? String(row.count), + label: formatLoad(row), + primary: row.primary ?? false, + arms, + maxMs: measured.length > 0 ? Math.max(...measured) : 0, + }; +}; + +/** Group a domain's sections into one card per scenario. */ +const cardsOf = (sections: readonly ProfileSection[]): readonly BenchCard[] => { + const byScenario = new Map(); + + for (const section of sections) { + for (const row of section.rows) { + const load = loadOf(row); + + if (load === null) { + continue; + } + + const card = byScenario.get(row.archetype) ?? { category: section.title, loads: [] }; + + card.loads.push(load); + byScenario.set(row.archetype, card); + } + } + + return [...byScenario.entries()].map(([id, card]) => ({ id, category: card.category, loads: card.loads })); +}; + +/** The rendering cards of one profile on one backend, or an empty list where it measured none. */ +export const renderingCards = (document: BenchProfileDocument, backend: ProfileBackendName): readonly BenchCard[] => { + const block = document.rendering?.backends.find(entry => entry.backend === backend); + + return block === undefined ? [] : cardsOf(block.sections); +}; + +/** The physics cards of one profile. Physics has no backend axis. */ +export const physicsCards = (document: BenchProfileDocument): readonly BenchCard[] => + document.physics === undefined ? [] : cardsOf([document.physics.section]); + +/** The cards a section opens with, and the ones kept behind its "show all" control. */ +export interface CardSelection { + readonly headline: readonly BenchCard[]; + readonly rest: readonly BenchCard[]; +} + +/** Split a domain's cards into the fixed opening set and the remainder. */ +export const selectCards = (cards: readonly BenchCard[], preferred: readonly string[], count: number): CardSelection => { + const headline = headlineOrFirst(cards, preferred, count); + const shown = new Set(headline.map(card => card.id)); + + return { headline, rest: cards.filter(card => !shown.has(card.id)) }; +}; + +/** The load a card opens on: its headline, or the first one it carries. */ +export const openingLoad = (card: BenchCard): CardLoad | undefined => card.loads.find(load => load.primary) ?? card.loads[0]; diff --git a/site/src/lib/bench-profiles.ts b/site/src/lib/bench-profiles.ts index 7f5ee81fd..64d4de2f6 100644 --- a/site/src/lib/bench-profiles.ts +++ b/site/src/lib/bench-profiles.ts @@ -21,7 +21,7 @@ */ /** Schema version this reader understands; anything else is refused. */ -const SUPPORTED_SCHEMA_VERSION = 6; +const SUPPORTED_SCHEMA_VERSIONS = new Set([6, 7]); /** * Arms that stand as a reference ceiling rather than as a peer. @@ -86,14 +86,20 @@ export interface ProfileCell { readonly referenceMs: number | null; /** Median of the per-run p95s. */ readonly referenceP95Ms: number | null; + /** GPU frame median when the published rendering run exposed a hardware timer. */ + readonly referenceGpuMs?: number | null; /** True when `referenceMs` is past a whole 60 fps frame; see `FRAME_BUDGET_MS`. */ readonly referenceOverFrameBudget: boolean; /** Median of the per-run medians. */ readonly competitorMs: number | null; /** Median of the per-run p95s. */ readonly competitorP95Ms: number | null; + /** GPU frame median when the published rendering run exposed a hardware timer. */ + readonly competitorGpuMs?: number | null; /** True when `competitorMs` is past a whole 60 fps frame; see `FRAME_BUDGET_MS`. */ readonly competitorOverFrameBudget: boolean; + /** What the timer check made of this comparison across the runs behind it; absent in profiles written before the clock was recorded. */ + readonly timer?: TimerCheck; readonly verdict: ProfileVerdict; /** Structural evidence behind the difference, or `null` when the counters carry none. */ readonly mechanism: string | null; @@ -102,20 +108,51 @@ export interface ProfileCell { } /** - * One published row: an archetype at the count it was measured at. + * One published row: an archetype at one load. * - * A rendering row carries its block's single node count. A physics row carries - * its own body count, because the physics archetypes have per-archetype ladders - - * so two physics rows are never comparable with each other, only the arms within - * one row are. + * An archetype measured at several loads publishes a row per load. A reader + * compares the arms WITHIN a row, which is like for like by construction, and + * never two rows against each other: two loads are two different scenes, as are + * two archetypes. */ export interface ProfileRow { readonly archetype: string; readonly category: string; readonly count: number; + /** + * Catalog load id, e.g. `10k`. Absent in profiles written before rows carried + * their load, where an archetype appears exactly once. + */ + readonly loadId?: string; + /** Unit {@link count} is quoted in, so a figure is never shown without one. */ + readonly unit?: LoadUnit; + /** Whether this is the scenario's headline load - the one a card opens on. */ + readonly primary?: boolean; + /** Display label for a load the count/unit pair cannot state, e.g. a resolution. */ + readonly label?: string; readonly cells: readonly ProfileCell[]; } +/** + * Unit a load is counted in. + * + * Carried per row rather than assumed per domain: the same figure means scene + * nodes in one scenario and world tiles in another, and those are not the same + * claim. + */ +export type LoadUnit = 'sprites' | 'nodes' | 'labels' | 'tiles' | 'layers' | 'particles' | 'widgets' | 'rects' | 'bodies' | 'viewport'; + +/** How a load reads beside its figure: `10,000 sprites`, or the row's own label. */ +export const formatLoad = (row: ProfileRow): string => { + if (row.label !== undefined) { + return row.label; + } + + const figure = row.count.toLocaleString('en-US'); + + return row.unit === undefined ? figure : `${figure} ${row.unit}`; +}; + /** A category section of a published table. */ export interface ProfileSection { readonly title: string; @@ -176,10 +213,19 @@ export interface PlatformVersionStamp { readonly evidence: string; } +/** What a page's clock was observed to do, where the run recorded it. */ +export interface ProfileClock { + /** Smallest positive step the probe saw, or `null` where it established none. */ + readonly resolutionMs: number | null; + readonly crossOriginIsolated: boolean; +} + /** Rendering provenance for one backend. */ export interface RenderingStamp { readonly backend: ProfileBackendName; readonly adapter: string; + /** The clock this backend's page was measured on; absent in profiles written before it was recorded. */ + readonly clock?: ProfileClock; /** Browser engine the run was measured in. */ readonly browser: string; /** Browser build the run was measured in. */ @@ -321,16 +367,26 @@ const byVersionDescending = (a: string, b: string): number => { return 0; }; -/** Newest engine version first, then newest measurement first. */ +/** How many of the two measured domains a profile carries. */ +const domainCount = (document: BenchProfileDocument): number => (document.rendering === undefined ? 0 : 1) + (document.physics === undefined ? 0 : 1); + +/** + * Newest engine version first, then widest coverage, then newest measurement. + * + * Coverage outranks the measurement date because the first profile leads the + * page: a partial run finished an hour later than a complete one would + * otherwise bury a whole domain in the collapsed list below, and the page would + * silently stop showing measurements it holds. + */ const byRecency = (a: BenchProfileDocument, b: BenchProfileDocument): number => - byVersionDescending(a.profile.engineVersion, b.profile.engineVersion) || b.profile.measuredAt.localeCompare(a.profile.measuredAt); + byVersionDescending(a.profile.engineVersion, b.profile.engineVersion) || + domainCount(b) - domainCount(a) || + b.profile.measuredAt.localeCompare(a.profile.measuredAt); const loaded = Object.entries(documents) .map(([path, document]) => { - if (document.schemaVersion !== SUPPORTED_SCHEMA_VERSION) { - throw new Error( - `Benchmark profile '${path}' declares schema version ${String(document.schemaVersion)}, but this site reads version ${String(SUPPORTED_SCHEMA_VERSION)}.`, - ); + if (!SUPPORTED_SCHEMA_VERSIONS.has(document.schemaVersion)) { + throw new Error(`Benchmark profile '${path}' declares unsupported schema version ${String(document.schemaVersion)}.`); } return document; @@ -359,6 +415,8 @@ export const BACKEND_LABELS: Readonly> = { we * harness never silently renames it here. */ const ARM_LABELS: Readonly> = { + exojs: 'ExoJS', + 'exojs-physics': 'ExoJS', pixi: 'PixiJS', excalibur: 'Excalibur', phaser: 'Phaser', @@ -370,6 +428,106 @@ const ARM_LABELS: Readonly> = { /** An arm's published name, or its slug where none is known. */ export const armLabel = (arm: string): string => ARM_LABELS[arm] ?? arm; +/** + * What each archetype's workload is, in one line. + * + * These live with the page rather than in a profile: they are prose for a + * reader, identical on every machine, and a measurement artifact that carried + * them would repeat them per run and let two published profiles disagree about + * what the same archetype means. The archetype id is the contract between the + * harness and this page; an id with no line here simply prints without one. + */ +const ARCHETYPE_DESCRIPTIONS: Readonly> = { + 'static-heavy': 'Mostly unchanged sprites; stresses retained scene reuse.', + 'dynamic-heavy': 'A lightly mutating sprite field; stresses transform and update work.', + 'deep-hierarchy': 'Deep parent-child nesting; stresses world-transform propagation.', + overdraw: 'Full-viewport sprites; stresses fragment fill and overdraw.', + 'batch-breaking': 'Many texture changes; stresses batch breaks and state submission.', + 'batch-breaking-atlased': 'Atlased texture changes; isolates batching without texture uploads.', + 'split-screen': 'Several simultaneous views; stresses multi-viewport traversal.', + 'mixed-blend': 'Long runs of blend modes; stresses state changes and batching.', + 'mixed-material': 'Several custom materials; stresses shader/material switches.', + 'mixed-material-atlased': 'Custom materials over atlased sprites; combines material and texture variety.', + 'instanced-batch': 'Explicit instance batches; stresses immediate submission cost.', + 'mixed-sprite-mesh-array': 'Sprites interleaved with mesh-array leaves; stresses renderer path switches.', + 'mixed-sprite-mesh-static': 'Sprites interleaved with static meshes; stresses mixed draw paths.', + 'scrolling-world': 'A moving camera over mostly off-screen content; stresses culling and retained reuse.', + 'text-static': 'Static labels with repeated glyphs; stresses text layout and glyph generation.', + 'text-dynamic': 'Changing labels; stresses per-frame text invalidation and layout.', + 'lifecycle-churn': 'A small fraction of leaves rebuilt each frame; stresses resource lifecycle work.', + 'filter-chain-1': 'One filter pass per scene; stresses offscreen composition.', + 'filter-chain-2': 'Two filter passes per scene; stresses chained offscreen composition.', + 'filter-chain-4': 'Four filter passes per scene; stresses deep filter composition.', + 'mask-clip': 'Clipped content; stresses mask setup and compositing.', + 'mask-clip-animated': 'Animated clipped content; stresses mask invalidation.', + composite: 'Nested render targets; stresses multi-pass composition.', + 'box-stack': 'Dense resting contacts; stresses collision detection, solving and sleeping.', + 'many-dynamic': 'Many active bodies in a bounded field; stresses broad-phase and live contacts.', + 'mixed-static-dynamic': 'Dynamic bodies falling onto static level geometry; models a common game mix.', + raycast: 'A mixed scene plus repeated rays; isolates query throughput.', + 'body-churn': 'Bodies rebuilt every step; stresses broad-phase repair and lifecycle work.', + joints: 'Constraint chains; stresses impulse propagation through joints.', + 'settling-pile': 'A dissipating pile; exposes steady-state settling and sleeping behavior.', + 'dynamic-all': 'Every sprite moving every frame; stresses transform and upload work at full mutation.', + 'fill-layers': 'Stacked translucent full-screen layers; stresses blended fill.', + 'tilemap-scroll': 'A large tile map scrolling past a fixed window; stresses the tile draw path.', + 'tilemap-edit': 'Tile ids replaced every frame; stresses getting a tile change to the GPU.', + 'particles-draw': 'A fixed set of quads submitted through the particle path; no simulation.', + 'particles-lifecycle': 'A steady particle effect: ageing, movement, fading and respawning.', +}; + +/** + * A scenario's title, as a card names it. + * + * Plain words rather than the archetype id: the id is the contract with the + * harness and belongs in the details, while the card has to be readable by + * someone who has never run the benchmark. + */ +const ARCHETYPE_TITLES: Readonly> = { + 'static-heavy': 'Static scene', + 'dynamic-heavy': 'Scene with few changes', + 'dynamic-all': 'Fully moving sprites', + 'deep-hierarchy': 'Deep scene hierarchy', + overdraw: 'Overdraw ceiling', + 'fill-layers': 'Transparent screen layers', + 'batch-breaking': 'Many texture changes', + 'batch-breaking-atlased': 'Same scene, atlased', + 'split-screen': 'Split screen', + 'mixed-blend': 'Mixed blend modes', + 'mixed-material': 'Custom materials', + 'mixed-material-atlased': 'Custom materials, atlased', + 'instanced-batch': 'Instanced submission', + 'mixed-sprite-mesh-array': 'Sprites and array meshes', + 'mixed-sprite-mesh-static': 'Sprites and static meshes', + 'scrolling-world': 'Camera over a sprite world', + 'text-static': 'Static labels', + 'text-dynamic': 'Changing labels', + 'lifecycle-churn': 'Creating and destroying objects', + 'filter-chain-1': 'One filter pass', + 'filter-chain-2': 'Two filter passes', + 'filter-chain-4': 'Four filter passes', + 'mask-clip': 'Clipping', + 'mask-clip-animated': 'Moving clip', + composite: 'Composited effect', + 'tilemap-scroll': 'Large tile map', + 'tilemap-edit': 'Editing tiles', + 'particles-draw': 'Drawing particles', + 'particles-lifecycle': 'Particle effect', + 'box-stack': 'Box stack', + 'many-dynamic': 'Many active bodies', + 'mixed-static-dynamic': 'Static level, falling bodies', + raycast: 'Ray queries', + 'body-churn': 'Bodies created and destroyed', + joints: 'Joint chains', + 'settling-pile': 'Settling pile', +}; + +/** The readable title for a scenario, falling back to its id where none is written. */ +export const archetypeTitle = (archetype: string): string => ARCHETYPE_TITLES[archetype] ?? archetype; + +/** The one-line workload description for an archetype, or `undefined` where none is written. */ +export const archetypeDescription = (archetype: string): string | undefined => ARCHETYPE_DESCRIPTIONS[archetype]; + /** * Spread factor at which a measurement's own noise is called out. * @@ -394,11 +552,67 @@ export const WIDE_SPREAD_RATIO = 1.2; */ export const FRAME_BUDGET_MS = 16.7; -/** A ratio in the form the verdict labels print it, or a dash when the pair produced none. */ -export const formatFactor = (factor: number | null): string => (factor === null || !Number.isFinite(factor) ? '-' : `${factor.toFixed(2)}x`); +/** + * How many digits of a published figure are worth printing. + * + * Three, and never a fixed number of decimals: a fixed three prints `13.380` for + * a value whose pooled runs spanned 12.73 to 14.10, which is a digit past what + * the measurement separated. + * + * The rule is about how many digits a figure of this size can carry, and it says + * nothing about the clock behind it. A value that reaches the page as `0.020` + * has three digits because the formatter counts places, not because the timer + * resolved a thousandth of a millisecond - see {@link ProfileAggregate} for what + * the runs actually separated. + */ +const SIGNIFICANT_DIGITS = 3; + +/** A number at {@link SIGNIFICANT_DIGITS}, as a fixed number of decimals for its magnitude. */ +const significant = (value: number): string => { + const magnitude = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value))); + + // Capped at three: below a tenth of a millisecond the significant-figure rule + // would keep adding decimals to values the clock delivers in fixed steps. + return value.toFixed(Math.max(0, Math.min(3, SIGNIFICANT_DIGITS - 1 - magnitude))); +}; + +/** + * A ratio as the cell prints it, or a dash when the pair produced none. + * + * A factor is read for its size, not for its digits, so it carries fewer than a + * millisecond value: whole numbers from ten up, one decimal below that, and no + * trailing zero. `72x` and `2x` say what `72.25x` and `2.00x` said, without + * offering four figures of a ratio the ladder rounds to a rung anyway. The + * unrounded figure stays in the row's detail. + */ +export const formatFactor = (factor: number | null): string => { + if (factor === null || !Number.isFinite(factor)) return '-'; + + const rounded = factor >= 10 ? factor.toFixed(0) : factor.toFixed(1).replace(/\.0$/, ''); + + return `${rounded}x`; +}; /** A median in milliseconds, or a dash when the arm produced no comparable number. */ -export const formatMs = (ms: number | null): string => (ms === null || !Number.isFinite(ms) ? '-' : ms.toFixed(3)); +export const formatMs = (ms: number | null): string => (ms === null || !Number.isFinite(ms) ? '-' : significant(ms)); + +/** + * True when the pair produced a comparison at all. + * + * A pair the harness could not compare - an arm that does not implement the + * archetype, or that reported nothing there - carries neither a ratio nor a + * factor. + */ +export const isComparable = (cell: ProfileCell): boolean => cell.verdict.ratio !== null || cell.verdict.factor !== null; + +/** + * One arm's measurement inside a cell, or `null` where that arm produced none. + * + * An arm that sat a comparison out is stored as a zero rather than as a missing + * value, so a reader would otherwise be shown `0.000 ms` - the fastest number on + * the page - for the arm that did not run. + */ +export const measuredMs = (cell: ProfileCell, ms: number | null): number | null => (!isComparable(cell) && ms === 0 ? null : ms); /** An ISO timestamp reduced to a calendar day. */ export const formatDay = (timestamp: string): string => timestamp.slice(0, 10); @@ -528,10 +742,20 @@ const listOf = (items: readonly string[]): string => (items.length < 2 ? (items[ * absence of one: the runs reached different rungs, so the pair carries numbers * and no conclusion. `absent` is an arm that produced no comparable cell at all. */ -export type CellOutcome = 'clear-lead' | 'lead' | 'level' | 'loss' | 'clear-loss' | 'unstable' | 'absent'; - -/** Outcomes in reading order: the widest lead first, the widest loss last, then the two that carry no verdict. */ -export const OUTCOME_ORDER: readonly CellOutcome[] = ['clear-lead', 'lead', 'level', 'loss', 'clear-loss', 'unstable', 'absent']; +export type CellOutcome = 'clear-lead' | 'lead' | 'level' | 'loss' | 'clear-loss' | 'unstable' | 'timer-limited' | 'timer-unknown' | 'absent'; + +/** Outcomes in reading order: the widest lead first, the widest loss last, then the three that carry no verdict. */ +export const OUTCOME_ORDER: readonly CellOutcome[] = [ + 'clear-lead', + 'lead', + 'level', + 'loss', + 'clear-loss', + 'unstable', + 'timer-limited', + 'timer-unknown', + 'absent', +]; /** The word a summary and a legend print for each outcome. */ export const OUTCOME_LABELS: Readonly> = { @@ -541,18 +765,42 @@ export const OUTCOME_LABELS: Readonly> = { loss: 'loss', 'clear-loss': 'clear loss', unstable: 'no clear lead', + 'timer-limited': 'below the timer', + 'timer-unknown': 'timer not recorded', absent: 'no shared cell', }; +/** + * What the timer check established about a comparison. + * + * The check itself runs in the harness, per run and against the grid of the + * session that produced each cell, and its merged result travels in the profile. + * Recomputing it here is not possible and would not be right: the per-run + * durations and their per-session clocks are not in the published document, and + * pooled medians checked against a pooled grid is exactly the reading the + * per-run check exists to prevent. + */ +export type TimerCheck = 'resolved' | 'limited' | 'unknown'; + /** * Which outcome a cell publishes. * - * This is the only place a comparison is turned into one of the seven words, so - * no table or scoreboard can invent an outcome for a cell whose runs did not - * agree on one. + * This is the only place a comparison is turned into one of the words, so no + * table or scoreboard can invent an outcome for a cell whose runs did not agree + * on one - or whose durations the clock did not separate. Pass `resolutionMs` + * as the coarsest step observed across the runs behind this cell: a pooled + * figure inherits the limit of the least resolved run that produced it. + * + * The two timer states outrank every verdict because they are about whether a + * comparison could be drawn at all. Either way the cell keeps both measured + * times and loses the factor, the bar and the winner - a refusal to publish a + * comparison, not a claim that the libraries are equally fast. They stay apart + * because they say different things: `timer-limited` is a check that tripped, + * `timer-unknown` is a check that could not be made. */ export const outcomeOf = (cell: ProfileCell | null): CellOutcome => { if (cell === null) return 'absent'; + if (cell.timer !== 'resolved') return cell.timer === 'limited' ? 'timer-limited' : 'timer-unknown'; if (!cell.aggregate.stable) return 'unstable'; if (cell.verdict.side === 'neither') return 'level'; if (cell.verdict.side === 'exojs') return cell.verdict.structural ? 'clear-lead' : 'lead'; @@ -588,7 +836,7 @@ export const ratioBand = (cell: ProfileCell): RatioBand | null => { }; /** A ratio band as the details print it. */ -export const formatBand = (band: RatioBand): string => `${band.low.toFixed(2)}x-${band.high.toFixed(2)}x`; +export const formatBand = (band: RatioBand): string => `${formatFactor(band.low)}-${formatFactor(band.high)}`; /** * The factor a pair's two pooled medians work out to. @@ -611,10 +859,10 @@ export const pooledFactor = (cell: ProfileCell): number | null => { }; /** A factor the runs did not settle, marked as such. */ -export const formatApproximate = (factor: number): string => `~${factor.toFixed(2)}x`; +export const formatApproximate = (factor: number): string => `~${formatFactor(factor)}`; /** How far the pooled runs moved, as the single factor the profile stores. */ -export const formatSpread = (spread: ProfileSpread): string => (spread.ratio === null || !Number.isFinite(spread.ratio) ? '' : `${spread.ratio.toFixed(2)}x`); +export const formatSpread = (spread: ProfileSpread): string => (spread.ratio === null || !Number.isFinite(spread.ratio) ? '' : formatFactor(spread.ratio)); /** * What a measured comparison came out as, once the ladder's five settled rungs @@ -636,6 +884,11 @@ export const SUMMARY_OF: Readonly> = { loss: 'behind', 'clear-loss': 'behind', unstable: 'unclear', + // Counted on its own line rather than folded into `unclear`: those runs + // disagreed about a comparison that was made, while these never resolved one. + 'timer-limited': null, + // Not judged rather than judged inconclusive, and counted as neither. + 'timer-unknown': null, absent: null, }; @@ -682,6 +935,9 @@ const tally = (key: string, group: string, label: string, arm: string, meta: str return { key, group, label, arm, meta, counts, summary, measured, total: cells.length }; }; +/** Which measured domain a view is showing. */ +export type BenchDomain = 'rendering' | 'physics'; + /** * The scoreboard, one line per arm a domain was measured against. * @@ -689,9 +945,12 @@ const tally = (key: string, group: string, label: string, arm: string, meta: str * different opponents into one strip, so a reader would see a mix that belongs * to neither of them. Nothing is summed across lines and no line is ranked * against another, because the arms answer different questions. + * + * Pass `domain` to keep the lines of one domain only; without it the document's + * whole scoreboard is returned. */ -export const comparisonTallies = (document: BenchProfileDocument): readonly ComparisonTally[] => [ - ...(document.rendering?.backends ?? []).flatMap(backend => +export const comparisonTallies = (document: BenchProfileDocument, domain?: BenchDomain): readonly ComparisonTally[] => [ + ...(domain === 'physics' ? [] : (document.rendering?.backends ?? [])).flatMap(backend => backend.competitors.map(arm => tally( `${backend.backend}-${arm}`, @@ -703,7 +962,7 @@ export const comparisonTallies = (document: BenchProfileDocument): readonly Comp ), ), ), - ...(document.physics === undefined + ...(document.physics === undefined || domain === 'rendering' ? [] : armsOfSection(document.physics.section).map(arm => tally( @@ -726,9 +985,9 @@ export const comparisonTallies = (document: BenchProfileDocument): readonly Comp * longer support. A profile carrying only one domain yields a shorter line * instead of a padded one. */ -export const profileScope = (document: BenchProfileDocument): string => { - const rendering = renderingCells(document); - const physics = physicsCells(document); +export const profileScope = (document: BenchProfileDocument, domain?: BenchDomain): string => { + const rendering = domain === 'physics' ? [] : renderingCells(document); + const physics = domain === 'rendering' ? [] : physicsCells(document); const parts: string[] = []; if (rendering.length > 0) parts.push(`${String(rendering.length)} rendering comparisons against ${listOf(armsIn(rendering).map(armLabel))}`); @@ -736,3 +995,7 @@ export const profileScope = (document: BenchProfileDocument): string => { return parts.join(' · '); }; + +/** True when the profile carries measurements for this domain. */ +export const coversDomain = (document: BenchProfileDocument, domain: BenchDomain): boolean => + domain === 'rendering' ? document.rendering !== undefined : document.physics !== undefined; diff --git a/site/src/lib/bench-tables.ts b/site/src/lib/bench-tables.ts index 6546fdd01..166e0fb80 100644 --- a/site/src/lib/bench-tables.ts +++ b/site/src/lib/bench-tables.ts @@ -22,6 +22,7 @@ */ import { + archetypeDescription, armLabel, armsOfSection, BACKEND_LABELS, @@ -59,6 +60,7 @@ export interface ComparisonRow { readonly section: string | null; /** The size every column measured this row at, or `null` where they differ. */ readonly count: number | null; + readonly description?: string; readonly entries: readonly ComparisonEntry[]; } @@ -70,8 +72,36 @@ export interface ComparisonTable { readonly unit: string; /** True where the rows were measured at different sizes, so the size belongs in a column of its own. */ readonly countColumn: boolean; + /** Index of the column a narrow reader is shown first; see {@link PREFERRED_COLUMN_KEYS}. */ + readonly defaultColumn: number; } +/** + * Which comparison a stacked view opens on, most preferred first. + * + * An editorial choice about the entry point, fixed here rather than computed + * from the measurements. A rule such as "the arm ExoJS leads on fewest rows" + * reads as fairness but is not one: an arm can lead on few rows because it was + * compared on few, or because most of its runs disagreed - and a later + * correction to how a comparison is judged would then silently move the view a + * reader lands on, without anything about the navigation having changed. + * + * The list names keys, so a profile that never measured the first entry falls + * through to the next and finally to the leftmost column that exists. + */ +const PREFERRED_COLUMN_KEYS: readonly string[] = ['webgl2-pixi', 'webgpu-pixi', 'webgl2-phaser', 'matter-js', 'planck']; + +/** The first preferred column this table actually has, or its leftmost one. */ +const preferredColumn = (columns: readonly ComparisonColumn[]): number => { + for (const key of PREFERRED_COLUMN_KEYS) { + const index = columns.findIndex(column => column.key === key); + + if (index !== -1) return index; + } + + return 0; +}; + const entryOf = (key: string, row: ProfileRow | undefined, arm: string): ComparisonEntry => ({ key, cell: row?.cells.find(cell => cell.competitor === arm) ?? null, @@ -116,10 +146,17 @@ export const renderingComparison = (document: BenchProfileDocument): ComparisonT }); const counts = [...new Set(entries.map(entry => entry.count).filter((count): count is number => count !== null))]; - return { key: archetype, archetype, section, count: counts.length === 1 ? (counts[0] ?? null) : null, entries }; + return { + key: archetype, + archetype, + section, + count: counts.length === 1 ? (counts[0] ?? null) : null, + description: archetypeDescription(archetype), + entries, + }; }); - return { columns, rows, unit: 'nodes', countColumn: false }; + return { columns, rows, unit: 'nodes', countColumn: false, defaultColumn: preferredColumn(columns) }; }; const singleBlockTable = ( @@ -130,11 +167,13 @@ const singleBlockTable = ( countColumn: boolean, ): ComparisonTable => ({ columns, + defaultColumn: preferredColumn(columns), rows: rows.map(row => ({ key: row.archetype, archetype: row.archetype, section: null, count: row.count, + description: archetypeDescription(row.archetype), entries: arms.map(arm => entryOf(arm, row, arm)), })), unit, diff --git a/site/src/pages/de/benchmarks/physics/index.astro b/site/src/pages/de/benchmarks/physics/index.astro new file mode 100644 index 000000000..93486d4ee --- /dev/null +++ b/site/src/pages/de/benchmarks/physics/index.astro @@ -0,0 +1,23 @@ +--- +/** + * Compatibility route for the physics view's old URL. + * + * Rendering and physics share one page now. The URL stays reachable rather than + * 404ing - it has been linked to - and sends the reader to the physics section + * of that page. A redirect rather than a second copy of the page: two pages + * carrying one profile would eventually disagree about it. + */ +const target = `${import.meta.env.BASE_URL}de/benchmarks/#physics`; +--- + + + + + + + ExoJS physics benchmarks + + +

The physics benchmarks are part of the benchmarks page.

+ + diff --git a/site/src/pages/en/benchmarks/physics/index.astro b/site/src/pages/en/benchmarks/physics/index.astro new file mode 100644 index 000000000..90539984c --- /dev/null +++ b/site/src/pages/en/benchmarks/physics/index.astro @@ -0,0 +1,23 @@ +--- +/** + * Compatibility route for the physics view's old URL. + * + * Rendering and physics share one page now. The URL stays reachable rather than + * 404ing - it has been linked to - and sends the reader to the physics section + * of that page. A redirect rather than a second copy of the page: two pages + * carrying one profile would eventually disagree about it. + */ +const target = `${import.meta.env.BASE_URL}en/benchmarks/#physics`; +--- + + + + + + + ExoJS physics benchmarks + + +

The physics benchmarks are part of the benchmarks page.

+ + diff --git a/test/site/bench-timer-check.test.ts b/test/site/bench-timer-check.test.ts new file mode 100644 index 000000000..334133ef9 --- /dev/null +++ b/test/site/bench-timer-check.test.ts @@ -0,0 +1,69 @@ +/** + * The published-comparison guard against a clock that did not separate the two + * arms. + * + * A duration only a step or two above the grid `performance.now()` delivers on + * carries no ratio worth printing: the same scene measured again lands on the + * neighbouring step, and the factor built from it moves by a whole multiple. The + * check therefore refuses the comparison rather than the measurement - both + * times stay readable, and only the figure, the bar and the winner go. + * + * Clearing the check is not a precision claim. It establishes that this one + * guard did not trip; every other check a cell passes still applies. + */ + +import { describe, expect, it } from 'vitest'; + +import { type CellOutcome, outcomeOf, type ProfileCell, SUMMARY_OF, type TimerCheck } from '../../site/src/lib/bench-profiles'; + +/** A stable comparison carrying a timer verdict; every other field is the uninteresting default. */ +const cellOf = (timer: TimerCheck | undefined, stable = true): ProfileCell => ({ + competitor: 'pixi', + referenceMs: 0.5, + referenceP95Ms: 0.6, + referenceOverFrameBudget: false, + competitorMs: 1.5, + competitorP95Ms: 1.7, + competitorOverFrameBudget: false, + ...(timer !== undefined && { timer }), + verdict: { side: 'exojs', ratio: 1 / 3, factor: 3, label: 'ExoJS leads (3.00x)', structural: false }, + mechanism: null, + aggregate: { + runs: 3, + reference: { minMs: 0.5, maxMs: 0.5, ratio: 1 }, + competitor: { minMs: 1.5, maxMs: 1.5, ratio: 1 }, + stable, + rungs: ['exojs-leads', 'exojs-leads', 'exojs-leads'], + }, +}); + +describe('outcomeOf', () => { + it('reports a timer-limited pair as such, ahead of any verdict the ladder reached', () => { + expect(outcomeOf(cellOf('limited'))).toBe('timer-limited'); + }); + + it('reports a timer-limited pair as such even where the runs also disagreed', () => { + expect(outcomeOf(cellOf('limited', false))).toBe('timer-limited'); + }); + + it('withholds the comparison of a profile that recorded no clock, and keeps that state apart', () => { + expect(outcomeOf(cellOf(undefined))).toBe('timer-unknown'); + expect(outcomeOf(cellOf('unknown'))).toBe('timer-unknown'); + }); + + it('leaves a resolved pair on the verdict the ladder reached', () => { + expect(outcomeOf(cellOf('resolved'))).toBe('lead'); + expect(outcomeOf(cellOf('resolved', false))).toBe('unstable'); + }); +}); + +describe('the scoreboard', () => { + it('counts neither timer state on a summary line, so neither is a win, a loss nor a level row', () => { + expect(SUMMARY_OF['timer-limited']).toBeNull(); + expect(SUMMARY_OF['timer-unknown']).toBeNull(); + }); + + it('keeps them apart from the pairs whose runs disagreed, which did produce a comparison', () => { + expect(SUMMARY_OF.unstable).toBe('unclear'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 75e901295..453154236 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ +import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createShaderPlugin } from '@codexo/exojs-build'; import { createJsdomTestProject, srcConditions, workerTransformPlugin, workletTransformPlugin } from '@codexo/exojs-config/vitest'; import { playwright } from '@vitest/browser-playwright'; import { webdriverio } from '@vitest/browser-webdriverio'; -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; import { emitAllocationRecord, startHeapSampling, stopHeapSampling } from './test/perf/webgpu/heapSamplingCommands'; import { resetParityEvidence, writeParityEvidence } from './test/rendering/parity/evidenceSink'; @@ -24,6 +25,10 @@ const aliasConfig = [ // exports a `@codexo/exojs-source` condition, so alias to source for in-repo tests. // @codexo/exojs-physics is aliased too so the example physics↔tilemap bridge // recipe (examples/shared/physics-tilemap.ts) can be unit-tested in-repo. + // @codexo/exojs-particles and @codexo/exojs-tilemap are aliased for the same + // reason: the benchmark's extension arms import them, and this lane runs + // without building the packages, so their `dist` entries resolve to nothing. + { find: '@codexo/exojs-particles', replacement: fileURLToPath(new URL('./packages/exojs-particles/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-tilemap', replacement: fileURLToPath(new URL('./packages/exojs-tilemap/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-tiled', replacement: fileURLToPath(new URL('./packages/exojs-tiled/src/index.ts', import.meta.url)) }, { find: '@codexo/exojs-aseprite', replacement: fileURLToPath(new URL('./packages/exojs-aseprite/src/index.ts', import.meta.url)) }, @@ -38,6 +43,44 @@ const aliasConfig = [ { find: 'create-exo-app', replacement: fileURLToPath(new URL('./packages/create-exo-app/src/scaffold.ts', import.meta.url)) }, ] as const; +/** + * Source roots whose `#*` specifiers address the package they sit in rather than + * the engine. The benchmark package is deliberately absent: its adapters use + * `#*` to reach engine modules. + */ +const EXTENSION_SOURCE_ROOTS = [ + fileURLToPath(new URL('./packages/exojs-particles', import.meta.url)), + fileURLToPath(new URL('./packages/exojs-tilemap', import.meta.url)), +] as const; + +/** + * Resolves the benchmark adapters' `#*` specifiers to the engine source. + * + * A blanket alias cannot do this: each extension package carries its own `#*` + * map pointing at its own `src`, so `#distributions/Curve` imported from inside + * `@codexo/exojs-particles` would be sent into the engine tree, where no such + * module exists. Declining for those importers leaves the specifier to Vite's + * normal `imports` resolution, which `srcConditions` already steers to the + * package's own sources. + */ +const benchEngineHashImports = (): Plugin => { + const engineSrc = fileURLToPath(new URL('./src', import.meta.url)); + + return { + name: 'exojs-bench-engine-hash-imports', + enforce: 'pre', + async resolveId(source: string, importer: string | undefined, options) { + if (!source.startsWith('#')) return null; + if (importer !== undefined && EXTENSION_SOURCE_ROOTS.some(root => resolve(importer).startsWith(root))) return null; + + // Re-enter resolution rather than returning the path: the engine's `#*` + // specifiers carry no extension, and shader imports need `.frag`/`.wgsl` + // rather than the `.ts` a hand-built path would have to assume. + return this.resolve(`${engineSrc}/${source.slice(1)}`, importer, options); + }, + }; +}; + // Loads every shader source (`.vert`/`.frag`/`.wgsl`) as its REAL text, exactly // as the production build does. Tests read what ships: the renderer performance // harness reflects attribute names out of the actual GLSL, the parity specs @@ -97,8 +140,21 @@ const parityCommands = { writeParityEvidence, resetParityEvidence }; // reachable only over CDP, which lives on the node side. See the command module. const allocationCommands = { startHeapSampling, stopHeapSampling, emitAllocationRecord }; +/** + * Worker cap for the jsdom lane. + * + * Vitest otherwise sizes its fork pool at one worker per core, which saturates a + * developer machine for the several minutes the unit lane runs - and the pre-push + * hook runs it on every push. Half the cores keeps the machine usable and costs + * little wall time, since the lane is not purely CPU-bound. CI keeps the default: + * its runners have few cores and nothing else to serve. `EXOJS_TEST_MAX_WORKERS` + * overrides both (a plain count or a `"50%"`-style share). + */ +const maxWorkers = process.env['EXOJS_TEST_MAX_WORKERS'] ?? (process.env['CI'] ? undefined : '50%'); + export default defineConfig({ test: { + ...(maxWorkers === undefined ? {} : { maxWorkers }), coverage: { provider: 'istanbul', reporter: ['lcov', 'clover', 'text-summary'], @@ -281,7 +337,8 @@ export default defineConfig({ // PR - run it on demand via `pnpm --filter @codexo/exojs-bench test`. createJsdomTestProject({ name: 'exojs-bench', - alias: [...aliasConfig, { find: /^#(.*)$/, replacement: `${fileURLToPath(new URL('./src', import.meta.url))}/$1` }], + alias: aliasConfig, + plugins: [benchEngineHashImports()], include: ['packages/exojs-bench/test/**/*.test.ts'], }),