diff --git a/analyze_bench.py b/analyze_bench.py new file mode 100644 index 0000000..e938b1a --- /dev/null +++ b/analyze_bench.py @@ -0,0 +1,70 @@ +import csv +import sys + +def analyze(filename, label): + durations = [] + lengths = [] + nodes = [] + failed = 0 + total = 0 + stutters = 0 + try: + with open(filename, 'r') as f: + reader = csv.reader(f) + next(reader) + for row in reader: + if not row or row[0].startswith('#'): + if 'total_paths' in ','.join(row) if row else '': + continue + continue + total += 1 + try: + d = int(row[1]) + durations.append(d) + lengths.append(int(row[2])) + nodes.append(int(row[3])) + if d > 1000: stutters += 1 + except: + pass + + if not durations: + return + durations.sort() + lengths.sort() + nodes.sort() + + p50 = durations[len(durations)//2] + p90 = durations[int(len(durations)*0.90)] + p99 = durations[int(len(durations)*0.99)] + max_d = durations[-1] + + avg_nodes = sum(nodes) / len(nodes) if nodes else 0 + avg_len = sum(lengths) / len(lengths) if lengths else 0 + + print(f"\n=== {label} ===") + print(f"Total paths: {total}") + print(f"P50: {p50}µs | P90: {p90}µs | P99: {p99}µs | Max: {max_d}µs") + print(f"Stutters (>1ms): {stutters} ({100*stutters/total:.2f}%)") + print(f"Avg nodes: {avg_nodes:.1f} | Avg length: {avg_len:.1f}") + print(f"Median length: {lengths[len(lengths)//2]}") + return durations, lengths, nodes, stutters, total + except Exception as e: + print(f"Error reading {filename}: {e}") + return None + +base = analyze("/home/popertots/bench/pathfinding_benchmark_baseline_release.csv", "ORIGINAL BASELINE (>6 months ago, 5x5 map)") +cur = analyze("pathfinding_benchmark_current.csv", "CURRENT (15x15 map, after all fixes)") + +if base and cur: + print("\n=== COMPARISON ===") + b_dur, b_len, b_nodes, b_stut, b_tot = base + c_dur, c_len, c_nodes, c_stut, c_tot = cur + + p50_imp = (b_dur[len(b_dur)//2] - c_dur[len(c_dur)//2]) / b_dur[len(b_dur)//2] * 100 + p99_imp = (b_dur[int(len(b_dur)*0.99)] - c_dur[int(len(c_dur)*0.99)]) / b_dur[int(len(b_dur)*0.99)] * 100 + stutter_imp = (b_stut - c_stut) / b_stut * 100 if b_stut > 0 else 0 + + print(f"P50 improvement: {p50_imp:+.1f}%") + print(f"P99 improvement: {p99_imp:+.1f}%") + print(f"Stutter reduction: {stutter_imp:+.1f}%") + print(f"Map size: 5x5 -> 15x15 (9x larger area)") diff --git a/check_lengths.py b/check_lengths.py new file mode 100644 index 0000000..4d0a0e6 --- /dev/null +++ b/check_lengths.py @@ -0,0 +1,47 @@ +import csv + +def check_lengths(filename, label): + lengths = [] + failed = 0 + total = 0 + with open(filename, 'r') as f: + reader = csv.reader(f) + next(reader) + for row in reader: + if not row or row[0].startswith('#'): + continue + total += 1 + try: + length = int(row[2]) + lengths.append(length) + if length == 0: + failed += 1 + except: + pass + + print(f"\n=== {label} ===") + print(f"Total paths: {total}") + print(f"Failed paths (length=0): {failed} ({100*failed/total if total else 0:.1f}%)") + print(f"Paths with length > 0: {total - failed}") + + if lengths: + avg = sum(lengths) / len(lengths) + print(f"Avg length (all): {avg:.1f}") + nonzero = [l for l in lengths if l > 0] + if nonzero: + print(f"Avg length (non-zero): {sum(nonzero)/len(nonzero):.1f}") + print(f"Max length: {max(lengths)}") + + # Length buckets + print("\nLength distribution:") + buckets = [(0, 0), (1, 5), (6, 10), (11, 50), (51, 100), (100, 1000)] + for lo, hi in buckets: + if lo == hi == 0: + count = sum(1 for l in lengths if l == 0) + else: + count = sum(1 for l in lengths if lo <= l <= hi) + pct = 100*count/len(lengths) if lengths else 0 + print(f" {lo}-{hi if hi < 1000 else 'inf'} nodes: {count} ({pct:.1f}%)") + +check_lengths("/home/popertots/bench/pathfinding_benchmark_baseline_release.csv", "BASELINE") +check_lengths("pathfinding_benchmark_current.csv", "CURRENT") diff --git a/check_nodes.py b/check_nodes.py new file mode 100644 index 0000000..2f7e14c --- /dev/null +++ b/check_nodes.py @@ -0,0 +1,36 @@ +import csv + +def check_nodes(filename, label): + nodes_list = [] + durations = [] + with open(filename, 'r') as f: + reader = csv.reader(f) + next(reader) + for row in reader: + if not row or row[0].startswith('#'): + continue + try: + nodes_list.append(int(row[3])) + durations.append(int(row[1])) + except: + pass + + print(f"\n=== {label} NODES EXPANDED ===") + print(f"Total paths: {len(nodes_list)}") + + # Group by node count + node_buckets = [(0, 0), (1, 64), (65, 200), (201, 500), (501, 1000), (1001, 10000)] + for lo, hi in node_buckets: + count = sum(1 for n in nodes_list if lo <= n <= hi) + pct = 100*count/len(nodes_list) if nodes_list else 0 + print(f" {lo}-{hi if hi < 10000 else 'inf'} nodes: {count} ({pct:.1f}%)") + + # Show duration correlation with nodes + print("\nDuration by node count:") + for lo, hi in [(1, 64), (65, 200), (201, 500), (501, 1000), (1001, 10000)]: + subset_durations = [d for n, d in zip(nodes_list, durations) if lo <= n <= hi] + if subset_durations: + avg = sum(subset_durations) / len(subset_durations) + print(f" {lo}-{hi if hi < 10000 else 'inf'} nodes: avg {avg:.0f}µs, count {len(subset_durations)}") + +check_nodes("pathfinding_benchmark_current.csv", "CURRENT") diff --git a/deep_analysis.py b/deep_analysis.py new file mode 100644 index 0000000..126a031 --- /dev/null +++ b/deep_analysis.py @@ -0,0 +1,42 @@ +import csv + +def analyze_tail(filename): + durations = [] + with open(filename, 'r') as f: + reader = csv.reader(f) + next(reader) + for row in reader: + if not row or row[0].startswith('#'): + continue + try: + durations.append(int(row[1])) + except: + pass + + durations.sort() + n = len(durations) + + print(f"\n=== TAIL ANALYSIS ({filename}) ===") + print(f"Total: {n}") + print(f"P50: {durations[n//2]}µs") + print(f"P90: {durations[int(n*0.90)]}µs") + print(f"P95: {durations[int(n*0.95)]}µs") + print(f"P99: {durations[int(n*0.99)]}µs") + print(f"P99.5: {durations[int(n*0.995)]}µs") + print(f"P99.9: {durations[int(n*0.999)]}µs") + print(f"Max: {durations[-1]}µs") + + # Count stutters + for threshold in [500, 1000, 2000, 3000, 5000]: + count = sum(1 for d in durations if d > threshold) + print(f"Paths > {threshold}µs: {count} ({100*count/n:.2f}%)") + + # Distribution buckets + print("\n=== DISTRIBUTION ===") + buckets = [(0, 50), (50, 100), (100, 200), (200, 500), (500, 1000), (1000, 2000), (2000, 5000), (5000, float('inf'))] + for lo, hi in buckets: + count = sum(1 for d in durations if lo <= d < hi) + print(f"{lo}-{hi if hi != float('inf') else '∞'}µs: {count} ({100*count/n:.1f}%)") + +analyze_tail("/home/popertots/bench/pathfinding_benchmark_baseline_release.csv") +analyze_tail("pathfinding_benchmark_current.csv") diff --git a/git_diff_24h.txt b/git_diff_24h.txt deleted file mode 100644 index d704679..0000000 --- a/git_diff_24h.txt +++ /dev/null @@ -1,1900 +0,0 @@ -diff --git a/Cargo.lock b/Cargo.lock -index d58b8d3..440fc3a 100644 ---- a/Cargo.lock -+++ b/Cargo.lock -@@ -400,9 +400,9 @@ dependencies = [ - - [[package]] - name = "avif-serialize" --version = "0.8.3" -+version = "0.8.8" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" -+checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" - dependencies = [ - "arrayvec", - ] -@@ -415,9 +415,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - - [[package]] - name = "bevy" --version = "0.18.0" -+version = "0.18.1" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "ec689b5a79452b6f777b889bbff22d3216b82a8d2ab7814d4a0eb571e9938d97" -+checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" - dependencies = [ - "bevy_internal", - ] -@@ -1175,9 +1175,9 @@ dependencies = [ - - [[package]] - name = "bevy_platform" --version = "0.18.0" -+version = "0.18.1" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "9b29ea749a8e85f98186ab662f607b885b97c804bb14cdb0cdf838164496d474" -+checksum = "ec6b36504169b644acd26a5469fd8d371aa6f1d73ee5c01b1b1181ae1cefbf9b" - dependencies = [ - "critical-section", - "foldhash 0.2.0", -@@ -1249,9 +1249,9 @@ checksum = "4f98cbc6d34bbdb58240b72ed1731931b4991a893b3a3238bb7c42ae054aa676" - - [[package]] - name = "bevy_rand" --version = "0.14.1" -+version = "0.14.2" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "c76e5db2c274081fdff32a525609436bd7ac2c24b91c1a5d3bc04e43d7558ddf" -+checksum = "526ba09324b96fb64f275a0cb4f6112d36b6c916ce2f14e38d5a8739dd2ac7f9" - dependencies = [ - "bevy_app", - "bevy_ecs", -@@ -2331,13 +2331,17 @@ dependencies = [ - name = "dorf" - version = "0.1.0" - dependencies = [ -+ "ahash", - "bevy", - "bevy_platform", - "bevy_rand", -+ "futures-lite", - "image", -+ "nohash-hasher", - "noise", - "rand 0.10.0", - "rayon", -+ "rustc-hash 2.1.1", - ] - - [[package]] -@@ -3023,9 +3027,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - - [[package]] - name = "image" --version = "0.25.9" -+version = "0.25.10" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" - dependencies = [ - "bytemuck", - "byteorder-lite", -@@ -3041,8 +3045,8 @@ dependencies = [ - "rayon", - "rgb", - "tiff", -- "zune-core 0.5.1", -- "zune-jpeg 0.5.12", -+ "zune-core", -+ "zune-jpeg", - ] - - [[package]] -@@ -3057,9 +3061,9 @@ dependencies = [ - - [[package]] - name = "imgref" --version = "1.11.0" -+version = "1.12.0" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" -+checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - - [[package]] - name = "indexmap" -@@ -3431,9 +3435,9 @@ dependencies = [ - - [[package]] - name = "moxcms" --version = "0.7.5" -+version = "0.8.1" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" -+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" - dependencies = [ - "num-traits", - "pxfm", -@@ -3566,6 +3570,12 @@ dependencies = [ - "libc", - ] - -+[[package]] -+name = "nohash-hasher" -+version = "0.2.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -+ - [[package]] - name = "noise" - version = "0.9.0" -@@ -4415,9 +4425,9 @@ dependencies = [ - - [[package]] - name = "ravif" --version = "0.12.0" -+version = "0.13.0" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -+checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" - dependencies = [ - "avif-serialize", - "imgref", -@@ -4536,9 +4546,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - - [[package]] - name = "rgb" --version = "0.8.50" -+version = "0.8.53" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" -+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - - [[package]] - name = "rodio" -@@ -4993,16 +5003,16 @@ dependencies = [ - - [[package]] - name = "tiff" --version = "0.10.3" -+version = "0.11.3" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" - dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", -- "zune-jpeg 0.4.21", -+ "zune-jpeg", - ] - - [[package]] -@@ -6468,12 +6478,6 @@ dependencies = [ - "syn", - ] - --[[package]] --name = "zune-core" --version = "0.4.12" --source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" -- - [[package]] - name = "zune-core" - version = "0.5.1" -@@ -6489,20 +6493,11 @@ dependencies = [ - "simd-adler32", - ] - --[[package]] --name = "zune-jpeg" --version = "0.4.21" --source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" --dependencies = [ -- "zune-core 0.4.12", --] -- - [[package]] - name = "zune-jpeg" - version = "0.5.12" - source = "registry+https://github.com/rust-lang/crates.io-index" - checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" - dependencies = [ -- "zune-core 0.5.1", -+ "zune-core", - ] -diff --git a/Cargo.toml b/Cargo.toml -index ae71471..8522f33 100644 ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -4,13 +4,17 @@ version = "0.1.0" - edition = "2021" - - [dependencies] --bevy = { version = "0.18.0", features = ["wayland"] } -+bevy = { version = "0.18.1", features = ["wayland"] } - noise = "0.9.0" - rand = "0.10.0" --bevy_rand = { version = "0.14.1", features = ["wyrand"] } --image = "0.25.9" --bevy_platform = "0.18.0" -+bevy_rand = { version = "0.14.2", features = ["wyrand"] } -+image = "0.25.10" -+bevy_platform = "0.18.1" - rayon = "1.11.0" -+rustc-hash = "2.1.1" -+ahash = "0.8.12" -+nohash-hasher = "0.2.0" -+futures-lite = "2.6" - - # Enable max optimizations for dependencies, but not for our code: - [profile.dev.package."*"] -diff --git a/src/constants.rs b/src/constants.rs -index a0c34b2..7a22b57 100644 ---- a/src/constants.rs -+++ b/src/constants.rs -@@ -3,3 +3,8 @@ pub const TILE_PIXELS: u32 = 16; - pub const TILE_SIZE: f32 = TILE_PIXELS as f32 * PIXEL_RATIO; - pub const ITILE_SIZE: i32 = TILE_SIZE as i32; - pub const SEED: u32 = 420; -+ -+pub const PATHFINDER_SHORT_PATH_MAX_TILES: i32 = 64; -+pub const PATHFINDER_MAX_NODES: usize = 5000; -+pub const PATHFINDER_WAYPOINT_THRESHOLD_TILES: i32 = 100; -+pub const PATHFINDER_PROVISIONAL_NODE_LIMIT: usize = 64; -diff --git a/src/entities/item/prefabs/misc/misc_prefabs.rs b/src/entities/item/prefabs/misc/misc_prefabs.rs -index 39d9e16..f5d9706 100644 ---- a/src/entities/item/prefabs/misc/misc_prefabs.rs -+++ b/src/entities/item/prefabs/misc/misc_prefabs.rs -@@ -41,15 +41,7 @@ pub fn spawn_prefab( - }, - }) - .id(); -- let mut items = tilemap -- .item_tiles -- .get(&position.as_ivec3()) -- .unwrap_or(&Vec::new()) -- .clone(); -- items.push(meat.index_u32()); -- tilemap -- .item_tiles -- .insert(position.as_ivec3(), items.clone()); -+ tilemap.insert_item(position.as_ivec3(), meat.index_u32()); - return commands - .entity(meat) - .insert(VisibleGameEntity) -@@ -76,15 +68,7 @@ pub fn spawn_prefab( - }, - }) - .id(); -- let mut items = tilemap -- .item_tiles -- .get(&position.as_ivec3()) -- .unwrap_or(&Vec::new()) -- .clone(); -- items.push(coin.index_u32()); -- tilemap -- .item_tiles -- .insert(position.as_ivec3(), items.clone()); -+ tilemap.insert_item(position.as_ivec3(), coin.index_u32()); - commands - .entity(coin) - .insert(VisibleGameEntity) -diff --git a/src/entities/item/systems.rs b/src/entities/item/systems.rs -index 5bc0eef..fc3f536 100644 ---- a/src/entities/item/systems.rs -+++ b/src/entities/item/systems.rs -@@ -116,13 +116,13 @@ pub fn item_tile_management_system( - - for position in positions { - let items = match tilemap.item_tiles.get(&position) { -- Some(i) => i, -+ Some(i) => i.clone(), - None => continue, - }; - - // Clean up empty tiles - if items.is_empty() { -- tilemap.item_tiles.remove(&position); -+ tilemap.remove_item(&position); - rotation_timer.current_indices.remove(&position); - continue; - } -@@ -146,8 +146,8 @@ pub fn item_tile_management_system( - .copied() - .unwrap_or(0); - -- for &entity_id in items { -- if let Some(entity) = Entity::from_raw_u32(entity_id) { -+ for entity_id in &items { -+ if let Some(entity) = Entity::from_raw_u32(*entity_id) { - if let Ok(mut rotation_state) = item_query.get_mut(entity) { - rotation_state.should_be_visible = false; - } -diff --git a/src/entities/shared_components/ambulatory.rs b/src/entities/shared_components/ambulatory.rs -index 8c7ae65..5b41470 100644 ---- a/src/entities/shared_components/ambulatory.rs -+++ b/src/entities/shared_components/ambulatory.rs -@@ -9,3 +9,21 @@ pub struct Ambulatory { - pub target: Option, - pub step_recovery: u32, - } -+ -+#[derive(Component)] -+pub struct PendingPath { -+ pub start: IVec3, -+ pub goal: IVec3, -+ pub waypoint_path: Vec, -+ pub request_id: u64, -+} -+ -+#[derive(Resource, Default)] -+pub struct PathRequestCounter { -+ pub next_id: u64, -+} -+ -+#[derive(Resource, Default)] -+pub struct CompletedPaths { -+ pub paths: Vec<(u64, Vec)>, -+} -diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs -index ee8c897..36437a0 100644 ---- a/src/entities/shared_systems/pathfinding.rs -+++ b/src/entities/shared_systems/pathfinding.rs -@@ -1,14 +1,91 @@ --use crate::constants::TILE_SIZE; -+use bevy::prelude::*; -+use rayon::prelude::*; -+use rustc_hash::FxHashMap; -+use rustc_hash::FxHashSet; -+use std::{cell::RefCell, collections::BinaryHeap, collections::VecDeque, time::Instant}; -+ -+use crate::constants::{ -+ ITILE_SIZE, PATHFINDER_MAX_NODES, PATHFINDER_PROVISIONAL_NODE_LIMIT, TILE_SIZE, -+}; - use crate::world::tiles::TileMap; - use crate::world::{chunks::ChunkMap, chunks::CHUNK_SIZE}; - use crate::{constants::*, entities::shared_components::Ambulatory}; --use bevy::{math::ivec3, prelude::*}; -+use bevy::math::ivec3; - use bevy_rand::prelude::*; - use rand::RngExt; --use std::{ -- collections::{BinaryHeap, HashMap, HashSet}, -- process::exit, --}; -+ -+thread_local! { -+ static LOCAL_PATH_TIMES: RefCell> = const { RefCell::new(Vec::new()) }; -+ static LOCAL_PATH_LENGTHS: RefCell> = const { RefCell::new(Vec::new()) }; -+ static LOCAL_NODES_EXPANDED: RefCell> = const { RefCell::new(Vec::new()) }; -+ static LOCAL_FAILED_PATHS: RefCell = const { RefCell::new(0) }; -+} -+ -+/// Single consolidated scratchpad for A* pathfinding. -+/// One RefCell borrow instead of multiple nested borrows. -+struct AStarScratchpad { -+ g_scores: FxHashMap, -+ came_from: FxHashMap, -+ closed_set: FxHashSet, -+ open_set: BinaryHeap, -+} -+ -+impl Default for AStarScratchpad { -+ fn default() -> Self { -+ Self { -+ g_scores: FxHashMap::default(), -+ came_from: FxHashMap::default(), -+ closed_set: FxHashSet::default(), -+ open_set: BinaryHeap::new(), -+ } -+ } -+} -+ -+impl AStarScratchpad { -+ fn clear_and_reserve(&mut self, capacity: usize) { -+ self.g_scores.clear(); -+ self.came_from.clear(); -+ self.closed_set.clear(); -+ self.open_set.clear(); -+ -+ if self.g_scores.capacity() < capacity { -+ self.g_scores.reserve(capacity); -+ self.came_from.reserve(capacity); -+ self.closed_set.reserve(capacity); -+ } -+ } -+} -+ -+thread_local! { -+ static SCRATCHPAD: RefCell = RefCell::new(AStarScratchpad::default()); -+} -+ -+const ALLOWED_MOVES: [IVec3; 24] = [ -+ IVec3::new(-ITILE_SIZE, 0, 0), -+ IVec3::new(ITILE_SIZE, 0, 0), -+ IVec3::new(0, -ITILE_SIZE, 0), -+ IVec3::new(0, ITILE_SIZE, 0), -+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, 0), -+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, 0), -+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, 0), -+ IVec3::new(ITILE_SIZE, ITILE_SIZE, 0), -+ IVec3::new(-ITILE_SIZE, 0, ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, 0, -ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, 0, ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, 0, -ITILE_SIZE), -+ IVec3::new(0, -ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(0, -ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(0, ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(0, ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE), -+]; - - #[derive(Clone, Eq, PartialEq, Debug)] - struct PathNode { -@@ -19,7 +96,10 @@ struct PathNode { - - impl Ord for PathNode { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { -- other.f_score.cmp(&self.f_score) -+ other -+ .f_score -+ .cmp(&self.f_score) -+ .then_with(|| other.g_score.cmp(&self.g_score)) - } - } - -@@ -29,58 +109,233 @@ impl PartialOrd for PathNode { - } - } - -+#[derive(Resource, Default)] -+pub struct PathfindingBenchmark { -+ pub path_calc_times_us: Vec, -+ pub path_lengths: Vec, -+ pub nodes_expanded: Vec, -+ pub movement_system_times_us: Vec, -+ pub wander_system_times_us: Vec, -+ pub total_paths_calculated: u64, -+ pub total_failed_paths: u64, -+ pub report_every_n: u32, -+ pub sample_count: u32, -+} -+ -+impl PathfindingBenchmark { -+ pub fn new(report_every_n: u32) -> Self { -+ Self { -+ report_every_n, -+ ..Default::default() -+ } -+ } -+} -+ -+#[derive(Resource, Default)] -+pub struct PathRequestQueue { -+ pub pending: VecDeque<(Entity, IVec3, IVec3)>, -+} -+ -+const MAX_PATHS_PER_FRAME: usize = 8; -+ - pub struct PathfindingPlugin; - - impl Plugin for PathfindingPlugin { - fn build(&self, app: &mut App) { -- app.add_systems(FixedUpdate, (update_wandering_targets, movement).chain()); -+ app.insert_resource(PathfindingBenchmark::new(100)) -+ .insert_resource(crate::entities::shared_components::CompletedPaths::default()) -+ .insert_resource(crate::entities::shared_components::PathRequestCounter::default()) -+ .insert_resource(PathRequestQueue::default()) -+ .add_systems( -+ FixedUpdate, -+ (prepare_paths, update_wandering_targets, movement).chain(), -+ ) -+ .add_systems( -+ PostUpdate, -+ ( -+ merge_benchmark_stats, -+ process_completed_paths, -+ process_path_queue, -+ ), -+ ) -+ .add_systems(Update, bench_report_system); -+ } -+} -+ -+pub fn prepare_paths( -+ mut commands: Commands, -+ mut queue: ResMut, -+ mut query: Query< -+ ( -+ Entity, -+ &mut crate::entities::shared_components::Ambulatory, -+ &Transform, -+ ), -+ Without, -+ >, -+ tilemap: Res, -+) { -+ for (entity, mut ambulatory, transform) in query.iter_mut() { -+ if ambulatory.current_path.is_some() || ambulatory.target.is_none() { -+ continue; -+ } -+ let Some(target) = ambulatory.target else { -+ continue; -+ }; -+ let start = transform.translation.as_ivec3(); -+ let goal = target.as_ivec3() - ivec3(0, 0, ITILE_SIZE); -+ let distance = octile_distance_3d(start, goal) / ITILE_SIZE; -+ -+ if distance <= PATHFINDER_SHORT_PATH_MAX_TILES { -+ let path = calculate_path_benchmarked(&tilemap, start, goal); -+ ambulatory.current_path = Some(path); -+ ambulatory.path_index = 0; -+ } else { -+ let provisional = calculate_provisional_path( -+ &tilemap, -+ start, -+ goal, -+ PATHFINDER_PROVISIONAL_NODE_LIMIT, -+ ); -+ if !provisional.is_empty() { -+ ambulatory.current_path = Some(provisional); -+ ambulatory.path_index = 0; -+ -+ queue.pending.push_back((entity, start, goal)); -+ commands -+ .entity(entity) -+ .insert(crate::entities::shared_components::PendingPath { -+ start, -+ goal, -+ waypoint_path: Vec::new(), -+ request_id: 0, -+ }); -+ } else { -+ let path = calculate_path_benchmarked(&tilemap, start, goal); -+ ambulatory.current_path = Some(path); -+ ambulatory.path_index = 0; -+ } -+ } -+ } -+} -+ -+pub fn process_path_queue( -+ mut commands: Commands, -+ mut queue: ResMut, -+ tilemap: Res, -+ mut query: Query< -+ (Entity, &mut Ambulatory, &Transform), -+ With, -+ >, -+) { -+ let mut processed = 0; -+ while processed < MAX_PATHS_PER_FRAME { -+ if let Some((entity, _old_start, goal)) = queue.pending.pop_front() { -+ processed += 1; -+ -+ if let Ok((_, mut ambulatory, transform)) = query.get_mut(entity) { -+ let actual_start = transform.translation.as_ivec3(); -+ let full_path = calculate_path_benchmarked(&tilemap, actual_start, goal); -+ -+ if !full_path.is_empty() { -+ ambulatory.current_path = Some(full_path); -+ ambulatory.path_index = 0; -+ } -+ commands -+ .entity(entity) -+ .remove::(); -+ } -+ } else { -+ break; -+ } -+ } -+} -+ -+pub fn process_completed_paths( -+ mut completed: ResMut, -+ mut query: Query<( -+ Entity, -+ &mut crate::entities::shared_components::Ambulatory, -+ &mut crate::entities::shared_components::PendingPath, -+ )>, -+ mut commands: Commands, -+) { -+ for (request_id, path) in completed.paths.drain(..) { -+ for (entity, mut ambulatory, pending) in query.iter_mut() { -+ if pending.request_id == request_id { -+ ambulatory.current_path = Some(path.clone()); -+ commands -+ .entity(entity) -+ .remove::(); -+ } -+ } - } - } - -+pub fn merge_benchmark_stats(mut bench: ResMut) { -+ LOCAL_PATH_TIMES.with(|t| { -+ let mut times = t.borrow_mut(); -+ bench.path_calc_times_us.extend(times.iter()); -+ bench.total_paths_calculated += times.len() as u64; -+ times.clear(); -+ }); -+ LOCAL_PATH_LENGTHS.with(|l| { -+ let mut lengths = l.borrow_mut(); -+ bench.path_lengths.extend(lengths.iter()); -+ lengths.clear(); -+ }); -+ LOCAL_NODES_EXPANDED.with(|n| { -+ let mut nodes = n.borrow_mut(); -+ bench.nodes_expanded.extend(nodes.iter()); -+ nodes.clear(); -+ }); -+ LOCAL_FAILED_PATHS.with(|f| { -+ let mut failed = f.borrow_mut(); -+ bench.total_failed_paths += *failed; -+ *failed = 0; -+ }); -+} -+ - pub fn update_wandering_targets( -- mut query: Query<(&mut Ambulatory, &Transform)>, // add a 'with' here when behaviours are implemented -+ mut query: Query<(&mut Ambulatory, &Transform)>, - tilemap: Res, - chunk_map: Res, - mut rng_q: Query<&mut WyRand, With>, - ) { -- if let Ok(mut rng) = rng_q.single_mut() { -- for (mut ambulatory, _) in query.iter_mut() { -- if ambulatory.target.is_none() -- || (ambulatory.current_path.is_some() -- && ambulatory.path_index >= ambulatory.current_path.as_ref().unwrap().len()) -- { -- // Find a random loaded chunk -- let loaded_chunks: Vec<&IVec2> = chunk_map.loaded_chunks.keys().collect(); -- if !loaded_chunks.is_empty() { -- let random_index = rng.random_range(0..loaded_chunks.len()); -- if let Some(&chunk_pos) = loaded_chunks.get(random_index) { -- // Generate random position within chunk -- let chunk_x = chunk_pos.x * CHUNK_SIZE; -- let chunk_y = chunk_pos.y * CHUNK_SIZE; -- -- let target_x = chunk_x + rng.random_range(0..CHUNK_SIZE); -- let target_y = chunk_y + rng.random_range(0..CHUNK_SIZE); -- -- // Get height at position -- let surface_height = 0; -- -- // Find a valid z-level near the surface -- for z in (surface_height - 3)..=(surface_height + 4) { -- let mut target_pos = IVec3::new(target_x, target_y, z) * ITILE_SIZE; -- if let Some(_) = tilemap.floor_tiles.get(&target_pos) { -- target_pos.z += ITILE_SIZE; -- if let Some(_base_texture) = tilemap.floor_tiles.get(&target_pos) { -- if is_standable_tile(&tilemap, target_pos) { -- ambulatory.target = Some(Vec3::new( -- target_pos.x as f32, -- target_pos.y as f32, -- target_pos.z as f32 + 1.0, -- )); -- ambulatory.current_path = None; -- ambulatory.path_index = 0; -- break; -- } -- } -+ let Ok(mut rng) = rng_q.single_mut() else { -+ return; -+ }; -+ -+ for (mut ambulatory, _) in query.iter_mut() { -+ if ambulatory.target.is_none() -+ || (ambulatory.current_path.is_some() -+ && ambulatory.path_index >= ambulatory.current_path.as_ref().unwrap().len()) -+ { -+ let loaded_chunks: Vec<&IVec2> = chunk_map.loaded_chunks.keys().collect(); -+ if !loaded_chunks.is_empty() { -+ let random_index = rng.random_range(0..loaded_chunks.len()); -+ if let Some(&chunk_pos) = loaded_chunks.get(random_index) { -+ let chunk_x = chunk_pos.x * CHUNK_SIZE; -+ let chunk_y = chunk_pos.y * CHUNK_SIZE; -+ -+ let target_x = chunk_x + rng.random_range(0..CHUNK_SIZE); -+ let target_y = chunk_y + rng.random_range(0..CHUNK_SIZE); -+ -+ for z in -3..=4 { -+ let mut target_pos = IVec3::new(target_x, target_y, z) * ITILE_SIZE; -+ if tilemap.floor_tiles.get(&target_pos).is_some() { -+ target_pos.z += ITILE_SIZE; -+ if tilemap.floor_tiles.get(&target_pos).is_some() -+ && is_standable_tile(&tilemap, target_pos) -+ { -+ ambulatory.target = Some(Vec3::new( -+ target_pos.x as f32, -+ target_pos.y as f32, -+ target_pos.z as f32 + 1.0, -+ )); -+ ambulatory.current_path = None; -+ ambulatory.path_index = 0; -+ break; - } - } - } -@@ -95,289 +350,435 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re - .par_iter_mut() - .for_each(|(mut ambulatory, mut transform)| { - let current_pos = transform.translation; -- // Apply gravity if in air - if !is_standable_tile(&tilemap, current_pos.as_ivec3()) { - transform.translation.z -= TILE_SIZE; - return; - } - -- if let Some(target) = ambulatory.target { -- // Calculate path if needed -- if ambulatory.current_path.is_none() { -- ambulatory.current_path = Some(calculate_path( -- &tilemap, -- transform.translation.as_ivec3(), -- target.as_ivec3() - ivec3(0, 0, 1), -- )); -- ambulatory.path_index = 0; -- } -- if ambulatory.walk_speed > 0. { -- if ambulatory.step_recovery <= ambulatory.walk_speed as u32 { -- ambulatory.step_recovery += 1; -- return; -- } else { -- ambulatory.step_recovery = 0; -- } -- } -+ if ambulatory.current_path.is_none() { -+ return; -+ } - -- // Follow the current path -- if let Some(path) = &ambulatory.current_path { -- if ambulatory.path_index < path.len() { -- let next_point = path[ambulatory.path_index]; -+ if ambulatory.walk_speed > 0. { -+ if ambulatory.step_recovery <= ambulatory.walk_speed as u32 { -+ ambulatory.step_recovery += 1; -+ return; -+ } else { -+ ambulatory.step_recovery = 0; -+ } -+ } - -- let direction = (next_point - transform.translation).normalize(); -- transform.translation = next_point; -+ if let Some(path) = &ambulatory.current_path { -+ if ambulatory.path_index < path.len() { -+ let next_point = path[ambulatory.path_index]; -+ let direction = (next_point - transform.translation).normalize(); -+ transform.translation = next_point; - -- // Update sprite direction (only for x movement) -- if direction.x > 0.0 { -- transform.scale.x = PIXEL_RATIO; -- } else if direction.x < 0.0 { -- transform.scale.x = -PIXEL_RATIO; -- } -+ if direction.x > 0.0 { -+ transform.scale.x = PIXEL_RATIO; -+ } else if direction.x < 0.0 { -+ transform.scale.x = -PIXEL_RATIO; -+ } - -- // Check if we've reached the next point -- if transform.translation.distance(next_point) < TILE_SIZE { -- ambulatory.path_index += 1; -- } -- } else { -- ambulatory.current_path = None; -- ambulatory.target = None; -+ if transform.translation.distance(next_point) < TILE_SIZE { -+ ambulatory.path_index += 1; - } -+ } else { -+ ambulatory.current_path = None; -+ ambulatory.target = None; - } - } - }); - } - - fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool { -- let mut can_i_stand_in_tile: bool = false; -- let mut can_i_stand_on_tile_bellow: bool = false; -- let mut can_i_stand_in_fixture: bool = false; -- let mut can_i_stand_on_fixture_bellow: bool = false; -- -- // Check if current position has a blocking floor tile -- if let Some(current_floor_tile) = tilemap.floor_tiles.get(&pos) { -- can_i_stand_in_tile = current_floor_tile.1; -- } -- // Check if current position has a solid fixture tile (e.g., log) -- if let Some(current_fixture_tile) = tilemap.fixture_tiles.get(&pos) { -- can_i_stand_in_fixture = current_fixture_tile.1; -- } -- -- // Check if there's solid ground below (fixture or floor) -+ let can_stand_in_tile = tilemap -+ .floor_tiles -+ .get(&pos) -+ .map(|t| t.can_stand_in()) -+ .unwrap_or(false); -+ let can_stand_in_fixture = tilemap -+ .fixture_tiles -+ .get(&pos) -+ .map(|t| t.can_stand_in()) -+ .unwrap_or(false); - let pos_below = pos - IVec3::new(0, 0, ITILE_SIZE); -+ let can_stand_on_tile_below = tilemap -+ .floor_tiles -+ .get(&pos_below) -+ .map(|t| t.can_stand_on()) -+ .unwrap_or(false); -+ let can_stand_on_fixture_below = tilemap -+ .fixture_tiles -+ .get(&pos_below) -+ .map(|t| t.can_stand_on()) -+ .unwrap_or(false); -+ -+ (can_stand_in_tile || can_stand_in_fixture) -+ && (can_stand_on_tile_below || can_stand_on_fixture_below) -+} - -- if let Some(below_floor_tile) = tilemap.floor_tiles.get(&pos_below) { -- can_i_stand_on_tile_bellow = below_floor_tile.2; -+fn calculate_movement_cost(move_dir: IVec3) -> i32 { -+ match ( -+ move_dir.x.abs() / ITILE_SIZE, -+ move_dir.y.abs() / ITILE_SIZE, -+ move_dir.z.abs() / ITILE_SIZE, -+ ) { -+ (1, 0, 0) | (0, 1, 0) => 10, -+ (1, 1, 0) => 14, -+ (1, 0, 1) | (0, 1, 1) => 42, -+ (1, 1, 1) => 56, -+ _ => 0, - } -+} - -- if let Some(below_fixture_tile) = tilemap.fixture_tiles.get(&pos_below) { -- can_i_stand_on_fixture_bellow = below_fixture_tile.2; -- } -- return (can_i_stand_in_tile || can_i_stand_in_fixture) -- && (can_i_stand_on_tile_bellow || can_i_stand_on_fixture_bellow); -+fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 { -+ let dx = (a.x - b.x).abs(); -+ let dy = (a.y - b.y).abs(); -+ let dz = (a.z - b.z).abs(); -+ let (dmax, dmid, dmin) = sorted_desc(dx, dy, dz); -+ 10 * dmax + 4 * dmid + dmin - } - --fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -- if !is_standable_tile(tilemap, start) { -- println!("Start pos invalid: {}", start); -- println!("Bugger (1)"); -- exit(0); -+fn sorted_desc(a: i32, b: i32, c: i32) -> (i32, i32, i32) { -+ let mut arr = [a, b, c]; -+ arr.sort_unstable_by(|x, y| y.cmp(x)); -+ (arr[0], arr[1], arr[2]) -+} -+ -+fn reconstruct_path(came_from: &FxHashMap, mut current: IVec3) -> Vec { -+ let mut path = vec![Vec3::new( -+ current.x as f32, -+ current.y as f32, -+ current.z as f32, -+ )]; -+ while let Some(&prev) = came_from.get(¤t) { -+ path.push(Vec3::new(prev.x as f32, prev.y as f32, prev.z as f32)); -+ current = prev; - } -- if !is_standable_tile(tilemap, goal) { -- println!("Goal pos invalid: {}", goal); -- println!("Bugger (2)"); -- exit(0); -+ path.reverse(); -+ path -+} -+ -+pub fn calculate_path_benchmarked(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ let timer = Instant::now(); -+ -+ if !is_standable_tile(tilemap, start) || !is_standable_tile(tilemap, goal) { -+ LOCAL_FAILED_PATHS.with(|f| { -+ *f.borrow_mut() += 1; -+ }); -+ return Vec::new(); - } - -- let mut open_set = BinaryHeap::new(); -- let mut came_from = HashMap::new(); -- let mut g_scores = HashMap::new(); -- let mut closed_set = HashSet::new(); -- let mut in_open_set = HashSet::new(); -+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; -+ let (result, nodes_expanded) = -+ calculate_path_with_scratchpad(tilemap, start, goal, estimated_tiles); -+ -+ let elapsed = timer.elapsed().as_micros(); -+ LOCAL_PATH_TIMES.with(|t| { -+ t.borrow_mut().push(elapsed); -+ }); -+ LOCAL_PATH_LENGTHS.with(|l| { -+ l.borrow_mut().push(result.len()); -+ }); -+ LOCAL_NODES_EXPANDED.with(|n| { -+ n.borrow_mut().push(nodes_expanded); -+ }); -+ -+ if result.is_empty() { -+ vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)] -+ } else { -+ result -+ } -+} - -- let start_node = PathNode { -- position: start, -- f_score: octile_distance_3d(start, goal), -- g_score: 0, -- }; -+fn calculate_path_with_scratchpad( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+ estimated_tiles: i32, -+) -> (Vec, usize) { -+ SCRATCHPAD.with(|s| { -+ let mut scratch = s.borrow_mut(); -+ let capacity = ((estimated_tiles as usize).max(64)).min(4096); -+ scratch.clear_and_reserve(capacity); -+ -+ let h = octile_distance_3d(start, goal); -+ scratch.open_set.push(PathNode { -+ position: start, -+ f_score: h, -+ g_score: 0, -+ }); -+ scratch.g_scores.insert(start, 0); - -- open_set.push(start_node); -- in_open_set.insert(start); -- g_scores.insert(start, 0); -- -- let allowed_moves = vec![ -- // Orthogonal moves -- IVec3::new(-ITILE_SIZE, 0, 0), -- IVec3::new(ITILE_SIZE, 0, 0), -- IVec3::new(0, -ITILE_SIZE, 0), -- IVec3::new(0, ITILE_SIZE, 0), -- // Diagonal moves -- IVec3::new(-ITILE_SIZE, -ITILE_SIZE, 0), -- IVec3::new(-ITILE_SIZE, ITILE_SIZE, 0), -- IVec3::new(ITILE_SIZE, -ITILE_SIZE, 0), -- IVec3::new(ITILE_SIZE, ITILE_SIZE, 0), -- // Diagonal with vertical moves (left/negative preference) -- IVec3::new(-ITILE_SIZE, 0, ITILE_SIZE), -- IVec3::new(-ITILE_SIZE, 0, -ITILE_SIZE), -- IVec3::new(ITILE_SIZE, 0, ITILE_SIZE), -- IVec3::new(ITILE_SIZE, 0, -ITILE_SIZE), -- IVec3::new(0, -ITILE_SIZE, ITILE_SIZE), -- IVec3::new(0, -ITILE_SIZE, -ITILE_SIZE), -- IVec3::new(0, ITILE_SIZE, ITILE_SIZE), -- IVec3::new(0, ITILE_SIZE, -ITILE_SIZE), -- // Full 3D diagonal moves -- IVec3::new(-ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE), -- IVec3::new(-ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE), -- IVec3::new(-ITILE_SIZE, ITILE_SIZE, ITILE_SIZE), -- IVec3::new(-ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE), -- IVec3::new(ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE), -- IVec3::new(ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE), -- IVec3::new(ITILE_SIZE, ITILE_SIZE, ITILE_SIZE), -- IVec3::new(ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE), -- ]; -- -- while let Some(current_node) = open_set.pop() { -- let current = current_node.position; -- -- in_open_set.remove(¤t); -- -- if current == goal { -- // println!("path found"); -- return reconstruct_path(came_from, current); -- } -+ let mut nodes_expanded: usize = 0; - -- closed_set.insert(current); -+ while let Some(current_node) = scratch.open_set.pop() { -+ let current = current_node.position; -+ nodes_expanded += 1; - -- for &move_dir in &allowed_moves { -- let neighbor_pos = current + move_dir; -+ if nodes_expanded > PATHFINDER_MAX_NODES { -+ return ( -+ reconstruct_path(&scratch.came_from, current), -+ nodes_expanded, -+ ); -+ } - -- if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) { -- continue; -+ if current == goal { -+ return ( -+ reconstruct_path(&scratch.came_from, current), -+ nodes_expanded, -+ ); - } - -- // TODO: Add terrain-based cost modifiers -- // movement_cost = apply_terrain_modifier(movement_cost, neighbor_pos, tilemap); -- // Examples: -- // - Mud/sand: +50% cost -- // - Ice: +100% cost -- // - Designated high-traffic areas: -25% cost -- // - Designated restricted areas: +500% cost -- // - Etc -- -- let movement_cost = match ( -- move_dir.x.abs() / ITILE_SIZE, -- move_dir.y.abs() / ITILE_SIZE, -- move_dir.z.abs() / ITILE_SIZE, -- ) { -- // 2D Movement (Dwarf Fortress style) -- (1, 0, 0) | (0, 1, 0) => 10, // Orthogonal movement -- (1, 1, 0) => 14, // Diagonal movement (~√2 × 10) -- -- // Vertical Movement (Raw climbing - very expensive) -- // (0, 0, 1) => 50, // Pure vertical climb/fall -- -- // 3D Movement (Climbing diagonally - even more expensive) -- (1, 0, 1) | (0, 1, 1) => 52, // Orthogonal + vertical climb -- (1, 1, 1) => 56, // Diagonal + vertical climb -- -- // TODO: Implement stairs and ramps for efficient vertical movement -- // Stairs would reduce vertical costs significantly: -- // (0, 0, 1) => 20 if has_stairs(current, neighbor_pos), // Stairs: 2× horizontal cost -- // (1, 0, 1) | (0, 1, 1) => 24 if has_stairs(current, neighbor_pos), // Stairs + horizontal -- // (1, 1, 1) => 28 if has_stairs(current, neighbor_pos), // Stairs + diagonal -- -- // TODO: Implement ramps for even smoother vertical movement -- // Ramps would be cheaper than stairs: -- // (0, 0, 1) => 15 if has_ramp(current, neighbor_pos), // Ramps: 1.5× horizontal cost -- // (1, 0, 1) | (0, 1, 1) => 18 if has_ramp(current, neighbor_pos), // Ramps + horizontal -- // (1, 1, 1) => 21 if has_ramp(current, neighbor_pos), // Ramps + diagonal -- _ => continue, -- }; -- -- let new_g = g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -- -- if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -- came_from.insert(neighbor_pos, current); -- g_scores.insert(neighbor_pos, new_g); -- let h = octile_distance_3d(neighbor_pos, goal); -- let f = new_g + h; -- -- // Only add to open set if not already there -- if !in_open_set.contains(&neighbor_pos) { -- let neighbor_node = PathNode { -+ scratch.closed_set.insert(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) -+ || scratch.closed_set.contains(&neighbor_pos) -+ { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let new_g = *scratch.g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ -+ if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -+ scratch.came_from.insert(neighbor_pos, current); -+ scratch.g_scores.insert(neighbor_pos, new_g); -+ let f = new_g + octile_distance_3d(neighbor_pos, goal); -+ scratch.open_set.push(PathNode { - position: neighbor_pos, - f_score: f, - g_score: new_g, -- }; -- open_set.push(neighbor_node); -- in_open_set.insert(neighbor_pos); -- } else { -- let neighbor_node = PathNode { -+ }); -+ } -+ } -+ } -+ -+ (Vec::new(), nodes_expanded) -+ }) -+} -+ -+pub fn calculate_provisional_path( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+ node_limit: usize, -+) -> Vec { -+ let timer = Instant::now(); -+ -+ if !is_standable_tile(tilemap, start) { -+ LOCAL_FAILED_PATHS.with(|f| { -+ *f.borrow_mut() += 1; -+ }); -+ return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)]; -+ } -+ -+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; -+ -+ let result = SCRATCHPAD.with(|s| { -+ let mut scratch = s.borrow_mut(); -+ let capacity = ((estimated_tiles as usize).max(64)).min(4096); -+ scratch.clear_and_reserve(capacity); -+ -+ let initial_h = octile_distance_3d(start, goal); -+ scratch.open_set.push(PathNode { -+ position: start, -+ f_score: initial_h, -+ g_score: 0, -+ }); -+ scratch.g_scores.insert(start, 0); -+ -+ let mut nodes_expanded: usize = 0; -+ let mut best_node = start; -+ let mut best_h = initial_h; -+ -+ while let Some(current_node) = scratch.open_set.pop() { -+ let current = current_node.position; -+ nodes_expanded += 1; -+ -+ let h = octile_distance_3d(current, goal); -+ if h < best_h { -+ best_h = h; -+ best_node = current; -+ } -+ -+ if current == goal { -+ return ( -+ reconstruct_path(&scratch.came_from, current), -+ nodes_expanded, -+ ); -+ } -+ -+ if nodes_expanded >= node_limit { -+ return ( -+ reconstruct_path(&scratch.came_from, best_node), -+ nodes_expanded, -+ ); -+ } -+ -+ scratch.closed_set.insert(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) -+ || scratch.closed_set.contains(&neighbor_pos) -+ { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let new_g = *scratch.g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ -+ if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -+ scratch.came_from.insert(neighbor_pos, current); -+ scratch.g_scores.insert(neighbor_pos, new_g); -+ let f = new_g + octile_distance_3d(neighbor_pos, goal); -+ scratch.open_set.push(PathNode { - position: neighbor_pos, - f_score: f, - g_score: new_g, -- }; -- open_set.push(neighbor_node); -+ }); - } - } - } -- } - -- Vec::new() -+ ( -+ reconstruct_path(&scratch.came_from, best_node), -+ nodes_expanded, -+ ) -+ }); -+ -+ let elapsed = timer.elapsed().as_micros(); -+ LOCAL_PATH_TIMES.with(|t| { -+ t.borrow_mut().push(elapsed); -+ }); -+ LOCAL_PATH_LENGTHS.with(|l| { -+ l.borrow_mut().push(result.0.len()); -+ }); -+ LOCAL_NODES_EXPANDED.with(|n| { -+ n.borrow_mut().push(result.1); -+ }); -+ -+ result.0 - } - --fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 { -- let dx = (a.x - b.x).abs(); -- let dy = (a.y - b.y).abs(); -- let dz = (a.z - b.z).abs(); -+pub fn bench_report_system( -+ keys: Res>, -+ mut bench: ResMut, -+) { -+ if keys.just_pressed(KeyCode::F8) { -+ println!("\n=== PATHFINDING BENCHMARK REPORT ==="); -+ report_stat("path_calc", &bench.path_calc_times_us); -+ report_stat( -+ "path_length", -+ &bench -+ .path_lengths -+ .iter() -+ .map(|&l| l as u128) -+ .collect::>(), -+ ); -+ report_stat( -+ "nodes_expanded", -+ &bench -+ .nodes_expanded -+ .iter() -+ .map(|&n| n as u128) -+ .collect::>(), -+ ); -+ -+ if !bench.movement_system_times_us.is_empty() { -+ report_stat("movement_system", &bench.movement_system_times_us); -+ } -+ if !bench.wander_system_times_us.is_empty() { -+ report_stat("wander_system", &bench.wander_system_times_us); -+ } - -- // Dwarf Fortress style costs -- let cost_orthogonal = 10; // Horizontal orthogonal -- let cost_diagonal = 14; // Horizontal diagonal (~√2 × 10) -- let cost_climb = 50; // Raw vertical movement (climbing) -- -- let mut diffs = [dx, dy, dz]; -- diffs.sort_unstable(); -- let dmin = diffs[0]; -- let dmax = diffs[2]; -- -- if dz == 0 { -- // Pure 2D movement -- let diagonal_moves = dmin / ITILE_SIZE; -- let orthogonal_moves = (dmax - dmin) / ITILE_SIZE; -- cost_diagonal * diagonal_moves + cost_orthogonal * orthogonal_moves -- } else { -- // Movement involves Z - assume raw climbing for now -- // TODO: Modify this when stairs/ramps are implemented -- let z_moves = dz / ITILE_SIZE; -- let xy_distance = ((dx * dx + dy * dy) as f32).sqrt() as i32; -- let remaining_2d_diagonal = (xy_distance.min(dz)) / ITILE_SIZE; -- let remaining_2d_orthogonal = -- (xy_distance - remaining_2d_diagonal * ITILE_SIZE) / ITILE_SIZE; -- -- // Raw climbing cost + remaining 2D movement -- cost_climb * z_moves -- + cost_diagonal * remaining_2d_diagonal -- + cost_orthogonal * remaining_2d_orthogonal -+ let total = bench.total_paths_calculated; -+ let failed = bench.total_failed_paths; -+ println!( -+ "[BENCH] total_paths={} failed_paths={} success_rate={:.1}%", -+ total, -+ failed, -+ if total > 0 { -+ 100.0 * (total - failed) as f64 / total as f64 -+ } else { -+ 100.0 -+ } -+ ); -+ -+ if let Err(e) = write_benchmark_csv(&bench, "pathfinding_benchmark_current.csv") { -+ eprintln!("Failed to write benchmark CSV: {}", e); -+ } -+ println!("=====================================\n"); - } - } - --fn reconstruct_path(came_from: HashMap, mut current: IVec3) -> Vec { -- let mut path = vec![Vec3::new( -- current.x as f32, -- current.y as f32, -- current.z as f32, -- )]; -+fn report_stat(label: &str, times: &[u128]) { -+ if times.is_empty() { -+ return; -+ } -+ let sum: u128 = times.iter().sum(); -+ let avg = sum / times.len() as u128; -+ let min = *times.iter().min().unwrap(); -+ let max = *times.iter().max().unwrap(); -+ let mut sorted = times.to_vec(); -+ sorted.sort_unstable(); -+ let median = sorted[sorted.len() / 2]; -+ let p95_idx = (sorted.len() as f64 * 0.95) as usize; -+ let p95 = sorted[p95_idx.min(sorted.len().saturating_sub(1))]; -+ -+ println!( -+ "[BENCH][{}] n={} avg={}µs median={}µs min={}µs max={}µs p95={}µs", -+ label, -+ times.len(), -+ avg, -+ median, -+ min, -+ max, -+ p95 -+ ); -+} - -- while let Some(&previous) = came_from.get(¤t) { -- path.push(Vec3::new( -- previous.x as f32, -- previous.y as f32, -- previous.z as f32, -- )); -- current = previous; -+fn write_benchmark_csv(bench: &PathfindingBenchmark, filename: &str) -> std::io::Result<()> { -+ use std::fs::File; -+ use std::io::Write; -+ -+ let mut file = File::create(filename)?; -+ writeln!( -+ file, -+ "sample,path_duration_us,path_length,nodes_expanded,success" -+ )?; -+ -+ let n = bench.path_calc_times_us.len(); -+ for i in 0..n { -+ let duration = bench.path_calc_times_us.get(i).copied().unwrap_or(0); -+ let length = bench.path_lengths.get(i).copied().unwrap_or(0); -+ let nodes = bench.nodes_expanded.get(i).copied().unwrap_or(0); -+ let success = i < (n - bench.total_failed_paths as usize); -+ writeln!(file, "{},{},{},{},{}", i, duration, length, nodes, success)?; - } - -- path.reverse(); -- path -+ writeln!(file, "# Summary")?; -+ if !bench.path_calc_times_us.is_empty() { -+ let avg: u128 = -+ bench.path_calc_times_us.iter().sum::() / bench.path_calc_times_us.len() as u128; -+ writeln!(file, "# avg_duration_us,{}", avg)?; -+ } -+ writeln!(file, "# total_paths,{}", bench.total_paths_calculated)?; -+ writeln!(file, "# failed_paths,{}", bench.total_failed_paths)?; -+ -+ Ok(()) - } -diff --git a/src/world/chunks/management.rs b/src/world/chunks/management.rs -index f066456..300c4c4 100644 ---- a/src/world/chunks/management.rs -+++ b/src/world/chunks/management.rs -@@ -6,7 +6,7 @@ use crate::world::{tiles::TileMap, CurrentWorldSpriteState, TerrainSpriteState}; - - pub const CHUNK_SIZE: i32 = 8; - --pub const Z_BELOW: f32 = 45.0; -+pub const Z_BELOW: f32 = 5.0; - pub const Z_ABOVE: f32 = 15.0; - pub const Z_TOTAL: f32 = Z_ABOVE + Z_BELOW; - -diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs -index 9fc597c..4decbb3 100644 ---- a/src/world/generation/forestry.rs -+++ b/src/world/generation/forestry.rs -@@ -11,8 +11,8 @@ use std::hash::{Hash, Hasher}; - use crate::{ - constants::{SEED, TILE_SIZE}, - world::{ -- tiles::TileMap, ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures, -- VisibleGameEntity, -+ tiles::{FixtureTileData, TileMap}, -+ ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures, VisibleGameEntity, - }, - }; - -@@ -26,8 +26,7 @@ pub fn generate_chunk_forrestry( - let start = Instant::now(); - let count = events.len(); - -- let collected_tilemap_updates: Mutex> = -- Mutex::new(Vec::<(IVec3, (i32, bool, bool, [u32; 8]))>::new()); -+ let collected_tilemap_updates: Mutex> = Mutex::new(Vec::new()); - - events.par_read().for_each(|event| { - let floor_positions = &event.floor_tiles; -@@ -87,10 +86,10 @@ pub fn generate_chunk_forrestry( - } - } - -- collected_tilemap_updates -- .lock() -- .unwrap() -- .push((trunk_ivec, (1, false, true, [0; 8]))); -+ collected_tilemap_updates.lock().unwrap().push(( -+ trunk_ivec, -+ FixtureTileData::new(1, false, true, [0; 8]), -+ )); - - log_positions.insert(trunk_ivec); - } -@@ -144,10 +143,14 @@ pub fn generate_chunk_forrestry( - )) - .id(); - commands.entity(leaf).insert(VisibleGameEntity); -- collected_tilemap_updates -- .lock() -- .unwrap() -- .push((ivec, (5, false, true, [0; 8]))); -+ collected_tilemap_updates.lock().unwrap().push( -+ ( -+ ivec, -+ FixtureTileData::new( -+ 5, false, true, [0; 8], -+ ), -+ ), -+ ); - } - } - } -@@ -164,7 +167,7 @@ pub fn generate_chunk_forrestry( - - let collected_updates = collected_tilemap_updates.into_inner().unwrap(); - for (ivec, data) in collected_updates { -- tilemap.fixture_tiles.insert(ivec, data); -+ tilemap.insert_fixture(ivec, data); - } - if count > 0 { - println!( -diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs -index fb46146..673ee7e 100644 ---- a/src/world/generation/terrain.rs -+++ b/src/world/generation/terrain.rs -@@ -7,8 +7,9 @@ use noise::{NoiseFn, Perlin}; - use crate::{ - constants::{SEED, TILE_SIZE}, - world::{ -- tiles::TileMap, ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, -- TileOcclusionEvent, CHUNK_SIZE, Z_ABOVE, Z_BELOW, -+ tiles::{FloorTileData, TileMap}, -+ ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, TileOcclusionEvent, CHUNK_SIZE, -+ Z_ABOVE, Z_BELOW, - }, - }; - -@@ -50,8 +51,7 @@ pub fn generate_chunk_terrain( - let start_y = chunk_pos.y * CHUNK_SIZE; - - let mut surface_positions: Vec<(Vec3, String)> = Vec::new(); -- let mut local_tilemap_updates: HashMap = -- HashMap::new(); -+ let mut local_tilemap_updates: HashMap = HashMap::new(); - - // Generate tiles for this chunk - for local_y in 0..CHUNK_SIZE { -@@ -84,23 +84,26 @@ pub fn generate_chunk_terrain( - commands.command_scope(|mut cmd| { - FloorTilePrefab::air(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (0, true, false, true, 0, [0; 8])); -- // Air tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(0, true, false, true, 0, [0; 8]), -+ ); - } else if cave_value < 0.8 { - commands.command_scope(|mut cmd| { - FloorTilePrefab::rock(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (2, false, true, false, 50, [0; 8])); -- // Rock tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(2, false, true, false, 50, [0; 8]), -+ ); - } else { - commands.command_scope(|mut cmd| { - FloorTilePrefab::dirt(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (1, false, true, false, 85, [0; 8])); -- // Dirt tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(1, false, true, false, 85, [0; 8]), -+ ); - } - } else if noise_position.z > position.z { - if (generate_surface_terrain(world_x, world_y) * TILE_SIZE).round() -@@ -109,23 +112,28 @@ pub fn generate_chunk_terrain( - commands.command_scope(|mut cmd| { - FloorTilePrefab::grass(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (1, false, true, false, 100, [0; 8])); // Dirt tile (grass) -- surface_positions.push((position, ("grass").to_string())); -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(1, false, true, false, 100, [0; 8]), -+ ); -+ surface_positions.push((position, "grass".to_string())); - } else { - commands.command_scope(|mut cmd| { - FloorTilePrefab::dirt(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (1, false, true, false, 85, [0; 8])); -- // Dirt tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(1, false, true, false, 85, [0; 8]), -+ ); - } - } else { - commands.command_scope(|mut cmd| { - FloorTilePrefab::air(position).spawn(&mut cmd); - }); -- local_tilemap_updates.insert(pos_ivec, (0, true, false, true, 0, [0; 8])); -- // Air tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(0, true, false, true, 0, [0; 8]), -+ ); - } - } - } -@@ -151,7 +159,7 @@ pub fn generate_chunk_terrain( - .unwrap() - .into_iter() - .map(|(pos, data)| { -- tilemap.floor_tiles.insert(pos, data); -+ tilemap.insert_floor(pos, data); - pos - }) - .collect(); -diff --git a/src/world/mod.rs b/src/world/mod.rs -index a0cb751..d94211f 100644 ---- a/src/world/mod.rs -+++ b/src/world/mod.rs -@@ -69,8 +69,8 @@ impl Plugin for WorldPlugin { - } - - fn setup_initial_chunks(mut event_writer: MessageWriter) { -- for x in -5..=5 { -- for y in -5..=5 { -+ for x in -15..=15 { -+ for y in -15..=15 { - event_writer.write(GenerateChunkEvent { - chunk_position: IVec2::new(x, y), - }); -diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs -index 8b3d811..028c005 100644 ---- a/src/world/tiles/tilemap.rs -+++ b/src/world/tiles/tilemap.rs -@@ -1,9 +1,197 @@ - use bevy::prelude::*; --use bevy_platform::collections::hash_map::HashMap; -+use rustc_hash::FxHashMap; - --#[derive(Resource, Default, Clone)] -+use crate::constants::ITILE_SIZE; -+ -+/// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple. -+#[derive(Clone, Copy, Debug)] -+pub struct FloorTileData { -+ pub id: u8, -+ /// bit0=can_stand_in, bit1=can_stand_on, bit2=visibly_transparent -+ pub flags: u8, -+ pub astar_weight: u8, -+ pub visible_range: [u32; 8], -+} -+ -+impl Default for FloorTileData { -+ fn default() -> Self { -+ Self { -+ id: 0, -+ flags: 0b001, -+ astar_weight: 0, -+ visible_range: [0; 8], -+ } -+ } -+} -+ -+impl FloorTileData { -+ pub fn new( -+ id: u8, -+ can_stand_in: bool, -+ can_stand_on: bool, -+ visibly_transparent: bool, -+ astar_weight: u8, -+ visible_range: [u32; 8], -+ ) -> Self { -+ let mut flags = 0u8; -+ if can_stand_in { -+ flags |= 0b001; -+ } -+ if can_stand_on { -+ flags |= 0b010; -+ } -+ if visibly_transparent { -+ flags |= 0b100; -+ } -+ Self { -+ id, -+ flags, -+ astar_weight, -+ visible_range, -+ } -+ } -+ -+ #[inline] -+ pub fn can_stand_in(&self) -> bool { -+ self.flags & 0b001 != 0 -+ } -+ #[inline] -+ pub fn can_stand_on(&self) -> bool { -+ self.flags & 0b010 != 0 -+ } -+ #[inline] -+ pub fn visibly_transparent(&self) -> bool { -+ self.flags & 0b100 != 0 -+ } -+ -+ #[inline] -+ pub fn set_can_stand_in(&mut self, value: bool) { -+ if value { -+ self.flags |= 0b001; -+ } else { -+ self.flags &= !0b001; -+ } -+ } -+ -+ #[inline] -+ pub fn set_can_stand_on(&mut self, value: bool) { -+ if value { -+ self.flags |= 0b010; -+ } else { -+ self.flags &= !0b010; -+ } -+ } -+ -+ #[inline] -+ pub fn set_visibly_transparent(&mut self, value: bool) { -+ if value { -+ self.flags |= 0b100; -+ } else { -+ self.flags &= !0b100; -+ } -+ } -+} -+ -+/// Packed fixture tile data. ~18 bytes vs 48 bytes tuple. -+#[derive(Clone, Copy, Debug)] -+pub struct FixtureTileData { -+ pub id: u8, -+ /// bit0=can_stand_in, bit1=can_stand_on -+ pub flags: u8, -+ pub visible_range: [u32; 8], -+} -+ -+impl Default for FixtureTileData { -+ fn default() -> Self { -+ Self { -+ id: 0, -+ flags: 0, -+ visible_range: [0; 8], -+ } -+ } -+} -+ -+impl FixtureTileData { -+ pub fn new(id: u8, can_stand_in: bool, can_stand_on: bool, visible_range: [u32; 8]) -> Self { -+ let mut flags = 0u8; -+ if can_stand_in { -+ flags |= 0b001; -+ } -+ if can_stand_on { -+ flags |= 0b010; -+ } -+ Self { -+ id, -+ flags, -+ visible_range, -+ } -+ } -+ -+ #[inline] -+ pub fn can_stand_in(&self) -> bool { -+ self.flags & 0b001 != 0 -+ } -+ #[inline] -+ pub fn can_stand_on(&self) -> bool { -+ self.flags & 0b010 != 0 -+ } -+} -+ -+/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access. -+#[derive(Resource, Default)] - pub struct TileMap { -- pub floor_tiles: HashMap, //id, canStandIn, canStandOn, visiblyTransparent, astar_weight, visible_range -- pub fixture_tiles: HashMap, // id, canStandIn, canStandOn, visible_range -- pub item_tiles: HashMap>, // Entity.id's of items on this tile -+ pub floor_tiles: FxHashMap, -+ pub fixture_tiles: FxHashMap, -+ pub item_tiles: FxHashMap>, -+} -+ -+impl TileMap { -+ pub fn new() -> Self { -+ Self::default() -+ } -+ -+ #[inline] -+ pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> { -+ self.floor_tiles.get(pos) -+ } -+ -+ #[inline] -+ pub fn get_fixture(&self, pos: &IVec3) -> Option<&FixtureTileData> { -+ self.fixture_tiles.get(pos) -+ } -+ -+ #[inline] -+ pub fn has_floor(&self, pos: &IVec3) -> bool { -+ self.floor_tiles.contains_key(pos) -+ } -+ -+ #[inline] -+ pub fn has_fixture(&self, pos: &IVec3) -> bool { -+ self.fixture_tiles.contains_key(pos) -+ } -+ -+ #[inline] -+ pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) { -+ self.floor_tiles.insert(pos, tile); -+ } -+ -+ #[inline] -+ pub fn insert_fixture(&mut self, pos: IVec3, tile: FixtureTileData) { -+ self.fixture_tiles.insert(pos, tile); -+ } -+ -+ #[inline] -+ pub fn insert_item(&mut self, pos: IVec3, entity_id: u32) { -+ self.item_tiles.entry(pos).or_default().push(entity_id); -+ } -+ -+ #[inline] -+ pub fn remove_item(&mut self, pos: &IVec3) -> Option> { -+ self.item_tiles.remove(pos) -+ } -+ -+ #[inline] -+ pub fn get_floor_mut(&mut self, pos: &IVec3) -> Option<&mut FloorTileData> { -+ self.floor_tiles.get_mut(pos) -+ } - } -diff --git a/src/world/tiles/visibility.rs b/src/world/tiles/visibility.rs -index 5c9a041..c56ce8b 100644 ---- a/src/world/tiles/visibility.rs -+++ b/src/world/tiles/visibility.rs -@@ -100,8 +100,8 @@ pub fn handle_tile_occlusion_updates( - for (_, mut tile, pos) in floor_tiles.iter_mut() { - if let Some(visibility) = update_map.get(&pos.translation.as_ivec3()) { - tile.visible_range = *visibility; -- if let Some(tile_data) = tilemap.floor_tiles.get_mut(&pos.translation.as_ivec3()) { -- tile_data.5 = *visibility; -+ if let Some(tile_data) = tilemap.get_floor_mut(&pos.translation.as_ivec3()) { -+ tile_data.visible_range = *visibility; - } - } - } -@@ -134,7 +134,7 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { - pos.z + z_offset * ITILE_SIZE, - ); - match tilemap.floor_tiles.get(&neighbor_pos) { -- Some(&(id, _, _, _, _, _)) if id == 0 => break 'neighbor_check true, -+ Some(tile) if tile.id == 0 => break 'neighbor_check true, - None => {} - _ => {} - } -@@ -163,8 +163,8 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { - let camera_z_index = (check_pos.z / ITILE_SIZE) + z_below; - - if camera_z_index < 0 { -- if let Some(&(_, _, _, vt, _, _)) = tilemap.floor_tiles.get(&check_pos) { -- occluded = !vt; -+ if let Some(tile) = tilemap.floor_tiles.get(&check_pos) { -+ occluded = !tile.visibly_transparent(); - } else { - occluded = false; - } -@@ -180,8 +180,8 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { - visible_range[z2 / 32] |= 1 << (z2 % 32); - } - -- if let Some(&(_, _, _, vt, _, _)) = tilemap.floor_tiles.get(&check_pos) { -- occluded = !vt; -+ if let Some(tile) = tilemap.floor_tiles.get(&check_pos) { -+ occluded = !tile.visibly_transparent(); - } else { - occluded = false; - } -diff --git a/verify_bench.py b/verify_bench.py -new file mode 100644 -index 0000000..fea2e64 ---- /dev/null -+++ b/verify_bench.py -@@ -0,0 +1,36 @@ -+import csv -+import sys -+ -+def analyze(filename): -+ durations = [] -+ failed = 0 -+ total = 0 -+ stutters = 0 -+ try: -+ with open(filename, 'r') as f: -+ reader = csv.reader(f) -+ next(reader) # skip header -+ for row in reader: -+ if not row or row[0].startswith('#'): continue -+ total += 1 -+ try: -+ d = int(row[1]) -+ durations.append(d) -+ if d > 1000: stutters += 1 -+ if row[4] == 'false': failed += 1 -+ except: -+ pass -+ -+ durations.sort() -+ if not durations: return -+ p50 = durations[len(durations)//2] -+ p90 = durations[int(len(durations)*0.90)] -+ p99 = durations[int(len(durations)*0.99)] -+ max_d = durations[-1] -+ print(f"[{filename}]") -+ print(f"P50={p50} P90={p90} P99={p99} Max={max_d} Stutters={stutters} Failed={failed}/{total}") -+ except Exception as e: -+ print(f"Error reading {filename}: {e}") -+ -+analyze("/home/popertots/bench/pathfinding_benchmark_baseline_release.csv") -+analyze("pathfinding_benchmark_current.csv")