Skip to content

perf(map-generator): major CPU and memory optimisations#3860

Merged
evanpelle merged 9 commits intoopenfrontio:mainfrom
alexjurkiewicz:feat/map-generator-optimisations
May 6, 2026
Merged

perf(map-generator): major CPU and memory optimisations#3860
evanpelle merged 9 commits intoopenfrontio:mainfrom
alexjurkiewicz:feat/map-generator-optimisations

Conversation

@alexjurkiewicz
Copy link
Copy Markdown
Contributor

@alexjurkiewicz alexjurkiewicz commented May 6, 2026

Description:

Five performance improvements to the map generator, measured on three maps of increasing size. End-to-end time on world improved ~15×, heap allocations ~19×.

Map Before After Speedup
bosphorusstraits (~612K tiles) 578ms / 594MB 45ms / 134MB 13× / 4.4×
world (~2M tiles) 2333ms / 2128MB 150ms / 553MB 15× / 3.8×
giantworldmap (~8M tiles) 10701ms / 9300MB 635ms / 2509MB 17× / 3.7×

Changes (one commit each):

  • --workers flag: bounds concurrent map processing to limit peak memory
  • Flat []bool visited sets: replaced map[string]bool keyed by fmt.Sprintf with flat []bool indexed x*height+y — the dominant cost
  • neighborCoords with stack buffer: eliminates per-call slice allocation for neighbour lookups
  • Terrain struct 24→16 bytes: field reorder + uint8 type for TerrainType
  • Nil buffers early: releases image/terrain arrays as soon as they're no longer needed
  • BFS mark-visited on push: each tile enters the queue once instead of up to 4×, halving queue memory

Also fixes a bug: createMiniMap downscales by averaging/sampling 2x2 blocks, copying field values across — including Ocean=true from the parent scale. When a single connected ocean at 1x splits into multiple disconnected bodies at 4x (because narrow water channels disappear when you halve resolution), those smaller fragments still carry Ocean=true from the carryover. The 4x processWater call picks the new largest fragment and sets it to Ocean=true, but never clears the others — so multiple disconnected bodies end up flagged as Ocean.

Fix: before the new BFS pass, zero out every Ocean flag, so only the truly-largest body at the current scale ends up marked. This is a real semantic change — the on-disk .bin files will differ from main on any map where ocean splits across downscaling.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

alexjurkiewicz

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 6, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Walkthrough

Adds a CLI --workers flag and a semaphore to bound concurrent map processing; refactors terrain data layout, neighbor discovery, BFS-based water/land processing, shoreline logic, packing order, and explicit buffer nil-ing for lower memory pressure.

Changes

Map generation + concurrency limit

Layer / File(s) Summary
Data Shape
map-generator/map_generator.go
TerrainType changed from int to uint8. Terrain fields reordered (now Magnitude, Type, Shoreline) and an Ocean field added.
Core Neighbor Utility
map-generator/map_generator.go
Added neighborCoords(out *[4]coord, x, y, w, h) int to populate up to 4 orthogonal neighbors into a caller buffer.
Shoreline Calculation
map-generator/map_generator.go
processShore rewritten to use preallocated buffers and neighborCoords for neighbor iteration; sets shoreline flags for land and water tiles.
Water / Land BFS & Visitation
map-generator/map_generator.go
Replaced map-based visitation with flat []bool visited slices; processWater, getArea, and removeSmallIslands updated to use BFS over visited[].
Packing & Multi-scale Flow
map-generator/map_generator.go
Packing order changed: 1x packed first and retained, terrain buffers nilled, then 4x and 16x packed and released. Water magnitude packing capped at 10.
Resource Management
map-generator/map_generator.go
Explicitly nil out image and buffer references (img = nil, args.ImageBuffer = nil, terrain = nil, etc.) after use to assist GC.
Concurrency Flag & Comment
map-generator/main.go
Introduced package-level workersFlag int and added a comment describing concurrency bounds and behavior before loadTerrainMaps.
Concurrency Enforcement
map-generator/main.go
loadTerrainMaps now creates sem := make(chan struct{}, workersFlag); per-map goroutines acquire (sem <- struct{}{}) and release (<-sem) the semaphore. Added CLI flag --workers with a default (4).

Sequence Diagram(s)

sequenceDiagram
    participant CLI
    participant Loader as loadTerrainMaps
    participant Semaphore as sem(chan rgba(0,128,255,0.5))
    participant Worker as mapWorker
    participant Generator as MapGenerator

    CLI->>Loader: start (reads --workers)
    Loader->>Semaphore: make(chan struct{}, workersFlag)
    Loader->>Worker: spawn goroutine per map
    Worker->>Semaphore: acquire (sem <- struct{}{})
    Worker->>Generator: process map (processWater, processShore, pack)
    Generator-->>Worker: packed map data
    Worker->>Semaphore: release (<-sem)
    Worker-->>Loader: done
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

Workers stand in quiet line,
Shores are drawn and small isles shine.
Buffers sleep, the packer sings,
Semaphore opens worker wings.
Maps step out on bounded springs.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf(map-generator): major CPU and memory optimisations' clearly and specifically summarizes the main changes—performance and memory improvements to the map generator.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description clearly relates to the changeset, detailing six performance optimizations with measured improvements across three test maps and listing specific implementation changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

alexjurkiewicz and others added 6 commits May 6, 2026 17:47
…ssing

Previously all maps were processed concurrently with no limit, so all
goroutines held their terrain data simultaneously causing peak RSS to
spike on large batches. The new --workers flag (default: 4) uses a
semaphore to cap true concurrency, keeping peak memory predictable.

Note: GOMAXPROCS=1 only limits CPU threads; it does not prevent multiple
goroutines from holding terrain arrays in memory at the same time.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…]bool

BFS area-finding in getArea, processWater and removeSmallIslands used a
map[string]bool keyed by fmt.Sprintf("%d,%d", x, y). On large maps this
map held millions of string keys, with every lookup and insert paying the
cost of string allocation and hash comparison.

Replaced with a flat []bool of size width*height, indexed by x*height+y.
Single allocation, O(1) access, zero GC pressure.

Benchmark (3 runs, -benchmem):
  bosphorusstraits (~612K tiles):  578ms → 105ms  (5.5×), 594MB → 458MB, 11.5M → 3.1M allocs
  world            (~2M tiles):   2333ms → 320ms  (7.3×), 2128MB → 1658MB, 42.4M → 10.0M allocs
  giantworldmap    (~8M tiles):  10701ms → 1333ms  (8.0×), 9300MB → 7196MB, 182.7M → 40.0M allocs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…te slice allocs

getNeighborCoords allocated a new []Coord on every call (up to 4 elements),
and getNeighbors allocated a further []Terrain slice on top. Both were called
millions of times per map — in processShore (once per tile) and getArea (once
per queued tile in every BFS traversal).

Inlined both functions at their two call sites:
- processShore: replaced neighbor slice iteration with 4 direct bounds-checked
  comparisons using short-circuit evaluation.
- getArea: replaced getNeighborCoords + queue append with 4 conditional appends.

Removed getNeighbors and getNeighborCoords entirely.

Benchmark vs previous commit (3 runs, -benchmem):
  bosphorusstraits (~612K tiles):  105ms → 52ms  (2.0×), 458MB → 254MB, 3.1M → 664K allocs
  world            (~2M tiles):   320ms → 170ms  (1.9×), 1658MB → 989MB, 10.0M → 2.2M allocs
  giantworldmap    (~8M tiles):  1333ms → 734ms  (1.8×), 7196MB → 4516MB, 40.0M → 8.6M allocs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…der and uint8 type

Two changes with no semantic impact:
- TerrainType: int (8 bytes) → uint8 (1 byte). Only two values (Land=0,
  Water=1) are ever used, well within uint8 range.
- Struct field order: Magnitude float64 moved first so it sits at offset 0
  (natural 8-byte alignment). The three 1-byte fields follow with 5 bytes of
  tail padding, giving 16 bytes vs 24 with the original layout.

No casts or logic changes required.

Benchmark vs previous commit (3 runs, -benchmem):
  bosphorusstraits (~612K tiles):   52ms →  44ms (1.2×), 254MB → 240MB
  world            (~2M tiles):    170ms → 148ms (1.2×), 989MB → 946MB
  giantworldmap    (~8M tiles):    734ms → 635ms (1.2×), 4516MB → 4339MB

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…no longer needed

The PNG image buffer and decoded image.Image are only needed for the pixel
classification loop. The three terrain grids (1x, 4x, 16x) are each only
needed until their packTerrain call completes.

Nil-ing them immediately allows the GC to reclaim the memory sooner. This
has no measurable effect in single-map benchmarks (GC cleans up at the same
time either way), but meaningfully reduces peak RSS when multiple maps are
processed concurrently via --workers, since goroutines no longer hold
finished terrain arrays while waiting for others to complete.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously getArea pushed all neighbours onto the BFS queue without
checking visited first. This allowed the same tile to be enqueued up
to 4 times (once per neighbour that discovers it), bloating the queue
to ~4× the actual region size and wasting matching pop iterations.

Fix: mark each tile visited immediately before pushing, so each tile
enters the queue at most once.

Benchmark vs previous commit (3 runs, -benchmem):
  bosphorusstraits (~612K tiles):  49.7ms → 44.9ms (1.1×), 240MB → 134MB (1.8×), 664K → 661K allocs
  world            (~2M tiles):   165ms  → 150ms  (1.1×), 946MB → 553MB (1.7×), 2.2M → 2.2M allocs
  giantworldmap    (~8M tiles):   706ms  → 635ms  (1.1×), 4339MB → 2508MB (1.7×), 8.6M → 8.6M allocs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@alexjurkiewicz alexjurkiewicz force-pushed the feat/map-generator-optimisations branch from 9c5be57 to 644c432 Compare May 6, 2026 09:48
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
map-generator/main.go (1)

263-275: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-positive --workers values before using the semaphore.

--workers=0 deadlocks on sem <- struct{}{}, and negative values panic in make(chan struct{}, workersFlag). Please validate this flag as >= 1 before loadTerrainMaps starts.

Suggested fix
 func loadTerrainMaps() error {
+	if workersFlag < 1 {
+		return fmt.Errorf("--workers must be >= 1, got %d", workersFlag)
+	}
+
 	selectedMaps, err := parseMapsFlag()
 	if err != nil {
 		return err
 	}
 	var wg sync.WaitGroup

Also applies to: 304-304

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/main.go` around lines 263 - 275, Reject invalid worker counts
by validating the workersFlag (or equivalent flag variable) is >= 1 before
creating the semaphore or calling loadTerrainMaps: check the flag early (e.g.,
in main or before sem := make(chan struct{}, workersFlag)) and if workersFlag <
1 return/exit with a clear error message so you never call make(chan struct{},
workersFlag) or attempt sem <- struct{}{} with 0 or negative capacity; update
any other uses (e.g., the loadTerrainMaps invocation) to assume a validated
positive worker count.
🧹 Nitpick comments (1)
map-generator/map_generator.go (1)

553-587: ⚡ Quick win

Update the pack-format comment to match the new water encoding.

Lines 558-559 still describe water magnitude as a 0-31 value, but Line 586 now clamps encoded water depth to 10. Please update the comment here so the binary format is not misleading.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` around lines 553 - 587, The packTerrain
function's header comment is out of sync: update the binary format comment to
reflect that water magnitude is encoded 0-10 (not 0-31) because the code clamps
water depth to 10; specifically edit the comment lines describing "Bits 0-4:
Magnitude (0-31). For Water, this is (Distance / 2)." to indicate bits 0-4 hold
magnitude for land (0-31) but for water the stored magnitude is clamped to 0-10
(computed as Distance/2), so the comment matches the encoding in packTerrain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@map-generator/main.go`:
- Around line 263-275: Reject invalid worker counts by validating the
workersFlag (or equivalent flag variable) is >= 1 before creating the semaphore
or calling loadTerrainMaps: check the flag early (e.g., in main or before sem :=
make(chan struct{}, workersFlag)) and if workersFlag < 1 return/exit with a
clear error message so you never call make(chan struct{}, workersFlag) or
attempt sem <- struct{}{} with 0 or negative capacity; update any other uses
(e.g., the loadTerrainMaps invocation) to assume a validated positive worker
count.

---

Nitpick comments:
In `@map-generator/map_generator.go`:
- Around line 553-587: The packTerrain function's header comment is out of sync:
update the binary format comment to reflect that water magnitude is encoded 0-10
(not 0-31) because the code clamps water depth to 10; specifically edit the
comment lines describing "Bits 0-4: Magnitude (0-31). For Water, this is
(Distance / 2)." to indicate bits 0-4 hold magnitude for land (0-31) but for
water the stored magnitude is clamped to 0-10 (computed as Distance/2), so the
comment matches the encoding in packTerrain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 87a9401b-b196-4d13-9236-67880196c6ad

📥 Commits

Reviewing files that changed from the base of the PR and between c0b9764 and 9c5be57.

📒 Files selected for processing (3)
  • map-generator/main.go
  • map-generator/map-generator
  • map-generator/map_generator.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
map-generator/map_generator.go (1)

397-440: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear inherited Ocean flags before reclassifying water.

createMiniMap copies Terrain structs from the previous scale, so 4x/16x tiles can arrive here with Ocean=true already set. This function only marks the new largest body as ocean, so stale Ocean bits can survive on smaller lakes and get serialized into packTerrain.

Suggested fix
func processWater(ctx context.Context, terrain [][]Terrain, removeSmall bool) {
	logger := LoggerFromContext(ctx)
	logger.Info("Processing water bodies")
	width := len(terrain)
	height := len(terrain[0])
	visited := make([]bool, width*height)
+
+	for x := 0; x < width; x++ {
+		for y := 0; y < height; y++ {
+			terrain[x][y].Ocean = false
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` around lines 397 - 440, The code fails to
clear existing Terrain.Ocean flags before reclassifying the largest water body,
causing stale Ocean=true values to persist; modify the createMiniMap flow (where
terrain, visited, getArea and waterBodies are used) to first iterate the entire
terrain grid and set terrain[x][y].Ocean = false for every cell (or at least for
every Water cell) before computing waterBodies and marking
largestWaterBody.coords as Ocean; ensure you reference the same terrain slice
and Coord struct so the cleared flags are the ones later set on
largestWaterBody.
map-generator/main.go (1)

256-275: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-positive --workers values.

make(chan struct{}, workersFlag) panics for negatives, and --workers=0 deadlocks here because every goroutine blocks on sem <- struct{}{} before it can reach the deferred receive. Please validate workersFlag >= 1 before building the semaphore.

Suggested fix
func loadTerrainMaps() error {
+	if workersFlag < 1 {
+		return fmt.Errorf("--workers must be >= 1")
+	}
+
 	selectedMaps, err := parseMapsFlag()
 	if err != nil {
 		return err
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/main.go` around lines 256 - 275, The code creates a buffered
semaphore with make(chan struct{}, workersFlag) in loadTerrainMaps but doesn't
validate workersFlag, so negative values panic and zero deadlocks; add a
validation at the start of loadTerrainMaps (or immediately after parseMapsFlag)
to require workersFlag >= 1 and return a descriptive error (e.g., "invalid
--workers value: must be >= 1") when it is not, before constructing sem; keep
the semaphore logic (sem := make(chan struct{}, workersFlag)) and goroutine
usage unchanged once validation is in place.
🧹 Nitpick comments (1)
map-generator/map_generator.go (1)

177-182: ⚡ Quick win

Pack and release each scale at its first safe point.

terrain stays live until after 4x/16x generation and WebP encoding, even though it is no longer needed once terrain4x exists apart from its own packing step. Reordering the pack/nil steps would drop the largest grid earlier and reduce peak RSS more on the big maps this PR targets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` around lines 177 - 182, The large terrain
grids are kept live too long; after calling packTerrain for each scale
(packTerrain(ctx, terrain), packTerrain(ctx, terrain4x), packTerrain(ctx,
terrain16x)) immediately set the corresponding source grid variable to nil
(terrain = nil right after packing terrain, terrain4x = nil right after packing
mapData4x) so each large slice can be GC'd before the next-scale generation and
WebP encoding; locate the packTerrain calls and reorder/insert the nil
assignments so each scale is cleared as soon as its packed
mapData/mapNumLandTiles are produced, and ensure later code reads only
mapData/mapNumLandTiles variables.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@map-generator/main.go`:
- Around line 256-275: The code creates a buffered semaphore with make(chan
struct{}, workersFlag) in loadTerrainMaps but doesn't validate workersFlag, so
negative values panic and zero deadlocks; add a validation at the start of
loadTerrainMaps (or immediately after parseMapsFlag) to require workersFlag >= 1
and return a descriptive error (e.g., "invalid --workers value: must be >= 1")
when it is not, before constructing sem; keep the semaphore logic (sem :=
make(chan struct{}, workersFlag)) and goroutine usage unchanged once validation
is in place.

In `@map-generator/map_generator.go`:
- Around line 397-440: The code fails to clear existing Terrain.Ocean flags
before reclassifying the largest water body, causing stale Ocean=true values to
persist; modify the createMiniMap flow (where terrain, visited, getArea and
waterBodies are used) to first iterate the entire terrain grid and set
terrain[x][y].Ocean = false for every cell (or at least for every Water cell)
before computing waterBodies and marking largestWaterBody.coords as Ocean;
ensure you reference the same terrain slice and Coord struct so the cleared
flags are the ones later set on largestWaterBody.

---

Nitpick comments:
In `@map-generator/map_generator.go`:
- Around line 177-182: The large terrain grids are kept live too long; after
calling packTerrain for each scale (packTerrain(ctx, terrain), packTerrain(ctx,
terrain4x), packTerrain(ctx, terrain16x)) immediately set the corresponding
source grid variable to nil (terrain = nil right after packing terrain,
terrain4x = nil right after packing mapData4x) so each large slice can be GC'd
before the next-scale generation and WebP encoding; locate the packTerrain calls
and reorder/insert the nil assignments so each scale is cleared as soon as its
packed mapData/mapNumLandTiles are produced, and ensure later code reads only
mapData/mapNumLandTiles variables.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bdb9bca6-edb2-40c5-bd37-f0cb552eb5c6

📥 Commits

Reviewing files that changed from the base of the PR and between 9c5be57 and 644c432.

📒 Files selected for processing (3)
  • map-generator/main.go
  • map-generator/map-generator
  • map-generator/map_generator.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 6, 2026
alexjurkiewicz and others added 2 commits May 6, 2026 18:34
--workers=0 would deadlock every goroutine on sem <- struct{}{} before
it could proceed. Negative values would panic in make(chan struct{}, n).
Add an early check in loadTerrainMaps and return a clear error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
createMiniMap copies Terrain structs directly, so downscaled grids can
inherit Ocean=true from the previous scale. processWater only marks the
new largest body as ocean and never cleared existing flags, meaning small
lakes adjacent to ocean tiles at a finer scale could retain Ocean=true
in the packed output, incorrectly making them ocean rather than lake in
the game.

Fix by resetting all Ocean flags to false at the start of processWater,
before any body classification occurs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
map-generator/map_generator.go (4)

479-506: 💤 Low value

BFS visit-on-push is correct here; one tiny note on queue growth.

Marking visited when pushing (line 498) is right: a tile can never be enqueued twice because the only path to enqueue it is via an unvisited check. Off-type neighbours getting marked is also harmless because the outer loops in processWater and removeSmallIslands only enter BFS on tiles of the matching type, and each call uses its own visited slice.

Optional: queue = queue[1:] keeps the original backing array alive until the slice is fully dropped, so peak queue memory is the sum of every tile ever pushed in a body, not the live frontier. For very large oceans on giantworldmap this can matter. A head-index pop avoids it:

♻️ Optional refactor
-	queue := []Coord{{X: x, Y: y}}
-
-	var buf [4]Coord
-	for len(queue) > 0 {
-		coord := queue[0]
-		queue = queue[1:]
+	queue := []Coord{{X: x, Y: y}}
+	head := 0
+
+	var buf [4]Coord
+	for head < len(queue) {
+		coord := queue[head]
+		head++
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` around lines 479 - 506, getArea currently
pops the queue with queue = queue[1:], which retains the underlying backing
array and can cause high peak memory for large BFS runs; change the queue
handling in getArea to use a head index (e.g., head := 0; push with queue =
append(queue, c); pop by reading queue[head] and incrementing head) and, to
avoid unbounded slice growth, periodically reset the slice when head grows large
(e.g., when head > len(queue)/2 or a fixed threshold) by doing queue =
queue[head:] and head = 0; update references in getArea (queue, head) and keep
visited/neighbor logic the same.

343-343: 💤 Low value

Move directions to a package-level var (or drop it entirely here).

This 4-element slice is rebuilt on every processDistToLand call. Tiny win, but you already use neighborCoords elsewhere — using it here too would also unify the neighbour-iteration style across the file.

♻️ Suggested change
-	directions := []Coord{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}
-
-	for len(queue) > 0 {
-		current := queue[0]
-		queue = queue[1:]
-
-		for _, dir := range directions {
-			nx := current.x + dir.X
-			ny := current.y + dir.Y
-
-			if nx >= 0 && ny >= 0 && nx < width && ny < height &&
-				!visited[nx][ny] && terrain[nx][ny].Type == Water {
-
-				visited[nx][ny] = true
-				terrain[nx][ny].Magnitude = float64(current.dist + 1)
-				queue = append(queue, queueItem{x: nx, y: ny, dist: current.dist + 1})
-			}
-		}
-	}
+	var buf [4]Coord
+	for len(queue) > 0 {
+		current := queue[0]
+		queue = queue[1:]
+
+		n := neighborCoords(current.x, current.y, width, height, &buf)
+		for _, c := range buf[:n] {
+			if !visited[c.X][c.Y] && terrain[c.X][c.Y].Type == Water {
+				visited[c.X][c.Y] = true
+				terrain[c.X][c.Y].Magnitude = float64(current.dist + 1)
+				queue = append(queue, queueItem{x: c.X, y: c.Y, dist: current.dist + 1})
+			}
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` at line 343, The local slice directions :=
[]Coord{{0, 1}, {1, 0}, {0, -1}, {-1, 0}} inside processDistToLand is being
reallocated on every call; move it to a package-level var (e.g. var directions =
[]Coord{...}) or replace its usage with the existing neighborCoords symbol to
unify neighbor iteration. Update processDistToLand to reference the
package-level directions (or neighborCoords) instead of creating the slice
locally, and remove the local declaration to avoid repeated allocations.

419-419: 💤 Low value

Heads up: indexing convention differs between flat slices in this file.

getArea / processWater / removeSmallIslands use x*height+y, while packTerrain uses y*width+x (line 596). Both are internally consistent so there is no bug, but it is easy to copy-paste the wrong formula in future edits. Worth a one-liner comment near each, or pick one convention everywhere.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` at line 419,
getArea/processWater/removeSmallIslands currently index flat slices with
x*height+y while packTerrain uses y*width+x; to avoid future copy-paste bugs,
either standardize on one convention across the file or add a one-line
clarifying comment next to each function/usage (e.g., near getArea,
processWater, removeSmallIslands, and packTerrain) that states the exact
indexing formula used for the visited/slice (mentioning whether the slice is
row-major y*width+x or column-major x*height+y). Update any mismatched usages if
you choose to standardize so all references use the same formula (and adjust
variable names if needed) or leave code as-is but add the explicit comment
beside visited, packTerrain, and the listed functions.

433-439: ⚡ Quick win

Replace the hand-rolled bubble sort with sort.Slice.

O(n²) is fine when there are only a few water bodies, but for maps with many tiny lakes this is wasted work, and sort.Slice reads much easier. Same idea applies if you later sort landBodies too.

♻️ Suggested change
+	sort.Slice(waterBodies, func(i, j int) bool {
+		return waterBodies[i].size > waterBodies[j].size
+	})
-	for i := 0; i < len(waterBodies)-1; i++ {
-		for j := i + 1; j < len(waterBodies); j++ {
-			if waterBodies[j].size > waterBodies[i].size {
-				waterBodies[i], waterBodies[j] = waterBodies[j], waterBodies[i]
-			}
-		}
-	}

Add "sort" to the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` around lines 433 - 439, Replace the
hand-rolled O(n²) bubble sort loop that swaps elements of waterBodies with a
call to sort.Slice: add "sort" to the import block and call
sort.Slice(waterBodies, func(i, j int) bool { return waterBodies[i].size >
waterBodies[j].size }) so the slice is sorted by size descending; apply the same
pattern later if you sort landBodies as well.
map-generator/main.go (1)

273-287: 💤 Low value

Small tweak: acquire the semaphore before wg.Add / go to cap goroutine count too.

Right now each filtered map spawns a goroutine that immediately blocks on sem <- struct{}{}. With ~80 maps that is fine, but if the registry grows a lot you will still hold ~N goroutine stacks waiting. Acquiring the slot in the loop body (before go func()) keeps in-flight goroutines bounded by workersFlag. Totally optional given the current scale.

♻️ Optional refactor
 	for _, mapItem := range maps {
 		if selectedMaps != nil && !selectedMaps[mapItem.Name] {
 			continue
 		}
+		sem <- struct{}{}
 		wg.Add(1)
 		mapItem := mapItem
 		go func() {
 			defer wg.Done()
-			sem <- struct{}{}
 			defer func() { <-sem }()
 			mapLogTag := slog.String("map", mapItem.Name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/main.go` around lines 273 - 287, The loop spawns goroutines
that immediately block on sem <- struct{}{}, which still creates N waiting
goroutine stacks; move the semaphore acquire to before creating the goroutine to
bound in-flight goroutines. Specifically, perform sem <- struct{}{} in the loop
before calling wg.Add(1) and starting the goroutine, then inside the goroutine
keep defer wg.Done() and defer func() { <-sem }() to release the slot; keep
passing mapItem (or use a loop-local copy) and keep calling processMap and
sending errors to errChan as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@map-generator/main.go`:
- Around line 273-287: The loop spawns goroutines that immediately block on sem
<- struct{}{}, which still creates N waiting goroutine stacks; move the
semaphore acquire to before creating the goroutine to bound in-flight
goroutines. Specifically, perform sem <- struct{}{} in the loop before calling
wg.Add(1) and starting the goroutine, then inside the goroutine keep defer
wg.Done() and defer func() { <-sem }() to release the slot; keep passing mapItem
(or use a loop-local copy) and keep calling processMap and sending errors to
errChan as before.

In `@map-generator/map_generator.go`:
- Around line 479-506: getArea currently pops the queue with queue = queue[1:],
which retains the underlying backing array and can cause high peak memory for
large BFS runs; change the queue handling in getArea to use a head index (e.g.,
head := 0; push with queue = append(queue, c); pop by reading queue[head] and
incrementing head) and, to avoid unbounded slice growth, periodically reset the
slice when head grows large (e.g., when head > len(queue)/2 or a fixed
threshold) by doing queue = queue[head:] and head = 0; update references in
getArea (queue, head) and keep visited/neighbor logic the same.
- Line 343: The local slice directions := []Coord{{0, 1}, {1, 0}, {0, -1}, {-1,
0}} inside processDistToLand is being reallocated on every call; move it to a
package-level var (e.g. var directions = []Coord{...}) or replace its usage with
the existing neighborCoords symbol to unify neighbor iteration. Update
processDistToLand to reference the package-level directions (or neighborCoords)
instead of creating the slice locally, and remove the local declaration to avoid
repeated allocations.
- Line 419: getArea/processWater/removeSmallIslands currently index flat slices
with x*height+y while packTerrain uses y*width+x; to avoid future copy-paste
bugs, either standardize on one convention across the file or add a one-line
clarifying comment next to each function/usage (e.g., near getArea,
processWater, removeSmallIslands, and packTerrain) that states the exact
indexing formula used for the visited/slice (mentioning whether the slice is
row-major y*width+x or column-major x*height+y). Update any mismatched usages if
you choose to standardize so all references use the same formula (and adjust
variable names if needed) or leave code as-is but add the explicit comment
beside visited, packTerrain, and the listed functions.
- Around line 433-439: Replace the hand-rolled O(n²) bubble sort loop that swaps
elements of waterBodies with a call to sort.Slice: add "sort" to the import
block and call sort.Slice(waterBodies, func(i, j int) bool { return
waterBodies[i].size > waterBodies[j].size }) so the slice is sorted by size
descending; apply the same pattern later if you sort landBodies as well.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3da5d93c-c88b-4868-943d-91e9b11af03f

📥 Commits

Reviewing files that changed from the base of the PR and between 644c432 and af57676.

📒 Files selected for processing (2)
  • map-generator/main.go
  • map-generator/map_generator.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 6, 2026
visited slices use x*height+y (column-major, mirrors terrain[x][y]).
packTerrain output uses y*width+x (row-major, matches raster scan order).
Both are intentional; add a note to each to prevent confusion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@alexjurkiewicz
Copy link
Copy Markdown
Contributor Author

Workers deadlock/panic + stale Ocean flags: Both fixed in commits 642812bb and af576765 respectively.

Indexing inconsistency (x*height+y vs y*width+x): We introduced x*height+y for the visited slices; y*width+x in packTerrain was pre-existing. A [][]bool would unify the access pattern (visited[x][y] matching terrain[x][y]), but costs N+1 allocations vs 1 and loses cache locality on large BFS traversals. Given the main win was eliminating string hashing, the flat slice was retained for efficiency. Added clarifying comments to both in commit 4a37df88.

BFS head-index to reduce peak memory: Acknowledged, but the map generator is a CLI batch tool and peak memory is already significantly reduced by the visit-on-push change. Declining for now.

directions slice rebuilt each call / bubble sort → sort.Slice: Both pre-date this PR. Out of scope.

Acquire semaphore before spawning goroutine: At ~80 maps goroutine stacks are negligible. The acquire-inside pattern is idiomatic Go. Leaving as-is.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
map-generator/map_generator.go (1)

496-501: ⚡ Quick win

Filter neighbors by targetType before enqueueing in getArea.

Right now, Line 499 marks non-target neighbors as visited and queues them, then they are discarded when popped. This adds avoidable queue churn on big maps.

♻️ Suggested change
 			n := neighborCoords(coord.X, coord.Y, width, height, &buf)
 			for _, c := range buf[:n] {
-				if !visited[c.X*height+c.Y] {
-					visited[c.X*height+c.Y] = true
-					queue = append(queue, c)
-				}
+				idx := c.X*height + c.Y
+				if !visited[idx] && terrain[c.X][c.Y].Type == targetType {
+					visited[idx] = true
+					queue = append(queue, c)
+				}
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@map-generator/map_generator.go` around lines 496 - 501, In getArea, avoid
marking and enqueueing neighbors that aren't the desired tile type: when
iterating neighbors from neighborCoords (buf[:n]), first check that the
neighbor's tile matches targetType before setting visited[c.X*height+c.Y] and
appending to queue; leave non-matching neighbors unvisited so they won't
generate unnecessary queue churn. Ensure you still prevent re-enqueueing by
using the visited map only for neighbors that pass the targetType check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@map-generator/map_generator.go`:
- Around line 496-501: In getArea, avoid marking and enqueueing neighbors that
aren't the desired tile type: when iterating neighbors from neighborCoords
(buf[:n]), first check that the neighbor's tile matches targetType before
setting visited[c.X*height+c.Y] and appending to queue; leave non-matching
neighbors unvisited so they won't generate unnecessary queue churn. Ensure you
still prevent re-enqueueing by using the visited map only for neighbors that
pass the targetType check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0a9b1bfd-10fa-4bd3-b597-a51e7015dc3e

📥 Commits

Reviewing files that changed from the base of the PR and between af57676 and 4a37df8.

📒 Files selected for processing (1)
  • map-generator/map_generator.go

Copy link
Copy Markdown
Collaborator

@evanpelle evanpelle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@github-project-automation github-project-automation Bot moved this from Triage to Final Review in OpenFront Release Management May 6, 2026
@evanpelle evanpelle added this to the v31 milestone May 6, 2026
@evanpelle evanpelle merged commit b0572ae into openfrontio:main May 6, 2026
8 of 13 checks passed
@github-project-automation github-project-automation Bot moved this from Final Review to Complete in OpenFront Release Management May 6, 2026
evanpelle pushed a commit that referenced this pull request May 6, 2026
## Description:

Five performance improvements to the map generator, measured on three
maps of increasing size. End-to-end time on `world` improved ~15×, heap
allocations ~19×.

| Map | Before | After | Speedup |
|-----|--------|-------|---------|
| bosphorusstraits (~612K tiles) | 578ms / 594MB | 45ms / 134MB | 13× /
4.4× |
| world (~2M tiles) | 2333ms / 2128MB | 150ms / 553MB | 15× / 3.8× |
| giantworldmap (~8M tiles) | 10701ms / 9300MB | 635ms / 2509MB | 17× /
3.7× |

Changes (one commit each):
- **`--workers` flag**: bounds concurrent map processing to limit peak
memory
- **Flat `[]bool` visited sets**: replaced `map[string]bool` keyed by
`fmt.Sprintf` with flat `[]bool` indexed `x*height+y` — the dominant
cost
- **`neighborCoords` with stack buffer**: eliminates per-call slice
allocation for neighbour lookups
- **`Terrain` struct 24→16 bytes**: field reorder + `uint8` type for
`TerrainType`
- **Nil buffers early**: releases image/terrain arrays as soon as
they're no longer needed
- **BFS mark-visited on push**: each tile enters the queue once instead
of up to 4×, halving queue memory


also fixes a bug (according to Claude):

Here's the bug: createMiniMap downscales by averaging/sampling 2x2
blocks, copying field values across — including Ocean=true from the
parent scale. When a single connected ocean at 1x splits into multiple
disconnected bodies at 4x (because narrow water channels disappear when
you halve resolution), those smaller fragments still carry Ocean=true
from the carryover. The 4x processWater call picks the new largest
fragment and sets it to Ocean=true, but never clears the others — so
multiple disconnected bodies end up flagged as Ocean.

This PR's fix: before the new BFS pass, zero out every Ocean flag, so
only the truly-largest body at the current scale ends up marked.

It's incidental to the perf work but it's a real semantic change — the
on-disk .bin files will differ from main on any map where ocean splits
across downscaling. The PR doesn't mention it, which is why I flagged
it.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

3 participants