Fix late-game slowdown from boxed-in wanderers; A* scratch buffers; bench mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 13:32 UTC
commit
bef876c2ecbf985ded94e492a04da5b256ea1baa
parent
4acafd45be
tree
browse at this commit

5 files changed +106 -26

CLAUDE.md +5 -1

@@ -55,11 +55,15 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
55 55 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
56 56
57 57 ## Performance notes
58 -- Native profiling: temporarily add a Startup system writing `Action::SetSpeed(SimSpeed::Max)` in sim.rs, `CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release`, run, `sample <pid> 15 -file out.txt`, aggregate `simulator::` symbols (revert the hack).
58 +- Benchmark: `SIM_BENCH=1 ./target/release/simulator` (native only) loads `simulator-save.json` (F9 path), sets Max and logs `bench: N us per tick, M game days per 10 s` every 10 s after a 5 s warmup. Use µs/tick: game days per 10 s depends on frame pacing, and macOS throttles the window once it's covered. Profile with `CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release` + `sample <pid> 10 -file out.txt` (rerun if it captures only a handful of samples); sample late in a run too — slowdowns can build up.
59 59 - Hot spots fixed so far: predicate BFS scanning items per tile (use `nearest_of` with precollected sets), searches that cannot match (pre-check wanted kinds, empty farm sets), choose_jobs setup skipped unless someone is ready (idle timer ticked in a pre-pass), periodic passes over only `WorldMap::worn_tiles` / `growing_plants`, light-driven tilemap re-tints rate-limited to 100 ms, `BuildContext.occupied` set.
60 60 - `BuildKind::Floor` (1 wood, 90 work): not blocked by structures (like Roof), sets Terrain::WoodFloor; Pave/Floor blueprints don't hold back plan_shelter.
61 61 - Lighting recomputes on `WorldMap::shape_version()` (bumped only by set_wall/set_roof changes), not on every TileChanged — flooring 100+ tiles made it a full-map pass per tile.
62 62 - `search_reach` is the flood's half-width (√max/2); it was the full width, so nearest_of's precheck let far targets through and every haul search swept its budget for map-edge stacks. `ColonyReach` resource (Option<Reach>, refreshed by plan_homes every 300 ticks, cleared on WorldGenerated) filters find_haul stacks and blueprint sites (`allows`, `allows_near`). Animal migration uses `zones.home` instead of `spawn_tiles` (full largest-region scan).
63 +- Fixed schedules (FixedFirst..FixedLast) use `SingleThreadedExecutor`: with hundreds of tiny ticks per frame the multi-threaded executor spent most time parking/handing off between threads (5.6× faster natively; wasm was already single-threaded).
64 +- A* (`astar_limited`) reuses thread-local buffers with generation stamps (no 800 KB clear per call); `find_nearby_path(.., WANDER_SEARCH 800)` caps wander searches. Failed wander attempts (animals, colonists) still restart the idle timer and failed flees wait FLEE_RETRY_SECS — retrying every tick for boxed-in animals made late games ~15× slower per tick.
65 +- plan_homes caches its reach flood by (`shape_version`, colony home, size); `WorldMap::set` bumps `shape_version` when walkability changes. `GatherMemo` also memoises forage (food mask) and haul failures (HAUL_RETRY_TICKS 120, reset when Zones change — checked before choose_jobs' early return). choose_jobs builds `occupied` lazily; run_jobs only collects mover positions while someone builds a wall.
66 +- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s.
63 67 - Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time).
64 68 - Searches and A* share `can_step` (no corner cutting): a target the BFS could reach diagonally but A* couldn't made A* exhaust the map (~10 ms) many times a second on a 50-day save. Mined yields drop on the miner's tile when the site is only diagonally reachable.
65 69 - Profiling a saved colony: copy the browser save to `simulator-save.json` (gitignored; the native build reads it with F9), temporarily add a Startup system writing `Action::Load` + `SetSpeed(Max)`. `sample` sometimes captures only a handful of samples on the first try — rerun.

src/animals.rs +52 -12

@@ -38,6 +38,7 @@ const BREED_CHANCE: f32 = 0.5;
38 38 const BREED_INTERVAL_TICKS: u64 = 24 * TICKS_PER_HOUR;
39 39 pub const FLEE_RANGE: i32 = 4;
40 40 const FLEE_DISTANCE: f32 = 7.0;
41 +const FLEE_RETRY_SECS: f32 = 1.0;
41 42 const IDLE_SECS: Range<f32> = 2.0..8.0;
42 43 const MIN_DISTANCE_FROM_COLONY: f32 = 25.0;
43 44 const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR;
@@ -123,11 +124,11 @@ pub struct Animal {
123 124 pub struct Tame;
124 125
125 126 #[derive(Component, Debug)]
126 -pub struct AnimalIdle(Timer);
127 +pub struct AnimalIdle(Timer, f32);
127 128
128 129 impl Default for AnimalIdle {
129 130 fn default() -> Self {
130 - Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once))
131 + Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once), 0.0)
131 132 }
132 133 }
133 134
@@ -394,23 +395,28 @@ pub fn wander(
394 395 && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
395 396 {
396 397 path.0 = route.into();
397 - idle.0
398 - .set_duration(Duration::from_secs_f32(rng.range_f32(IDLE_SECS)));
399 - idle.0.reset();
400 398 }
399 + idle.0
400 + .set_duration(Duration::from_secs_f32(rng.range_f32(IDLE_SECS)));
401 + idle.0.reset();
401 402 continue;
402 403 }
403 404 let threats: &[UVec2] = if predator.is_some() { &[] } else { &threats };
404 - let heading_into_danger = path
405 - .0
406 - .back()
407 - .is_none_or(|end| flee_goal(*end, threats).is_some());
405 + idle.1 = (idle.1 - time.delta_secs()).max(0.0);
406 + let ready = idle.1 <= 0.0;
407 + let heading_into_danger = ready
408 + && path
409 + .0
410 + .back()
411 + .is_none_or(|end| flee_goal(*end, threats).is_some());
408 412 if heading_into_danger && let Some(goal) = flee_goal(tile.0, threats) {
409 413 let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2();
410 414 if let Some(route) = find_nearby_path(&map, tile.0, goal, WANDER_SEARCH) {
411 415 path.0 = route.into();
412 416 continue;
413 417 }
418 + path.0.truncate(1);
419 + idle.1 = FLEE_RETRY_SECS;
414 420 }
415 421 if !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
416 422 continue;
@@ -424,10 +430,10 @@ pub fn wander(
424 430 && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
425 431 {
426 432 path.0 = route.into();
427 - let secs = rng.range_f32(IDLE_SECS);
428 - idle.0.set_duration(Duration::from_secs_f32(secs));
429 - idle.0.reset();
430 433 }
434 + let secs = rng.range_f32(IDLE_SECS);
435 + idle.0.set_duration(Duration::from_secs_f32(secs));
436 + idle.0.reset();
431 437 }
432 438 }
433 439
@@ -507,4 +513,38 @@ mod tests {
507 513 }
508 514 assert!(!app.world().get::<Path>(idle).unwrap().0.is_empty());
509 515 }
516 +
517 + // A boxed-in animal can't reach any wander goal. It used to retry on the very next tick,
518 + // forever; once the colony walled in a few animals, those failing searches ran every tick and
519 + // made late games ten times slower. Failed attempts now wait out the idle time too.
520 + #[test]
521 + fn boxed_in_animals_wait_before_trying_again() {
522 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
523 + for d in [-1, 0, 1] {
524 + for (x, y) in [(d, -1), (d, 1), (-1, d), (1, d)] {
525 + map.set_wall(UVec2::new((10 + x) as u32, (10 + y) as u32), true);
526 + }
527 + }
528 + let mut app = App::new();
529 + app.insert_resource(map)
530 + .insert_resource(SimRng::new(1))
531 + .init_resource::<Time>()
532 + .init_resource::<Zones>()
533 + .add_systems(Update, wander);
534 + let deer = app
535 + .world_mut()
536 + .spawn((
537 + Animal {
538 + kind: AnimalKind::Deer,
539 + },
540 + TilePos(UVec2::splat(10)),
541 + ))
542 + .id();
543 + app.world_mut()
544 + .resource_mut::<Time>()
545 + .advance_by(Duration::from_secs(10));
546 + app.update();
547 + let idle = app.world().get::<AnimalIdle>(deer).unwrap();
548 + assert!(!idle.0.is_finished());
549 + }
510 550 }

src/jobs/mod.rs +16 -7

@@ -1308,11 +1308,7 @@ fn choose_jobs(
1308 1308 .map(|(_, _, pos)| pos.0)
1309 1309 .collect();
1310 1310 let hour = hour_of_day(*tick);
1311 - let mut occupied: HashSet<UVec2> = structures_list
1312 - .iter()
1313 - .map(|(pos, ..)| *pos)
1314 - .chain(items.iter().map(|(_, _, pos)| pos.0))
1315 - .collect();
1311 + let mut occupied: Option<HashSet<UVec2>> = None;
1316 1312 for (
1317 1313 entity,
1318 1314 tile,
@@ -1439,6 +1435,13 @@ fn choose_jobs(
1439 1435 {
1440 1436 Some(Task::Equip { pickup })
1441 1437 } else {
1438 + let occupied = occupied.get_or_insert_with(|| {
1439 + structures_list
1440 + .iter()
1441 + .map(|(pos, ..)| *pos)
1442 + .chain(items.iter().map(|(_, _, pos)| pos.0))
1443 + .collect()
1444 + });
1442 1445 occupied.extend(planned.iter().map(|(pos, _)| *pos));
1443 1446 let build = BuildContext {
1444 1447 reach: &reach,
@@ -1449,7 +1452,7 @@ fn choose_jobs(
1449 1452 structures: &structures_list,
1450 1453 planned: &planned,
1451 1454 blueprints: &blueprints,
1452 - occupied: &occupied,
1455 + occupied,
1453 1456 };
1454 1457 let cooking = || {
1455 1458 (wanted(ItemKind::Meal) && input_source(&build, tile.0, Recipe::Meal).is_some())
@@ -1622,8 +1625,8 @@ fn choose_jobs(
1622 1625 find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
1623 1626 {
1624 1627 path.0 = wander_path.into();
1625 - idle.restart(rng.range_f32(IDLE_SECS));
1626 1628 }
1629 + idle.restart(rng.range_f32(IDLE_SECS));
1627 1630 }
1628 1631 }
1629 1632
@@ -1678,12 +1681,18 @@ fn run_jobs(
1678 1681 ) {
1679 1682 let (mut changed, mut ate) = messages;
1680 1683 let hour = hour_of_day(*tick);
1684 + let walling = colonists.iter().any(|c| {
1685 + c.3.0
1686 + .is_some_and(|j| matches!(j.task, Task::Build { kind, .. } if kind.blocks_tile()))
1687 + });
1681 1688 let occupied: Vec<UVec2> = colonists
1682 1689 .iter()
1690 + .filter(|_| walling)
1683 1691 .flat_map(|c| [Some(c.1.0), c.2.0.front().copied()])
1684 1692 .chain(
1685 1693 animals
1686 1694 .iter()
1695 + .filter(|_| walling)
1687 1696 .flat_map(|(t, p)| [Some(t.0), p.0.front().copied()]),
1688 1697 )
1689 1698 .flatten()

src/sim.rs +32 -6

@@ -62,16 +62,42 @@ fn start_bench(mut actions: MessageWriter<Action>) {
62 62 }
63 63
64 64 #[cfg(not(target_arch = "wasm32"))]
65 -fn report_bench(tick: Res<SimTick>, real: Res<Time<Real>>, mut last: Local<Option<(f32, u64)>>) {
65 +#[derive(Default)]
66 +struct BenchWindow {
67 + since: f32,
68 + ticks: u64,
69 + sim_secs: f32,
70 + last_tick: u64,
71 +}
72 +
73 +#[cfg(not(target_arch = "wasm32"))]
74 +fn report_bench(
75 + tick: Res<SimTick>,
76 + real: Res<Time<Real>>,
77 + clock: Res<SimClock>,
78 + mut window: Local<Option<BenchWindow>>,
79 +) {
66 80 let now = real.elapsed_secs();
67 81 if now < BENCH_WARMUP_SECS {
68 82 return;
69 83 }
70 - let (since, from) = *last.get_or_insert((now, tick.0));
71 - if now - since >= BENCH_REPORT_SECS {
72 - let days = (tick.0 - from) as f32 / (HOURS_PER_DAY * TICKS_PER_HOUR) as f32;
73 - info!("bench: {days:.2} game days per {BENCH_REPORT_SECS} s");
74 - *last = Some((now, tick.0));
84 + let window = window.get_or_insert_with(|| BenchWindow {
85 + since: now,
86 + last_tick: tick.0,
87 + ..default()
88 + });
89 + window.ticks += tick.0 - window.last_tick;
90 + window.last_tick = tick.0;
91 + window.sim_secs += clock.last_sim_secs;
92 + if now - window.since >= BENCH_REPORT_SECS {
93 + let days = window.ticks as f32 / (HOURS_PER_DAY * TICKS_PER_HOUR) as f32;
94 + let micros = window.sim_secs * 1e6 / window.ticks.max(1) as f32;
95 + info!("bench: {micros:.1} us per tick, {days:.2} game days per {BENCH_REPORT_SECS} s");
96 + *window = BenchWindow {
97 + since: now,
98 + last_tick: tick.0,
99 + ..default()
100 + };
75 101 }
76 102 }
77 103

tasks.md +1 -0

@@ -47,6 +47,7 @@
47 47 - [x] Connected wall rendering (wall tops join neighbouring walls and doors)
48 48 - [x] Skills: eight work skills set work speed, improve with practice, newcomers have a talent; poor-skill work is tried last
49 49 - [x] Wolves: predators hunt animals and colonists; health, bites, healing; adults fight wolves near the colony, children and the hurt flee; newcomers now walk to the colony instead of stranding at the edge
50 +- [x] Optimization pass: single-threaded fixed schedules, A* scratch buffers, capped wander searches, no retry-every-tick for boxed-in animals, cached reach, more search memos; native SIM_BENCH mode
50 51 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
51 52
52 53 ## Planned