mirror of
https://github.com/PolarisSS13/Polaris.git
synced 2025-12-30 12:03:11 +00:00
Imagine you're on the last iteration of this loop, you've done the final column of X coordinates and you're going to set maxx. Would you want to set it to where you are now, the final X column... or would you want to add ONE MORE beyond the template size for some reason then set it to that? This bug causes all templates that are the size of normal maps to fail to initialize any atoms if your template is the size of your normal maps, because it tries to obtain a square of size minx, miny, minz, maxx, maxy, maxz to initialize, however maxx is nonextant because it exists outside the bounds of the world. It also causes all submaps to initialize an additional column of atoms twice, or not initialize any if they spawn against the right edge of the map.
545 lines
18 KiB
Plaintext
545 lines
18 KiB
Plaintext
///////////////////////////////////////////////////////////////
|
|
//SS13 Optimized Map loader
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
/*
|
|
//global datum that will preload variables on atoms instanciation
|
|
GLOBAL_VAR_INIT(use_preloader, FALSE)
|
|
GLOBAL_DATUM_INIT(_preloader, /dmm_suite/preloader, new)
|
|
*/
|
|
|
|
//global datum that will preload variables on atoms instanciation
|
|
var/global/dmm_suite/preloader/_preloader = new()
|
|
var/global/use_preloader = FALSE
|
|
|
|
/dmm_suite
|
|
// /"([a-zA-Z]+)" = \(((?:.|\n)*?)\)\n(?!\t)|\((\d+),(\d+),(\d+)\) = \{"([a-zA-Z\n]*)"\}/g
|
|
var/static/regex/dmmRegex = new/regex({""(\[a-zA-Z]+)" = \\(((?:.|\n)*?)\\)\n(?!\t)|\\((\\d+),(\\d+),(\\d+)\\) = \\{"(\[a-zA-Z\n]*)"\\}"}, "g")
|
|
// /^[\s\n]+"?|"?[\s\n]+$|^"|"$/g
|
|
var/static/regex/trimQuotesRegex = new/regex({"^\[\\s\n]+"?|"?\[\\s\n]+$|^"|"$"}, "g")
|
|
// /^[\s\n]+|[\s\n]+$/
|
|
var/static/regex/trimRegex = new/regex("^\[\\s\n]+|\[\\s\n]+$", "g")
|
|
var/static/list/modelCache = list()
|
|
var/static/space_key
|
|
#ifdef TESTING
|
|
var/static/turfsSkipped
|
|
#endif
|
|
|
|
/**
|
|
* Construct the model map and control the loading process
|
|
*
|
|
* WORKING :
|
|
*
|
|
* 1) Makes an associative mapping of model_keys with model
|
|
* e.g aa = /turf/unsimulated/wall{icon_state = "rock"}
|
|
* 2) Read the map line by line, parsing the result (using parse_grid)
|
|
*
|
|
*/
|
|
/dmm_suite/load_map(dmm_file as file, x_offset as num, y_offset as num, z_offset as num, cropMap as num, measureOnly as num, no_changeturf as num, orientation as num)
|
|
//How I wish for RAII
|
|
if(!measureOnly)
|
|
Master.StartLoadingMap()
|
|
space_key = null
|
|
#ifdef TESTING
|
|
turfsSkipped = 0
|
|
#endif
|
|
. = load_map_impl(dmm_file, x_offset, y_offset, z_offset, cropMap, measureOnly, no_changeturf, orientation)
|
|
#ifdef TESTING
|
|
if(turfsSkipped)
|
|
testing("Skipped loading [turfsSkipped] default turfs")
|
|
#endif
|
|
if(!measureOnly)
|
|
Master.StopLoadingMap()
|
|
|
|
/dmm_suite/proc/load_map_impl(dmm_file, x_offset, y_offset, z_offset, cropMap, measureOnly, no_changeturf, orientation)
|
|
var/tfile = dmm_file//the map file we're creating
|
|
if(isfile(tfile))
|
|
tfile = file2text(tfile)
|
|
|
|
if(!x_offset)
|
|
x_offset = 1
|
|
if(!y_offset)
|
|
y_offset = 1
|
|
if(!z_offset)
|
|
z_offset = world.maxz + 1
|
|
|
|
// If it's not a single dir, default to north (Default orientation)
|
|
if(!orientation in cardinal)
|
|
orientation = SOUTH
|
|
|
|
var/list/bounds = list(1.#INF, 1.#INF, 1.#INF, -1.#INF, -1.#INF, -1.#INF)
|
|
var/list/grid_models = list()
|
|
var/key_len = 0
|
|
|
|
var/stored_index = 1
|
|
while(dmmRegex.Find(tfile, stored_index))
|
|
stored_index = dmmRegex.next
|
|
|
|
// "aa" = (/type{vars=blah})
|
|
if(dmmRegex.group[1]) // Model
|
|
var/key = dmmRegex.group[1]
|
|
if(grid_models[key]) // Duplicate model keys are ignored in DMMs
|
|
continue
|
|
if(key_len != length(key))
|
|
if(!key_len)
|
|
key_len = length(key)
|
|
else
|
|
throw EXCEPTION("Inconsistant key length in DMM")
|
|
if(!measureOnly)
|
|
grid_models[key] = dmmRegex.group[2]
|
|
|
|
// (1,1,1) = {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
|
else if(dmmRegex.group[3]) // Coords
|
|
if(!key_len)
|
|
throw EXCEPTION("Coords before model definition in DMM")
|
|
|
|
var/xcrdStart = text2num(dmmRegex.group[3]) + x_offset - 1
|
|
//position of the currently processed square
|
|
var/xcrd
|
|
var/ycrd = text2num(dmmRegex.group[4]) + y_offset - 1
|
|
var/zcrd = text2num(dmmRegex.group[5]) + z_offset - 1
|
|
|
|
var/zexpansion = zcrd > world.maxz
|
|
if(zexpansion && !measureOnly)
|
|
if(cropMap)
|
|
continue
|
|
else
|
|
world.maxz = zcrd //create a new z_level if needed
|
|
if(!no_changeturf)
|
|
WARNING("Z-level expansion occurred without no_changeturf set, this may cause problems")
|
|
|
|
bounds[MAP_MINX] = min(bounds[MAP_MINX], xcrdStart)
|
|
bounds[MAP_MINZ] = min(bounds[MAP_MINZ], zcrd)
|
|
bounds[MAP_MAXZ] = max(bounds[MAP_MAXZ], zcrd)
|
|
|
|
var/list/gridLines = splittext(dmmRegex.group[6], "\n")
|
|
|
|
var/leadingBlanks = 0
|
|
while(leadingBlanks < gridLines.len && gridLines[++leadingBlanks] == "")
|
|
if(leadingBlanks > 1)
|
|
gridLines.Cut(1, leadingBlanks) // Remove all leading blank lines.
|
|
|
|
if(!gridLines.len) // Skip it if only blank lines exist.
|
|
continue
|
|
|
|
if(gridLines.len && gridLines[gridLines.len] == "")
|
|
gridLines.Cut(gridLines.len) // Remove only one blank line at the end.
|
|
|
|
bounds[MAP_MINY] = min(bounds[MAP_MINY], ycrd)
|
|
ycrd += gridLines.len - 1 // Start at the top and work down
|
|
|
|
if(!cropMap && ycrd > world.maxy)
|
|
if(!measureOnly)
|
|
world.maxy = ycrd // Expand Y here. X is expanded in the loop below
|
|
bounds[MAP_MAXY] = max(bounds[MAP_MAXY], ycrd)
|
|
else
|
|
bounds[MAP_MAXY] = max(bounds[MAP_MAXY], min(ycrd, world.maxy))
|
|
|
|
var/maxx = xcrdStart
|
|
|
|
// Assemble the grid of keys
|
|
var/list/key_list = list()
|
|
for(var/line in gridLines)
|
|
var/list/line_keys = list()
|
|
xcrd = 1
|
|
for(var/tpos = 1 to length(line) - key_len + 1 step key_len)
|
|
if(xcrd > world.maxx)
|
|
if(cropMap)
|
|
break
|
|
else
|
|
world.maxx = xcrd
|
|
|
|
if(xcrd >= 1)
|
|
var/model_key = copytext(line, tpos, tpos + key_len)
|
|
line_keys[++line_keys.len] = model_key
|
|
#ifdef TESTING
|
|
else
|
|
++turfsSkipped
|
|
#endif
|
|
CHECK_TICK
|
|
maxx = max(maxx, xcrd++)
|
|
key_list[++key_list.len] = line_keys
|
|
|
|
// Rotate the list according to orientation
|
|
if(orientation != SOUTH)
|
|
var/num_cols = key_list[1].len
|
|
var/num_rows = key_list.len
|
|
var/list/new_key_list = list()
|
|
// If it's rotated 180 degrees, the dimensions are the same
|
|
if(orientation == NORTH)
|
|
new_key_list.len = num_rows
|
|
for(var/i = 1 to new_key_list.len)
|
|
new_key_list[i] = list()
|
|
new_key_list[i].len = num_cols
|
|
// Else, the dimensions are swapped
|
|
else
|
|
new_key_list.len = num_cols
|
|
for(var/i = 1 to new_key_list.len)
|
|
new_key_list[i] = list()
|
|
new_key_list[i].len = num_rows
|
|
|
|
num_rows++ // Buffering against the base index of 1
|
|
num_cols++
|
|
// Populate the new list
|
|
for(var/i = 1 to new_key_list.len)
|
|
for(var/j = 1 to new_key_list[i].len)
|
|
switch(orientation)
|
|
if(NORTH)
|
|
new_key_list[i][j] = key_list[num_rows - i][num_cols - j]
|
|
if(EAST)
|
|
new_key_list[i][j] = key_list[num_rows - j][i]
|
|
if(WEST)
|
|
new_key_list[i][j] = key_list[j][num_cols - i]
|
|
|
|
key_list = new_key_list
|
|
|
|
if(measureOnly)
|
|
for(var/list/line in key_list)
|
|
maxx = max(maxx, line.len)
|
|
else
|
|
for(var/i = 1 to key_list.len)
|
|
if(ycrd <= world.maxy && ycrd >= 1)
|
|
xcrd = xcrdStart
|
|
for(var/j = 1 to key_list[1].len)
|
|
if(xcrd > world.maxx)
|
|
if(cropMap)
|
|
break
|
|
else
|
|
world.maxx = xcrd
|
|
|
|
if(xcrd >= 1)
|
|
var/no_afterchange = no_changeturf || zexpansion
|
|
if(!no_afterchange || (key_list[i][j] != space_key))
|
|
if(!grid_models[key_list[i][j]])
|
|
throw EXCEPTION("Undefined model key in DMM: [dmm_file], [key_list[i][j]]")
|
|
parse_grid(grid_models[key_list[i][j]], key_list[i][j], xcrd, ycrd, zcrd, no_afterchange, orientation)
|
|
#ifdef TESTING
|
|
else
|
|
++turfsSkipped
|
|
#endif
|
|
CHECK_TICK
|
|
maxx = max(maxx, xcrd)
|
|
++xcrd
|
|
--ycrd
|
|
|
|
bounds[MAP_MAXX] = max(bounds[MAP_MAXX], cropMap ? min(maxx, world.maxx) : maxx)
|
|
|
|
CHECK_TICK
|
|
|
|
if(bounds[1] == 1.#INF) // Shouldn't need to check every item
|
|
return null
|
|
else
|
|
// if(!measureOnly)
|
|
// if(!no_changeturf)
|
|
// for(var/t in block(locate(bounds[MAP_MINX], bounds[MAP_MINY], bounds[MAP_MINZ]), locate(bounds[MAP_MAXX], bounds[MAP_MAXY], bounds[MAP_MAXZ])))
|
|
// var/turf/T = t
|
|
// //we do this after we load everything in. if we don't; we'll have weird atmos bugs regarding atmos adjacent turfs
|
|
// T.post_change()
|
|
return bounds
|
|
|
|
/**
|
|
* Fill a given tile with its area/turf/objects/mobs
|
|
* Variable model is one full map line (e.g /turf/unsimulated/wall{icon_state = "rock"}, /area/mine/explored)
|
|
*
|
|
* WORKING :
|
|
*
|
|
* 1) Read the model string, member by member (delimiter is ',')
|
|
*
|
|
* 2) Get the path of the atom and store it into a list
|
|
*
|
|
* 3) a) Check if the member has variables (text within '{' and '}')
|
|
*
|
|
* 3) b) Construct an associative list with found variables, if any (the atom index in members is the same as its variables in members_attributes)
|
|
*
|
|
* 4) Instanciates the atom with its variables
|
|
*
|
|
*/
|
|
/dmm_suite/proc/parse_grid(model as text, model_key as text, xcrd as num,ycrd as num,zcrd as num, no_changeturf as num, orientation as num)
|
|
/*Method parse_grid()
|
|
- Accepts a text string containing a comma separated list of type paths of the
|
|
same construction as those contained in a .dmm file, and instantiates them.
|
|
*/
|
|
|
|
var/list/members //will contain all members (paths) in model (in our example : /turf/unsimulated/wall and /area/mine/explored)
|
|
var/list/members_attributes //will contain lists filled with corresponding variables, if any (in our example : list(icon_state = "rock") and list())
|
|
var/list/cached = modelCache[model]
|
|
var/index
|
|
|
|
if(cached)
|
|
members = cached[1]
|
|
members_attributes = cached[2]
|
|
else
|
|
|
|
/////////////////////////////////////////////////////////
|
|
//Constructing members and corresponding variables lists
|
|
////////////////////////////////////////////////////////
|
|
|
|
members = list()
|
|
members_attributes = list()
|
|
index = 1
|
|
|
|
var/old_position = 1
|
|
var/dpos
|
|
|
|
do
|
|
//finding next member (e.g /turf/unsimulated/wall{icon_state = "rock"} or /area/mine/explored)
|
|
dpos = find_next_delimiter_position(model, old_position, ",", "{", "}") //find next delimiter (comma here) that's not within {...}
|
|
|
|
var/full_def = trim_text(copytext(model, old_position, dpos)) //full definition, e.g : /obj/foo/bar{variables=derp}
|
|
var/variables_start = findtext(full_def, "{")
|
|
var/atom_def = text2path(trim_text(copytext(full_def, 1, variables_start))) //path definition, e.g /obj/foo/bar
|
|
old_position = dpos + 1
|
|
|
|
if(!atom_def) // Skip the item if the path does not exist. Fix your crap, mappers!
|
|
continue
|
|
members.Add(atom_def)
|
|
|
|
//transform the variables in text format into a list (e.g {var1="derp"; var2; var3=7} => list(var1="derp", var2, var3=7))
|
|
var/list/fields = list()
|
|
|
|
if(variables_start)//if there's any variable
|
|
full_def = copytext(full_def,variables_start+1,length(full_def))//removing the last '}'
|
|
fields = readlist(full_def, ";", TRUE)
|
|
if(fields.len)
|
|
if(!trim(fields[fields.len]))
|
|
--fields.len
|
|
for(var/I in fields)
|
|
var/value = fields[I]
|
|
if(istext(value))
|
|
fields[I] = apply_text_macros(value)
|
|
|
|
// Rotate dir if orientation isn't south (default)
|
|
if(fields["dir"])
|
|
fields["dir"] = turn(fields["dir"], dir2angle(orientation) + 180)
|
|
else
|
|
fields["dir"] = turn(SOUTH, dir2angle(orientation) + 180)
|
|
|
|
//then fill the members_attributes list with the corresponding variables
|
|
members_attributes.len++
|
|
members_attributes[index++] = fields
|
|
|
|
CHECK_TICK
|
|
while(dpos != 0)
|
|
|
|
//check and see if we can just skip this turf
|
|
//So you don't have to understand this horrid statement, we can do this if
|
|
// 1. no_changeturf is set
|
|
// 2. the space_key isn't set yet
|
|
// 3. there are exactly 2 members
|
|
// 4. with no attributes
|
|
// 5. and the members are world.turf and world.area
|
|
// Basically, if we find an entry like this: "XXX" = (/turf/default, /area/default)
|
|
// We can skip calling this proc every time we see XXX
|
|
if(no_changeturf && !space_key && members.len == 2 && members_attributes.len == 2 && length(members_attributes[1]) == 0 && length(members_attributes[2]) == 0 && (world.area in members) && (world.turf in members))
|
|
space_key = model_key
|
|
return
|
|
|
|
|
|
modelCache[model] = list(members, members_attributes)
|
|
|
|
|
|
////////////////
|
|
//Instanciation
|
|
////////////////
|
|
|
|
//The next part of the code assumes there's ALWAYS an /area AND a /turf on a given tile
|
|
var/turf/crds = locate(xcrd,ycrd,zcrd)
|
|
|
|
//first instance the /area and remove it from the members list
|
|
index = members.len
|
|
if(members[index] != /area/template_noop)
|
|
var/atom/instance
|
|
_preloader.setup(members_attributes[index])//preloader for assigning set variables on atom creation
|
|
var/atype = members[index]
|
|
for(var/area/A in all_areas)
|
|
if(A.type == atype)
|
|
instance = A
|
|
break
|
|
if(!instance)
|
|
instance = new atype(null)
|
|
if(crds)
|
|
instance.contents.Add(crds)
|
|
|
|
if(use_preloader && instance)
|
|
_preloader.load(instance)
|
|
|
|
//then instance the /turf and, if multiple tiles are presents, simulates the DMM underlays piling effect
|
|
|
|
var/first_turf_index = 1
|
|
while(!ispath(members[first_turf_index], /turf)) //find first /turf object in members
|
|
first_turf_index++
|
|
|
|
//turn off base new Initialization until the whole thing is loaded
|
|
SSatoms.map_loader_begin()
|
|
//instanciate the first /turf
|
|
var/turf/T
|
|
if(members[first_turf_index] != /turf/template_noop)
|
|
T = instance_atom(members[first_turf_index],members_attributes[first_turf_index],crds,no_changeturf)
|
|
|
|
if(T)
|
|
//if others /turf are presents, simulates the underlays piling effect
|
|
index = first_turf_index + 1
|
|
while(index <= members.len - 1) // Last item is an /area
|
|
var/underlay = T.appearance
|
|
T = instance_atom(members[index],members_attributes[index],crds,no_changeturf)//instance new turf
|
|
T.underlays += underlay
|
|
index++
|
|
|
|
//finally instance all remainings objects/mobs
|
|
for(index in 1 to first_turf_index-1)
|
|
instance_atom(members[index],members_attributes[index],crds,no_changeturf)
|
|
//Restore initialization to the previous value
|
|
SSatoms.map_loader_stop()
|
|
|
|
////////////////
|
|
//Helpers procs
|
|
////////////////
|
|
|
|
//Instance an atom at (x,y,z) and gives it the variables in attributes
|
|
/dmm_suite/proc/instance_atom(path,list/attributes, turf/crds, no_changeturf)
|
|
_preloader.setup(attributes, path)
|
|
|
|
if(crds)
|
|
if(!no_changeturf && ispath(path, /turf))
|
|
. = crds.ChangeTurf(path, FALSE, TRUE)
|
|
else
|
|
. = create_atom(path, crds)//first preloader pass
|
|
|
|
if(use_preloader && .)//second preloader pass, for those atoms that don't ..() in New()
|
|
_preloader.load(.)
|
|
|
|
//custom CHECK_TICK here because we don't want things created while we're sleeping to not initialize
|
|
if(TICK_CHECK)
|
|
SSatoms.map_loader_stop()
|
|
stoplag()
|
|
SSatoms.map_loader_begin()
|
|
|
|
/dmm_suite/proc/create_atom(path, crds)
|
|
set waitfor = FALSE
|
|
. = new path (crds)
|
|
|
|
//text trimming (both directions) helper proc
|
|
//optionally removes quotes before and after the text (for variable name)
|
|
/dmm_suite/proc/trim_text(what as text,trim_quotes=0)
|
|
if(trim_quotes)
|
|
return trimQuotesRegex.Replace(what, "")
|
|
else
|
|
return trimRegex.Replace(what, "")
|
|
|
|
|
|
//find the position of the next delimiter,skipping whatever is comprised between opening_escape and closing_escape
|
|
//returns 0 if reached the last delimiter
|
|
/dmm_suite/proc/find_next_delimiter_position(text as text,initial_position as num, delimiter=",",opening_escape="\"",closing_escape="\"")
|
|
var/position = initial_position
|
|
var/next_delimiter = findtext(text,delimiter,position,0)
|
|
var/next_opening = findtext(text,opening_escape,position,0)
|
|
|
|
while((next_opening != 0) && (next_opening < next_delimiter))
|
|
position = findtext(text,closing_escape,next_opening + 1,0)+1
|
|
next_delimiter = findtext(text,delimiter,position,0)
|
|
next_opening = findtext(text,opening_escape,position,0)
|
|
|
|
return next_delimiter
|
|
|
|
|
|
//build a list from variables in text form (e.g {var1="derp"; var2; var3=7} => list(var1="derp", var2, var3=7))
|
|
// text - variables in text form. Not including surrounding {} or list()
|
|
// delimiter - Delimiter between list entries
|
|
// keys_only_string - If true, text that looks like an associative list has its keys treated as var names,
|
|
// otherwise they are parsed as valid associative list keys.
|
|
//return the filled list
|
|
/dmm_suite/proc/readlist(text as text, delimiter=",", keys_only_string = FALSE)
|
|
|
|
var/list/to_return = list()
|
|
if(text == "")
|
|
return to_return // Fast bail-out
|
|
|
|
var/position
|
|
var/old_position = 1
|
|
|
|
do
|
|
//find next delimiter that is not within "..."
|
|
position = find_next_delimiter_position(text,old_position,delimiter)
|
|
|
|
//check if this is a simple variable (as in list(var1, var2)) or an associative one (as in list(var1="foo",var2=7))
|
|
var/equal_position = findtext(text,"=",old_position, position)
|
|
|
|
// part to the left of = (the key/var name), or the entire value. If treating it as a var name, strip quotes at the same time.
|
|
var/trim_left = trim_text(copytext(text,old_position,(equal_position ? equal_position : position)), keys_only_string)
|
|
old_position = position + 1
|
|
|
|
var/trim_right = trim_left
|
|
if(equal_position)//associative var, so do the association
|
|
trim_right = trim_text(copytext(text,equal_position+1,position))//the content of the variable
|
|
if(!keys_only_string) // We also need to evaluate the key for the types it is permitted to be
|
|
if(findtext(trim_left,"\"",1,2)) //Check for string
|
|
trim_left = copytext(trim_left,2,findtext(trim_left,"\"",3,0))
|
|
else if(isnum(text2num(trim_left))) //Check for number
|
|
trim_left = text2num(trim_left)
|
|
else if(ispath(text2path(trim_left))) //Check for path
|
|
trim_left = text2path(trim_left)
|
|
|
|
// Parse the value in trim_right
|
|
//Check for string
|
|
if(findtext(trim_right,"\"",1,2))
|
|
trim_right = copytext(trim_right,2,findtext(trim_right,"\"",3,0))
|
|
//Check for number
|
|
else if(isnum(text2num(trim_right)))
|
|
trim_right = text2num(trim_right)
|
|
//Check for null
|
|
else if(trim_right == "null")
|
|
trim_right = null
|
|
//Check for list
|
|
else if(copytext(trim_right,1,5) == "list")
|
|
trim_right = readlist(copytext(trim_right,6,length(trim_right)))
|
|
//Check for file
|
|
else if(copytext(trim_right,1,2) == "'")
|
|
trim_right = file(copytext(trim_right,2,length(trim_right)))
|
|
//Check for path
|
|
else if(ispath(text2path(trim_right)))
|
|
trim_right = text2path(trim_right)
|
|
|
|
// Now put the trim_right into the result. Method by which we do so varies on if its assoc or not
|
|
if(equal_position)
|
|
to_return[trim_left] = trim_right
|
|
else
|
|
to_return += trim_right
|
|
|
|
while(position != 0)
|
|
|
|
return to_return
|
|
|
|
/dmm_suite/Destroy()
|
|
..()
|
|
return QDEL_HINT_HARDDEL_NOW
|
|
|
|
//////////////////
|
|
//Preloader datum
|
|
//////////////////
|
|
|
|
/dmm_suite/preloader
|
|
parent_type = /datum
|
|
var/list/attributes
|
|
var/target_path
|
|
|
|
/dmm_suite/preloader/proc/setup(list/the_attributes, path)
|
|
if(the_attributes.len)
|
|
use_preloader = TRUE
|
|
attributes = the_attributes
|
|
target_path = path
|
|
|
|
/dmm_suite/preloader/proc/load(atom/what)
|
|
for(var/attribute in attributes)
|
|
var/value = attributes[attribute]
|
|
if(islist(value))
|
|
value = deepCopyList(value)
|
|
what.vars[attribute] = value
|
|
use_preloader = FALSE
|
|
|
|
/area/template_noop
|
|
name = "Area Passthrough"
|
|
|
|
/turf/template_noop
|
|
name = "Turf Passthrough"
|
|
icon_state = "template_void"
|