Skip to content

Commit

Permalink
Mapgen fixes and speedups (ignore the branch name. I'm dumb) (shiptes…
Browse files Browse the repository at this point in the history
…t-ss13#1637)

## About The Pull Request

Alters the structure of map/planet generation to squash some bugs and
improve performance.

Previously, planet maps were generated by placing the ruin first, and
THEN generating the turfs according to the map_generator datum. This has
been adjusted -- now, turfs are generated WITHOUT objects such as
mobs/flora, the ruin is placed, and THEN the objects are added (turfs
are "populated"). In conjunction with the addition of needed
AfterChange() calls to update the atmos adjacency of the generated
turfs, this ensures that planet atmos acts correctly surrounding ruins.

When deleting reservations (such as the deletion of planets after
undocking), all objects on the planet are rounded up in a list and
qdeleted. Although this causes a small lag spike, it SHOULD prevent
items from hanging out inside the edges of planets.

There's a feature to change the default baseturf of a virtual level,
ZTRAIT_BASETURF, that we now use. This should cut down on the instances
where a ruin on a planet is blown up and there's space underneath (might
still happen on asteroids, because the baseturf there is still space; I
didn't want space turfs without space as their baseturf).

Overmap encounter areas aren't global anymore (they no longer have the
flag UNIQUE_AREA). Don't fucking add the flag UNIQUE_AREA to anything
that should have weather in it, because if that area gets added anywhere
else that _actually respects the flag_ you'll end up with cross-planet
weather, because weather code sucks. This didn't cause bugs before,
because the flag wasn't respected; it will now.

The biome assoc list has been moved into the map generator datum, and
all encounters now generate using a map generator that either uses a
biome or replaces everything with a single turf. This prevents
duplication of cave generation code and makes dynamic overmap object
code slightly easier to understand.

Some systems have been altered to improve performance; many of these
changes are rather small, like the changes to turf population (mob
placement now uses a stack of recently-created mobs to check if there
are any nearby, instead of checking everything within 12 turfs; I've yet
to add ruin mobs to these stacks to avoid placing mobs near ruin mobs)
or lighting objects (removed a single line that changed the color of the
lighting object on init).

Starlight has been altered, so that small turf changes near space turfs
don't need to check as many nearby turfs and so that large turf changes
can be batched to prevent further recalculation. This is probably
responsible for the biggest performance increase.

Smoothing groups are cached before sorting instead of after, to prevent
sort calls on many atom inits; /tg/station uses a unit test to avoid
needing to sort at runtime ever, but I couldn't figure out how to do
that without larger changes or writing a unit test that attempted to
instance every atom once, which would be an undertaking of its own.

Gas strings have been similarly altered, and now their interpretation
defaults to copying from a cached, immutable version of the mix encoded
by the string. This avoids the significant overhead caused by repeated
calls to params2list(). Auxmos has a better solution to this,
__auxtools_parse_gas_string(), but our current custom build of Auxmos
doesn't support it.

There are a few other small changes that I'm probably forgetting about
and you should yell at me to read my own fucking code and tell you what
else I changed.

- [ ] I affirm that I have tested all of my proposed changes and that
any issues found during tested have been addressed.

I still need to manually check each planet type to make sure they aren't
fucked up, I should probably do some proper profiling comparisons.

## Why It's Good For The Game

Fewer weird bugs, things generate faster, better* code.

## Changelog

:cl:
fix: Ruins don't sometimes start in hard vacuum anymore; planet turfs
now share atmos correctly.
fix: There hopefully shouldn't be any random stray objects sitting in
the edges of planets anymore.
fix: Planets now (hopefully) have the correct baseturfs (more or less).
When you bomb a ruin on a planet, it probably won't break through to
space anymore.
refactor: Planet generation has been refactored, improving performance
somewhat.
/:cl:
  • Loading branch information
tmtmtl30 authored Feb 24, 2023
1 parent f117e6e commit d21740b
Show file tree
Hide file tree
Showing 64 changed files with 1,448 additions and 1,354 deletions.
24 changes: 12 additions & 12 deletions code/__DEFINES/flags.dm
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,6 @@ GLOBAL_LIST_INIT(bitflags, list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 204
/// Should we use the initial icon for display? Mostly used by overlay only objects
#define HTML_USE_INITAL_ICON_1 (1<<20)

/// If the thing can reflect light (lasers/energy)
#define RICOCHET_SHINY (1<<0)
/// If the thing can reflect matter (bullets/bomb shrapnel)
#define RICOCHET_HARD (1<<1)

//turf-only flags
#define NOJAUNT_1 (1<<0)
/// If a turf can be made dirty at roundstart. This is also used in areas.
Expand All @@ -60,6 +55,13 @@ GLOBAL_LIST_INIT(bitflags, list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 204
/// Blocks ruins spawning on the turf
#define NO_RUINS_1 (1<<4)


//ricochet flags
/// If the thing can reflect light (lasers/energy)
#define RICOCHET_SHINY (1<<0)
/// If the thing can reflect matter (bullets/bomb shrapnel)
#define RICOCHET_HARD (1<<1)

////////////////Area flags\\\\\\\\\\\\\\
/// If it's a valid territory for cult summoning or the CRAB-17 phone to spawn
#define VALID_TERRITORY (1<<0)
Expand All @@ -71,18 +73,16 @@ GLOBAL_LIST_INIT(bitflags, list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 204
#define FLORA_ALLOWED (1<<3)
/// If mobs can be spawned by natural random generation
#define MOB_SPAWN_ALLOWED (1<<4)
/// If megafauna can be spawned by natural random generation
#define MEGAFAUNA_SPAWN_ALLOWED (1<<5)
/// Are you forbidden from teleporting to the area? (centcom, mobs, wizard, hand teleporter)
#define NOTELEPORT (1<<6)
#define NOTELEPORT (1<<5)
/// Hides area from player Teleport function.
#define HIDDEN_AREA (1<<7)
#define HIDDEN_AREA (1<<6)
/// If false, loading multiple maps with this area type will create multiple instances.
#define UNIQUE_AREA (1<<8)
#define UNIQUE_AREA (1<<7)
/// If people are allowed to suicide in it. Mostly for OOC stuff like minigames
#define BLOCK_SUICIDE (1<<9)
#define BLOCK_SUICIDE (1<<8)
/// Can the Xenobio management console transverse this area by default?
#define XENOBIOLOGY_COMPATIBLE (1<<10)
#define XENOBIOLOGY_COMPATIBLE (1<<9)

/*
These defines are used specifically with the atom/pass_flags bitmask
Expand Down
3 changes: 0 additions & 3 deletions code/__DEFINES/mobs.dm
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,6 @@
///How much a mob's sprite should be moved when they're lying down
#define PIXEL_Y_OFFSET_LYING -6

///Define for spawning megafauna instead of a mob for cave gen
#define SPAWN_MEGAFAUNA "bluh bluh huge boss"

/// Breathing types. Lungs can access either by these or by a string, which will be considered a gas ID.
#define BREATH_OXY /datum/breathing_class/oxygen
#define BREATH_PLASMA /datum/breathing_class/plasma
Expand Down
18 changes: 12 additions & 6 deletions code/__DEFINES/turfs.dm
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
#define CHANGETURF_DEFER_CHANGE 1
#define CHANGETURF_IGNORE_AIR 2 // This flag prevents changeturf from gathering air from nearby turfs to fill the new turf with an approximation of local air
#define CHANGETURF_FORCEOP 4
#define CHANGETURF_SKIP 8 // A flag for PlaceOnTop to just instance the new turf instead of calling ChangeTurf. Used for uninitialized turfs NOTHING ELSE
#define CHANGETURF_INHERIT_AIR 16 // Inherit air from previous turf. Implies CHANGETURF_IGNORE_AIR
#define CHANGETURF_RECALC_ADJACENT 32 //Recalc adjacent or not
/// Defers call of proc AfterChange in ChangeTurf.
#define CHANGETURF_DEFER_CHANGE (1 << 0)
/// This flag prevents changeturf from gathering air from nearby turfs to fill the new turf with an approximation of local air
#define CHANGETURF_IGNORE_AIR (1 << 1)
/// Prevents ChangeTurf from returning without an operation if the given path and baseturfs are identical to the pre-existing ones and the preloader is not engaged.
#define CHANGETURF_FORCEOP (1 << 2)
/// A flag for PlaceOnTop to just instance the new turf instead of calling ChangeTurf. Used for uninitialized turfs NOTHING ELSE
#define CHANGETURF_SKIP (1 << 3)
/// Inherit air from previous turf. Implies CHANGETURF_IGNORE_AIR
#define CHANGETURF_INHERIT_AIR (1 << 4)
/// Defers smoothing and starlight recalculation in ChangeTurf so that they may later be more performantly done in bulk.
#define CHANGETURF_DEFER_BATCH (1 << 5)

#define IS_OPAQUE_TURF(turf) (turf.directional_opacity == ALL_CARDINALS)
22 changes: 18 additions & 4 deletions code/__HELPERS/bitflag_lists.dm
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
GLOBAL_LIST_EMPTY(bitflag_lists)


// This version of SET_BITFLAG_LIST has been modified to further cache bitflag lists
// to minimize sorting, as sorting is very expensive when done for all smoothable atoms.
// A unit test, as /tg/ has, would be a better solution, but as of writing this I do not have the time
// or energy to do this. Somebody help, please. I'm begging.

/**
* System for storing bitflags past the 24 limit, making use of an associative list.
*
Expand All @@ -15,11 +21,19 @@ GLOBAL_LIST_EMPTY(bitflag_lists)
do { \
var/txt_signature = target.Join("-"); \
if(!GLOB.bitflag_lists[txt_signature]) { \
var/list/new_bitflag_list = list(); \
for(var/value in target) { \
new_bitflag_list["[round(value / 24)]"] |= (1 << (value % 24)); \
sortTim(target); \
var/new_txt_signature = target.Join("-"); \
if(GLOB.bitflag_lists[new_txt_signature]) { \
GLOB.bitflag_lists[txt_signature] = GLOB.bitflag_lists[new_txt_signature]; \
}; \
else { \
var/list/new_bitflag_list = list(); \
for(var/value in target) { \
new_bitflag_list["[round(value / 24)]"] |= (1 << (value % 24)); \
}; \
GLOB.bitflag_lists[new_txt_signature] = new_bitflag_list; \
GLOB.bitflag_lists[txt_signature] = new_bitflag_list; \
}; \
GLOB.bitflag_lists[txt_signature] = new_bitflag_list; \
}; \
target = GLOB.bitflag_lists[txt_signature]; \
} while (FALSE)
2 changes: 1 addition & 1 deletion code/__HELPERS/game.dm
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ block( \
listclearnulls(.)

/proc/get_open_turf_in_dir(atom/center, dir)
var/turf/open/T = get_ranged_target_turf(center, dir, 1)
var/turf/open/T = get_step(center, dir)
if(istype(T))
return T

Expand Down
1 change: 0 additions & 1 deletion code/_globalvars/bitfields.dm
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ DEFINE_BITFIELD(area_flags, list(
"CAVES_ALLOWED" = CAVES_ALLOWED,
"FLORA_ALLOWED" = FLORA_ALLOWED,
"MOB_SPAWN_ALLOWED" = MOB_SPAWN_ALLOWED,
"MEGAFAUNA_SPAWN_ALLOWED" = MEGAFAUNA_SPAWN_ALLOWED,
"NOTELEPORT" = NOTELEPORT,
"HIDDEN_AREA" = HIDDEN_AREA,
"UNIQUE_AREA" = UNIQUE_AREA,
Expand Down
3 changes: 0 additions & 3 deletions code/_globalvars/lists/mapping.dm
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,3 @@ GLOBAL_LIST_EMPTY(sortedAreas)
GLOBAL_LIST_EMPTY_TYPED(areas_by_type, /area)

GLOBAL_LIST_EMPTY(all_abstract_markers)

/// Global list of megafauna spawns on cave gen
GLOBAL_LIST_INIT(megafauna_spawn_list, list(/mob/living/simple_animal/hostile/megafauna/dragon = 4, /mob/living/simple_animal/hostile/megafauna/colossus = 2, /mob/living/simple_animal/hostile/megafauna/bubblegum = 6))
59 changes: 48 additions & 11 deletions code/controllers/subsystem/air.dm
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ SUBSYSTEM_DEF(air)

//atmos singletons
var/list/gas_reactions = list()
var/list/atmos_gen
var/list/string_mixes

//Special functions lists
var/list/turf/open/high_pressure_delta = list()
Expand Down Expand Up @@ -612,19 +612,56 @@ SUBSYSTEM_DEF(air)

return pipe_init_dirs_cache[type]["[dir]"]

// Once we've got __auxtools_parse_gas_string, replace this with the original preprocess_gas_string (commented out, below);
// there's no need to cache because auxtools can parse strings in an actually reasonable amount of time.
// Atmosphere datums will also require some changes to put them back how they were before. I am sorry
/datum/controller/subsystem/air/proc/get_gas_string_mix(gas_string)
if(!string_mixes)
generate_atmos()

var/datum/gas_mixture/ret_mix = string_mixes[gas_string]
if(ret_mix)
return ret_mix

ret_mix = new(CELL_VOLUME)
var/list/gas_list = params2list(gas_string)

if(gas_list["TEMP"])
var/temp = text2num(gas_list["TEMP"])
gas_list -= "TEMP"
if(!isnum(temp) || temp < TCMB)
temp = TCMB
ret_mix.set_temperature(temp)
ret_mix.clear()
for(var/id in gas_list)
ret_mix.set_moles(id, text2num(gas_list[id]))
ret_mix.mark_immutable()

string_mixes[gas_string] = ret_mix
return ret_mix

/datum/controller/subsystem/air/proc/generate_atmos()
atmos_gen = list()
string_mixes = list()
for(var/T in subtypesof(/datum/atmosphere))
var/datum/atmosphere/atmostype = T
atmos_gen[initial(atmostype.id)] = new atmostype
var/datum/atmosphere/atmostype = new T
string_mixes[initial(atmostype.id)] = atmostype.gasmix
qdel(atmostype)

// Saved for when we switch to auxmos 2.0 and gain access to __auxtools_parse_gas_string
// /datum/controller/subsystem/air/proc/preprocess_gas_string(gas_string)
// if(!string_mixes)
// generate_atmos()
// if(!string_mixes[gas_string])
// return gas_string
// var/datum/atmosphere/mix = string_mixes[gas_string]
// return mix.gas_string

// /datum/controller/subsystem/air/proc/generate_atmos()
// string_mixes = list()
// for(var/T in subtypesof(/datum/atmosphere))
// var/datum/atmosphere/atmostype = T
// string_mixes[initial(atmostype.id)] = new atmostype

/datum/controller/subsystem/air/proc/preprocess_gas_string(gas_string)
if(!atmos_gen)
generate_atmos()
if(!atmos_gen[gas_string])
return gas_string
var/datum/atmosphere/mix = atmos_gen[gas_string]
return mix.gas_string

#undef SSAIR_EXCITEDGROUPS
#undef SSAIR_HIGHPRESSURE
Expand Down
92 changes: 42 additions & 50 deletions code/controllers/subsystem/overmap.dm
Original file line number Diff line number Diff line change
Expand Up @@ -212,67 +212,59 @@ SUBSYSTEM_DEF(overmap)
new /datum/overmap/dynamic()

/**
* Reserves a square dynamic encounter area, and spawns a ruin in it if one is supplied.
* Reserves a square dynamic encounter area, generates it, and spawns a ruin in it if one is supplied.
* * on_planet - If the encounter should be on a generated planet. Required, as it will be otherwise inaccessible.
* * target - The ruin to spawn, if any
* * ruin_type - The ruin to spawn. Don't pass this argument if you want it to randomly select based on planet type.
* * ruin_type - The type of ruin to spawn, or null if none should be placed.
*/
/datum/controller/subsystem/overmap/proc/spawn_dynamic_encounter(datum/overmap/dynamic/dynamic_datum, ruin = TRUE, ignore_cooldown = FALSE, datum/map_template/ruin/ruin_type)
/datum/controller/subsystem/overmap/proc/spawn_dynamic_encounter(datum/overmap/dynamic/dynamic_datum, ruin_type)
log_shuttle("SSOVERMAP: SPAWNING DYNAMIC ENCOUNTER STARTED")
var/list/ruin_list = dynamic_datum.ruin_list
var/datum/map_generator/mapgen
var/area/target_area = dynamic_datum.target_area
var/turf/surface = dynamic_datum.surface
var/datum/weather_controller/weather_controller_type = dynamic_datum.weather_controller_type
///A planet template that contains a list of biomes to use
var/datum/planet/planet_template = dynamic_datum.planet_template

if(!dynamic_datum)
CRASH("spawn_dynamic_encounter called without any datum to spawn!")
if(!dynamic_datum.default_baseturf)
CRASH("spawn_dynamic_encounter called with overmap datum [REF(dynamic_datum)], which lacks a default_baseturf!")

if(ruin && ruin_list && !ruin_type)
ruin_type = ruin_list[pick(ruin_list)]
if(ispath(ruin_type))
ruin_type = new ruin_type

var/height = dynamic_datum.vlevel_height
var/width = dynamic_datum.vlevel_width
var/datum/map_generator/mapgen = new dynamic_datum.mapgen
var/datum/map_template/ruin/used_ruin = ispath(ruin_type) ? (new ruin_type) : ruin_type

var/encounter_name = "Dynamic Overmap Encounter"
if(dynamic_datum.planet_name)
encounter_name = dynamic_datum.planet_name
// name is random but PROBABLY unique
var/encounter_name = dynamic_datum.planet_name || "Dynamic Overmap Encounter #[rand(1111,9999)]-[rand(1111,9999)]"
var/datum/map_zone/mapzone = SSmapping.create_map_zone(encounter_name)
var/datum/virtual_level/vlevel = SSmapping.create_virtual_level(encounter_name, list(ZTRAIT_MINING = TRUE), mapzone, width, height, ALLOCATION_QUADRANT, QUADRANT_MAP_SIZE)
var/datum/virtual_level/vlevel = SSmapping.create_virtual_level(
encounter_name,
list(ZTRAIT_MINING = TRUE, ZTRAIT_BASETURF = dynamic_datum.default_baseturf),
mapzone,
dynamic_datum.vlevel_width,
dynamic_datum.vlevel_height,
ALLOCATION_QUADRANT,
QUADRANT_MAP_SIZE
)

vlevel.reserve_margin(QUADRANT_SIZE_BORDER)

if(dynamic_datum.mapgen) /// If we have a map generator, don't ChangeTurf's in fill_in. Just to ChangeTurf them once again.
mapgen = new dynamic_datum.mapgen
surface = null
vlevel.fill_in(surface, target_area)
// the generataed turfs start unpopulated (i.e. no flora / fauna / etc.). we add that AFTER placing the ruin, relying on the ruin's areas to determine what gets populated
log_shuttle("SSOVERMAP: START_DYN_E: RUNNING MAPGEN REF [REF(mapgen)] FOR VLEV [vlevel.id] OF TYPE [mapgen.type]")
mapgen.generate_turfs(vlevel.get_unreserved_block())

var/list/ruin_turfs = list()
if(ruin_type)
var/turf/ruin_turf = locate(rand(
vlevel.low_x+6 + vlevel.reserved_margin,
vlevel.high_x-ruin_type.width-6 - vlevel.reserved_margin),
vlevel.high_y-ruin_type.height-6 - vlevel.reserved_margin,
if(used_ruin)
var/turf/ruin_turf = locate(
rand(
vlevel.low_x+6 + vlevel.reserved_margin,
vlevel.high_x-used_ruin.width-6 - vlevel.reserved_margin
),
vlevel.high_y-used_ruin.height-6 - vlevel.reserved_margin,
vlevel.z_value
)
ruin_type.load(ruin_turf)
ruin_turfs[ruin_type.name] = ruin_turf

if(mapgen) //If what is going on is what I think it is, this is going to need to return some sort of promise to await.
log_shuttle("SSOVERMAP: START_DYN_E: RUNNING MAPGEN REF [REF(mapgen)] FOR VLEV [vlevel.id] OF TYPE [mapgen.type]")
if (istype(mapgen, /datum/map_generator/planet_generator) && !isnull(planet_template))
planet_template = new planet_template
mapgen.generate_terrain(vlevel.get_unreserved_block(), planet_template)
else
mapgen.generate_terrain(vlevel.get_unreserved_block())
log_shuttle("SSOVERMAP: START_DYN_E: MAPGEN REF [REF(mapgen)] RETURNED FOR VLEV [vlevel.id] OF TYPE [mapgen.type]. IT MAY NOT BE FINISHED YET.")

if(weather_controller_type)
new weather_controller_type(mapzone)
)
used_ruin.load(ruin_turf)
ruin_turfs[used_ruin.name] = ruin_turf

// fill in the turfs, AFTER generating the ruin. this prevents them from generating within the ruin
// and ALSO prevents the ruin from being spaced when it spawns in
// WITHOUT needing to fill the reservation with a bunch of dummy turfs
mapgen.populate_turfs(vlevel.get_unreserved_block())

if(dynamic_datum.weather_controller_type)
new dynamic_datum.weather_controller_type(mapzone)

// locates the first dock in the bottom left, accounting for padding and the border
var/turf/primary_docking_turf = locate(
Expand Down Expand Up @@ -307,19 +299,19 @@ SUBSYSTEM_DEF(overmap)
secondary_dock.dwidth = 0
docking_ports += secondary_dock

if(!ruin_type)
if(!used_ruin)
// no ruin, so we can make more docks upward
var/turf/tertiary_docking_turf = locate(
primary_docking_turf.x,
primary_docking_turf.y+RESERVE_DOCK_MAX_SIZE_SHORT+RESERVE_DOCK_DEFAULT_PADDING,
primary_docking_turf.z
)
)
// rinse and repeat
var/turf/quaternary_docking_turf = locate(
secondary_docking_turf.x,
secondary_docking_turf.y+RESERVE_DOCK_MAX_SIZE_SHORT+RESERVE_DOCK_DEFAULT_PADDING,
secondary_docking_turf.z
)
)

var/obj/docking_port/stationary/tertiary_dock = new(tertiary_docking_turf)
tertiary_dock.dir = NORTH
Expand Down
19 changes: 7 additions & 12 deletions code/datums/atmosphere/_atmosphere.dm
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/datum/atmosphere
var/gas_string
var/id
/// The atmosphere's gasmix. No longer a gas string, because params2list sucks.
/// When auxmos is updated, change this back to a string and use __auxtools_parse_gas_string.
var/datum/gas_mixture/gasmix

var/list/base_gases // A list of gases to always have
var/list/normal_gases // A list of allowed gases:base_amount
Expand All @@ -14,15 +16,14 @@
var/maximum_temp

/datum/atmosphere/New()
generate_gas_string()
generate_gas()

/datum/atmosphere/proc/generate_gas_string()
/datum/atmosphere/proc/generate_gas()
var/target_pressure = rand(minimum_pressure, maximum_pressure)
var/pressure_scalar = target_pressure / maximum_pressure

// First let's set up the gasmix and base gases for this template
// We make the string from a gasmix in this proc because gases need to calculate their pressure
var/datum/gas_mixture/gasmix = new
gasmix = new(CELL_VOLUME)
gasmix.set_temperature(rand(minimum_temp, maximum_temp))
for(var/i in base_gases)
gasmix.set_moles(i, base_gases[i])
Expand Down Expand Up @@ -50,10 +51,4 @@
while(gasmix.return_pressure() > target_pressure)
gasmix.set_moles(gastype, gasmix.get_moles(gastype) - (gasmix.get_moles(gastype) * 0.1))
gasmix.set_moles(gastype, FLOOR(gasmix.get_moles(gastype), 0.1))

// Now finally lets make that string
var/list/gas_string_builder = list()
for(var/i in gasmix.get_gases())
gas_string_builder += "[GLOB.gas_data.ids[i]]=[gasmix.get_moles(i)]"
gas_string_builder += "TEMP=[gasmix.return_temperature()]"
gas_string = gas_string_builder.Join(";")
gasmix.mark_immutable()
Loading

0 comments on commit d21740b

Please sign in to comment.