OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_dc_world.gd

598 lines · gd ref:
extends "res://scenes/maps/map_dc_level.gd"
## DC World — the Quake level as an EXACT brush mesh, embedded in the procedural
## voxel world. level-mesh-implementation.md §5a.1, the integration step.
##
## This is the first scene in which the two subsystems exist together. Until now
## they were mutually exclusive by construction:
##
##   map_dc_terrain   DCTerrain only — the level is STAMPED INTO the SDF as
##                    positive convex edits, so level and terrain are ONE field.
##                    Blends by construction, and costs 5-17M triangles for a
##                    ~13K-triangle level.
##   map_dc_level     DCLevel only — the exact mesh, ~100x cheaper and sharp,
##                    in a void. §5a's escape hatch: "with no procedural world
##                    there is no level<->world seam to solve."
##
## Both problems §5a describes are the price of having both at once, and they are
## visible in here rather than argued about:
##
##   §5a.2 interior   the terrain must not fill the level's rooms. Handled today
##                    by the MOAT — the level's inflated AABB subtracted from the
##                    field — which is crude, leaves a trench, and is the only
##                    thing doing the job. Negative brushes replace it (#25 §2).
##   §5a.3 exterior   the terrain should MEET the outside walls rather than stop
##                    4 m short of them. Not built. What you will see at the
##                    boundary is the trench, and that is expected, not a bug in
##                    this scene.
##
## Everything else — textures, sky lights, rtlights, entities, persistence, the
## editor, the gate — is inherited from map_dc_level unchanged. This subclass
## exists to answer two questions and no others: where does the level sit, and
## what gets carved.

## Where the level starts, before you move it.
##
## WAS 20 m, and that was inherited reasoning rather than a choice. In
## map_dc_terrain the level is STAMPED INTO the terrain's SDF, and the lift raised
## it clear of the procedural hill before the two became one field. Carried onto
## the exact mesh it kept the level floating as a separate surface out of the
## terrain's reach — reported in-game as "the start map is still hovering above
## the terrain" — and it also meant the moat was carving a volume nothing was
## going to occupy.
##
## With the shell subtract (§5a.2) that is backwards: the terrain is now removed
## from wherever the level actually is, so holding the level clear of the ground
## is precisely what we do not want. ZERO puts it centred on the origin — the
## level spans about -17.2 to +17.3 m, so it sits roughly half in the ground
## against a terrain whose own surface lives around -15 to +10.
##
## STILL A STARTING POINT, NOT A DERIVED VALUE. The terrain's surface height
## varies across the level's 82 x 86 m footprint, so no constant is right
## everywhere and this one is not computed from DensityConfig. Find the depth by
## looking, with the two keys below, then freeze what you find.
##
## Snapped by the importer to a voxel boundary (`(world_offset / vox).round() *
## vox`), so an arbitrary value here is quantized to 0.224 m before it is used.
const DEFAULT_LIFT := Vector3(0, 0, 0)

## Per press. Two metres is coarse enough to cross 20 m in ten taps and fine
## enough to sit the level ON the surface rather than skipping past it.
const LIFT_STEP := 2.0

## Seconds of no further edits before the shell is re-derived.
##
## A DEBOUNCE, BECAUSE THE RE-DERIVE IS NOT CHEAP. build_shell() is 1355 ms
## measured headless on START.MAP (563544 triangles) and 2206 ms in the running
## game, and it is followed by clear_landscape_edits() and a re-stamp, which
## rebuilds every chunk the shell touches. Per edit that is unusable: dragging a
## face writes a record per step, so one shell per record would put a
## multi-second build between consecutive frames of a single drag.
##
## Long enough to swallow a drag, short enough that you watch the terrain follow
## what you did instead of wondering whether it is going to.
const RESHELL_QUIET := 0.75

## Live embed depth. A var, not a const — this is the whole point of the scene.
var _lift: Vector3 = DEFAULT_LIFT

## Where the way home goes, relative to info_player_start. Beside the player, at
## the same height, so it is visible on arrival without standing in the doorway.
##
## APPROXIMATE ON PURPOSE and honestly so: nothing here tests the spot for solid
## brush, so on a map whose start is in a tight corner this can end up in a wall.
## It is anchored to the start rather than hand-placed precisely because a fixed
## transform in the .tscn was guaranteed to be wrong once the level moved.
const HOME_OFFSET := Vector3(2.5, 0, 0)

@onready var terrain: Node3D = get_node_or_null("DCTerrain")

## The shell-cache key the landscape edit currently in the terrain was derived
## from. Empty means nothing has been derived yet. Compared against the key of the
## level AS IT IS NOW to decide whether the subtract has gone stale.
var _shell_key_stamped := ""
## Counts down while an edit is settling; negative means nothing is pending.
var _reshell_countdown := -1.0
## The cache key the IN-FLIGHT background build was started for. Empty when none
## is running. Kept because the level may have moved on by the time it lands, and
## the shell must be filed under the state it was actually derived from.
var _shell_key_building := ""
## Re-derives attempted since this map was entered. Diagnostic, and what
## scenes/test/shell_tracks_edits_gate.gd reads — headless has no terrain to
## inspect, so the attempt is the only observable.
var _reshell_attempts := 0


func _ready() -> void:
	# A headless peer syncs gamestate and never renders, so meshing the terrain is
	# pure waste — with no Vulkan RenderingDevice the GPU dual-contouring CPU-falls
	# back through OpenMP and saturates every core on a surface nobody sees.
	# map_dc_terrain.gd has done this since the daemon work (spec/daemon-session.md
	# P0); the level itself stays, because its brushes ARE the durable state.
	if DisplayServer.get_name() == "headless" and terrain != null:
		terrain.queue_free()
		terrain = null
		print("[MapDCWorld] headless — DCTerrain freed (level kept, no meshing)")

	# BEFORE super, which defers the import: carve_field() is read during that
	# import, and the terrain it returns should already be configured when the
	# carve lands on it. Deferred or not, doing it first removes the question.
	DCWorldSetup.configure_terrain(terrain)
	super._ready()


## Hook the journal. `folded` fires once per frame in which the level changed —
## this peer's edits, another peer's, an undo, and the replay on load — and until
## now map_dc_level was its only consumer, for the wireframe. The subtract was
## stamped once at import and never again (hyper-games #25 §2).
##
## The connection lives HERE rather than in _ensure_persistence's own body because
## the base class owns when persistence appears; this only adds a listener to it.
func _ensure_persistence() -> void:
	var had := _persist != null and is_instance_valid(_persist)
	super._ensure_persistence()
	if not had and _persist != null and not _persist.folded.is_connected(_on_level_folded):
		_persist.folded.connect(_on_level_folded)


## An edit landed. Arm the debounce IF the level is no longer the one the current
## subtract was derived from.
##
## The comparison is the shell CACHE key, deliberately: it already carries the
## brush/face/triangle counts, the lift and the journal's record count, and it is
## already the thing that decides whether a stored shell may be reused. Two
## answers to "has the shell gone stale" would be one too many, and the second
## would be the one that rots.
func _on_level_folded(_stats: Dictionary) -> void:
	var key := _shell_cache_key()
	if key == "" or key == _shell_key_stamped:
		return                                  # unknown level, or already current
	_reshell_countdown = RESHELL_QUIET


func _process(delta: float) -> void:
	super._process(delta)
	_pump_shell()
	if _reshell_countdown < 0.0:
		return
	_reshell_countdown -= delta
	if _reshell_countdown >= 0.0:
		return
	# Re-checked at FIRING time, not just at arming time. The replay on load folds
	# before the import stamps its shell, so the arming that produces is already
	# satisfied by the time it expires — and re-deriving there would spend 2.2 s
	# reproducing what the map had just built.
	var key := _shell_cache_key()
	if key == "" or key == _shell_key_stamped:
		return
	print("[MapDCWorld] the level changed — re-deriving the shell")
	_reshell()


## The world half of the editor — hyper-games #27.
##
## REGISTERED BY THE MAP, not by game.gd, because the terrain belongs to the map:
## map_dc_level has no DCTerrain at all, and a headless peer frees the one here.
## BuildDock.register is idempotent on id, so re-entering the map replaces the
## panel rather than stacking a second one.
# _register_terrain_panel() moved to map_core.gd. It was here, which is the only
# reason no other map had a terrain panel: nothing in it was map-specific, it
# simply was not in the base class. Every map with a DCTerrain now gets one.


## The level sits above the procedural ground rather than at the origin.
func world_offset() -> Vector3:
	return _lift


## `,` / `.` move the level down / up and re-import at the new depth.
##
## SUPER FIRST, deliberately. map_dc_level gives the active operator first refusal
## and marks the event handled when it acts; taking these keys before that would
## reintroduce exactly the bug hyper-games #24 lists five times over — a binding
## consumed above the thing that was supposed to see it. If the event was handled,
## it was not for us.
func _unhandled_key_input(event: InputEvent) -> void:
	super._unhandled_key_input(event)
	var vp := get_viewport()
	if vp.is_input_handled():
		return
	if Keymap.tapped(event, "builder.lift_down"):
		_shift_lift(-LIFT_STEP)
		vp.set_input_as_handled()
	elif Keymap.tapped(event, "builder.lift_up"):
		_shift_lift(LIFT_STEP)
		vp.set_input_as_handled()
	elif Keymap.tapped(event, "builder.reshell"):
		_reshell()
		vp.set_input_as_handled()


## Re-derive the shell from the brush set AS IT IS NOW — the visual assessment
## hook for what editing the level does to the subtract.
##
## The subtract is stamped once, in _import_from, from the brushes the import
## produced. Nothing re-derives it: LevelPersistence.folded is wired only to
## _refresh_brush_view (map_dc_level.gd), which is the wireframe. So delete a wall
## and the terrain stays carved to a level that no longer has one. This is the
## manual re-derive, and it is how you find out what a deleted BOUNDARY brush
## does — the shell should stop being sealed and the moat should take over.
##
## CLEAR FIRST, ALWAYS. A second stamp does not replace the first: two overlapping
## mesh SDFs land in the same chunks and only the first gets its BVH uploaded, so
## the second is evaluated against the wrong tree and the geometry belongs to
## neither (dc_compute.cpp upload_edits). clear_landscape_edits() drops the old
## shell and keeps the player's carve journal.
func _reshell() -> void:
	_reshell_attempts += 1
	# Recorded up front, and on every path out. A re-derive that cannot run leaves
	# nothing stale behind — with no terrain there is no landscape to be out of
	# date — and NOT recording it would re-arm on the next fold and keep re-arming,
	# which on the sealed path means a 563k-triangle build every RESHELL_QUIET
	# seconds for as long as the map is open.
	_shell_key_stamped = _shell_cache_key()
	if terrain == null or not is_instance_valid(terrain):
		# Kept from when `;` was the only caller: pressing it and getting silence
		# is indistinguishable from pressing it and having nothing happen. Rare
		# either way — a headless peer edits no more often than a player does.
		push_warning("[MapDCWorld] no DCTerrain — nothing to re-shell")
		return
	if not terrain.has_method("clear_landscape_edits"):
		push_warning("[MapDCWorld] libdc predates clear_landscape_edits — a re-stamp " +
				"would ADD a second shell rather than replace it. Refusing.")
		return

	# A CACHED shell needs no thread: the work was done the last time the level was
	# in this state, and re-entering a map or undoing back to a shape you have
	# already had is the common case. Replace it now and skip the rest.
	var key: String = _shell_cache_key()
	if not _shell_cache_load(key).is_empty():
		print("[MapDCWorld] re-deriving the shell from the CURRENT brush set… [cached]")
		terrain.clear_landscape_edits()
		_subtract_shell()
		return

	if not level.has_method("build_shell_start"):
		# An older libdc — the .so is a symlink into dc-terrain and has lagged its
		# source before. Correct, and it freezes the frame for the length of the
		# build, which is what this whole path exists to stop doing.
		push_warning("[MapDCWorld] libdc predates build_shell_start — deriving on " +
				"the main thread, which will stall for the length of the build")
		terrain.clear_landscape_edits()
		_subtract_shell()
		return

	if level.shell_building():
		# One at a time. The fold that wanted this re-arms the countdown, so the
		# newer level state gets its own build after this one lands.
		_reshell_countdown = RESHELL_QUIET
		return

	# NOTHING IS CLEARED HERE, and that is the ordering the whole change turns on.
	# clear_landscape_edits() drops the shell currently subtracted from the
	# terrain; doing it at START would leave the rooms filled with hillside for the
	# 2.2 s the build takes. The old shell stays in the field until a new one is
	# ready to replace it, so the visible state goes from correct to correct.
	if level.build_shell_start():
		_shell_key_building = key
		print("[MapDCWorld] re-deriving the shell from the CURRENT brush set… (in the background)")


## Collect a finished background build. Polled from _process, because a Godot
## signal emitted from a worker thread is not a thing to reach for when a poll on
## an atomic will do — and the frame this lands on is already the one doing the
## stamping.
func _pump_shell() -> void:
	if level == null or not level.has_method("shell_ready") or not level.shell_ready():
		return
	var shell: Dictionary = level.build_shell_take()
	var key: String = _shell_key_building
	_shell_key_building = ""
	if shell.is_empty():
		push_warning("[MapDCWorld] the background shell build produced nothing")
		return
	if terrain == null or not is_instance_valid(terrain):
		return
	# STAMPED EVEN IF THE LEVEL MOVED ON while this was building. It is derived
	# from a newer state than whatever is in the terrain, so it is an improvement
	# on it; and a fold during the build has already re-armed the countdown, so the
	# newer state gets its own build. Refusing here would leave the OLDER shell in
	# place and re-derive from scratch, which is slower and no more correct.
	terrain.clear_landscape_edits()
	_apply_shell(shell, key, false, Time.get_ticks_msec())


func _shift_lift(dy: float) -> void:
	if _map_path_used == "":
		push_warning("[MapDCWorld] no level imported yet — nothing to move")
		return
	_lift.y += dy
	print("[MapDCWorld] lift -> %.3f m, re-importing" % _lift.y)
	_clear_for_reimport()
	_import_from(_map_path_used)


## A RE-IMPORT IS NOT AN IMPORT. map_dc_level imports exactly once at _ready and
## therefore never had to undo anything; doing it twice accumulates, and every one
## of these was verified against the code that creates it rather than assumed:
##
##   the brush store   add_brush APPENDS — a second import gives 2366 brushes, the
##                     level renders doubled and every count in the gate is wrong.
##   SkyAreaLights     _spawn_sky_area_lights add_child()s a fresh container and
##                     does not free an existing one.
##   RtlightManager    _spawn_rtlights news one per call. Two managers = 402
##                     lights fighting over the same shadow budget.
##   QuakeLights       the importer's own light spawner, via light_parent=self.
##
## Liquids, teleporters and doors are NOT here on purpose: QuakeEntities.spawn_*
## already free their containers first (quake_entities.gd:24, :52, :91), so they
## are re-runnable as they stand. Freeing them again here would be duplicate
## cleanup that silently rots when that file changes.
func _clear_for_reimport() -> void:
	if level != null and level.has_method("clear"):
		level.clear()
	for n in ["SkyAreaLights", "RtlightManager", "QuakeLights"]:
		var node := get_node_or_null(n)
		if node != null:
			# free(), not queue_free(): the re-import happens THIS frame and would
			# otherwise add a second container alongside one not yet reaped.
			node.free()


## NULL ON PURPOSE — the moat is replaced, not disabled.
##
## Returning the terrain here would fire the importer's inflated-AABB carve, which
## subtracts 324622 m^3 around a level whose sealed volume is ~38500 and whose own
## solid is 12600. That box is what reads from inside as "it still just cuts out a
## large aabb around the map", and it is 93% space the terrain should be occupying.
##
## _subtract_shell() below does the same job with the level's actual shape, after
## the import, and falls back to the moat by hand if the shell cannot be built.
## The fallback is the reason this can safely return null: something always
## subtracts, or the rooms fill with hillside.
func carve_field() -> Node:
	return null


## Subtract the level's SEALED VOLUME from the terrain field — §5a.2.
##
## One TerrainEdit::MESH rather than the AABB box or a set of negative brushes.
## Boxes were measured and rejected: a decomposition fine enough to be accurate
## needs 2735 of them against MAX_GPU_EDITS = 1536 per chunk, and the excess is
## dropped SILENTLY, so terrain leaks back by an amount that varies with LOD and
## camera. A mesh is one edit and the cap stops applying.
func _subtract_shell() -> void:
	if terrain == null or not is_instance_valid(terrain):
		return                                  # headless — nothing renders
	if level == null or not level.has_method("build_shell"):
		push_warning("[MapDCWorld] libdc has no build_shell — falling back to the moat")
		_moat_fallback()
		_shell_key_stamped = _shell_cache_key()
		return

	var t0 := Time.get_ticks_msec()
	var key := _shell_cache_key()
	var shell: Dictionary = _shell_cache_load(key)
	var from_cache := not shell.is_empty()
	if not from_cache:
		shell = level.build_shell()
	_apply_shell(shell, key, from_cache, t0)


## Validate a shell and put it in the terrain. Shared by the synchronous path (an
## import, a cache hit, an old libdc) and by the background one, so a shell built
## on a worker meets EXACTLY the same seal checks as one built on the frame —
## those checks are the four load-bearing invariants, and two paths' worth of them
## would be one too many.
func _apply_shell(shell: Dictionary, key: String, from_cache: bool, t0: int) -> void:
	var verts: PackedVector3Array = shell.get("vertices", PackedVector3Array())
	var idx: PackedInt32Array = shell.get("indices", PackedInt32Array())
	# A shell that came back empty, or that encloses far less than the level's own
	# solid, means the flood fill leaked — the usual cause is a level imported
	# without sky brushes, which leaves holes in the boundary. Falling back is
	# correct; shipping a shell that encloses nothing is not.
	var vol: float = float(shell.get("volume", 0.0))
	# A VOLUME THRESHOLD CANNOT CATCH THE FAILURE THAT MATTERS. If the flood fill
	# escapes — a brush deleted from the sealed boundary is enough, and those
	# deletions are durable and replay on load — the interior collapses toward
	# zero and the shell degenerates into the boundary of the SOLID alone. That is
	# still closed, still ~12600 m^3, and encloses none of the rooms. It sails
	# past `vol > 1000` and the terrain fills the level.
	#
	# So ask the shell to prove it instead: zero boundary edges (closed), and more
	# air enclosed than wall (sealed rather than merely shrink-wrapped).
	var sealed: bool = bool(shell.get("sealed", false))
	var bnd: int = int(shell.get("boundary_edges", -1))
	var air: float = float(shell.get("interior_volume", 0.0))
	var wall: float = float(shell.get("solid_volume", 0.0))
	if verts.size() < 4 or idx.size() < 3 or vol < 1000.0 or not sealed:
		push_warning(("[MapDCWorld] SHELL NOT SEALED — falling back to the moat. " +
				"verts=%d tris=%d vol=%.0f boundary_edges=%d interior=%.0f solid=%.0f. " +
				"A holed boundary usually means brushes were deleted from the level's " +
				"outer shell, or it was imported without sky brushes.")
				% [verts.size(), idx.size() / 3, vol, bnd, air, wall])
		_moat_fallback()
		# THE PATH EDITING LANDS YOU IN, so it is the one that must not re-arm:
		# a brush deleted from the outer boundary un-seals the shell durably, and
		# without this the map would re-derive a failing shell every RESHELL_QUIET
		# seconds for the rest of the session.
		_shell_key_stamped = key
		return

	# `center` is the mesh's own centroid, which is what stamp_mesh_arrays
	# re-centres on. Passing anything else translates the whole shell.
	var centre: Vector3 = shell.get("center", Vector3.ZERO)
	# strength < 0 = subtract (dc_edit.h:228). interactive=false: this is derived
	# landscape, not a live carve, so it must not flood the priority lane.
	#
	# persistent=true is LOAD-BEARING and was the whole bug. TerrainEditManager
	# calls DCTerrain.clear_edits() on every GameState.map_changing
	# (terrain_edit_manager.gd:53) to drop the previous world's craters — and that
	# signal fires AFTER this map's deferred import, so it found the terrain we had
	# just stamped, wiped the shell, and rebuilt every chunk from a field with no
	# subtract in it. The rooms filled with hillside while the density probe at
	# those same points still read air, because by then the edit was gone.
	#
	# The shell is MAP-AUTHORED LANDSCAPE, not a player edit — nothing outside this
	# map may drop it. persistent=true makes clear_edits() keep it.
	if not from_cache:
		_shell_cache_store(key, shell)
	terrain.stamp_mesh_arrays(verts, idx, centre, 1.0, -1.0, false, 0, true)
	_shell_key_stamped = key
	print("[MapDCWorld] shell SEALED%s (0 boundary edges, air %.0f > wall %.0f) — subtracted: %d tris, %.0f m^3, %.0f m^2, snapped %d/%d, %d ms" % [
		" [cached]" if from_cache else "", air, wall,
		int(shell.get("triangles", 0)), vol, float(shell.get("area", 0.0)),
		int(shell.get("snapped", 0)),
		int(shell.get("snapped", 0)) + int(shell.get("unmoved", 0)),
		Time.get_ticks_msec() - t0])


## The old behaviour, by hand, for when the shell cannot be built. Crude and
## kept: 26x the level's solid is still better than terrain in every room.
func _moat_fallback() -> void:
	if terrain == null or not terrain.has_method("edit_terrain_box"):
		return
	var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
	if mi == null:
		return
	var aabb: AABB = mi.get_aabb()
	# persistent=true, FOR THE SAME REASON THE SHELL IS. This is the fallback that
	# runs when the shell will not seal — a brush deleted from the level's outer
	# boundary is enough, and those deletions are durable and replay on load. So
	# the degraded path is exactly the one you land in after editing, and leaving
	# it non-persistent meant TerrainEditManager's clear_edits() on map_changing
	# wiped the moat too: the seal check fired correctly, the fallback landed, and
	# then NOTHING subtracted. The original bug, back through the fallback door.
	terrain.edit_terrain_box(aabb.get_center(), aabb.size * 0.5 * 1.1, -1.0, false, 0, true)
	print("[MapDCWorld] moat fallback: box %s half %s" % [str(aabb.get_center()), str(aabb.size * 0.5 * 1.1)])


## Import as inherited, then anchor the way out to where the player actually
## arrives. The inherited TeleporterToHome sits at a transform authored for the
## arena, which after a 20 m lift is underground.
func _import_from(map_path: String) -> void:
	super._import_from(map_path)
	# AFTER super, because the shell is derived from the brushes the import just
	# loaded — building it before there is a level produces an empty mesh.
	_subtract_shell()
	_place_home_teleporter()


func _place_home_teleporter() -> void:
	var home := get_node_or_null("TeleporterToHome") as Node3D
	if home == null:
		push_warning("[MapDCWorld] no TeleporterToHome — no way back to map_test")
		return
	if not _has_start:
		# Left where the scene put it. Say so, because a teleporter silently
		# somewhere underground is indistinguishable from one that is missing.
		push_warning("[MapDCWorld] no info_player_start — TeleporterToHome left at its authored transform (may be underground)")
		return
	home.global_position = _start_pos + HOME_OFFSET
	print("[MapDCWorld] TeleporterToHome at %s (player start %s)" % [
		str(home.global_position), str(_start_pos)])


# ---- shell cache -----------------------------------------------------------
#
# build_shell() costs 2.2 s, measured, and it ran on EVERY entry to this map even
# though the level was usually identical to last time. Timestamping the game log
# attributed the ~4 s map load precisely:
#
#   import: 1183 brushes stamped in 1533 ms   1.5 s
#   shell SEALED, 563544 tris,     2206 ms    2.2 s
#   sky restore                      21 ms
#
# So this is the larger half of the wait, and it is recomputation rather than
# waiting on anything.
#
# KEYED BY WHAT CHANGES THE SHELL, not just the .MAP. The source hash alone is
# wrong: the level is editable, and a deleted boundary brush is exactly the case
# the seal check exists for. The key therefore also carries the lift (the shell is
# built in world space), the geometry counts, and the journal's record count so an
# edit invalidates it. `;` (builder.reshell) still forces a re-derive.
#
# Not bulletproof: an edit that preserves every count — a face nudged along its
# own normal — collides. That is why `;` exists and why this stays a cache rather
# than becoming the source of truth.

const SHELL_CACHE_DIR := "user://shell_cache"
## Bump when the shell's meaning changes (winding, clamp, cell size), or old
## entries would be served against new semantics.
## Bumped to 2 when the key stopped being a tuple of counts and became the
## geometry fingerprint: every v1 entry was filed under a key that could collide,
## so serving one now would reintroduce the bug the change exists to fix.
## Bumped to 3 with per-brush origins (dc-terrain, hyper-games #123): the brushes'
## world planes are now derived from a frame and can differ in the last bit, so a
## shell cached from the old planes is not the shell this build would derive.
const SHELL_CACHE_VERSION := 3


func _shell_cache_key() -> String:
	if level == null:
		return ""
	var src := ""
	if _persist != null and _persist.has_method("find_source"):
		src = String(_persist.find_source().get("hash", ""))
	if src == "":
		return ""                                # unknown level: do not cache
	# THE GEOMETRY'S OWN HASH, not a tuple of counts.
	#
	# The key was b<brushes>_f<faces>_t<triangles>_r<records>, and the comment
	# above already conceded that an edit preserving every count collides. It does
	# worse than collide: a hit SUBTRACTS THE WRONG VOLUME. Reported from the game
	# at 0.929.0 — an arch created outside the level re-derived "[cached]" and
	# stamped a shell of 564840 triangles / 38512 m^3, where building the same
	# state fresh gives 566048 / 38538. The tunnel stayed full of dirt and every
	# line on screen said SEALED, because the shell that sealed was a different
	# level's.
	#
	# DCLevel::fingerprint is an order-independent hash of live brush geometry over
	# DURABLE KEYS — built for exactly this question and identical on every peer.
	# A clearance volume changes it; so does a face nudged along its own normal,
	# which is the case the counts were blind to.
	var fp := 0
	if level.has_method("fingerprint"):
		fp = int(level.fingerprint())
	return "v%d_%s_l%.3f_fp%d" % [SHELL_CACHE_VERSION, src, _lift.y, fp]


func _shell_cache_path(key: String) -> String:
	return "%s/%s.dat" % [SHELL_CACHE_DIR, key]


## The shell as last built for this key, or {} when there is nothing usable.
## Any failure returns {} so the caller simply rebuilds — a cache must never be
## able to break the thing it accelerates.
func _shell_cache_load(key: String) -> Dictionary:
	if key == "":
		return {}
	var f := FileAccess.open(_shell_cache_path(key), FileAccess.READ)
	if f == null:
		return {}
	var v: Variant = f.get_var(true)
	f.close()
	if not (v is Dictionary):
		return {}
	var d: Dictionary = v
	# Re-validate rather than trust: a truncated or half-written file must not be
	# stamped, and a shell that is not sealed was never worth storing.
	if not (d.has("vertices") and d.has("indices") and d.has("center") and d.has("sealed")):
		return {}
	if (d["vertices"] as PackedVector3Array).size() < 4:
		return {}
	return d


func _shell_cache_store(key: String, shell: Dictionary) -> void:
	# Only cache a shell that PASSED. Storing a leaked one would make the moat
	# fallback permanent for that key, and silently.
	if key == "" or not bool(shell.get("sealed", false)):
		return
	DirAccess.make_dir_recursive_absolute(SHELL_CACHE_DIR)
	var f := FileAccess.open(_shell_cache_path(key), FileAccess.WRITE)
	if f == null:
		push_warning("[MapDCWorld] shell cache not writable — rebuilding every entry")
		return
	f.store_var(shell, true)
	f.close()