diff --git a/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUi.cs b/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUi.cs
new file mode 100644
index 00000000000..6300df87450
--- /dev/null
+++ b/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUi.cs
@@ -0,0 +1,29 @@
+using Content.Client.UserInterface.Fragments;
+using Content.Shared.CartridgeLoader.Cartridges;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._NF.CartridgeLoader.Cartridges;
+
+public sealed partial class AppraisalUi : UIFragment
+{
+ private AppraisalUiFragment? _fragment;
+
+ public override Control GetUIFragmentRoot()
+ {
+ return _fragment!;
+ }
+
+ public override void Setup(BoundUserInterface userInterface, EntityUid? fragmentOwner)
+ {
+ _fragment = new AppraisalUiFragment();
+ }
+
+ public override void UpdateState(BoundUserInterfaceState state)
+ {
+ if (state is not AppraisalUiState appraisalUiState)
+ return;
+
+ _fragment?.UpdateState(appraisalUiState.AppraisedItems);
+ }
+}
diff --git a/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUiFragment.xaml b/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUiFragment.xaml
new file mode 100644
index 00000000000..50d478cdb34
--- /dev/null
+++ b/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUiFragment.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUiFragment.xaml.cs b/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUiFragment.xaml.cs
new file mode 100644
index 00000000000..fbffdce9450
--- /dev/null
+++ b/Content.Client/_NF/CartridgeLoader/Cartridges/AppraisalUiFragment.xaml.cs
@@ -0,0 +1,65 @@
+using Content.Shared.CartridgeLoader.Cartridges;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._NF.CartridgeLoader.Cartridges;
+
+[GenerateTypedNameReferences]
+public sealed partial class AppraisalUiFragment : BoxContainer
+{
+ private readonly StyleBoxFlat _styleBox = new()
+ {
+ BackgroundColor = Color.Transparent,
+ BorderColor = Color.FromHex("#5a5a5a"),
+ BorderThickness = new Thickness(0, 0, 0, 1)
+ };
+
+ public AppraisalUiFragment()
+ {
+ RobustXamlLoader.Load(this);
+ Orientation = LayoutOrientation.Vertical;
+ HorizontalExpand = true;
+ VerticalExpand = true;
+ HeaderPanel.PanelOverride = _styleBox;
+ }
+
+ public void UpdateState(List items)
+ {
+ AppraisedItemContainer.RemoveAllChildren();
+
+ //Reverse the list so the oldest entries appear at the bottom
+ items.Reverse();
+
+ //Enable scrolling if there are more entries that can fit on the screen
+ ScrollContainer.HScrollEnabled = items.Count > 9;
+
+ foreach (var item in items)
+ {
+ AddAppraisedItem(item);
+ }
+ }
+
+ private void AddAppraisedItem(AppraisedItem item)
+ {
+ var row = new BoxContainer();
+ row.HorizontalExpand = true;
+ row.Orientation = LayoutOrientation.Horizontal;
+ row.Margin = new Thickness(4);
+
+ var nameLabel = new Label();
+ nameLabel.Text = item.Name;
+ nameLabel.HorizontalExpand = true;
+ nameLabel.ClipText = true;
+ row.AddChild(nameLabel);
+
+ var valueLabel = new Label();
+ valueLabel.Text = item.AppraisedPrice;
+ valueLabel.HorizontalExpand = true;
+ valueLabel.ClipText = true;
+ row.AddChild(valueLabel);
+
+ AppraisedItemContainer.AddChild(row);
+ }
+}
diff --git a/Content.Server/_NF/CartridgeLoader/Cartridges/AppraisalCartridgeComponent.cs b/Content.Server/_NF/CartridgeLoader/Cartridges/AppraisalCartridgeComponent.cs
new file mode 100644
index 00000000000..d0b99f29152
--- /dev/null
+++ b/Content.Server/_NF/CartridgeLoader/Cartridges/AppraisalCartridgeComponent.cs
@@ -0,0 +1,23 @@
+using Content.Shared.CartridgeLoader.Cartridges;
+using Robust.Shared.Audio;
+
+namespace Content.Server.CartridgeLoader.Cartridges;
+
+[RegisterComponent]
+public sealed partial class AppraisalCartridgeComponent : Component
+{
+ ///
+ /// The list of appraised items
+ ///
+ [DataField("appraisedItems")]
+ public List AppraisedItems = new();
+
+ ///
+ /// Limits the amount of items that can be saved
+ ///
+ [DataField("maxSavedItems")]
+ public int MaxSavedItems { get; set; } = 9;
+
+ [DataField("soundScan")]
+ public SoundSpecifier SoundScan = new SoundPathSpecifier("/Audio/Machines/scan_finish.ogg");
+}
diff --git a/Content.Server/_NF/CartridgeLoader/Cartridges/AppraisalCartridgeSystem.cs b/Content.Server/_NF/CartridgeLoader/Cartridges/AppraisalCartridgeSystem.cs
new file mode 100644
index 00000000000..0e9973aae97
--- /dev/null
+++ b/Content.Server/_NF/CartridgeLoader/Cartridges/AppraisalCartridgeSystem.cs
@@ -0,0 +1,105 @@
+using Content.Server.Cargo.Systems;
+using Content.Shared.Audio;
+using Content.Shared.CartridgeLoader;
+using Content.Shared.CartridgeLoader.Cartridges;
+using Content.Shared.Popups;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+using Content.Server.Cargo.Components;
+using Content.Shared.Timing;
+
+namespace Content.Server.CartridgeLoader.Cartridges;
+
+public sealed class AppraisalCartridgeSystem : EntitySystem
+{
+ [Dependency] private readonly CargoSystem _bountySystem = default!;
+ [Dependency] private readonly CartridgeLoaderSystem? _cartridgeLoaderSystem = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly PricingSystem _pricingSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnUiReady);
+ SubscribeLocalEvent(AfterInteract);
+ SubscribeLocalEvent(OnCartridgeActivated);
+ SubscribeLocalEvent(OnCartridgeDeactivated);
+ }
+
+ // Kinda jank, but easiest way to get the right-click Appraisal verb to also work.
+ // I'd much rather pass the GetUtilityVerb event through to the AppraisalCartridgeSystem and have all of
+ // the functionality in there, rather than adding a PriceGunComponent to the PDA itself, but getting
+ // that passthrough to work is not a straightforward thing.
+
+ // Because of this weird workaround, items appraised with the right-click utility verb don't get added
+ // to the history in the UI. That'll be something to revisit someday if anyone notices and complains :P
+
+ // Doing this on cartridge activation and deactivation rather than install and remove so that the price
+ // gun functionality is only there when the program is active.
+ private void OnCartridgeActivated(Entity ent, ref CartridgeActivatedEvent args)
+ {
+ EnsureComp(args.Loader);
+ // PriceGunSystem methods exit early if a DelayComponent is not present
+ EnsureComp(args.Loader);
+ }
+
+ private void OnCartridgeDeactivated(Entity ent, ref CartridgeDeactivatedEvent args)
+ {
+ var parent = Transform(args.Loader).ParentUid;
+ RemComp(parent);
+ RemComp(parent);
+ }
+
+ ///
+ /// The gets relayed to this system if the cartridge loader is running
+ /// the Appraisal program and someone clicks on something with it.
+ ///
+ /// Does the thing... TODO
+ ///
+ private void AfterInteract(EntityUid uid, AppraisalCartridgeComponent component, CartridgeAfterInteractEvent args)
+ {
+ if (args.InteractEvent.Handled || !args.InteractEvent.CanReach || !args.InteractEvent.Target.HasValue)
+ return;
+
+ var target = args.InteractEvent.Target;
+ var who = args.InteractEvent.User;
+ double price = 0.00;
+
+ // All of the pop up display stuff is being handled by the PriceGunComponent addded to the PDA,
+ // all we're doing in here is getting the price and recording it to the PDA interface bit.
+ price = _pricingSystem.GetPrice(target.Value);
+
+ //Limit the amount of saved probe results to 9
+ //This is hardcoded because the UI doesn't support a dynamic number of results
+ if (component.AppraisedItems.Count >= component.MaxSavedItems)
+ component.AppraisedItems.RemoveAt(0);
+
+ var item = new AppraisedItem(
+ Name(target.Value),
+ price.ToString("0.00")
+ );
+
+ component.AppraisedItems.Add(item);
+ UpdateUiState(uid, args.Loader, component);
+ }
+
+ ///
+ /// This gets called when the ui fragment needs to be updated for the first time after activating
+ ///
+ private void OnUiReady(EntityUid uid, AppraisalCartridgeComponent component, CartridgeUiReadyEvent args)
+ {
+ UpdateUiState(uid, args.Loader, component);
+ }
+
+ private void UpdateUiState(EntityUid uid, EntityUid loaderUid, AppraisalCartridgeComponent? component)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ var state = new AppraisalUiState(component.AppraisedItems);
+ _cartridgeLoaderSystem?.UpdateCartridgeUiState(loaderUid, state);
+ }
+}
diff --git a/Content.Shared/_NF/CartridgeLoader/Cartridges/AppraisalUiState.cs b/Content.Shared/_NF/CartridgeLoader/Cartridges/AppraisalUiState.cs
new file mode 100644
index 00000000000..41b9ed09477
--- /dev/null
+++ b/Content.Shared/_NF/CartridgeLoader/Cartridges/AppraisalUiState.cs
@@ -0,0 +1,30 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.CartridgeLoader.Cartridges;
+
+[Serializable, NetSerializable]
+public sealed class AppraisalUiState : BoundUserInterfaceState
+{
+ ///
+ /// The list of appraised items
+ ///
+ public List AppraisedItems;
+
+ public AppraisalUiState(List appraisedItems)
+ {
+ AppraisedItems = appraisedItems;
+ }
+}
+
+[Serializable, NetSerializable, DataRecord]
+public sealed class AppraisedItem
+{
+ public readonly string Name;
+ public readonly string AppraisedPrice;
+
+ public AppraisedItem(string name, string appraisedPrice)
+ {
+ Name = name;
+ AppraisedPrice = appraisedPrice;
+ }
+}
diff --git a/Resources/Locale/en-US/_NF/cartridge-loader/cartridges.ftl b/Resources/Locale/en-US/_NF/cartridge-loader/cartridges.ftl
new file mode 100644
index 00000000000..99a458e07a2
--- /dev/null
+++ b/Resources/Locale/en-US/_NF/cartridge-loader/cartridges.ftl
@@ -0,0 +1,4 @@
+# Appraisal cartridge
+appraisal-program-name = Appraisal App Plus
+appraisal-label-name = Item
+appraisal-label-price = Appraised Price
diff --git a/Resources/Prototypes/_NF/Catalog/VendingMachines/Inventories/astrovend.yml b/Resources/Prototypes/_NF/Catalog/VendingMachines/Inventories/astrovend.yml
index 75cfd619224..d339a11f15c 100644
--- a/Resources/Prototypes/_NF/Catalog/VendingMachines/Inventories/astrovend.yml
+++ b/Resources/Prototypes/_NF/Catalog/VendingMachines/Inventories/astrovend.yml
@@ -13,6 +13,7 @@
JetpackMiniFilled: 10
JetpackBlue: 10
HandHeldMassScanner: 10
+ AppraisalCartridge: 3
# Keys
EncryptionKeyCommon: 3
EncryptionKeyTraffic: 3
@@ -53,6 +54,7 @@
JetpackMiniFilled: 4294967295 # Infinite
JetpackBlue: 4294967295 # Infinite
HandHeldMassScanner: 4294967295 # Infinite
+ AppraisalCartridge: 4294967295 # Infinite
# Keys
EncryptionKeyCommon: 4294967295 # Infinite
EncryptionKeyTraffic: 4294967295 # Infinite
diff --git a/Resources/Prototypes/_NF/Entities/Objects/Devices/cartridges.yml b/Resources/Prototypes/_NF/Entities/Objects/Devices/cartridges.yml
index 643d986aa27..065a9ac25fb 100644
--- a/Resources/Prototypes/_NF/Entities/Objects/Devices/cartridges.yml
+++ b/Resources/Prototypes/_NF/Entities/Objects/Devices/cartridges.yml
@@ -19,6 +19,25 @@
- type: AccessReader
access: [["HeadOfSecurity"], ["HeadOfPersonnel"]]
+- type: entity
+ parent: BaseItem
+ id: AppraisalCartridge
+ name: appraisal cartridge
+ description: A program for appraising the monetary value of items
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/cartridge.rsi
+ state: cart-y
+ - type: Icon
+ sprite: Objects/Devices/cartridge.rsi
+ state: cart-y
+ - type: UIFragment
+ ui: !type:AppraisalUi
+ - type: Cartridge
+ programName: appraisal-program-name
+ icon: Interface/Actions/shop.png
+ - type: AppraisalCartridge
+
# Not a PDA cartridge (then why is this here)
- type: entity
parent: BaseItem