From ece35e83db68156067da9aa57081746837c988f9 Mon Sep 17 00:00:00 2001
From: SmArtKar <44720187+SmArtKar@users.noreply.github.com>
Date: Mon, 4 May 2026 11:19:35 +0200
Subject: [PATCH] Implements OverFlayer, VV dev tool for debugging appearances
and images (#95852)
---
code/__DEFINES/vv.dm | 1 +
code/_globalvars/layers.dm | 190 +++++
code/datums/datumvars.dm | 3 +
code/game/atom/atom_vv.dm | 1 +
code/modules/admin/holder2.dm | 3 +
.../appearance_debugger.dm | 243 +++++++
.../debug_variable_appearance.dm | 5 +
tgstation.dme | 2 +
.../AppearanceDebug/AppearanceBox.tsx | 134 ++++
.../AppearanceDebug/AppearanceInfo.tsx | 185 +++++
.../tgui/interfaces/AppearanceDebug/index.tsx | 670 ++++++++++++++++++
.../tgui/interfaces/AppearanceDebug/types.ts | 135 ++++
.../AppearanceDebug/useAppearanceDebug.ts | 30 +
.../PlaneMasterDebug/PlaneEditor.tsx | 2 +-
.../interfaces/PlaneMasterDebug/index.tsx | 1 +
15 files changed, 1604 insertions(+), 1 deletion(-)
create mode 100644 code/_globalvars/layers.dm
create mode 100644 code/modules/admin/verbs/appearance_debugger/appearance_debugger.dm
create mode 100644 tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceBox.tsx
create mode 100644 tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceInfo.tsx
create mode 100644 tgui/packages/tgui/interfaces/AppearanceDebug/index.tsx
create mode 100644 tgui/packages/tgui/interfaces/AppearanceDebug/types.ts
create mode 100644 tgui/packages/tgui/interfaces/AppearanceDebug/useAppearanceDebug.ts
diff --git a/code/__DEFINES/vv.dm b/code/__DEFINES/vv.dm
index eae9769003f..1cbb45a235a 100644
--- a/code/__DEFINES/vv.dm
+++ b/code/__DEFINES/vv.dm
@@ -84,6 +84,7 @@
#define VV_HK_REMOVECOMPONENT "removecomponent"
#define VV_HK_MASS_REMOVECOMPONENT "massremovecomponent"
#define VV_HK_MODIFY_TRAITS "modtraits"
+#define VV_HK_DEBUG_APPEARANCE "debugappearance"
// /atom
#define VV_HK_MODIFY_TRANSFORM "atom_transform"
diff --git a/code/_globalvars/layers.dm b/code/_globalvars/layers.dm
new file mode 100644
index 00000000000..e90c38756d5
--- /dev/null
+++ b/code/_globalvars/layers.dm
@@ -0,0 +1,190 @@
+GLOBAL_LIST_INIT(admin_readable_planes, list(
+ "FLOAT_PLANE" = FLOAT_PLANE,
+ "FIELD_OF_VISION_BLOCKER_PLANE" = FIELD_OF_VISION_BLOCKER_PLANE,
+ "CLICKCATCHER_PLANE" = CLICKCATCHER_PLANE,
+ "PLANE_SPACE" = PLANE_SPACE,
+ "PLANE_SPACE_PARALLAX" = PLANE_SPACE_PARALLAX,
+ "DISPLACEMENT_PLANE" = DISPLACEMENT_PLANE,
+ "RENDER_PLANE_TRANSPARENT" = RENDER_PLANE_TRANSPARENT,
+ "TRANSPARENT_FLOOR_PLANE" = TRANSPARENT_FLOOR_PLANE,
+ "FLOOR_PLANE" = FLOOR_PLANE,
+ "WALL_PLANE" = WALL_PLANE,
+ "GAME_PLANE" = GAME_PLANE,
+ "ABOVE_GAME_PLANE" = ABOVE_GAME_PLANE,
+ "SEETHROUGH_PLANE" = SEETHROUGH_PLANE,
+ "RENDER_PLANE_GAME_WORLD" = RENDER_PLANE_GAME_WORLD,
+ "DEFAULT_PLANE" = DEFAULT_PLANE,
+ "WEATHER_PLANE" = WEATHER_PLANE,
+ "AREA_PLANE" = AREA_PLANE,
+ "MASSIVE_OBJ_PLANE" = MASSIVE_OBJ_PLANE,
+ "GHOST_PLANE" = GHOST_PLANE,
+ "POINT_PLANE" = POINT_PLANE,
+ "LIGHTING_PLANE" = LIGHTING_PLANE,
+ "O_LIGHTING_VISUAL_PLANE" = O_LIGHTING_VISUAL_PLANE,
+ "RENDER_PLANE_TURF_LIGHTING" = RENDER_PLANE_TURF_LIGHTING,
+ "EMISSIVE_PLANE" = EMISSIVE_PLANE,
+ "RENDER_PLANE_EMISSIVE" = RENDER_PLANE_EMISSIVE,
+ "RENDER_PLANE_EMISSIVE_BLOOM_MASK" = RENDER_PLANE_EMISSIVE_BLOOM_MASK,
+ "RENDER_PLANE_EMISSIVE_BLOOM" = RENDER_PLANE_EMISSIVE_BLOOM,
+ "RENDER_PLANE_SPECULAR_MASK" = RENDER_PLANE_SPECULAR_MASK,
+ "RENDER_PLANE_UNLIT_GAME" = RENDER_PLANE_UNLIT_GAME,
+ "RENDER_PLANE_LIGHTING" = RENDER_PLANE_LIGHTING,
+ "RENDER_PLANE_LIGHT_MASK" = RENDER_PLANE_LIGHT_MASK,
+ "RENDER_PLANE_SPECULAR" = RENDER_PLANE_SPECULAR,
+ "ABOVE_LIGHTING_PLANE" = ABOVE_LIGHTING_PLANE,
+ "WEATHER_GLOW_PLANE" = WEATHER_GLOW_PLANE,
+ "PIPECRAWL_IMAGES_PLANE" = PIPECRAWL_IMAGES_PLANE,
+ "CAMERA_STATIC_PLANE" = CAMERA_STATIC_PLANE,
+ "HIGH_GAME_PLANE" = HIGH_GAME_PLANE,
+ "FULLSCREEN_PLANE" = FULLSCREEN_PLANE,
+ "RUNECHAT_PLANE" = RUNECHAT_PLANE,
+ "BALLOON_CHAT_PLANE" = BALLOON_CHAT_PLANE,
+ "HUD_PLANE" = HUD_PLANE,
+ "ABOVE_HUD_PLANE" = ABOVE_HUD_PLANE,
+ "SPLASHSCREEN_PLANE" = SPLASHSCREEN_PLANE,
+ "RENDER_PLANE_GAME" = RENDER_PLANE_GAME,
+ "RENDER_PLANE_GAME_MASKED" = RENDER_PLANE_GAME_MASKED,
+ "RENDER_PLANE_GAME_UNMASKED" = RENDER_PLANE_GAME_UNMASKED,
+ "RENDER_PLANE_NON_GAME" = RENDER_PLANE_NON_GAME,
+ "ESCAPE_MENU_PLANE" = ESCAPE_MENU_PLANE,
+ "RENDER_PLANE_MASTER" = RENDER_PLANE_MASTER
+))
+
+GLOBAL_LIST_INIT(admin_readable_layers, list(
+ "FLOAT_LAYER" = FLOAT_LAYER,
+ "SPACE_LAYER" = SPACE_LAYER,
+ // Topdown layers
+ "TOPDOWN_LAYER" = TOPDOWN_LAYER,
+ "LOWER_FLOOR_LAYER (Topdown)" = LOWER_FLOOR_LAYER,
+ "LOW_FLOOR_LAYER (Topdown)" = LOW_FLOOR_LAYER,
+ "TURF_PLATING_DECAL_LAYER (Topdown)" = TURF_PLATING_DECAL_LAYER,
+ "TURF_DECAL_LAYER (Topdown)" = TURF_DECAL_LAYER,
+ "CULT_OVERLAY_LAYER (Topdown)" = CULT_OVERLAY_LAYER,
+ "MID_TURF_LAYER (Topdown)" = MID_TURF_LAYER,
+ "HIGH_TURF_LAYER (Topdown)" = HIGH_TURF_LAYER,
+ "LATTICE_LAYER (Topdown)" = LATTICE_LAYER,
+ "DISPOSAL_PIPE_LAYER (Topdown)" = DISPOSAL_PIPE_LAYER,
+ "WIRE_LAYER (Topdown)" = WIRE_LAYER,
+ "BELOW_CATWALK_LAYER (Topdown)" = BELOW_CATWALK_LAYER,
+ "GLASS_FLOOR_LAYER (Topdown)" = GLASS_FLOOR_LAYER,
+ "CATWALK_LAYER (Topdown)" = CATWALK_LAYER,
+ "TRAM_RAIL_LAYER (Topdown)" = TRAM_RAIL_LAYER,
+ "ABOVE_OPEN_TURF_LAYER (Topdown)" = ABOVE_OPEN_TURF_LAYER,
+ "LOWER_RUNE_LAYER (Topdown)" = LOWER_RUNE_LAYER,
+ "RUNE_LAYER (Topdown)" = RUNE_LAYER,
+ "CLEANABLE_FLOOR_OBJECT_LAYER (Topdown)" = CLEANABLE_FLOOR_OBJECT_LAYER,
+ "TOPDOWN_WATER_LEVEL_LAYER (Topdown)" = TOPDOWN_WATER_LEVEL_LAYER,
+ "TOPDOWN_ABOVE_WATER_LAYER (Topdown)" = TOPDOWN_ABOVE_WATER_LAYER,
+ // Game layers
+ "BELOW_CLOSED_TURF_LAYER" = BELOW_CLOSED_TURF_LAYER,
+ "CLOSED_TURF_LAYER" = CLOSED_TURF_LAYER,
+ "BULLET_HOLE_LAYER" = BULLET_HOLE_LAYER,
+ "ABOVE_NORMAL_TURF_LAYER" = ABOVE_NORMAL_TURF_LAYER,
+ "FLOOR_EMISSIVE_START_LAYER" = FLOOR_EMISSIVE_START_LAYER,
+ "FLOOR_EMISSIVE_END_LAYER" = FLOOR_EMISSIVE_END_LAYER,
+ "GAS_PIPE_HIDDEN_LAYER" = GAS_PIPE_HIDDEN_LAYER,
+ "WIRE_BRIDGE_LAYER" = WIRE_BRIDGE_LAYER,
+ "WIRE_TERMINAL_LAYER" = WIRE_TERMINAL_LAYER,
+ "GAS_SCRUBBER_LAYER" = GAS_SCRUBBER_LAYER,
+ "GAS_PIPE_VISIBLE_LAYER" = GAS_PIPE_VISIBLE_LAYER,
+ "GAS_FILTER_LAYER" = GAS_FILTER_LAYER,
+ "GAS_PUMP_LAYER" = GAS_PUMP_LAYER,
+ "PLUMBING_PIPE_VISIBILE_LAYER" = PLUMBING_PIPE_VISIBILE_LAYER,
+ "BOT_PATH_LAYER" = BOT_PATH_LAYER,
+ "LOW_OBJ_LAYER" = LOW_OBJ_LAYER,
+ "HIGH_PIPE_LAYER" = HIGH_PIPE_LAYER,
+ "CLEANABLE_OBJECT_LAYER" = CLEANABLE_OBJECT_LAYER,
+ "TRAM_STRUCTURE_LAYER" = TRAM_STRUCTURE_LAYER,
+ "TRAM_FLOOR_LAYER" = TRAM_FLOOR_LAYER,
+ "TRAM_WALL_LAYER" = TRAM_WALL_LAYER,
+ "BELOW_OPEN_DOOR_LAYER" = BELOW_OPEN_DOOR_LAYER,
+ "WATER_LEVEL_LAYER" = WATER_LEVEL_LAYER,
+ "BLASTDOOR_LAYER" = BLASTDOOR_LAYER,
+ "OPEN_DOOR_LAYER" = OPEN_DOOR_LAYER,
+ "DOOR_ACCESS_HELPER_LAYER" = DOOR_ACCESS_HELPER_LAYER,
+ "DOOR_HELPER_LAYER" = DOOR_HELPER_LAYER,
+ "PROJECTILE_HIT_THRESHHOLD_LAYER" = PROJECTILE_HIT_THRESHHOLD_LAYER,
+ "TABLE_LAYER" = TABLE_LAYER,
+ "GIB_LAYER" = GIB_LAYER,
+ "BELOW_OBJ_LAYER" = BELOW_OBJ_LAYER,
+ "LOW_ITEM_LAYER" = LOW_ITEM_LAYER,
+ "OBJ_LAYER" = OBJ_LAYER,
+ "CLOSED_DOOR_LAYER" = CLOSED_DOOR_LAYER,
+ "CLOSED_FIREDOOR_LAYER" = CLOSED_FIREDOOR_LAYER,
+ "ABOVE_OBJ_LAYER" = ABOVE_OBJ_LAYER,
+ "CLOSED_BLASTDOOR_LAYER" = CLOSED_BLASTDOOR_LAYER,
+ "SHUTTER_LAYER" = SHUTTER_LAYER,
+ "ABOVE_WINDOW_LAYER" = ABOVE_WINDOW_LAYER,
+ "SIGN_LAYER" = SIGN_LAYER,
+ "CORGI_ASS_PIN_LAYER" = CORGI_ASS_PIN_LAYER,
+ "NOT_HIGH_OBJ_LAYER" = NOT_HIGH_OBJ_LAYER,
+ "HIGH_OBJ_LAYER" = HIGH_OBJ_LAYER,
+ "BELOW_MOB_LAYER" = BELOW_MOB_LAYER,
+ "LOW_MOB_LAYER" = LOW_MOB_LAYER,
+ "LYING_MOB_LAYER" = LYING_MOB_LAYER,
+ "ABOVE_LYING_MOB_LAYER" = ABOVE_LYING_MOB_LAYER,
+ "VEHICLE_LAYER" = VEHICLE_LAYER,
+ "MOB_BELOW_PIGGYBACK_LAYER" = MOB_BELOW_PIGGYBACK_LAYER,
+ "MOB_LAYER" = MOB_LAYER,
+ "MOB_SHIELD_LAYER" = MOB_SHIELD_LAYER,
+ "MOB_ABOVE_PIGGYBACK_LAYER" = MOB_ABOVE_PIGGYBACK_LAYER,
+ "MOB_UPPER_LAYER" = MOB_UPPER_LAYER,
+ "HITSCAN_PROJECTILE_LAYER" = HITSCAN_PROJECTILE_LAYER,
+ "ABOVE_MOB_LAYER" = ABOVE_MOB_LAYER,
+ "WALL_OBJ_LAYER" = WALL_OBJ_LAYER,
+ "TRAM_SIGNAL_LAYER" = TRAM_SIGNAL_LAYER,
+ "EDGED_TURF_LAYER" = EDGED_TURF_LAYER,
+ "ON_EDGED_TURF_LAYER" = ON_EDGED_TURF_LAYER,
+ "SPACEVINE_LAYER" = SPACEVINE_LAYER,
+ "LARGE_MOB_LAYER" = LARGE_MOB_LAYER,
+ "SPACEVINE_MOB_LAYER" = SPACEVINE_MOB_LAYER,
+ "ABOVE_ALL_MOB_LAYER" = ABOVE_ALL_MOB_LAYER,
+ "NAVIGATION_EYE_LAYER" = NAVIGATION_EYE_LAYER,
+ "FLY_LAYER" = FLY_LAYER,
+ "ABOVE_TREE_LAYER" = ABOVE_TREE_LAYER,
+ "GASFIRE_LAYER" = GASFIRE_LAYER,
+ "RIPPLE_LAYER" = RIPPLE_LAYER,
+ // LIGHTING_PLANE layers
+ "LIGHTING_MASK_LAYER" = LIGHTING_MASK_LAYER,
+ "LIGHTING_PRIMARY_LAYER" = LIGHTING_PRIMARY_LAYER,
+ "LIGHTING_ABOVE_ALL" = LIGHTING_ABOVE_ALL,
+ "EMISSIVE_LAYER_UNBLOCKABLE" = EMISSIVE_LAYER_UNBLOCKABLE,
+ // Mob layers
+ "MUTATIONS_LAYER" = -MUTATIONS_LAYER,
+ "BODY_BEHIND_LAYER" = -BODY_BEHIND_LAYER,
+ "BODYPARTS_LOW_LAYER" = -BODYPARTS_LOW_LAYER,
+ "BODYPARTS_LAYER" = -BODYPARTS_LAYER,
+ "BODY_ADJ_LAYER" = -BODY_ADJ_LAYER,
+ "BODY_LAYER" = -BODY_LAYER,
+ "EYES_LAYER" = -EYES_LAYER,
+ "FRONT_MUTATIONS_LAYER" = -FRONT_MUTATIONS_LAYER,
+ "DAMAGE_LAYER" = -DAMAGE_LAYER,
+ "UNIFORM_LAYER" = -UNIFORM_LAYER,
+ "ID_LAYER" = -ID_LAYER,
+ "ID_CARD_LAYER" = -ID_CARD_LAYER,
+ "BODYPARTS_HIGH_LAYER" = -BODYPARTS_HIGH_LAYER,
+ "GLOVES_LAYER" = -GLOVES_LAYER,
+ "SHOES_LAYER" = -SHOES_LAYER,
+ "LOW_FACEMASK_LAYER" = -LOW_FACEMASK_LAYER,
+ "EARS_LAYER" = -EARS_LAYER,
+ "LOW_NECK_LAYER" = -LOW_NECK_LAYER,
+ "SUIT_LAYER" = -SUIT_LAYER,
+ "GLASSES_LAYER" = -GLASSES_LAYER,
+ "BELT_LAYER" = -BELT_LAYER,
+ "SUIT_STORE_LAYER" = -SUIT_STORE_LAYER,
+ "NECK_LAYER" = -NECK_LAYER,
+ "BACK_LAYER" = -BACK_LAYER,
+ "BENEATH_HAIR_LAYER" = -BENEATH_HAIR_LAYER,
+ "HAIR_LAYER" = -HAIR_LAYER,
+ "FACEMASK_LAYER" = -FACEMASK_LAYER,
+ "HEAD_LAYER" = -HEAD_LAYER,
+ "OUTER_HAIR_LAYER" = -OUTER_HAIR_LAYER,
+ "HANDCUFF_LAYER" = -HANDCUFF_LAYER,
+ "LEGCUFF_LAYER" = -LEGCUFF_LAYER,
+ "HANDS_LAYER" = -HANDS_LAYER,
+ "BODY_FRONT_LAYER" = -BODY_FRONT_LAYER,
+ "ABOVE_BODY_FRONT_GLASSES_LAYER" = -ABOVE_BODY_FRONT_GLASSES_LAYER,
+ "ABOVE_BODY_FRONT_HEAD_LAYER" = -ABOVE_BODY_FRONT_HEAD_LAYER,
+ "WOUND_LAYER" = -WOUND_LAYER,
+ "HALO_LAYER" = -HALO_LAYER,
+))
diff --git a/code/datums/datumvars.dm b/code/datums/datumvars.dm
index 55d8e28b197..3d7fff25706 100644
--- a/code/datums/datumvars.dm
+++ b/code/datums/datumvars.dm
@@ -60,6 +60,9 @@ GLOBAL_LIST_INIT(vv_var_blacklist, list(
return FALSE
if(href_list[VV_HK_MODIFY_TRAITS])
usr.client.holder.modify_traits(src)
+ if(href_list[VV_HK_DEBUG_APPEARANCE]) // On base datum as it is shared by atoms and mutable_appearance/image VVs
+ usr.client.holder.appearance_debug.set_target(src)
+ usr.client.holder.appearance_debug.ui_interact(usr)
return TRUE
/datum/proc/vv_get_header()
diff --git a/code/game/atom/atom_vv.dm b/code/game/atom/atom_vv.dm
index 85d7ed011c7..d5a4b5ccd9d 100644
--- a/code/game/atom/atom_vv.dm
+++ b/code/game/atom/atom_vv.dm
@@ -11,6 +11,7 @@
if(curturf)
. += "Jump To"
VV_DROPDOWN_OPTION(VV_HK_MODIFY_TRANSFORM, "Modify Transform")
+ VV_DROPDOWN_OPTION(VV_HK_DEBUG_APPEARANCE, "Debug Appearance")
VV_DROPDOWN_OPTION(VV_HK_SPIN_ANIMATION, "SpinAnimation")
VV_DROPDOWN_OPTION(VV_HK_STOP_ALL_ANIMATIONS, "Stop All Animations")
VV_DROPDOWN_OPTION(VV_HK_SHOW_HIDDENPRINTS, "Show Hiddenprint log")
diff --git a/code/modules/admin/holder2.dm b/code/modules/admin/holder2.dm
index 18b96318f06..9db5efee0db 100644
--- a/code/modules/admin/holder2.dm
+++ b/code/modules/admin/holder2.dm
@@ -36,6 +36,7 @@ GLOBAL_PROTECT(href_token)
var/datum/particle_editor/particle_test
var/datum/colorblind_tester/color_test
var/datum/plane_master_debug/plane_debug
+ var/datum/appearance_debugger/appearance_debug
var/obj/machinery/computer/libraryconsole/admin_only_do_not_map_in_you_fucker/library_manager
var/datum/pathfind_debug/path_debug
var/datum/spawn_menu/spawn_menu
@@ -98,6 +99,7 @@ GLOBAL_PROTECT(href_token)
GLOB.admin_datums[target] = src
deadmined = FALSE
plane_debug = new(src)
+ appearance_debug = new(src)
if (GLOB.directory[target])
associate(GLOB.directory[target]) //find the client for a ckey if they are connected and associate them with us
@@ -109,6 +111,7 @@ GLOBAL_PROTECT(href_token)
GLOB.deadmins[target] = src
GLOB.admin_datums -= target
QDEL_NULL(plane_debug)
+ QDEL_NULL(appearance_debug)
QDEL_NULL(path_debug)
deadmined = TRUE
diff --git a/code/modules/admin/verbs/appearance_debugger/appearance_debugger.dm b/code/modules/admin/verbs/appearance_debugger/appearance_debugger.dm
new file mode 100644
index 00000000000..32c29a9e540
--- /dev/null
+++ b/code/modules/admin/verbs/appearance_debugger/appearance_debugger.dm
@@ -0,0 +1,243 @@
+/// Allows developers to see a breakdown of an atom or a specific target and edit some of the values
+/datum/appearance_debugger
+ var/datum/admins/owner
+ /// Currently debugged atom or mutable appearance, hence /datum
+ var/datum/debug_target
+ /// A list of copies of the currently debugged appearance and its children for access from the UI
+ var/list/mutable_appearance/appearance_copies
+ /// Assoc list of ref -> appearance as to prevent refreshing of dynamic appearances
+ var/list/mutable_appearance/appearance_cache
+ /// Mapview used to display the hovered over appearance
+ var/atom/movable/screen/map_view/proxy_view_hover
+ /// Mapview used to display the selected appearance
+ var/atom/movable/screen/map_view/proxy_view_selected
+ /// Assoc list of ref -> list of atoms to be displayed in said ref atom's vis_contents
+ var/list/atom/movable/fake_vis
+ /// Should we display a popup that the appearance in-game has updated?
+ var/update_warning = FALSE
+
+/datum/appearance_debugger/New(datum/admins/owner)
+ src.owner = owner
+ proxy_view_hover = new()
+ proxy_view_hover.generate_view("appearance_debugger_[REF(owner)]_proxy_hover")
+ proxy_view_selected = new()
+ proxy_view_selected.generate_view("appearance_debugger_[REF(owner)]_proxy_selected")
+
+/datum/appearance_debugger/Destroy()
+ if (owner)
+ owner.appearance_debug = null
+ owner = null
+ if (fake_vis)
+ for (var/ref_id in fake_vis)
+ var/list/all_fakes = fake_vis[ref_id]
+ QDEL_LIST(all_fakes)
+ fake_vis.Cut()
+ QDEL_NULL(proxy_view_hover)
+ QDEL_NULL(proxy_view_selected)
+ return ..()
+
+/datum/appearance_debugger/proc/get_appearance_data(atom/appearance_owner)
+ var/mutable_appearance/target = appearance_owner
+ if (isatom(appearance_owner))
+ target = appearance_cache["[REF(appearance_owner)]"] || new /mutable_appearance(appearance_owner.appearance)
+ appearance_cache["[REF(appearance_owner)]"] = target
+ if (!fake_vis["[REF(appearance_owner)]"] && (ismovable(appearance_owner) || isturf(appearance_owner)))
+ var/atom/movable/as_movable = appearance_owner
+ var/list/false_vis_contents = list()
+ fake_vis["[REF(appearance_owner)]"] = false_vis_contents
+ for (var/atom/something in (isturf(as_movable) ? (as_movable.vis_contents + as_movable.contents) : as_movable.vis_contents))
+ var/atom/movable/mimic = new()
+ mimic.appearance = new /mutable_appearance(something.appearance)
+ false_vis_contents += mimic
+
+ var/list/data = list(
+ "type" = isatom(appearance_owner) ? "atom" : (isimage(appearance_owner) ? "image" : "appearance"),
+ "alpha" = target.alpha,
+ "flags" = target.appearance_flags,
+ "blend_mode" = target.blend_mode,
+ "color" = target.color,
+ "dir" = target.dir,
+ "icon" = length("[target.icon]") ? "[target.icon]" : null,
+ "icon_state" = target.icon_state,
+ "invisibility" = target.invisibility,
+ "layer" = target.layer,
+ "name" = target.name,
+ "maptext" = target.maptext,
+ "maptext_width" = target.maptext_width,
+ "maptext_height" = target.maptext_height,
+ "maptext_x" = target.maptext_x,
+ "maptext_y" = target.maptext_y,
+ "mouse_opacity" = target.mouse_opacity,
+ "pixel_x" = target.pixel_x,
+ "pixel_y" = target.pixel_y,
+ "pixel_w" = target.pixel_w,
+ "pixel_z" = target.pixel_z,
+ "plane" = target.plane,
+ "plane_true" = PLANE_TO_TRUE(target.plane),
+ "render_source" = target.render_source,
+ "render_target" = target.render_target,
+ "screen_loc" = target.screen_loc,
+ )
+
+ if (!(target in appearance_copies))
+ appearance_copies += target
+ data["id"] = length(appearance_copies)
+ else
+ data["id"] = appearance_copies.Find(target)
+
+ var/list/filter_data = list()
+ for (var/list/our_filter as anything in target.filter_data)
+ filter_data += list(our_filter)
+ data["filters"] = filter_data
+
+ var/list/underlay_data = list()
+ for (var/mutable_appearance/underlay as anything in target.underlays)
+ underlay_data += list(get_appearance_data(underlay))
+ data["underlays"] = underlay_data
+
+ var/list/overlay_data = list()
+ for (var/mutable_appearance/overlay as anything in target.overlays)
+ overlay_data += list(get_appearance_data(overlay))
+ data["overlays"] = overlay_data
+
+ // Display previews if it is either an instance icon or a file and we have icon_state set
+ if ((isicon(target.icon) && !isfile(target.icon)) || (target.icon && target.icon_state))
+ var/icon/used_icon = icon(target.icon, target.icon_state, (isimage(target) && target.dir) ? target.dir : SOUTH, frame = 1)
+ if (istext(target.color))
+ used_icon.Blend(target.color, ICON_MULTIPLY)
+ data["embed_icon"] = icon2base64(used_icon)
+
+ data["transform"] = list(target.transform.a, target.transform.b, target.transform.c, target.transform.d, target.transform.e, target.transform.f)
+ // Turfs aren't movables but they still can have vis_flags/contents so lets just count them as such
+ if (ismovable(appearance_owner) || isturf(appearance_owner))
+ var/atom/movable/as_movable = appearance_owner
+ data["vis_flags"] = as_movable.vis_flags
+ // Maybe should be cached but I'm too lazy and don't think this'll matter enough
+ var/list/vis_data = list()
+ // Turfs also inherit their contents as vis_contents
+ for (var/atom/vis_thing as anything in isturf(as_movable) ? (as_movable.vis_contents + as_movable.contents) : as_movable.vis_contents)
+ vis_data += list(get_appearance_data(vis_thing))
+ data["vis_contents"] = vis_data
+
+ // Handle all dynamically modified layers
+ if (target.layer > FLOOR_EMISSIVE_START_LAYER && target.layer < FLOOR_EMISSIVE_END_LAYER)
+ data["layer_text_override"] = "FLOOR_EMISSIVE_LAYER (+[target.layer - FLOOR_EMISSIVE_START_LAYER])"
+ else if (target.layer > GAS_PIPE_HIDDEN_LAYER && target.layer < GAS_PIPE_HIDDEN_LAYER + 0.006)
+ data["layer_text_override"] = "GAS_PIPE_HIDDEN_LAYER (+[target.layer - GAS_PIPE_HIDDEN_LAYER])"
+ else if (target.layer > GAS_PIPE_VISIBLE_LAYER && target.layer < GAS_PIPE_VISIBLE_LAYER + 0.006)
+ data["layer_text_override"] = "GAS_PIPE_VISIBLE_LAYER (+[target.layer - GAS_PIPE_VISIBLE_LAYER])"
+ else if (target.layer > PLUMBING_PIPE_VISIBILE_LAYER && target.layer < PLUMBING_PIPE_VISIBILE_LAYER + (FIFTH_DUCT_LAYER * 2 / 3333))
+ data["layer_text_override"] = "PLUMBING_PIPE_VISIBILE_LAYER (+[target.layer - PLUMBING_PIPE_VISIBILE_LAYER])"
+
+ return data
+
+/datum/appearance_debugger/ui_data(mob/user)
+ return list(
+ "updateWarning" = update_warning,
+ )
+
+/datum/appearance_debugger/ui_static_data(mob/user)
+ return list(
+ "mainAppearance" = get_appearance_data(debug_target),
+ "planeToText" = GLOB.admin_readable_planes,
+ "layerToText" = GLOB.admin_readable_layers,
+ "flagsToText" = get_valid_bitflags("appearance_flags"),
+ "visToText" = get_valid_bitflags("vis_flags"),
+ "blendToText" = GLOB.blend_names,
+ "mapRefHover" = proxy_view_hover.assigned_map,
+ "mapRefSelected" = proxy_view_selected.assigned_map,
+ )
+
+/datum/appearance_debugger/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return
+ switch(action)
+ if("swapMapViewHover")
+ var/appearance_id = text2num(params["id"])
+ proxy_view_hover.appearance = appearance_copies[appearance_id]
+
+ for (var/atom/movable/something as anything in proxy_view_hover.vis_contents)
+ proxy_view_hover.vis_contents -= something
+ for (var/ref_id in appearance_cache)
+ if (appearance_copies[appearance_id] != appearance_cache[ref_id])
+ continue
+ if (fake_vis[ref_id])
+ proxy_view_hover.vis_contents |= fake_vis[ref_id]
+
+ // Needs screenloc to be set since we're setting the appearance and carrying over target's screenloc
+ proxy_view_hover.set_position(1, 1)
+
+ if("swapMapViewSelected")
+ var/appearance_id = text2num(params["id"])
+ proxy_view_selected.appearance = appearance_copies[appearance_id]
+
+ for (var/atom/movable/something as anything in proxy_view_selected.vis_contents)
+ proxy_view_selected.vis_contents -= something
+ for (var/ref_id in appearance_cache)
+ if (appearance_copies[appearance_id] != appearance_cache[ref_id])
+ continue
+ if (fake_vis[ref_id])
+ proxy_view_selected.vis_contents |= fake_vis[ref_id]
+
+ proxy_view_selected.set_position(1, 1)
+
+ if("refreshAppearance")
+ update_warning = FALSE
+ appearance_copies = list()
+ appearance_cache = list()
+ for (var/ref_id in fake_vis)
+ var/list/all_fakes = fake_vis[ref_id]
+ QDEL_LIST(all_fakes)
+ fake_vis = list()
+ update_static_data_for_all_viewers()
+
+ if("vvAppearance")
+ if (!check_rights(R_VAREDIT))
+ return
+ var/appearance_id = text2num(params["id"])
+ usr.client.debug_variables(appearance_copies[appearance_id])
+
+/datum/appearance_debugger/ui_assets(mob/user)
+ return list(get_asset_datum(/datum/asset/simple/plane_background))
+
+/datum/appearance_debugger/ui_state(mob/user)
+ return ADMIN_STATE(R_DEBUG)
+
+/datum/appearance_debugger/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "AppearanceDebug")
+ ui.open()
+ proxy_view_hover.display_to(user, ui.window)
+ proxy_view_selected.display_to(user, ui.window)
+
+/datum/appearance_debugger/ui_close(mob/user)
+ . = ..()
+ // Reset appearances when the UI is closed
+ proxy_view_hover.appearance = new /mutable_appearance()
+ proxy_view_selected.appearance = new /mutable_appearance()
+ proxy_view_hover.vis_contents.Cut()
+ proxy_view_selected.vis_contents.Cut()
+ if (isatom(debug_target))
+ UnregisterSignal(debug_target, COMSIG_ATOM_UPDATE_APPEARANCE)
+
+/datum/appearance_debugger/proc/set_target(mutable_appearance/new_target)
+ if (isatom(debug_target))
+ UnregisterSignal(debug_target, COMSIG_ATOM_UPDATE_APPEARANCE)
+ update_warning = FALSE
+ // Can be an atom!
+ debug_target = new_target
+ if (isatom(debug_target))
+ RegisterSignal(debug_target, COMSIG_ATOM_UPDATE_APPEARANCE, PROC_REF(warn_update))
+ appearance_copies = list()
+ appearance_cache = list()
+ for (var/ref_id in fake_vis)
+ var/list/all_fakes = fake_vis[ref_id]
+ QDEL_LIST(all_fakes)
+ fake_vis = list()
+ update_static_data_for_all_viewers()
+
+/datum/appearance_debugger/proc/warn_update()
+ SIGNAL_HANDLER
+ update_warning = TRUE
diff --git a/code/modules/admin/view_variables/debug_variable_appearance.dm b/code/modules/admin/view_variables/debug_variable_appearance.dm
index d92efcb7b2a..9f801066998 100644
--- a/code/modules/admin/view_variables/debug_variable_appearance.dm
+++ b/code/modules/admin/view_variables/debug_variable_appearance.dm
@@ -64,6 +64,10 @@
var/value = vars[var_name]
return "
(READ ONLY) [var_name] = [_debug_variable_value(var_name, value, 0, src, sanitize = TRUE, display_flags = NONE)]"
+/mutable_appearance/vv_get_dropdown()
+ . = ..()
+ VV_DROPDOWN_OPTION(VV_HK_DEBUG_APPEARANCE, "Debug Appearance")
+
/mutable_appearance/appearance_mirror/vv_get_dropdown()
SHOULD_CALL_PARENT(FALSE)
@@ -74,6 +78,7 @@
VV_DROPDOWN_OPTION(VV_HK_TAG, "Tag Datum")
VV_DROPDOWN_OPTION(VV_HK_DELETE, "Delete")
VV_DROPDOWN_OPTION(VV_HK_EXPOSE, "Show VV To Player")
+ VV_DROPDOWN_OPTION(VV_HK_DEBUG_APPEARANCE, "Debug Appearance")
/proc/get_vv_appearance(mutable_appearance/appearance) // actually appearance yadeeyada
return new /mutable_appearance/appearance_mirror(appearance)
diff --git a/tgstation.dme b/tgstation.dme
index 496de2a3b74..9d6ca244378 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -554,6 +554,7 @@
#include "code\_globalvars\configuration.dm"
#include "code\_globalvars\game_modes.dm"
#include "code\_globalvars\genetics.dm"
+#include "code\_globalvars\layers.dm"
#include "code\_globalvars\lighting.dm"
#include "code\_globalvars\logging.dm"
#include "code\_globalvars\phobias.dm"
@@ -3277,6 +3278,7 @@
#include "code\modules\admin\verbs\spawnobjasmob.dm"
#include "code\modules\admin\verbs\special_verbs.dm"
#include "code\modules\admin\verbs\sprite_auditor.dm"
+#include "code\modules\admin\verbs\appearance_debugger\appearance_debugger.dm"
#include "code\modules\admin\verbs\lua\_wrappers.dm"
#include "code\modules\admin\verbs\lua\helpers.dm"
#include "code\modules\admin\verbs\lua\lua_editor.dm"
diff --git a/tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceBox.tsx b/tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceBox.tsx
new file mode 100644
index 00000000000..3264c9df34e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceBox.tsx
@@ -0,0 +1,134 @@
+import { Box, Button, Image, Stack, Tooltip } from 'tgui-core/components';
+import { classes } from 'tgui-core/react';
+import type { Coordinates } from '../common/Connections';
+import {
+ getReadableLayer,
+ getReadablePlane,
+ isEmissive,
+ isEmissiveBlocker,
+} from '.';
+import {
+ APPEARANCE_FLAGS,
+ type Appearance,
+ AppearanceType,
+ HiddenState,
+} from './types';
+import { useAppearanceDebugContext } from './useAppearanceDebug';
+
+export type AppearanceProps = {
+ appearance: Appearance;
+ position: Coordinates;
+ onClick: React.MouseEventHandler;
+};
+
+export function AppearanceBox(props: AppearanceProps) {
+ const { appearance, position, onClick } = props;
+ const { planeToText, layerToText, act } = useAppearanceDebugContext();
+
+ return (
+ <>
+ {!!(
+ appearance.data.flags &
+ (APPEARANCE_FLAGS.KEEP_APART | APPEARANCE_FLAGS.KEEP_TOGETHER)
+ ) && (
+
+ act('swapMapViewHover', { id: appearance.data.id })
+ }
+ style={{
+ zIndex: -(999 - appearance.depth),
+ border: `3px solid ${appearance.data.flags & APPEARANCE_FLAGS.KEEP_APART ? (appearance.data.flags & APPEARANCE_FLAGS.KEEP_TOGETHER ? '#2a7dc6' : '#107e2e') : '#e9cb0c'}`,
+ borderRadius: '5px',
+ padding: '5px',
+ backgroundColor: `${appearance.data.flags & APPEARANCE_FLAGS.KEEP_APART ? (appearance.data.flags & APPEARANCE_FLAGS.KEEP_TOGETHER ? '#223c54' : '#13381c') : '#544b15'}`,
+ }}
+ >
+ {!!(appearance.data.flags & APPEARANCE_FLAGS.KEEP_APART) &&
+ 'KEEP_APART'}
+ {!!(
+ appearance.data.flags & APPEARANCE_FLAGS.KEEP_APART &&
+ appearance.data.flags & APPEARANCE_FLAGS.KEEP_TOGETHER
+ ) && ' | '}
+ {!!(appearance.data.flags & APPEARANCE_FLAGS.KEEP_TOGETHER) &&
+ 'KEEP_TOGETHER'}
+
+ )}
+ act('swapMapViewHover', { id: appearance.data.id })}
+ onClick={onClick}
+ style={{ zIndex: 1 }}
+ opacity={appearance.hidden === HiddenState.VisibleChild ? 0.7 : 1}
+ >
+
+
+
+ {appearance.data.name || appearance.data.icon_state}
+ {isEmissive(appearance)
+ ? ' (Emissive)'
+ : isEmissiveBlocker(appearance)
+ ? ' (Emissive Blocker)'
+ : ''}
+
+
+
+
+
+
+
+
+ {appearance.data.icon && (
+ icon: {appearance.data.icon}
+ )}
+ {appearance.data.icon_state && (
+ icon_state: {appearance.data.icon_state}
+ )}
+
+ layer:{` ${getReadableLayer(appearance, layerToText)}`}
+
+
+
+ plane:{` ${getReadablePlane(appearance, planeToText)}`}
+
+
+ {!!appearance.data.embed_icon && (
+
+
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceInfo.tsx b/tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceInfo.tsx
new file mode 100644
index 00000000000..7e3f0994520
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AppearanceDebug/AppearanceInfo.tsx
@@ -0,0 +1,185 @@
+import {
+ Button,
+ ByondUi,
+ LabeledList,
+ Section,
+ Tooltip,
+} from 'tgui-core/components';
+import { getReadableLayer, getReadablePlane } from '.';
+import { type Appearance, AppearanceType, DIR, MOUSE_OPACITY } from './types';
+import { useAppearanceDebugContext } from './useAppearanceDebug';
+
+export type AppearanceInfoProps = {
+ appearance: Appearance;
+ onClose: React.MouseEventHandler;
+};
+
+export function AppearanceInfo(props: AppearanceInfoProps) {
+ const { appearance, onClose } = props;
+ const {
+ planeToText,
+ layerToText,
+ flagsToText,
+ visToText,
+ blendToText,
+ mapRefSelected,
+ act,
+ } = useAppearanceDebugContext();
+ return (
+ }
+ >
+
+
+
+
+ {appearance.data.type === AppearanceType.Atom
+ ? 'Atom'
+ : appearance.data.type === AppearanceType.Image
+ ? 'Image'
+ : 'Mutable Appearance'}
+
+ {!!appearance.data.icon && (
+
+ {appearance.data.icon}
+ {appearance.inherited_icon ? ' (Inherited)' : ''}
+
+ )}
+ {!!appearance.data.icon_state && (
+
+ {appearance.data.icon_state}
+ {appearance.inherited_icon_state ? ' (Inherited)' : ''}
+
+ )}
+
+ {appearance.data.alpha}
+ {appearance.total_alpha !== appearance.data.alpha
+ ? ` (Displayed as ${appearance.total_alpha})`
+ : ''}
+
+
+ {Object.entries(flagsToText)
+ .filter((x) => appearance.data.flags & x[1])
+ .map((x) => x[0])
+ .join(' | ') || 'NONE'}
+
+
+ {Object.entries(blendToText)
+ .find((x) => appearance.data.blend_mode.toString() === x[0])
+ ?.at(1) || 'ERROR'}
+
+ {!!appearance.data.color && (
+
+ {typeof appearance.data.color === 'string'
+ ? appearance.data.color
+ : `[${appearance.data.color.join(', ')}]`}
+
+ )}
+
+ {`${
+ Object.entries(DIR)
+ .filter((x) => appearance.data.dir & x[1])
+ .map((x) => x[0])
+ .join(' | ') || 'NONE'
+ } (${appearance.data.dir})`}
+ {appearance.inherited_dir ? ' (Inherited)' : ''}
+
+ {!!appearance.data.filters?.length && (
+
+ {appearance.data.filters
+ .map((x) => `${x.name} (${x.type})`)
+ .join(', ')}
+
+ )}
+
+ {appearance.data.invisibility}
+
+
+ {getReadableLayer(appearance, layerToText)}
+ {appearance.inherited_layer ? ' (Inherited)' : ''}
+
+
+
+ {getReadablePlane(appearance, planeToText)}
+ {appearance.inherited_plane ? ' (Inherited)' : ''}
+
+
+ {!!appearance.data.maptext && (
+ <>
+
+ {appearance.data.maptext}
+
+
+ {appearance.data.maptext_width}/{appearance.data.maptext_height}
+
+
+ {'['}
+ {appearance.data.maptext_x}
+ {', '}
+ {appearance.data.maptext_y}
+ {']'}
+
+ >
+ )}
+
+ {Object.entries(MOUSE_OPACITY)
+ .find((x) => appearance.data.mouse_opacity === x[1])
+ ?.at(0) || 'ERROR'}
+
+
+ {'['}
+ {appearance.data.pixel_x}
+ {', '}
+ {appearance.data.pixel_y}
+ {', '}
+ {appearance.data.pixel_w}
+ {', '}
+ {appearance.data.pixel_z}
+ {']'}
+
+ {!!appearance.data.render_source && (
+
+ {appearance.data.render_source}
+
+ )}
+ {!!appearance.data.render_target && (
+
+ {appearance.data.render_target}
+
+ )}
+ {!!appearance.data.screen_loc && (
+
+ {appearance.data.screen_loc}
+
+ )}
+
+ {`[${appearance.data.transform.join(', ')}]`}
+
+ {!!(appearance.data.vis_flags !== null) && (
+
+ {Object.entries(visToText)
+ .filter((x) => (appearance.data.vis_flags as number) & x[1])
+ .map((x) => x[0])
+ .join(' | ') || 'NONE'}
+
+ )}
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/AppearanceDebug/index.tsx b/tgui/packages/tgui/interfaces/AppearanceDebug/index.tsx
new file mode 100644
index 00000000000..a7ffab3210d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AppearanceDebug/index.tsx
@@ -0,0 +1,670 @@
+import { useState } from 'react';
+import { resolveAsset } from 'tgui/assets';
+import { useBackend } from 'tgui/backend';
+import {
+ Box,
+ Button,
+ ByondUi,
+ Dropdown,
+ InfinitePlane,
+ Stack,
+} from 'tgui-core/components';
+import { Window } from '../../layouts';
+import {
+ type Connection,
+ Connections,
+ type Coordinates,
+} from '../common/Connections';
+import { AppearanceBox } from './AppearanceBox';
+import { AppearanceInfo } from './AppearanceInfo';
+import type {
+ Appearance,
+ AppearanceData,
+ AppearanceDebugData,
+ AppearanceMap,
+} from './types';
+import {
+ APPEARANCE_FLAGS,
+ AppearanceParentType,
+ AppearanceType,
+ HiddenState,
+ VIS_FLAGS,
+} from './types';
+import { AppearanceDebugContext } from './useAppearanceDebug';
+
+function textWidth(text: string, font: string, fontsize: number) {
+ font = `${fontsize}px ${font}`;
+ const c = document.createElement('canvas');
+ const ctx = c.getContext('2d') as CanvasRenderingContext2D;
+ ctx.font = font;
+ return ctx.measureText(text).width;
+}
+
+function mapAppearance(
+ appearance_data: AppearanceData,
+ parent: Appearance | null = null,
+ parentType: AppearanceParentType = AppearanceParentType.None,
+ depth: number = 0,
+ appearances: AppearanceMap = {},
+ planeFilter: number | null,
+ hideEmissives: boolean,
+ keepTogether: boolean,
+) {
+ if (appearance_data.flags & APPEARANCE_FLAGS.KEEP_APART) keepTogether = false;
+
+ const appearance: Appearance = {
+ data: appearance_data,
+ underlays: null,
+ overlays: null,
+ parent: parent,
+ hidden: HiddenState.Visible,
+ boundingBox: [
+ { x: 0, y: 0 },
+ { x: 0, y: 0 },
+ ],
+ parentType: parentType,
+ renderTargetTo: null,
+ relativePosition: { x: 0, y: 0 },
+ depth: depth,
+ inherited_icon: !!(
+ (appearance_data.vis_flags || 0) & VIS_FLAGS.VIS_INHERIT_ICON
+ ),
+ inherited_icon_state: !!(
+ (appearance_data.vis_flags || 0) & VIS_FLAGS.VIS_INHERIT_ICON_STATE
+ ),
+ inherited_layer: !!(
+ (appearance_data.vis_flags || 0) & VIS_FLAGS.VIS_INHERIT_LAYER
+ ),
+ inherited_plane: !!(
+ (appearance_data.vis_flags || 0) & VIS_FLAGS.VIS_INHERIT_PLANE
+ ),
+ inherited_dir:
+ !!((appearance_data.vis_flags || 0) & VIS_FLAGS.VIS_INHERIT_DIR) ||
+ keepTogether,
+ total_alpha:
+ (appearance_data.flags & APPEARANCE_FLAGS.RESET_ALPHA && !keepTogether) ||
+ !parent
+ ? appearance_data.alpha
+ : appearance_data.alpha * (parent.total_alpha / 255),
+ };
+ if (
+ hideEmissives &&
+ (isEmissive(appearance) || isEmissiveBlocker(appearance))
+ )
+ appearance.hidden = HiddenState.Hidden;
+ else if (planeFilter !== null && appearance.data.plane_true !== planeFilter)
+ appearance.hidden = HiddenState.Hidden;
+ appearances[appearance_data.id] = appearance;
+ let underlays = appearance_data.underlays;
+ if (appearance_data.vis_contents)
+ underlays = underlays.concat(
+ appearance_data.vis_contents.filter(
+ (x) => x.vis_flags && x.vis_flags & VIS_FLAGS.VIS_UNDERLAY,
+ ),
+ );
+ if (underlays.length > 0) {
+ appearance.underlays = underlays
+ .map((data) =>
+ mapAppearance(
+ data,
+ appearance,
+ AppearanceParentType.Underlay,
+ depth + 1,
+ appearances,
+ planeFilter,
+ hideEmissives,
+ keepTogether ||
+ !!(appearance.data.flags & APPEARANCE_FLAGS.KEEP_TOGETHER),
+ ),
+ )
+ .sort((a, b) =>
+ a.data.plane === b.data.plane
+ ? a.data.layer - b.data.layer
+ : a.data.plane - b.data.plane,
+ );
+ if (
+ appearance.hidden === HiddenState.Hidden &&
+ appearance.underlays.filter((x) => x.hidden !== HiddenState.Hidden)
+ .length > 0
+ )
+ appearance.hidden = HiddenState.VisibleChild;
+ }
+ let overlays = appearance_data.overlays;
+ if (appearance_data.vis_contents)
+ overlays = overlays.concat(
+ appearance_data.vis_contents.filter(
+ (x) => !x.vis_flags || !(x.vis_flags & VIS_FLAGS.VIS_UNDERLAY),
+ ),
+ );
+ if (overlays.length > 0) {
+ appearance.overlays = overlays
+ .map((data) =>
+ mapAppearance(
+ data,
+ appearance,
+ AppearanceParentType.Overlay,
+ depth + 1,
+ appearances,
+ planeFilter,
+ hideEmissives,
+ keepTogether ||
+ !!(appearance.data.flags & APPEARANCE_FLAGS.KEEP_TOGETHER),
+ ),
+ )
+ // vis_contents get priority by layer over overlays, but not by plane
+ .sort((a, b) =>
+ a.data.plane === b.data.plane
+ ? a.data.type === AppearanceType.Atom &&
+ b.data.type !== AppearanceType.Atom
+ ? 1
+ : a.data.type !== AppearanceType.Atom &&
+ b.data.type === AppearanceType.Atom
+ ? -1
+ : a.data.layer - b.data.layer
+ : a.data.plane - b.data.plane,
+ );
+ if (
+ appearance.hidden === HiddenState.Hidden &&
+ appearance.overlays.filter((x) => x.hidden !== HiddenState.Hidden)
+ .length > 0
+ )
+ appearance.hidden = HiddenState.VisibleChild;
+ }
+ return appearance;
+}
+
+function getAppearanceHeight(appearance: Appearance) {
+ const TITLEBAR = 27;
+ const COLUMN_BREAK = 20;
+ let rows = 0;
+ if (appearance.data.icon) rows++;
+ if (appearance.data.icon_state) rows++;
+ if (appearance.data.layer) rows++;
+ if (appearance.data.plane) rows++;
+ let height = COLUMN_BREAK + TITLEBAR + rows * 15 + (rows - 1) * 6 - 8;
+ if (appearance.data.embed_icon) height += 64 + 6;
+ return height;
+}
+
+export function isEmissive(appearance: Appearance) {
+ const EMISSIVE_PLANE = 13;
+ // Emissives are always constant color matrixes
+ if (
+ appearance.data.plane_true !== EMISSIVE_PLANE ||
+ !Array.isArray(appearance.data.color)
+ )
+ return false;
+ const colorMatrix = appearance.data.color as number[];
+ for (let i = 0; i < colorMatrix.length; i++) {
+ if (i === 15 && colorMatrix[i] !== 1) return false;
+ else if (colorMatrix[i] !== 0 && (i < 15 || i > 18)) return false;
+ }
+ return true;
+}
+
+export function isEmissiveBlocker(appearance: Appearance) {
+ const EMISSIVE_PLANE = 13;
+ // Emissive blockers can be a constant matrix or pure black
+ if (appearance.data.plane_true !== EMISSIVE_PLANE || !appearance.data.color)
+ return false;
+ if (appearance.data.color === '#000000') return true;
+ if (!Array.isArray(appearance.data.color)) return false;
+ const colorMatrix = appearance.data.color as number[];
+ for (let i = 0; i < colorMatrix.length; i++) {
+ if (colorMatrix[i] !== 0 && i !== 15) return false;
+ }
+ return true;
+}
+
+function getAppearanceWidth(
+ appearance: Appearance,
+ layerToText: Record,
+ planeToText: Record,
+) {
+ return Math.max(
+ textWidth(
+ (appearance.data.name || appearance.data.icon_state) +
+ (isEmissive(appearance)
+ ? ' (Emissive)'
+ : isEmissiveBlocker(appearance)
+ ? ' (Emissive Blocker)'
+ : ''),
+ 'Verdana, Geneva',
+ 12,
+ ) + 18,
+ textWidth(`icon: ${appearance.data.icon}`, 'Verdana, Geneva', 12) + 12,
+ textWidth(
+ `icon_state: ${appearance.data.icon_state}`,
+ 'Verdana, Geneva',
+ 12,
+ ) + 12,
+ layerToText &&
+ textWidth(
+ `layer: ${getReadableLayer(appearance, layerToText)}`,
+ 'Verdana, Geneva',
+ 12,
+ ) + 12,
+ planeToText &&
+ textWidth(
+ `plane: ${getReadablePlane(appearance, planeToText)}`,
+ 'Verdana, Geneva',
+ 12,
+ ) + 12,
+ 150,
+ );
+}
+
+export function getReadableLayer(
+ appearance: Appearance,
+ layerToText: Record,
+) {
+ return (
+ (appearance.data.layer_text_override ||
+ Object.keys(layerToText).find(
+ (x) => layerToText[x] === appearance.data.layer,
+ ) ||
+ '') + (appearance.data.layer !== -1 ? ` (${appearance.data.layer})` : '')
+ );
+}
+
+export function getReadablePlane(
+ appearance: Appearance,
+ planeToText: Record,
+) {
+ return (
+ (Object.keys(planeToText).find(
+ (x) => planeToText[x] === appearance.data.plane_true,
+ ) || appearance.data.plane_true.toString()) +
+ (appearance.data.plane !== -32767 ? ` (${appearance.data.plane})` : '')
+ );
+}
+
+function parseAppearanceData(
+ mainAppearance: AppearanceData,
+ layerToText: Record,
+ planeToText: Record,
+ planeFilter: number | null,
+ hideEmissives: boolean,
+) {
+ const appearances: AppearanceMap = {};
+ // Recursively map all appearances
+ const primary: Appearance = mapAppearance(
+ mainAppearance,
+ null,
+ AppearanceParentType.None,
+ 0,
+ appearances,
+ planeFilter,
+ hideEmissives,
+ !!(mainAppearance.flags & APPEARANCE_FLAGS.KEEP_TOGETHER),
+ );
+
+ const sourceMap: Record = {};
+ Object.values(appearances).forEach((element) => {
+ if (element.data.render_target)
+ sourceMap[element.data.render_target] = element;
+ });
+
+ Object.values(appearances).forEach((element) => {
+ if (element.data.render_source && element.data.render_source in sourceMap) {
+ if (sourceMap[element.data.render_source].renderTargetTo === null)
+ sourceMap[element.data.render_source].renderTargetTo = [];
+ sourceMap[element.data.render_source].renderTargetTo?.push(element);
+ }
+ });
+
+ const STACK_COLUMN_GAP = 60;
+ const KEEP_APART_TOGETHER_GAP = 18;
+ const KEEP_APART_TOGETHER_GAP_TOP = 30;
+
+ // Returns a *relative* bounding box, includes KEEP_TOGETHER/APART borders!
+ function getBoundingBox(appearance: Appearance): [Coordinates, Coordinates] {
+ let minX = 0;
+ let minY = 0;
+ let maxX = getAppearanceWidth(appearance, layerToText, planeToText);
+ let maxY = getAppearanceHeight(appearance);
+
+ if (appearance.underlays) {
+ for (let i = 0; i < appearance.underlays.length; i++) {
+ const underlay = appearance.underlays[i];
+ if (underlay.hidden === HiddenState.Hidden) continue;
+ const underlayBox = getBoundingBox(underlay);
+ minX = Math.min(minX, underlayBox[0].x + underlay.relativePosition.x);
+ minY = Math.min(minY, underlayBox[0].y + underlay.relativePosition.y);
+ // Shouldn't happen with maxX but just in case
+ maxX = Math.max(maxX, underlayBox[1].x + underlay.relativePosition.x);
+ maxY = Math.max(maxY, underlayBox[1].y + underlay.relativePosition.y);
+ }
+ }
+
+ if (appearance.overlays) {
+ for (let i = 0; i < appearance.overlays.length; i++) {
+ const overlay = appearance.overlays[i];
+ if (overlay.hidden === HiddenState.Hidden) continue;
+ const overlayBox = getBoundingBox(overlay);
+ minX = Math.min(minX, overlayBox[0].x + overlay.relativePosition.x);
+ minY = Math.min(minY, overlayBox[0].y + overlay.relativePosition.y);
+ // Shouldn't happen with maxX but just in case
+ maxX = Math.max(maxX, overlayBox[1].x + overlay.relativePosition.x);
+ maxY = Math.max(maxY, overlayBox[1].y + overlay.relativePosition.y);
+ }
+ }
+
+ if (
+ appearance.data.flags &
+ (APPEARANCE_FLAGS.KEEP_TOGETHER | APPEARANCE_FLAGS.KEEP_APART)
+ ) {
+ minX -= KEEP_APART_TOGETHER_GAP;
+ minY -= KEEP_APART_TOGETHER_GAP_TOP;
+ maxX += KEEP_APART_TOGETHER_GAP;
+ maxY += KEEP_APART_TOGETHER_GAP;
+ }
+
+ return [
+ { x: minX, y: minY },
+ { x: maxX, y: maxY },
+ ];
+ }
+
+ // By recursing through our children we can have them position their
+ // children's relative positions, and then position them based on said
+ // children's positions and bounding boxes when going back
+ function positionChildren(appearance: Appearance) {
+ const VERTICAL_APPEARANCE_GAP = 15;
+ const CENTRAL_APPEARANCE_GAP = 30;
+
+ if (appearance.overlays) {
+ let minHeight =
+ -getAppearanceHeight(appearance) / 2 + CENTRAL_APPEARANCE_GAP / 2;
+ let totalOverlayHeight = 0;
+ for (let i = 0; i < appearance.overlays.length; i++) {
+ const overlay = appearance.overlays[i];
+ if (overlay.hidden === HiddenState.Hidden) continue;
+ positionChildren(overlay);
+ const overlayBounds = getBoundingBox(overlay);
+ overlay.boundingBox = overlayBounds;
+ overlay.relativePosition.x =
+ -STACK_COLUMN_GAP -
+ getAppearanceWidth(overlay, layerToText, planeToText);
+ overlay.relativePosition.y = -minHeight - overlayBounds[1].y;
+ const totalHeight =
+ overlayBounds[1].y - overlayBounds[0].y + VERTICAL_APPEARANCE_GAP;
+ minHeight += totalHeight;
+ totalOverlayHeight += totalHeight;
+ }
+ // If we don't have any underlays, shift all overlays down
+ if (
+ !appearance.underlays?.filter((x) => x.hidden !== HiddenState.Hidden)
+ .length
+ ) {
+ const staticShift =
+ CENTRAL_APPEARANCE_GAP / 2 +
+ (totalOverlayHeight - VERTICAL_APPEARANCE_GAP) / 2;
+ for (let i = 0; i < appearance.overlays.length; i++) {
+ appearance.overlays[i].relativePosition.y += staticShift;
+ }
+ }
+ }
+
+ if (appearance.underlays) {
+ let minHeight =
+ getAppearanceHeight(appearance) / 2 - CENTRAL_APPEARANCE_GAP / 2;
+ let totalUnderlayHeight = 0;
+ for (let i = 0; i < appearance.underlays.length; i++) {
+ const underlay = appearance.underlays[i];
+ if (underlay.hidden === HiddenState.Hidden) continue;
+ positionChildren(underlay);
+ const underlayBounds = getBoundingBox(underlay);
+ underlay.boundingBox = underlayBounds;
+ underlay.relativePosition.x =
+ -STACK_COLUMN_GAP -
+ getAppearanceWidth(underlay, layerToText, planeToText);
+ underlay.relativePosition.y = minHeight + getAppearanceHeight(underlay);
+ const totalHeight =
+ underlayBounds[1].y - underlayBounds[0].y + VERTICAL_APPEARANCE_GAP;
+ minHeight += totalHeight;
+ totalUnderlayHeight += totalHeight;
+ }
+ // If we don't have any overlays, shift all underlays up
+ if (
+ !appearance.overlays?.filter((x) => x.hidden !== HiddenState.Hidden)
+ .length
+ ) {
+ const staticShift =
+ CENTRAL_APPEARANCE_GAP / 2 +
+ (totalUnderlayHeight - VERTICAL_APPEARANCE_GAP) / 2;
+ for (let i = 0; i < appearance.underlays.length; i++) {
+ appearance.underlays[i].relativePosition.y -= staticShift;
+ }
+ }
+ }
+ }
+
+ positionChildren(primary);
+ primary.boundingBox = getBoundingBox(primary);
+ return appearances;
+}
+
+export function AppearanceDebug() {
+ const { data, act } = useBackend();
+ const {
+ mainAppearance,
+ planeToText,
+ layerToText,
+ flagsToText,
+ visToText,
+ blendToText,
+ mapRefHover,
+ mapRefSelected,
+ updateWarning,
+ } = data;
+ const [planeFilter, setPlaneFilter] = useState(null);
+ const [hideEmissives, setHideEmissives] = useState(false);
+
+ // This is a constant because we do not dynamically refresh, as appearances cannot be modified and rebuild all at once
+ // So we do not need to concern ourselves with constant updates, and can just send data in ui_static_data()
+ const appsProcessed = parseAppearanceData(
+ mainAppearance,
+ layerToText,
+ planeToText,
+ planeFilter ? planeToText[planeFilter] : null,
+ hideEmissives,
+ );
+ const [zoomToX, setZoomToX] = useState();
+ const [zoomToY, setZoomToY] = useState();
+ const [selection, setSelection] = useState(null);
+
+ function mapPosition(
+ appearance: Appearance,
+ positions: Record,
+ ) {
+ if (appearance.data.id in positions) return positions[appearance.data.id];
+ const position: Coordinates = {
+ x: appearance.relativePosition.x,
+ y: appearance.relativePosition.y,
+ };
+ if (appearance.parent) {
+ if (!(appearance.parent.data.id in positions))
+ mapPosition(appearance.parent, positions);
+ position.x += positions[appearance.parent.data.id].x;
+ position.y += positions[appearance.parent.data.id].y;
+ }
+ positions[appearance.data.id] = position;
+ return position;
+ }
+
+ const NODE_PADDING = 20;
+ const OVERLAY_NODE_INPUT_PADDING = 60;
+ const UNDERLAY_NODE_INPUT_PADDING = 40;
+
+ const connections: Connection[] = [];
+ const appearancePositions: Record = {};
+ for (let i = 0; i < Object.keys(appsProcessed).length; i++) {
+ const appearance = appsProcessed[
+ Object.keys(appsProcessed)[i]
+ ] as Appearance;
+ if (!appearance.parent || appearance.hidden === HiddenState.Hidden)
+ continue;
+ const position = mapPosition(appearance, appearancePositions);
+ const parentPosition = mapPosition(appearance.parent, appearancePositions);
+ connections.push({
+ from: {
+ x:
+ position.x +
+ getAppearanceWidth(appearance, layerToText, planeToText) -
+ NODE_PADDING,
+ y: position.y + getAppearanceHeight(appearance) / 2,
+ },
+ to: {
+ x: parentPosition.x + NODE_PADDING,
+ y:
+ appearance.parentType === AppearanceParentType.Overlay
+ ? parentPosition.y + OVERLAY_NODE_INPUT_PADDING
+ : parentPosition.y +
+ getAppearanceHeight(appearance.parent) -
+ UNDERLAY_NODE_INPUT_PADDING,
+ },
+ index: i,
+ color: `hsl(${60 + 5 * (i % 30)}, 50%, ${50 + (i % 30)}%)`,
+ });
+ }
+
+ return (
+
+
+ a.length - b.length)
+ .pop() as string,
+ 'Verdana, Geneva',
+ 12,
+ ),
+ ) + 40
+ }px`}
+ >
+ {
+ setSelection(null);
+ if (!(value in planeToText)) setPlaneFilter(null);
+ setPlaneFilter(value);
+ }}
+ />
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ {Object.entries(appsProcessed)
+ .filter((keyValue) => keyValue[1].hidden !== HiddenState.Hidden)
+ .map((keyValue) => (
+ {
+ setSelection(keyValue[1].data.id);
+ act('swapMapViewSelected', { id: keyValue[1].data.id });
+ }}
+ />
+ ))}
+
+
+ {!!(selection !== null) && (
+ x.data.id === selection,
+ ) as Appearance
+ }
+ onClose={() => setSelection(null)}
+ />
+ )}
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/AppearanceDebug/types.ts b/tgui/packages/tgui/interfaces/AppearanceDebug/types.ts
new file mode 100644
index 00000000000..793d01e333d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AppearanceDebug/types.ts
@@ -0,0 +1,135 @@
+import type { Coordinates } from '../common/Connections';
+
+export type AppearanceDebugData = {
+ mainAppearance: AppearanceData;
+ planeToText: Record;
+ layerToText: Record;
+ flagsToText: Record;
+ visToText: Record;
+ blendToText: Record;
+ mapRefHover: string;
+ mapRefSelected: string;
+ updateWarning: boolean;
+};
+
+export enum AppearanceType {
+ MutableAppearance = 'appearance',
+ Image = 'image',
+ Atom = 'atom',
+}
+
+export type FilterData = {
+ type: string;
+ name: string;
+};
+
+export const APPEARANCE_FLAGS = {
+ LONG_GLIDE: 1,
+ RESET_COLOR: 2,
+ RESET_ALPHA: 4,
+ RESET_TRANSFORM: 8,
+ NO_CLIENT_COLOR: 16,
+ KEEP_TOGETHER: 32,
+ KEEP_APART: 64,
+ PLANE_MASTER: 128,
+ TILE_BOUND: 256,
+ PIXEL_SCALE: 512,
+ PASS_MOUSE: 1024,
+ TILE_MOVER: 2048,
+};
+
+export const VIS_FLAGS = {
+ VIS_INHERIT_ICON: 1,
+ VIS_INHERIT_ICON_STATE: 2,
+ VIS_INHERIT_DIR: 4,
+ VIS_INHERIT_LAYER: 8,
+ VIS_INHERIT_PLANE: 16,
+ VIS_INHERIT_ID: 32,
+ VIS_UNDERLAY: 64,
+ VIS_HIDE: 128,
+};
+
+export const MOUSE_OPACITY = {
+ MOUSE_OPACITY_TRANSPARENT: 0,
+ MOUSE_OPACITY_ICON: 1,
+ MOUSE_OPACITY_OPAQUE: 2,
+};
+
+export const DIR = {
+ NORTH: 1,
+ SOUTH: 2,
+ EAST: 4,
+ WEST: 8,
+};
+
+export type AppearanceData = {
+ type: AppearanceType;
+ id: number;
+ alpha: number;
+ flags: number;
+ blend_mode: number;
+ color: string | number[];
+ dir: number;
+ filters: FilterData[] | null;
+ icon: string;
+ icon_state: string;
+ invisibility: number;
+ underlays: AppearanceData[];
+ overlays: AppearanceData[];
+ vis_contents: AppearanceData[] | null;
+ layer: number;
+ layer_text_override: string | null;
+ name: string;
+ maptext: string | null;
+ maptext_width: number;
+ maptext_height: number;
+ maptext_x: number;
+ maptext_y: number;
+ mouse_opacity: number;
+ pixel_x: number;
+ pixel_y: number;
+ pixel_w: number;
+ pixel_z: number;
+ plane: number;
+ plane_true: number;
+ render_source: string | null;
+ render_target: string | null;
+ screen_loc: string | null;
+ transform: number[];
+ embed_icon: string | null;
+ vis_flags: number | null;
+};
+
+export enum AppearanceParentType {
+ None = 0,
+ Underlay = 1,
+ Overlay = 2,
+}
+
+export enum HiddenState {
+ Visible = 0,
+ Hidden = 1,
+ VisibleChild = 2,
+}
+
+export type Appearance = {
+ data: AppearanceData;
+ underlays: Appearance[] | null;
+ overlays: Appearance[] | null;
+ parent: Appearance | null;
+ parentType: AppearanceParentType;
+ renderTargetTo: Appearance[] | null;
+ relativePosition: Coordinates;
+ depth: number;
+ boundingBox: [Coordinates, Coordinates];
+ hidden: HiddenState;
+ // Inherit info
+ inherited_icon: boolean;
+ inherited_icon_state: boolean;
+ inherited_layer: boolean;
+ inherited_plane: boolean;
+ inherited_dir: boolean;
+ total_alpha: number;
+};
+
+export type AppearanceMap = Record;
diff --git a/tgui/packages/tgui/interfaces/AppearanceDebug/useAppearanceDebug.ts b/tgui/packages/tgui/interfaces/AppearanceDebug/useAppearanceDebug.ts
new file mode 100644
index 00000000000..25ec63989c0
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/AppearanceDebug/useAppearanceDebug.ts
@@ -0,0 +1,30 @@
+import {
+ createContext,
+ type Dispatch,
+ type SetStateAction,
+ useContext,
+} from 'react';
+import type { sendAct } from 'tgui/events/act';
+import type { AppearanceMap } from './types';
+
+type AppearanceDebug = {
+ act: typeof sendAct;
+ planeToText: Record;
+ layerToText: Record;
+ flagsToText: Record;
+ visToText: Record;
+ blendToText: Record;
+ mapRefHover: string;
+ mapRefSelected: string;
+ appsProcessed: AppearanceMap;
+ zoomToX: number | undefined;
+ setZoomToX: Dispatch>;
+ zoomToY: number | undefined;
+ setZoomToY: Dispatch>;
+};
+
+export const AppearanceDebugContext = createContext({} as AppearanceDebug);
+
+export function useAppearanceDebugContext() {
+ return useContext(AppearanceDebugContext);
+}
diff --git a/tgui/packages/tgui/interfaces/PlaneMasterDebug/PlaneEditor.tsx b/tgui/packages/tgui/interfaces/PlaneMasterDebug/PlaneEditor.tsx
index 92319bc4f46..f423357d2ed 100644
--- a/tgui/packages/tgui/interfaces/PlaneMasterDebug/PlaneEditor.tsx
+++ b/tgui/packages/tgui/interfaces/PlaneMasterDebug/PlaneEditor.tsx
@@ -28,7 +28,7 @@ export function PlaneEditor() {
position="absolute"
top="0px"
right="0px"
- backgroundColor="#121212"
+ backgroundColor="#121212DA"
title={`Plane Master: ${currentPlane.name}`}
buttons={