Find the seam before you add parameters
Splitting at a seam and fitting one new offset cut a map's worst landmark error from 189 px to 61 px and left the good half untouched. A second slope would have bought 0.6 px more.
When a fit is good on one part of your data and bad on another, look for a seam in the data before you add parameters. On a map I built, splitting at the seam and fitting one new offset cut the worst error on the bad side from 189 px to 61 px and left the good side at 29 px, where it already was. The extra slope I didn't add would have bought 0.6 px.
What the map is for #
LeaguesMap pins the tasks in an Old School RuneScape league to the places you do them, on the wiki's world map, at leagues-map.karpinsky.io. Of the league's 1,592 tasks, 1,177 have a place, and 304 of those are on the western continent. Each pin goes from a game tile coordinate to a pixel on the wiki's full-size map image, 9,216 px wide, at about 3 px per tile. Every pixel number below is on that image.
The residuals had a sign #
The first version mapped game x to pixel x with one line: one slope, one origin. I checked it against 19 landmarks with known positions on the image, 11 in the west and 8 in the east.
| Side | Landmarks | Single-fit x error (px) |
|---|---|---|
| East | 8 | -16 to +29 |
| West | 11 | +69 to +189 |
The east was fine. The west was off by up to 64 tiles, and every western landmark was off the same way: each one landed east of where it belongs. Noise scatters in both directions. Error with one sign means the data has a shape the model is missing.
My reading of that shape: the world map image is two pieces placed side by side, with more ocean between them than a single scale predicts. I didn't measure the gap directly. The residuals suggest it, and the next section tests it. If it holds, no single line can be right on both sides. A higher-order fit across the whole map would have to bend the east, which was already right, to chase an error that behaves like a shift.
One constant on the far side of the seam #
I put the seam at game x 2000, in the gap between the continents. The nearest pin west of it is at x 1847 and the nearest east of it at 2117, so the 130 px jump the split creates lands where no pin can be.
Both sides keep the same slope. The west gets its own x origin: 963 instead of 919, which moves every western pin 44 tiles west on the image. Apart from the seam's position, it's the only constant the x fix adds.
export function gameToImagePixel(x: number, y: number): [number, number] {
const isWest = x < STITCH_X;
const xBase = isWest ? GAME_X_BASE_WEST : GAME_X_BASE_EAST;
const yBase = isWest ? GAME_Y_BASE_WEST : GAME_Y_BASE_EAST;
const ySpan = isWest ? GAME_Y_SPAN_WEST : GAME_Y_SPAN_EAST;
const px = ((x - xBase) / GAME_X_SPAN) * MAP_IMAGE.widthPx;
const py = (1 - (y - yBase) / ySpan) * MAP_IMAGE.heightPx;
return [px, py];
}
The shared slope is a choice, so I tested it. Fit the west on its own, with its own slope and its own origin, and compare:
| West fit | Slope (px/tile) | Worst error (px) |
|---|---|---|
| Free: own slope, own origin | 2.975 | 60.0 |
| Shared slope, own origin (shipped) | 2.952 | 60.6 |
The slopes agree to 0.8%, and a second slope would cut the worst error by 0.6 px out of 60. You'd expect that agreement if both pieces were drawn at one scale and placed too far apart. I can't observe how the image was made, so that part is an inference, but it's the one the numbers support. Given it, one offset is the right model, and a second slope would mostly be fitting the error that remains.
I fit the western origin against landmarks measured at the centers of their map labels, the same way for all 11.
Y had a seam too, in the reference points #
Y uses the same split: each side gets its own base and span, keyed on the same STITCH_X.
The east's Y fit got a second fix the same afternoon. Its reference points were label text, but pins point at features: banks, teleports, buildings. Under the old fit, the far north sat south of its features and the far south sat north of them, while the middle was fine. That's a sign pattern again, split by latitude, and this time the seam ran between what I fit against and what I place.
Refitting against 20 feature pixels instead of labels:
| Landmark | Where | Old error (px) | New error (px) |
|---|---|---|---|
| Weiss | far north | -105.4 | -25.4 |
| Falador | middle | -6.9 | +0.1 |
| Nardah | far south | +53.5 | +1.0 |
Worst error went from 123 px to 82, and RMS from 54 to 34. The big moves are at the top and bottom of the map, where the error was.
The derivation lives next to the constant #
Each constant in calibration.ts carries its reasoning in the comment above it: why piecewise, the 11 western landmarks by name, the residuals before and after, why the seam sits at 2000.
Two scripts re-derive the numbers from the landmark tables. fit-calibration.py prints the recommended constants: 963, and the 189 → 61 px result above. fit-east-y-calibration.py prints 2019 and 2209, the east Y base and span the map ships with.
A third script loads the shipped TypeScript module and runs game → pixel → game at ten points on both sides of the seam, including the two tiles either side of it. Every point comes back exact. The inverse splits on the seam too, so clicking the map in dev mode reads back correct game coordinates when I add a new anchor.
A fitted constant without its data and its script is a magic number. With both in the repo, whoever adds a landmark next re-runs the script instead of guessing.
The test #
When a fit is good on one part of your data and bad on another, check the sign of the residuals in each part. If one part is off in one direction, split where the data has a gap, fit each side freely, and compare the slopes. If they agree, as 2.975 and 2.952 did here, add one offset and stop.