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()