tess 1.0.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/config.h>
5#include <tess/core/fail_fast.h>
6#include <tess/core/shape.h>
7#include <tess/core/tag_identity.h>
8#include <tess/path/request.h>
9#include <tess/storage/residency.h>
10#include <tess/storage/world.h>
11#include <tess/topology/movement_class.h>
12#include <tess/topology/transition_model.h>
13#include <tess/topology/transition_provider.h>
14
15#include <algorithm>
16#include <cstddef>
17#include <cstdint>
18#include <iterator>
19#include <limits>
20#include <span>
21#include <type_traits>
22#include <vector>
23
24namespace tess {
25
26// RegionGraph is a class template on the world residency policy so a sparse
27// graph is a distinct type from a dense one (no silent dense indexing). The
28// AlwaysResident alias below keeps every existing dense call site unchanged.
30template <typename Residency>
31class RegionGraphT;
32
33// Local region ids are 1-based: 0 is the invalid sentinel
34// (`invalid_local_region`). A valid id maps to
35// `LocalChunkTopology::regions()[value - 1]`; use
36// `LocalChunkTopology::region(id)` for checked access.
39 std::uint32_t value = 0;
40
41 friend constexpr bool operator==(LocalRegionId lhs,
42 LocalRegionId rhs) noexcept = default;
43};
44
46inline constexpr LocalRegionId invalid_local_region{};
47
48// Sentinel returned by RegionGraph::region_index for invalid or
49// out-of-range region references.
51inline constexpr std::uint32_t invalid_region_index =
52 std::numeric_limits<std::uint32_t>::max();
53
55enum class BoundaryFace : std::uint8_t {
56 NegativeX,
57 PositiveX,
58 NegativeY,
59 PositiveY,
60 NegativeZ,
61 PositiveZ,
62 PositiveXNegativeY,
63 NegativeXPositiveY,
64};
65
67enum class TopologyStatus : std::uint8_t {
68 Built,
69 InvalidChunk,
70 MissingChunk,
71};
72
75 LocalRegionId id{};
76 std::size_t tile_count = 0;
77 Box3 bounds{};
78 std::size_t boundary_exit_count = 0;
79
80 friend constexpr bool operator==(const LocalRegion& lhs,
81 const LocalRegion& rhs) noexcept = default;
82};
83
86 LocalRegionId region{};
87 LocalTileId local_tile{};
88 Coord3 coord{};
89 BoundaryFace face = BoundaryFace::NegativeX;
90 ChunkKey target_chunk{};
91
92 friend constexpr bool operator==(const LocalBoundaryExit& lhs,
93 const LocalBoundaryExit& rhs) noexcept =
94 default;
95};
96
111 std::size_t region_count = 0;
112 std::size_t passable_tile_count = 0;
113 std::size_t boundary_exit_count = 0;
114 std::uint64_t topology_version_sum = 0;
115};
116
119 TopologyStatus status = TopologyStatus::Built;
120 std::size_t region_count = 0;
121 std::size_t passable_tile_count = 0;
122 std::size_t boundary_exit_count = 0;
123 std::uint64_t topology_version_sum = 0;
124};
125
127struct RegionRef {
128 ChunkKey chunk{};
129 LocalRegionId region{};
130
131 friend constexpr bool operator==(RegionRef lhs,
132 RegionRef rhs) noexcept = default;
133};
134
137 RegionRef from{};
138 RegionRef to{};
139 Coord3 from_coord{};
140 Coord3 to_coord{};
141 BoundaryFace face = BoundaryFace::NegativeX;
142
143 friend constexpr bool operator==(const RegionPortal& lhs,
144 const RegionPortal& rhs) noexcept = default;
145};
146
148enum class ReachabilityStatus : std::uint8_t {
149 Reachable,
150 Unreachable,
151 InvalidStart,
152 InvalidGoal,
153 // The query reached the edge of the resident set: a region on the searched
154 // side has a boundary exit into a non-resident chunk, so a route through the
155 // non-resident region cannot be ruled out. Distinct from Unreachable, which
156 // means a route was definitively searched and none exists within the resident
157 // set. Only ever returned for sparse worlds. Appended last so existing
158 // enumerator values do not shift.
159 Indeterminate,
160};
161
164 ReachabilityStatus status = ReachabilityStatus::Unreachable;
165 std::size_t visited_regions = 0;
166};
167
170 ReachabilityStatus status = ReachabilityStatus::Unreachable;
171 std::size_t visited_regions = 0;
172 std::span<const RegionRef> regions;
173 std::span<const RegionPortal> portals;
174 std::span<const ChunkKey> chunks;
175 Box3 bounds{};
176};
177
180 public:
181 void reserve_tiles(std::size_t count) { stack_.reserve(count); }
182
183 [[nodiscard]] auto capacity() const noexcept -> std::size_t {
184 return stack_.capacity();
185 }
186
187 private:
188 template <typename World, typename PassableTag>
189 friend auto build_local_chunk_topology(const World& world, ChunkKey chunk,
190 LocalTopologyScratch& scratch,
191 class LocalChunkTopology& topology)
193
194 std::vector<LocalTileId> stack_;
195};
196
199 public:
200 void reserve_regions(std::size_t count) {
201 frontier_.reserve(count);
202 visited_epoch_.reserve(count);
203 parent_.reserve(count);
204 parent_portal_.reserve(count);
205 path_regions_.reserve(count);
206 path_portals_.reserve(count);
207 corridor_chunks_.reserve(count);
208 }
209
210 [[nodiscard]] auto capacity() const noexcept -> std::size_t {
211 return frontier_.capacity();
212 }
213
214 private:
215 template <typename Shape, typename Residency>
216 friend auto reachable(const RegionGraphT<Residency>& graph,
217 PathRequest request, RegionGraphScratch& scratch)
219
220 template <typename Shape, typename Residency>
221 friend auto coarse_path(const RegionGraphT<Residency>& graph,
222 PathRequest request, RegionGraphScratch& scratch)
224
225 // Epoch-stamped visited marks: a region index is visited when its
226 // generation stamp matches the current epoch, so traversals reset in
227 // O(1) instead of clearing the whole vector.
228 void begin_traversal(std::size_t region_count) {
229 frontier_.clear();
230 path_regions_.clear();
231 path_portals_.clear();
232 corridor_chunks_.clear();
233 if (visited_epoch_.size() < region_count) {
234 visited_epoch_.resize(region_count, 0);
235 }
236 ++epoch_;
237 if (epoch_ == 0) {
238 std::fill(visited_epoch_.begin(), visited_epoch_.end(), 0);
239 epoch_ = 1;
240 }
241 }
242
243 [[nodiscard]] auto is_visited(std::uint32_t region_index) const noexcept
244 -> bool {
245 return visited_epoch_[static_cast<std::size_t>(region_index)] == epoch_;
246 }
247
248 void visit(std::uint32_t region_index) noexcept {
249 visited_epoch_[static_cast<std::size_t>(region_index)] = epoch_;
250 }
251
252 std::vector<std::uint32_t> frontier_;
253 std::vector<std::uint32_t> visited_epoch_;
254 std::vector<std::uint32_t> parent_;
255 std::vector<std::uint32_t> parent_portal_;
256 std::vector<RegionRef> path_regions_;
257 std::vector<RegionPortal> path_portals_;
258 std::vector<ChunkKey> corridor_chunks_;
259 std::uint32_t epoch_ = 0;
260};
261
264 public:
265 void clear() noexcept {
266 chunk_ = ChunkKey{0};
267 chunk_coord_ = ChunkCoord3{};
268 topology_version_ = {};
269 region_ids_.clear();
270 regions_.clear();
271 boundary_exits_.clear();
272 }
273
274 [[nodiscard]] auto chunk() const noexcept -> ChunkKey { return chunk_; }
275
276 [[nodiscard]] auto chunk_coord() const noexcept -> ChunkCoord3 {
277 return chunk_coord_;
278 }
279
280 [[nodiscard]] auto topology_version() const noexcept -> TopologyVersion {
281 return topology_version_;
282 }
283
284 [[nodiscard]] auto region_ids() const noexcept
285 -> std::span<const LocalRegionId> {
286 return {region_ids_.data(), region_ids_.size()};
287 }
288
289 [[nodiscard]] auto regions() const noexcept -> std::span<const LocalRegion> {
290 return {regions_.data(), regions_.size()};
291 }
292
293 // Checked accessor for the 1-based LocalRegionId convention: id N maps to
294 // regions()[N - 1]. Returns nullptr for the invalid sentinel and for
295 // out-of-range ids.
296 [[nodiscard]] auto region(LocalRegionId id) const noexcept
297 -> const LocalRegion* {
298 if (id.value == 0 || id.value > regions_.size()) {
299 return nullptr;
300 }
301 return &regions_[static_cast<std::size_t>(id.value) - 1];
302 }
303
304 [[nodiscard]] auto boundary_exits() const noexcept
305 -> std::span<const LocalBoundaryExit> {
306 return {boundary_exits_.data(), boundary_exits_.size()};
307 }
308
309 [[nodiscard]] auto region_at(LocalTileId tile) const noexcept
310 -> LocalRegionId {
311 if (tile.value >= region_ids_.size()) {
312 return invalid_local_region;
313 }
314 return region_ids_[static_cast<std::size_t>(tile.value)];
315 }
316
317 template <typename Shape>
318 [[nodiscard]] auto region_at(LocalCoord3 coord) const noexcept
319 -> LocalRegionId {
320 return region_at(local_tile_id<Shape>(coord));
321 }
322
323 private:
324 template <typename World, typename PassableTag>
325 friend auto build_local_chunk_topology(const World& world, ChunkKey chunk,
326 LocalTopologyScratch& scratch,
327 LocalChunkTopology& topology)
329
330 ChunkKey chunk_{};
331 ChunkCoord3 chunk_coord_{};
332 TopologyVersion topology_version_{};
333 std::vector<LocalRegionId> region_ids_;
334 std::vector<LocalRegion> regions_;
335 std::vector<LocalBoundaryExit> boundary_exits_;
336};
337
338namespace detail {
339
340// Sparse-only companion state for RegionGraphT. Empty (via the explicit
341// AlwaysResident specialization) so a dense graph carries zero extra storage
342// through the [[no_unique_address]] member.
343template <typename Residency>
344struct RegionGraphSparseData {
345 // Frozen at build, sorted ascending by ChunkKey.value: the resident chunk set
346 // this graph was built over. Resolves ChunkKey -> local index by lower_bound,
347 // world-free, so eviction after the build cannot invalidate the graph.
348 std::vector<ChunkKey> topology_keys_;
349 // One flag per global region: the region has a boundary exit into a chunk
350 // that was non-resident at build time (a route through it cannot be ruled
351 // out).
352 std::vector<std::uint8_t> region_reaches_missing_;
353 // Per local topology: the residency generation at build, for staleness
354 // detection in update_region_graph.
355 std::vector<ResidencyGeneration> frozen_generations_;
356};
357
358template <>
359struct RegionGraphSparseData<AlwaysResident> {};
360
361// Whether a provider transition emitted for `chunk` really originates there,
362// as the TransitionProvider contract requires. Shared so every enumeration
363// of a provider rejects a contract violation identically: the portal pass
364// dropping a misowned edge while the sparse missing-reach pass still honored
365// it would report Indeterminate where the other reports Unreachable.
366template <typename Shape>
367[[nodiscard]] constexpr bool provider_source_is_owned(Coord3 from,
368 ChunkKey chunk) noexcept {
369 return contains<Shape>(from) &&
370 chunk_key<Shape>(chunk_coord<Shape>(from)).value == chunk.value;
371}
372
373} // namespace detail
374
381template <typename Residency>
383 public:
384 void clear() noexcept {
385 local_topologies_.clear();
386 portals_.clear();
387 region_offsets_.clear();
388 adjacency_starts_.clear();
389 adjacency_targets_.clear();
390 adjacency_portals_.clear();
391 built_chunk_grid_ = Extent3{0, 0, 0};
392 built_chunk_extent_ = Extent3{0, 0, 0};
393 built_lattice_identity_ = 0;
394 built_lattice_version_ = 0;
395 built_class_ = 0;
396 built_step_policy_identity_ = 0;
397 built_cost_scale_ = 0;
398 built_provider_ = 0;
399 built_provider_instance_ = nullptr;
400 built_provider_revision_ = 0;
401 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
402 sparse_.topology_keys_.clear();
403 sparse_.region_reaches_missing_.clear();
404 sparse_.frozen_generations_.clear();
405 }
406 bump_revision();
407 }
408
409 [[nodiscard]] auto local_topologies() const noexcept
410 -> std::span<const LocalChunkTopology> {
411 return {local_topologies_.data(), local_topologies_.size()};
412 }
413
414 [[nodiscard]] auto portals() const noexcept -> std::span<const RegionPortal> {
415 return {portals_.data(), portals_.size()};
416 }
417
419 [[nodiscard]] auto revision() const noexcept -> std::uint64_t {
420 return revision_;
421 }
422
423 [[nodiscard]] auto local_topology(ChunkKey chunk) const noexcept
424 -> const LocalChunkTopology* {
425 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
426 if (chunk.value >= local_topologies_.size()) {
427 return nullptr;
428 }
429 return &local_topologies_[static_cast<std::size_t>(chunk.value)];
430 } else {
431 const auto idx = local_index(chunk);
432 if (idx == npos) {
433 return nullptr;
434 }
435 return &local_topologies_[idx];
436 }
437 }
438
439 template <typename Shape>
440 [[nodiscard]] auto region_of(Coord3 coord) const noexcept -> RegionRef {
441 if (!contains<Shape>(coord)) {
442 return RegionRef{ChunkKey{std::numeric_limits<std::uint64_t>::max()},
443 invalid_local_region};
444 }
445 const auto key = chunk_key<Shape>(chunk_coord<Shape>(coord));
446 const auto* local = local_topology(key);
447 if (local == nullptr) {
448 return RegionRef{key, invalid_local_region};
449 }
450 return RegionRef{
451 key, local->region_at(local_tile_id<Shape>(local_coord<Shape>(coord)))};
452 }
453
454 // Total region count across all chunks in the dense global region index.
455 [[nodiscard]] auto region_count() const noexcept -> std::uint32_t {
456 return region_offsets_.empty() ? 0U : region_offsets_.back();
457 }
458
459 // Maps a region reference to its dense global index:
460 // region_offsets_[chunk] + (1-based local id - 1). Returns
461 // invalid_region_index for invalid or out-of-range references. The chunk
462 // guard compares without +1 (the sentinel ChunkKey region_of returns for
463 // out-of-world coordinates would wrap past it) and the offset arithmetic
464 // is 64-bit (a region id near 2^32 would wrap back into a valid index).
465 [[nodiscard]] auto region_index(RegionRef ref) const noexcept
466 -> std::uint32_t {
467 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
468 if (ref.region == invalid_local_region ||
469 ref.chunk.value >= local_topologies_.size()) {
470 return invalid_region_index;
471 }
472 const auto chunk = static_cast<std::size_t>(ref.chunk.value);
473 const auto index = static_cast<std::uint64_t>(region_offsets_[chunk]) +
474 ref.region.value - 1;
475 if (index >= region_offsets_[chunk + 1]) {
476 return invalid_region_index;
477 }
478 return static_cast<std::uint32_t>(index);
479 } else {
480 if (ref.region == invalid_local_region) {
481 return invalid_region_index;
482 }
483 const auto li = local_index(ref.chunk);
484 if (li == npos || li + 1 >= region_offsets_.size()) {
485 return invalid_region_index;
486 }
487 const auto index = static_cast<std::uint64_t>(region_offsets_[li]) +
488 ref.region.value - 1;
489 if (index >= region_offsets_[li + 1]) {
490 return invalid_region_index;
491 }
492 return static_cast<std::uint32_t>(index);
493 }
494 }
495
496 // True iff this graph was built for `ClassOrTag` (normalized, so a raw tag
497 // and its UnitCostFieldMovement identity agree). The graph type does not
498 // encode the movement class, so a graph labeled for one class must never
499 // answer reachability for another: update_region_graph treats a mismatch as
500 // "not built for this class" (full rebuild) and is_region_graph_fresh_for
501 // reports it as not fresh. False until the first build.
502 template <typename ClassOrTag>
503 [[nodiscard]] auto matches_class() const noexcept -> bool {
504 using Class = movement::movement_class_of<ClassOrTag>;
505 using Policy = movement::step_policy_of<Class>;
506 return built_class_ == detail::tag_identity<Class>() &&
507 built_step_policy_identity_ ==
508 static_cast<std::uint32_t>(Policy::identity) &&
509 built_cost_scale_ == Policy::cost_scale;
510 }
511
512 // True iff this graph was built with transition provider `Provider`
513 // (AdjacentTransitions for the providerless overloads). Mirrors the class
514 // stamp: update_region_graph with a different provider type falls back to
515 // a full rebuild rather than patching with mismatched special-transition
516 // edges. False until the first build.
517 template <typename Provider>
518 [[nodiscard]] auto matches_provider() const noexcept -> bool {
519 return built_provider_ == detail::tag_identity<Provider>();
520 }
521
522 // Instance-aware provider match used by incremental updates. The address
523 // distinguishes two live stateful providers whose equal local revision
524 // counters say nothing about one another. The provider therefore has to
525 // remain at an address-stable location for the graph's lifetime; clear or
526 // rebuild the graph before ending that lifetime.
527 template <typename Provider>
528 [[nodiscard]] auto matches_provider(const Provider& provider) const noexcept
529 -> bool {
530 return matches_provider<Provider>() &&
531 built_provider_instance_ ==
532 detail::transition_provider_instance_identity(provider) &&
533 built_provider_revision_ ==
534 detail::transition_provider_revision(provider);
535 }
536
537 private:
538 template <typename World, typename ClassOrTag, typename Provider>
539 friend auto build_region_graph(
540 const World& world, LocalTopologyScratch& scratch,
541 RegionGraphT<typename World::residency_type>& graph,
542 const Provider& provider) -> RegionGraphBuildResult;
543
544 template <typename World, typename ClassOrTag, typename Provider>
545 friend auto update_region_graph(
546 const World& world, LocalTopologyScratch& scratch,
547 RegionGraphT<typename World::residency_type>& graph,
548 std::span<const ChunkKey> dirty_chunks, const Provider& provider)
549 -> TopologyBuildResult;
550
551 template <typename Shape, typename OtherResidency>
552 friend auto reachable(const RegionGraphT<OtherResidency>& graph,
553 PathRequest request, RegionGraphScratch& scratch)
554 -> ReachabilityResult;
555
556 template <typename Shape, typename OtherResidency>
557 friend auto coarse_path(const RegionGraphT<OtherResidency>& graph,
558 PathRequest request, RegionGraphScratch& scratch)
559 -> CoarsePathResult;
560
561 template <typename OtherWorld>
562 friend auto is_region_graph_fresh(
563 const OtherWorld& world,
564 const RegionGraphT<typename OtherWorld::residency_type>& graph) noexcept
565 -> bool;
566
567 // Rebuilds the dense region index and the CSR portal adjacency. The CSR
568 // fill preserves portal order within each from-region bucket so
569 // traversal remains deterministic.
570 void rebuild_region_index() {
571 region_offsets_.assign(local_topologies_.size() + 1, 0);
572 for (std::size_t i = 0; i < local_topologies_.size(); ++i) {
573 region_offsets_[i + 1] =
574 region_offsets_[i] +
575 static_cast<std::uint32_t>(local_topologies_[i].regions().size());
576 }
577
578 adjacency_starts_.assign(static_cast<std::size_t>(region_count()) + 1, 0);
579 for (const auto& portal : portals_) {
580 ++adjacency_starts_[static_cast<std::size_t>(region_index(portal.from)) +
581 1];
582 }
583 for (std::size_t i = 1; i < adjacency_starts_.size(); ++i) {
584 adjacency_starts_[i] += adjacency_starts_[i - 1];
585 }
586
587 adjacency_targets_.resize(portals_.size());
588 adjacency_portals_.resize(portals_.size());
589 auto cursor = adjacency_starts_;
590 for (std::size_t portal_index = 0; portal_index < portals_.size();
591 ++portal_index) {
592 const auto& portal = portals_[portal_index];
593 const auto from = static_cast<std::size_t>(region_index(portal.from));
594 const auto edge = static_cast<std::size_t>(cursor[from]++);
595 adjacency_targets_[edge] = region_index(portal.to);
596 adjacency_portals_[edge] = static_cast<std::uint32_t>(portal_index);
597 }
598
599 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
600 // Flag every region with a boundary exit into a non-resident chunk. The
601 // membership test (has_chunk) -- not portal absence -- is what separates
602 // "unknown, non-resident" from "a real wall in a resident neighbor".
603 // Keyed by the same global region index the BFS/CSR use, so reachable
604 // reads it directly.
605 sparse_.region_reaches_missing_.assign(
606 static_cast<std::size_t>(region_count()), 0);
607 for (const auto& topology : local_topologies_) {
608 for (const auto& exit : topology.boundary_exits()) {
609 if (has_chunk(exit.target_chunk)) {
610 continue;
611 }
612 const auto idx =
613 region_index(RegionRef{topology.chunk(), exit.region});
614 if (idx != invalid_region_index) {
615 sparse_.region_reaches_missing_[static_cast<std::size_t>(idx)] = 1;
616 }
617 }
618 }
619 }
620 }
621
622 // Shape binding captured at build time. The graph type is templated on
623 // residency only, so two Shapes with equal chunk counts share it; the
624 // chunk-grid and per-chunk tile extents recorded here tell them apart.
625 // update_region_graph treats a mismatch as "not built for this world" (full
626 // rebuild); is_region_graph_fresh reports it as not fresh.
627 template <typename Shape>
628 [[nodiscard]] auto matches_shape() const noexcept -> bool {
629 using Traits = ShapeTraits<Shape>;
630 return built_chunk_grid_ == Extent3{Traits::chunk_count_x,
631 Traits::chunk_count_y,
632 Traits::chunk_count_z} &&
633 built_chunk_extent_ == Traits::chunk &&
634 built_lattice_identity_ ==
635 static_cast<std::uint32_t>(Traits::lattice_identity) &&
636 built_lattice_version_ == Traits::lattice_version;
637 }
638
639 template <typename Shape>
640 void bind_shape() noexcept {
641 using Traits = ShapeTraits<Shape>;
642 built_chunk_grid_ = Extent3{Traits::chunk_count_x, Traits::chunk_count_y,
643 Traits::chunk_count_z};
644 built_chunk_extent_ = Traits::chunk;
645 built_lattice_identity_ =
646 static_cast<std::uint32_t>(Traits::lattice_identity);
647 built_lattice_version_ = Traits::lattice_version;
648 }
649
650 // Movement-class and provider bindings captured at build time, mirroring
651 // the shape stamp (see the public matches_class / matches_provider).
652 template <typename ClassOrTag>
653 void bind_class() noexcept {
654 using Class = movement::movement_class_of<ClassOrTag>;
655 using Policy = movement::step_policy_of<Class>;
656 built_class_ = detail::tag_identity<Class>();
657 built_step_policy_identity_ = static_cast<std::uint32_t>(Policy::identity);
658 built_cost_scale_ = Policy::cost_scale;
659 }
660
661 template <typename Provider>
662 void bind_provider(const Provider& provider) noexcept {
663 built_provider_ = detail::tag_identity<Provider>();
664 built_provider_instance_ =
665 detail::transition_provider_instance_identity(provider);
666 built_provider_revision_ = detail::transition_provider_revision(provider);
667 }
668
669 void bump_revision() noexcept {
670 ++revision_;
671 if (revision_ == 0) {
672 ++revision_;
673 }
674 }
675
676 // Sparse only: flags every region owning a provider transition that lands
677 // in a NON-RESIDENT chunk, so reachability answers Indeterminate rather
678 // than a wrong Unreachable across a non-resident special transition. Must run
679 // after rebuild_region_index, which reassigns the flags from boundary
680 // exits alone.
681 template <typename Shape, typename World, typename Provider>
682 void mark_provider_missing_reaches(
683 [[maybe_unused]] const World& world,
684 [[maybe_unused]] const Provider& provider) {
685 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
686 for (const auto& topology : local_topologies_) {
687 provider.for_each_transition(
688 world, topology.chunk(), [&](Coord3 from, Coord3 to) {
689 // Same ownership rejection the portal pass applies. Without
690 // it a misowned edge creates no portal yet still marks its
691 // source region as reaching missing topology, so reachable()
692 // answers Indeterminate where the portal pass implies
693 // Unreachable.
694 if (!detail::provider_source_is_owned<Shape>(from,
695 topology.chunk())) {
696 return;
697 }
698 if (!contains<Shape>(to) ||
699 has_chunk(chunk_key<Shape>(chunk_coord<Shape>(to)))) {
700 return;
701 }
702 const auto source = this->template region_of<Shape>(from);
703 if (source.region == invalid_local_region) {
704 return;
705 }
706 const auto idx = region_index(source);
707 if (idx != invalid_region_index) {
708 sparse_.region_reaches_missing_[static_cast<std::size_t>(idx)] =
709 1;
710 }
711 });
712 }
713 }
714 }
715
716 static constexpr std::size_t npos = static_cast<std::size_t>(-1);
717
718 // Sparse only: resolve a ChunkKey to its position in the frozen sorted key
719 // set, or npos if the chunk was not resident when this graph was built.
720 [[nodiscard]] auto local_index(ChunkKey chunk) const noexcept -> std::size_t {
721 const auto& keys = sparse_.topology_keys_;
722 const auto it = std::lower_bound(
723 keys.begin(), keys.end(), chunk,
724 [](ChunkKey lhs, ChunkKey rhs) { return lhs.value < rhs.value; });
725 if (it == keys.end() || it->value != chunk.value) {
726 return npos;
727 }
728 return static_cast<std::size_t>(it - keys.begin());
729 }
730
731 [[nodiscard]] auto has_chunk(ChunkKey chunk) const noexcept -> bool {
732 return local_index(chunk) != npos;
733 }
734
735 [[nodiscard]] auto region_ref(std::uint32_t index) const noexcept
736 -> RegionRef {
737 if (index >= region_count()) {
738 return RegionRef{ChunkKey{std::numeric_limits<std::uint64_t>::max()},
739 invalid_local_region};
740 }
741 const auto upper =
742 std::upper_bound(region_offsets_.begin(), region_offsets_.end(), index);
743 const auto local = static_cast<std::size_t>(
744 std::distance(region_offsets_.begin(), upper) - 1);
745 const auto chunk = [&] {
746 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
747 return ChunkKey{static_cast<std::uint64_t>(local)};
748 } else {
749 return sparse_.topology_keys_[local];
750 }
751 }();
752 return RegionRef{chunk, LocalRegionId{index - region_offsets_[local] +
753 std::uint32_t{1}}};
754 }
755
756 std::vector<LocalChunkTopology> local_topologies_;
757 std::vector<RegionPortal> portals_;
758 std::vector<std::uint32_t> region_offsets_;
759 std::vector<std::uint32_t> adjacency_starts_;
760 std::vector<std::uint32_t> adjacency_targets_;
761 std::vector<std::uint32_t> adjacency_portals_;
762 // Zero until the first build, so an unbuilt graph never matches any shape,
763 // movement class, or transition provider.
764 Extent3 built_chunk_grid_{0, 0, 0};
765 Extent3 built_chunk_extent_{0, 0, 0};
766 std::uint32_t built_lattice_identity_ = 0;
767 std::uint32_t built_lattice_version_ = 0;
768 std::uintptr_t built_class_ = 0;
769 std::uint32_t built_step_policy_identity_ = 0;
770 std::uint32_t built_cost_scale_ = 0;
771 std::uintptr_t built_provider_ = 0;
772 const void* built_provider_instance_ = nullptr;
773 std::uint64_t built_provider_revision_ = 0;
774 std::uint64_t revision_ = 0;
775 [[no_unique_address]] detail::RegionGraphSparseData<Residency> sparse_;
776};
777
779using RegionGraph = RegionGraphT<AlwaysResident>;
781using SparseRegionGraph = RegionGraphT<SparseResident>;
782
783namespace detail {
784
785template <typename Shape>
786[[nodiscard]] constexpr auto local_tile_coord(LocalTileId id) noexcept
787 -> LocalCoord3 {
788 const auto chunk = ShapeTraits<Shape>::chunk;
789 const auto xy = chunk.x * chunk.y;
790 const auto z = id.value / xy;
791 const auto remainder = id.value % xy;
792 return LocalCoord3{
793 remainder % chunk.x,
794 remainder / chunk.x,
795 z,
796 };
797}
798
799template <typename Shape>
800constexpr void add_boundary_exit(std::vector<LocalBoundaryExit>& exits,
801 LocalRegion& region, LocalTileId local_tile,
802 Coord3 coord, BoundaryFace face,
803 ChunkCoord3 target_chunk) {
804 exits.push_back(LocalBoundaryExit{
805 region.id,
806 local_tile,
807 coord,
808 face,
809 chunk_key<Shape>(target_chunk),
810 });
811 ++region.boundary_exit_count;
812}
813
814template <typename Shape>
815constexpr void add_boundary_exits(std::vector<LocalBoundaryExit>& exits,
816 ChunkCoord3 chunk_coord, LocalRegion& region,
817 LocalTileId local_tile, LocalCoord3 local,
818 Coord3 coord) {
819 const auto chunk = ShapeTraits<Shape>::chunk;
820
821 if constexpr (std::is_same_v<typename ShapeTraits<Shape>::lattice_type,
822 lattice::HexAxial>) {
823 detail::for_each_regular_candidate<Shape, movement::DefaultSteps>(
824 coord, [&](detail::RegularTransitionCandidate candidate) {
825 const auto target = tess::chunk_coord<Shape>(candidate.to);
826 if (target == chunk_coord) {
827 return;
828 }
829 auto face = BoundaryFace::NegativeX;
830 if (candidate.to.x > coord.x && candidate.to.y < coord.y) {
831 face = BoundaryFace::PositiveXNegativeY;
832 } else if (candidate.to.x < coord.x && candidate.to.y > coord.y) {
833 face = BoundaryFace::NegativeXPositiveY;
834 } else if (candidate.to.x > coord.x) {
835 face = BoundaryFace::PositiveX;
836 } else if (candidate.to.x < coord.x) {
837 face = BoundaryFace::NegativeX;
838 } else if (candidate.to.y > coord.y) {
839 face = BoundaryFace::PositiveY;
840 } else {
841 face = BoundaryFace::NegativeY;
842 }
843 add_boundary_exit<Shape>(exits, region, local_tile, coord, face,
844 target);
845 });
846 return;
847 }
848
849 if (local.x == 0 && chunk_coord.x > 0) {
850 auto target = chunk_coord;
851 --target.x;
852 add_boundary_exit<Shape>(exits, region, local_tile, coord,
853 BoundaryFace::NegativeX, target);
854 }
855 if (local.x + 1 == chunk.x &&
856 chunk_coord.x + 1 < ShapeTraits<Shape>::chunk_count_x) {
857 auto target = chunk_coord;
858 ++target.x;
859 add_boundary_exit<Shape>(exits, region, local_tile, coord,
860 BoundaryFace::PositiveX, target);
861 }
862 if (local.y == 0 && chunk_coord.y > 0) {
863 auto target = chunk_coord;
864 --target.y;
865 add_boundary_exit<Shape>(exits, region, local_tile, coord,
866 BoundaryFace::NegativeY, target);
867 }
868 if (local.y + 1 == chunk.y &&
869 chunk_coord.y + 1 < ShapeTraits<Shape>::chunk_count_y) {
870 auto target = chunk_coord;
871 ++target.y;
872 add_boundary_exit<Shape>(exits, region, local_tile, coord,
873 BoundaryFace::PositiveY, target);
874 }
875 if (local.z == 0 && chunk_coord.z > 0) {
876 auto target = chunk_coord;
877 --target.z;
878 add_boundary_exit<Shape>(exits, region, local_tile, coord,
879 BoundaryFace::NegativeZ, target);
880 }
881 if (local.z + 1 == chunk.z &&
882 chunk_coord.z + 1 < ShapeTraits<Shape>::chunk_count_z) {
883 auto target = chunk_coord;
884 ++target.z;
885 add_boundary_exit<Shape>(exits, region, local_tile, coord,
886 BoundaryFace::PositiveZ, target);
887 }
888}
889
890template <typename Shape, typename Fn>
891constexpr void for_each_local_axis_neighbor(LocalCoord3 coord, Fn&& fn) {
892 const auto chunk = ShapeTraits<Shape>::chunk;
893 if (coord.x + 1 < chunk.x) {
894 fn(LocalCoord3{coord.x + 1, coord.y, coord.z});
895 }
896 if (coord.x > 0) {
897 fn(LocalCoord3{coord.x - 1, coord.y, coord.z});
898 }
899 if (coord.y + 1 < chunk.y) {
900 fn(LocalCoord3{coord.x, coord.y + 1, coord.z});
901 }
902 if (coord.y > 0) {
903 fn(LocalCoord3{coord.x, coord.y - 1, coord.z});
904 }
905 if constexpr (std::is_same_v<typename ShapeTraits<Shape>::lattice_type,
906 lattice::HexAxial>) {
907 if (coord.x + 1 < chunk.x && coord.y > 0) {
908 fn(LocalCoord3{coord.x + 1, coord.y - 1, 0});
909 }
910 if (coord.x > 0 && coord.y + 1 < chunk.y) {
911 fn(LocalCoord3{coord.x - 1, coord.y + 1, 0});
912 }
913 return;
914 }
915 if (coord.z + 1 < chunk.z) {
916 fn(LocalCoord3{coord.x, coord.y, coord.z + 1});
917 }
918 if (coord.z > 0) {
919 fn(LocalCoord3{coord.x, coord.y, coord.z - 1});
920 }
921}
922
923constexpr void include_coord_in_bounds(LocalRegion& region,
924 Coord3 coord) noexcept {
925 if (region.tile_count == 0) {
926 region.bounds = Box3{coord, Extent3{1, 1, 1}};
927 return;
928 }
929
930 const auto end = [](std::int64_t origin, std::uint64_t extent) {
931 // Same saturation as detail::box_axis_end in storage/chunk_meta.h: an
932 // extent >= 2^63 would flip the int64 cast negative and corrupt bounds.
933 constexpr auto max = std::numeric_limits<std::int64_t>::max();
934 if (extent > static_cast<std::uint64_t>(max)) {
935 return max;
936 }
937 const auto delta = static_cast<std::int64_t>(extent);
938 return origin > max - delta ? max : origin + delta;
939 };
940 const auto min = [](std::int64_t lhs, std::int64_t rhs) {
941 return lhs < rhs ? lhs : rhs;
942 };
943 const auto max = [](std::int64_t lhs, std::int64_t rhs) {
944 return lhs < rhs ? rhs : lhs;
945 };
946 const auto min_x = min(region.bounds.origin.x, coord.x);
947 const auto min_y = min(region.bounds.origin.y, coord.y);
948 const auto min_z = min(region.bounds.origin.z, coord.z);
949 const auto max_x =
950 max(end(region.bounds.origin.x, region.bounds.extent.x), coord.x + 1);
951 const auto max_y =
952 max(end(region.bounds.origin.y, region.bounds.extent.y), coord.y + 1);
953 const auto max_z =
954 max(end(region.bounds.origin.z, region.bounds.extent.z), coord.z + 1);
955
956 region.bounds = Box3{
957 Coord3{min_x, min_y, min_z},
958 // max >= min on every axis; abs_delta subtracts in unsigned space, so a
959 // saturated end paired with a negative origin cannot overflow int64.
960 Extent3{
961 abs_delta(max_x, min_x),
962 abs_delta(max_y, min_y),
963 abs_delta(max_z, min_z),
964 },
965 };
966}
967
968[[nodiscard]] constexpr auto neighbor_coord(Coord3 coord,
969 BoundaryFace face) noexcept
970 -> Coord3 {
971 switch (face) {
972 case BoundaryFace::NegativeX:
973 return Coord3{coord.x - 1, coord.y, coord.z};
974 case BoundaryFace::PositiveX:
975 return Coord3{coord.x + 1, coord.y, coord.z};
976 case BoundaryFace::NegativeY:
977 return Coord3{coord.x, coord.y - 1, coord.z};
978 case BoundaryFace::PositiveY:
979 return Coord3{coord.x, coord.y + 1, coord.z};
980 case BoundaryFace::NegativeZ:
981 return Coord3{coord.x, coord.y, coord.z - 1};
982 case BoundaryFace::PositiveZ:
983 return Coord3{coord.x, coord.y, coord.z + 1};
984 case BoundaryFace::PositiveXNegativeY:
985 return Coord3{coord.x + 1, coord.y - 1, coord.z};
986 case BoundaryFace::NegativeXPositiveY:
987 return Coord3{coord.x - 1, coord.y + 1, coord.z};
988 }
989 return coord;
990}
991
992template <typename Shape, typename Fn>
993constexpr void for_each_face_neighbor_chunk(ChunkCoord3 coord, Fn&& fn) {
994 using Traits = ShapeTraits<Shape>;
995 if (coord.x > 0) {
996 auto target = coord;
997 --target.x;
998 fn(target);
999 }
1000 if (coord.x + 1 < Traits::chunk_count_x) {
1001 auto target = coord;
1002 ++target.x;
1003 fn(target);
1004 }
1005 if (coord.y > 0) {
1006 auto target = coord;
1007 --target.y;
1008 fn(target);
1009 }
1010 if (coord.y + 1 < Traits::chunk_count_y) {
1011 auto target = coord;
1012 ++target.y;
1013 fn(target);
1014 }
1015 if (coord.z > 0) {
1016 auto target = coord;
1017 --target.z;
1018 fn(target);
1019 }
1020 if (coord.z + 1 < Traits::chunk_count_z) {
1021 auto target = coord;
1022 ++target.z;
1023 fn(target);
1024 }
1025 if constexpr (std::is_same_v<typename Traits::lattice_type,
1026 lattice::HexAxial>) {
1027 if (coord.x + 1 < Traits::chunk_count_x && coord.y > 0) {
1028 auto target = coord;
1029 ++target.x;
1030 --target.y;
1031 fn(target);
1032 }
1033 if (coord.x > 0 && coord.y + 1 < Traits::chunk_count_y) {
1034 auto target = coord;
1035 --target.x;
1036 ++target.y;
1037 fn(target);
1038 }
1039 }
1040}
1041
1042// Face reported for a provider transition: the dominant axis of the delta
1043// (ties fall to x, then y, then z). Reachability reads only the CSR
1044// adjacency, so the face is diagnostic labeling for special transitions.
1045[[nodiscard]] inline auto transition_face(Coord3 from, Coord3 to) noexcept
1046 -> BoundaryFace {
1047 const auto dx = to.x - from.x;
1048 const auto dy = to.y - from.y;
1049 const auto dz = to.z - from.z;
1050 const auto ax = dx < 0 ? -dx : dx;
1051 const auto ay = dy < 0 ? -dy : dy;
1052 const auto az = dz < 0 ? -dz : dz;
1053 if (ax >= ay && ax >= az) {
1054 return dx < 0 ? BoundaryFace::NegativeX : BoundaryFace::PositiveX;
1055 }
1056 if (ay >= az) {
1057 return dy < 0 ? BoundaryFace::NegativeY : BoundaryFace::PositiveY;
1058 }
1059 return dz < 0 ? BoundaryFace::NegativeZ : BoundaryFace::PositiveZ;
1060}
1061
1062// True iff the two coordinates' chunks are identical or regular-step
1063// neighbors. For axial hexes that includes the two diagonal chunk seams.
1064// Keeping this definition shared with invalidation prevents provider portals
1065// from surviving an edit in their landing chunk.
1066template <typename Shape>
1067[[nodiscard]] auto same_or_face_neighbor_chunk(Coord3 from, Coord3 to) noexcept
1068 -> bool {
1069 const auto a = chunk_coord<Shape>(from);
1070 const auto b = chunk_coord<Shape>(to);
1071 if (a == b) {
1072 return true;
1073 }
1074 auto is_neighbor = false;
1075 for_each_face_neighbor_chunk<Shape>(a, [&](ChunkCoord3 neighbor) {
1076 is_neighbor = is_neighbor || neighbor == b;
1077 });
1078 return is_neighbor;
1079}
1080
1081// Appends one directed portal per provider transition originating in this
1082// chunk whose endpoints both resolve to labeled regions, in enumeration
1083// order. Out-of-shape or unlabeled endpoints contribute nothing; a sparse
1084// non-resident landing is handled by the builder's reaches-missing pass.
1085template <typename Shape, typename World, typename Residency, typename Provider>
1086void append_provider_portals(const World& world,
1087 const RegionGraphT<Residency>& graph,
1088 const LocalChunkTopology& topology,
1089 const Provider& provider,
1090 std::vector<RegionPortal>& portals) {
1091 provider.for_each_transition(
1092 world, topology.chunk(), [&](Coord3 from, Coord3 to) {
1093 // Enforced, not asserted. The incremental erase keys removal on
1094 // `portal.from.chunk`, so a portal whose source lies outside the
1095 // enumerated chunk is never erased, while every update touching that
1096 // chunk appends it again. A provider violating the documented
1097 // ownership contract would therefore grow `portals_` without bound
1098 // and make incremental output diverge from a full rebuild -- and
1099 // only in builds with assertions compiled out, which is where it is
1100 // hardest to notice. Dropping the transition keeps the graph
1101 // well-formed in every build, and makes the behaviour testable
1102 // rather than an abort.
1103 if (!detail::provider_source_is_owned<Shape>(from, topology.chunk())) {
1104 return;
1105 }
1106 if (!contains<Shape>(to)) {
1107 return;
1108 }
1109 TESS_ASSERT((same_or_face_neighbor_chunk<Shape>(from, to)));
1110 const auto source = graph.template region_of<Shape>(from);
1111 if (source.region == invalid_local_region) {
1112 return;
1113 }
1114 const auto target = graph.template region_of<Shape>(to);
1115 if (target.region == invalid_local_region) {
1116 return;
1117 }
1118 portals.push_back(
1119 RegionPortal{source, target, from, to, transition_face(from, to)});
1120 });
1121}
1122
1123// Derives directed portals for every boundary exit of one chunk topology,
1124// in exit order, appending only exits whose neighbor tile maps to a
1125// passable region.
1126template <typename Shape, typename Residency>
1127void append_chunk_portals(const RegionGraphT<Residency>& graph,
1128 const LocalChunkTopology& topology,
1129 std::vector<RegionPortal>& portals) {
1130 for (const auto& exit : topology.boundary_exits()) {
1131 const auto to_coord = neighbor_coord(exit.coord, exit.face);
1132 const auto target = graph.template region_of<Shape>(to_coord);
1133 if (target.region == invalid_local_region) {
1134 continue;
1135 }
1136 portals.push_back(RegionPortal{
1137 RegionRef{topology.chunk(), exit.region},
1138 target,
1139 exit.coord,
1140 to_coord,
1141 exit.face,
1142 });
1143 }
1144}
1145
1146} // namespace detail
1147
1149template <typename World, typename ClassOrTag>
1150[[nodiscard]] auto build_local_chunk_topology(const World& world,
1151 ChunkKey chunk,
1152 LocalTopologyScratch& scratch,
1153 LocalChunkTopology& topology)
1155 using Shape = typename World::shape_type;
1156 using Traits = ShapeTraits<Shape>;
1157 using Class = movement::movement_class_of<ClassOrTag>;
1158
1159 topology.clear();
1160 if (chunk.value >= Traits::chunk_count) {
1161 return TopologyBuildResult{TopologyStatus::InvalidChunk, 0, 0, 0, 0};
1162 }
1163 if constexpr (std::is_same_v<typename World::residency_type,
1164 SparseResident>) {
1165 if (!world.is_resident(chunk)) {
1166 return TopologyBuildResult{TopologyStatus::MissingChunk, 0, 0, 0, 0};
1167 }
1168 }
1169
1170 topology.chunk_ = chunk;
1171 topology.chunk_coord_ = chunk_coord<Shape>(chunk);
1172 topology.topology_version_ = world.meta(chunk).topology_version;
1173 topology.region_ids_.assign(
1174 static_cast<std::size_t>(Traits::local_tile_count), invalid_local_region);
1175 scratch.stack_.clear();
1176
1177 // Identity classes flood the raw field span exactly as the legacy
1178 // single-tag build did (byte-identical labels and codegen); composed
1179 // classes evaluate their predicate on the resolved page per tile.
1180 const auto& page = world.chunk(chunk);
1181 [[maybe_unused]] const auto passable = [&] {
1182 if constexpr (movement::HasPassableSpan<Class>) {
1183 return Class::passable_span(page);
1184 } else {
1185 return nullptr;
1186 }
1187 }();
1188 const auto tile_passable = [&](LocalTileId id) -> bool {
1189 if constexpr (movement::HasPassableSpan<Class>) {
1190 return static_cast<bool>(passable[static_cast<std::size_t>(id.value)]);
1191 } else {
1192 return Class::passable(page, id);
1193 }
1194 };
1195 std::size_t passable_tiles = 0;
1196
1197 for (std::uint64_t raw_id = 0; raw_id < Traits::local_tile_count; ++raw_id) {
1198 const auto tile = LocalTileId{raw_id};
1199 const auto offset = static_cast<std::size_t>(raw_id);
1200 if (!tile_passable(tile) ||
1201 topology.region_ids_[offset] != invalid_local_region) {
1202 continue;
1203 }
1204
1205 const auto region_id =
1206 LocalRegionId{static_cast<std::uint32_t>(topology.regions_.size() + 1)};
1207 topology.regions_.push_back(LocalRegion{region_id});
1208 scratch.stack_.push_back(tile);
1209 topology.region_ids_[offset] = region_id;
1210
1211 while (!scratch.stack_.empty()) {
1212 const auto current = scratch.stack_.back();
1213 scratch.stack_.pop_back();
1214 const auto local = detail::local_tile_coord<Shape>(current);
1215 const auto coord = tess::coord<Shape>(topology.chunk_coord_, current);
1216 auto& region = topology.regions_.back();
1217 detail::include_coord_in_bounds(region, coord);
1218 ++region.tile_count;
1219 ++passable_tiles;
1220 detail::add_boundary_exits<Shape>(topology.boundary_exits_,
1221 topology.chunk_coord_, region, current,
1222 local, coord);
1223
1224 detail::for_each_local_axis_neighbor<Shape>(
1225 local, [&](LocalCoord3 neighbor_coord) {
1226 const auto neighbor = local_tile_id<Shape>(neighbor_coord);
1227 const auto neighbor_offset =
1228 static_cast<std::size_t>(neighbor.value);
1229 if (!tile_passable(neighbor) ||
1230 topology.region_ids_[neighbor_offset] != invalid_local_region) {
1231 return;
1232 }
1233 topology.region_ids_[neighbor_offset] = region_id;
1234 scratch.stack_.push_back(neighbor);
1235 });
1236 }
1237 }
1238
1239 return TopologyBuildResult{
1240 TopologyStatus::Built,
1241 topology.regions_.size(),
1242 passable_tiles,
1243 topology.boundary_exits_.size(),
1244 topology.topology_version_.value,
1245 };
1246}
1247
1248namespace detail {
1249inline void add_topology_version_sum(std::uint64_t& sum,
1250 std::uint64_t value) noexcept {
1251 if (value > std::numeric_limits<std::uint64_t>::max() - sum) {
1252 fail_fast("topology version sum exhausted");
1253 }
1254 sum += value;
1255}
1256
1257// A full rebuild cannot fail, so an update that falls back to one always
1258// reports Built. Spelled once here rather than at each of the three
1259// fallback sites.
1260[[nodiscard]] constexpr auto as_topology_build_result(
1261 RegionGraphBuildResult built) noexcept -> TopologyBuildResult {
1262 return TopologyBuildResult{
1263 TopologyStatus::Built, built.region_count,
1264 built.passable_tile_count, built.boundary_exit_count,
1265 built.topology_version_sum,
1266 };
1267}
1268
1269} // namespace detail
1270
1271template <typename World, typename ClassOrTag,
1272 typename Provider = AdjacentTransitions>
1285 const Provider& provider = {})
1288 "build_region_graph's provider must satisfy "
1289 "TransitionProviderFor (see transition_provider.h).");
1290 using Shape = typename World::shape_type;
1291 using Traits = ShapeTraits<Shape>;
1292 using Class = movement::movement_class_of<ClassOrTag>;
1293
1294 graph.clear();
1295 graph.template bind_shape<Shape>();
1296 graph.template bind_class<Class>();
1297 graph.bind_provider(provider);
1298 auto result = RegionGraphBuildResult{};
1299
1300#if TESS_HAS_EXCEPTIONS
1301 try {
1302#endif
1303 if constexpr (std::is_same_v<typename World::residency_type,
1304 AlwaysResident>) {
1305 graph.local_topologies_.resize(
1306 static_cast<std::size_t>(Traits::chunk_count));
1307 for (std::uint64_t raw_chunk = 0; raw_chunk < Traits::chunk_count;
1308 ++raw_chunk) {
1309 auto& topology =
1310 graph.local_topologies_[static_cast<std::size_t>(raw_chunk)];
1311 const auto local_result = build_local_chunk_topology<World, Class>(
1312 world, ChunkKey{raw_chunk}, scratch, topology);
1313 if (local_result.status != TopologyStatus::Built) {
1314 // Unreachable: raw_chunk < chunk_count rules out InvalidChunk and
1315 // MissingChunk does not exist under AlwaysResident. If it ever
1316 // fires, the residency assumptions this function rests on have
1317 // changed, and continuing would publish a half-built graph.
1318 detail::fail_fast(
1319 "build_region_graph: dense local build reported a failure");
1320 }
1321 result.region_count += local_result.region_count;
1322 result.passable_tile_count += local_result.passable_tile_count;
1323 result.boundary_exit_count += local_result.boundary_exit_count;
1324 detail::add_topology_version_sum(result.topology_version_sum,
1325 local_result.topology_version_sum);
1326 }
1327 } else {
1328 // Sparse: build only over the resident set, sized by resident_count,
1329 // never chunk_count. Freeze the resident keys sorted ascending so a local
1330 // index equals chunk order; portals then append in chunk order exactly as
1331 // the dense build does, keeping "incremental == fresh" trivially.
1332 auto& keys = graph.sparse_.topology_keys_;
1333 const auto resident = world.resident_chunk_keys();
1334 keys.assign(resident.begin(), resident.end());
1335 std::sort(keys.begin(), keys.end(), [](ChunkKey lhs, ChunkKey rhs) {
1336 return lhs.value < rhs.value;
1337 });
1338 const auto count = keys.size();
1339 graph.local_topologies_.resize(count);
1340 graph.sparse_.frozen_generations_.resize(count);
1341 for (std::size_t i = 0; i < count; ++i) {
1342 const auto local_result = build_local_chunk_topology<World, Class>(
1343 world, keys[i], scratch, graph.local_topologies_[i]);
1344 // Unreachable: resident keys are in-world and resident by
1345 // construction, so neither InvalidChunk nor MissingChunk can arise.
1346 if (local_result.status != TopologyStatus::Built) {
1347 detail::fail_fast(
1348 "build_region_graph: sparse local build reported a failure");
1349 }
1350 result.region_count += local_result.region_count;
1351 result.passable_tile_count += local_result.passable_tile_count;
1352 result.boundary_exit_count += local_result.boundary_exit_count;
1353 detail::add_topology_version_sum(result.topology_version_sum,
1354 local_result.topology_version_sum);
1355 graph.sparse_.frozen_generations_[i] =
1356 world.residency_generation(keys[i]);
1357 }
1358 }
1359
1360 for (const auto& topology : graph.local_topologies_) {
1361 detail::append_chunk_portals<Shape>(graph, topology, graph.portals_);
1362 detail::append_provider_portals<Shape>(world, graph, topology, provider,
1363 graph.portals_);
1364 }
1365 graph.rebuild_region_index();
1366 graph.template mark_provider_missing_reaches<Shape>(world, provider);
1367
1368 return result;
1369#if TESS_HAS_EXCEPTIONS
1370 } catch (...) {
1371 // Full builds publish directly into caller storage for locality. If any
1372 // allocation fails after clear(), discard all partial labels and derived
1373 // indices so freshness checks cannot bless a torn graph.
1374 graph.clear();
1375 throw;
1376 }
1377#endif
1378}
1379
1380// Incrementally patches an already-built region graph after passability
1381// edits confined to `dirty_chunks`. Rebuilds local topology for each dirty
1382// chunk, re-derives portals for dirty chunks and their face neighbors, and
1383// restores the canonical full-build portal order, so the resulting graph is
1384// identical to a fresh build_region_graph over the edited world. An empty
1385// dirty set leaves the graph untouched. Returns the aggregate
1386// TopologyBuildResult over all chunks, mirroring build_region_graph. If the
1387// graph was not built for this world shape, falls back to a full build.
1388template <typename World, typename ClassOrTag,
1389 typename Provider = AdjacentTransitions>
1391[[nodiscard]] auto update_region_graph(
1392 const World& world, LocalTopologyScratch& scratch,
1394 std::span<const ChunkKey> dirty_chunks, const Provider& provider = {})
1397 "update_region_graph's provider must satisfy "
1398 "TransitionProviderFor (see transition_provider.h).");
1399 using Shape = typename World::shape_type;
1400 using Traits = ShapeTraits<Shape>;
1401 using Class = movement::movement_class_of<ClassOrTag>;
1402
1403 if constexpr (std::is_same_v<typename World::residency_type,
1404 AlwaysResident>) {
1405 const auto chunk_count = static_cast<std::size_t>(Traits::chunk_count);
1406 if (graph.local_topologies_.size() != chunk_count ||
1407 !graph.template matches_shape<Shape>() ||
1408 !graph.template matches_class<Class>() ||
1409 !graph.matches_provider(provider)) {
1410 return detail::as_topology_build_result(
1411 build_region_graph<World, Class>(world, scratch, graph, provider));
1412 }
1413 for (const auto chunk : dirty_chunks) {
1414 if (chunk.value >= Traits::chunk_count) {
1415 return TopologyBuildResult{TopologyStatus::InvalidChunk, 0, 0, 0, 0};
1416 }
1417 }
1418
1419 if (!dirty_chunks.empty()) {
1420 // Mark dirty chunks, then widen to every face neighbor: those are the
1421 // only chunks whose outgoing portals can reference a dirty chunk.
1422 std::vector<std::uint8_t> dirty(chunk_count, 0);
1423 std::vector<std::uint8_t> affected(chunk_count, 0);
1424 for (const auto chunk : dirty_chunks) {
1425 const auto offset = static_cast<std::size_t>(chunk.value);
1426 dirty[offset] = 1;
1427 affected[offset] = 1;
1428 }
1429 for (std::size_t raw_chunk = 0; raw_chunk < chunk_count; ++raw_chunk) {
1430 if (dirty[raw_chunk] == 0) {
1431 continue;
1432 }
1433 detail::for_each_face_neighbor_chunk<Shape>(
1434 chunk_coord<Shape>(ChunkKey{raw_chunk}), [&](ChunkCoord3 neighbor) {
1435 const auto key = chunk_key<Shape>(neighbor);
1436 affected[static_cast<std::size_t>(key.value)] = 1;
1437 });
1438 }
1439
1440#if TESS_HAS_EXCEPTIONS
1441 try {
1442#endif
1443 for (std::size_t raw_chunk = 0; raw_chunk < chunk_count; ++raw_chunk) {
1444 if (dirty[raw_chunk] == 0) {
1445 continue;
1446 }
1447 // Discarded deliberately. Neither failure status is reachable
1448 // here: we are inside `if constexpr (AlwaysResident)`, where
1449 // build_local_chunk_topology never reports MissingChunk -- that
1450 // status is returned only under SparseResident -- and
1451 // InvalidChunk is ruled out by the dirty-chunk bounds check
1452 // above. The incremental sparse branch discards it too, on a
1453 // different argument (see the matching comment there); the two
1454 // full rebuilds do propagate it.
1455 static_cast<void>(build_local_chunk_topology<World, Class>(
1456 world, ChunkKey{raw_chunk}, scratch,
1457 graph.local_topologies_[raw_chunk]));
1458 }
1459
1460 // Every invalidated portal originates from an affected chunk, because
1461 // portals only span face-adjacent chunks. Drop them in one filtered
1462 // pass, re-derive all portals of affected chunks in exit order, then
1463 // stable-sort by from-chunk to restore the canonical build order.
1464 std::erase_if(graph.portals_, [&](const RegionPortal& portal) {
1465 return affected[static_cast<std::size_t>(portal.from.chunk.value)] !=
1466 0;
1467 });
1468 for (std::size_t raw_chunk = 0; raw_chunk < chunk_count; ++raw_chunk) {
1469 if (affected[raw_chunk] == 0) {
1470 continue;
1471 }
1472 detail::append_chunk_portals<Shape>(
1473 graph, graph.local_topologies_[raw_chunk], graph.portals_);
1474 detail::append_provider_portals<Shape>(
1475 world, graph, graph.local_topologies_[raw_chunk], provider,
1476 graph.portals_);
1477 }
1478 std::stable_sort(graph.portals_.begin(), graph.portals_.end(),
1479 [](const RegionPortal& lhs, const RegionPortal& rhs) {
1480 return lhs.from.chunk.value < rhs.from.chunk.value;
1481 });
1482 graph.rebuild_region_index();
1483 graph.bump_revision();
1484#if TESS_HAS_EXCEPTIONS
1485 } catch (...) {
1486 // Incremental locality matters on this performance-sensitive path, so
1487 // do not copy every unchanged tile label merely for rollback. Clear is
1488 // allocation-free and revision-invalidates all consumers, providing a
1489 // safe basic guarantee: never expose mixed labels, portals, or CSR.
1490 graph.clear();
1491 throw;
1492 }
1493#endif
1494 }
1495 } else {
1496 // Sparse: any residency change since build forces a full rebuild (the graph
1497 // is frozen to a residency snapshot). Exact set-equality via resident_count
1498 // plus per-key generation: an evicted key reads generation 0, a
1499 // rematerialized key gets a strictly greater monotonic generation, so equal
1500 // count with all frozen keys still at their frozen generation forces set
1501 // identity. Generations are per-WORLD clocks: a graph must only ever be
1502 // updated against the world it was built from (see is_region_graph_fresh).
1503 const auto count = graph.local_topologies_.size();
1504 if (count != world.resident_count() ||
1505 graph.sparse_.frozen_generations_.size() != world.resident_count() ||
1506 !graph.template matches_shape<Shape>() ||
1507 !graph.template matches_class<Class>() ||
1508 !graph.matches_provider(provider)) {
1509 return detail::as_topology_build_result(
1510 build_region_graph<World, Class>(world, scratch, graph, provider));
1511 }
1512 for (std::size_t i = 0; i < count; ++i) {
1513 if (world.residency_generation(graph.sparse_.topology_keys_[i]) !=
1514 graph.sparse_.frozen_generations_[i]) {
1515 return detail::as_topology_build_result(
1516 build_region_graph<World, Class>(world, scratch, graph, provider));
1517 }
1518 }
1519 for (const auto chunk : dirty_chunks) {
1520 if (chunk.value >= Traits::chunk_count) {
1521 return TopologyBuildResult{TopologyStatus::InvalidChunk, 0, 0, 0, 0};
1522 }
1523 }
1524
1525 if (!dirty_chunks.empty()) {
1526 // dirty/affected live in local-index space (size N = resident_count),
1527 // never chunk_count. A dirty chunk that is not resident holds no topology
1528 // in the frozen graph, so it is skipped.
1529 std::vector<std::uint8_t> dirty(count, 0);
1530 std::vector<std::uint8_t> affected(count, 0);
1531 for (const auto chunk : dirty_chunks) {
1532 const auto li = graph.local_index(chunk);
1533 if (li == graph.npos) {
1534 continue;
1535 }
1536 dirty[li] = 1;
1537 affected[li] = 1;
1538 }
1539 for (std::size_t i = 0; i < count; ++i) {
1540 if (dirty[i] == 0) {
1541 continue;
1542 }
1543 detail::for_each_face_neighbor_chunk<Shape>(
1544 chunk_coord<Shape>(graph.sparse_.topology_keys_[i]),
1545 [&](ChunkCoord3 neighbor) {
1546 const auto li = graph.local_index(chunk_key<Shape>(neighbor));
1547 if (li != graph.npos) {
1548 affected[li] = 1;
1549 }
1550 });
1551 }
1552
1553#if TESS_HAS_EXCEPTIONS
1554 try {
1555#endif
1556 for (std::size_t i = 0; i < count; ++i) {
1557 if (dirty[i] == 0) {
1558 continue;
1559 }
1560 // Discarded deliberately, and sound for a different reason than
1561 // the dense branch above. MissingChunk would mean a frozen key
1562 // is no longer resident, but the generation loop at the top of
1563 // this branch already returned to a full rebuild in that case:
1564 // an absent key reads residency_generation 0, every frozen
1565 // generation is non-zero because the keys were resident when
1566 // frozen, so any eviction fails the equality check. InvalidChunk
1567 // is ruled out by the dirty-chunk bounds check above. Built is
1568 // therefore the only reachable status.
1569 static_cast<void>(build_local_chunk_topology<World, Class>(
1570 world, graph.sparse_.topology_keys_[i], scratch,
1571 graph.local_topologies_[i]));
1572 }
1573
1574 std::erase_if(graph.portals_, [&](const RegionPortal& portal) {
1575 const auto li = graph.local_index(portal.from.chunk);
1576 return li != graph.npos && affected[li] != 0;
1577 });
1578 for (std::size_t i = 0; i < count; ++i) {
1579 if (affected[i] == 0) {
1580 continue;
1581 }
1582 detail::append_chunk_portals<Shape>(graph, graph.local_topologies_[i],
1583 graph.portals_);
1584 detail::append_provider_portals<Shape>(world, graph,
1585 graph.local_topologies_[i],
1586 provider, graph.portals_);
1587 }
1588 std::stable_sort(graph.portals_.begin(), graph.portals_.end(),
1589 [](const RegionPortal& lhs, const RegionPortal& rhs) {
1590 return lhs.from.chunk.value < rhs.from.chunk.value;
1591 });
1592 graph.rebuild_region_index();
1593 graph.template mark_provider_missing_reaches<Shape>(world, provider);
1594 graph.bump_revision();
1595#if TESS_HAS_EXCEPTIONS
1596 } catch (...) {
1597 // Clear also drops frozen residency and missing-region state, so a
1598 // retry cannot mistake a partly rebuilt sparse snapshot for fresh.
1599 graph.clear();
1600 throw;
1601 }
1602#endif
1603 }
1604 }
1605
1606 auto result = TopologyBuildResult{};
1607 for (const auto& topology : graph.local_topologies_) {
1608 result.region_count += topology.regions().size();
1609 for (const auto& region : topology.regions()) {
1610 result.passable_tile_count += region.tile_count;
1611 }
1612 result.boundary_exit_count += topology.boundary_exits().size();
1613 detail::add_topology_version_sum(result.topology_version_sum,
1614 topology.topology_version().value);
1615 }
1616 return result;
1617}
1618
1620template <typename Shape, typename Residency>
1621[[nodiscard]] auto reachable(const RegionGraphT<Residency>& graph,
1622 PathRequest request, RegionGraphScratch& scratch)
1624 if (!contains<Shape>(request.start)) {
1625 return ReachabilityResult{ReachabilityStatus::InvalidStart, 0};
1626 }
1627 if (!contains<Shape>(request.goal)) {
1628 return ReachabilityResult{ReachabilityStatus::InvalidGoal, 0};
1629 }
1630
1631 const auto start_region = graph.template region_of<Shape>(request.start);
1632 if (start_region.region == invalid_local_region) {
1633 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1634 // A non-resident endpoint cannot be answered: its region is unknown,
1635 // distinct from a resident-but-walled tile (InvalidStart below).
1636 if (!graph.has_chunk(
1637 chunk_key<Shape>(chunk_coord<Shape>(request.start)))) {
1638 return ReachabilityResult{ReachabilityStatus::Indeterminate, 0};
1639 }
1640 }
1641 return ReachabilityResult{ReachabilityStatus::InvalidStart, 0};
1642 }
1643 const auto goal_region = graph.template region_of<Shape>(request.goal);
1644 if (goal_region.region == invalid_local_region) {
1645 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1646 if (!graph.has_chunk(
1647 chunk_key<Shape>(chunk_coord<Shape>(request.goal)))) {
1648 return ReachabilityResult{ReachabilityStatus::Indeterminate, 0};
1649 }
1650 }
1651 return ReachabilityResult{ReachabilityStatus::InvalidGoal, 0};
1652 }
1653 if (start_region == goal_region) {
1654 return ReachabilityResult{ReachabilityStatus::Reachable, 1};
1655 }
1656
1657 const auto start_index = graph.region_index(start_region);
1658 if (start_index == invalid_region_index) {
1659 return ReachabilityResult{ReachabilityStatus::InvalidStart, 0};
1660 }
1661 const auto goal_index = graph.region_index(goal_region);
1662 if (goal_index == invalid_region_index) {
1663 return ReachabilityResult{ReachabilityStatus::InvalidGoal, 0};
1664 }
1665
1666 scratch.begin_traversal(static_cast<std::size_t>(graph.region_count()));
1667 scratch.visit(start_index);
1668 std::size_t visited_count = 1;
1669 scratch.frontier_.push_back(start_index);
1670
1671 // Sparse: track whether the searched component touches a region that exits
1672 // into a non-resident chunk, so an exhausted BFS that never reached goal
1673 // returns Indeterminate rather than a wrong Unreachable.
1674 [[maybe_unused]] bool touched_missing = false;
1675 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1676 touched_missing =
1677 graph.sparse_
1678 .region_reaches_missing_[static_cast<std::size_t>(start_index)] !=
1679 0;
1680 }
1681
1682 while (!scratch.frontier_.empty()) {
1683 const auto current = scratch.frontier_.back();
1684 scratch.frontier_.pop_back();
1685
1686 const auto begin = static_cast<std::size_t>(
1687 graph.adjacency_starts_[static_cast<std::size_t>(current)]);
1688 const auto end = static_cast<std::size_t>(
1689 graph.adjacency_starts_[static_cast<std::size_t>(current) + 1]);
1690 for (std::size_t edge = begin; edge < end; ++edge) {
1691 const auto target = graph.adjacency_targets_[edge];
1692 if (scratch.is_visited(target)) {
1693 continue;
1694 }
1695 if (target == goal_index) {
1696 return ReachabilityResult{ReachabilityStatus::Reachable,
1697 visited_count + 1};
1698 }
1699 scratch.visit(target);
1700 ++visited_count;
1701 scratch.frontier_.push_back(target);
1702 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1703 touched_missing =
1704 touched_missing ||
1705 graph.sparse_.region_reaches_missing_[static_cast<std::size_t>(
1706 target)] != 0;
1707 }
1708 }
1709 }
1710
1711 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1712 if (touched_missing) {
1713 return ReachabilityResult{ReachabilityStatus::Indeterminate,
1714 visited_count};
1715 }
1716 }
1717 return ReachabilityResult{ReachabilityStatus::Unreachable, visited_count};
1718}
1719
1724template <typename Shape, typename Residency>
1725[[nodiscard]] auto coarse_path(const RegionGraphT<Residency>& graph,
1726 PathRequest request, RegionGraphScratch& scratch)
1727 -> CoarsePathResult {
1728 const auto region_count = static_cast<std::size_t>(graph.region_count());
1729 scratch.begin_traversal(region_count);
1730 const auto result = [&](ReachabilityStatus status,
1731 std::size_t visited) -> CoarsePathResult {
1732 return CoarsePathResult{
1733 status,
1734 visited,
1735 std::span<const RegionRef>{scratch.path_regions_},
1736 std::span<const RegionPortal>{scratch.path_portals_},
1737 std::span<const ChunkKey>{scratch.corridor_chunks_},
1738 {},
1739 };
1740 };
1741 if (!contains<Shape>(request.start)) {
1742 return result(ReachabilityStatus::InvalidStart, 0);
1743 }
1744 if (!contains<Shape>(request.goal)) {
1745 return result(ReachabilityStatus::InvalidGoal, 0);
1746 }
1747
1748 const auto start_region = graph.template region_of<Shape>(request.start);
1749 if (start_region.region == invalid_local_region) {
1750 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1751 if (!graph.has_chunk(
1752 chunk_key<Shape>(chunk_coord<Shape>(request.start)))) {
1753 return result(ReachabilityStatus::Indeterminate, 0);
1754 }
1755 }
1756 return result(ReachabilityStatus::InvalidStart, 0);
1757 }
1758 const auto goal_region = graph.template region_of<Shape>(request.goal);
1759 if (goal_region.region == invalid_local_region) {
1760 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1761 if (!graph.has_chunk(
1762 chunk_key<Shape>(chunk_coord<Shape>(request.goal)))) {
1763 return result(ReachabilityStatus::Indeterminate, 0);
1764 }
1765 }
1766 return result(ReachabilityStatus::InvalidGoal, 0);
1767 }
1768
1769 const auto start_index = graph.region_index(start_region);
1770 const auto goal_index = graph.region_index(goal_region);
1771 if (start_index == invalid_region_index) {
1772 return result(ReachabilityStatus::InvalidStart, 0);
1773 }
1774 if (goal_index == invalid_region_index) {
1775 return result(ReachabilityStatus::InvalidGoal, 0);
1776 }
1777
1778 scratch.parent_.resize(region_count, invalid_region_index);
1779 scratch.parent_portal_.resize(region_count, invalid_region_index);
1780 scratch.visit(start_index);
1781 scratch.parent_[start_index] = start_index;
1782 scratch.frontier_.push_back(start_index);
1783 auto visited_count = std::size_t{1};
1784 auto found = start_index == goal_index;
1785 [[maybe_unused]] auto touched_missing = false;
1786 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1787 touched_missing = graph.sparse_.region_reaches_missing_[start_index] != 0;
1788 }
1789
1790 for (std::size_t head = 0; head < scratch.frontier_.size() && !found;
1791 ++head) {
1792 const auto current = scratch.frontier_[head];
1793 const auto begin = static_cast<std::size_t>(
1794 graph.adjacency_starts_[static_cast<std::size_t>(current)]);
1795 const auto end = static_cast<std::size_t>(
1796 graph.adjacency_starts_[static_cast<std::size_t>(current) + 1]);
1797 for (auto edge = begin; edge < end; ++edge) {
1798 const auto target = graph.adjacency_targets_[edge];
1799 if (scratch.is_visited(target)) {
1800 continue;
1801 }
1802 scratch.visit(target);
1803 scratch.parent_[target] = current;
1804 scratch.parent_portal_[target] = graph.adjacency_portals_[edge];
1805 scratch.frontier_.push_back(target);
1806 ++visited_count;
1807 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1808 touched_missing = touched_missing ||
1809 graph.sparse_.region_reaches_missing_[target] != 0;
1810 }
1811 if (target == goal_index) {
1812 found = true;
1813 break;
1814 }
1815 }
1816 }
1817
1818 if (!found) {
1819 if constexpr (!std::is_same_v<Residency, AlwaysResident>) {
1820 if (touched_missing) {
1821 return result(ReachabilityStatus::Indeterminate, visited_count);
1822 }
1823 }
1824 return result(ReachabilityStatus::Unreachable, visited_count);
1825 }
1826
1827 auto current = goal_index;
1828 scratch.path_regions_.push_back(graph.region_ref(current));
1829 while (current != start_index) {
1830 const auto portal_index = scratch.parent_portal_[current];
1831 TESS_ASSERT(portal_index < graph.portals_.size());
1832 scratch.path_portals_.push_back(graph.portals_[portal_index]);
1833 current = scratch.parent_[current];
1834 scratch.path_regions_.push_back(graph.region_ref(current));
1835 }
1836 std::reverse(scratch.path_regions_.begin(), scratch.path_regions_.end());
1837 std::reverse(scratch.path_portals_.begin(), scratch.path_portals_.end());
1838 for (const auto region : scratch.path_regions_) {
1839 if (std::find(scratch.corridor_chunks_.begin(),
1840 scratch.corridor_chunks_.end(),
1841 region.chunk) == scratch.corridor_chunks_.end()) {
1842 scratch.corridor_chunks_.push_back(region.chunk);
1843 }
1844 }
1845
1846 using Traits = ShapeTraits<Shape>;
1847 auto minimum = Coord3{std::numeric_limits<std::int64_t>::max(),
1848 std::numeric_limits<std::int64_t>::max(),
1849 std::numeric_limits<std::int64_t>::max()};
1850 auto maximum = Coord3{};
1851 for (const auto chunk_key_value : scratch.corridor_chunks_) {
1852 const auto chunk = chunk_coord<Shape>(chunk_key_value);
1853 const auto begin = Coord3{
1854 static_cast<std::int64_t>(chunk.x * Traits::chunk.x),
1855 static_cast<std::int64_t>(chunk.y * Traits::chunk.y),
1856 static_cast<std::int64_t>(chunk.z * Traits::chunk.z),
1857 };
1858 const auto end = Coord3{
1859 static_cast<std::int64_t>(
1860 std::min((chunk.x + 1) * Traits::chunk.x, Traits::size.x)),
1861 static_cast<std::int64_t>(
1862 std::min((chunk.y + 1) * Traits::chunk.y, Traits::size.y)),
1863 static_cast<std::int64_t>(
1864 std::min((chunk.z + 1) * Traits::chunk.z, Traits::size.z)),
1865 };
1866 minimum.x = std::min(minimum.x, begin.x);
1867 minimum.y = std::min(minimum.y, begin.y);
1868 minimum.z = std::min(minimum.z, begin.z);
1869 maximum.x = std::max(maximum.x, end.x);
1870 maximum.y = std::max(maximum.y, end.y);
1871 maximum.z = std::max(maximum.z, end.z);
1872 }
1873
1874 auto output = result(ReachabilityStatus::Reachable, visited_count);
1875 output.bounds = Box3{
1876 minimum,
1877 Extent3{static_cast<std::uint64_t>(maximum.x - minimum.x),
1878 static_cast<std::uint64_t>(maximum.y - minimum.y),
1879 static_cast<std::uint64_t>(maximum.z - minimum.z)},
1880 };
1881 return output;
1882}
1883
1884// Reports whether `graph` still matches `world` -- i.e. whether a reachability
1885// query on it would reflect the world's current topology. A precheck MUST
1886// consult this and fall back to A* when it returns false: a STALE graph can
1887// return a definitive (but wrong) Unreachable from an outdated snapshot. Const
1888// and non-mutating -- it recomputes the same staleness test update_region_graph
1889// applies, WITHOUT triggering a rebuild. Allocation-free; O(chunk_count) dense,
1890// O(resident_count) sparse (never scans non-resident chunks).
1891//
1892// One-graph-per-world contract (sparse): the staleness test reads per-WORLD
1893// residency-generation clocks, so a graph built on world A can validate as
1894// fresh against a same-shape world B whose chunks were loaded in the same
1895// order -- the same cross-world collision class documented at
1896// storage/sparse_world.h residency_fingerprint(). Keep each graph paired
1897// with the world it was built from; this check does not detect world swaps.
1899template <typename World>
1900[[nodiscard]] auto is_region_graph_fresh(
1901 const World& world,
1903 -> bool {
1904 using Residency = typename World::residency_type;
1905 using Shape = typename World::shape_type;
1906 if constexpr (std::is_same_v<Residency, AlwaysResident>) {
1907 // Dense: every chunk's stored topology version must still be current. A
1908 // graph that was never built -- or built for a different shape, detected
1909 // via the shape binding even when chunk counts coincide -- is not fresh.
1910 if (graph.local_topologies_.size() != World::chunk_count ||
1911 !graph.template matches_shape<Shape>()) {
1912 return false;
1913 }
1914 for (std::uint64_t c = 0; c < World::chunk_count; ++c) {
1915 if (graph.local_topologies_[static_cast<std::size_t>(c)]
1916 .topology_version() != world.meta(ChunkKey{c}).topology_version) {
1917 return false;
1918 }
1919 }
1920 return true;
1921 } else {
1922 // Sparse: the frozen residency snapshot must still hold (resident_count
1923 // plus per-key generation -- an evicted key reads generation 0, a
1924 // rematerialized key a strictly greater one), AND every resident chunk's
1925 // topology version must still be current (an in-place edit). The generation
1926 // is checked first so metadata and topology-version reads touch only
1927 // resident keys.
1928 const auto count = graph.local_topologies_.size();
1929 if (count != world.resident_count() ||
1930 graph.sparse_.frozen_generations_.size() != world.resident_count() ||
1931 !graph.template matches_shape<Shape>()) {
1932 return false;
1933 }
1934 for (std::size_t i = 0; i < count; ++i) {
1935 const auto key = graph.sparse_.topology_keys_[i];
1936 // One directory probe for both facts; the by-key accessors would probe
1937 // twice per chunk on this per-pathing-tick check. A non-resident key
1938 // reads generation 0 (meta null),
1939 // failing the generation compare before meta is touched.
1940 const auto ref = world.resident_ref(key);
1941 if (ref.generation != graph.sparse_.frozen_generations_[i]) {
1942 return false;
1943 }
1944 if (graph.local_topologies_[i].topology_version() !=
1945 ref.meta->topology_version) {
1946 return false;
1947 }
1948 }
1949 return true;
1950 }
1951}
1952
1953// Class-aware freshness: additionally requires the graph's movement-class
1954// stamp to match `ClassOrTag` (normalized, so a raw tag and its
1955// UnitCostFieldMovement identity agree). A graph labeled for another class is
1956// NOT fresh for this one even when every topology version is current -- its
1957// labels answer a different passability question. The class is the explicit
1958// first template argument; `World` stays deduced.
1960template <typename ClassOrTag, typename World>
1961[[nodiscard]] auto is_region_graph_fresh_for(
1962 const World& world,
1963 const RegionGraphT<typename World::residency_type>& graph) noexcept
1964 -> bool {
1965 return graph.template matches_class<ClassOrTag>() &&
1966 is_region_graph_fresh(world, graph);
1967}
1968
1969} // namespace tess
Connected-region labels and boundary exits for one world chunk.
Definition topology.h:263
friend auto build_local_chunk_topology(const World &world, ChunkKey chunk, LocalTopologyScratch &scratch, LocalChunkTopology &topology) -> TopologyBuildResult
Builds connected-region labels for one valid, resident world chunk.
Definition topology.h:1150
Reusable flood-fill storage for local topology construction.
Definition topology.h:179
friend auto build_local_chunk_topology(const World &world, ChunkKey chunk, LocalTopologyScratch &scratch, class LocalChunkTopology &topology) -> TopologyBuildResult
Builds connected-region labels for one valid, resident world chunk.
Definition topology.h:1150
Reusable frontier and visitation storage for graph reachability queries.
Definition topology.h:198
friend auto coarse_path(const RegionGraphT< Residency > &graph, PathRequest request, RegionGraphScratch &scratch) -> CoarsePathResult
Definition topology.h:1725
friend auto reachable(const RegionGraphT< Residency > &graph, PathRequest request, RegionGraphScratch &scratch) -> ReachabilityResult
Queries graph reachability between two world coordinates.
Definition topology.h:1621
Region graph storage specialized by dense or sparse residency policy.
Definition topology.h:382
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) -> TopologyBuildResult
Incrementally updates dirty chunks, rebuilding fully when stamps differ.
Definition topology.h:1391
friend auto build_region_graph(const World &world, LocalTopologyScratch &scratch, RegionGraphT< typename World::residency_type > &graph, const Provider &provider) -> RegionGraphBuildResult
Definition topology.h:1283
auto revision() const noexcept -> std::uint64_t
Monotonic identity for the graph's current derived contents.
Definition topology.h:419
Definition world.h:22
Constrains deterministic special-transition providers for a world type.
Definition transition_provider.h:61
Checks whether a movement class advertises the exact field-span fast path.
Definition movement_class.h:271
Definition world.h:18
Definition shape.h:94
Definition shape.h:58
Definition shape.h:86
Scratch-borrowing shortest region path and its chunk corridor.
Definition topology.h:169
Definition shape.h:46
Definition shape.h:14
Passable boundary tile that points into an adjacent chunk.
Definition topology.h:85
Definition shape.h:68
One-based identifier of a connected region within a chunk.
Definition topology.h:38
Summary and bounds of one connected local region.
Definition topology.h:74
Definition shape.h:78
Specifies inclusive start and goal coordinates for a path query.
Definition request.h:10
Reachability verdict plus the number of graph regions visited.
Definition topology.h:163
Definition topology.h:110
Directed connection between regions, including its endpoint tiles.
Definition topology.h:136
Reference scoped to a local region within a specific chunk topology.
Definition topology.h:127
Definition shape.h:320
Definition shape.h:296
Definition residency.h:18
Counts and status returned by a local chunk build or an incremental update.
Definition topology.h:118
Definition metadata_types.h:102