diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md index e49322df1..c5e763e30 100644 --- a/changelog.d/1264-overpass-request-admission.md +++ b/changelog.d/1264-overpass-request-admission.md @@ -4,3 +4,6 @@ - Release unread responses from the optional Google-token probe so keyless browser validation can settle; the Overpass gate recognizes only that probe's expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f59f6c5d9..16817cd44 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5011,7 +5011,7 @@ pins and share links already supply complete boxes. - Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker. - Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere. - Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks. -- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. +- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. ### TR-3B conversion Easter egg (August 2026) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c7a95e6e3..690a33f3e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1071,5 +1071,6 @@ "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", "src/terrainHeightsBounds.test.mjs", - "server/providers/overpass/requestAdmission.test.mjs" + "server/providers/overpass/requestAdmission.test.mjs", + "src/tooling/trackArrivalObservation.test.mjs" ] diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 756daccfc..044fdf74f 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -94,6 +94,7 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; +import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; @@ -2695,16 +2696,17 @@ async function main() { // reappear wearing its stale/creation-north nose for up to 1 s). // Staged deterministically: park the camera nadir over the synthetic // cluster, let rotations settle, TAMPER a billboard's rotation, then - // trigger each path with the camera idle. The idle-drift catch-up - // (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a - // ~300 ms SwiftShader frame window, so a regression shows as an - // intermittent (not guaranteed) failure — never a false failure. + // trigger each path with the camera idle. The fleet runs every 80 ms, + // independently of rendered FPS. Observe the first completed settle and + // reveal frames within 500 ms of forcing moveEnd, before the 1 s idle + // rotation refresh can repair an incorrect first reveal. // ============================================================ console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass'); const arrival = await evalPage(async () => { const v = window.__godsEyeView.viewer; const dm = window.__godsEyeView.dataManager; const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`); + const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`); // 3D models OFF for this phase: a model-handed-off billboard is hidden // and skips rotation updates entirely — the probes need live billboards // (this is also the app's default state the field report came from). @@ -2766,39 +2768,29 @@ async function main() { const bb = findBB(id); if (!bb || !bb.show) return { error: `${id} missing/hidden` }; const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below - // (a) settle pass: tamper, then raise moveEnd with the camera IDLE — - // the pose signature is unchanged, so only the moveEnd hook can fix - // this before the 1 s catch-up. - bb.rotation = r0 + 1.5; - v.camera.moveEnd.raiseEvent(); - await nextFrames(2); - const afterMoveEnd = bb.rotation; - // A real-GPU fleet tick can land near the edge of the frame budget and - // advance the contact before the probe reads it. Compare with the - // production projection at the CURRENT position, not the now-stale r0. - const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null); - // (b) reveal pass: tamper + hide — the next fleet tick must flip it - // visible AND correct the nose in that same tick (camera still idle, - // no moveEnd raised). Diagnostic fields (round 5): record WHICH frame - // the flip landed on (flipFrame; -1 = never within the extended - // window) and whether a 3D model owns this id — distinguishes "tick - // slow under load" from "occluder says hidden" from "model handoff". - bb.rotation = r0 + 1.5; - bb.show = false; - let flipFrame = -1; - for (let f = 1; f <= 6; f++) { - await nextFrames(1); - if (bb.show && flipFrame === -1) flipFrame = f; - if (f >= 2 && flipFrame !== -1) break; - } - const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null); + const observed = await observeArrivalRotation({ + scene: v.scene, + startMoveEnd() { + // Only moveEnd can repair the tampered angle at the idle pose. + bb.rotation = r0 + 1.5; + v.camera.moveEnd.raiseEvent(); + }, + startReveal() { + // Hide immediately after the confirmed settle frame. The next + // eligible fleet tick must reveal AND re-aim in that same tick. + bb.rotation = r0 + 1.5; + bb.show = false; + }, + read() { + return { + shown: bb.show, + rotationError: angDiff(bb.rotation, + screenProjectedRotation(v.scene, bb.position, course, null)), + }; + }, + }); const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id)); - return { - r0, - dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd), - dReveal: angDiff(bb.rotation, expectedReveal), - shown: bb.show, flipFrame, hasModel, - }; + return { r0, ...observed, hasModel }; }; const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0; const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0; @@ -2807,22 +2799,13 @@ async function main() { military: await probe('bbb101', militaryCourse), }; }); - const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error - : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`); + const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms` + : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`); const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off - // Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe - // shows the military flip consistently lands on frame 2 — the exact edge - // of the old budget — so any load hiccup flaked it (flights lands frame - // 1). The regression this protects against is a reveal that waits for - // the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still - // catches that with a wide margin, with zero-headroom flakes gone. - const arrOk = (a) => !!a && !a.error && a.shown - && (a.flipFrame === -1 ? false : a.flipFrame <= 4) - && a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10; - record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.flights), fmtArr(arrival.flights)); - record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.military), fmtArr(arrival.military)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.military), fmtArr(arrival.military)); // ============================================================ // DISPLAY FLOOR (2026-08-19): a grounded contact must never render below diff --git a/src/tooling/trackArrivalObservation.mjs b/src/tooling/trackArrivalObservation.mjs new file mode 100644 index 000000000..370624891 --- /dev/null +++ b/src/tooling/trackArrivalObservation.mjs @@ -0,0 +1,100 @@ +/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */ +export const ARRIVAL_OBSERVATION_MS = 500; + +/** + * Observe the first completed moveEnd frame and the first subsequent reveal. + * The deadline starts when moveEnd is forced, before its rotation baseline, so + * waiting for either frame cannot let the one-second idle refresh mask a bug. + * `read` snapshots visibility and rotation error against the current position. + */ +export function observeArrivalRotation({ + scene, + startMoveEnd, + startReveal, + read, + now = () => performance.now(), + setTimer = setTimeout, + clearTimer = clearTimeout, +}) { + return new Promise((resolve, reject) => { + const startedAt = now(); + let moveEnd = null; + let moveEndMs = null; + let revealFrames = 0; + let done = false; + let remove; + let timer; + const cleanup = () => { + done = true; + remove?.(); + clearTimer(timer); + }; + const finish = (result) => { + if (done) return; + cleanup(); + resolve(result); + }; + const expired = () => ({ + error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`, + elapsedMs: now() - startedAt, + moveEndMs, + flipFrame: -1, + }); + remove = scene.postRender.addEventListener(() => { + if (done) return; + try { + const elapsedMs = now() - startedAt; + if (elapsedMs >= ARRIVAL_OBSERVATION_MS) { + finish(expired()); + return; + } + const sample = read(); + if (moveEnd === null) { + moveEnd = sample; + moveEndMs = elapsedMs; + startReveal(); + } else { + revealFrames++; + if (sample.shown) { + // Keep the FIRST visible result, including a wrong angle. Never + // wait for a subsequent tick to repair a failed reveal. + finish({ + dMoveEnd: moveEnd.rotationError, + dReveal: sample.rotationError, + shown: sample.shown, + flipFrame: revealFrames, + moveEndMs, + revealMs: elapsedMs - moveEndMs, + elapsedMs, + }); + return; + } + } + scene.requestRender(); + } catch (error) { + cleanup(); + reject(error); + } + }); + timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS); + try { + startMoveEnd(); + scene.requestRender(); + } catch (error) { + cleanup(); + reject(error); + } + }); +} + +/** Test the captured frames without retrying a bad first observation. */ +export function arrivalRotationPassed(observation) { + return ( + !!observation && + !observation.error && + observation.shown === true && + observation.elapsedMs < ARRIVAL_OBSERVATION_MS && + (observation.dMoveEnd * 180) / Math.PI < 10 && + (observation.dReveal * 180) / Math.PI < 10 + ); +} diff --git a/src/tooling/trackArrivalObservation.test.mjs b/src/tooling/trackArrivalObservation.test.mjs new file mode 100644 index 000000000..7c140bf37 --- /dev/null +++ b/src/tooling/trackArrivalObservation.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ARRIVAL_OBSERVATION_MS, + arrivalRotationPassed, + observeArrivalRotation, +} from './trackArrivalObservation.mjs'; + +function fixture() { + let time = 0; + let state = { shown: true, rotationError: 0 }; + const listeners = new Set(); + const timers = new Map(); + let renders = 0; + let moveEnds = 0; + const observation = observeArrivalRotation({ + scene: { + postRender: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + requestRender() { + renders++; + }, + }, + startMoveEnd() { + moveEnds++; + state.rotationError = 1.5; + }, + startReveal() { + state = { shown: false, rotationError: 1.5 }; + }, + read: () => ({ ...state }), + now: () => time, + setTimer(callback, delay) { + const handle = {}; + timers.set(handle, { callback, at: time + delay }); + return handle; + }, + clearTimer(handle) { + timers.delete(handle); + }, + }); + return { + observation, + frame(at, nextState) { + time = at; + state = { ...state, ...nextState }; + for (const listener of [...listeners]) listener(); + }, + expire(at) { + time = at; + for (const timer of [...timers.values()]) + if (timer.at <= at) timer.callback(); + }, + get resources() { + return { + listeners: listeners.size, + timers: timers.size, + renders, + moveEnds, + }; + }, + }; +} + +test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + // The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine + // more frames before the next 80 ms fleet interval becomes eligible. + for (let at = 16; at < 88; at += 8) f.frame(at); + f.frame(88, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.flipFrame, 10); + assert.equal(result.moveEndMs, 8); + assert.equal(result.revealMs, 80); + assert.equal(result.elapsedMs, 88); + assert.equal(arrivalRotationPassed(result), true); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); + assert.equal(f.resources.moveEnds, 1); +}); + +test('a wrong first visible angle fails even if the next frame corrects it', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(88, { shown: true, rotationError: 1.5 }); + f.frame(96, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.dReveal, 1.5); + assert.equal(result.flipFrame, 1); + assert.equal(result.elapsedMs, 88); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 1.5 }); + f.frame(88, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.dMoveEnd, 1.5); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('no reveal fails at a deadline before the one-second idle catch-up', async () => { + const f = fixture(); + assert.equal(ARRIVAL_OBSERVATION_MS, 500); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(480); + f.expire(500); + const result = await f.observation; + assert.match(result.error, /reveal.*500 ms/); + assert.equal(arrivalRotationPassed(result), false); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); + f.frame(1000, { shown: true, rotationError: 0 }); + assert.equal(result.flipFrame, -1); +}); + +test('a late visible frame fails even if the timeout callback is delayed', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(501, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.match(result.error, /reveal.*500 ms/); + assert.equal(result.elapsedMs, 501); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('the deadline includes moveEnd setup rather than restarting when hidden', async () => { + const f = fixture(); + f.frame(400, { shown: true, rotationError: 0 }); + f.frame(600, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.moveEndMs, 400); + assert.equal(result.elapsedMs, 600); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('missing moveEnd frames fail without waiting for a render callback', async () => { + const f = fixture(); + f.expire(500); + const result = await f.observation; + assert.match(result.error, /moveEnd.*500 ms/); + assert.equal(arrivalRotationPassed(result), false); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); +});