Files

468 lines
17 KiB
Plaintext

//* This file is explicitly licensed under the MIT license. *//
//* Copyright (c) 2025 Citadel Station Developers *//
/**
* Z-Level Management System
*
* All adds/removes should go through this. Directly modifying zlevel amount/whatever is forbidden.
*/
/**
* Rebuilds multiz lookup.
*
* * Please specify a specific index/dir, doing this without that is extremely expensive.
* * This proc reserves the right to eagerly rebuild something you didn't specify, but will never
* skip rebuilding something you did specify. As an example, telling it rebuild z-1's NORTH
* might result in all of z-1's lookups being updated, but NORTH will always be updated no matter
* what.
* * This proc does not sleep, as it's used in some critical blocking contexts. That's why
* it's important to not rebuild everything unless it's actually needed, as that's very slow.
*
* @params
* * for_index - for level index
* * for_dir - for dir bits; this is ignored if for_index is null.
* * for_reciprocal - do the same for the other level towards the target index; this is ignored if for_index is null
*/
/datum/controller/subsystem/mapping/proc/rebuild_multiz_lookup(for_index, for_dir, for_reciprocal)
z_stack_dirty = TRUE
if(!for_index)
cached_level_up = new /list(world.maxz)
cached_level_down = new /list(world.maxz)
cached_level_east = new /list(world.maxz)
cached_level_west = new /list(world.maxz)
cached_level_north = new /list(world.maxz)
cached_level_south = new /list(world.maxz)
if(for_index)
var/datum/map_level/level = ordered_levels[for_index]
var/level_partner_index
level_partner_index = level.get_level_in_dir(UP)?.z_index
cached_level_up[for_index] = level_partner_index
if(level_partner_index && for_reciprocal)
rebuild_multiz_lookup(level_partner_index, DOWN, FALSE)
level_partner_index = level.get_level_in_dir(DOWN)?.z_index
cached_level_down[for_index] = level_partner_index
if(level_partner_index && for_reciprocal)
rebuild_multiz_lookup(level_partner_index, UP, FALSE)
level_partner_index = level.get_level_in_dir(NORTH)?.z_index
cached_level_north[for_index] = level_partner_index
if(level_partner_index && for_reciprocal)
rebuild_multiz_lookup(level_partner_index, SOUTH, FALSE)
level_partner_index = level.get_level_in_dir(SOUTH)?.z_index
cached_level_south[for_index] = level_partner_index
if(level_partner_index && for_reciprocal)
rebuild_multiz_lookup(level_partner_index, NORTH, FALSE)
level_partner_index = level.get_level_in_dir(EAST)?.z_index
cached_level_east[for_index] = level_partner_index
if(level_partner_index && for_reciprocal)
rebuild_multiz_lookup(level_partner_index, EAST, FALSE)
level_partner_index = level.get_level_in_dir(WEST)?.z_index
cached_level_west[for_index] = level_partner_index
if(level_partner_index && for_reciprocal)
rebuild_multiz_lookup(level_partner_index, WEST, FALSE)
else
for(var/i in 1 to world.maxz)
var/datum/map_level/level = ordered_levels[i]
cached_level_up[i] = level.get_level_in_dir(UP)?.z_index
cached_level_down[i] = level.get_level_in_dir(DOWN)?.z_index
cached_level_north[i] = level.get_level_in_dir(NORTH)?.z_index
cached_level_south[i] = level.get_level_in_dir(SOUTH)?.z_index
cached_level_east[i] = level.get_level_in_dir(EAST)?.z_index
cached_level_west[i] = level.get_level_in_dir(WEST)?.z_index
/**
* Performs full multiz (including turf / transition) rebuild of an active level.
*
* * Implies [rebuild_multiz_lookup]
* * Please specify a specific index/dir, doing this without that is extremely expensive.
* * This proc reserves the right to eagerly rebuild something you didn't specify, but will never
* skip rebuilding something you did specify. As an example, telling it rebuild z-1's NORTH
* might result in all of z-1's lookups being updated, but NORTH will always be updated no matter
* what.
*
* @params
* * for_index - for level index
* * for_dir - for dir bits; this is ignored if for_index is null.
* * for_reciprocal - do the same for the other level towards the target index; this is ignored if for_index is null
*/
/datum/controller/subsystem/mapping/proc/rebuild_multiz(for_index, for_dir, for_reciprocal)
if(for_index)
rebuild_multiz_lookup(for_index, for_dir, for_reciprocal)
var/datum/map_level/level = ordered_levels[for_index]
// TODO: how do we only rebuild in a specific direction?
level.rebuild_multiz(for_reciprocal)
else
rebuild_multiz_lookup()
spawn(0)
for(var/i in 1 to world.maxz)
var/datum/map_level/level = ordered_levels[for_index]
level.rebuild_multiz()
//* Allocations & Deallocations * //
/**
* allocates a raw map level using the given datum type.
*
* * This does not perform **any** generation or processing on the level, including replacing baseturfs!
* * This **will** call the level's on_loaded_immediate and on_loaded_finalize.
* * You usually want load_level() instead, this is instead the equivalent of just incrementing world.maxz.
* * Any level passed in or returned from this belongs to SSmapping; you are not allowed to keep direct references to it
* in other datastructures.
* * This is a very low level proc, please be careful what you do with it.
*
* @params
* * level - a path, or instance.
*
* @#return the instance of /datum/map_level created / used, null on failure
*/
/datum/controller/subsystem/mapping/proc/allocate_level(datum/map_level/level) as /datum/map_level
RETURN_TYPE(/datum/map_level)
UNTIL(!map_system_mutex)
map_system_mutex = TRUE
. = allocate_level_impl(ispath(level) ? new level : level)
map_system_mutex = FALSE
/**
* Special internal helper. Used to allocate a level's z-index and set system variables.
*/
/datum/controller/subsystem/mapping/proc/allocate_level_impl(datum/map_level/level) as /datum/map_level
PRIVATE_PROC(TRUE)
RETURN_TYPE(/datum/map_level)
// run safety checks
ASSERT(!level.loaded)
// register level, generating an ID if it has none
if(level.id)
if(!isnull(keyed_levels[level.id]))
CRASH("ID collision on '[level.id]' during level allocation.")
else
do
// TODO: persistence-stable ID
level.id = "gen-[copytext(md5("[rand(1, 1024 ** 2)]"), 1, 5)]"
while(keyed_levels[level.id])
keyed_levels[level.id] = level
// allocate a zlevel
var/allocated_z_level = allocate_z_index()
ASSERT(allocated_z_level)
ASSERT(isnull(ordered_levels[allocated_z_level]))
// assign z-level and set it as loaded
level.z_index = allocated_z_level
level.loaded = TRUE
ordered_levels[allocated_z_level] = level
. = level
// generate names if they're not there
if(isnull(level.display_id))
level.display_id = generate_fluff_level_id()
if(isnull(level.display_name))
level.display_name = "Sector [level.display_id]"
//! LEGACY
if(!isnull(level.planet_path))
SSplanets.legacy_planet_assert(allocated_z_level, level.planet_path)
if(loaded_station)
if((level.flags & LEGACY_LEVEL_STATION) || level.has_trait(ZTRAIT_STATION))
loaded_station.station_levels += allocated_z_level
if((level.flags & LEGACY_LEVEL_ADMIN) || level.has_trait(ZTRAIT_ADMIN))
loaded_station.admin_levels += allocated_z_level
if((level.flags & LEGACY_LEVEL_CONTACT) || level.has_trait(ZTRAIT_STATION))
loaded_station.contact_levels += allocated_z_level
if((level.flags & LEGACY_LEVEL_CONSOLES) || level.has_trait(ZTRAIT_STATION))
loaded_station.map_levels += allocated_z_level
// Holomaps
// Auto-center the map if needed (Guess based on maxx/maxy)
if (level.holomap_offset_x < 0)
level.holomap_offset_x = ((HOLOMAP_ICON_SIZE - world.maxx) / 2)
if (level.holomap_offset_x < 0)
level.holomap_offset_y = ((HOLOMAP_ICON_SIZE - world.maxy) / 2)
// Assign them to the map lists
LIST_NUMERIC_SET(loaded_station.holomap_offset_x, allocated_z_level, level.holomap_offset_x)
LIST_NUMERIC_SET(loaded_station.holomap_offset_y, allocated_z_level, level.holomap_offset_y)
LIST_NUMERIC_SET(loaded_station.holomap_legend_x, allocated_z_level, level.holomap_legend_x)
LIST_NUMERIC_SET(loaded_station.holomap_legend_y, allocated_z_level, level.holomap_legend_y)
//! END
/**
* loads a map level.
*
* if it doesn't have a file, we'll change all the turfs to the given baseturf and set atmos/whatever.
*
* @params
* * instance - level to load
* * use_area_cache - use the given list as the area cache for the DMM loader. if none is provided, one will be created.
* * use_dmm_context - use the given dmm_context for the loader. if none is provided, one will be created.
* * defer_for_group_load - defer things like generation callbacks. should generally only be internally used by SSmapping; the API can change at any time.
* * out_generation_callbacks - generation callbacks emitted by on_load_immediate are put in here instead of executed, if we are deferring due to group load
*
* @return loaded context, or null on fail
*/
/datum/controller/subsystem/mapping/proc/load_level(
datum/map_level/instance,
list/use_area_cache,
datum/dmm_context/use_dmm_context,
defer_for_group_load,
list/datum/callback/out_generation_callbacks,
)
UNTIL(!map_system_mutex)
map_system_mutex = TRUE
. = load_level_impl(
instance,
use_area_cache,
use_dmm_context,
defer_for_group_load,
out_generation_callbacks,
)
map_system_mutex = FALSE
/datum/controller/subsystem/mapping/proc/load_level_impl(
datum/map_level/instance,
list/use_area_cache,
datum/dmm_context/use_dmm_context,
defer_for_group_load,
list/datum/callback/out_generation_callbacks,
)
PRIVATE_PROC(TRUE)
var/datum/map_level/allocated_instance = allocate_level_impl(instance, FALSE)
ASSERT(instance.z_index)
ASSERT(instance.id)
ASSERT(instance == allocated_instance)
if(isnull(use_dmm_context))
use_dmm_context = create_dmm_context()
if(isnull(use_dmm_context.mangling_id))
use_dmm_context.mangling_id = "level-[instance.mangling_id || instance.id]"
var/datum/dmm_context/loaded_context
if(instance.base_area)
var/area/level_area_type = instance.base_area
var/area/level_area_instance
if(level_area_type.unique)
level_area_instance = GLOB.areas_by_type[level_area_type]
if(!level_area_instance)
level_area_instance = new(null)
level_area_instance.take_turfs(Z_TURFS(instance.z_index))
CHECK_TICK
if(instance.base_turf)
var/list/turf/to_change = Z_TURFS(instance.z_index)
for(var/turf/T as anything in to_change)
T.ChangeTurf(instance.base_turf, list(/turf/baseturf_bottom))
CHECK_TICK
if(instance.has_map_path())
var/datum/dmm_parsed/parsed = instance.parse_map_path()
if(!parsed)
CRASH("level [instance] ([instance.type]) couldn't parse its map because its map path was missing or an unknown error occurred.")
if(!parsed.parsed)
CRASH("failed to parse a level being loaded. is the map malformed?")
var/real_x = instance.load_center ? 1 + round((world.maxx - parsed.width) / 2) : 1
var/real_y = instance.load_center ? 1 + round((world.maxy - parsed.height) / 2) : 1
var/real_z = instance.z_index
if(!instance.load_crop && ((parsed.width + real_x - 1) > world.maxx || (parsed.height + real_y - 1) > world.maxy))
CRASH("tried to load a map that would overrun the world bounds")
loaded_context = parsed.load(
real_x,
real_y,
real_z,
no_changeturf = TRUE,
place_on_top = FALSE,
orientation = instance.load_orientation,
area_cache = use_area_cache,
context = use_dmm_context,
)
ASSERT(loaded_context.success)
else
// this is not a real map path
loaded_context = use_dmm_context
loaded_context.mark_used()
loaded_context.set_empty_load()
loaded_context.success = TRUE
// immediately update munltiz cache
rebuild_multiz_lookup(instance.z_index)
// fire context immediately
loaded_context.execute_postload()
// fire instance on load hooks
var/list/datum/callback/generation_callbacks = list()
instance.on_loaded_immediate(instance.z_index, generation_callbacks)
// fire finalize and generation callbacks if not deferred
if(!defer_for_group_load)
for(var/datum/callback/generation_callback as anything in generation_callbacks)
generation_callback.Invoke()
if(!SSatoms.initialized)
SSatoms.init_map_bounds(loaded_context.loaded_bounds)
instance.on_loaded_finalize(instance.z_index)
instance.rebuild_multiz()
else
out_generation_callbacks += generation_callbacks
return loaded_context
/**
* destroys a loaded level and frees it for later usage
*
* @return TRUE / FALSE based on success / fail
*/
/datum/controller/subsystem/mapping/proc/unload_level(datum/map_level/instance)
if(instance.parent_map)
CRASH("attempted to unload_level on a level with a parent map. the map must be unloaded, not the level individually.")
CRASH("unimplemented")
/**
* immediately de-allocates a loaded level and frees its z-index.
*
* **Do not use this directly unless you absolutely know what you are doing.**
* This does not perform any cleanup, and calling this on a loaded zlevel can have
* severe consequences.
*
* @return TRUE / FALSE based on success / fail
*/
/datum/controller/subsystem/mapping/proc/unallocate_level(datum/map_level/instance)
if(instance.parent_map)
CRASH("attempted to unallocate_level on a level with a parent map. the map must be unloaded, not the level individually.")
CRASH("unimplemented")
//* Traits, Attributes, and IDs *//
/**
* called when a trait is added to a loaded level
*
* if a level is loading with traits included, this is called per trait after load.
*/
/datum/controller/subsystem/mapping/proc/on_level_trait_add(datum/map_level/level, trait)
return
/**
* called when a trait is removed from a loaded level
*
* if a level is being deleted with traits on it, this is called per trait prior to delete.
*/
/datum/controller/subsystem/mapping/proc/on_level_trait_del(datum/map_level/level, trait)
return
/**
* called when an attribute is set ton a level
*
* if a level is loading with attribute included, this is called per attribute after load with an old_value of null.
*/
/datum/controller/subsystem/mapping/proc/on_level_attribute_set(datum/map_level/level, attribute, old_value, new_value)
return
/**
* generates random hex fluff level id
*/
/datum/controller/subsystem/mapping/proc/generate_fluff_level_id()
// todo: needs to be persistence-stable..?
// todo: this looks ugly, fix it!!!
var/discriminator = GLOB.round_id? "[num2hex(text2num(GLOB.round_id), 6)]-" : ""
var/safety = 500
do
if(!--safety)
CRASH("ran out of safety somehow, wtf")
. = "[discriminator][num2hex(rand(1, 16 ** 4 - 1))]"
while(. in random_fluff_level_hashes)
random_fluff_level_hashes += .
//* Z-stacks *//
/**
* Recalculates Z stack
*
* **Warning**: rebuild_verticality must be called to recalculate up/down lookups,
* as this proc uses them for speed!
*/
/datum/controller/subsystem/mapping/proc/recalculate_z_stack()
validate_no_loops()
z_stack_lookup = new /list(world.maxz)
// let's sing the bottom song
var/list/datum/map_level/bottoms = list()
for(var/z in 1 to world.maxz)
if(cached_level_down[z])
continue
bottoms += ordered_levels[z]
for(var/datum/map_level/bottom as anything in bottoms)
var/datum/map_level/iterating = bottom
var/datum/map_level/last = null
var/list/stack = list()
var/list/levels_in_stack = list()
var/discontinuity_found = FALSE
do
if(last && last != iterating.get_level_in_dir(DOWN))
discontinuity_found = TRUE
stack += iterating.z_index
levels_in_stack += iterating.z_index
last = iterating
iterating = ordered_levels[cached_level_up[iterating.z_index]]
while(iterating)
if(discontinuity_found)
stack_trace("z-levels [english_list(levels_in_stack)] had at least one discontinuity where going down isn't the same as going up. \
non-euclidean verticality is not supported.")
stack = list()
for(var/z in levels_in_stack)
z_stack_lookup[z] = stack
for(var/i in 1 to world.maxz)
if(length(z_stack_lookup[i]) >= 1)
else
z_stack_lookup[i] = list()
stack_trace("z-level [i] ([ordered_levels[i]?.name]) had no z-stack. did someone mess up their up/down configs?")
z_stack_dirty = FALSE
/**
* Ensures there's no up/down infinite loops
*/
/datum/controller/subsystem/mapping/proc/validate_no_loops()
var/list/loops = list()
for(var/z in 1 to world.maxz)
// already in a loop don't bother
if(z in loops)
continue
var/list/found
var/next
// scan up
next = z
found = list(z)
while(next)
next = cached_level_up[next]
if(next in found)
loops += found
break
if(next)
found += next
// scan down
next = z
found = list(z)
while(next)
next = cached_level_down[next]
if(next in found)
loops += found
break
if(next)
found += next
if(!loops.len)
return
for(var/z in loops)
var/datum/map_level/level = ordered_levels[z]
level.link_above_id = null
level.link_below_id = null
stack_trace("WARNING: Up/Down loops found in zlevels [english_list(loops)]. This is not allowed and will cause both falling and zcopy to infinitely loop. All zlevels involved have been disconnected, and any structs involved have been destroyed.")
rebuild_multiz_lookup()
for(var/z in loops)
var/datum/map_level/level = ordered_levels[z]
if(level.link_above_id || level.link_below_id)
stack_trace("WARNING: level [z] ([level.name]) wasn't unlinked after having a loop detected.")
continue
spawn(0)
level.rebuild_multiz_vertical()