From 8a7e6d059fb62c577e4182ebff1728cece7056d4 Mon Sep 17 00:00:00 2001 From: itsmeow Date: Wed, 30 Jul 2025 23:51:23 -0500 Subject: [PATCH] Bumps rust_g to `4.0.0` / IconForge Improvements (#92333) --- code/__DEFINES/rust_g.dm | 113 ++++++- code/__HELPERS/icons.dm | 80 ++++- code/__HELPERS/turfs.dm | 2 +- code/_globalvars/lists/icons.dm | 4 + .../subsystem/processing/greyscale.dm | 1 - .../batched/batched_spritesheet.dm | 4 +- .../spritesheet/batched/universal_icon.dm | 304 ++++++++++++++++-- .../preferences/species_features/basic.dm | 2 +- dependencies.sh | 2 +- icons/map_icons/clothing/accessory.dmi | Bin 1016 -> 1016 bytes icons/map_icons/clothing/head/_head.dmi | Bin 17368 -> 3971 bytes icons/map_icons/clothing/head/beret.dmi | Bin 4331 -> 2309 bytes icons/map_icons/clothing/mask.dmi | Bin 4937 -> 9082 bytes icons/map_icons/clothing/neck.dmi | Bin 19719 -> 19149 bytes icons/map_icons/clothing/shoes.dmi | Bin 15469 -> 14646 bytes icons/map_icons/clothing/suit/_suit.dmi | Bin 26769 -> 10872 bytes icons/map_icons/clothing/suit/costume.dmi | Bin 2368 -> 2368 bytes icons/map_icons/clothing/under/_under.dmi | Bin 18868 -> 17773 bytes icons/map_icons/clothing/under/color.dmi | Bin 3489 -> 3489 bytes icons/map_icons/clothing/under/costume.dmi | Bin 9272 -> 8849 bytes icons/map_icons/clothing/under/dress.dmi | Bin 3543 -> 6603 bytes icons/map_icons/items/_item.dmi | Bin 27202 -> 27357 bytes icons/map_icons/items/encryptionkey.dmi | Bin 2771 -> 5216 bytes icons/map_icons/items/pda.dmi | Bin 12151 -> 9470 bytes icons/map_icons/objects.dmi | Bin 40328 -> 39837 bytes rust_g.dll | Bin 9090048 -> 9787904 bytes 26 files changed, 446 insertions(+), 66 deletions(-) diff --git a/code/__DEFINES/rust_g.dm b/code/__DEFINES/rust_g.dm index d2928195a50..048ea0bd0b7 100644 --- a/code/__DEFINES/rust_g.dm +++ b/code/__DEFINES/rust_g.dm @@ -138,6 +138,36 @@ */ #define rustg_dmi_icon_states(fname) RUSTG_CALL(RUST_G, "dmi_icon_states")(fname) +/** + * The below functions involve dmi metadata represented in the following format: + * list( + * "width": number, + * "height": number, + * "states": list([STATE_DATA], ...) + * ) + * + * STATE_DATA format: + * list( + * "name": string, + * "dirs": 1 | 4 | 8, + * "delays"?: list(number, ...), + * "rewind"?: TRUE | FALSE, + * "movement"?: TRUE | FALSE, + * "loop"?: number + * ) + */ + +/** + * Get the dmi metadata of the file located at `fname`. + * Returns a list in the metadata format listed above, or an error message. + */ +#define rustg_dmi_read_metadata(fname) json_decode(RUSTG_CALL(RUST_G, "dmi_read_metadata")(fname)) +/** + * Inject dmi metadata into a png file located at `path`. + * `metadata` must be a json_encode'd list in the metadata format listed above. + */ +#define rustg_dmi_inject_metadata(path, metadata) RUSTG_CALL(RUST_G, "dmi_inject_metadata")(path, metadata) + #define rustg_file_read(fname) RUSTG_CALL(RUST_G, "file_read")(fname) #define rustg_file_exists(fname) (RUSTG_CALL(RUST_G, "file_exists")(fname) == "true") #define rustg_file_write(text, fname) RUSTG_CALL(RUST_G, "file_write")(text, fname) @@ -198,13 +228,21 @@ #define rustg_http_request_blocking(method, url, body, headers, options) RUSTG_CALL(RUST_G, "http_request_blocking")(method, url, body, headers, options) #define rustg_http_request_async(method, url, body, headers, options) RUSTG_CALL(RUST_G, "http_request_async")(method, url, body, headers, options) #define rustg_http_check_request(req_id) RUSTG_CALL(RUST_G, "http_check_request")(req_id) +/// This is basically just `rustg_http_request_async` if you don't care about the response. +/// This will either return "ok" or an error, as this does not create a job. +#define rustg_http_request_fire_and_forget(method, url, body, headers, options) RUSTG_CALL(RUST_G, "http_request_fire_and_forget")(method, url, body, headers, options) -/// Generates a spritesheet at: [file_path][spritesheet_name]_[size_id].png +/// Generates a spritesheet at: [file_path][spritesheet_name]_[size_id].[png or dmi] /// The resulting spritesheet arranges icons in a random order, with the position being denoted in the "sprites" return value. /// All icons have the same y coordinate, and their x coordinate is equal to `icon_width * position`. /// /// hash_icons is a boolean (0 or 1), and determines if the generator will spend time creating hashes for the output field dmi_hashes. -/// These hashes can be heplful for 'smart' caching (see rustg_iconforge_cache_valid), but require extra computation. +/// These hashes can be helpful for 'smart' caching (see rustg_iconforge_cache_valid), but require extra computation. +/// +/// generate_dmi is a boolean (0 or 1), and determines if the generator will save the sheet as a DMI or stripped PNG file. +/// DMI files can be used to replace bulk Insert() operations, PNGs are more useful for asset transport or UIs. DMI generation is slower due to more metadata. +/// flatten is a boolean (0 or 1), and determines if the DMI output will be flattened to a single frame/dir if unscoped (null/0 dir or frame values). +/// PNGs are always flattened, regardless of argument. /// /// Spritesheet will contain all sprites listed within "sprites". /// "sprites" format: @@ -220,9 +258,15 @@ /// ) /// TRANSFORM_OBJECT format: /// list("type" = RUSTG_ICONFORGE_BLEND_COLOR, "color" = "#ff0000", "blend_mode" = ICON_MULTIPLY) -/// list("type" = RUSTG_ICONFORGE_BLEND_ICON, "icon" = [SPRITE_OBJECT], "blend_mode" = ICON_OVERLAY) +/// list("type" = RUSTG_ICONFORGE_BLEND_ICON, "icon" = [SPRITE_OBJECT], "blend_mode" = ICON_OVERLAY, "x" = 1, "y" = 1) // offsets optional /// list("type" = RUSTG_ICONFORGE_SCALE, "width" = 32, "height" = 32) /// list("type" = RUSTG_ICONFORGE_CROP, "x1" = 1, "y1" = 1, "x2" = 32, "y2" = 32) // (BYOND icons index from 1,1 to the upper bound, inclusive) +/// list("type" = RUSTG_ICONFORGE_MAP_COLORS, "rr" = 0.5, "rg" = 0.5, "rb" = 0.5, "ra" = 1, "gr" = 1, "gg" = 1, "gb" = 1, "ga" = 1, ...) // alpha arguments and rgba0 optional +/// list("type" = RUSTG_ICONFORGE_FLIP, "dir" = SOUTH) +/// list("type" = RUSTG_ICONFORGE_TURN, "angle" = 90.0) +/// list("type" = RUSTG_ICONFORGE_SHIFT, "dir" = EAST, "offset" = 10, "wrap" = FALSE) +/// list("type" = RUSTG_ICONFORGE_SWAP_COLOR, "src_color" = "#ff0000", "dst_color" = "#00ff00") // alpha bits supported +/// list("type" = RUSTG_ICONFORGE_DRAW_BOX, "color" = "#ff0000", "x1" = 1, "y1" = 1, "x2" = 32, "y2" = 32) // alpha bits supported. color can be null/omitted for transparency. x2 and y2 will default to x1 and y1 if omitted /// /// Returns a SpritesheetResult as JSON, containing fields: /// list( @@ -233,9 +277,9 @@ /// "error" = "[A string, empty if there were no errors.]" /// ) /// In the case of an unrecoverable panic from within Rust, this function ONLY returns a string containing the error. -#define rustg_iconforge_generate(file_path, spritesheet_name, sprites, hash_icons) RUSTG_CALL(RUST_G, "iconforge_generate")(file_path, spritesheet_name, sprites, "[hash_icons]") +#define rustg_iconforge_generate(file_path, spritesheet_name, sprites, hash_icons, generate_dmi, flatten) RUSTG_CALL(RUST_G, "iconforge_generate")(file_path, spritesheet_name, sprites, "[hash_icons]", "[generate_dmi]", "[flatten]") /// Returns a job_id for use with rustg_iconforge_check() -#define rustg_iconforge_generate_async(file_path, spritesheet_name, sprites, hash_icons) RUSTG_CALL(RUST_G, "iconforge_generate_async")(file_path, spritesheet_name, sprites, "[hash_icons]") +#define rustg_iconforge_generate_async(file_path, spritesheet_name, sprites, hash_icons, generate_dmi, flatten) RUSTG_CALL(RUST_G, "iconforge_generate_async")(file_path, spritesheet_name, sprites, "[hash_icons]", "[generate_dmi]", "[flatten]") /// Returns the status of an async job_id, or its result if it is completed. See RUSTG_JOB DEFINEs. #define rustg_iconforge_check(job_id) RUSTG_CALL(RUST_G, "iconforge_check")("[job_id]") /// Clears all cached DMIs and images, freeing up memory. @@ -256,7 +300,7 @@ /// Provided a /datum/greyscale_config typepath, JSON string containing the greyscale config, and path to a DMI file containing the base icons, /// Loads that config into memory for later use by rustg_iconforge_gags(). The config_path is the unique identifier used later. /// JSON Config schema: https://hackmd.io/@tgstation/GAGS-Layer-Types -/// Unsupported features: color_matrix layer type, 'or' blend_mode. May not have BYOND parity with animated icons or varying dirs between layers. +/// Adding dirs or frames (via blending larger icons) to icons with more than 1 dir or 1 frame is not supported. /// Returns "OK" if successful, otherwise, returns a string containing the error. #define rustg_iconforge_load_gags_config(config_path, config_json, config_icon_path) RUSTG_CALL(RUST_G, "iconforge_load_gags_config")("[config_path]", config_json, config_icon_path) /// Given a config_path (previously loaded by rustg_iconforge_load_gags_config), and a string of hex colors formatted as "#ff00ff#ffaa00" @@ -272,6 +316,12 @@ #define RUSTG_ICONFORGE_BLEND_ICON "BlendIcon" #define RUSTG_ICONFORGE_CROP "Crop" #define RUSTG_ICONFORGE_SCALE "Scale" +#define RUSTG_ICONFORGE_MAP_COLORS "MapColors" +#define RUSTG_ICONFORGE_FLIP "Flip" +#define RUSTG_ICONFORGE_TURN "Turn" +#define RUSTG_ICONFORGE_SHIFT "Shift" +#define RUSTG_ICONFORGE_SWAP_COLOR "SwapColor" +#define RUSTG_ICONFORGE_DRAW_BOX "DrawBox" #define RUSTG_JOB_NO_RESULTS_YET "NO RESULTS YET" #define RUSTG_JOB_NO_SUCH_JOB "NO SUCH JOB" @@ -298,6 +348,39 @@ */ #define rustg_noise_poisson_map(seed, width, length, radius) RUSTG_CALL(RUST_G, "noise_poisson_map")(seed, width, length, radius) +/** + * Register a list of nodes into a rust library. This list of nodes must have been serialized in a json. + * Node {// Index of this node in the list of nodes + * unique_id: usize, + * // Position of the node in byond + * x: usize, + * y: usize, + * z: usize, + * // Indexes of nodes connected to this one + * connected_nodes_id: Vec} + * It is important that the node with the unique_id 0 is the first in the json, unique_id 1 right after that, etc. + * It is also important that all unique ids follow. {0, 1, 2, 4} is not a correct list and the registering will fail + * Nodes should not link across z levels. + * A node cannot link twice to the same node and shouldn't link itself either + */ +#define rustg_register_nodes_astar(json) RUSTG_CALL(RUST_G, "register_nodes_astar")(json) + +/** + * Add a new node to the static list of nodes. Same rule as registering_nodes applies. + * This node unique_id must be equal to the current length of the static list of nodes + */ +#define rustg_add_node_astar(json) RUSTG_CALL(RUST_G, "add_node_astar")(json) + +/** + * Remove every link to the node with unique_id. Replace that node by null + */ +#define rustg_remove_node_astar(unique_id) RUSTG_CALL(RUST_G, "remove_node_astar")("[unique_id]") + +/** + * Compute the shortest path between start_node and goal_node using A*. Heuristic used is simple geometric distance + */ +#define rustg_generate_path_astar(start_node_id, goal_node_id) RUSTG_CALL(RUST_G, "generate_path_astar")("[start_node_id]", "[goal_node_id]") + /* * Takes in a string and json_encode()"d lists to produce a sanitized string. * This function operates on whitelists, there is currently no way to blacklist. @@ -362,8 +445,17 @@ #define rustg_time_milliseconds(id) text2num(RUSTG_CALL(RUST_G, "time_milliseconds")(id)) #define rustg_time_reset(id) RUSTG_CALL(RUST_G, "time_reset")(id) +/// Returns the current timestamp (in local time), formatted with the given format string. +/// See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for documentation on the formatting syntax. +#define rustg_formatted_timestamp(format) RUSTG_CALL(RUST_G, "formatted_timestamp")(format) + +/// Returns the current timestamp (with the given UTC offset in hours), formatted with the given format string. +/// See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for documentation on the formatting syntax. +#define rustg_formatted_timestamp_tz(format, offset) RUSTG_CALL(RUST_G, "formatted_timestamp")(format, offset) + /// Returns the timestamp as a string -#define rustg_unix_timestamp(...) (RUSTG_CALL(RUST_G, "unix_timestamp")()) +/proc/rustg_unix_timestamp() + return RUSTG_CALL(RUST_G, "unix_timestamp")() #define rustg_raw_read_toml_file(path) json_decode(RUSTG_CALL(RUST_G, "toml_file_to_json")(path) || "null") @@ -391,10 +483,3 @@ #define url_decode(text) rustg_url_decode(text) #endif -/// Returns the current timestamp (in local time), formatted with the given format string. -/// See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for documentation on the formatting syntax. -#define rustg_formatted_timestamp(format) RUSTG_CALL(RUST_G, "formatted_timestamp")(format) - -/// Returns the current timestamp (with the given UTC offset in hours), formatted with the given format string. -/// See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for documentation on the formatting syntax. -#define rustg_formatted_timestamp_tz(format, offset) RUSTG_CALL(RUST_G, "formatted_timestamp")(format, offset) diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm index a14f8839ba1..5b25afcb611 100644 --- a/code/__HELPERS/icons.dm +++ b/code/__HELPERS/icons.dm @@ -1171,27 +1171,60 @@ GLOBAL_LIST_EMPTY(transformation_animation_objects) animate(pixel_x = initialpixelx + rand(-pixelshiftx,pixelshiftx), pixel_y = initialpixely + rand(-pixelshifty,pixelshifty), time = shake_interval) animate(pixel_x = initialpixelx, pixel_y = initialpixely, time = shake_interval) +/// Returns rustg-parsed metadata for an icon, universal icon, or DMI file, using cached values where possible +/// Returns null if passed object is not a filepath or icon with a valid DMI file +/proc/icon_metadata(file) + var/static/list/icon_metadata_cache = list() + if(istype(file, /datum/universal_icon)) + var/datum/universal_icon/u_icon = file + file = u_icon.icon_file + var/file_string = "[file]" + if(!istext(file) && !(isfile(file) && length(file_string))) + return null + var/list/cached_metadata = icon_metadata_cache[file_string] + if(islist(cached_metadata)) + return cached_metadata + var/list/metadata_result = rustg_dmi_read_metadata(file_string) + if(!islist(metadata_result) || !length(metadata_result)) + CRASH("Error while reading DMI metadata for path '[file_string]': [metadata_result]") + else + icon_metadata_cache[file_string] = metadata_result + return metadata_result + /// Checks whether a given icon state exists in a given icon file. If `file` and `state` both exist, /// this will return `TRUE` - otherwise, it will return `FALSE`. /// /// If you want a stack trace to be output when the given state/file doesn't exist, use /// `/proc/icon_exists_or_scream()`. /proc/icon_exists(file, state) - var/static/list/icon_states_cache = list() if(isnull(file) || isnull(state)) return FALSE //This is common enough that it shouldn't panic, imo. - if(isnull(icon_states_cache[file])) - icon_states_cache[file] = list() - var/file_string = "[file]" - if(isfile(file) && length(file_string)) // ensure that it's actually a file, and not a runtime icon - for(var/istate in json_decode(rustg_dmi_icon_states(file_string))) - icon_states_cache[file][istate] = TRUE - else // Otherwise, we have to use the slower BYOND proc - for(var/istate in icon_states(file)) - icon_states_cache[file][istate] = TRUE + if(isnull(GLOB.icon_states_cache_lookup[file])) + compile_icon_states_cache(file) + return !isnull(GLOB.icon_states_cache_lookup[file][state]) - return !isnull(icon_states_cache[file][state]) +/// Cached, rustg-based alternative to icon_states() +/proc/icon_states_fast(file) + if(isnull(file)) + return null + if(isnull(GLOB.icon_states_cache[file])) + compile_icon_states_cache(file) + return GLOB.icon_states_cache[file] + +/proc/compile_icon_states_cache(file) + GLOB.icon_states_cache[file] = list() + GLOB.icon_states_cache_lookup[file] = list() + // Try to use rustg first + var/list/metadata = icon_metadata(file) + if(islist(metadata) && islist(metadata["states"])) + for(var/list/state_data as anything in metadata["states"]) + GLOB.icon_states_cache[file] += state_data["name"] + GLOB.icon_states_cache_lookup[file][state_data["name"]] = TRUE + else // Otherwise, we have to use the slower BYOND proc + for(var/istate in icon_states(file)) + GLOB.icon_states_cache[file] += istate + GLOB.icon_states_cache_lookup[file][istate] = TRUE /// Functions the same as `/proc/icon_exists()`, but with the addition of a stack trace if the /// specified file or state doesn't exist. @@ -1238,6 +1271,31 @@ GLOBAL_LIST_EMPTY(transformation_animation_objects) /// Returns a list containing the width and height of an icon file /proc/get_icon_dimensions(icon_path) + if(istype(icon_path, /datum/universal_icon)) + var/datum/universal_icon/u_icon = icon_path + icon_path = u_icon.icon_file + // Icons can be a real file(), a rsc backed file(), a dynamic rsc (dyn.rsc) reference (known as a cache reference in byond docs), or an /icon which is pointing to one of those. + // Runtime generated dynamic icons are an unbounded concept cache identity wise, the same icon can exist millions of ways and holding them in a list as a key can lead to unbounded memory usage if called often by consumers. + // Check distinctly that this is something that has this unspecified concept, and thus that we should not cache. + if (!istext(icon_path) && (!isfile(icon_path) || !length("[icon_path]"))) + var/icon/my_icon = icon(icon_path) + return list("width" = my_icon.Width(), "height" = my_icon.Height()) + if (isnull(GLOB.icon_dimensions[icon_path])) + // Used cached icon metadata + var/list/metadata = icon_metadata(icon_path) + var/list/result = null + if(islist(metadata) && isnum(metadata["width"]) && isnum(metadata["height"])) + result = list("width" = metadata["width"], "height" = metadata["height"]) + // Otherwise, we have to use the slower BYOND proc + else + var/icon/my_icon = icon(icon_path) + result = list("width" = my_icon.Width(), "height" = my_icon.Height()) + GLOB.icon_dimensions[icon_path] = result + + return GLOB.icon_dimensions[icon_path] + +/// Returns a list containing the width and height of an icon file, without using rustg for pure function calls +/proc/get_icon_dimensions_pure(icon_path) // Icons can be a real file(), a rsc backed file(), a dynamic rsc (dyn.rsc) reference (known as a cache reference in byond docs), or an /icon which is pointing to one of those. // Runtime generated dynamic icons are an unbounded concept cache identity wise, the same icon can exist millions of ways and holding them in a list as a key can lead to unbounded memory usage if called often by consumers. // Check distinctly that this is something that has this unspecified concept, and thus that we should not cache. diff --git a/code/__HELPERS/turfs.dm b/code/__HELPERS/turfs.dm index 1aa7fcd65d0..902bfbf585e 100644 --- a/code/__HELPERS/turfs.dm +++ b/code/__HELPERS/turfs.dm @@ -236,7 +236,7 @@ Turf and target are separate in case you want to teleport some distance from a t var/pixel_y_offset = checked_atom.pixel_y + checked_atom.pixel_z + atom_matrix.get_y_shift() //Irregular objects - var/list/icon_dimensions = get_icon_dimensions(checked_atom.icon) + var/list/icon_dimensions = get_icon_dimensions_pure(checked_atom.icon) var/checked_atom_icon_height = icon_dimensions["height"] var/checked_atom_icon_width = icon_dimensions["width"] if(checked_atom_icon_height != ICON_SIZE_Y || checked_atom_icon_width != ICON_SIZE_X) diff --git a/code/_globalvars/lists/icons.dm b/code/_globalvars/lists/icons.dm index ff60e6bc8d9..4d0b6279945 100644 --- a/code/_globalvars/lists/icons.dm +++ b/code/_globalvars/lists/icons.dm @@ -1,2 +1,6 @@ /// Cache of the width and height of icon files, to avoid repeating the same expensive operation GLOBAL_LIST_EMPTY(icon_dimensions) +/// Cache of the states of icon files +GLOBAL_LIST_EMPTY(icon_states_cache) +/// Cache of the states of icon files, stored associatively with TRUE for lookup +GLOBAL_LIST_EMPTY(icon_states_cache_lookup) diff --git a/code/controllers/subsystem/processing/greyscale.dm b/code/controllers/subsystem/processing/greyscale.dm index 4b77aa3e7b4..f88b960e869 100644 --- a/code/controllers/subsystem/processing/greyscale.dm +++ b/code/controllers/subsystem/processing/greyscale.dm @@ -1,7 +1,6 @@ /// Disable to use builtin DM-based generation. /// IconForge is 250x times faster but requires storing the icons in tmp/ and may result in higher asset transport. /// Note that the builtin GAGS editor still uses the 'legacy' generation to allow for debugging. -/// IconForge also does not support the color matrix layer type or the 'or' blend_mode, however both are currently unused. #define USE_RUSTG_ICONFORGE_GAGS PROCESSING_SUBSYSTEM_DEF(greyscale) diff --git a/code/modules/asset_cache/spritesheet/batched/batched_spritesheet.dm b/code/modules/asset_cache/spritesheet/batched/batched_spritesheet.dm index 9256231c0e8..a5a636327f5 100644 --- a/code/modules/asset_cache/spritesheet/batched/batched_spritesheet.dm +++ b/code/modules/asset_cache/spritesheet/batched/batched_spritesheet.dm @@ -189,10 +189,10 @@ var/data_out if(yield || !isnull(job_id)) if(isnull(job_id)) - job_id = rustg_iconforge_generate_async("data/spritesheets/", name, entries_json, do_cache) + job_id = rustg_iconforge_generate_async("data/spritesheets/", name, entries_json, do_cache, FALSE, TRUE) UNTIL((data_out = rustg_iconforge_check(job_id)) != RUSTG_JOB_NO_RESULTS_YET) else - data_out = rustg_iconforge_generate("data/spritesheets/", name, entries_json, do_cache) + data_out = rustg_iconforge_generate("data/spritesheets/", name, entries_json, do_cache, FALSE, TRUE) if (data_out == RUSTG_JOB_ERROR) CRASH("Spritesheet [name] JOB PANIC") else if(!findtext(data_out, "{", 1, 2)) diff --git a/code/modules/asset_cache/spritesheet/batched/universal_icon.dm b/code/modules/asset_cache/spritesheet/batched/universal_icon.dm index 3f06001900b..0ecdd1ea349 100644 --- a/code/modules/asset_cache/spritesheet/batched/universal_icon.dm +++ b/code/modules/asset_cache/spritesheet/batched/universal_icon.dm @@ -10,7 +10,7 @@ var/datum/icon_transformer/transform /// Don't instantiate these yourself, use uni_icon. -/datum/universal_icon/New(icon/icon_file, icon_state="", dir=SOUTH, frame=1, datum/icon_transformer/transform=null, color=null) +/datum/universal_icon/New(icon/icon_file, icon_state="", dir=null, frame=null, datum/icon_transformer/transform=null, color=null) #ifdef UNIT_TESTS // This check is kinda slow and shouldn't fail unless a developer makes a mistake. So it'll get caught in unit tests. if(!isicon(icon_file) || !isfile(icon_file) || "[icon_file]" == "/icon" || !length("[icon_file]")) @@ -44,10 +44,10 @@ transform.blend_color(color, blend_mode) return src -/datum/universal_icon/proc/blend_icon(datum/universal_icon/icon_object, blend_mode) +/datum/universal_icon/proc/blend_icon(datum/universal_icon/icon_object, blend_mode, x=1, y=1) if(!transform) transform = new - transform.blend_icon(icon_object, blend_mode) + transform.blend_icon(icon_object, blend_mode, x, y) return src /datum/universal_icon/proc/scale(width, height) @@ -62,14 +62,116 @@ transform.crop(x1, y1, x2, y2) return src -/// Internally performs a crop. -/datum/universal_icon/proc/shift(dir, amount, icon_width, icon_height) +/datum/universal_icon/proc/flip(dir) if(!transform) transform = new - var/list/offsets = dir2offset(dir) - var/shift_x = -offsets[1] * amount - var/shift_y = -offsets[2] * amount - transform.crop(1 + shift_x, 1 + shift_y, icon_width + shift_x, icon_height + shift_y) + transform.flip(dir) + return src + +/datum/universal_icon/proc/rotate(angle) + if(!transform) + transform = new + transform.rotate(angle) + return src + +/datum/universal_icon/proc/shift(dir, offset, wrap=0) + if(!transform) + transform = new + transform.shift(dir, offset, wrap) + return src + +/datum/universal_icon/proc/swap_color(src_color, dst_color) + if(!transform) + transform = new + transform.swap_color(src_color, dst_color) + return src + +/datum/universal_icon/proc/draw_box(color, x1, y1, x2=x1, y2=y1) + if(!transform) + transform = new + transform.draw_box(color, x1, y1, x2, y2) + return src + +/datum/universal_icon/proc/map_colors_inferred(list/color_args) + var/num_args = length(color_args) + if(num_args <= 20 || num_args >= 16) + src.map_colors_rgba(arglist(color_args)) + else if(num_args <= 12 || num_args >= 9) + src.map_colors_rgb(arglist(color_args)) + else if(num_args == 5) + src.map_colors_rgba_hex(arglist(color_args)) + else if(num_args == 4) + // is there alpha in the hex? + if(length(color_args[3]) == 7 || length(color_args[3]) == 4) + src.map_colors_rgb_hex(arglist(color_args)) + else + src.map_colors_rgba_hex(arglist(color_args)) + else if(num_args == 3) + src.map_colors_rgb_hex(arglist(color_args)) + +/datum/universal_icon/proc/map_colors_rgba(rr, rg, rb, ra, gr, gg, gb, ga, br, bg, bb, ba, ar, ag, ab, aa, r0=0, g0=0, b0=0, a0=0) + if(!transform) + transform = new + transform.map_colors(rr, rg, rb, ra, gr, gg, gb, ga, br, bg, bb, ba, ar, ag, ab, aa, r0, g0, b0, a0) + return src + +/datum/universal_icon/proc/map_colors_rgb(rr, rg, rb, gr, gg, gb, br, bg, bb, r0=0, g0=0, b0=0) + if(!transform) + transform = new + transform.map_colors(rr, rg, rb, 0, gr, gg, gb, 0, br, bg, bb, 0, 0, 0, 0, 1, r0, g0, b0, 0) + return src + +/datum/universal_icon/proc/map_colors_rgb_hex(r_rgb, g_rgb, b_rgb, rgb0=rgb(0,0,0)) + if(!transform) + transform = new + var/rr = hex2num(copytext(r_rgb, 2, 4)) / 255 + var/rg = hex2num(copytext(r_rgb, 4, 6)) / 255 + var/rb = hex2num(copytext(r_rgb, 6, 8)) / 255 + + var/gr = hex2num(copytext(g_rgb, 2, 4)) / 255 + var/gg = hex2num(copytext(g_rgb, 4, 6)) / 255 + var/gb = hex2num(copytext(g_rgb, 6, 8)) / 255 + + var/br = hex2num(copytext(b_rgb, 2, 4)) / 255 + var/bg = hex2num(copytext(b_rgb, 4, 6)) / 255 + var/bb = hex2num(copytext(b_rgb, 6, 8)) / 255 + + var/r0 = hex2num(copytext(rgb0, 2, 4)) / 255 + var/b0 = hex2num(copytext(rgb0, 4, 6)) / 255 + var/g0 = hex2num(copytext(rgb0, 6, 8)) / 255 + + transform.map_colors(rr, rg, rb, 0, gr, gg, gb, 0, br, bg, bb, 0, 0, 0, 0, 1, r0, b0, g0, 0) + return src + +/datum/universal_icon/proc/map_colors_rgba_hex(r_rgba, g_rgba, b_rgba, a_rgba, rgba0="#00000000") + if(!transform) + transform = new + var/rr = hex2num(copytext(r_rgba, 2, 4)) / 255 + var/rg = hex2num(copytext(r_rgba, 4, 6)) / 255 + var/rb = hex2num(copytext(r_rgba, 6, 8)) / 255 + var/ra = hex2num(copytext(r_rgba, 8, 10)) / 255 + + var/gr = hex2num(copytext(g_rgba, 2, 4)) / 255 + var/gg = hex2num(copytext(g_rgba, 4, 6)) / 255 + var/gb = hex2num(copytext(g_rgba, 6, 8)) / 255 + var/ga = hex2num(copytext(g_rgba, 8, 10)) / 255 + + var/br = hex2num(copytext(b_rgba, 2, 4)) / 255 + var/bg = hex2num(copytext(b_rgba, 4, 6)) / 255 + var/bb = hex2num(copytext(b_rgba, 6, 8)) / 255 + var/ba = hex2num(copytext(b_rgba, 8, 10)) / 255 + + var/ar = hex2num(copytext(a_rgba, 2, 4)) / 255 + var/ag = hex2num(copytext(a_rgba, 4, 6)) / 255 + var/ab = hex2num(copytext(a_rgba, 6, 8)) / 255 + var/aa = hex2num(copytext(a_rgba, 8, 10)) / 255 + + var/r0 = hex2num(copytext(rgba0, 2, 4)) / 255 + var/b0 = hex2num(copytext(rgba0, 4, 6)) / 255 + var/g0 = hex2num(copytext(rgba0, 6, 8)) / 255 + var/a0 = hex2num(copytext(rgba0, 8, 10)) / 255 + + transform.map_colors(rr, rg, rb, ra, gr, gg, gb, ga, br, bg, bb, ba, ar, ag, ab, aa, r0, b0, g0, a0) return src /// Internally performs a color blend. @@ -118,11 +220,29 @@ if(!istype(icon_object)) stack_trace("Invalid icon found in icon transformer during apply()! [icon_object]") continue - target.Blend(icon_object.to_icon(), transform["blend_mode"]) + target.Blend(icon_object.to_icon(), transform["blend_mode"], transform["x"], transform["y"]) if(RUSTG_ICONFORGE_SCALE) target.Scale(transform["width"], transform["height"]) if(RUSTG_ICONFORGE_CROP) target.Crop(transform["x1"], transform["y1"], transform["x2"], transform["y2"]) + if(RUSTG_ICONFORGE_MAP_COLORS) + target.MapColors( + transform["rr"], transform["rg"], transform["rb"], transform["ra"], + transform["gr"], transform["gg"], transform["gb"], transform["ga"], + transform["br"], transform["bg"], transform["bb"], transform["ba"], + transform["ar"], transform["ag"], transform["ab"], transform["aa"], + transform["r0"], transform["g0"], transform["b0"], transform["a0"], + ) + if(RUSTG_ICONFORGE_FLIP) + target.Flip(transform["dir"]) + if(RUSTG_ICONFORGE_TURN) + target.Turn(transform["angle"]) + if(RUSTG_ICONFORGE_SHIFT) + target.Shift(transform["dir"], transform["offset"], transform["wrap"]) + if(RUSTG_ICONFORGE_SWAP_COLOR) + target.SwapColor(transform["src_color"], transform["dst_color"]) + if(RUSTG_ICONFORGE_DRAW_BOX) + target.DrawBox(transform["color"], transform["x1"], transform["y1"], transform["x2"], transform["y2"]) return target /datum/icon_transformer/proc/copy() @@ -142,13 +262,17 @@ #endif transforms += list(list("type" = RUSTG_ICONFORGE_BLEND_COLOR, "color" = color, "blend_mode" = blend_mode)) -/datum/icon_transformer/proc/blend_icon(datum/universal_icon/icon_object, blend_mode) +/datum/icon_transformer/proc/blend_icon(datum/universal_icon/icon_object, blend_mode, x=1, y=1) #ifdef UNIT_TESTS // icon_object's type is checked later in to_list if(!isnum(blend_mode)) CRASH("Invalid blend_mode provided to blend_icon: [blend_mode]") + if(!isnum(x)) + CRASH("Invalid x offset provided to blend_icon: [x]") + if(!isnum(y)) + CRASH("Invalid y offset provided to blend_icon: [y]") #endif - transforms += list(list("type" = RUSTG_ICONFORGE_BLEND_ICON, "icon" = icon_object, "blend_mode" = blend_mode)) + transforms += list(list("type" = RUSTG_ICONFORGE_BLEND_ICON, "icon" = icon_object, "blend_mode" = blend_mode, "x" = x, "y" = y)) /datum/icon_transformer/proc/scale(width, height) #ifdef UNIT_TESTS @@ -164,6 +288,51 @@ #endif transforms += list(list("type" = RUSTG_ICONFORGE_CROP, "x1" = x1, "y1" = y1, "x2" = x2, "y2" = y2)) +/datum/icon_transformer/proc/flip(dir) + #ifdef UNIT_TESTS + if(!isnum(dir)) + CRASH("Invalid arguments provided to flip: [dir]") + #endif + transforms += list(list("type" = RUSTG_ICONFORGE_FLIP, "dir" = dir)) + +/datum/icon_transformer/proc/rotate(angle) + #ifdef UNIT_TESTS + if(!isnum(angle)) + CRASH("Invalid arguments provided to rotate: [angle]") + #endif + transforms += list(list("type" = RUSTG_ICONFORGE_TURN, "angle" = angle)) + +/datum/icon_transformer/proc/shift(dir, offset, wrap=FALSE) + #ifdef UNIT_TESTS + if(!isnum(dir) || !isnum(offset) || (wrap != FALSE && wrap != TRUE)) + CRASH("Invalid arguments provided to shift: [dir],[offset],[wrap]") + #endif + transforms += list(list("type" = RUSTG_ICONFORGE_SHIFT, "dir" = dir, "offset" = offset, "wrap" = wrap)) + +/datum/icon_transformer/proc/swap_color(src_color, dst_color) + #ifdef UNIT_TESTS + if(!istext(src_color) || !istext(dst_color)) + CRASH("Invalid arguments provided to swap_color: [src_color],[dst_color]") + #endif + transforms += list(list("type" = RUSTG_ICONFORGE_SWAP_COLOR, "src_color" = src_color, "dst_color" = dst_color)) + +/datum/icon_transformer/proc/draw_box(color, x1, y1, x2=x1, y2=y1) + #ifdef UNIT_TESTS + if(!istext(color) || !isnum(x1) || !isnum(y1) || !isnum(x2) || !isnum(y2)) + CRASH("Invalid arguments provided to draw_box: [color],[x1],[y1],[x2],[y2]") + #endif + transforms += list(list("type" = RUSTG_ICONFORGE_DRAW_BOX, "color" = color, "x1" = x1, "y1" = y1, "x2" = x2, "y2" = y2)) + +/datum/icon_transformer/proc/map_colors(rr, rg, rb, ra, gr, gg, gb, ga, br, bg, bb, ba, ar, ag, ab, aa, r0=0, g0=0, b0=0, a0=0) + transforms += list(list( + "type" = RUSTG_ICONFORGE_MAP_COLORS, + "rr" = rr, "rg" = rg, "rb" = rb, "ra" = ra, + "gr" = gr, "gg" = gg, "gb" = gb, "ga" = ga, + "br" = br, "bg" = bg, "bb" = bb, "ba" = ba, + "ar" = ar, "ag" = ag, "ab" = ab, "aa" = aa, + "r0" = r0, "g0" = g0, "b0" = b0, "a0" = a0, + )) + /// Recursively converts all contained [/datum/universal_icon]s and their associated [/datum/icon_transformer]s into list form so the transforms can be JSON encoded. /datum/icon_transformer/proc/to_list() RETURN_TYPE(/list) @@ -218,21 +387,19 @@ /proc/get_display_icon_for(atom/atom_path) if (!ispath(atom_path, /atom)) return FALSE - var/icon_file = initial(atom_path.icon) - var/icon_state = initial(atom_path.icon_state) - if(initial(atom_path.greyscale_config) && initial(atom_path.greyscale_colors)) + var/icon_file = atom_path::icon + var/icon_state = atom_path::icon_state + if(atom_path::greyscale_config && atom_path::greyscale_colors) return gags_to_universal_icon(atom_path) if(ispath(atom_path, /obj)) var/obj/obj_path = atom_path - if(initial(obj_path.icon_state_preview)) - icon_state = initial(obj_path.icon_state_preview) - return uni_icon(icon_file, icon_state, color=initial(atom_path.color)) + if(obj_path::icon_state_preview) + icon_state = obj_path::icon_state_preview + return uni_icon(icon_file, icon_state, color=atom_path::color) /// getFlatIcon for [/datum/universal_icon]s -/// Only supports 32x32 icons facing south -/// Tough luck if you want anything else /// Still fairly slow for complex appearances due to filesystem operations. Try to avoid using it -/proc/get_flat_uni_icon(image/appearance, deficon, defstate, defblend, start = TRUE, parentcolor) +/proc/get_flat_uni_icon(image/appearance, defdir, deficon, defstate, defblend, start = TRUE, parentcolor) // Loop through the underlays, then overlays, sorting them into the layers list #define PROCESS_OVERLAYS_OR_UNDERLAYS(flat, process, base_layer) \ for (var/i in 1 to process.len) { \ @@ -283,7 +450,7 @@ var/curstate = appearance.icon_state || defstate // Filter out 'runtime' icons (server-generated RSC cache icons) // Write the icon to the filesystem so it can be used by iconforge - if(!isfile(curicon) || string_curicon == "/icon" || string_curicon == "/image" || !length(string_curicon)) + if(!isfile(curicon) || !length(string_curicon)) var/file_path_tmp = "tmp/uni_icon-tmp-[rand(1, 999)].dmi" // this filename is temporary. fcopy(curicon, file_path_tmp) var/file_hash = rustg_hash_file(RUSTG_HASH_MD5, file_path_tmp) @@ -293,21 +460,51 @@ fdel(file_path_tmp) // delete the old one curicon = file(file_path) - var/curblend = appearance.blend_mode || defblend - var/list/curstates = icon_states(curicon) - if(!(curstate in curstates)) - if("" in curstates) // BYOND defaulting functionality + if(!icon_exists(curicon, curstate)) + if("" in icon_states_fast(curicon)) // BYOND defaulting functionality curstate = "" else should_display = FALSE + var/curdir = (!appearance.dir || appearance.dir == SOUTH) ? defdir : appearance.dir + var/base_icon_dir //We'll use this to get the icon state to display if not null BUT NOT pass it to overlays as the dir we have + + if(should_display) + //Determines if there're directionals. + if (curdir != SOUTH) + // icon states either have 1, 4 or 8 dirs. We only have to check + // one of NORTH, EAST or WEST to know that this isn't a 1-dir icon_state since they just have SOUTH. + var/list/metadata = icon_metadata(curicon) + if(islist(metadata)) + for(var/list/state_data as anything in metadata["states"]) + var/name = state_data["name"] + if(name != curstate) + continue + var/dir_count = state_data["dirs"] + if(dir_count == 1) + base_icon_dir = SOUTH + else if(!length(icon_states(icon(curicon, curstate, NORTH)))) + base_icon_dir = SOUTH + + var/list/icon_dimensions = get_icon_dimensions(curicon) + var/icon_width = icon_dimensions["width"] + var/icon_height = icon_dimensions["height"] + if(icon_width != 32 || icon_height != 32) + flat.scale(icon_width, icon_height) + + if(!base_icon_dir) + base_icon_dir = curdir + + var/curblend = appearance.blend_mode || defblend + + if(appearance.overlays.len || appearance.underlays.len) // Layers will be a sorted list of icons/overlays, based on the order in which they are displayed var/list/layers = list() var/image/copy if(should_display) // Add the atom's icon itself, without pixel_x/y offsets. - copy = image(icon=curicon, icon_state=curstate, layer=appearance.layer, dir=SOUTH) + copy = image(icon=curicon, icon_state=curstate, layer=appearance.layer, dir=base_icon_dir) copy.color = appearance.color copy.alpha = appearance.alpha copy.blend_mode = curblend @@ -318,15 +515,26 @@ var/datum/universal_icon/add // Icon of overlay being added + var/list/flat_dimensions = get_icon_dimensions(flat) + var/flatX1 = 1 + var/flatX2 = flat_dimensions["width"] + var/flatY1 = 1 + var/flatY2 = flat_dimensions["height"] + + var/addX1 = 0 + var/addX2 = 0 + var/addY1 = 0 + var/addY2 = 0 + if(appearance.color) if(islist(appearance.color)) - stack_trace("Unsupported color map appearance provided to get_flat_uni_icon, ignoring it.") + flat.map_colors_inferred(appearance.color) else flat.blend_color(appearance.color, ICON_MULTIPLY) if(parentcolor && !(appearance.appearance_flags & RESET_COLOR)) if(islist(parentcolor)) - stack_trace("Unsupported color map appearance provided to get_flat_uni_icon, ignoring it.") + flat.map_colors_inferred(parentcolor) else flat.blend_color(parentcolor, ICON_MULTIPLY) @@ -338,19 +546,45 @@ if(layer_image == copy && length("[layer_image.icon]")) // 'layer_image' is an /image based on the object being flattened, and isn't a 'runtime' icon. curblend = BLEND_OVERLAY - add = uni_icon(layer_image.icon, layer_image.icon_state, SOUTH) + add = uni_icon(layer_image.icon, layer_image.icon_state, base_icon_dir) if(appearance.color) if(islist(appearance.color)) - stack_trace("Unsupported color map appearance provided to get_flat_uni_icon, ignoring it.") + add.map_colors_inferred(appearance.color) else add.blend_color(appearance.color, ICON_MULTIPLY) else // 'layer_image' is an appearance object. - add = get_flat_uni_icon(layer_image, curicon, curstate, curblend, FALSE, next_parentcolor) + add = get_flat_uni_icon(layer_image, curdir, curicon, curstate, curblend, FALSE, next_parentcolor) if(!add || !length(add.icon_file)) continue + // Find the new dimensions of the flat icon to fit the added overlay + var/list/add_dimensions = get_icon_dimensions(add) + addX1 = min(flatX1, layer_image.pixel_x + layer_image.pixel_w + 1) + addX2 = max(flatX2, layer_image.pixel_x + layer_image.pixel_w + add_dimensions["width"]) // assuming 32x32 + addY1 = min(flatY1, layer_image.pixel_y + layer_image.pixel_z + 1) + addY2 = max(flatY2, layer_image.pixel_y + layer_image.pixel_z + add_dimensions["height"]) + + if ( + addX1 != flatX1 \ + && addX2 != flatX2 \ + && addY1 != flatY1 \ + && addY2 != flatY2 \ + ) + // Resize the flattened icon so the new icon fits + flat.crop( + addX1 - flatX1 + 1, + addY1 - flatY1 + 1, + addX2 - flatX1 + 1, + addY2 - flatY1 + 1 + ) + + flatX1 = addX1 + flatX2 = addY1 + flatY1 = addX2 + flatY2 = addY2 + // Blend the overlay into the flattened icon - flat.blend_icon(add, blendMode2iconMode(curblend)) + flat.blend_icon(add, blendMode2iconMode(curblend), layer_image.pixel_x + layer_image.pixel_w + 2 - flatX1, layer_image.pixel_y + layer_image.pixel_z + 2 - flatY1) if(appearance.alpha < 255) flat.blend_color(rgb(255, 255, 255, appearance.alpha), ICON_MULTIPLY) @@ -358,14 +592,14 @@ return flat else if(should_display) // There's no overlays. - var/datum/universal_icon/final_icon = uni_icon(curicon, curstate, SOUTH) + var/datum/universal_icon/final_icon = uni_icon(curicon, curstate, base_icon_dir) if (appearance.alpha < 255) final_icon.blend_color(rgb(255,255,255, appearance.alpha), ICON_MULTIPLY) if (appearance.color) if (islist(appearance.color)) - stack_trace("Unsupported color map appearance provided to get_flat_uni_icon, ignoring it.") + final_icon.map_colors_inferred(appearance.color) else final_icon.blend_color(appearance.color, ICON_MULTIPLY) diff --git a/code/modules/client/preferences/species_features/basic.dm b/code/modules/client/preferences/species_features/basic.dm index 876d3a3c45d..b33f2818bda 100644 --- a/code/modules/client/preferences/species_features/basic.dm +++ b/code/modules/client/preferences/species_features/basic.dm @@ -10,7 +10,7 @@ var/datum/universal_icon/head_accessory_icon = uni_icon(sprite_accessory.icon, sprite_accessory.icon_state) if(y_offset) - head_accessory_icon.shift(NORTH, y_offset, ICON_SIZE_X, ICON_SIZE_Y) + head_accessory_icon.shift(NORTH, y_offset) head_accessory_icon.blend_color(COLOR_DARK_BROWN, ICON_MULTIPLY) final_icon.blend_icon(head_accessory_icon, ICON_OVERLAY) diff --git a/dependencies.sh b/dependencies.sh index c89c1d8597a..e685a8aed3b 100644 --- a/dependencies.sh +++ b/dependencies.sh @@ -8,7 +8,7 @@ export BYOND_MAJOR=516 export BYOND_MINOR=1659 #rust_g git tag -export RUST_G_VERSION=3.9.0 +export RUST_G_VERSION=4.0.0 # node version export NODE_VERSION_LTS=22.11.0 diff --git a/icons/map_icons/clothing/accessory.dmi b/icons/map_icons/clothing/accessory.dmi index 1f048116d9ad5382fb7336e330435a4f3c7573f7..1719b8a73bbd9b33491e902254aea869e33c11ff 100644 GIT binary patch delta 27 jcmeyt{)2r&kASSKl9rBvv8lO@o&B=!?v5Mh#4rN@fWrwk delta 27 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 literal 17368 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