Skip to content

Repository files navigation

ESTIMER: Microsecond Timing for Adobe ExtendScript (ES3)

ExtendScript TIMER = E.S.TIMER

The quirk-solving microsecond timing facade — ESTIMER.prime() / now() / measureUs() / median() / stopwatch() / calibrate() / sleep() / stats() — a monotonic, wrap-corrected delta clock over $.hiresTimer + Date for Adobe Illustrator, InDesign, Photoshop & any ExtendScript host

Clock: monotonic wrap-corrected Differential: reference model Engine parity: live Adobe: Creative Suite Engine Size License: GPL-3.0-or-later


Part Of The Same Toolkit

Production-grade ExtendScript infrastructure for Illustrator-era JavaScript engines.

Runtime Primitives

ESON
Strict RFC 8259 JSON for ExtendScript.

ESB64
Base64 and UTF-8 utilities.

ESARR
ES5+ Array compatibility methods.

ESSTR
String whitespace and trim methods.

ESCHARS
Native bulk byte operations.

ESHTTP
HTTP transport for ExtendScript automation.

ESTIMER
Microsecond timing for ExtendScript automation.

Build & Integration Tools

ESPACK
Self-extracting ExternalObject bundles.

ESMIN
Minification for shipped JSX bundles.

ESOBF coming soon
Obfuscation for hardened JSX distribution.

Also from the same team: ArcFit.dev, deterministic arc warp for Illustrator.


Table of Contents


Why ESTIMER?

ExtendScript ships no usable high-resolution timer. There is no performance.now(), no Date.now(), and Date().getTime() only reaches millisecond resolution. The one µs source the engine does ship — $.hiresTimer — is a delta clock, not a wall clock: each read returns µs since the property was last accessed, so consecutive values must never be subtracted like timestamps. Probed live on Adobe Illustrator 30.6.0 (build 109R) / ExtendScript 4.5.6 (docs/research-probes.md, gaps G1-G8), the engine's timer has four documented traps and one missing feature:

  • The first read is engine/thread startup µs, not a sample. The first ever read in the main engine measured 675,881,881 µs (≈ 11.26 min — the engine's own age, not the process's). A naive harness records that huge value as a measurement; it must be discarded.
  • The counter is signed 32-bit and wraps. It turns negative at 2^31 µs (35.79 min of counter uptime) and fully aliases every 2^32 µs (71.58 min). The correction is exact for single-wrap intervals and unrecoverable past that — the honest long-span lane is the Date wall clock. (Measured + RE-confirmed on 30.6.0 and PS 2026: the timer is a 64-bit QPC-based counter, so reads spanning the documented wrap point came back positive and accurate — the +2^32 correction is the deterministic safety net, and the multi-wrap limit still stands.)
  • Nested reads corrupt naive outer timings. A stopwatch read inside a stopwatch steals the parent's elapsed time — the profiler bug that once reported a parent phase at ~0 ms while its children summed to ~20 s (ArcFit history, HANDOFF_ARCFIT_RUNTIME_OPTIMIZATION).
  • $.sleep polls coarsely (~100 ms granularity; can overrun or wake early) — fine for pauses, useless for timing.
  • No µs wall clock at all: Date is ms-only, and the read itself costs 1 µs median (p99 2 µs) on 30.6.0 — a correction floor for sub-10 µs measurements.

ESTIMER solves all of it with one accumulator: every raw read advances a monotonic, wrap-corrected absolute µs clock (abs += wrapCorrect(delta)), so now() is safe anywhere — including inside measured functions and nested stopwatches. The first read is discarded by prime(), samples are validated per the house rejection protocol (> 0, <= maxValidUs), and the raw source is injected so the identical bundle runs in the engine ($.hiresTimer), in Node (performance.now()), and on the degraded Date lane.


Features

  • Monotonic, wrap-corrected absolute clock — 0 negative deltas in 10,000 consecutive live reads (G5, probe_hirestimer.jsx); the accumulator floors uncorrectable reads at 0 (never backwards) and single-wrap-corrects negative deltas by +2^32 (exact for true intervals in [2^31, 2^32) µs ≈ 35.8-71.6 min).
  • First-read discardprime() consumes and discards the startup read (measured 675,881,881 µs) idempotently; live-verify confirms now() starts near 0 after prime() even in a ~40 min-old engine.
  • Nesting-safe stopwatchelapsed() is cumulative; an inner stopwatch or now() between start() and stop() cannot corrupt the outer run (live-verify: "stopwatch outer >= inner", "elapsed >= outer").
  • House rejection protocol — samples validated > 0 && <= 1e8 µs (100 s), warmup runs (default 2) discarded, rejected samples reduce the set with {index, value, reason} carried via collectRejected; measureUs(fn, 1) returns null live-verified.
  • Calibrated sleep — busy-wait below 25 ms lands within +0.2 % of the request; at/above 25 ms in the engine, $.sleep(ms - 1) + a final calibrated busy-wait never wakes early (measured overshoot 10-38 %, returned as the actual µs slept).
  • Stats, edge-exactstats() returns {count, min, max, mean, median, p95, p99, rejected} with house upper-median indexing; n=0 → zeros, n=1 → the single value; 3,000 differential stats checks + 2,000 medianOf checks against the Node reference model.
  • Differential-validated core — 5,165 unit checks (npm test) + 2,000,028 seeded fuzz iterations vs the hand-rolled reference accumulator (npm run fuzz, seed 1337, 0 divergences), including scripted wrap-boundary, negative-delta, and startup-value sequences.
  • Live-verified in the real engine — 56/56 engine checks pass on Illustrator 30.6.0 / ExtendScript 4.5.6 (npm run live-verify): prime/now/epoch, 100-read monotonicity, measureUs rejection, samples/median/best, stopwatch nesting, wrap-policy round-trip, sleep lanes, calibrate.
  • Three lanes, one code pathengine ($.hiresTimer, delta, wraps), node (performance.now(), absolute), date (ms wall clock, degraded) auto-detected at load; setSource() injects a fake source so wrap/negative-delta behavior is tested deterministically without 35-minute waits.
  • No runtime dependencies, ES3-clean — one 13.7 KB runtime file (vendor-estimer.js); no let/const/arrows/Promise/Map, no "use strict", functions-only exports (esbuild IIFE getter quirk — see Engine quirks).

Get the Release

All production bundles ship as GitHub release assets — this repo holds sources. Grab the runnable builds from the Releases page.

Latest stable Release date Downloads

How it works, in three steps:

  1. Open the Releases page.
  2. Pick the latest stable tag.
  3. Download the asset that matches your use case:
You are... Take this release And this asset
A script that wants the drop-in facade on $.global Latest stable vendor-estimer.js — assigns $.global.ESTIMER
A bannerless IIFE for $.evalFile / COM eval Latest stable ESTIMER.jsx — defines var ESTIMER
Node.js testing / tooling Latest stable estimer-core.esm.mjs — ESM core
A fix that isn't released yet Pre-release / main Build from source: npm run build

Installation

// @includepath "path/to/estimer/dist"
#include "vendor-estimer.js"

// ESTIMER is now on $.global — drop-in, no install call needed.

Or load explicitly in any order / from COM:

$.evalFile(File("C:/path/to/estimer/dist/vendor-estimer.js"));
// or, for a bannerless IIFE that defines var ESTIMER without touching $.global:
$.evalFile(File("C:/path/to/estimer/dist/ESTIMER.jsx"));

Prime once per engine/thread: the accumulator's base is set from the first read of whichever engine the library is loaded into (see Engine quirks).


Quick Start

ESTIMER.prime();                          // discard the startup read; anchor the epoch
var us = ESTIMER.now();                   // µs since the first raw read (monotonic, wrap-corrected)

var t = ESTIMER.measureUs(function () {   // one validated sample; null when rejected
  for (var i = 0; i < 1000; i++) { work(i); }
});
var med = ESTIMER.median(9, function () { // median-of-9, warmup 2, rejection protocol
  work();
});

var sw = ESTIMER.stopwatch();             // nesting-safe
sw.start();
work();
var runUs = sw.stop();                    // this run's µs

ESTIMER.calibrate();                      // median read overhead µs — subtract for sub-10 µs ops
ESTIMER.sleep(100);                       // calibrated: $.sleep(99) + final busy-wait to land at 100 ms
var s = ESTIMER.stats(ESTIMER.samples(9, work));  // { count, min, max, mean, median, p95, p99, rejected }
var wall = ESTIMER.wallNow();             // Date ms — the honest lane for long spans

API

The facade is functions only — no exported var bindings (the ES3 engine evaluates esbuild's IIFE var-export getters at define time; constants ride on ESTIMER.constants()).

  • prime() — idempotent; consumes and discards one read so the first (startup/thread-init) value is never a sample; records the epoch base.
  • now() — µs, monotonic, wrap-corrected; absolute accumulated µs since the first raw read (safe anywhere, including nested contexts).
  • nowMs()now() / 1000 (ms convenience, µs precision kept).
  • wallNow()Date().getTime() ms; the honest lane for spans past ~35.8 min between reads.
  • epoch() — µs since prime(); auto-primes on first call.
  • measureUs(fn, maxValidUs?)number | null — one validated sample (> 0, <= maxValidUs, default 1e8 µs); null when rejected.
  • measureMs(fn, maxValidMs?)number | null — ms version, default cap 1e5 ms.
  • samples(n, fn, opts?)SampleSet — n measured runs with {warmup=2, maxValidUs=1e8, minValidUs=0, collectRejected}; rejected samples reduce the set; array.rejected = [{index, value, reason}] when collectRejected.
  • median(n, fn, opts?)number | null — median-of-n primed samples (the house benchmark protocol).
  • best(n, fn, opts?)number | null — min-of-n primed samples.
  • stopwatch(){ start, stop, elapsed, reset, running } — nesting-safe; elapsed() cumulative; stop() returns the just-completed run's µs; double-start / stop-without-start are no-ops.
  • calibrate(n?) → number — median read-overhead µs (default n=100, samples validated <= 10 ms); result recorded in describe().calibratedUs.
  • sleep(ms) → number — actual µs slept; busy-wait below 25 ms; engine lane at/above: $.sleep(ms - 1) + calibrated busy-wait (never early; overshoot returned).
  • stats(samples){count, min, max, mean, median, p95, p99, rejected} — house upper-median indexing; n=0 → zeros, n=1 → single value.
  • medianOf(values) / minOf(values) — pure array lanes (no timer dependency).
  • setSource(source?, {wrapPolicy}?) — inject a TimerSource { readUs(), delta?, wraps? } (test hook; undefined re-detects the lane) and re-create the accumulator.
  • setWrapPolicy('correct'|'reject') / wrapPolicy()'correct' (default): negative delta += 2^32 (single-wrap); 'reject': negatives never advance.
  • lane()'engine' | 'node' | 'date' | 'custom'.
  • describe(){lane, engine, wrapPolicy, primed, reads, calibratedUs} — the evidence snapshot (host/version recorded with every measurement).
  • constants(){MAX_VALID_US: 1e8, MAX_VALID_MS: 1e5, MIN_VALID_US: 0, WRAP_PERIOD_US: 4294967296, WRAP_POINT_US: 2147483648, SLEEP_COARSE_THRESHOLD_MS: 25}.

Validation

Check Command Result
TypeScript strict npx tsc --noEmit -p . clean (exit 0)
Node harness (clock vectors + stats/median differential) npm test 5,165 checks, 0 failures
Seeded differential fuzz vs reference model npm run fuzz 2,000,028 iterations, seed 1337, 0 divergences
Live engine parity npm run live-verify 56/56 checks, Illustrator 30.6.0 / ExtendScript 4.5.6
Node reference benchmark npm run benchmark exit 0 (tables in Performance)

The differential oracle is a hand-rolled reference model of the delta-clock accumulator (tests/vectors.ts — the same model npm test validates bit-for-bit in Node); engine parity is verified by running the identical bundled code in the real engine via the COM tool.


Performance

Measured live in Adobe Illustrator 30.6.0 (build 109R) / ExtendScript 4.5.6 (engine build 80.1), Windows 10/64, x86-64. Protocol: prime before each lane, 5 warmups discarded, 9 measured runs per lane, samples validated in (0, 1e8] µs, one lane per eval, 3 rounds per lane, median of the round medians. Full tables + raw JSON: docs/benchmark-rounds-1.md, bench/raw/.

Engine lanes (ESTIMER facade, real $.hiresTimer)

lane medianUs minUs p95Us n rej
measureUs-noop 10 9 10 9 0
now-read 5 4 5 9 0
epoch-read 5 5 5 9 0
stopwatch-cycle 11 10 13 9 0
median9-noop 98 95 101 9 0
medianOf-31 86 85 87 9 0
empty-loop-1e6 56,474 54,320 61,609 9 0

Read overhead decomposition (engine): raw $.hiresTimer read 1 µs median (p99 2 µs, G6); calibrate() through the facade 2 µs; now() 5 µs (accumulator + wrap-correction per read); measureUs(noop) 9-10 µs; the full median-of-9 protocol 98 µs.

Sleep accuracy (sleep() actual µs vs requested ms, wallNow() span)

reqMs sleptUs (median) wallMs (median) error % vs req
2 2,004 2 +0.2 %
25 34,463 35 +37.9 %
50 60,810 61 +21.6 %
250 275,888 276 +10.4 %

The busy-wait lane (< 25 ms) lands within ~0.2 %. The coarse lane (>= 25 ms) never sleeps early but overshoots 10-38 % — $.sleep polls at ~100 ms granularity, and the landing policy is deliberately "never early"; the actual µs slept is the return value (the contract).

Node reference lane (parity)

lane medianUs (node) medianUs (engine) ratio engine/node
measureUs-noop 0.40 10 25x
now-read 0.70 5 7.1x
epoch-read 0.70 5 7.1x
medianOf-31 4.10 86 21x
empty-loop-1e6 462.6 56,474 122x

Node host: node v22.23.2 (lane=node, performance.now() x1000, calibrated read overhead 0.30 µs). The engine is 7-122x slower depending on the lane — raw read overhead ~1 µs engine vs ~0.3 µs Node; interpreter throughput 122x slower on the empty loop. The Node lane exists for harness parity, not production timing.


Security Model

ESTIMER is a pure data-transform library. It executes no eval, loads no native code, writes nothing to disk, and makes no network access; the shipped bundle is plain ES3 function definitions plus one closure state machine. The only host interactions are reads of $.hiresTimer and Date for time values, and a call to $.sleep only inside ESTIMER.sleep() (engine lane, at/above the 25 ms coarse threshold) — both read-only or deliberately invoked by the caller. The one injection surface is setSource(), an explicit test/embedding hook that replaces the timer source with a caller-provided function; it is never called by the library itself.


Compatibility

Target Status
ExtendScript ES3 (no let/const/arrows/Promise/Map in the bundle; "use strict" stripped; functions-only exports) Bundled
Adobe Illustrator 30.6.0 / ExtendScript 4.5.6 / Windows x86-64 Verified live (56/56 engine checks + G1-G8 probes)
Any other ExtendScript host (InDesign, Photoshop, After Effects, InCopy, Bridge) ES3-safe by construction; $.hiresTimer getter chain RE-confirmed 64-bit QPC on Illustrator 30.6.0 and Photoshop 2026 (evidence/re-ai-sccore.md, evidence/re-ps-extendscript.md, evidence/re-ps-sccore.md); re-probe other hosts before relying on timing values
Node.js v18+ (v22.23.2 used) Build and test harnesses

Engine quirks that shaped the design

All measured live on Illustrator 30.6.0 (build 109R) / ExtendScript 4.5.6 (engine build 80.1); re-probe other hosts.

  • The first read is engine/thread startup µs, not ~0. Main-engine first read: 675,881,881 µs (the engine's age). A naive harness records it as a sample; ESTIMER discards it via prime().
  • The counter base is engine creation, not process start. The main engine was created ~9.2 h after process start; its first read was 11.26 min of engine age. Each engine has its own clock (transient engine first read = its own 13.15 s age).
  • $.hiresTimer is a delta clock. Every read is µs since the property was last accessed — never a timestamp. abs += wrapCorrect(delta) turns it into an absolute clock.
  • Signed 32-bit wrap. 2^31 µs = 35.79 min = the wrap point (value turns negative); 2^32 µs = 71.58 min = the full alias period. 'correct' adds +2^32 to a single-wrap negative (exact for [2^31, 2^32) µs intervals); multi-wrap gaps are unrecoverable — wallNow() is the honest long-span lane. Measured on 30.6.0, the engine's own delta math is wrap-safe in practice — reads spanning the wrap point came back positive and accurate (0 negatives across every probe + 10k consecutive reads), so 'correct' is the deterministic safety net for fake/adversarial sources, not a live-path correction on this engine; the multi-wrap long-span limit still holds.
  • Nested reads corrupt naive outer timings. The ArcFit profiler once reported a parent phase at ~0 ms while children summed to ~20 s. The single accumulator makes the outer interval exactly the sum of the inner intervals (live-verified: stopwatch outer >= inner).
  • $.sleep polls coarsely (~100 ms granularity; can overrun or wake early). ESTIMER sleeps busy-wait-exact below 25 ms and lands a coarse sleep with a calibrated spin above it.
  • Zero-delta reads are legitimate. 27.6% of 10,000 tight reads measured 0 µs — sub-µs intervals round down (read overhead is ~1 µs median). ESTIMER's samples() accepts minValidUs = 0; measureUs rejects <= 0 because a 0 µs operation is more likely a protocol error.
  • Read overhead is ~1 µs raw, 2 µs through calibrate(), ~5 µs through now(), 9-10 µs through measureUs (G6). ESTIMER never auto-subtracts; the caller decides.
  • ScriptUI callbacks share the main engine's clock. A button onClick first read (15,586 µs) matched the wall gap since the main-thread prime (15 ms) — same thread, same delta clock, no fresh-thread startup value.
  • Delta spans stay accurate over minute-scale gaps. A 60 s $.sleep span measured 64,330,333 µs vs Date's 64,331 ms (ratio 0.99999); no drift.
  • #targetengine is not honored via $.evalFile() / COM DoJavaScriptFile in AI 30.6.0 — named engines were not created; evals landed in main. Only the ESD transport's transient engine exposes a second clock.
  • esbuild var-export getters evaluate at define time. The engine lacks __defineGetter__, so defineProperty-getter exports of var bindings evaluate immediately while hoisting still leaves them undefined — the export is permanently undefined. ESTIMER ships no exported var bindings: functions only, constants via ESTIMER.constants() (the esarr lesson).
  • Timer is a closure factory, not a class — esbuild cannot downlevel a class to ES5 in this configuration (verified error); the factory pattern is the ES3-safe shape.

Development

npm install            # esbuild + typescript
npm run build          # dist/ESTIMER.jsx, vendor-estimer.js, estimer-core.esm.mjs
npm test               # Node harness: 5,165 checks (clock vectors + stats/median differential)
npm run fuzz           # 2,000,028 seeded differential iterations (seed 1337)
npm run typecheck      # npx tsc --noEmit -p .
npm run benchmark      # Node reference lane (canonical protocol)
npm run live-verify    # 56/56 engine checks via the COM tool (Illustrator 30.6.0)

Repository layout

estimer/
  src/            TypeScript core (index.ts facade, timer-core.ts accumulator, sleep.ts, stats.ts, types.ts)
  tests/          Node harnesses (custom, no framework): unit + vectors + fuzz + benchmark + live-verify
  scripts/        live-engine probe .jsx files (G1-G8 evidence)
  bench/          engine-lane benchmark (bench-live.jsx, run-bench-live.mjs) + raw JSON
  docs/           design.md + research-probes.md + benchmark-rounds-1.md
  evidence/raw/   raw probe JSON outputs
  dist/           generated bundles (gitignored; produced by npm run build)

Research corrections

  • Counter base = ExtendScript engine creation, not process start. The repo previously phrased the wrap as "~35.8 min since the epoch/init" without defining the base. Probed: the main engine was created ~9.2 h after process start, yet its first read was +675.9e6 µs (11.26 min of engine age, positive, unwrapped). Wrap math and first-read interpretation must use engine-creation time, and per-engine clocks mean a fresh engine gets a fresh base (docs/research-probes.md G1/G2).
  • #targetengine is not honored via $.evalFile() / COM DoJavaScriptFile in AI 30.6.0. Named engines were not created (ESD engine list stayed ["main", "transient"]); "fresh and reused engines" probes must use the ESD transport's transient engine (G2).
  • Read overhead on 30.6.0 is 1 µs median (p99 2 µs) — the earlier round-2 figure of ~2 µs (30.5.1) remains compatible; 1 µs is now the measured median (G6).
  • Zero-delta reads are not errors. 27.6% of 10,000 tight reads measured 0 µs (sub-µs intervals round down); validation must not treat them as failures (G5).
  • G4 wrap sign: DISPROVED the "wrapped reads return negative" assumption. Live probes — main engine in wrapped territory at 52.7 min engine age (60 s delta 64,301,512 µs vs Date 64,301 ms, ratio 0.99999, evidence/raw/probe_g4_longdelta_2026-08-11T120629.json), the 11:51:13 wrap-straddle read (+170.8e6 µs, positive), and the transient engine wrap-span read (12:12:33, +213,446,667 µs, positive, evidence/raw/transient_wrap_result.json) — all returned positive, sane deltas; 0 negatives across every probe + 10k consecutive reads (G5). Engine deltas are wrap-safe in practice on 30.6.0; the 'correct' +2^32 policy remains the deterministic safety net for fake/adversarial sources, not a live-path correction on this engine (MEASURED-FACTS.md correction 5; docs/research-probes.md G4). Root cause (static RE of ExtendScript.dll + host ScCore.dll, Illustrator 30.6.0 and Photoshop 2026): the $ getter dispatches to the host import ScCore::Thread::getHiResTimer() — a full 64-bit QPC-based delta (now - lastRead, per-thread TLS) converted to µs and stored as a double; no wrap arithmetic exists at either layer, so a negative read is structurally impossible on these builds (evidence/re-ai-sccore.md, evidence/re-ps-extendscript.md, evidence/re-ps-sccore.md; see docs/design.md §3).

Credits

  • docsforadobe and the docsforadobe.dev community — maintainers of the de-facto reference documentation for the ExtendScript runtime; their reverse-engineering of $.hiresTimer's delta-clock and thread-local semantics made the measured findings in this README possible to write down at all.
  • The ESON/ESB64 family — the ES3 engineering patterns carry over directly: $.hiresTimer discipline, the esbuild var-export quirk, and the (0, 1e8] µs rejection ceiling.
  • The es-family — ESARR's differential-validation harness shape and its benchmark-rounds-1.md protocol, and ESSTR's release pipeline, are the templates this repository follows.

License

GPL-3.0-or-later. See LICENSE.


ESTIMER: ExtendScript TIMER. Measured on the engine, safe across the wrap.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages