diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm
index 90864cf359..1d37f639bf 100644
--- a/code/__HELPERS/icons.dm
+++ b/code/__HELPERS/icons.dm
@@ -1235,3 +1235,18 @@ GLOBAL_DATUM_INIT(dummySave, /savefile, new("tmp/dummySave.sav")) //Cache of ico
var/icon/I = getFlatIcon(thing)
return icon2html(I, target, sourceonly = sourceonly)
+/* Gives the result RGB of a RGB string after a matrix transformation. No alpha.
+ * Input: rr, rg, rb, gr, gg, gb, br, bg, bb, cr, cg, cb
+ * Output: RGB string
+ */
+/proc/RGBMatrixTransform(list/color, list/cm)
+ ASSERT(cm.len >= 9)
+ if(cm.len < 12) // fill in the rest
+ for(var/i in 1 to (12 - cm.len))
+ cm += 0
+ if(!islist(color))
+ color = ReadRGB(color)
+ color[1] = color[1] * cm[1] + color[2] * cm[2] + color[3] * cm[3] + cm[10] * 255
+ color[2] = color[1] * cm[4] + color[2] * cm[5] + color[3] * cm[6] + cm[11] * 255
+ color[3] = color[1] * cm[7] + color[2] * cm[8] + color[3] * cm[9] + cm[12] * 255
+ return rgb(color[1], color[2], color[3])
diff --git a/code/__HELPERS/matrices.dm b/code/__HELPERS/matrices.dm
index af8efd425d..59fbb4ffc0 100644
--- a/code/__HELPERS/matrices.dm
+++ b/code/__HELPERS/matrices.dm
@@ -177,3 +177,9 @@ round(cos_inv_third+sqrt3_sin, 0.001), round(cos_inv_third-sqrt3_sin, 0.001), ro
for(x in 1 to 4)
output[offset+x] = round(A[offset+1]*B[x] + A[offset+2]*B[x+4] + A[offset+3]*B[x+8] + A[offset+4]*B[x+12]+(y==5?B[x+16]:0), 0.001)
return output
+
+/**
+ * Assembles a color matrix, defaulting to identity
+ */
+/proc/rgb_construct_color_matrix(rr = 1, rg, rb, gr, gg = 1, gb, br, bg, bb = 1, cr, cg, cb)
+ return list(rr, rg, rb, gr, gg, gb, br, bg, bb, cr, cg, cb)
diff --git a/code/game/machinery/colormate.dm b/code/game/machinery/colormate.dm
new file mode 100644
index 0000000000..38edcd6d8a
--- /dev/null
+++ b/code/game/machinery/colormate.dm
@@ -0,0 +1,223 @@
+/obj/machinery/gear_painter
+ name = "\improper Color Mate"
+ desc = "A machine to give your apparel a fresh new color! Recommended to use with white items for best results."
+ icon = 'icons/obj/vending.dmi'
+ icon_state = "colormate"
+ density = TRUE
+ anchored = TRUE
+ circuit = /obj/item/circuitboard/machine/colormate
+ var/obj/item/inserted
+ var/activecolor = "#FFFFFF"
+ var/list/color_matrix_last
+ var/matrix_mode = FALSE
+ /// Minimum lightness for normal mode
+ var/minimum_normal_lightness = 50
+ /// Minimum lightness for matrix mode, tested using 4 test colors of full red, green, blue, white.
+ var/minimum_matrix_lightness = 75
+ /// Minimum matrix tests that must pass for something to be considered a valid color (see above)
+ var/minimum_matrix_tests = 2
+ var/list/allowed_types = list(
+ /obj/item/clothing,
+ /obj/item/storage/backpack,
+ /obj/item/storage/belt
+ )
+
+/obj/machinery/gear_painter/Initialize(mapload)
+ . = ..()
+ color_matrix_last = list(
+ 1, 0, 0,
+ 0, 1, 0,
+ 0, 0, 1,
+ 0, 0, 0
+ )
+
+/obj/machinery/gear_painter/update_icon_state()
+ if(panel_open)
+ icon_state = "colormate_open"
+ else if(!is_operational())
+ icon_state = "colormate_off"
+ else if(inserted)
+ icon_state = "colormate_active"
+ else
+ icon_state = "colormate"
+
+/obj/machinery/gear_painter/Destroy()
+ inserted.forceMove(drop_location())
+ return ..()
+
+/obj/machinery/gear_painter/attackby(obj/item/I, mob/living/user)
+ if(inserted)
+ to_chat(user, "The machine is already loaded.")
+ return
+ if(default_deconstruction_screwdriver(user, "colormate_open", "colormate", I))
+ return
+ if(default_deconstruction_crowbar(I))
+ return
+ if(default_unfasten_wrench(user, I, 40))
+ return
+ if(user.a_intent == INTENT_HARM)
+ return ..()
+
+ if(is_type_in_list(I, allowed_types) && is_operational())
+ if(!user.transferItemToLoc(I, src))
+ to_chat(user, "[I] is stuck to your hand!")
+ return
+ user.visible_message("[user] inserts [I] into [src]'s receptable.")
+
+ inserted = I
+ update_icon()
+ else
+ return ..()
+
+/obj/machinery/gear_painter/AllowDrop()
+ return FALSE
+
+/obj/machinery/gear_painter/AltClick(mob/user)
+ . = ..()
+ if(!user.CanReach(src))
+ return
+ if(!inserted)
+ return
+ to_chat(user, "You remove [inserted] from [src]")
+ inserted.forceMove(drop_location())
+ inserted = null
+ update_icon()
+ updateUsrDialog()
+
+/obj/machinery/gear_painter/ui_interact(mob/user)
+ if(!is_operational())
+ return
+ user.set_machine(src)
+ var/list/dat = list("Color Mate Control Panel
")
+ if(!inserted)
+ dat += "No item inserted."
+ else
+ dat += "Item inserted: [inserted]
"
+ dat += "Matrix mode: [matrix_mode? "On" : "Off"]"
+ if(!matrix_mode)
+ dat += "Select new color.
"
+ dat += "Color: ⚫"
+ dat += "Apply new color.
"
+ else
+ // POGGERS
+#define MATRIX_FIELD(field, default) " "
+ dat += "
"
+#undef MATRIX_FIELD
+ dat += "Remove paintjob.
"
+ dat += "Eject item.
"
+
+ var/datum/browser/menu = new(user, "colormate","Color Mate Control Panel", 800, 600, src)
+ menu.set_content(dat.Join(""))
+ menu.open()
+
+/obj/machinery/gear_painter/Topic(href, href_list)
+ if((. = ..()))
+ return
+
+ add_fingerprint(usr)
+
+ if(href_list["close"])
+ usr << browse(null, "window=colormate")
+ return
+
+ if(href_list["select"])
+ var/newcolor = input(usr, "Choose a color.", "", activecolor) as color|null
+ if(newcolor)
+ activecolor = newcolor
+ updateUsrDialog()
+
+ if(href_list["paint"])
+ if(!inserted)
+ return
+ if(!check_valid_color(activecolor, usr))
+ return
+ inserted.add_atom_colour(activecolor, FIXED_COLOUR_PRIORITY)
+ playsound(src, 'sound/effects/spray3.ogg', 50, 1)
+ updateUsrDialog()
+
+ if(href_list["toggle_matrix_mode"])
+ matrix_mode = !matrix_mode
+ updateUsrDialog()
+
+ if(href_list["matrix_paint"])
+ if(!inserted)
+ return
+ // assemble matrix
+ var/list/cm = rgb_construct_color_matrix(
+ text2num(href_list["rr"]),
+ text2num(href_list["rg"]),
+ text2num(href_list["rb"]),
+ text2num(href_list["gr"]),
+ text2num(href_list["gg"]),
+ text2num(href_list["gb"]),
+ text2num(href_list["br"]),
+ text2num(href_list["bg"]),
+ text2num(href_list["bb"]),
+ text2num(href_list["cr"]),
+ text2num(href_list["cg"]),
+ text2num(href_list["cb"])
+ )
+ color_matrix_last = cm.Copy()
+ if(!check_valid_color(cm, usr))
+ return
+ inserted.add_atom_colour(cm, FIXED_COLOUR_PRIORITY)
+ playsound(src, 'sound/effects/spray3.ogg', 50, 1)
+ updateUsrDialog()
+
+ if(href_list["clear"])
+ if(!inserted)
+ return
+ inserted.remove_atom_colour(FIXED_COLOUR_PRIORITY)
+ playsound(src, 'sound/effects/spray3.ogg', 50, 1)
+ updateUsrDialog()
+
+ if(href_list["eject"])
+ if(!inserted)
+ return
+ inserted.forceMove(drop_location())
+ inserted = null
+ update_icon()
+ updateUsrDialog()
+
+/obj/machinery/gear_painter/proc/check_valid_color(list/cm, mob/user)
+ if(!islist(cm)) // normal
+ var/list/HSV = ReadHSV(RGBtoHSV(cm))
+ if(HSV[3] < minimum_normal_lightness)
+ to_chat(user, "[cm] is far too dark (min lightness [minimum_normal_lightness]!")
+ return FALSE
+ return TRUE
+ else // matrix
+ // We test using full red, green, blue, and white
+ // A predefined number of them must pass to be considered valid
+ var/passed = 0
+#define COLORTEST(thestring, thematrix) passed += (ReadHSV(RGBtoHSV(RGBMatrixTransform(thestring, thematrix)))[3] >= minimum_matrix_lightness)
+ COLORTEST("FF0000", cm)
+ COLORTEST("00FF00", cm)
+ COLORTEST("0000FF", cm)
+ COLORTEST("FFFFFF", cm)
+#undef COLORTEST
+ if(passed < minimum_matrix_tests)
+ to_chat(user, "[english_list(color)] is not allowed (pased [passed] out of 4, minimum [minimum_matrix_tests], minimum lightness [minimum_matrix_lightness]).")
+ return FALSE
+ return TRUE
diff --git a/code/game/objects/items/circuitboards/machine_circuitboards.dm b/code/game/objects/items/circuitboards/machine_circuitboards.dm
index 8e59e91e38..4873962587 100644
--- a/code/game/objects/items/circuitboards/machine_circuitboards.dm
+++ b/code/game/objects/items/circuitboards/machine_circuitboards.dm
@@ -243,6 +243,12 @@
/obj/item/stack/sheet/glass = 1)
def_components = list(/obj/item/stack/ore/bluespace_crystal = /obj/item/stack/ore/bluespace_crystal/artificial)
+/obj/item/circuitboard/machine/colormate
+ name = "Colormate (Machine Board)"
+ build_path = /obj/machinery/gear_painter
+ req_components = list()
+ def_components = list()
+
/obj/item/circuitboard/machine/vendor
name = "Custom Vendor (Machine Board)"
desc = "You can turn the \"brand selection\" dial using a screwdriver."
diff --git a/code/modules/research/designs/machine_desings/machine_designs_all_misc.dm b/code/modules/research/designs/machine_desings/machine_designs_all_misc.dm
index e0702be689..81b16d1118 100644
--- a/code/modules/research/designs/machine_desings/machine_designs_all_misc.dm
+++ b/code/modules/research/designs/machine_desings/machine_designs_all_misc.dm
@@ -163,3 +163,10 @@
category = list ("Misc. Machinery")
departmental_flags = DEPARTMENTAL_FLAG_ALL
+/datum/design/board/colormate
+ name = "ColorMate"
+ desc = "This machine lets you paint your gear."
+ id = "colormate"
+ build_path = /obj/item/circuitboard/machine/colormate
+ category = list ("Misc. Machinery")
+ departmental_flags = DEPARTMENTAL_FLAG_ALL
diff --git a/code/modules/research/techweb/nodes/engineering_nodes.dm b/code/modules/research/techweb/nodes/engineering_nodes.dm
index d024823c85..f4dce58740 100644
--- a/code/modules/research/techweb/nodes/engineering_nodes.dm
+++ b/code/modules/research/techweb/nodes/engineering_nodes.dm
@@ -8,7 +8,7 @@
design_ids = list("solarcontrol", "recharger", "powermonitor", "rped", "pacman", "adv_capacitor", "adv_scanning", "emitter", "high_cell", "adv_matter_bin",
"atmosalerts", "atmos_control", "recycler", "autolathe", "autolathe_secure", "high_micro_laser", "nano_mani", "mesons", "thermomachine", "rad_collector", "tesla_coil", "grounding_rod",
"apc_control", "power control", "airlock_board", "firelock_board", "airalarm_electronics", "firealarm_electronics", "cell_charger", "stack_console", "stack_machine", "rcd_ammo","oxygen_tank",
- "plasma_tank", "emergency_oxygen", "emergency_oxygen_engi", "plasmaman_tank_belt")
+ "plasma_tank", "emergency_oxygen", "emergency_oxygen_engi", "plasmaman_tank_belt", "colormate")
research_costs = list(TECHWEB_POINT_TYPE_GENERIC = 6000)
/datum/techweb_node/adv_engi
diff --git a/icons/obj/vending.dmi b/icons/obj/vending.dmi
index d8f5c1d334..c70fe9d44e 100644
Binary files a/icons/obj/vending.dmi and b/icons/obj/vending.dmi differ
diff --git a/tgstation.dme b/tgstation.dme
index 72ab44c48a..bac0ffbdd3 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -793,6 +793,7 @@
#include "code\game\machinery\buttons.dm"
#include "code\game\machinery\cell_charger.dm"
#include "code\game\machinery\cloning.dm"
+#include "code\game\machinery\colormate.dm"
#include "code\game\machinery\constructable_frame.dm"
#include "code\game\machinery\cryopod.dm"
#include "code\game\machinery\dance_machine.dm"