hoi4-agent-tools 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. package/server.json +2 -2
@@ -0,0 +1,1110 @@
1
+ import { RenderBudget } from '../core/render-budget.js';
2
+ import { assignments, firstScalar, parseClausewitz } from '../core/source/index.js';
3
+ import { decodeDds } from '../gui/dds.js';
4
+ import { BmpImage } from './bmp.js';
5
+ import { MAP_PROVINCE_ENGINE_MAX_PIXELS, defaultMapSelectorValue } from './model.js';
6
+ import { LAKE_BANK_ALLOWANCE_PX, LAKE_BANK_MAXIMUM_HEIGHT, LAKE_MINIMUM_PIXELS, WATER_OPAQUE_HEIGHT, landDistanceField, } from './water.js';
7
+ const SEA = 0;
8
+ const LAND = 1;
9
+ const LAKE = 2;
10
+ const UNKNOWN = 3;
11
+ const STRIP_ROWS = 64;
12
+ const SAMPLE_LIMIT = 8;
13
+ /** Performance guard for river networks, and the colour-map and water ranges of ADR 0054. */
14
+ export const RIVER_PIXEL_LIMIT = 130_000;
15
+ export const RIVER_LAND_SHARE_LIMIT = 0.025;
16
+ export const COLORMAP_MEAN_VALUE_RANGE = [50, 100];
17
+ export const COLORMAP_DISTINCT_MINIMUM = 400;
18
+ export const COLORMAP_DEVIATION_MINIMUM = 15;
19
+ export const WATER_COLORMAP_DEVIATION_MINIMUM = 8;
20
+ function normalized(value) {
21
+ return value.replaceAll('\\', '/').replace(/^\.\//u, '').toLowerCase();
22
+ }
23
+ function locate(file) {
24
+ return {
25
+ path: file.displayPath,
26
+ start: { line: 1, column: 1, offset: 0 },
27
+ end: { line: 1, column: 1, offset: 0 },
28
+ };
29
+ }
30
+ function mapFile(index, name) {
31
+ for (const root of index.sourceRoots.map) {
32
+ const found = index.activeFiles.byRelativePath.get(normalized(`${root}/${name}`));
33
+ if (found !== undefined)
34
+ return found;
35
+ }
36
+ return undefined;
37
+ }
38
+ function selectedFile(index, selector, fallback) {
39
+ try {
40
+ return mapFile(index, defaultMapSelectorValue(index.defaultMapFile, selector, fallback));
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ function decodeBitmap(file, report, code) {
47
+ try {
48
+ return BmpImage.decode(file.bytes);
49
+ }
50
+ catch (error) {
51
+ report({
52
+ code,
53
+ severity: 'error',
54
+ message: `${file.relativePath} cannot be read: ${error instanceof Error ? error.message : String(error)}`,
55
+ location: locate(file),
56
+ });
57
+ return undefined;
58
+ }
59
+ }
60
+ /** Zero-copy access to the rows of a bitmap in top-left order. */
61
+ function rowOf(image, y) {
62
+ const row = image.topDown ? y : image.height - 1 - y;
63
+ const start = image.pixelOffset + row * image.rowStride;
64
+ return image.bytes.subarray(start, start + image.width * (image.bitsPerPixel === 24 ? 3 : 1));
65
+ }
66
+ function buildRaster(index) {
67
+ const raster = index.raster;
68
+ if (raster === undefined)
69
+ return undefined;
70
+ const typeById = new Map();
71
+ for (const definition of index.definitions)
72
+ if (!typeById.has(definition.id))
73
+ typeById.set(definition.id, definition.type === 'land' ? LAND : definition.type === 'lake' ? LAKE : SEA);
74
+ const kind = new Uint8Array(raster.width * raster.height);
75
+ let landPixels = 0;
76
+ let seaPixels = 0;
77
+ for (let pixel = 0; pixel < kind.length; pixel += 1) {
78
+ const type = typeById.get(raster.provinceIds[pixel]) ?? UNKNOWN;
79
+ kind[pixel] = type;
80
+ if (type === LAND)
81
+ landPixels += 1;
82
+ else if (type === SEA)
83
+ seaPixels += 1;
84
+ }
85
+ return { width: raster.width, height: raster.height, kind, landPixels, seaPixels };
86
+ }
87
+ function graphicalTerrain(index) {
88
+ const files = index.activeFiles.all.filter((file) => /^common\/terrain\/[^/]+\.txt$/u.test(normalized(file.relativePath)));
89
+ if (files.length === 0)
90
+ return undefined;
91
+ const entries = [];
92
+ try {
93
+ for (const file of files) {
94
+ const document = parseClausewitz(file.bytes, file.displayPath);
95
+ for (const section of assignments(document.root, 'terrain'))
96
+ if (section.value.type === 'block')
97
+ for (const entry of assignments(section.value)) {
98
+ if (entry.value.type !== 'block')
99
+ continue;
100
+ const type = firstScalar(entry.value, 'type')?.value;
101
+ const texture = firstScalar(entry.value, 'texture')?.value;
102
+ const colors = assignments(entry.value, 'color')[0]?.value;
103
+ if (type === undefined || colors?.type !== 'block')
104
+ continue;
105
+ for (const color of colors.entries)
106
+ if (color.type === 'scalar' && /^\d+$/u.test(color.value))
107
+ entries.push({
108
+ name: entry.key.value,
109
+ index: Number(color.value),
110
+ type,
111
+ ...(texture !== undefined && /^\d+$/u.test(texture)
112
+ ? { texture: Number(texture) }
113
+ : {}),
114
+ });
115
+ }
116
+ }
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ return entries.length === 0 ? undefined : entries;
122
+ }
123
+ function* checkProvinceMapSize(file, image, raster, report, code, expectedWidth, expectedHeight) {
124
+ yield;
125
+ if (image.width !== expectedWidth || image.height !== expectedHeight)
126
+ report({
127
+ code,
128
+ severity: 'error',
129
+ message: `${file.relativePath} is ${image.width}x${image.height}; the province map is ${raster.width}x${raster.height}, so this file must be ${expectedWidth}x${expectedHeight}`,
130
+ location: locate(file),
131
+ details: {
132
+ width: image.width,
133
+ height: image.height,
134
+ expectedWidth,
135
+ expectedHeight,
136
+ },
137
+ });
138
+ }
139
+ // ---------------------------------------------------------------------------------------------
140
+ // Heightmap and lake surfaces
141
+ function* validateHeightmap(index, raster, report, options) {
142
+ const file = index.heightmapFile;
143
+ if (file === undefined)
144
+ return;
145
+ const image = decodeBitmap(file, report, 'MAP_HEIGHT_UNREADABLE');
146
+ if (image === undefined)
147
+ return;
148
+ if (image.bitsPerPixel !== 8) {
149
+ report({
150
+ code: 'MAP_HEIGHT_FORMAT',
151
+ severity: 'error',
152
+ message: `${file.relativePath} must be an 8-bit greyscale bitmap`,
153
+ location: locate(file),
154
+ details: { bitsPerPixel: image.bitsPerPixel },
155
+ });
156
+ return;
157
+ }
158
+ if (image.width !== raster.width || image.height !== raster.height) {
159
+ yield* checkProvinceMapSize(file, image, raster, report, 'MAP_HEIGHT_SIZE', raster.width, raster.height);
160
+ return;
161
+ }
162
+ const { width, height, kind } = raster;
163
+ const hasLake = kind.includes(LAKE);
164
+ const distance = hasLake ? landDistanceField(kind, width, height) : undefined;
165
+ let lakePixels = 0;
166
+ let landBelow = 0;
167
+ let seaAbove = 0;
168
+ let tooHigh = 0;
169
+ let unrendered = 0;
170
+ const highSamples = [];
171
+ let seamSteps = 0;
172
+ const palette = image.palette;
173
+ const value = (raw) => palette[raw]?.r ?? raw;
174
+ for (let y = 0; y < height; y += 1) {
175
+ if (y % STRIP_ROWS === 0) {
176
+ options.signal?.throwIfAborted();
177
+ yield;
178
+ }
179
+ const row = rowOf(image, y);
180
+ for (let x = 0; x < width; x += 1) {
181
+ const pixelKind = kind[y * width + x];
182
+ const h = value(row[x]);
183
+ if (pixelKind === LAND) {
184
+ if (h < 94)
185
+ landBelow += 1;
186
+ }
187
+ else if (pixelKind === SEA) {
188
+ if (h > 94)
189
+ seaAbove += 1;
190
+ }
191
+ else if (pixelKind === LAKE) {
192
+ lakePixels += 1;
193
+ if (h > LAKE_BANK_MAXIMUM_HEIGHT)
194
+ unrendered += 1;
195
+ if (h > WATER_OPAQUE_HEIGHT && (distance?.[y * width + x] ?? 0) > LAKE_BANK_ALLOWANCE_PX) {
196
+ tooHigh += 1;
197
+ if (highSamples.length < SAMPLE_LIMIT)
198
+ highSamples.push({ x, y, height: h });
199
+ }
200
+ }
201
+ }
202
+ if (Math.abs(value(row[0]) - value(row[width - 1])) > 4)
203
+ seamSteps += 1;
204
+ }
205
+ // The base game has a handful of translucent lake pixels (about 0.1%); a whole lake at the
206
+ // wrong height is the defect.
207
+ if (tooHigh > 0)
208
+ report({
209
+ code: 'MAP_LAKE_SURFACE_TOO_HIGH',
210
+ severity: tooHigh / Math.max(1, lakePixels) > 0.05 ? 'error' : 'warning',
211
+ message: `${tooHigh} lake pixels lie above height ${WATER_OPAQUE_HEIGHT} away from the shore; the water layer draws them translucent, so the lake shows the ground through it. Write lake interiors at 89`,
212
+ location: locate(file),
213
+ details: {
214
+ pixels: tooHigh,
215
+ opaqueUpTo: WATER_OPAQUE_HEIGHT,
216
+ bankPx: LAKE_BANK_ALLOWANCE_PX,
217
+ samples: highSamples,
218
+ },
219
+ });
220
+ if (unrendered > 0 && unrendered / Math.max(1, lakePixels) > 0.01)
221
+ report({
222
+ code: 'MAP_HEIGHT_LAKE_ABOVE_SURFACE',
223
+ severity: 'warning',
224
+ message: `${unrendered} lake pixels are at or above the water surface (height 95), so no water is drawn there`,
225
+ location: locate(file),
226
+ details: { pixels: unrendered },
227
+ });
228
+ if (landBelow / Math.max(1, raster.landPixels) > 0.005)
229
+ report({
230
+ code: 'MAP_HEIGHT_LAND_BELOW_COAST',
231
+ severity: 'warning',
232
+ message: `${landBelow} land pixels lie below height 94, where the water layer covers them`,
233
+ location: locate(file),
234
+ details: { pixels: landBelow },
235
+ });
236
+ if (seaAbove / Math.max(1, raster.seaPixels) > 0.005)
237
+ report({
238
+ code: 'MAP_HEIGHT_SEA_ABOVE_SURFACE',
239
+ severity: 'warning',
240
+ message: `${seaAbove} sea pixels lie above height 94 and draw as shallow or dry ground`,
241
+ location: locate(file),
242
+ details: { pixels: seaAbove },
243
+ });
244
+ if (seamSteps > 0)
245
+ report({
246
+ code: 'MAP_HEIGHT_SEAM_STEP',
247
+ severity: 'warning',
248
+ message: `${seamSteps} rows differ by more than 4 between the first and last column; the terrain steps where the world wraps`,
249
+ location: locate(file),
250
+ details: { rows: seamSteps },
251
+ });
252
+ }
253
+ // ---------------------------------------------------------------------------------------------
254
+ // terrain.bmp
255
+ function* validateTerrainBitmap(index, raster, report, options) {
256
+ const file = selectedFile(index, 'terrain', 'terrain.bmp');
257
+ if (file === undefined)
258
+ return;
259
+ const image = decodeBitmap(file, report, 'MAP_TERRAIN_BITMAP_UNREADABLE');
260
+ if (image === undefined)
261
+ return;
262
+ if (image.bitsPerPixel !== 8) {
263
+ report({
264
+ code: 'MAP_TERRAIN_BITMAP_FORMAT',
265
+ severity: 'error',
266
+ message: `${file.relativePath} must be an 8-bit indexed bitmap`,
267
+ location: locate(file),
268
+ details: { bitsPerPixel: image.bitsPerPixel },
269
+ });
270
+ return;
271
+ }
272
+ if (image.width !== raster.width || image.height !== raster.height) {
273
+ yield* checkProvinceMapSize(file, image, raster, report, 'MAP_TERRAIN_BITMAP_SIZE', raster.width, raster.height);
274
+ return;
275
+ }
276
+ const entries = graphicalTerrain(index);
277
+ const byIndex = new Map();
278
+ for (const entry of entries ?? [])
279
+ if (!byIndex.has(entry.index))
280
+ byIndex.set(entry.index, entry);
281
+ const counts = new Uint32Array(256);
282
+ const waterCounts = new Uint32Array(256);
283
+ const { width, height, kind } = raster;
284
+ for (let y = 0; y < height; y += 1) {
285
+ if (y % STRIP_ROWS === 0) {
286
+ options.signal?.throwIfAborted();
287
+ yield;
288
+ }
289
+ const row = rowOf(image, y);
290
+ for (let x = 0; x < width; x += 1) {
291
+ const value = row[x];
292
+ counts[value] += 1;
293
+ if (kind[y * width + x] === LAKE)
294
+ waterCounts[value] += 1;
295
+ }
296
+ }
297
+ if (entries === undefined)
298
+ return;
299
+ const undefinedIndices = [];
300
+ const unrendered = [];
301
+ for (let value = 0; value < 256; value += 1) {
302
+ if (counts[value] === 0)
303
+ continue;
304
+ const entry = byIndex.get(value);
305
+ if (entry === undefined)
306
+ undefinedIndices.push({ index: value, pixels: counts[value] });
307
+ else if (entry.texture === 255)
308
+ unrendered.push({
309
+ index: value,
310
+ name: entry.name,
311
+ pixels: counts[value],
312
+ lakePixels: waterCounts[value],
313
+ });
314
+ }
315
+ const undefinedPixels = undefinedIndices.reduce((sum, item) => sum + item.pixels, 0);
316
+ if (undefinedIndices.length > 0)
317
+ report({
318
+ code: 'MAP_TERRAIN_BITMAP_INDEX_UNDEFINED',
319
+ severity: undefinedPixels > width * height * 0.001 ? 'error' : 'warning',
320
+ message: `${file.relativePath} uses ${undefinedIndices.length} palette index(es) that no graphical terrain in common/terrain defines`,
321
+ location: locate(file),
322
+ details: { indices: undefinedIndices.slice(0, 32) },
323
+ });
324
+ if (unrendered.length > 0)
325
+ report({
326
+ code: 'MAP_LAKE_TEXTURE_UNRENDERED',
327
+ severity: 'error',
328
+ message: `${file.relativePath} uses graphical terrain index ${unrendered.map((item) => item.index).join(', ')}, which selects no atlas tile (texture = 255); the engine draws nothing useful there. Use the ocean index for lakes`,
329
+ location: locate(file),
330
+ details: { indices: unrendered },
331
+ });
332
+ }
333
+ // ---------------------------------------------------------------------------------------------
334
+ // rivers.bmp
335
+ function* validateRivers(index, raster, report, options) {
336
+ const file = selectedFile(index, 'rivers', 'rivers.bmp');
337
+ if (file === undefined)
338
+ return;
339
+ const image = decodeBitmap(file, report, 'MAP_RIVER_UNREADABLE');
340
+ if (image === undefined)
341
+ return;
342
+ if (image.bitsPerPixel !== 8) {
343
+ report({
344
+ code: 'MAP_RIVER_FORMAT',
345
+ severity: 'error',
346
+ message: `${file.relativePath} must be an 8-bit indexed bitmap (the engine refuses other depths)`,
347
+ location: locate(file),
348
+ details: { bitsPerPixel: image.bitsPerPixel },
349
+ });
350
+ return;
351
+ }
352
+ const colorsUsed = file.bytes.readUInt32LE(46);
353
+ if (colorsUsed !== 0)
354
+ report({
355
+ code: 'MAP_RIVER_PALETTE_COUNT',
356
+ severity: 'warning',
357
+ message: `${file.relativePath} declares ${colorsUsed} palette colours; the engine logs "Palette in rivers.bmp is probably not correct" unless the header count is 0`,
358
+ location: locate(file),
359
+ details: { colorsUsed },
360
+ });
361
+ if (image.width !== raster.width || image.height !== raster.height) {
362
+ yield* checkProvinceMapSize(file, image, raster, report, 'MAP_RIVER_SIZE', raster.width, raster.height);
363
+ return;
364
+ }
365
+ const { width, height, kind } = raster;
366
+ let riverPixels = 0;
367
+ const badIndex = [];
368
+ let badIndexCount = 0;
369
+ let blocks = 0;
370
+ const blockSamples = [];
371
+ let notOrthogonal = 0;
372
+ const orthogonalSamples = [];
373
+ let onSea = 0;
374
+ const seaSamples = [];
375
+ const isRiver = (value) => value <= 11;
376
+ let previous;
377
+ let current = rowOf(image, 0);
378
+ for (let y = 0; y < height; y += 1) {
379
+ if (y % STRIP_ROWS === 0) {
380
+ options.signal?.throwIfAborted();
381
+ yield;
382
+ }
383
+ const next = y + 1 < height ? rowOf(image, y + 1) : undefined;
384
+ for (let x = 0; x < width; x += 1) {
385
+ const value = current[x];
386
+ if (!isRiver(value)) {
387
+ if (value < 254) {
388
+ badIndexCount += 1;
389
+ if (badIndex.length < SAMPLE_LIMIT)
390
+ badIndex.push({ x, y, value });
391
+ }
392
+ continue;
393
+ }
394
+ riverPixels += 1;
395
+ if (kind[y * width + x] === SEA) {
396
+ onSea += 1;
397
+ if (seaSamples.length < SAMPLE_LIMIT)
398
+ seaSamples.push({ x, y });
399
+ }
400
+ const east = x + 1 < width && isRiver(current[x + 1]);
401
+ const west = x > 0 && isRiver(current[x - 1]);
402
+ const south = next !== undefined && isRiver(next[x]);
403
+ const north = previous !== undefined && isRiver(previous[x]);
404
+ const southEast = next !== undefined && x + 1 < width && isRiver(next[x + 1]);
405
+ if (east && south && southEast) {
406
+ blocks += 1;
407
+ if (blockSamples.length < SAMPLE_LIMIT)
408
+ blockSamples.push({ x, y });
409
+ }
410
+ if (!east && !west && !south && !north) {
411
+ notOrthogonal += 1;
412
+ if (orthogonalSamples.length < SAMPLE_LIMIT)
413
+ orthogonalSamples.push({ x, y });
414
+ }
415
+ }
416
+ previous = current;
417
+ current = next ?? current;
418
+ }
419
+ if (badIndexCount > 0)
420
+ report({
421
+ code: 'MAP_RIVER_INDEX_INVALID',
422
+ severity: 'error',
423
+ message: `${badIndexCount} river pixels use an index other than 0 to 11, 254 or 255`,
424
+ location: locate(file),
425
+ details: { pixels: badIndexCount, samples: badIndex },
426
+ });
427
+ if (blocks > 0)
428
+ report({
429
+ code: 'MAP_RIVER_BLOCK',
430
+ severity: blocks > riverPixels * 0.005 ? 'error' : 'warning',
431
+ message: `${blocks} places hold a 2 x 2 block of river pixels; rivers are one pixel thick`,
432
+ location: locate(file),
433
+ details: { blocks, samples: blockSamples },
434
+ });
435
+ if (notOrthogonal > 0)
436
+ report({
437
+ code: 'MAP_RIVER_NOT_ORTHOGONAL',
438
+ severity: 'warning',
439
+ message: `${notOrthogonal} river pixels touch no other river pixel orthogonally (isolated or diagonal-only), which breaks the river path`,
440
+ location: locate(file),
441
+ details: { pixels: notOrthogonal, samples: orthogonalSamples },
442
+ });
443
+ if (onSea > 0)
444
+ report({
445
+ code: 'MAP_RIVER_ON_WATER',
446
+ severity: 'warning',
447
+ message: `${onSea} river pixels lie on sea provinces`,
448
+ location: locate(file),
449
+ details: { pixels: onSea, samples: seaSamples },
450
+ });
451
+ if (riverPixels > RIVER_PIXEL_LIMIT ||
452
+ (raster.landPixels > 0 && riverPixels / raster.landPixels > RIVER_LAND_SHARE_LIMIT))
453
+ report({
454
+ code: 'MAP_RIVER_DENSITY',
455
+ severity: 'warning',
456
+ message: `${riverPixels} river pixels (${((100 * riverPixels) / Math.max(1, raster.landPixels)).toFixed(2)}% of land) exceed the guard of ${RIVER_PIXEL_LIMIT} pixels or 2.5% of land; long river networks slow the game`,
457
+ location: locate(file),
458
+ details: { pixels: riverPixels, landPixels: raster.landPixels },
459
+ });
460
+ yield* validateRiverSources(file, image, report, options);
461
+ }
462
+ /** Every connected river should have exactly one green source (index 0). */
463
+ function* validateRiverSources(file, image, report, options) {
464
+ const { width, height } = image;
465
+ const rows = (y) => rowOf(image, y);
466
+ let count = 0;
467
+ for (let y = 0; y < height; y += 1) {
468
+ const row = rows(y);
469
+ for (let x = 0; x < width; x += 1)
470
+ if (row[x] <= 11)
471
+ count += 1;
472
+ if (y % (STRIP_ROWS * 4) === 0) {
473
+ options.signal?.throwIfAborted();
474
+ yield;
475
+ }
476
+ }
477
+ if (count === 0)
478
+ return;
479
+ // Component labelling over only the river pixels, keyed by pixel offset.
480
+ const offsets = new Uint32Array(count);
481
+ let fill = 0;
482
+ for (let y = 0; y < height; y += 1) {
483
+ const row = rows(y);
484
+ for (let x = 0; x < width; x += 1)
485
+ if (row[x] <= 11)
486
+ offsets[fill++] = y * width + x;
487
+ }
488
+ const parent = new Int32Array(count);
489
+ for (let i = 0; i < count; i += 1)
490
+ parent[i] = i;
491
+ const find = (value) => {
492
+ let root = value;
493
+ while (parent[root] !== root)
494
+ root = parent[root];
495
+ while (parent[value] !== root) {
496
+ const next = parent[value];
497
+ parent[value] = root;
498
+ value = next;
499
+ }
500
+ return root;
501
+ };
502
+ // offsets are sorted, so the neighbour's slot is found by binary search.
503
+ const slotOf = (offset) => {
504
+ let low = 0;
505
+ let high = count - 1;
506
+ while (low <= high) {
507
+ const mid = (low + high) >>> 1;
508
+ const at = offsets[mid];
509
+ if (at === offset)
510
+ return mid;
511
+ if (at < offset)
512
+ low = mid + 1;
513
+ else
514
+ high = mid - 1;
515
+ }
516
+ return -1;
517
+ };
518
+ for (let i = 0; i < count; i += 1) {
519
+ if (i % 65_536 === 0) {
520
+ options.signal?.throwIfAborted();
521
+ yield;
522
+ }
523
+ const offset = offsets[i];
524
+ const x = offset % width;
525
+ if (x + 1 < width) {
526
+ const east = slotOf(offset + 1);
527
+ if (east >= 0)
528
+ parent[find(i)] = find(east);
529
+ }
530
+ const south = slotOf(offset + width);
531
+ if (south >= 0)
532
+ parent[find(i)] = find(south);
533
+ }
534
+ const sources = new Map();
535
+ const firstOf = new Map();
536
+ for (let i = 0; i < count; i += 1) {
537
+ const root = find(i);
538
+ if (!firstOf.has(root))
539
+ firstOf.set(root, offsets[i]);
540
+ const y = Math.floor(offsets[i] / width);
541
+ if (rows(y)[offsets[i] % width] === 0)
542
+ sources.set(root, (sources.get(root) ?? 0) + 1);
543
+ else if (!sources.has(root))
544
+ sources.set(root, 0);
545
+ }
546
+ let none = 0;
547
+ let several = 0;
548
+ const sampleOf = [];
549
+ for (const [root, total] of sources) {
550
+ if (total === 1)
551
+ continue;
552
+ if (total === 0)
553
+ none += 1;
554
+ else
555
+ several += 1;
556
+ if (sampleOf.length < SAMPLE_LIMIT) {
557
+ const offset = firstOf.get(root);
558
+ sampleOf.push({ x: offset % width, y: Math.floor(offset / width), sources: total });
559
+ }
560
+ }
561
+ if (none + several > 0)
562
+ report({
563
+ code: 'MAP_RIVER_SOURCE_COUNT',
564
+ severity: 'warning',
565
+ message: `${none} connected river(s) have no green source (index 0) and ${several} have several; each river wants exactly one source on its main branch`,
566
+ location: locate(file),
567
+ details: { withoutSource: none, withSeveralSources: several, samples: sampleOf },
568
+ });
569
+ }
570
+ // ---------------------------------------------------------------------------------------------
571
+ // trees.bmp
572
+ function* validateTrees(index, raster, report, options) {
573
+ const file = selectedFile(index, 'tree_definition', 'trees.bmp');
574
+ if (file === undefined)
575
+ return;
576
+ const image = decodeBitmap(file, report, 'MAP_TREES_UNREADABLE');
577
+ if (image === undefined)
578
+ return;
579
+ if (image.bitsPerPixel !== 8) {
580
+ report({
581
+ code: 'MAP_TREES_FORMAT',
582
+ severity: 'error',
583
+ message: `${file.relativePath} must be an 8-bit indexed bitmap`,
584
+ location: locate(file),
585
+ details: { bitsPerPixel: image.bitsPerPixel },
586
+ });
587
+ return;
588
+ }
589
+ const expectedWidth = Math.round((raster.width * 75) / 256);
590
+ const expectedHeight = Math.round((raster.height * 75) / 256);
591
+ if (image.width !== expectedWidth || image.height !== expectedHeight) {
592
+ yield* checkProvinceMapSize(file, image, raster, report, 'MAP_TREES_SIZE', expectedWidth, expectedHeight);
593
+ return;
594
+ }
595
+ let invalid = 0;
596
+ let onWater = 0;
597
+ let nonZero = 0;
598
+ const samplesOnWater = [];
599
+ for (let y = 0; y < image.height; y += 1) {
600
+ if (y % STRIP_ROWS === 0) {
601
+ options.signal?.throwIfAborted();
602
+ yield;
603
+ }
604
+ const row = rowOf(image, y);
605
+ for (let x = 0; x < image.width; x += 1) {
606
+ const value = row[x];
607
+ if (value === 0)
608
+ continue;
609
+ nonZero += 1;
610
+ if (value >= image.palette.length)
611
+ invalid += 1;
612
+ // A tree cell stands over water only when every sample of its footprint is water.
613
+ let allWater = true;
614
+ for (const [fx, fy] of [
615
+ [0, 0],
616
+ [1, 0],
617
+ [0, 1],
618
+ [1, 1],
619
+ [0.5, 0.5],
620
+ ]) {
621
+ const sourceX = Math.min(raster.width - 1, Math.floor(((x + fx * 0.999) * raster.width) / image.width));
622
+ const sourceY = Math.min(raster.height - 1, Math.floor(((y + fy * 0.999) * raster.height) / image.height));
623
+ const kind = raster.kind[sourceY * raster.width + sourceX];
624
+ if (kind !== SEA && kind !== LAKE) {
625
+ allWater = false;
626
+ break;
627
+ }
628
+ }
629
+ if (allWater) {
630
+ onWater += 1;
631
+ if (samplesOnWater.length < SAMPLE_LIMIT)
632
+ samplesOnWater.push({ x, y });
633
+ }
634
+ }
635
+ }
636
+ if (invalid > 0)
637
+ report({
638
+ code: 'MAP_TREES_INDEX_INVALID',
639
+ severity: 'error',
640
+ message: `${invalid} tree cells use a palette index beyond the ${image.palette.length} entries of ${file.relativePath}`,
641
+ location: locate(file),
642
+ details: { cells: invalid },
643
+ });
644
+ if (nonZero > 0 && onWater / nonZero > 0.02)
645
+ report({
646
+ code: 'MAP_TREES_ON_WATER',
647
+ severity: 'warning',
648
+ message: `${onWater} tree cells (${((100 * onWater) / nonZero).toFixed(1)}%) stand over sea or lake provinces`,
649
+ location: locate(file),
650
+ details: { cells: onWater, samples: samplesOnWater },
651
+ });
652
+ }
653
+ function readDds(file, report) {
654
+ if (file.bytes.length < 128 || file.bytes.readUInt32LE(0) !== 0x2053_4444) {
655
+ report({
656
+ code: 'MAP_COLORMAP_UNREADABLE',
657
+ severity: 'error',
658
+ message: `${file.relativePath} is not a DDS texture`,
659
+ location: locate(file),
660
+ });
661
+ return undefined;
662
+ }
663
+ try {
664
+ const decoded = decodeDds(file.bytes, new RenderBudget());
665
+ if ('unsupported' in decoded) {
666
+ report({
667
+ code: 'MAP_COLORMAP_UNREADABLE',
668
+ severity: 'info',
669
+ message: `${file.relativePath} is in a DDS format this tool does not examine (${decoded.format})`,
670
+ location: locate(file),
671
+ details: { format: decoded.format, reason: decoded.reason },
672
+ });
673
+ return undefined;
674
+ }
675
+ return { width: decoded.width, height: decoded.height, data: decoded.data };
676
+ }
677
+ catch {
678
+ report({
679
+ code: 'MAP_COLORMAP_UNREADABLE',
680
+ severity: 'info',
681
+ message: `${file.relativePath} could not be decoded within the raster limits and was not examined`,
682
+ location: locate(file),
683
+ });
684
+ return undefined;
685
+ }
686
+ }
687
+ function* validateColourMaps(index, raster, report, options) {
688
+ const land = mapFile(index, 'terrain/colormap_rgb_cityemissivemask_a.dds');
689
+ if (land !== undefined && land.bytes.length >= 128) {
690
+ const headerHeight = land.bytes.readUInt32LE(12);
691
+ const headerWidth = land.bytes.readUInt32LE(16);
692
+ const expectedWidth = raster.width / 2;
693
+ const expectedHeight = raster.height / 2;
694
+ if (headerWidth !== expectedWidth || headerHeight !== expectedHeight)
695
+ report({
696
+ code: 'MAP_COLORMAP_SIZE',
697
+ severity: 'error',
698
+ message: `${land.relativePath} is ${headerWidth}x${headerHeight}; the colour map must be half the province map, ${expectedWidth}x${expectedHeight}`,
699
+ location: locate(land),
700
+ details: { width: headerWidth, height: headerHeight, expectedWidth, expectedHeight },
701
+ });
702
+ else {
703
+ yield;
704
+ const pixels = readDds(land, report);
705
+ if (pixels !== undefined)
706
+ yield* landColourStatistics(land, pixels, raster, report, options);
707
+ }
708
+ }
709
+ for (const level of [0, 1, 2]) {
710
+ const water = mapFile(index, `terrain/colormap_water_${level}.dds`);
711
+ if (water === undefined || water.bytes.length < 128)
712
+ continue;
713
+ const factor = 2 ** (level + 1);
714
+ const headerHeight = water.bytes.readUInt32LE(12);
715
+ const headerWidth = water.bytes.readUInt32LE(16);
716
+ const expectedWidth = raster.width / factor;
717
+ const expectedHeight = raster.height / factor;
718
+ if (headerWidth !== expectedWidth || headerHeight !== expectedHeight) {
719
+ report({
720
+ code: 'MAP_COLORMAP_SIZE',
721
+ severity: 'error',
722
+ message: `${water.relativePath} is ${headerWidth}x${headerHeight}; level ${level} must be 1/${factor} of the province map, ${expectedWidth}x${expectedHeight}`,
723
+ location: locate(water),
724
+ details: { width: headerWidth, height: headerHeight, expectedWidth, expectedHeight },
725
+ });
726
+ continue;
727
+ }
728
+ if (level !== 0)
729
+ continue;
730
+ yield;
731
+ const pixels = readDds(water, report);
732
+ if (pixels === undefined)
733
+ continue;
734
+ let count = 0;
735
+ const sums = [0, 0, 0];
736
+ const squares = [0, 0, 0];
737
+ for (let y = 0; y < pixels.height; y += 1) {
738
+ if (y % STRIP_ROWS === 0) {
739
+ options.signal?.throwIfAborted();
740
+ yield;
741
+ }
742
+ for (let x = 0; x < pixels.width; x += 1) {
743
+ if (raster.kind[y * factor * raster.width + x * factor] !== SEA)
744
+ continue;
745
+ const offset = (y * pixels.width + x) * 4;
746
+ count += 1;
747
+ for (let channel = 0; channel < 3; channel += 1) {
748
+ const value = pixels.data[offset + channel];
749
+ sums[channel] += value;
750
+ squares[channel] += value * value;
751
+ }
752
+ }
753
+ }
754
+ if (count > 1000) {
755
+ const deviation = sums.map((sum, channel) => Math.sqrt(Math.max(0, squares[channel] / count - (sum / count) ** 2)));
756
+ const mean = (deviation[0] + deviation[1] + deviation[2]) / 3;
757
+ if (mean < WATER_COLORMAP_DEVIATION_MINIMUM)
758
+ report({
759
+ code: 'MAP_WATER_COLORMAP_FLAT',
760
+ severity: 'warning',
761
+ message: `${water.relativePath} is almost one colour over the sea (deviation ${mean.toFixed(1)}); the base game's varies by about 40 and shows the shelf as a lighter band`,
762
+ location: locate(water),
763
+ details: { deviation: deviation.map((value) => Math.round(value * 10) / 10) },
764
+ });
765
+ }
766
+ }
767
+ }
768
+ function* landColourStatistics(file, pixels, raster, report, options) {
769
+ let landCount = 0;
770
+ let valueSum = 0;
771
+ let valueSquares = 0;
772
+ const distinct = new Set();
773
+ let lightsOnWater = 0;
774
+ for (let y = 0; y < pixels.height; y += 1) {
775
+ if (y % STRIP_ROWS === 0) {
776
+ options.signal?.throwIfAborted();
777
+ yield;
778
+ }
779
+ for (let x = 0; x < pixels.width; x += 1) {
780
+ const kind = raster.kind[y * 2 * raster.width + x * 2];
781
+ const offset = (y * pixels.width + x) * 4;
782
+ if (kind === LAND) {
783
+ const r = pixels.data[offset];
784
+ const g = pixels.data[offset + 1];
785
+ const b = pixels.data[offset + 2];
786
+ const value = Math.max(r, g, b);
787
+ landCount += 1;
788
+ valueSum += value;
789
+ valueSquares += value * value;
790
+ distinct.add(((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3));
791
+ }
792
+ else if ((kind === SEA || kind === LAKE) && pixels.data[offset + 3] > 0)
793
+ lightsOnWater += 1;
794
+ }
795
+ }
796
+ if (lightsOnWater > 0)
797
+ report({
798
+ code: 'MAP_COLORMAP_ALPHA_CITY',
799
+ severity: 'info',
800
+ message: `${lightsOnWater} colour map pixels over water carry city-light alpha`,
801
+ location: locate(file),
802
+ details: { pixels: lightsOnWater },
803
+ });
804
+ if (landCount < 100)
805
+ return;
806
+ const mean = valueSum / landCount;
807
+ const deviation = Math.sqrt(Math.max(0, valueSquares / landCount - mean * mean));
808
+ // Small maps cannot hold hundreds of colours, so the floor scales with the land area.
809
+ const distinctMinimum = Math.min(COLORMAP_DISTINCT_MINIMUM, Math.floor(landCount / 100));
810
+ const problems = [];
811
+ if (mean < COLORMAP_MEAN_VALUE_RANGE[0] || mean > COLORMAP_MEAN_VALUE_RANGE[1])
812
+ problems.push(`mean value ${mean.toFixed(0)} is outside ${COLORMAP_MEAN_VALUE_RANGE[0]} to ${COLORMAP_MEAN_VALUE_RANGE[1]} (the base game's is 74; a brighter colour map leaves the atlas pale)`);
813
+ if (distinct.size < distinctMinimum)
814
+ problems.push(`${distinct.size} distinct 5-bit colours is below ${distinctMinimum}`);
815
+ if (deviation < COLORMAP_DEVIATION_MINIMUM)
816
+ problems.push(`value deviation ${deviation.toFixed(0)} is below ${COLORMAP_DEVIATION_MINIMUM}`);
817
+ if (problems.length > 0)
818
+ report({
819
+ code: 'MAP_COLORMAP_RANGE',
820
+ severity: 'warning',
821
+ message: `The land colour map looks flat or washed out: ${problems.join('; ')}`,
822
+ location: locate(file),
823
+ details: {
824
+ meanValue: Math.round(mean * 10) / 10,
825
+ valueDeviation: Math.round(deviation * 10) / 10,
826
+ distinct5BitColours: distinct.size,
827
+ landPixels: landCount,
828
+ },
829
+ });
830
+ }
831
+ // ---------------------------------------------------------------------------------------------
832
+ // world_normal.bmp
833
+ function* validateNormals(index, raster, report, options) {
834
+ const file = mapFile(index, 'world_normal.bmp');
835
+ if (file === undefined)
836
+ return;
837
+ const image = decodeBitmap(file, report, 'MAP_NORMAL_UNREADABLE');
838
+ if (image === undefined)
839
+ return;
840
+ if (image.bitsPerPixel !== 24) {
841
+ report({
842
+ code: 'MAP_NORMAL_FORMAT',
843
+ severity: 'error',
844
+ message: `${file.relativePath} must be a 24-bit bitmap`,
845
+ location: locate(file),
846
+ details: { bitsPerPixel: image.bitsPerPixel },
847
+ });
848
+ return;
849
+ }
850
+ if (image.width !== raster.width / 2 || image.height !== raster.height / 2) {
851
+ yield* checkProvinceMapSize(file, image, raster, report, 'MAP_NORMAL_SIZE', raster.width / 2, raster.height / 2);
852
+ return;
853
+ }
854
+ let sampled = 0;
855
+ let notUnit = 0;
856
+ for (let y = 0; y < image.height; y += 4) {
857
+ if (y % STRIP_ROWS === 0) {
858
+ options.signal?.throwIfAborted();
859
+ yield;
860
+ }
861
+ const row = rowOf(image, y);
862
+ for (let x = 0; x < image.width; x += 4) {
863
+ const nx = (row[x * 3 + 2] - 127.5) / 127.5;
864
+ const ny = (row[x * 3 + 1] - 127.5) / 127.5;
865
+ const nz = (row[x * 3] - 127.5) / 127.5;
866
+ sampled += 1;
867
+ if (Math.abs(Math.sqrt(nx * nx + ny * ny + nz * nz) - 1) > 0.02)
868
+ notUnit += 1;
869
+ }
870
+ }
871
+ // The base game has about 10% of normals off unit length (hand detail); mostly-wrong is a defect.
872
+ if (sampled > 0 && notUnit / sampled > 0.25)
873
+ report({
874
+ code: 'MAP_NORMAL_NOT_UNIT',
875
+ severity: 'warning',
876
+ message: `${((100 * notUnit) / sampled).toFixed(1)}% of sampled normals are not unit length within 2%`,
877
+ location: locate(file),
878
+ details: { sampled, notUnit },
879
+ });
880
+ // Response of the red channel to the east-west height step, against the base game's -2.6.
881
+ const heightFile = index.heightmapFile;
882
+ if (heightFile === undefined)
883
+ return;
884
+ const heights = decodeBitmap(heightFile, () => undefined, 'MAP_HEIGHT_UNREADABLE');
885
+ if (heights?.bitsPerPixel !== 8 ||
886
+ heights.width !== raster.width ||
887
+ heights.height !== raster.height)
888
+ return;
889
+ const palette = heights.palette;
890
+ const value = (raw) => palette[raw]?.r ?? raw;
891
+ let covariance = 0;
892
+ let variance = 0;
893
+ for (let y = 0; y < image.height; y += 2) {
894
+ if (y % STRIP_ROWS === 0) {
895
+ options.signal?.throwIfAborted();
896
+ yield;
897
+ }
898
+ const row = rowOf(image, y);
899
+ const heightRow = rowOf(heights, y * 2);
900
+ for (let x = 1; x < image.width - 1; x += 2) {
901
+ const step = value(heightRow[x * 2 + 2]) - value(heightRow[x * 2 - 2]);
902
+ covariance += (row[x * 3 + 2] - 128) * step;
903
+ variance += step * step;
904
+ }
905
+ }
906
+ if (variance < 2_000)
907
+ return;
908
+ const response = -covariance / variance;
909
+ if (response < 0.5 || response > 6)
910
+ report({
911
+ code: 'MAP_NORMAL_RESPONSE',
912
+ severity: 'warning',
913
+ message: `The normal map responds ${response.toFixed(2)} per height step; the base game's is about 2.6, so shading will look ${response < 0.5 ? 'flat' : 'harsh'}`,
914
+ location: locate(file),
915
+ details: { response: Math.round(response * 100) / 100, expected: [0.5, 6] },
916
+ });
917
+ }
918
+ // ---------------------------------------------------------------------------------------------
919
+ // cities.bmp
920
+ function* validateCities(index, raster, report, options) {
921
+ const file = mapFile(index, 'cities.bmp');
922
+ if (file === undefined)
923
+ return;
924
+ const image = decodeBitmap(file, report, 'MAP_CITIES_UNREADABLE');
925
+ if (image === undefined)
926
+ return;
927
+ if (image.bitsPerPixel !== 8) {
928
+ report({
929
+ code: 'MAP_CITIES_FORMAT',
930
+ severity: 'error',
931
+ message: `${file.relativePath} must be an 8-bit indexed bitmap`,
932
+ location: locate(file),
933
+ details: { bitsPerPixel: image.bitsPerPixel },
934
+ });
935
+ return;
936
+ }
937
+ if (image.width !== raster.width || image.height !== raster.height) {
938
+ yield* checkProvinceMapSize(file, image, raster, report, 'MAP_CITIES_SIZE', raster.width, raster.height);
939
+ return;
940
+ }
941
+ const counts = new Uint32Array(256);
942
+ for (let y = 0; y < image.height; y += 1) {
943
+ if (y % STRIP_ROWS === 0) {
944
+ options.signal?.throwIfAborted();
945
+ yield;
946
+ }
947
+ const row = rowOf(image, y);
948
+ for (let x = 0; x < image.width; x += 1)
949
+ counts[row[x]] += 1;
950
+ }
951
+ const definitions = mapFile(index, 'cities.txt');
952
+ if (definitions === undefined)
953
+ return;
954
+ const defined = new Set();
955
+ for (const match of definitions.bytes.toString('utf8').matchAll(/color_index\s*=\s*(\d+)/gu))
956
+ defined.add(Number(match[1]));
957
+ if (defined.size === 0)
958
+ return;
959
+ const undefinedIndices = [...counts.keys()].filter((value) => counts[value] > 0 && !defined.has(value));
960
+ if (undefinedIndices.length > 0)
961
+ report({
962
+ code: 'MAP_CITIES_INDEX_UNDEFINED',
963
+ severity: 'warning',
964
+ message: `${file.relativePath} uses palette index(es) ${undefinedIndices.slice(0, 10).join(', ')} that no city_group in cities.txt defines`,
965
+ location: locate(file),
966
+ details: { indices: undefinedIndices.slice(0, 32) },
967
+ });
968
+ }
969
+ // ---------------------------------------------------------------------------------------------
970
+ // Lakes and the world edge, from the province map
971
+ function* validateLakesAndEdge(index, raster, report, options) {
972
+ const provincesFile = index.provinceBitmapFile;
973
+ const provinceRaster = index.raster;
974
+ if (provincesFile === undefined || provinceRaster === undefined)
975
+ return;
976
+ const location = locate(provincesFile);
977
+ const typeById = new Map(index.definitions.map((definition) => [definition.id, definition.type]));
978
+ // A lake body is a set of adjacent lake provinces; its size decides whether it reads as water.
979
+ const bodyOf = new Map();
980
+ const lakeProvinces = [...provinceRaster.geometry.keys()]
981
+ .filter((id) => typeById.get(id) === 'lake')
982
+ .sort((a, b) => a - b);
983
+ for (const start of lakeProvinces) {
984
+ if (bodyOf.has(start))
985
+ continue;
986
+ const queue = [start];
987
+ bodyOf.set(start, start);
988
+ while (queue.length > 0) {
989
+ const current = queue.pop();
990
+ for (const other of provinceRaster.adjacency.get(current) ?? [])
991
+ if (typeById.get(other) === 'lake' && !bodyOf.has(other)) {
992
+ bodyOf.set(other, start);
993
+ queue.push(other);
994
+ }
995
+ }
996
+ }
997
+ const bodyPixels = new Map();
998
+ for (const id of lakeProvinces)
999
+ bodyPixels.set(bodyOf.get(id), (bodyPixels.get(bodyOf.get(id)) ?? 0) + provinceRaster.geometry.get(id).pixelCount);
1000
+ for (const [body, pixels] of bodyPixels)
1001
+ if (pixels < LAKE_MINIMUM_PIXELS)
1002
+ report({
1003
+ code: 'MAP_LAKE_TOO_SMALL',
1004
+ severity: 'warning',
1005
+ message: `Lake province ${body} holds a lake of only ${pixels} pixels; water below ${LAKE_MINIMUM_PIXELS} pixels is too small to read as a lake`,
1006
+ location,
1007
+ details: { provinceId: body, pixels },
1008
+ });
1009
+ for (const id of lakeProvinces)
1010
+ for (const other of provinceRaster.adjacency.get(id) ?? [])
1011
+ if (typeById.get(other) === 'sea')
1012
+ report({
1013
+ code: 'MAP_LAKE_CONNECTED_TO_SEA',
1014
+ severity: 'warning',
1015
+ message: `Lake province ${id} borders sea province ${other}; water that joins the sea is sea, not a lake`,
1016
+ location,
1017
+ details: { lakeProvinceId: id, seaProvinceId: other },
1018
+ });
1019
+ yield;
1020
+ const { width, height, kind } = raster;
1021
+ let left = 0;
1022
+ let right = 0;
1023
+ let rows = 0;
1024
+ const rowSamples = [];
1025
+ const pairs = new Set();
1026
+ for (let y = 0; y < height; y += 1) {
1027
+ if (y % 256 === 0) {
1028
+ options.signal?.throwIfAborted();
1029
+ yield;
1030
+ }
1031
+ const start = kind[y * width] === LAND;
1032
+ const end = kind[y * width + width - 1] === LAND;
1033
+ if (start)
1034
+ left += 1;
1035
+ if (end)
1036
+ right += 1;
1037
+ if (start || end) {
1038
+ rows += 1;
1039
+ if (rowSamples.length < SAMPLE_LIMIT)
1040
+ rowSamples.push(y);
1041
+ }
1042
+ if (start && end) {
1043
+ const a = provinceRaster.provinceIds[y * width];
1044
+ const b = provinceRaster.provinceIds[y * width + width - 1];
1045
+ if (a !== b)
1046
+ pairs.add(`${Math.min(a, b)}:${Math.max(a, b)}`);
1047
+ }
1048
+ }
1049
+ if (rows > 0)
1050
+ report({
1051
+ code: 'MAP_EDGE_LAND_CUT',
1052
+ severity: options.edgeMode === 'sea-band' ? 'error' : 'warning',
1053
+ message: `Land touches the first or last column on ${rows} rows (${left} on the west edge, ${right} on the east); where the world wraps, the coast is cut by a straight vertical line. Use edge.mode "sea-band" or "rim"`,
1054
+ location,
1055
+ details: { rows, west: left, east: right, firstRows: rowSamples },
1056
+ });
1057
+ if (pairs.size > 0) {
1058
+ const covered = new Set();
1059
+ for (const adjacency of index.adjacencies)
1060
+ if (adjacency.type === 'impassable')
1061
+ covered.add(`${Math.min(adjacency.from, adjacency.to)}:${Math.max(adjacency.from, adjacency.to)}`);
1062
+ const open = [...pairs].filter((pair) => !covered.has(pair)).length;
1063
+ report({
1064
+ code: 'MAP_SEAM_LAND_ADJACENCY',
1065
+ severity: 'info',
1066
+ message: `${pairs.size} pairs of land provinces meet across the wrapping seam; ${pairs.size - open} are blocked by an impassable adjacency`,
1067
+ location,
1068
+ details: { pairs: pairs.size, impassable: pairs.size - open },
1069
+ });
1070
+ }
1071
+ let top = 0;
1072
+ let bottom = 0;
1073
+ for (let x = 0; x < width; x += 1) {
1074
+ if (kind[x] === LAND)
1075
+ top += 1;
1076
+ if (kind[(height - 1) * width + x] === LAND)
1077
+ bottom += 1;
1078
+ }
1079
+ if (top + bottom > 0)
1080
+ report({
1081
+ code: 'MAP_POLAR_LAND',
1082
+ severity: 'info',
1083
+ message: `Land touches the top row on ${top} columns and the bottom row on ${bottom}; the north and south frame borders cover the map edge there`,
1084
+ location,
1085
+ details: { top, bottom },
1086
+ });
1087
+ }
1088
+ /**
1089
+ * Run every appearance check for the files present in the scanned set. The rasters are built
1090
+ * once; each check reads its bitmap in strips.
1091
+ */
1092
+ export function* validateAppearance(index, report, options = {}) {
1093
+ const provinceRaster = index.raster;
1094
+ if (provinceRaster === undefined)
1095
+ return;
1096
+ if (provinceRaster.width * provinceRaster.height > MAP_PROVINCE_ENGINE_MAX_PIXELS)
1097
+ return;
1098
+ const raster = buildRaster(index);
1099
+ if (raster === undefined)
1100
+ return;
1101
+ yield;
1102
+ yield* validateLakesAndEdge(index, raster, report, options);
1103
+ yield* validateHeightmap(index, raster, report, options);
1104
+ yield* validateTerrainBitmap(index, raster, report, options);
1105
+ yield* validateRivers(index, raster, report, options);
1106
+ yield* validateTrees(index, raster, report, options);
1107
+ yield* validateColourMaps(index, raster, report, options);
1108
+ yield* validateNormals(index, raster, report, options);
1109
+ yield* validateCities(index, raster, report, options);
1110
+ }