24 KiB
CesiumJS Globe Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the MapLibre/Mercator renderer with a CesiumJS globe so trajectories above 85.05° latitude render correctly, enabling verification of predictor behaviour at the poles.
Architecture: The app already isolates the renderer behind the IMap interface (src/lib/map/core.ts). After the wind layer is deleted, maplibre-gl is imported in exactly one file (src/lib/map/maplibre.ts) and nothing calls getRawInstance(). So the migration is: add src/lib/map/cesium.ts implementing the same IMap/Scene contract, then change one import in Map.svelte. MapLibre stays on disk until the Cesium path is proven, then is removed.
Tech Stack: SvelteKit 2 + adapter-static, Svelte 5 runes, TypeScript, Vite 6, CesiumJS 1.129.0.
Global Constraints
- Node is 18.19.1 and
.npmrcsetsengine-strict=true. Cesium MUST be pinned to1.129.0— the last release declaringnode >=18.18.0. Cesium 1.130.0 requiresnode >=20.19.0; 1.141.0+ requiresnode >=22.0.0.npm installwill hard-fail (not warn) on a newer version. - Vite stays at 6.x. Vite 7 requires Node 20+.
- No Cesium Ion access token. Do not use
Cesium.Ion,createWorldTerrainAsync, or the default Ion imagery. UseUrlTemplateImageryProviderwith the existing OSM/Esri tile URLs andEllipsoidTerrainProvider. Any Ion code path fails without a token. - Existing basemap URLs must be preserved verbatim:
- osm:
https://a.tile.openstreetmap.org/{z}/{x}/{y}.png - satellite:
https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}
- osm:
- The
IMap,Scene,Marker,MapLayerinterfaces insrc/lib/map/core.tsare the contract. Do not change their signatures — features depend on them. - Coordinate order convention in this codebase:
LngLatTuple = [lng, lat],LatLngTuple = [lat, lng].LineOptions.coordsisLatLngTuple[]. Getting this backwards is the most likely bug in this migration. - Commit after each task.
Task 1: Delete the wind layer and unused dependencies
Removes the only getRawInstance() consumer, leaving a clean seam for the renderer swap. The user has approved dropping wind entirely.
Files:
-
Delete:
src/lib/features/wind/ParticleField.ts,src/lib/features/wind/WindRenderer.svelte,src/lib/features/wind/index.ts,src/lib/features/wind/store.ts -
Delete:
src/lib/domain/wind.ts,src/lib/api/wind.ts -
Modify:
src/routes/predict/+page.svelte(removeWindRendererimport + usage) -
Modify:
src/lib/domain/index.ts,src/lib/api/index.ts(drop wind re-exports) -
Modify:
src/lib/features/settings/schema.ts(remove the wind settings section, ~lines 87-130) -
Modify:
src/lib/features/settings/store.ts(removewind.*defaults if present) -
Modify:
src/lib/i18n/locales/en.json,src/lib/i18n/locales/ru.json(removesettings.wind*keys) -
Modify:
package.json(remove@sakitam-gis/maplibre-wind,svelte5-chartjs) -
Step 1: Confirm the blast radius before deleting
cd /home/anton/stratoflights/leaflet_svelte
grep -rn "features/wind\|\$features/wind\|windCache\|api/wind\|WindRenderer\|WindInterpolator\|windSettings\|domain/wind" src/
Expected: hits only in the files listed above. If anything else appears, add it to the list before proceeding.
- Step 2: Delete the wind files
rm -r src/lib/features/wind src/lib/domain/wind.ts src/lib/api/wind.ts
- Step 3: Remove the references
Edit each Modify-listed file to drop wind imports, <WindRenderer /> usage, wind re-exports, the wind settings section, and the settings.wind* i18n keys. @sakitam-gis/maplibre-wind and svelte5-chartjs are already imported nowhere (verified) — remove them from package.json dependencies.
npm uninstall @sakitam-gis/maplibre-wind svelte5-chartjs
- Step 4: Verify nothing dangles
grep -rn "wind\|Wind" src/ | grep -viE "window|rewind"
npm run check
Expected: no wind references remain; npm run check reports 0 errors.
- Step 5: Confirm the seam is clean
grep -rn "from 'maplibre-gl'" src/
grep -rn "getRawInstance" src/
Expected: maplibre-gl imported ONLY in src/lib/map/maplibre.ts. getRawInstance appears only in core.ts (declaration) and maplibre.ts (implementation) — no callers.
- Step 6: Commit
git add -A && git commit -m "refactor: remove wind particle layer and unused deps"
Task 2: Serve Cesium's static assets
Cesium loads Workers, Assets, Widgets and ThirdParty files at runtime by URL. They must be served from a known base path. static/ is copied verbatim by adapter-static, so copying there works in both dev and build with zero plugins.
Files:
- Create:
scripts/copy-cesium.js - Modify:
package.json(preparescript) - Modify:
.gitignore(ignore the generatedstatic/cesium/) - Modify:
src/app.html(setCESIUM_BASE_URL)
Interfaces:
-
Produces: Cesium assets served at
/cesium/, andwindow.CESIUM_BASE_URL === '/cesium/'set before any Cesium module loads. -
Step 1: Write the copy script
Create scripts/copy-cesium.js:
// Copies Cesium's runtime assets into static/ so they are served at /cesium/.
// Cesium resolves Workers/Assets/Widgets at runtime via CESIUM_BASE_URL; they
// cannot be bundled. Regenerated on `npm install` via the prepare script.
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'node_modules', 'cesium', 'Build', 'Cesium');
const dest = join(root, 'static', 'cesium');
if (!existsSync(src)) {
console.error(`[copy-cesium] missing ${src} — is cesium installed?`);
process.exit(1);
}
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const dir of ['Assets', 'ThirdParty', 'Widgets', 'Workers']) {
cpSync(join(src, dir), join(dest, dir), { recursive: true });
}
console.log('[copy-cesium] assets copied to static/cesium/');
- Step 2: Wire it into
prepareand ignore the output
In package.json, change the prepare script to:
"prepare": "svelte-kit sync || echo '' && node scripts/copy-cesium.js"
Append to .gitignore:
# generated from node_modules/cesium by scripts/copy-cesium.js
static/cesium/
- Step 3: Set the base URL before Cesium loads
In src/app.html, add inside <head> before %sveltekit.head%:
<script>window.CESIUM_BASE_URL = '/cesium/';</script>
- Step 4: Run it and verify
node scripts/copy-cesium.js
ls static/cesium/
du -sh static/cesium/
Expected: Assets ThirdParty Widgets Workers present, ~20 MB total.
- Step 5: Commit
git add -A && git commit -m "build: serve Cesium runtime assets from static/cesium"
Task 3: Implement IMap on Cesium — camera, events, basemaps
Files:
- Create:
src/lib/map/cesium.ts - Test:
tests/e2e/globe.spec.ts(added in Task 5)
Interfaces:
- Consumes:
IMap,MapInit,MapEvent,MapEventPayload,MapClickEvent,Scenefrom./core;LatLngTuple,LngLatTuplefrom$domain. - Produces:
export function createCesiumMap(init: MapInit): IMap— same shape ascreateMapLibreMap, soMap.svelteswaps one import. - Produces:
zoomToHeight(zoom: number): numberandheightToZoom(height: number): number— the camera-height ↔ zoom-level bridge, exported for tests.
Zoom note: Cesium has no discrete zoom levels; it has camera height. getZoom/setZoom have zero consumers outside the map implementation (verified), so a simple monotonic mapping is sufficient. Use the standard Web-Mercator-equivalent relation at the equator: height = 40075017 / 2^zoom, inverted as zoom = log2(40075017 / height).
- Step 1: Write the implementation
import {
Cartesian2,
Cartesian3,
Color,
EllipsoidTerrainProvider,
Math as CesiumMath,
Rectangle,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
UrlTemplateImageryProvider,
Viewer,
} from 'cesium';
import 'cesium/Build/Cesium/Widgets/widgets.css';
import type {
IMap,
MapEvent,
MapEventHandler,
MapInit,
MapClickEvent,
Scene as MapScene,
} from './core';
import type { LatLngTuple, LngLatTuple } from '$domain';
import { CesiumScene } from './cesium-scene';
/** Equatorial circumference in metres — the zoom<->height reference. */
const EQUATOR_M = 40075017;
export function zoomToHeight(zoom: number): number {
return EQUATOR_M / Math.pow(2, zoom);
}
export function heightToZoom(height: number): number {
return Math.log2(EQUATOR_M / Math.max(height, 1));
}
const IMAGERY: Record<NonNullable<MapInit['baseLayer']>, string> = {
osm: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
satellite:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{reverseY}/{x}',
};
class CesiumMap implements IMap {
readonly ready: Promise<void>;
private viewer: Viewer;
private handler: ScreenSpaceEventHandler;
private scenes = new Map<string, CesiumScene>();
constructor(init: MapInit) {
this.viewer = new Viewer(init.container, {
// No Ion: explicit imagery + ellipsoid terrain, no token required.
baseLayer: false,
terrainProvider: new EllipsoidTerrainProvider(),
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
navigationInstructionsInitiallyVisible: false,
});
this.viewer.imageryLayers.addImageryProvider(
new UrlTemplateImageryProvider({ url: IMAGERY[init.baseLayer ?? 'osm'], maximumLevel: 19 }),
);
// Scale/navigation controls: Cesium has no direct equivalents, and the
// existing flags only ever hid MapLibre chrome we no longer render.
this.setCenter(init.center, init.zoom);
this.ready = Promise.resolve();
this.handler = new ScreenSpaceEventHandler(this.viewer.canvas);
}
/** Screen point -> lng/lat on the globe, or null if the click missed the globe. */
private pick(position: Cartesian2): { lat: number; lng: number } | null {
const ray = this.viewer.camera.getPickRay(position);
if (!ray) return null;
const hit = this.viewer.scene.globe.pick(ray, this.viewer.scene);
if (!hit) return null;
const c = this.viewer.scene.globe.ellipsoid.cartesianToCartographic(hit);
return {
lat: CesiumMath.toDegrees(c.latitude),
lng: CesiumMath.toDegrees(c.longitude),
};
}
on<E extends MapEvent>(event: E, handler: MapEventHandler<E>): () => void {
if (event === 'load') {
(handler as MapEventHandler<'load'>)(undefined);
return () => {};
}
if (event === 'click' || event === 'mousemove') {
const type =
event === 'click' ? ScreenSpaceEventType.LEFT_CLICK : ScreenSpaceEventType.MOUSE_MOVE;
const cb = (e: { position?: Cartesian2; endPosition?: Cartesian2 }) => {
const pos = e.position ?? e.endPosition;
if (!pos) return;
const lngLat = this.pick(pos);
if (!lngLat) return; // clicked space, not the globe
(handler as MapEventHandler<'click'>)({
lngLat,
originalEvent: new MouseEvent(event === 'click' ? 'click' : 'mousemove'),
} as MapClickEvent);
};
this.handler.setInputAction(cb, type);
return () => this.handler.removeInputAction(type);
}
// 'move' | 'zoom' — both ride Cesium's camera.changed event.
const listener = () => {
const c = this.viewer.camera.positionCartographic;
const zoom = heightToZoom(c.height);
if (event === 'move') {
(handler as MapEventHandler<'move'>)({
center: [CesiumMath.toDegrees(c.longitude), CesiumMath.toDegrees(c.latitude)],
zoom,
});
} else {
(handler as MapEventHandler<'zoom'>)({ zoom });
}
};
this.viewer.camera.changed.addEventListener(listener);
return () => this.viewer.camera.changed.removeEventListener(listener);
}
setCenter(pos: LngLatTuple, zoom?: number): void {
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
pos[0],
pos[1],
zoomToHeight(zoom ?? this.getZoom()),
),
});
}
panTo(pos: LngLatTuple, durationMs = 500): void {
this.viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(pos[0], pos[1], this.viewer.camera.positionCartographic.height),
duration: durationMs / 1000,
});
}
fitBounds(coords: LatLngTuple[], paddingPx = 50): void {
if (coords.length === 0) return;
// coords are [lat, lng]; Rectangle.fromDegrees takes (west, south, east, north).
const lats = coords.map((c) => c[0]);
const lngs = coords.map((c) => c[1]);
const rect = Rectangle.fromDegrees(
Math.min(...lngs),
Math.min(...lats),
Math.max(...lngs),
Math.max(...lats),
);
// A degenerate rectangle (single point) makes Cesium fly to the centre of
// the earth; pad it so there is always area to frame.
if (rect.width === 0 || rect.height === 0) {
const pad = CesiumMath.toRadians(0.05);
rect.west -= pad;
rect.east += pad;
rect.south -= pad;
rect.north += pad;
}
this.viewer.camera.flyTo({ destination: rect, duration: 0.5 });
void paddingPx; // Cesium frames the rectangle itself; no pixel padding knob.
}
getZoom(): number {
return heightToZoom(this.viewer.camera.positionCartographic.height);
}
setZoom(zoom: number): void {
const c = this.viewer.camera.positionCartographic;
this.viewer.camera.setView({
destination: Cartesian3.fromDegrees(
CesiumMath.toDegrees(c.longitude),
CesiumMath.toDegrees(c.latitude),
zoomToHeight(zoom),
),
});
}
setCursor(cursor: string | null): void {
this.viewer.canvas.style.cursor = cursor ?? '';
}
scene(name: string): MapScene {
let s = this.scenes.get(name);
if (!s) {
s = new CesiumScene(name, this.viewer);
this.scenes.set(name, s);
}
return s;
}
disposeScene(name: string): void {
const s = this.scenes.get(name);
if (!s) return;
s.dispose();
this.scenes.delete(name);
}
getRawInstance(): unknown {
return this.viewer;
}
dispose(): void {
for (const s of this.scenes.values()) s.dispose();
this.scenes.clear();
this.handler.destroy();
this.viewer.destroy();
}
}
export function createCesiumMap(init: MapInit): IMap {
return new CesiumMap(init);
}
Note the Esri URL uses {reverseY} — Cesium's UrlTemplateImageryProvider template for the ArcGIS {z}/{y}/{x} tile order.
- Step 2: Verify it type-checks (Scene not yet written, so expect one error)
npm run check 2>&1 | head -20
Expected: errors only about the missing ./cesium-scene module. Task 4 supplies it.
- Step 3: Commit
git add src/lib/map/cesium.ts && git commit -m "feat: Cesium IMap implementation — camera, events, basemaps"
Task 4: Implement Scene on Cesium — lines, circles, markers
Files:
- Create:
src/lib/map/cesium-scene.ts
Interfaces:
- Consumes:
Scene,MapLayer,Marker,LineOptions,CircleOptions,MarkerOptionsfrom./core. - Produces:
export class CesiumScene implements Scenewith constructor(name: string, viewer: Viewer)— consumed bycesium.tsTask 3.
Why this is the whole point: these entities take Cartesian3.fromDegrees(lng, lat) directly. Nothing routes through a Mercator tiler, so latitude 89.99° and 90° render correctly — which is what MapLibre could not do.
- Step 1: Write the implementation
import {
Cartesian2,
Cartesian3,
Color,
Entity,
HorizontalOrigin,
PolylineDashMaterialProperty,
VerticalOrigin,
type Viewer,
} from 'cesium';
import type {
CircleOptions,
LineOptions,
MapLayer,
Marker,
MarkerOptions,
Scene,
} from './core';
/** '#rrggbb' + opacity -> Cesium Color. Falls back to black on a bad string. */
function toColor(css: string | undefined, opacity = 1): Color {
try {
return Color.fromCssColorString(css ?? '#000').withAlpha(opacity);
} catch {
return Color.BLACK.withAlpha(opacity);
}
}
export class CesiumScene implements Scene {
private entities = new Map<string, Entity>();
constructor(
public readonly name: string,
private viewer: Viewer,
) {}
private scopeId(id: string): string {
return `${this.name}__${id}`;
}
private add(id: string, entity: Entity): Entity {
this.remove(id);
const added = this.viewer.entities.add(entity);
this.entities.set(id, added);
return added;
}
addLine(id: string, options: LineOptions): MapLayer {
// LineOptions.coords is [lat, lng][] — Cesium wants a flat lng,lat list.
const degrees = options.coords.flatMap((c) => [c[1], c[0]]);
const color = toColor(options.color, options.opacity ?? 1);
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
polyline: {
positions: Cartesian3.fromDegreesArray(degrees),
width: options.width ?? 3,
material: options.dashArray
? new PolylineDashMaterialProperty({ color })
: color,
// Draw on the globe surface without clamping to terrain, and
// keep the line visible when it passes behind the horizon.
clampToGround: false,
},
}),
);
void entity;
return { id, remove: () => this.remove(id) };
}
addCircle(id: string, options: CircleOptions): MapLayer {
// radiusPx is screen-space, matching the MapLibre circle-layer semantics;
// Cesium's PointGraphics.pixelSize is the direct equivalent.
this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.center[0], options.center[1]),
point: {
pixelSize: (options.radiusPx ?? 5) * 2,
color: toColor(options.color, options.opacity ?? 1),
outlineColor: toColor(options.strokeColor, 1),
outlineWidth: options.strokeWidth ?? 0,
},
}),
);
return { id, remove: () => this.remove(id) };
}
addMarker(id: string, options: MarkerOptions): Marker {
const entity = this.add(
id,
new Entity({
id: this.scopeId(id),
position: Cartesian3.fromDegrees(options.lngLat[0], options.lngLat[1]),
...(options.iconUrl
? {
billboard: {
image: options.iconUrl,
width: options.iconSize?.[0],
height: options.iconSize?.[1],
horizontalOrigin: HorizontalOrigin.CENTER,
verticalOrigin: VerticalOrigin.BOTTOM,
},
}
: { point: { pixelSize: 10, color: Color.CRIMSON } }),
...(options.popupHtml ? { description: options.popupHtml } : {}),
}),
);
return {
setLngLat: (pos) => {
entity.position = Cartesian3.fromDegrees(pos[0], pos[1]) as never;
},
remove: () => this.remove(id),
};
}
remove(id: string): void {
const e = this.entities.get(id);
if (!e) return;
this.viewer.entities.remove(e);
this.entities.delete(id);
}
clear(): void {
for (const e of this.entities.values()) this.viewer.entities.remove(e);
this.entities.clear();
}
dispose(): void {
this.clear();
}
}
- Step 2: Type-check
npm run check 2>&1 | head -20
Expected: 0 errors.
- Step 3: Commit
git add src/lib/map/cesium-scene.ts && git commit -m "feat: Cesium Scene — geodesic lines, points, billboards"
Task 5: Swap the renderer and get the app green
Files:
-
Modify:
src/lib/map/Map.svelte:5,44(import + factory call) -
Modify:
src/lib/map/index.ts(export the Cesium factory) -
Create:
tests/e2e/globe.spec.ts -
Step 1: Swap the factory
In src/lib/map/Map.svelte, change line 5 from import { createMapLibreMap } from './maplibre'; to import { createCesiumMap } from './cesium';, and line 44 from map = createMapLibreMap({ to map = createCesiumMap({.
- Step 2: Write the pole e2e test
Create tests/e2e/globe.spec.ts:
import { expect, test } from '@playwright/test';
test('globe renders a polar trajectory above the Mercator limit', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/predict');
// The dev build exposes the raw Cesium Viewer on window._lsvMap.
await page.waitForFunction(() => (window as any)._lsvMap?.scene !== undefined, { timeout: 30_000 });
// Place a polyline crossing 89.99N and read back what Cesium stored.
const readback = await page.evaluate(() => {
const viewer = (window as any)._lsvMap;
const Cesium = (window as any).Cesium;
const e = viewer.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray([0, 89.0, 30, 89.99, 60, 89.5]),
width: 3,
},
});
const carto = Cesium.Cartographic.fromCartesian(e.polyline.positions.getValue()[1]);
return Cesium.Math.toDegrees(carto.latitude);
});
// MapLibre's tiler clamped anything above 85.051129; Cesium must not.
expect(readback).toBeGreaterThan(85.051129);
expect(readback).toBeCloseTo(89.99, 2);
expect(errors).toEqual([]);
});
- Step 3: Run it
npm run check
npx playwright test tests/e2e/globe.spec.ts
Expected: npm run check clean; the globe test passes, proving latitude survives past 85.051129°.
- Step 4: Run the existing suite and fix fallout
npx playwright test
Expected: auth, settings, saved-points pass unchanged. smoke, track, workspaces may assert on MapLibre-specific DOM (.maplibregl-* classes) — update those selectors to the Cesium canvas. Fix each failure; do not delete assertions.
- Step 5: Commit
git add -A && git commit -m "feat: switch map renderer to CesiumJS globe"
Task 6: Verify predictor behaviour at the poles
The actual objective. The globe is the instrument; this task is the measurement.
Files:
-
Create:
docs/POLAR_FINDINGS.md -
Step 1: Bring up the full stack
cd /home/anton/stratoflights
# predictor already holds a complete 2026-08-03T00:00:00Z dataset in .stack-data/gfs
./run-stack.py
Expected: stack up — predictor :8080 django :8000 svelte http://127.0.0.1:5173 …. If port 8080 is already held by a standalone predictor, stop that first.
- Step 2: Run polar predictions and record the output
for lat in 85 89.5 89.99; do
curl -s "http://127.0.0.1:8080/api/v1/prediction?launch_latitude=$lat&launch_longitude=0.1&launch_datetime=2026-08-03T12:00:00Z&ascent_rate=5&burst_altitude=30000&descent_rate=5&launch_altitude=0"
done
- Step 3: View each on the globe
Open http://127.0.0.1:5173/predict, run the same three launches through the UI, and confirm the trajectory renders as a track over the polar region rather than a line pinned along the 85.05° parallel.
- Step 4: Write up the findings
Create docs/POLAR_FINDINGS.md recording, for each latitude: whether the track rendered, whether longitude behaved plausibly, and the three predictor defects listed under "Known predictor defects" below with their file:line references.
- Step 5: Commit
git add docs/POLAR_FINDINGS.md && git commit -m "docs: polar behaviour findings"
Known predictor defects (out of scope — for a follow-up decision)
Verified by source reading plus a unit-level probe against the real Axis geometry. These live in the predictor repo, not this one, and are not fixed by this plan:
internal/engine/models.go:85-88—WindTransportswallows the wind-field error and returnsState{}, a zero derivative. At latitude ≥ 90° the balloon silently freezes horizontally with no error and no event emitted. This is the most dangerous defect: a wrong answer that looks valid.internal/numerics/vec.go:71-73—GeoAddwraps longitude viaPyModbut never bounds latitude. Latitude walks past 90° (90.05, 90.10, …) off the sphere. Correct pole-crossing behaviour islat → 180 − lat,lng → lng + 180.internal/numerics/vec.go:87—dLng = degPerRad * u / (r * cos(lat))diverges ascos(lat) → 0(0.00089 °/s at the equator → 0.513 °/s at 89.99° → 1.46e12 °/s at exactly 90°; finite, not NaN, becausemath.Cos(π/2)is 6.12e-17). Combined with the fixed-step RK4 ininternal/numerics/ode.go:15(no adaptive step, no error control), truncation error grows without bound near the pole. A step size scaled bycos(lat)would bound it.
internal/numerics/grid.go:40 (Axis.Locate) is correct — it returns an explicit lat=90 out of range error rather than reading out of bounds. The bug is purely that the caller discards it.