tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
topology.h
1#pragma once
2
3#include <tess/core/assert.h>
4#include <tess/core/shape.h>
5#include <tess/core/tag_identity.h>
6#include <tess/storage/residency.h>
7#include <tess/storage/world.h>
8#include <tess/topology/movement_class.h>
9#include <tess/topology/transition_provider.h>
10
11#include <algorithm>
12#include <cstddef>
13#include <cstdint>
14#include <limits>
15#include <span>
16#include <type_traits>
17#include <vector>
18
19namespace tess {
20
21// RegionGraph is a class template on the world residency policy so a sparse
22// graph is a distinct type from a dense one (no silent dense indexing). The
23// AlwaysResident alias below keeps every existing dense call site unchanged.
25template <typename Residency>
26class RegionGraphT;
27
28// Local region ids are 1-based: 0 is the invalid sentinel
29// (`invalid_local_region`). A valid id maps to
30// `LocalChunkTopology::regions()[value - 1]`; use
31// `LocalChunkTopology::region(id)` for checked access.
34 std::uint32_t value = 0;
35
36 friend constexpr bool operator==(LocalRegionId lhs,
37 LocalRegionId rhs) noexcept = default;
38};
39
41inline constexpr LocalRegionId invalid_local_region{};
42
43// Sentinel returned by RegionGraph::region_index for invalid or
44// out-of-range region references.
46inline constexpr std::uint32_t invalid_region_index =
47 std::numeric_limits<std::uint32_t>::max();
48
50enum class BoundaryFace : std::uint8_t {
51 NegativeX,
52 PositiveX,
53 NegativeY,
54 PositiveY,
55 NegativeZ,
56 PositiveZ,
57};
58
60enum class TopologyStatus : std::uint8_t {
61 Built,
62 InvalidChunk,
63 MissingChunk,
64};
65
68 LocalRegionId id{};
69 std::size_t tile_count = 0;
70 Box3 bounds{};
71 std::size_t boundary_exit_count = 0;
72
73 friend constexpr bool operator==(const LocalRegion& lhs,
74 const LocalRegion& rhs) noexcept = default;
75};
76
79 LocalRegionId region{};
80 LocalTileId local_tile{};
81 Coord3 coord{};
82 BoundaryFace face = BoundaryFace::NegativeX;
83 ChunkKey target_chunk{};
84
85 friend constexpr bool operator==(const LocalBoundaryExit& lhs,
86 const LocalBoundaryExit& rhs) noexcept =
87 default;
88};
89
92 TopologyStatus status = TopologyStatus::Built;
93 std::size_t region_count = 0;
94 std::size_t passable_tile_count = 0;
95 std::size_t boundary_exit_count = 0;
96 std::uint32_t version = 0;
97};
98
100struct RegionRef {
101 ChunkKey chunk{};
102 LocalRegionId region{};
103
104 friend constexpr bool operator==(RegionRef lhs,
105 RegionRef rhs) noexcept = default;
106};
107
110 RegionRef from{};
111 RegionRef to{};
112 Coord3 from_coord{};
113 Coord3 to_coord{};
114 BoundaryFace face = BoundaryFace::NegativeX;
115
116 friend constexpr bool operator==(const RegionPortal& lhs,
117 const RegionPortal& rhs) noexcept = default;
118};
119
121enum class ReachabilityStatus : std::uint8_t {
122 Reachable,
123 Unreachable,
124 InvalidStart,
125 InvalidGoal,
126 // The query reached the edge of the resident set: a region on the searched
127 // side has a boundary exit into a non-resident chunk, so a route through the
128 // unloaded region cannot be ruled out. Distinct from Unreachable, which means
129 // a route was definitively searched and none exists within the resident set.
130 // Only ever returned for sparse worlds. Appended last so existing enumerator
131 // values do not shift.
132 Indeterminate,
133};
134
137 ReachabilityStatus status = ReachabilityStatus::Unreachable;
138 std::size_t visited_regions = 0;
139};
140
143 public:
144 void reserve_tiles(std::size_t count) { stack_.reserve(count); }
145
146 [[nodiscard]] auto capacity() const noexcept -> std::size_t {
147 return stack_.capacity();
148 }
149
150 private:
151 template <typename World, typename PassableTag>
152 friend auto build_local_chunk_topology(const World& world, ChunkKey chunk,
153 LocalTopologyScratch& scratch,
154 class LocalChunkTopology& topology)
156
157 std::vector<LocalTileId> stack_;
158};
159
162 public:
163 void reserve_regions(std::size_t count) {
164 frontier_.reserve(count);
165 visited_epoch_.reserve(count);
166 }
167
168 [[nodiscard]] auto capacity() const noexcept -> std::size_t {
169 return frontier_.capacity();
170 }
171
172 private:
173 template <typename Shape, typename Residency>
174 friend auto reachable(const RegionGraphT<Residency>& graph, Coord3 start,
175 Coord3 goal, RegionGraphScratch& scratch)
177
178 // Epoch-stamped visited marks: a region index is visited when its
179 // generation stamp matches the current epoch, so traversals reset in
180 // O(1) instead of clearing the whole vector.
181 void begin_traversal(std::size_t region_count) {
182 frontier_.clear();
183 if (visited_epoch_.size() < region_count) {
184 visited_epoch_.resize(region_count, 0);
185 }
186 ++epoch_;
187 if (epoch_ == 0) {
188 std::fill(visited_epoch_.begin(), visited_epoch_.end(), 0);
189 epoch_ = 1;
190 }
191 }
192
193 [[nodiscard]] auto is_visited(std::uint32_t region_index) const noexcept
194 -> bool {
195 return visited_epoch_[static_cast<std::size_t>(region_index)] == epoch_;
196 }
197
198 void visit(std::uint32_t region_index) noexcept {
199 visited_epoch_[static_cast<std::size_t>(region_index)] = epoch_;
200 }
201
202 std::vector<std::uint32_t> frontier_;
203 std::vector<std::uint32_t> visited_epoch_;
204 std::uint32_t epoch_ = 0;
205};
206
209 public:
210 void clear() noexcept {
211 chunk_ = ChunkKey{0};
212 chunk_coord_ = ChunkCoord3{};
213 version_ = 0;
214 region_ids_.clear();
215 regions_.clear();
216 boundary_exits_.clear();
217 }
218
219 [[nodiscard]] auto chunk() const noexcept -> ChunkKey { return chunk_; }
220
221 [[nodiscard]] auto chunk_coord() const noexcept -> ChunkCoord3 {
222 return chunk_coord_;
223 }
224
225 [[nodiscard]] auto version() const noexcept -> std::uint32_t {
226 return version_;
227 }
228
229 [[nodiscard]] auto region_ids() const noexcept
230 -> std::span<const LocalRegionId> {
231 return {region_ids_.data(), region_ids_.size()};
232 }
233
234 [[nodiscard]] auto regions() const noexcept -> std::span<const LocalRegion> {
235 return {regions_.data(), regions_.size()};
236 }
237
238 // Checked accessor for the 1-based LocalRegionId convention: id N maps to
239 // regions()[N - 1]. Returns nullptr for the invalid sentinel and for
240 // out-of-range ids.
241 [[nodiscard]] auto region(LocalRegionId id) const noexcept
242 -> const LocalRegion* {
243 if (id.value == 0 || id.value > regions_.size()) {
244 return nullptr;
245 }
246 return &regions_[static_cast<std::size_t>(id.value) - 1];
247 }
248
249 [[nodiscard]] auto boundary_exits() const noexcept
250 -> std::span<const LocalBoundaryExit> {
251 return {boundary_exits_.data(), boundary_exits_.size()};
252 }
253
254 [[nodiscard]] auto region_at(LocalTileId tile) const noexcept
255 -> LocalRegionId {
256 if (tile.value >= region_ids_.size()) {
257 return invalid_local_region;
258 }
259 return region_ids_[static_cast<std::size_t>(tile.value)];
260 }
261
262 template <typename Shape>
263 [[nodiscard]] auto region_at(LocalCoord3 coord) const noexcept
264 -> LocalRegionId {
265 return region_at(local_tile_id<Shape>(coord));
266 }
267
268 private:
269 template <typename World, typename PassableTag>
270 friend auto build_local_chunk_topology(const World& world, ChunkKey chunk,
271 LocalTopologyScratch& scratch,
272 LocalChunkTopology& topology)
274
275 ChunkKey chunk_{};
276 ChunkCoord3 chunk_coord_{};
277 std::uint32_t version_ = 0;
278 std::vector<LocalRegionId> region_ids_;
279 std::vector<LocalRegion> regions_;
280 std::vector<LocalBoundaryExit> boundary_exits_;
281};
282
283namespace detail {
284
285// Sparse-only companion state for RegionGraphT. Empty (via the explicit
286// AlwaysResident specialization) so a dense graph carries zero extra storage
287// through the [[no_unique_address]] member.
288template <typename Residency>
289struct RegionGraphSparseData {
290 // Frozen at build, sorted ascending by ChunkKey.value: the resident chunk set
291 // this graph was built over. Resolves ChunkKey -> local index by lower_bound,
292 // world-free, so eviction after the build cannot invalidate the graph.
293 std::vector<ChunkKey> topology_keys_;
294 // One flag per global region: the region has a boundary exit into a chunk
295 // that was non-resident at build time (a route through it cannot be ruled
296 // out).
297 std::vector<std::uint8_t> region_reaches_missing_;
298 // Per local topology: the residency generation at build, for staleness
299 // detection in update_region_graph.
300 std::vector<std::uint64_t> frozen_generations_;
301};
302
303template <>
304struct RegionGraphSparseData<AlwaysResident> {};
305
306} // namespace detail
307
308template <typename Residency>
310 public:
311 void clear() noexcept {
312 local_topologies_.clear();
313 portals_.clear();
314 region_offsets_.clear();
315 adjacency_starts_.clear();
316 adjacency_targets_.clear();
317 built_chunk_grid_ = Extent3{0, 0, 0};
318 built_chunk_extent_ = Extent3{0, 0, 0};
319 built_class_ = 0;
320 built_provider_ = 0;
321 built_provider_revision_ = 0;
322 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
323 sparse_.topology_keys_.clear();
324 sparse_.region_reaches_missing_.clear();
325 sparse_.frozen_generations_.clear();
326 }
327 }
328
329 [[nodiscard]] auto local_topologies() const noexcept
330 -> std::span<const LocalChunkTopology> {
331 return {local_topologies_.data(), local_topologies_.size()};
332 }
333
334 [[nodiscard]] auto portals() const noexcept -> std::span<const RegionPortal> {
335 return {portals_.data(), portals_.size()};
336 }
337
338 [[nodiscard]] auto local_topology(ChunkKey chunk) const noexcept
339 -> const LocalChunkTopology* {
340 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
341 if (chunk.value >= local_topologies_.size()) {
342 return nullptr;
343 }
344 return &local_topologies_[static_cast<std::size_t>(chunk.value)];
345 } else {
346 const auto idx = local_index(chunk);
347 if (idx == npos) {
348 return nullptr;
349 }
350 return &local_topologies_[idx];
351 }
352 }
353
354 template <typename Shape>
355 [[nodiscard]] auto region_of(Coord3 coord) const noexcept -> RegionRef {
356 if (!contains<Shape>(coord)) {
357 return RegionRef{ChunkKey{std::numeric_limits<std::uint64_t>::max()},
358 invalid_local_region};
359 }
360 const auto key = chunk_key<Shape>(chunk_coord<Shape>(coord));
361 const auto* local = local_topology(key);
362 if (local == nullptr) {
363 return RegionRef{key, invalid_local_region};
364 }
365 return RegionRef{
366 key, local->region_at(local_tile_id<Shape>(local_coord<Shape>(coord)))};
367 }
368
369 // Total region count across all chunks in the dense global region index.
370 [[nodiscard]] auto region_count() const noexcept -> std::uint32_t {
371 return region_offsets_.empty() ? 0U : region_offsets_.back();
372 }
373
374 // Maps a region reference to its dense global index:
375 // region_offsets_[chunk] + (1-based local id - 1). Returns
376 // invalid_region_index for invalid or out-of-range references. The chunk
377 // guard compares without +1 (the sentinel ChunkKey region_of returns for
378 // out-of-world coordinates would wrap past it) and the offset arithmetic
379 // is 64-bit (a region id near 2^32 would wrap back into a valid index).
380 [[nodiscard]] auto region_index(RegionRef ref) const noexcept
381 -> std::uint32_t {
382 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
383 if (ref.region == invalid_local_region ||
384 ref.chunk.value >= local_topologies_.size()) {
385 return invalid_region_index;
386 }
387 const auto chunk = static_cast<std::size_t>(ref.chunk.value);
388 const auto index = static_cast<std::uint64_t>(region_offsets_[chunk]) +
389 ref.region.value - 1;
390 if (index >= region_offsets_[chunk + 1]) {
391 return invalid_region_index;
392 }
393 return static_cast<std::uint32_t>(index);
394 } else {
395 if (ref.region == invalid_local_region) {
396 return invalid_region_index;
397 }
398 const auto li = local_index(ref.chunk);
399 if (li == npos || li + 1 >= region_offsets_.size()) {
400 return invalid_region_index;
401 }
402 const auto index = static_cast<std::uint64_t>(region_offsets_[li]) +
403 ref.region.value - 1;
404 if (index >= region_offsets_[li + 1]) {
405 return invalid_region_index;
406 }
407 return static_cast<std::uint32_t>(index);
408 }
409 }
410
411 // True iff this graph was built for `ClassOrTag` (normalized, so a raw tag
412 // and its WalkableField identity agree). The graph type does not encode the
413 // movement class, so a graph labeled for one class must never answer
414 // reachability for another: update_region_graph treats a mismatch as "not
415 // built for this class" (full rebuild) and is_region_graph_fresh_for
416 // reports it as not fresh. False until the first build.
417 template <typename ClassOrTag>
418 [[nodiscard]] auto matches_class() const noexcept -> bool {
419 return built_class_ ==
420 detail::tag_identity<movement::movement_class_of<ClassOrTag>>();
421 }
422
423 // True iff this graph was built with transition provider `Provider`
424 // (AdjacentTransitions for the providerless overloads). Mirrors the class
425 // stamp: update_region_graph with a different provider type falls back to
426 // a full rebuild rather than patching with mismatched special-transition
427 // edges. False until the first build.
428 template <typename Provider>
429 [[nodiscard]] auto matches_provider() const noexcept -> bool {
430 return built_provider_ == detail::tag_identity<Provider>();
431 }
432
433 // Instance-aware provider match used by incremental updates. Stateful
434 // providers must advance their revision whenever emitted edges can change;
435 // a mismatch forces a full rebuild even when the provider type is unchanged.
436 template <typename Provider>
437 [[nodiscard]] auto matches_provider(const Provider& provider) const noexcept
438 -> bool {
439 return matches_provider<Provider>() &&
440 built_provider_revision_ ==
441 detail::transition_provider_revision(provider);
442 }
443
444 private:
445 template <typename World, typename ClassOrTag, typename Provider>
446 friend auto build_region_graph(
447 const World& world, LocalTopologyScratch& scratch,
449 const Provider& provider) -> LocalTopologyResult;
450
451 template <typename World, typename ClassOrTag, typename Provider>
452 friend auto update_region_graph(
453 const World& world, LocalTopologyScratch& scratch,
455 std::span<const ChunkKey> dirty_chunks, const Provider& provider)
457
458 template <typename Shape, typename OtherResidency>
459 friend auto reachable(const RegionGraphT<OtherResidency>& graph, Coord3 start,
460 Coord3 goal, RegionGraphScratch& scratch)
462
463 template <typename OtherWorld>
464 friend auto is_region_graph_fresh(
465 const OtherWorld& world,
467 -> bool;
468
469 // Rebuilds the dense region index and the CSR portal adjacency. The CSR
470 // fill preserves portal order within each from-region bucket so
471 // traversal remains deterministic.
472 void rebuild_region_index() {
473 region_offsets_.assign(local_topologies_.size() + 1, 0);
474 for (std::size_t i = 0; i < local_topologies_.size(); ++i) {
475 region_offsets_[i + 1] =
476 region_offsets_[i] +
477 static_cast<std::uint32_t>(local_topologies_[i].regions().size());
478 }
479
480 adjacency_starts_.assign(static_cast<std::size_t>(region_count()) + 1, 0);
481 for (const auto& portal : portals_) {
482 ++adjacency_starts_[static_cast<std::size_t>(region_index(portal.from)) +
483 1];
484 }
485 for (std::size_t i = 1; i < adjacency_starts_.size(); ++i) {
486 adjacency_starts_[i] += adjacency_starts_[i - 1];
487 }
488
489 adjacency_targets_.resize(portals_.size());
490 auto cursor = adjacency_starts_;
491 for (const auto& portal : portals_) {
492 const auto from = static_cast<std::size_t>(region_index(portal.from));
493 adjacency_targets_[static_cast<std::size_t>(cursor[from]++)] =
494 region_index(portal.to);
495 }
496
497 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
498 // Flag every region with a boundary exit into a non-resident chunk. The
499 // membership test (has_chunk) -- not portal absence -- is what separates
500 // "unknown, unloaded" from "a real wall in a resident neighbor". Keyed by
501 // the same global region index the BFS/CSR use, so reachable reads it
502 // directly.
503 sparse_.region_reaches_missing_.assign(
504 static_cast<std::size_t>(region_count()), 0);
505 for (const auto& topology : local_topologies_) {
506 for (const auto& exit : topology.boundary_exits()) {
507 if (has_chunk(exit.target_chunk)) {
508 continue;
509 }
510 const auto idx =
511 region_index(RegionRef{topology.chunk(), exit.region});
512 if (idx != invalid_region_index) {
513 sparse_.region_reaches_missing_[static_cast<std::size_t>(idx)] = 1;
514 }
515 }
516 }
517 }
518 }
519
520 // Shape binding captured at build time. The graph type is templated on
521 // residency only, so two Shapes with equal chunk counts share it; the
522 // chunk-grid and per-chunk tile extents recorded here tell them apart.
523 // update_region_graph treats a mismatch as "not built for this world" (full
524 // rebuild); is_region_graph_fresh reports it as not fresh.
525 template <typename Shape>
526 [[nodiscard]] auto matches_shape() const noexcept -> bool {
527 using Traits = ShapeTraits<Shape>;
528 return built_chunk_grid_ == Extent3{Traits::chunk_count_x,
529 Traits::chunk_count_y,
530 Traits::chunk_count_z} &&
531 built_chunk_extent_ == Traits::chunk;
532 }
533
534 template <typename Shape>
535 void bind_shape() noexcept {
536 using Traits = ShapeTraits<Shape>;
537 built_chunk_grid_ = Extent3{Traits::chunk_count_x, Traits::chunk_count_y,
538 Traits::chunk_count_z};
539 built_chunk_extent_ = Traits::chunk;
540 }
541
542 // Movement-class and provider bindings captured at build time, mirroring
543 // the shape stamp (see the public matches_class / matches_provider).
544 template <typename ClassOrTag>
545 void bind_class() noexcept {
546 built_class_ =
547 detail::tag_identity<movement::movement_class_of<ClassOrTag>>();
548 }
549
550 template <typename Provider>
551 void bind_provider(const Provider& provider) noexcept {
552 built_provider_ = detail::tag_identity<Provider>();
553 built_provider_revision_ = detail::transition_provider_revision(provider);
554 }
555
556 // Sparse only: flags every region owning a provider transition that lands
557 // in a NON-RESIDENT chunk, so reachability answers Indeterminate rather
558 // than a wrong Unreachable across an unloaded special transition. Must run
559 // after rebuild_region_index, which reassigns the flags from boundary
560 // exits alone.
561 template <typename Shape, typename World, typename Provider>
562 void mark_provider_missing_reaches(
563 [[maybe_unused]] const World& world,
564 [[maybe_unused]] const Provider& provider) {
565 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
566 for (const auto& topology : local_topologies_) {
567 provider.for_each_transition(
568 world, topology.chunk(), [&](Coord3 from, Coord3 to) {
569 if (!contains<Shape>(to) ||
570 has_chunk(chunk_key<Shape>(chunk_coord<Shape>(to)))) {
571 return;
572 }
573 const auto source = this->template region_of<Shape>(from);
574 if (source.region == invalid_local_region) {
575 return;
576 }
577 const auto idx = region_index(source);
578 if (idx != invalid_region_index) {
579 sparse_.region_reaches_missing_[static_cast<std::size_t>(idx)] =
580 1;
581 }
582 });
583 }
584 }
585 }
586
587 static constexpr std::size_t npos = static_cast<std::size_t>(-1);
588
589 // Sparse only: resolve a ChunkKey to its position in the frozen sorted key
590 // set, or npos if the chunk was not resident when this graph was built.
591 [[nodiscard]] auto local_index(ChunkKey chunk) const noexcept -> std::size_t {
592 const auto& keys = sparse_.topology_keys_;
593 const auto it = std::lower_bound(
594 keys.begin(), keys.end(), chunk,
595 [](ChunkKey lhs, ChunkKey rhs) { return lhs.value < rhs.value; });
596 if (it == keys.end() || it->value != chunk.value) {
597 return npos;
598 }
599 return static_cast<std::size_t>(it - keys.begin());
600 }
601
602 [[nodiscard]] auto has_chunk(ChunkKey chunk) const noexcept -> bool {
603 return local_index(chunk) != npos;
604 }
605
606 std::vector<LocalChunkTopology> local_topologies_;
607 std::vector<RegionPortal> portals_;
608 std::vector<std::uint32_t> region_offsets_;
609 std::vector<std::uint32_t> adjacency_starts_;
610 std::vector<std::uint32_t> adjacency_targets_;
611 // Zero until the first build, so an unbuilt graph never matches any shape,
612 // movement class, or transition provider.
613 Extent3 built_chunk_grid_{0, 0, 0};
614 Extent3 built_chunk_extent_{0, 0, 0};
615 std::uintptr_t built_class_ = 0;
616 std::uintptr_t built_provider_ = 0;
617 std::uint64_t built_provider_revision_ = 0;
618 [[no_unique_address]] detail::RegionGraphSparseData<Residency> sparse_;
619};
620
622using RegionGraph = RegionGraphT<AlwaysResident>;
624using SparseRegionGraph = RegionGraphT<SparseResident>;
625
626namespace detail {
627
628template <typename Shape>
629[[nodiscard]] constexpr auto local_tile_coord(LocalTileId id) noexcept
630 -> LocalCoord3 {
631 const auto chunk = ShapeTraits<Shape>::chunk;
632 const auto xy = chunk.x * chunk.y;
633 const auto z = id.value / xy;
634 const auto remainder = id.value % xy;
635 return LocalCoord3{
636 remainder % chunk.x,
637 remainder / chunk.x,
638 z,
639 };
640}
641
642template <typename Shape>
643constexpr void add_boundary_exit(std::vector<LocalBoundaryExit>& exits,
644 LocalRegion& region, LocalTileId local_tile,
645 Coord3 coord, BoundaryFace face,
646 ChunkCoord3 target_chunk) {
647 exits.push_back(LocalBoundaryExit{
648 region.id,
649 local_tile,
650 coord,
651 face,
652 chunk_key<Shape>(target_chunk),
653 });
654 ++region.boundary_exit_count;
655}
656
657template <typename Shape>
658constexpr void add_boundary_exits(std::vector<LocalBoundaryExit>& exits,
659 ChunkCoord3 chunk_coord, LocalRegion& region,
660 LocalTileId local_tile, LocalCoord3 local,
661 Coord3 coord) {
662 const auto chunk = ShapeTraits<Shape>::chunk;
663
664 if (local.x == 0 && chunk_coord.x > 0) {
665 auto target = chunk_coord;
666 --target.x;
667 add_boundary_exit<Shape>(exits, region, local_tile, coord,
668 BoundaryFace::NegativeX, target);
669 }
670 if (local.x + 1 == chunk.x &&
671 chunk_coord.x + 1 < ShapeTraits<Shape>::chunk_count_x) {
672 auto target = chunk_coord;
673 ++target.x;
674 add_boundary_exit<Shape>(exits, region, local_tile, coord,
675 BoundaryFace::PositiveX, target);
676 }
677 if (local.y == 0 && chunk_coord.y > 0) {
678 auto target = chunk_coord;
679 --target.y;
680 add_boundary_exit<Shape>(exits, region, local_tile, coord,
681 BoundaryFace::NegativeY, target);
682 }
683 if (local.y + 1 == chunk.y &&
684 chunk_coord.y + 1 < ShapeTraits<Shape>::chunk_count_y) {
685 auto target = chunk_coord;
686 ++target.y;
687 add_boundary_exit<Shape>(exits, region, local_tile, coord,
688 BoundaryFace::PositiveY, target);
689 }
690 if (local.z == 0 && chunk_coord.z > 0) {
691 auto target = chunk_coord;
692 --target.z;
693 add_boundary_exit<Shape>(exits, region, local_tile, coord,
694 BoundaryFace::NegativeZ, target);
695 }
696 if (local.z + 1 == chunk.z &&
697 chunk_coord.z + 1 < ShapeTraits<Shape>::chunk_count_z) {
698 auto target = chunk_coord;
699 ++target.z;
700 add_boundary_exit<Shape>(exits, region, local_tile, coord,
701 BoundaryFace::PositiveZ, target);
702 }
703}
704
705template <typename Shape, typename Fn>
706constexpr void for_each_local_axis_neighbor(LocalCoord3 coord, Fn&& fn) {
707 const auto chunk = ShapeTraits<Shape>::chunk;
708 if (coord.x + 1 < chunk.x) {
709 fn(LocalCoord3{coord.x + 1, coord.y, coord.z});
710 }
711 if (coord.x > 0) {
712 fn(LocalCoord3{coord.x - 1, coord.y, coord.z});
713 }
714 if (coord.y + 1 < chunk.y) {
715 fn(LocalCoord3{coord.x, coord.y + 1, coord.z});
716 }
717 if (coord.y > 0) {
718 fn(LocalCoord3{coord.x, coord.y - 1, coord.z});
719 }
720 if (coord.z + 1 < chunk.z) {
721 fn(LocalCoord3{coord.x, coord.y, coord.z + 1});
722 }
723 if (coord.z > 0) {
724 fn(LocalCoord3{coord.x, coord.y, coord.z - 1});
725 }
726}
727
728constexpr void include_coord_in_bounds(LocalRegion& region,
729 Coord3 coord) noexcept {
730 if (region.tile_count == 0) {
731 region.bounds = Box3{coord, Extent3{1, 1, 1}};
732 return;
733 }
734
735 const auto end = [](std::int64_t origin, std::uint64_t extent) {
736 // Same saturation as detail::box_axis_end in storage/chunk_meta.h: an
737 // extent >= 2^63 would flip the int64 cast negative and corrupt bounds.
738 constexpr auto max = std::numeric_limits<std::int64_t>::max();
739 if (extent > static_cast<std::uint64_t>(max)) {
740 return max;
741 }
742 const auto delta = static_cast<std::int64_t>(extent);
743 return origin > max - delta ? max : origin + delta;
744 };
745 const auto min = [](std::int64_t lhs, std::int64_t rhs) {
746 return lhs < rhs ? lhs : rhs;
747 };
748 const auto max = [](std::int64_t lhs, std::int64_t rhs) {
749 return lhs < rhs ? rhs : lhs;
750 };
751 const auto min_x = min(region.bounds.origin.x, coord.x);
752 const auto min_y = min(region.bounds.origin.y, coord.y);
753 const auto min_z = min(region.bounds.origin.z, coord.z);
754 const auto max_x =
755 max(end(region.bounds.origin.x, region.bounds.extent.x), coord.x + 1);
756 const auto max_y =
757 max(end(region.bounds.origin.y, region.bounds.extent.y), coord.y + 1);
758 const auto max_z =
759 max(end(region.bounds.origin.z, region.bounds.extent.z), coord.z + 1);
760
761 region.bounds = Box3{
762 Coord3{min_x, min_y, min_z},
763 // max >= min on every axis; abs_delta subtracts in unsigned space, so a
764 // saturated end paired with a negative origin cannot overflow int64.
765 Extent3{
766 abs_delta(max_x, min_x),
767 abs_delta(max_y, min_y),
768 abs_delta(max_z, min_z),
769 },
770 };
771}
772
773[[nodiscard]] constexpr auto neighbor_coord(Coord3 coord,
774 BoundaryFace face) noexcept
775 -> Coord3 {
776 switch (face) {
777 case BoundaryFace::NegativeX:
778 return Coord3{coord.x - 1, coord.y, coord.z};
779 case BoundaryFace::PositiveX:
780 return Coord3{coord.x + 1, coord.y, coord.z};
781 case BoundaryFace::NegativeY:
782 return Coord3{coord.x, coord.y - 1, coord.z};
783 case BoundaryFace::PositiveY:
784 return Coord3{coord.x, coord.y + 1, coord.z};
785 case BoundaryFace::NegativeZ:
786 return Coord3{coord.x, coord.y, coord.z - 1};
787 case BoundaryFace::PositiveZ:
788 return Coord3{coord.x, coord.y, coord.z + 1};
789 }
790 return coord;
791}
792
793template <typename Shape, typename Fn>
794constexpr void for_each_face_neighbor_chunk(ChunkCoord3 coord, Fn&& fn) {
795 using Traits = ShapeTraits<Shape>;
796 if (coord.x > 0) {
797 auto target = coord;
798 --target.x;
799 fn(target);
800 }
801 if (coord.x + 1 < Traits::chunk_count_x) {
802 auto target = coord;
803 ++target.x;
804 fn(target);
805 }
806 if (coord.y > 0) {
807 auto target = coord;
808 --target.y;
809 fn(target);
810 }
811 if (coord.y + 1 < Traits::chunk_count_y) {
812 auto target = coord;
813 ++target.y;
814 fn(target);
815 }
816 if (coord.z > 0) {
817 auto target = coord;
818 --target.z;
819 fn(target);
820 }
821 if (coord.z + 1 < Traits::chunk_count_z) {
822 auto target = coord;
823 ++target.z;
824 fn(target);
825 }
826}
827
828// Face reported for a provider transition: the dominant axis of the delta
829// (ties fall to x, then y, then z). Reachability reads only the CSR
830// adjacency, so the face is diagnostic labeling for special transitions.
831[[nodiscard]] inline auto transition_face(Coord3 from, Coord3 to) noexcept
832 -> BoundaryFace {
833 const auto dx = to.x - from.x;
834 const auto dy = to.y - from.y;
835 const auto dz = to.z - from.z;
836 const auto ax = dx < 0 ? -dx : dx;
837 const auto ay = dy < 0 ? -dy : dy;
838 const auto az = dz < 0 ? -dz : dz;
839 if (ax >= ay && ax >= az) {
840 return dx < 0 ? BoundaryFace::NegativeX : BoundaryFace::PositiveX;
841 }
842 if (ay >= az) {
843 return dy < 0 ? BoundaryFace::NegativeY : BoundaryFace::PositiveY;
844 }
845 return dz < 0 ? BoundaryFace::NegativeZ : BoundaryFace::PositiveZ;
846}
847
848// True iff the two coordinates' chunks are identical or face-adjacent -- the
849// provider contract that keeps incremental invalidation sound (updates
850// re-derive portals only for dirty chunks and their face neighbors).
851template <typename Shape>
852[[nodiscard]] auto same_or_face_neighbor_chunk(Coord3 from, Coord3 to) noexcept
853 -> bool {
854 const auto a = chunk_coord<Shape>(from);
855 const auto b = chunk_coord<Shape>(to);
856 const auto dx = a.x < b.x ? b.x - a.x : a.x - b.x;
857 const auto dy = a.y < b.y ? b.y - a.y : a.y - b.y;
858 const auto dz = a.z < b.z ? b.z - a.z : a.z - b.z;
859 return dx + dy + dz <= 1;
860}
861
862// Appends one directed portal per provider transition originating in this
863// chunk whose endpoints both resolve to labeled regions, in enumeration
864// order. Out-of-shape or unlabeled endpoints contribute nothing; a sparse
865// non-resident landing is handled by the builder's reaches-missing pass.
866template <typename Shape, typename World, typename Residency, typename Provider>
867void append_provider_portals(const World& world,
868 const RegionGraphT<Residency>& graph,
869 const LocalChunkTopology& topology,
870 const Provider& provider,
871 std::vector<RegionPortal>& portals) {
872 provider.for_each_transition(
873 world, topology.chunk(), [&](Coord3 from, Coord3 to) {
874 TESS_ASSERT(contains<Shape>(from));
875 TESS_ASSERT(chunk_key<Shape>(chunk_coord<Shape>(from)).value ==
876 topology.chunk().value);
877 if (!contains<Shape>(to)) {
878 return;
879 }
880 TESS_ASSERT((same_or_face_neighbor_chunk<Shape>(from, to)));
881 const auto source = graph.template region_of<Shape>(from);
882 if (source.region == invalid_local_region) {
883 return;
884 }
885 const auto target = graph.template region_of<Shape>(to);
886 if (target.region == invalid_local_region) {
887 return;
888 }
889 portals.push_back(
890 RegionPortal{source, target, from, to, transition_face(from, to)});
891 });
892}
893
894// Derives directed portals for every boundary exit of one chunk topology,
895// in exit order, appending only exits whose neighbor tile maps to a
896// passable region.
897template <typename Shape, typename Residency>
898void append_chunk_portals(const RegionGraphT<Residency>& graph,
899 const LocalChunkTopology& topology,
900 std::vector<RegionPortal>& portals) {
901 for (const auto& exit : topology.boundary_exits()) {
902 const auto to_coord = neighbor_coord(exit.coord, exit.face);
903 const auto target = graph.template region_of<Shape>(to_coord);
904 if (target.region == invalid_local_region) {
905 continue;
906 }
907 portals.push_back(RegionPortal{
908 RegionRef{topology.chunk(), exit.region},
909 target,
910 exit.coord,
911 to_coord,
912 exit.face,
913 });
914 }
915}
916
917} // namespace detail
918
920template <typename World, typename ClassOrTag>
922 LocalTopologyScratch& scratch,
923 LocalChunkTopology& topology)
925 using Shape = typename World::shape_type;
926 using Traits = ShapeTraits<Shape>;
927 using Class = movement::movement_class_of<ClassOrTag>;
928
929 topology.clear();
930 if (chunk.value >= Traits::chunk_count) {
931 return LocalTopologyResult{TopologyStatus::InvalidChunk, 0, 0, 0, 0};
932 }
933 if constexpr (std::is_same_v<typename World::residency_type,
935 if (!world.is_resident(chunk)) {
936 return LocalTopologyResult{TopologyStatus::MissingChunk, 0, 0, 0, 0};
937 }
938 }
939
940 topology.chunk_ = chunk;
941 topology.chunk_coord_ = chunk_coord<Shape>(chunk);
942 topology.version_ = world.meta(chunk).topology_version;
943 topology.region_ids_.assign(
944 static_cast<std::size_t>(Traits::local_tile_count), invalid_local_region);
945 scratch.stack_.clear();
946
947 // Identity classes flood the raw field span exactly as the legacy
948 // single-tag build did (byte-identical labels and codegen); composed
949 // classes evaluate their predicate on the resolved page per tile.
950 const auto& page = world.chunk(chunk);
951 [[maybe_unused]] const auto passable = [&] {
952 if constexpr (movement::HasPassableSpan<Class>) {
953 return Class::passable_span(page);
954 } else {
955 return nullptr;
956 }
957 }();
958 const auto tile_passable = [&](LocalTileId id) -> bool {
959 if constexpr (movement::HasPassableSpan<Class>) {
960 return static_cast<bool>(passable[static_cast<std::size_t>(id.value)]);
961 } else {
962 return Class::passable(page, id);
963 }
964 };
965 std::size_t passable_tiles = 0;
966
967 for (std::uint64_t raw_id = 0; raw_id < Traits::local_tile_count; ++raw_id) {
968 const auto tile = LocalTileId{raw_id};
969 const auto offset = static_cast<std::size_t>(raw_id);
970 if (!tile_passable(tile) ||
971 topology.region_ids_[offset] != invalid_local_region) {
972 continue;
973 }
974
975 const auto region_id =
976 LocalRegionId{static_cast<std::uint32_t>(topology.regions_.size() + 1)};
977 topology.regions_.push_back(LocalRegion{region_id});
978 scratch.stack_.push_back(tile);
979 topology.region_ids_[offset] = region_id;
980
981 while (!scratch.stack_.empty()) {
982 const auto current = scratch.stack_.back();
983 scratch.stack_.pop_back();
984 const auto local = detail::local_tile_coord<Shape>(current);
985 const auto coord = tess::coord<Shape>(topology.chunk_coord_, current);
986 auto& region = topology.regions_.back();
987 detail::include_coord_in_bounds(region, coord);
988 ++region.tile_count;
989 ++passable_tiles;
990 detail::add_boundary_exits<Shape>(topology.boundary_exits_,
991 topology.chunk_coord_, region, current,
992 local, coord);
993
994 detail::for_each_local_axis_neighbor<Shape>(
995 local, [&](LocalCoord3 neighbor_coord) {
996 const auto neighbor = local_tile_id<Shape>(neighbor_coord);
997 const auto neighbor_offset =
998 static_cast<std::size_t>(neighbor.value);
999 if (!tile_passable(neighbor) ||
1000 topology.region_ids_[neighbor_offset] != invalid_local_region) {
1001 return;
1002 }
1003 topology.region_ids_[neighbor_offset] = region_id;
1004 scratch.stack_.push_back(neighbor);
1005 });
1006 }
1007 }
1008
1009 return LocalTopologyResult{
1010 TopologyStatus::Built, topology.regions_.size(), passable_tiles,
1011 topology.boundary_exits_.size(), topology.version_,
1012 };
1013}
1014
1015template <typename World, typename ClassOrTag,
1016 typename Provider = AdjacentTransitions>
1020 const Provider& provider = {}) -> LocalTopologyResult {
1022 "build_region_graph's provider must satisfy "
1023 "TransitionProviderFor (see transition_provider.h).");
1024 using Shape = typename World::shape_type;
1025 using Traits = ShapeTraits<Shape>;
1026 using Class = movement::movement_class_of<ClassOrTag>;
1027
1028 graph.clear();
1029 graph.template bind_shape<Shape>();
1030 graph.template bind_class<Class>();
1031 graph.bind_provider(provider);
1032 auto result = LocalTopologyResult{};
1033
1034 if constexpr (std::is_same_v<typename World::residency_type,
1035 AlwaysResident>) {
1036 graph.local_topologies_.resize(
1037 static_cast<std::size_t>(Traits::chunk_count));
1038 for (std::uint64_t raw_chunk = 0; raw_chunk < Traits::chunk_count;
1039 ++raw_chunk) {
1040 auto& topology =
1041 graph.local_topologies_[static_cast<std::size_t>(raw_chunk)];
1042 const auto local_result = build_local_chunk_topology<World, Class>(
1043 world, ChunkKey{raw_chunk}, scratch, topology);
1044 if (local_result.status != TopologyStatus::Built) {
1045 result.status = local_result.status;
1046 graph.rebuild_region_index();
1047 return result;
1048 }
1049 result.region_count += local_result.region_count;
1050 result.passable_tile_count += local_result.passable_tile_count;
1051 result.boundary_exit_count += local_result.boundary_exit_count;
1052 result.version += local_result.version;
1053 }
1054 } else {
1055 // Sparse: build only over the resident set, sized by resident_count, never
1056 // chunk_count. Freeze the resident keys sorted ascending so a local index
1057 // equals chunk order; portals then append in chunk order exactly as the
1058 // dense build does, keeping "incremental == fresh" trivially.
1059 auto& keys = graph.sparse_.topology_keys_;
1060 const auto resident = world.resident_chunk_keys();
1061 keys.assign(resident.begin(), resident.end());
1062 std::sort(keys.begin(), keys.end(),
1063 [](ChunkKey lhs, ChunkKey rhs) { return lhs.value < rhs.value; });
1064 const auto count = keys.size();
1065 graph.local_topologies_.resize(count);
1066 graph.sparse_.frozen_generations_.resize(count);
1067 for (std::size_t i = 0; i < count; ++i) {
1068 const auto local_result = build_local_chunk_topology<World, Class>(
1069 world, keys[i], scratch, graph.local_topologies_[i]);
1070 // Resident keys are always in-world, so InvalidChunk cannot arise; keep
1071 // the status propagation for symmetry with the dense build.
1072 if (local_result.status != TopologyStatus::Built) {
1073 result.status = local_result.status;
1074 graph.rebuild_region_index();
1075 return result;
1076 }
1077 result.region_count += local_result.region_count;
1078 result.passable_tile_count += local_result.passable_tile_count;
1079 result.boundary_exit_count += local_result.boundary_exit_count;
1080 result.version += local_result.version;
1081 graph.sparse_.frozen_generations_[i] =
1082 world.residency_generation(keys[i]);
1083 }
1084 }
1085
1086 for (const auto& topology : graph.local_topologies_) {
1087 detail::append_chunk_portals<Shape>(graph, topology, graph.portals_);
1088 detail::append_provider_portals<Shape>(world, graph, topology, provider,
1089 graph.portals_);
1090 }
1091 graph.rebuild_region_index();
1092 graph.template mark_provider_missing_reaches<Shape>(world, provider);
1093
1094 return result;
1095}
1096
1097// Incrementally patches an already-built region graph after passability
1098// edits confined to `dirty_chunks`. Rebuilds local topology for each dirty
1099// chunk, re-derives portals for dirty chunks and their face neighbors, and
1100// restores the canonical full-build portal order, so the resulting graph is
1101// identical to a fresh build_region_graph over the edited world. An empty
1102// dirty set leaves the graph untouched. Returns the aggregate
1103// LocalTopologyResult over all chunks, mirroring build_region_graph. If the
1104// graph was not built for this world shape, falls back to a full build.
1105template <typename World, typename ClassOrTag,
1106 typename Provider = AdjacentTransitions>
1110 std::span<const ChunkKey> dirty_chunks,
1111 const Provider& provider = {}) -> LocalTopologyResult {
1113 "update_region_graph's provider must satisfy "
1114 "TransitionProviderFor (see transition_provider.h).");
1115 using Shape = typename World::shape_type;
1116 using Traits = ShapeTraits<Shape>;
1117 using Class = movement::movement_class_of<ClassOrTag>;
1118
1119 if constexpr (std::is_same_v<typename World::residency_type,
1120 AlwaysResident>) {
1121 const auto chunk_count = static_cast<std::size_t>(Traits::chunk_count);
1122 if (graph.local_topologies_.size() != chunk_count ||
1123 !graph.template matches_shape<Shape>() ||
1124 !graph.template matches_class<Class>() ||
1125 !graph.matches_provider(provider)) {
1126 return build_region_graph<World, Class>(world, scratch, graph, provider);
1127 }
1128 for (const auto chunk : dirty_chunks) {
1129 if (chunk.value >= Traits::chunk_count) {
1130 return LocalTopologyResult{TopologyStatus::InvalidChunk, 0, 0, 0, 0};
1131 }
1132 }
1133
1134 if (!dirty_chunks.empty()) {
1135 // Mark dirty chunks, then widen to every face neighbor: those are the
1136 // only chunks whose outgoing portals can reference a dirty chunk.
1137 std::vector<std::uint8_t> dirty(chunk_count, 0);
1138 std::vector<std::uint8_t> affected(chunk_count, 0);
1139 for (const auto chunk : dirty_chunks) {
1140 const auto offset = static_cast<std::size_t>(chunk.value);
1141 dirty[offset] = 1;
1142 affected[offset] = 1;
1143 }
1144 for (std::size_t raw_chunk = 0; raw_chunk < chunk_count; ++raw_chunk) {
1145 if (dirty[raw_chunk] == 0) {
1146 continue;
1147 }
1148 detail::for_each_face_neighbor_chunk<Shape>(
1149 chunk_coord<Shape>(ChunkKey{raw_chunk}), [&](ChunkCoord3 neighbor) {
1150 const auto key = chunk_key<Shape>(neighbor);
1151 affected[static_cast<std::size_t>(key.value)] = 1;
1152 });
1153 }
1154
1155 for (std::size_t raw_chunk = 0; raw_chunk < chunk_count; ++raw_chunk) {
1156 if (dirty[raw_chunk] == 0) {
1157 continue;
1158 }
1159 build_local_chunk_topology<World, Class>(
1160 world, ChunkKey{raw_chunk}, scratch,
1161 graph.local_topologies_[raw_chunk]);
1162 }
1163
1164 // Every invalidated portal originates from an affected chunk, because
1165 // portals only span face-adjacent chunks. Drop them in one filtered
1166 // pass, re-derive all portals of affected chunks in exit order, then
1167 // stable-sort by from-chunk to restore the canonical build order.
1168 std::erase_if(graph.portals_, [&](const RegionPortal& portal) {
1169 return affected[static_cast<std::size_t>(portal.from.chunk.value)] != 0;
1170 });
1171 for (std::size_t raw_chunk = 0; raw_chunk < chunk_count; ++raw_chunk) {
1172 if (affected[raw_chunk] == 0) {
1173 continue;
1174 }
1175 detail::append_chunk_portals<Shape>(
1176 graph, graph.local_topologies_[raw_chunk], graph.portals_);
1177 detail::append_provider_portals<Shape>(
1178 world, graph, graph.local_topologies_[raw_chunk], provider,
1179 graph.portals_);
1180 }
1181 std::stable_sort(graph.portals_.begin(), graph.portals_.end(),
1182 [](const RegionPortal& lhs, const RegionPortal& rhs) {
1183 return lhs.from.chunk.value < rhs.from.chunk.value;
1184 });
1185 graph.rebuild_region_index();
1186 }
1187 } else {
1188 // Sparse: any residency change since build forces a full rebuild (the graph
1189 // is frozen to a residency snapshot). Exact set-equality via resident_count
1190 // plus per-key generation: an evicted key reads generation 0, a reloaded
1191 // key gets a strictly greater monotonic generation, so equal count with all
1192 // frozen keys still at their frozen generation forces set identity.
1193 // Generations are per-WORLD clocks: a graph must only ever be updated
1194 // against the world it was built from (see is_region_graph_fresh).
1195 const auto count = graph.local_topologies_.size();
1196 if (count != world.resident_count() ||
1197 graph.sparse_.frozen_generations_.size() != world.resident_count() ||
1198 !graph.template matches_shape<Shape>() ||
1199 !graph.template matches_class<Class>() ||
1200 !graph.matches_provider(provider)) {
1201 return build_region_graph<World, Class>(world, scratch, graph, provider);
1202 }
1203 for (std::size_t i = 0; i < count; ++i) {
1204 if (world.residency_generation(graph.sparse_.topology_keys_[i]) !=
1205 graph.sparse_.frozen_generations_[i]) {
1206 return build_region_graph<World, Class>(world, scratch, graph,
1207 provider);
1208 }
1209 }
1210 for (const auto chunk : dirty_chunks) {
1211 if (chunk.value >= Traits::chunk_count) {
1212 return LocalTopologyResult{TopologyStatus::InvalidChunk, 0, 0, 0, 0};
1213 }
1214 }
1215
1216 if (!dirty_chunks.empty()) {
1217 // dirty/affected live in local-index space (size N = resident_count),
1218 // never chunk_count. A dirty chunk that is not resident holds no topology
1219 // in the frozen graph, so it is skipped.
1220 std::vector<std::uint8_t> dirty(count, 0);
1221 std::vector<std::uint8_t> affected(count, 0);
1222 for (const auto chunk : dirty_chunks) {
1223 const auto li = graph.local_index(chunk);
1224 if (li == graph.npos) {
1225 continue;
1226 }
1227 dirty[li] = 1;
1228 affected[li] = 1;
1229 }
1230 for (std::size_t i = 0; i < count; ++i) {
1231 if (dirty[i] == 0) {
1232 continue;
1233 }
1234 detail::for_each_face_neighbor_chunk<Shape>(
1235 chunk_coord<Shape>(graph.sparse_.topology_keys_[i]),
1236 [&](ChunkCoord3 neighbor) {
1237 const auto li = graph.local_index(chunk_key<Shape>(neighbor));
1238 if (li != graph.npos) {
1239 affected[li] = 1;
1240 }
1241 });
1242 }
1243
1244 for (std::size_t i = 0; i < count; ++i) {
1245 if (dirty[i] == 0) {
1246 continue;
1247 }
1248 build_local_chunk_topology<World, Class>(
1249 world, graph.sparse_.topology_keys_[i], scratch,
1250 graph.local_topologies_[i]);
1251 }
1252
1253 std::erase_if(graph.portals_, [&](const RegionPortal& portal) {
1254 const auto li = graph.local_index(portal.from.chunk);
1255 return li != graph.npos && affected[li] != 0;
1256 });
1257 for (std::size_t i = 0; i < count; ++i) {
1258 if (affected[i] == 0) {
1259 continue;
1260 }
1261 detail::append_chunk_portals<Shape>(graph, graph.local_topologies_[i],
1262 graph.portals_);
1263 detail::append_provider_portals<Shape>(
1264 world, graph, graph.local_topologies_[i], provider, graph.portals_);
1265 }
1266 std::stable_sort(graph.portals_.begin(), graph.portals_.end(),
1267 [](const RegionPortal& lhs, const RegionPortal& rhs) {
1268 return lhs.from.chunk.value < rhs.from.chunk.value;
1269 });
1270 graph.rebuild_region_index();
1271 graph.template mark_provider_missing_reaches<Shape>(world, provider);
1272 }
1273 }
1274
1275 auto result = LocalTopologyResult{};
1276 for (const auto& topology : graph.local_topologies_) {
1277 result.region_count += topology.regions().size();
1278 for (const auto& region : topology.regions()) {
1279 result.passable_tile_count += region.tile_count;
1280 }
1281 result.boundary_exit_count += topology.boundary_exits().size();
1282 result.version += topology.version();
1283 }
1284 return result;
1285}
1286
1288template <typename Shape, typename Residency>
1289auto reachable(const RegionGraphT<Residency>& graph, Coord3 start, Coord3 goal,
1291 if (!contains<Shape>(start)) {
1292 return ReachabilityResult{ReachabilityStatus::InvalidStart, 0};
1293 }
1294 if (!contains<Shape>(goal)) {
1295 return ReachabilityResult{ReachabilityStatus::InvalidGoal, 0};
1296 }
1297
1298 const auto start_region = graph.template region_of<Shape>(start);
1299 if (start_region.region == invalid_local_region) {
1300 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1301 // A non-resident endpoint cannot be answered: its region is unknown,
1302 // distinct from a resident-but-walled tile (InvalidStart below).
1303 if (!graph.has_chunk(chunk_key<Shape>(chunk_coord<Shape>(start)))) {
1304 return ReachabilityResult{ReachabilityStatus::Indeterminate, 0};
1305 }
1306 }
1307 return ReachabilityResult{ReachabilityStatus::InvalidStart, 0};
1308 }
1309 const auto goal_region = graph.template region_of<Shape>(goal);
1310 if (goal_region.region == invalid_local_region) {
1311 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1312 if (!graph.has_chunk(chunk_key<Shape>(chunk_coord<Shape>(goal)))) {
1313 return ReachabilityResult{ReachabilityStatus::Indeterminate, 0};
1314 }
1315 }
1316 return ReachabilityResult{ReachabilityStatus::InvalidGoal, 0};
1317 }
1318 if (start_region == goal_region) {
1319 return ReachabilityResult{ReachabilityStatus::Reachable, 1};
1320 }
1321
1322 const auto start_index = graph.region_index(start_region);
1323 if (start_index == invalid_region_index) {
1324 return ReachabilityResult{ReachabilityStatus::InvalidStart, 0};
1325 }
1326 const auto goal_index = graph.region_index(goal_region);
1327 if (goal_index == invalid_region_index) {
1328 return ReachabilityResult{ReachabilityStatus::InvalidGoal, 0};
1329 }
1330
1331 scratch.begin_traversal(static_cast<std::size_t>(graph.region_count()));
1332 scratch.visit(start_index);
1333 std::size_t visited_count = 1;
1334 scratch.frontier_.push_back(start_index);
1335
1336 // Sparse: track whether the searched component touches a region that exits
1337 // into a non-resident chunk, so an exhausted BFS that never reached goal
1338 // returns Indeterminate rather than a wrong Unreachable.
1339 [[maybe_unused]] bool touched_missing = false;
1340 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1341 touched_missing =
1342 graph.sparse_
1343 .region_reaches_missing_[static_cast<std::size_t>(start_index)] !=
1344 0;
1345 }
1346
1347 while (!scratch.frontier_.empty()) {
1348 const auto current = scratch.frontier_.back();
1349 scratch.frontier_.pop_back();
1350
1351 const auto begin = static_cast<std::size_t>(
1352 graph.adjacency_starts_[static_cast<std::size_t>(current)]);
1353 const auto end = static_cast<std::size_t>(
1354 graph.adjacency_starts_[static_cast<std::size_t>(current) + 1]);
1355 for (std::size_t edge = begin; edge < end; ++edge) {
1356 const auto target = graph.adjacency_targets_[edge];
1357 if (scratch.is_visited(target)) {
1358 continue;
1359 }
1360 if (target == goal_index) {
1361 return ReachabilityResult{ReachabilityStatus::Reachable,
1362 visited_count + 1};
1363 }
1364 scratch.visit(target);
1365 ++visited_count;
1366 scratch.frontier_.push_back(target);
1367 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1368 touched_missing =
1369 touched_missing ||
1370 graph.sparse_.region_reaches_missing_[static_cast<std::size_t>(
1371 target)] != 0;
1372 }
1373 }
1374 }
1375
1376 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1377 if (touched_missing) {
1378 return ReachabilityResult{ReachabilityStatus::Indeterminate,
1379 visited_count};
1380 }
1381 }
1382 return ReachabilityResult{ReachabilityStatus::Unreachable, visited_count};
1383}
1384
1385// Reports whether `graph` still matches `world` -- i.e. whether a reachability
1386// query on it would reflect the world's current topology. A precheck MUST
1387// consult this and fall back to A* when it returns false: a STALE graph can
1388// return a definitive (but wrong) Unreachable from an outdated snapshot. Const
1389// and non-mutating -- it recomputes the same staleness test update_region_graph
1390// applies, WITHOUT triggering a rebuild. Allocation-free; O(chunk_count) dense,
1391// O(resident_count) sparse (never scans non-resident chunks).
1392//
1393// One-graph-per-world contract (sparse): the staleness test reads per-WORLD
1394// residency-generation clocks, so a graph built on world A can validate as
1395// fresh against a same-shape world B whose chunks were loaded in the same
1396// order -- the same cross-world collision class documented at
1397// storage/sparse_world.h residency_fingerprint(). Keep each graph paired
1398// with the world it was built from; this check does not detect world swaps.
1400template <typename World>
1401[[nodiscard]] auto is_region_graph_fresh(
1402 const World& world,
1404 -> bool {
1405 using Residency = typename World::residency_type;
1406 using Shape = typename World::shape_type;
1407 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
1408 // Dense: every chunk's stored topology version must still be current. A
1409 // graph that was never built -- or built for a different shape, detected
1410 // via the shape binding even when chunk counts coincide -- is not fresh.
1411 if (graph.local_topologies_.size() != World::chunk_count ||
1412 !graph.template matches_shape<Shape>()) {
1413 return false;
1414 }
1415 for (std::uint64_t c = 0; c < World::chunk_count; ++c) {
1416 if (graph.local_topologies_[static_cast<std::size_t>(c)].version() !=
1417 world.meta(ChunkKey{c}).topology_version) {
1418 return false;
1419 }
1420 }
1421 return true;
1422 } else {
1423 // Sparse: the frozen residency snapshot must still hold (resident_count
1424 // plus per-key generation -- an evicted key reads generation 0, a reloaded
1425 // key a strictly greater one), AND every resident chunk's topology version
1426 // must still be current (an in-place edit). The generation is checked first
1427 // so meta()/version reads only ever touch a still-resident key.
1428 const auto count = graph.local_topologies_.size();
1429 if (count != world.resident_count() ||
1430 graph.sparse_.frozen_generations_.size() != world.resident_count() ||
1431 !graph.template matches_shape<Shape>()) {
1432 return false;
1433 }
1434 for (std::size_t i = 0; i < count; ++i) {
1435 const auto key = graph.sparse_.topology_keys_[i];
1436 // One directory probe for both facts; the by-key accessors would
1437 // probe twice per chunk on this per-pathing-tick check (audit
1438 // 2026-07-11 M2). A non-resident key reads generation 0 (meta null),
1439 // failing the generation compare before meta is touched.
1440 const auto ref = world.resident_ref(key);
1441 if (ref.generation != graph.sparse_.frozen_generations_[i]) {
1442 return false;
1443 }
1444 if (graph.local_topologies_[i].version() != ref.meta->topology_version) {
1445 return false;
1446 }
1447 }
1448 return true;
1449 }
1450}
1451
1452// Class-aware freshness: additionally requires the graph's movement-class
1453// stamp to match `ClassOrTag` (normalized, so a raw tag and its WalkableField
1454// identity agree). A graph labeled for another class is NOT fresh for this
1455// one even when every topology version is current -- its labels answer a
1456// different passability question. The class is the explicit first template
1457// argument; `World` stays deduced.
1459template <typename ClassOrTag, typename World>
1460[[nodiscard]] auto is_region_graph_fresh_for(
1461 const World& world,
1462 const RegionGraphT<typename World::residency_type>& graph) noexcept
1463 -> bool {
1464 return graph.template matches_class<ClassOrTag>() &&
1465 is_region_graph_fresh(world, graph);
1466}
1467
1468} // namespace tess
Connected-region labels and boundary exits for one world chunk.
Definition topology.h:208
friend auto build_local_chunk_topology(const World &world, ChunkKey chunk, LocalTopologyScratch &scratch, LocalChunkTopology &topology) -> LocalTopologyResult
Builds connected-region labels for one valid, resident world chunk.
Definition topology.h:921
Reusable flood-fill storage for local topology construction.
Definition topology.h:142
friend auto build_local_chunk_topology(const World &world, ChunkKey chunk, LocalTopologyScratch &scratch, class LocalChunkTopology &topology) -> LocalTopologyResult
Builds connected-region labels for one valid, resident world chunk.
Definition topology.h:921
Reusable frontier and visitation storage for graph reachability queries.
Definition topology.h:161
friend auto reachable(const RegionGraphT< Residency > &graph, Coord3 start, Coord3 goal, RegionGraphScratch &scratch) -> ReachabilityResult
Queries graph reachability between two world coordinates.
Definition topology.h:1289
Region graph storage specialized by dense or sparse residency policy.
Definition topology.h:309
friend auto update_region_graph(const World &world, LocalTopologyScratch &scratch, RegionGraphT< typename World::residency_type > &graph, std::span< const ChunkKey > dirty_chunks, const Provider &provider) -> LocalTopologyResult
Incrementally updates dirty chunks, rebuilding fully when stamps differ.
Definition topology.h:1108
friend auto build_region_graph(const World &world, LocalTopologyScratch &scratch, RegionGraphT< typename World::residency_type > &graph, const Provider &provider) -> LocalTopologyResult
Rebuilds a complete region graph for the world's current resident set.
Definition topology.h:1018
Definition world.h:22
Constrains deterministic special-transition providers for a world type.
Definition transition_provider.h:43
Checks whether a movement class advertises the exact field-span fast path.
Definition movement_class.h:236
Supplies no special transitions beyond ordinary face adjacency.
Definition transition_provider.h:75
Definition world.h:18
Definition shape.h:75
Definition shape.h:39
Definition shape.h:67
Definition shape.h:30
Definition shape.h:13
Passable boundary tile that points into an adjacent chunk.
Definition topology.h:78
Definition shape.h:49
One-based identifier of a connected region within a chunk.
Definition topology.h:33
Summary and bounds of one connected local region.
Definition topology.h:67
Definition shape.h:59
Counts and status returned by local or whole-graph topology builds.
Definition topology.h:91
Reachability verdict plus the number of graph regions visited.
Definition topology.h:136
Directed connection between regions, including its endpoint tiles.
Definition topology.h:109
Stable reference to a local region within a specific chunk.
Definition topology.h:100
Definition shape.h:244
Definition shape.h:225
Definition residency.h:17