diff --git a/code/datums/mind.dm b/code/datums/mind.dm
index bb7f5b6aa9b..2cc69c449cf 100644
--- a/code/datums/mind.dm
+++ b/code/datums/mind.dm
@@ -123,22 +123,54 @@
return output
/datum/mind/proc/show_memory(mob/recipient)
- var/output = "[current.real_name]'s Memory
"
- output += memory
+ ui_interact(recipient)
+/datum/mind/ui_state()
+ return GLOB.always_state
+
+/datum/mind/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "Memory")
+ ui.open()
+
+/datum/mind/ui_data(mob/user)
+ var/list/data = list()
+ data["name"] = current ? current.real_name : name
+ data["memory"] = memory
+
+ var/list/obj_groups = list()
for(var/datum/antagonist/A as anything in antagonist)
if(!length(A.objectives))
- break
- if(A.faction)
- output += "Your [A.faction.name] faction objectives: "
- else
- output += "Your [A.role_text] objectives: "
- output += "[A.print_objectives(FALSE)]"
- output += print_individualobjectives()
-
- var/datum/browser/panel = new(recipient, "memory", "Memory", 333, 333)
- panel.set_content(output)
- panel.open()
+ continue
+ var/list/group = list()
+ group["label"] = A.faction ? "Your [A.faction.name] faction objectives:" : "Your [A.role_text] objectives:"
+ var/list/objs = list()
+ for(var/datum/objective/O in A.objectives)
+ objs += list(list("text" = O.explanation_text))
+ group["objectives"] = objs
+ obj_groups += list(group)
+ data["objective_groups"] = obj_groups
+
+ var/list/ind_objs = list()
+ var/ind_la_explanation
+ if(LAZYLEN(individual_objectives))
+ var/obj_count = 1
+ for(var/datum/individual_objective/objective in individual_objectives)
+ var/list/obj = list()
+ obj["num"] = obj_count
+ obj["name"] = objective.name
+ obj["desc"] = objective.get_description()
+ obj["limited_antag"] = objective.limited_antag ? TRUE : FALSE
+ if(objective.limited_antag)
+ obj["show_la"] = objective.show_la
+ ind_la_explanation = objective.la_explanation
+ ind_objs += list(obj)
+ obj_count++
+ data["individual_objectives"] = ind_objs
+ data["la_explanation"] = ind_la_explanation
+
+ return data
/datum/mind/proc/edit_memory()
if(!SSticker.IsRoundInProgress())
diff --git a/code/datums/uplink/uplink_sources.dm b/code/datums/uplink/uplink_sources.dm
index 541b390304c..43ba290ad2a 100644
--- a/code/datums/uplink/uplink_sources.dm
+++ b/code/datums/uplink/uplink_sources.dm
@@ -59,7 +59,7 @@ GLOBAL_LIST_INIT(default_uplink_source_priority, list(
R.hidden_uplink = T
T.trigger_code = freq
to_chat(M, span_notice("A portable object teleportation relay has been installed in your [R.name]. Simply dial the frequency [format_frequency(freq)] to unlock its hidden features."))
- M.mind.store_memory("Radio Freq: [format_frequency(freq)] ([R.name]).")
+ M.mind.store_memory("Radio Freq: [format_frequency(freq)] ([R.name]). ")
/decl/uplink_source/implant
name = "Implant"
diff --git a/code/game/antagonist/station/contractor.dm b/code/game/antagonist/station/contractor.dm
index 589bd85ad28..cc91f5c3890 100644
--- a/code/game/antagonist/station/contractor.dm
+++ b/code/game/antagonist/station/contractor.dm
@@ -56,7 +56,7 @@
to_chat(contractor_mob, "Code Phrase : [span_danger("[syndicate_code_phrase]")]")
to_chat(contractor_mob, "Code Response : [span_danger("[syndicate_code_response]")]")
contractor_mob.mind.store_memory("Code Phrase : [syndicate_code_phrase]")
- contractor_mob.mind.store_memory("Code Response : [syndicate_code_response]")
+ contractor_mob.mind.store_memory("Code Response : [syndicate_code_response] ")
to_chat(contractor_mob, "Use the code words, preferably in the order provided, during regular conversation, to identify other agents. Proceed with caution, however, as everyone is a potential foe.")
diff --git a/code/game/machinery/vending.dm b/code/game/machinery/vending.dm
index 7dd7e770ba5..66ffde4464f 100644
--- a/code/game/machinery/vending.dm
+++ b/code/game/machinery/vending.dm
@@ -115,6 +115,10 @@
var/purchase_message = ""
var/purchase_error = FALSE
+ var/needs_pin = FALSE
+ var/obj/item/card/id/pending_pin_card
+ var/pending_pin_mode = "" // "purchase" or "manage"
+
/*
Variables used to initialize the product list
These are used for initialization only, and so are optional if
@@ -411,11 +415,11 @@
// Enter PIN, so you can't loot a vending machine with only the owner's ID card (as long as they increased the sec level)
if(user_account.security_level != 0)
- var/attempt_pin = input("Enter pin code", "Vendor transaction") as num | null
- user_account = attempt_account_access(ID.associated_account_number, attempt_pin, 2)
- if(!user_account)
- to_chat(user, span_warning("Unable to access account! Credentials are incorrect."))
- return
+ pending_pin_card = ID
+ pending_pin_mode = "manage"
+ needs_pin = TRUE
+ ui_interact(user)
+ return
if(!machine_vendor_account)
machine_vendor_account = user_account
@@ -528,13 +532,11 @@
// Have the customer punch in the PIN before checking if there's enough money. Prevents people from figuring out acct is
// empty at high security levels
if(customer_account.security_level != 0) //If card requires pin authentication (ie seclevel 1 or 2)
- var/attempt_pin = input("Enter pin code", "Vendor transaction") as num
- customer_account = attempt_account_access(I.associated_account_number, attempt_pin, 2)
-
- if(!customer_account)
- purchase_message = "Unable to access account: incorrect credentials."
- purchase_error = TRUE
- return FALSE
+ pending_pin_card = I
+ pending_pin_mode = "purchase"
+ needs_pin = TRUE
+ ui_interact(usr)
+ return FALSE
if(currently_vending.price > customer_account.money)
purchase_message = "Insufficient funds in account."
@@ -646,6 +648,8 @@
"message" = purchase_message,
"isError" = purchase_error
)
+ data["needsPin"] = needs_pin ? TRUE : FALSE
+ data["pinMode"] = pending_pin_mode
var/list/listed_products = list()
for(var/key = 1 to length(product_records))
@@ -767,6 +771,45 @@
if("cancelpurchase")
currently_vending = null
+ needs_pin = FALSE
+ pending_pin_card = null
+ return TRUE
+
+ if("submit_pin")
+ if(!needs_pin || !pending_pin_card)
+ return TRUE
+ var/datum/money_account/verified = attempt_account_access(pending_pin_card.associated_account_number, text2num(params["pin"]), 2)
+ if(!verified)
+ purchase_message = "Unable to access account: incorrect PIN."
+ purchase_error = TRUE
+ needs_pin = FALSE
+ pending_pin_card = null
+ return TRUE
+ if(pending_pin_mode == "purchase")
+ if(!currently_vending)
+ needs_pin = FALSE
+ return TRUE
+ if(currently_vending.price > verified.money)
+ purchase_message = "Insufficient funds in account."
+ purchase_error = TRUE
+ else
+ var/datum/transaction/T = new(-currently_vending.price, earnings_account.get_name(), "Purchase of [currently_vending.product_name]", src)
+ T.apply_to(verified)
+ credit_purchase(verified.owner_name)
+ needs_pin = FALSE
+ pending_pin_card = null
+ vend(currently_vending, usr)
+ return TRUE
+ else if(pending_pin_mode == "manage")
+ if(!machine_vendor_account)
+ machine_vendor_account = verified
+ earnings_account = verified
+ locked = !locked
+ playsound(usr.loc, 'sound/machines/id_swipe.ogg', 60, 1)
+ to_chat(usr, span_notice("You [locked ? "" : "un"]lock \the [src]."))
+ log_econ("[src] was [locked ? "" : "un"]locked by [usr].")
+ needs_pin = FALSE
+ pending_pin_card = null
return TRUE
if("togglevoice")
diff --git a/code/modules/economy/ATM.dm b/code/modules/economy/ATM.dm
index 40cb5c9c160..bc1b8c2b3e3 100644
--- a/code/modules/economy/ATM.dm
+++ b/code/modules/economy/ATM.dm
@@ -136,293 +136,212 @@ log transactions
if(issilicon(user))
to_chat(user, span_red("[icon2html(src, user)] Artificial unit recognized. Artificial units do not currently receive monetary compensation, as per system banking regulation #1005."))
return
- if (..())
+ if(..())
return
- if(get_dist(src,user) <= 1)
-
- //js replicated from obj/machinery/computer/card
- var/dat = "Automatic Teller Machine "
- dat += "For all your monetary needs! "
- dat += "This terminal is [machine_id]. Report this code when contacting IT Support "
-
- if(emagged > 0)
- dat += "Card: LOCKED Unauthorized terminal access detected! This ATM has been locked. Please contact IT Support. "
- else
- dat += "Card: [held_card ? held_card.name : "------"] "
-
- if(ticks_left_locked_down > 0)
- dat += span_alert("Maximum number of pin attempts exceeded! Access to this ATM has been temporarily disabled.")
- else if(authenticated_account)
- if(authenticated_account.suspended)
- dat += span_red(span_bold("Access to this account has been suspended, and the funds within frozen."))
+ if(get_dist(src, user) <= 1)
+ ui_interact(user)
+
+/obj/machinery/atm/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "ATM")
+ ui.set_autoupdate(TRUE)
+ ui.open()
+
+/obj/machinery/atm/ui_data(mob/user)
+ var/list/data = list()
+ data["machine_id"] = machine_id
+ data["emagged"] = emagged ? TRUE : FALSE
+ data["locked_down"] = ticks_left_locked_down > 0 ? TRUE : FALSE
+ data["has_card"] = held_card ? TRUE : FALSE
+ data["card_name"] = held_card ? held_card.name : null
+ data["card_account_number"] = held_card ? held_card.associated_account_number : null
+ data["authenticated"] = authenticated_account ? TRUE : FALSE
+ data["screen"] = view_screen
+ if(authenticated_account)
+ data["suspended"] = authenticated_account.suspended ? TRUE : FALSE
+ data["owner_name"] = authenticated_account.owner_name
+ data["balance"] = authenticated_account.money
+ data["account_number"] = authenticated_account.account_number
+ data["security_level"] = authenticated_account.security_level
+ var/list/logs = list()
+ for(var/datum/transaction/T in authenticated_account.transaction_log)
+ logs += list(list(
+ "date" = T.date,
+ "time" = T.time,
+ "target_name" = T.target_name,
+ "purpose" = T.purpose,
+ "amount" = T.amount,
+ "source_terminal" = T.source_terminal
+ ))
+ data["transaction_log"] = logs
+ return data
+
+/obj/machinery/atm/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return TRUE
+
+ switch(action)
+ if("attempt_auth")
+ if(ticks_left_locked_down)
+ return TRUE
+ var/tried_account_num = text2num(params["account_num"])
+ var/tried_pin = text2num(params["account_pin"])
+ var/card_match_check = held_card && held_card.associated_account_number == tried_account_num ? 2 : 1
+ authenticated_account = attempt_account_access(tried_account_num, tried_pin, card_match_check, force_security = TRUE)
+ if(!authenticated_account)
+ number_incorrect_tries++
+ if(previous_account_number == tried_account_num)
+ if(number_incorrect_tries > max_pin_attempts)
+ ticks_left_locked_down = 30
+ playsound(src, 'sound/machines/buzz-two.ogg', 50, 1)
+ var/datum/money_account/failed_account = get_account(tried_account_num)
+ if(failed_account)
+ var/datum/transaction/T = new(0, failed_account.owner_name, "Unauthorised login attempt", machine_id)
+ T.apply_to(failed_account)
+ else
+ to_chat(usr, span_red("[icon2html(src, usr)] Incorrect PIN/account combination entered, [max_pin_attempts - number_incorrect_tries] attempts remaining."))
+ previous_account_number = tried_account_num
+ playsound(src, 'sound/machines/buzz-sigh.ogg', 50, 1)
else
- switch(view_screen)
- if(CHANGE_SECURITY_LEVEL)
- dat += "Select a new security level for this account: "
- var/text = "Zero - Either the account number and pin, or card and pin are required to access this account. "
- text += "Vending machine transactions will only require a card. EFTPOS transactions will require a card and ask for a pin, but not verify the pin is correct."
-
- if(authenticated_account.security_level != 0)
- text = "[text] "
- dat += "[text] "
- text = "One - An account number and pin must be manually entered to access this account and process transactions. Vending machine transactions will require card and pin."
- if(authenticated_account.security_level != 1)
- text = "[text] "
- dat += "[text] "
- text = "Two - In addition to account number and pin, a card is required to access this account and process transactions."
- if(authenticated_account.security_level != 2)
- text = "[text] "
- dat += "[text] "
- dat += "Back "
- if(VIEW_TRANSACTION_LOGS)
- dat += "Transaction logs "
- dat += "Back "
- dat += ""
- dat += ""
- dat += "Date "
- dat += "Time "
- dat += "Target "
- dat += "Purpose "
- dat += "Value "
- dat += "Source terminal ID "
- dat += " "
- for(var/datum/transaction/T in authenticated_account.transaction_log)
- dat += ""
- dat += "[T.date] "
- dat += "[T.time] "
- dat += "[T.target_name] "
- dat += "[T.purpose] "
- dat += "[num2text(T.amount,12)][CREDS] "
- dat += "[T.source_terminal] "
- dat += " "
- dat += "
"
- dat += "Print "
- if(TRANSFER_FUNDS)
- dat += "Account balance: [num2text(authenticated_account.money,12)][CREDS] "
- dat += "Back "
- dat += ""
- else
- dat += "Welcome, [authenticated_account.owner_name]. "
- dat += "Account balance: [num2text(authenticated_account.money, 12)][CREDS]"
- dat += ""
- dat += "Change account security level "
- dat += "Make transfer "
- dat += "View transaction log "
- dat += "Print balance statement "
- dat += "Logout "
+ to_chat(usr, span_red("[icon2html(src, usr)] Incorrect PIN/account combination entered."))
+ number_incorrect_tries = 0
+ else
+ playsound(src, 'sound/machines/twobeep.ogg', 50, 1)
+ ticks_left_timeout = 120
+ view_screen = NO_SCREEN
+ var/datum/transaction/T = new(0, authenticated_account.owner_name, "Remote terminal access", machine_id)
+ T.apply_to(authenticated_account)
+ to_chat(usr, span_notice("Access granted. Welcome, '[authenticated_account.owner_name].'"))
+ previous_account_number = tried_account_num
+
+ if("withdrawal")
+ if(!authenticated_account)
+ return TRUE
+ var/amount = round(max(text2num(params["funds_amount"]), 0), 0.01)
+ if(amount <= 0)
+ to_chat(usr, span_warning("That is not a valid amount."))
+ return TRUE
+ if(amount > authenticated_account.money)
+ to_chat(usr, span_warning("You don't have enough funds to do that!"))
+ return TRUE
+ playsound(src, 'sound/machines/chime.ogg', 50, 1)
+ var/datum/transaction/T = new(-amount, authenticated_account.owner_name, "Credit withdrawal", machine_id)
+ if(T.apply_to(authenticated_account))
+ spawn_money(amount, src.loc, usr)
+
+ if("e_withdrawal")
+ if(!authenticated_account)
+ return TRUE
+ var/amount = round(max(text2num(params["funds_amount"]), 0), 0.01)
+ if(amount <= 0)
+ to_chat(usr, span_warning("That is not a valid amount."))
+ return TRUE
+ if(amount > authenticated_account.money)
+ to_chat(usr, span_warning("You don't have enough funds to do that!"))
+ return TRUE
+ playsound(src, 'sound/machines/chime.ogg', 50, 1)
+ var/datum/transaction/T = new(-amount, authenticated_account.owner_name, "Credit withdrawal", machine_id)
+ if(T.apply_to(authenticated_account))
+ spawn_ewallet(amount, src.loc, usr)
+
+ if("transfer")
+ if(!authenticated_account)
+ return TRUE
+ var/transfer_amount = round(text2num(params["funds_amount"]), 0.01)
+ if(transfer_amount <= 0)
+ to_chat(usr, span_warning("That is not a valid amount."))
+ return TRUE
+ if(transfer_amount > authenticated_account.money)
+ to_chat(usr, span_warning("You don't have enough funds to do that!"))
+ return TRUE
+ var/target_account_number = text2num(params["target_acc_number"])
+ var/transfer_purpose = params["purpose"]
+ if(transfer_funds(authenticated_account.account_number, target_account_number, transfer_purpose, machine_id, transfer_amount))
+ to_chat(usr, "[icon2html(src, usr)][span_info("Funds transfer successful.")]")
else
- dat += ""
+ if("insert_card")
+ if(!held_card)
+ if(emagged > 0)
+ to_chat(usr, span_red("[icon2html(src, usr)] The ATM card reader rejected your ID because this machine has been sabotaged!"))
+ else
+ var/obj/item/I = usr.get_active_held_item()
+ if(isidcard(I))
+ usr.drop_item()
+ I.loc = src
+ held_card = I
+ else
+ release_held_id(usr)
- user << browse(HTML_SKELETON_TITLE("ATM", dat),"window=atm;size=600x650")
- else
- user << browse(null,"window=atm")
+ if("logout")
+ authenticated_account = null
-/obj/machinery/atm/Topic(href, href_list)
- if (..())
- return
- if(href_list["choice"])
- switch(href_list["choice"])
- if("transfer")
- if(authenticated_account)
- var/transfer_amount = text2num(href_list["funds_amount"])
- transfer_amount = round(transfer_amount, 0.01)
- if(transfer_amount <= 0)
- alert("That is not a valid amount.")
- else if(transfer_amount <= authenticated_account.money)
- var/target_account_number = text2num(href_list["target_acc_number"])
- var/transfer_purpose = href_list["purpose"]
- if(transfer_funds(authenticated_account.account_number, target_account_number, transfer_purpose, machine_id, transfer_amount))
- to_chat(usr, "[icon2html(src, usr)][span_info("Funds transfer successful.")]")
- else
- to_chat(usr, "[icon2html(src, usr)][span_warning("Funds transfer failed.")]")
+ if("balance_statement")
+ if(!authenticated_account)
+ return TRUE
+ var/obj/item/paper/R = new(src.loc)
+ R.name = "Balance Statement - [authenticated_account.owner_name]"
+ R.icon_state = "slipfull"
+ R.info = "ASTERS GUILD BANKING AUTHORITY "
+ R.info += "Automated Teller Account Statement "
+ R.info += " "
+ R.info += ""
+ R.info += "Account Holder: [authenticated_account.owner_name] "
+ R.info += "Account Number: [authenticated_account.account_number] "
+ R.info += "Date / Time: [current_date_string], [stationtime2text()] "
+ R.info += "Terminal ID: [machine_id] "
+ R.info += "
"
+ R.info += " "
+ R.info += "AVAILABLE BALANCE: [authenticated_account.money][CREDS] "
+ R.info += " "
+ R.info += "This statement is generated automatically by the Asters Guild Automated Teller Network. "
+ R.info += "Please retain this document for your records. "
+ var/image/stampoverlay = image('icons/obj/bureaucracy.dmi')
+ stampoverlay.icon_state = "paper_stamp-cent"
+ if(!R.stamped)
+ R.stamped = new
+ R.stamped += /obj/item/stamp
+ R.overlays += stampoverlay
+ R.stamps += "This document has been certified by the Asters Guild Automated Teller Network. "
+ playsound(loc, pick('sound/items/polaroid1.ogg','sound/items/polaroid2.ogg'), 50, 1)
- else
- to_chat(usr, span_warning("You don't have enough funds to do that!"))
- if("view_screen")
- view_screen = text2num(href_list["view_screen"])
- if("change_security_level")
- if(authenticated_account)
- var/new_sec_level = max( min(text2num(href_list["new_security_level"]), 2), 0)
- authenticated_account.security_level = new_sec_level
- if("attempt_auth")
- if(!ticks_left_locked_down)
- var/tried_account_num = text2num(href_list["account_num"])
- var/tried_pin = text2num(href_list["account_pin"])
-
- var/card_match_check = held_card && held_card.associated_account_number == tried_account_num ? 2 : 1
-
- authenticated_account = attempt_account_access(tried_account_num, tried_pin, card_match_check, force_security = TRUE)
- if(!authenticated_account)
- number_incorrect_tries++
- if(previous_account_number == tried_account_num)
- if(number_incorrect_tries > max_pin_attempts)
- //lock down the atm
- ticks_left_locked_down = 30
- playsound(src, 'sound/machines/buzz-two.ogg', 50, 1)
-
- //create an entry in the account transaction log
- var/datum/money_account/failed_account = get_account(tried_account_num)
- if(failed_account)
- //Just crazy
- var/datum/transaction/T = new(0, failed_account.owner_name, "Unauthorised login attempt", machine_id)
- T.apply_to(failed_account)
- else
- to_chat(usr, span_red("[icon2html(src, usr)] Incorrect pin/account combination entered, [max_pin_attempts - number_incorrect_tries] attempts remaining."))
- previous_account_number = tried_account_num
- playsound(src, 'sound/machines/buzz-sigh.ogg', 50, 1)
- else
- to_chat(usr, span_red("[icon2html(src, usr)] incorrect pin/account combination entered."))
- number_incorrect_tries = 0
- else
- playsound(src, 'sound/machines/twobeep.ogg', 50, 1)
- ticks_left_timeout = 120
- view_screen = NO_SCREEN
-
- //create a transaction log entry
- var/datum/transaction/T = new(0, authenticated_account.owner_name, "Remote terminal access", machine_id)
- T.apply_to(authenticated_account)
-
- to_chat(usr, span_notice("Access granted. Welcome, '[authenticated_account.owner_name].'"))
-
- previous_account_number = tried_account_num
- if("e_withdrawal")
- var/amount = max(text2num(href_list["funds_amount"]),0)
- amount = round(amount, 0.01)
- if(amount <= 0)
- alert("That is not a valid amount.")
- else if(authenticated_account && amount > 0)
- if(amount <= authenticated_account.money)
- playsound(src, 'sound/machines/chime.ogg', 50, 1)
-
-
- //remove the money
- //create an entry in the account transaction log
- var/datum/transaction/T = new(-amount, authenticated_account.owner_name, "Credit withdrawal", machine_id)
- if(T.apply_to(authenticated_account))
- // spawn_money(amount,src.loc)
- spawn_ewallet(amount,src.loc,usr)
- else
- to_chat(usr, span_warning("You don't have enough funds to do that!"))
- if("withdrawal")
- var/amount = max(text2num(href_list["funds_amount"]),0)
- amount = round(amount, 0.01)
- if(amount <= 0)
- alert("That is not a valid amount.")
- else if(authenticated_account && amount > 0)
- if(amount <= authenticated_account.money)
- playsound(src, 'sound/machines/chime.ogg', 50, 1)
-
- //create an entry in the account transaction log
- var/datum/transaction/T = new(-amount, authenticated_account.owner_name, "Credit withdrawal", machine_id)
- if(T.apply_to(authenticated_account))
- //remove the money
- spawn_money(amount,src.loc,usr)
+ if("print_transaction")
+ if(!authenticated_account)
+ return TRUE
+ var/obj/item/paper/R = new(src.loc)
+ R.name = "Transaction logs: [authenticated_account.owner_name]"
+ R.info = "Transaction logs "
+ R.info += "Account holder: [authenticated_account.owner_name] "
+ R.info += "Account number: [authenticated_account.account_number] "
+ R.info += "Date and time: [stationtime2text()], [current_date_string] "
+ R.info += "Service terminal ID: [machine_id] "
+ R.info += ""
+ R.info += "Date Time Target Purpose Value Source terminal ID "
+ for(var/datum/transaction/T in authenticated_account.transaction_log)
+ R.info += "[T.date] [T.time] [T.target_name] [T.purpose] [T.amount][CREDS] [T.source_terminal] "
+ R.info += "
"
+ var/image/stampoverlay = image('icons/obj/bureaucracy.dmi')
+ stampoverlay.icon_state = "paper_stamp-cent"
+ if(!R.stamped)
+ R.stamped = new
+ R.stamped += /obj/item/stamp
+ R.overlays += stampoverlay
+ R.stamps += "This paper has been stamped by the Automatic Teller Machine. "
+ playsound(loc, pick('sound/items/polaroid1.ogg','sound/items/polaroid2.ogg'), 50, 1)
- else
- to_chat(usr, span_warning("You don't have enough funds to do that!"))
- if("balance_statement")
- if(authenticated_account)
- var/obj/item/paper/R = new(src.loc)
- R.name = "Account balance: [authenticated_account.owner_name]"
- R.info = "Automated Teller Account Statement "
- R.info += "Account holder: [authenticated_account.owner_name] "
- R.info += "Account number: [authenticated_account.account_number] "
- R.info += "Balance: [authenticated_account.money][CREDS] "
- R.info += "Date and time: [stationtime2text()], [current_date_string] "
- R.info += "Service terminal ID: [machine_id] "
-
- //stamp the paper
- var/image/stampoverlay = image('icons/obj/bureaucracy.dmi')
- stampoverlay.icon_state = "paper_stamp-cent"
- if(!R.stamped)
- R.stamped = new
- R.stamped += /obj/item/stamp
- R.overlays += stampoverlay
- R.stamps += "This paper has been stamped by the Automatic Teller Machine. "
-
- playsound(loc, pick('sound/items/polaroid1.ogg','sound/items/polaroid2.ogg'), 50, 1)
- if ("print_transaction")
- if(authenticated_account)
- var/obj/item/paper/R = new(src.loc)
- R.name = "Transaction logs: [authenticated_account.owner_name]"
- R.info = "Transaction logs "
- R.info += "Account holder: [authenticated_account.owner_name] "
- R.info += "Account number: [authenticated_account.account_number] "
- R.info += "Date and time: [stationtime2text()], [current_date_string] "
- R.info += "Service terminal ID: [machine_id] "
- R.info += ""
- R.info += ""
- R.info += "Date "
- R.info += "Time "
- R.info += "Target "
- R.info += "Purpose "
- R.info += "Value "
- R.info += "Source terminal ID "
- R.info += " "
- for(var/datum/transaction/T in authenticated_account.transaction_log)
- R.info += ""
- R.info += "[T.date] "
- R.info += "[T.time] "
- R.info += "[T.target_name] "
- R.info += "[T.purpose] "
- R.info += "[T.amount][CREDS] "
- R.info += "[T.source_terminal] "
- R.info += " "
- R.info += "
"
-
- //stamp the paper
- var/image/stampoverlay = image('icons/obj/bureaucracy.dmi')
- stampoverlay.icon_state = "paper_stamp-cent"
- if(!R.stamped)
- R.stamped = new
- R.stamped += /obj/item/stamp
- R.overlays += stampoverlay
- R.stamps += "This paper has been stamped by the Automatic Teller Machine. "
-
- playsound(loc, pick('sound/items/polaroid1.ogg','sound/items/polaroid2.ogg'), 50, 1)
-
- if("insert_card")
- if(!held_card)
- //this might happen if the user had the browser window open when somebody emagged it
- if(emagged > 0)
- to_chat(usr, span_red("[icon2html(src, usr)] The ATM card reader rejected your ID because this machine has been sabotaged!"))
- else
- var/obj/item/I = usr.get_active_held_item()
- if (isidcard(I))
- usr.drop_item()
- I.loc = src
- held_card = I
- else
- release_held_id(usr)
- if("logout")
- authenticated_account = null
- //usr << browse(null,"window=atm")
playsound(loc, 'sound/machines/button.ogg', 100, 1)
- src.attack_hand(usr)
+ return TRUE
// put the currently held id on the ground or in the hand of the user
/obj/machinery/atm/proc/release_held_id(mob/living/carbon/human/human_user as mob)
diff --git a/code/modules/modular_computers/NTNet/NTNet.dm b/code/modules/modular_computers/NTNet/NTNet.dm
index 129f54079bd..94968108670 100644
--- a/code/modules/modular_computers/NTNet/NTNet.dm
+++ b/code/modules/modular_computers/NTNet/NTNet.dm
@@ -266,7 +266,7 @@ var/global/datum/ntnet/ntnet_global = new()
if(user.mind)
user.mind.initial_email_login["login"] = EA.login
user.mind.initial_email_login["password"] = EA.password
- user.mind.store_memory("Your email account address is [EA.login] and the password is [EA.password].")
+ user.mind.store_memory("Your email account address is [EA.login] and the password is [EA.password]. ")
if(ishuman(user))
for(var/obj/item/modular_computer/C in user.GetAllContents())
var/datum/computer_file/program/email_client/P = C.getProgramByType(/datum/computer_file/program/email_client)
diff --git a/icons/obj/bureaucracy.dmi b/icons/obj/bureaucracy.dmi
index 91448131a3b..4b869bcbdc9 100755
Binary files a/icons/obj/bureaucracy.dmi and b/icons/obj/bureaucracy.dmi differ
diff --git a/tgui/packages/tgui/interfaces/ATM.tsx b/tgui/packages/tgui/interfaces/ATM.tsx
new file mode 100644
index 00000000000..c332e874abd
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/ATM.tsx
@@ -0,0 +1,447 @@
+import { useEffect, useState } from 'react';
+import { useBackend } from 'tgui/backend';
+import {
+ Box,
+ Button,
+ Input,
+ LabeledList,
+ NoticeBox,
+ NumberInput,
+ Section,
+ Stack,
+ Table,
+} from 'tgui-core/components';
+
+import { Window } from '../layouts';
+
+const SCREEN_MAIN = 0;
+const SCREEN_SECURITY = 1;
+const SCREEN_TRANSFER = 2;
+const SCREEN_LOGS = 3;
+
+type Transaction = {
+ date: string;
+ time: string;
+ target_name: string;
+ purpose: string;
+ amount: number;
+ source_terminal: string;
+};
+
+type ATMData = {
+ machine_id: string;
+ emagged: boolean;
+ locked_down: boolean;
+ has_card: boolean;
+ card_name: string | null;
+ card_account_number: number | null;
+ authenticated: boolean;
+ suspended: boolean;
+ owner_name: string;
+ balance: number;
+ account_number: number;
+ security_level: number;
+ screen: number;
+ transaction_log: Transaction[];
+};
+
+const LoginScreen = () => {
+ const { act, data } = useBackend();
+ const { card_account_number } = data;
+ const [accountNum, setAccountNum] = useState(
+ card_account_number ? String(card_account_number) : '',
+ );
+ const [pin, setPin] = useState('');
+
+ useEffect(() => {
+ setAccountNum(card_account_number ? String(card_account_number) : '');
+ }, [card_account_number]);
+
+ const submit = () => {
+ if (!accountNum || !pin) return;
+ act('attempt_auth', { account_num: accountNum, account_pin: pin });
+ setPin('');
+ };
+
+ return (
+
+ );
+};
+
+const MainScreen = () => {
+ const { act, data } = useBackend();
+ const { owner_name, balance } = data;
+ const [withdrawAmount, setWithdrawAmount] = useState(0);
+ const [withdrawType, setWithdrawType] = useState<
+ 'withdrawal' | 'e_withdrawal'
+ >('withdrawal');
+
+ return (
+ <>
+
+
+
+
+ {balance} cr
+
+
+
+
+
+
+
+ setWithdrawType('withdrawal')}
+ >
+ Cash
+
+ setWithdrawType('e_withdrawal')}
+ >
+ Chargecard
+
+
+
+
+
+ setWithdrawAmount(val)}
+ />
+
+
+
+ act(withdrawType, { funds_amount: withdrawAmount })
+ }
+ >
+ Withdraw
+
+
+
+
+
+
+
+
+ act('view_screen', { view_screen: SCREEN_SECURITY })
+ }
+ >
+ Change Account Security Level
+
+
+
+
+ act('view_screen', { view_screen: SCREEN_TRANSFER })
+ }
+ >
+ Make Transfer
+
+
+
+ act('view_screen', { view_screen: SCREEN_LOGS })}
+ >
+ View Transaction Log
+
+
+
+ act('balance_statement')}>
+ Print Balance Statement
+
+
+
+ act('logout')}
+ >
+ Logout
+
+
+
+
+ >
+ );
+};
+
+const SECURITY_LEVELS = [
+ {
+ level: 0,
+ label: 'Level 0',
+ desc: 'Card or account number with PIN required. Vending machines only require a card.',
+ },
+ {
+ level: 1,
+ label: 'Level 1',
+ desc: 'Account number and PIN must be entered manually. Vending machines require card and PIN.',
+ },
+ {
+ level: 2,
+ label: 'Level 2',
+ desc: 'Card, account number, and PIN all required.',
+ },
+];
+
+const SecurityScreen = () => {
+ const { act, data } = useBackend();
+ const { security_level } = data;
+
+ return (
+ act('view_screen', { view_screen: SCREEN_MAIN })}
+ >
+ Back
+
+ }
+ >
+
+ {SECURITY_LEVELS.map((l) => (
+
+
+ act('change_security_level', { new_security_level: l.level })
+ }
+ >
+ {l.label}
+
+
+ {l.desc}
+
+
+ ))}
+
+
+ );
+};
+
+const TransferScreen = () => {
+ const { act, data } = useBackend();
+ const { balance } = data;
+ const [targetAccount, setTargetAccount] = useState('');
+ const [amount, setAmount] = useState(0);
+ const [purpose, setPurpose] = useState('Funds transfer');
+
+ return (
+
+ );
+};
+
+const LogsScreen = () => {
+ const { act, data } = useBackend();
+ const { transaction_log } = data;
+
+ return (
+
+ act('print_transaction')}>
+ Print
+
+ act('view_screen', { view_screen: SCREEN_MAIN })}
+ >
+ Back
+
+ >
+ }
+ >
+ {!transaction_log || transaction_log.length === 0 ? (
+ No transactions recorded.
+ ) : (
+
+
+ Date
+ Time
+ Target
+ Purpose
+ Value
+ Terminal
+
+ {transaction_log.map((t, i) => (
+
+ {t.date}
+ {t.time}
+ {t.target_name}
+ {t.purpose}
+ = 0 ? 'good' : 'bad'}>
+ {t.amount} cr
+
+ {t.source_terminal}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const ATM = () => {
+ const { act, data } = useBackend();
+ const {
+ machine_id,
+ emagged,
+ locked_down,
+ has_card,
+ card_name,
+ authenticated,
+ suspended,
+ screen,
+ } = data;
+
+ return (
+
+
+
+ Terminal: {machine_id}. Report this code when contacting IT Support.
+
+ {emagged ? (
+
+ CARD READER ERROR - Unauthorized terminal access detected! This ATM
+ has been locked. Please contact IT Support.
+
+ ) : (
+
+
+
+
+
+ act('insert_card')}
+ >
+ {has_card ? card_name : '------'}
+
+
+
+
+
+
+ {locked_down ? (
+
+ Maximum number of PIN attempts exceeded! Access to this ATM
+ has been temporarily disabled.
+
+ ) : !authenticated ? (
+
+ ) : suspended ? (
+
+ Access to this account has been suspended, and the funds
+ within frozen.
+
+ ) : screen === SCREEN_SECURITY ? (
+
+ ) : screen === SCREEN_TRANSFER ? (
+
+ ) : screen === SCREEN_LOGS ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/Memory.tsx b/tgui/packages/tgui/interfaces/Memory.tsx
new file mode 100644
index 00000000000..bae248c8b57
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Memory.tsx
@@ -0,0 +1,292 @@
+import { Fragment, useState } from 'react';
+import { useBackend } from 'tgui/backend';
+import { Box, Divider, Section, Stack } from 'tgui-core/components';
+
+import { Window } from '../layouts';
+
+type ObjectiveGroup = {
+ label: string;
+ objectives: { text: string }[];
+};
+
+type IndividualObjective = {
+ num: number;
+ name: string;
+ desc: string;
+ limited_antag: boolean;
+ show_la?: string;
+};
+
+type MemoryData = {
+ name: string;
+ memory: string;
+ objective_groups: ObjectiveGroup[];
+ individual_objectives: IndividualObjective[];
+ la_explanation?: string;
+};
+
+/**
+ * Wraps a copyable value with a placeholder so MemoryLines can replace it
+ * with an interactive CopyToken component after parsing.
+ */
+const COPY_SENTINEL = '\x00COPY\x00';
+const copyTokens: { value: string; color: string }[] = [];
+
+const makeCopyToken = (value: string, color: string): string => {
+ const idx = copyTokens.length;
+ copyTokens.push({ value, color });
+ return `${COPY_SENTINEL}${idx}${COPY_SENTINEL}`;
+};
+
+const CopyToken = ({ value, color }: { value: string; color: string }) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleClick = () => {
+ navigator.clipboard?.writeText(value).catch(() => {
+ const el = document.createElement('textarea');
+ el.value = value;
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ });
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ };
+
+ return (
+
+ {value}
+ {copied && (
+
+ ✓ copied
+
+ )}
+
+ );
+};
+
+/**
+ * Highlights known sensitive fields and wraps copyable values with tokens.
+ * Copyable: email addresses, passwords, uplink passcodes.
+ * Code Phrase → green, Code Response → red.
+ */
+const styleMemory = (raw: string): string => {
+ copyTokens.length = 0;
+ return raw
+ .replace(
+ /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g,
+ (_, email) =>
+ `${makeCopyToken(email, '#6ab0de')} `,
+ )
+ .replace(
+ /((?:password|pin) is )([^\s<.,]+)/gi,
+ (_, prefix, val) => `${prefix}${makeCopyToken(val, '#2ecc71')}`,
+ )
+ .replace(
+ /(<[Bb]>(?:Your )?(?:department's )?account number is:<\/[Bb]> #?)(\d+)/gi,
+ (_, prefix, val) => `${prefix}${makeCopyToken(val, '#f0c040')}`,
+ )
+ .replace(
+ /(<[Bb]>(?:Your )?(?:department's )?account pin is:<\/[Bb]> )(\d+)/gi,
+ (_, prefix, val) => `${prefix}${makeCopyToken(val, '#2ecc71')}`,
+ )
+ .replace(
+ /(<[Bb]>Uplink passcode:<\/[Bb]> )([^<.(]+?)(?=\s*\()/g,
+ (_, prefix, val) => `${prefix}${makeCopyToken(val.trim(), '#c678dd')}`,
+ )
+ .replace(
+ /(<[Bb]>Radio Freq:<\/[Bb]> )([^<(]+?)(?=\s*\()/g,
+ (_, prefix, val) => `${prefix}${makeCopyToken(val.trim(), '#c678dd')}`,
+ )
+ .replace(
+ /(<[Bb]>Code Phrase<\/[Bb]>: )([^<\n]+)/g,
+ (_, prefix, val) =>
+ `${prefix}${val.trim()} `,
+ )
+ .replace(
+ /(<[Bb]>Code Response<\/[Bb]>: )([^<\n]+)/g,
+ (_, prefix, val) =>
+ `${prefix}${val.trim()} `,
+ );
+};
+
+/**
+ * Splits a styled HTML string on tags and renders each line, replacing
+ * COPY_SENTINEL tokens with interactive CopyToken components.
+ */
+const MemoryLines = ({ html }: { html: string }) => {
+ const styled = styleMemory(html);
+ const rawLines = styled.split(/ /i);
+
+ // Collapse consecutive empty lines into a single gap marker
+ type Line = { empty: true } | { empty: false; content: string };
+ const lines: Line[] = [];
+ for (const raw of rawLines) {
+ const isEmpty = raw.trim().length === 0;
+ if (isEmpty) {
+ if (lines.length > 0 && !lines[lines.length - 1].empty) {
+ lines.push({ empty: true });
+ }
+ } else {
+ lines.push({ empty: false, content: raw });
+ }
+ }
+
+ return (
+ <>
+ {lines.map((line, i) => {
+ if (line.empty) {
+ return ;
+ }
+ const parts = line.content.split(
+ new RegExp(`${COPY_SENTINEL}(\\d+)${COPY_SENTINEL}`),
+ );
+ return (
+
+ {parts.map((part, j) => {
+ if (j % 2 === 1) {
+ const token = copyTokens[Number(part)];
+ return (
+
+
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ );
+ })}
+ >
+ );
+};
+
+export const Memory = () => {
+ const { data } = useBackend();
+ const {
+ name,
+ memory,
+ objective_groups,
+ individual_objectives,
+ la_explanation,
+ } = data;
+
+ const hasObjectives =
+ (objective_groups && objective_groups.length > 0) ||
+ (individual_objectives && individual_objectives.length > 0);
+
+ return (
+
+
+
+
+
+ {memory ? (
+
+ ) : (
+ No notes recorded.
+ )}
+
+
+ {hasObjectives && (
+
+ {objective_groups &&
+ objective_groups.map((group, i) => (
+
+
+ {group.objectives.map((obj, j) => (
+
+
+
+ Objective {j + 1}:
+
+
+
+
+ ))}
+
+
+ ))}
+ {individual_objectives && individual_objectives.length > 0 && (
+
+
+ {individual_objectives.map((obj) => (
+
+
+
+ #{obj.num} {obj.name}
+ {obj.limited_antag && obj.show_la && (
+
+ )}
+ :
+
+
+
+
+ ))}
+
+ {la_explanation && (
+ <>
+
+
+ >
+ )}
+
+ )}
+
+ )}
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/Vending.tsx b/tgui/packages/tgui/interfaces/Vending.tsx
index b87c854e37d..fa6f1d145ac 100644
--- a/tgui/packages/tgui/interfaces/Vending.tsx
+++ b/tgui/packages/tgui/interfaces/Vending.tsx
@@ -1,9 +1,11 @@
+import { useState } from 'react';
import { useBackend } from 'tgui/backend';
import {
BlockQuote,
Box,
Button,
Icon,
+ Input,
LabeledList,
Modal,
NoticeBox,
@@ -53,6 +55,8 @@ interface VendingData {
markup?: number;
speaker?: string;
advertisement?: string;
+ needsPin: boolean;
+ pinMode: string;
}
const managing = (managingData: ErrorData) => {
@@ -255,6 +259,61 @@ const pay = (vendingProduct: VendingProductData) => {
);
};
+const pinModal = () => {
+ const { act, data } = useBackend();
+ const [pin, setPin] = useState('');
+
+ const submit = () => {
+ if (!pin) return;
+ act('submit_pin', { pin });
+ setPin('');
+ };
+
+ return (
+
+
+
+
+ {data.pinMode === 'manage'
+ ? 'Authorization Required'
+ : 'PIN Required'}
+
+
+ Enter the PIN for this account to continue.
+
+ setPin(val)}
+ onEnter={submit}
+ />
+
+
+
+
+
+ Confirm
+
+
+
+ act('cancelpurchase')}
+ >
+ Cancel
+
+
+
+
+
+
+ );
+};
+
export const Vending = (props: any) => {
const { act, data } = useBackend();
@@ -297,7 +356,8 @@ export const Vending = (props: any) => {
- {(data.isVending && pay(data.vendingData)) || null}
+ {(data.needsPin && pinModal()) || null}
+ {(data.isVending && !data.needsPin && pay(data.vendingData)) || null}
);
};
diff --git a/tools/merge_dmi.py b/tools/merge_dmi.py
new file mode 100644
index 00000000000..330761ad15a
--- /dev/null
+++ b/tools/merge_dmi.py
@@ -0,0 +1,248 @@
+#!/usr/bin/env python3
+"""
+merge_dmi.py - Merges icon states from one DMI file into another.
+
+This script parses that metadata and the underlying sprite sheet to copy
+icon states from a source DMI into a target DMI, skipping any states that
+already exist in the target.
+
+Requirements:
+ pip install Pillow
+
+Usage:
+ python tools/merge_dmi.py
+
+The original target file is backed up as .bak before any changes
+are written. Only states missing from the target are added, existing states
+are never overwritten. Both DMI files must use the same icon size (width x
+height); states from a differently-sized source are skipped.
+
+Example:
+ python tools/merge_dmi.py icons/obj/bureaucracy-monke.dmi icons/obj/bureaucracy.dmi
+"""
+
+import io
+import math
+import shutil
+import struct
+import sys
+import zlib
+
+from PIL import Image
+
+
+def read_png_chunks(data):
+ """Read all PNG chunks from raw bytes, returning list of (type, data, crc)."""
+ if data[:8] != b'\x89PNG\r\n\x1a\n':
+ raise ValueError("Not a PNG file")
+ chunks = []
+ pos = 8
+ while pos < len(data):
+ length = struct.unpack('>I', data[pos:pos+4])[0]
+ chunk_type = data[pos+4:pos+8]
+ chunk_data = data[pos+8:pos+8+length]
+ crc = data[pos+8+length:pos+12+length]
+ chunks.append((chunk_type, chunk_data, crc))
+ pos += 12 + length
+ return chunks
+
+
+def get_dmi_description(filepath):
+ """Extract the BYOND DMI description string from a DMI file."""
+ with open(filepath, 'rb') as f:
+ data = f.read()
+ for chunk_type, chunk_data, _ in read_png_chunks(data):
+ if chunk_type == b'zTXt':
+ null_pos = chunk_data.index(b'\x00')
+ if chunk_data[:null_pos].decode('latin-1') == 'Description':
+ compressed = chunk_data[null_pos+2:] # skip null + compression method byte
+ return zlib.decompress(compressed).decode('latin-1')
+ elif chunk_type == b'tEXt':
+ null_pos = chunk_data.index(b'\x00')
+ if chunk_data[:null_pos].decode('latin-1') == 'Description':
+ return chunk_data[null_pos+1:].decode('latin-1')
+ return None
+
+
+def parse_dmi_description(desc):
+ """
+ Parse the BYOND DMI description block into a header dict and a list of
+ state dicts. Each state dict contains: name, dirs, frames, raw_lines.
+ raw_lines preserves the original metadata lines so they can be
+ round-tripped exactly into the output file.
+ """
+ header = {}
+ states = []
+ current_state = None
+
+ for line in desc.strip().split('\n'):
+ line = line.strip()
+ if line.startswith('# BEGIN DMI') or line.startswith('# END DMI'):
+ continue
+ if line.startswith('version'):
+ header['version'] = line.split('=')[1].strip()
+ elif line.startswith('width'):
+ header['width'] = int(line.split('=')[1].strip())
+ elif line.startswith('height'):
+ header['height'] = int(line.split('=')[1].strip())
+ elif line.startswith('state'):
+ if current_state is not None:
+ states.append(current_state)
+ name = line.split('=', 1)[1].strip().strip('"')
+ current_state = {'name': name, 'dirs': 1, 'frames': 1, 'raw_lines': [line]}
+ elif current_state is not None:
+ current_state['raw_lines'].append(line)
+ if line.startswith('dirs'):
+ current_state['dirs'] = int(line.split('=')[1].strip())
+ elif line.startswith('frames'):
+ current_state['frames'] = int(line.split('=')[1].strip())
+
+ if current_state is not None:
+ states.append(current_state)
+
+ return header, states
+
+
+def get_state_frame_count(state):
+ return state['dirs'] * state['frames']
+
+
+def extract_state_images(dmi_path, header, states):
+ """
+ Slice the sprite sheet into individual PIL images per state, returned as
+ a dict mapping state name -> list of frame images (in sheet order).
+ """
+ img = Image.open(dmi_path).convert('RGBA')
+ w, h = header['width'], header['height']
+ cols = img.width // w
+
+ result = {}
+ frame_index = 0
+ for state in states:
+ count = get_state_frame_count(state)
+ frames = []
+ for _ in range(count):
+ col = frame_index % cols
+ row = frame_index // cols
+ box = (col * w, row * h, (col + 1) * w, (row + 1) * h)
+ frames.append(img.crop(box))
+ frame_index += 1
+ result[state['name']] = frames
+
+ return result
+
+
+def build_dmi_description(header, states):
+ """Reconstruct the BYOND DMI description string from header and state list."""
+ lines = ['# BEGIN DMI']
+ lines.append(f"version = {header['version']}")
+ lines.append(f"\twidth = {header['width']}")
+ lines.append(f"\theight = {header['height']}")
+ for state in states:
+ for line in state['raw_lines']:
+ lines.append(f"\t{line}" if not line.startswith('state') else line)
+ lines.append('# END DMI')
+ return '\n'.join(lines) + '\n'
+
+
+def build_dmi(header, states, all_frames_map):
+ """
+ Assemble a complete DMI file (as bytes) from the given states and frames.
+ Lays out frames in a near-square grid, embeds the description as a zTXt
+ chunk before the first IDAT chunk (matching BYOND's format).
+ """
+ w, h = header['width'], header['height']
+ total_frames = sum(get_state_frame_count(s) for s in states)
+ cols = math.ceil(math.sqrt(total_frames))
+
+ sheet = Image.new('RGBA', (cols * w, math.ceil(total_frames / cols) * h), (0, 0, 0, 0))
+ frame_index = 0
+ for state in states:
+ for frame in all_frames_map[state['name']]:
+ col = frame_index % cols
+ row = frame_index // cols
+ sheet.paste(frame, (col * w, row * h))
+ frame_index += 1
+
+ # Encode as PNG first, then inject our Description zTXt chunk
+ png_bytes = io.BytesIO()
+ sheet.save(png_bytes, format='PNG', optimize=False)
+ chunks = read_png_chunks(png_bytes.getvalue())
+
+ desc = build_dmi_description(header, states)
+ keyword = b'Description\x00'
+ compressed_desc = zlib.compress(desc.encode('latin-1'))
+ ztxt_data = keyword + b'\x00' + compressed_desc
+ ztxt_crc = struct.pack('>I', zlib.crc32(b'zTXt' + ztxt_data) & 0xffffffff)
+ ztxt_chunk = struct.pack('>I', len(ztxt_data)) + b'zTXt' + ztxt_data + ztxt_crc
+
+ result = b'\x89PNG\r\n\x1a\n'
+ desc_injected = False
+ for chunk_type, chunk_data, crc in chunks:
+ # Drop any existing Description chunk
+ if chunk_type in (b'zTXt', b'tEXt'):
+ null_pos = chunk_data.find(b'\x00')
+ if null_pos >= 0 and chunk_data[:null_pos] == b'Description':
+ continue
+ # Inject ours just before image data
+ if chunk_type == b'IDAT' and not desc_injected:
+ result += ztxt_chunk
+ desc_injected = True
+ result += struct.pack('>I', len(chunk_data)) + chunk_type + chunk_data + crc
+
+ return result
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ sys.exit(1)
+
+ source_path = sys.argv[1]
+ target_path = sys.argv[2]
+
+ print(f"Reading source: {source_path}")
+ src_header, src_states = parse_dmi_description(get_dmi_description(source_path))
+ src_frames = extract_state_images(source_path, src_header, src_states)
+ print(f" {len(src_states)} states found")
+
+ print(f"Reading target: {target_path}")
+ tgt_header, tgt_states = parse_dmi_description(get_dmi_description(target_path))
+ tgt_frames = extract_state_images(target_path, tgt_header, tgt_states)
+ print(f" {len(tgt_states)} states found")
+
+ tgt_state_names = {s['name'] for s in tgt_states}
+ to_add = [s for s in src_states if s['name'] not in tgt_state_names]
+
+ if not to_add:
+ print("\nNothing to do - all source states already exist in target.")
+ return
+
+ print(f"\nStates to add ({len(to_add)}): {[s['name'] for s in to_add]}")
+
+ if src_header['width'] != tgt_header['width'] or src_header['height'] != tgt_header['height']:
+ print(
+ f"\nERROR: Icon sizes differ - "
+ f"source is {src_header['width']}x{src_header['height']}, "
+ f"target is {tgt_header['width']}x{tgt_header['height']}. Aborting."
+ )
+ sys.exit(1)
+
+ merged_states = tgt_states + to_add
+ merged_frames = {**tgt_frames, **{s['name']: src_frames[s['name']] for s in to_add}}
+
+ print(f"\nBuilding merged DMI ({len(merged_states)} states total)...")
+ result = build_dmi(tgt_header, merged_states, merged_frames)
+
+ backup_path = target_path + '.bak'
+ shutil.copy2(target_path, backup_path)
+ print(f"Backed up original to: {backup_path}")
+
+ with open(target_path, 'wb') as f:
+ f.write(result)
+
+ print(f"Done. Added {len(to_add)} new states to {target_path}")
+
+
+if __name__ == '__main__':
+ main()