diff --git a/data/libs/EquipSet.lua b/data/libs/EquipSet.lua
index 3107e1a513..9304729636 100644
--- a/data/libs/EquipSet.lua
+++ b/data/libs/EquipSet.lua
@@ -442,6 +442,7 @@ end
 ---@param slotHandle HullConfig.Slot?
 ---@return boolean
 function EquipSet:Install(equipment, slotHandle)
+	assert(equipment:isInstance())
 	local slotId = self.idCache[slotHandle]
 
 	if slotHandle then
diff --git a/data/libs/EquipType.lua b/data/libs/EquipType.lua
index 374c8ef359..989feb2936 100644
--- a/data/libs/EquipType.lua
+++ b/data/libs/EquipType.lua
@@ -161,6 +161,14 @@ function EquipType:isProto()
 	return not rawget(self, "__proto")
 end
 
+-- Method: isInstance
+--
+-- Returns true if this object is an equipment item instance, false if it is
+-- n prototype.
+function EquipType:isInstance()
+	return rawget(self, "__proto") ~= nil
+end
+
 -- Method: GetPrototype
 --
 -- Return the prototype this equipment item instance is derived from, or the
@@ -190,6 +198,7 @@ end
 -- instance represents.
 ---@param count integer
 function EquipType:SetCount(count)
+	assert(self:isInstance())
 	local proto = self:GetPrototype()
 
 	self.mass = proto.mass * count
diff --git a/data/libs/Event.lua b/data/libs/Event.lua
index ec22cbb678..099b2f0e79 100644
--- a/data/libs/Event.lua
+++ b/data/libs/Event.lua
@@ -240,6 +240,28 @@ Event.New = function()
 	return self
 end
 
+--
+-- Event: onEnterMainMenu
+--
+-- Triggered when the menu is loaded.
+--
+-- > local onMenuLoaded = function () ... end
+-- > Event.Register("onEnterMainMenu", onMenuLoaded)
+--
+-- onMenuLoaded is triggered once the menu is fully available
+--
+-- This is a good place to perform startup checks, including checking for
+-- errors and making them visible to the player
+--
+-- Availability:
+--
+--   2025-02-03
+--
+-- Status:
+--
+--   stable
+--
+
 --
 -- Event: onGameStart
 --
diff --git a/data/libs/HullConfig.lua b/data/libs/HullConfig.lua
index 1c8ad21971..cbdc9a531b 100644
--- a/data/libs/HullConfig.lua
+++ b/data/libs/HullConfig.lua
@@ -23,6 +23,7 @@ Slot.size = 1
 Slot.size_min = nil ---@type number?
 Slot.tag = nil ---@type string?
 Slot.default = nil ---@type string?
+Slot.required = false ---@type boolean
 Slot.hardpoint = false
 Slot.i18n_key = nil ---@type string?
 Slot.i18n_res = "equipment-core"
@@ -41,6 +42,7 @@ Slot.gimbal = nil ---@type table?
 local HullConfig = utils.proto("HullConfig")
 
 HullConfig.id = ""
+HullConfig.path = ""
 HullConfig.equipCapacity = 0
 
 -- Default slot config for a new shipdef
@@ -71,6 +73,7 @@ local function CreateShipConfig(def)
 	Serializer:RegisterPersistent("ShipDef." .. def.id, newShip)
 
 	newShip.id = def.id
+	newShip.path = def.path
 	newShip.equipCapacity = def.equipCapacity
 
 	table.merge(newShip.slots, def.raw.equipment_slots or {}, function(name, slotDef)
diff --git a/data/libs/utils.lua b/data/libs/utils.lua
index cbe6e9964e..5782773f9b 100644
--- a/data/libs/utils.lua
+++ b/data/libs/utils.lua
@@ -11,6 +11,24 @@ local Engine = require 'Engine' -- rand
 --
 local utils = {}
 
+--
+-- Function: keys
+--
+-- Create an iterator that returns a numberic index and the keys of the
+-- provided table. Iteration order is undefined (uses pairs() internally).
+--
+-- Example:
+--   > for i, k in utils.keys(table) do ... end
+--
+function utils.keys(table)
+	local k = nil
+	local f = function(s, i)
+		k = next(s, k)
+		return (k and i + 1 or nil), k
+	end
+	return f, table, 0
+end
+
 --
 -- Function: numbered_keys
 --
diff --git a/data/meta/ShipDef.lua b/data/meta/ShipDef.lua
index 433e132631..b5bfdf7def 100644
--- a/data/meta/ShipDef.lua
+++ b/data/meta/ShipDef.lua
@@ -9,6 +9,7 @@
 
 ---@class ShipDef
 ---@field id string
+---@field path string
 ---@field name string
 ---@field shipClass string
 ---@field manufacturer string
diff --git a/data/modules/Debug/CheckShipData.lua b/data/modules/Debug/CheckShipData.lua
new file mode 100644
index 0000000000..1811a59c48
--- /dev/null
+++ b/data/modules/Debug/CheckShipData.lua
@@ -0,0 +1,148 @@
+-- Copyright © 2008-2025 Pioneer Developers. See AUTHORS.txt for details
+-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt
+
+local Equipment  = require 'Equipment'
+local HullConfig = require 'HullConfig'
+local Loader     = require '.DebugLoader'
+local EquipSet   = require 'EquipSet'
+local Lang       = require 'Lang'
+local ShipDef    = require 'ShipDef'
+
+local utils      = require 'utils'
+
+-- This file implements validation passes for ship JSON files
+-- It's intended to catch most common errors, especially those that would be
+-- difficult to find outside of switching to each ship type in sequence.
+
+local activeFile = nil
+
+local error = function(message) Loader.LogFileMessage(Loader.Type.Error, activeFile, message) end
+local warn = function(message) Loader.LogFileMessage(Loader.Type.Warn, activeFile, message) end
+local info = function(message) Loader.LogFileMessage(Loader.Type.Info, activeFile, message) end
+
+local function findMatchingSlots(config, type)
+	return utils.filter_table(config.slots, function(_, slot)
+		return EquipSet.SlotTypeMatches(slot.type, type)
+	end)
+end
+
+---@param slot HullConfig.Slot
+local function checkSlot(slot)
+
+	if string.match(slot.id, "##") then
+		error("Slot {id} name contains invalid sequence '##'." % slot)
+	end
+
+	if not string.match(slot.id, "^[a-zA-Z0-9_]+$") then
+		warn("Slot {id} name contains non-identifier characters." % slot)
+	end
+
+	if slot.required and not slot.default then
+		error("Slot {id} is a required slot but does not have a default equipment item." % slot)
+	end
+
+	if slot.default and not Equipment.Get(slot.default) then
+		error("Slot {id} default item ({default}) does not exist." % slot)
+	end
+
+	if EquipSet.SlotTypeMatches(slot.type, "hyperdrive") and not slot.default then
+		warn("Slot {id} has no default hyperdrive equipment." % slot)
+	end
+
+	if slot.i18n_key then
+		if not slot.i18n_res then
+			error("Slot {id} has an invalid language resource key {i18n_res}." % slot)
+		end
+
+		local res = Lang.GetResource(slot.i18n_res)
+
+		if not rawget(res, slot.i18n_key) then
+			warn("Slot {id} uses undefined lang string '{i18n_res}.{i18n_key}'." % slot)
+		end
+	end
+
+	local isWeaponType = EquipSet.SlotTypeMatches(slot.type, "weapon")
+	local isPylonType = EquipSet.SlotTypeMatches(slot.type, "pylon")
+	local isBayType = EquipSet.SlotTypeMatches(slot.type, "missile_bay")
+	local isScoopType = EquipSet.SlotTypeMatches(slot.type, "fuel_scoop")
+
+	local isExternal = isWeaponType or isPylonType or isBayType or isScoopType
+
+	if isExternal then
+
+		if not slot.hardpoint then
+			error("External slot {id} with type {type} should have hardpoint=true." % slot)
+		end
+
+		if not slot.tag then
+			info("External slot {id} with type {type} is missing an associated tag." % slot)
+		end
+
+	end
+
+	if isWeaponType then
+
+		if not slot.gimbal or type(slot.gimbal) ~= "table" then
+			error("Weapon slot {id} is missing gimbal data." % slot)
+		elseif type(slot.gimbal[1]) ~= "number" or type(slot.gimbal[2]) ~= "number" then
+			error("Weapon slot {id} should have a two-axis gimbal expressed as [x, y]." % slot)
+		end
+
+	end
+
+end
+
+---@param config HullConfig
+local function checkConfig(config)
+	if utils.count(findMatchingSlots(config, "hyperdrive")) > 1 then
+		error("Ship {id} has more than one hyperdrive slot; this will break module code." % config)
+	end
+
+	if utils.count(findMatchingSlots(config, "thruster")) == 0 then
+		warn("Ship {id} has no thruster slots. This may break in the future.")
+	end
+
+	-- TODO: more validation passes on the whole ship config
+end
+
+---@param shipDef ShipDef
+local function checkShipDef(shipDef)
+	if shipDef.tag ~= "SHIP" then
+		return
+	end
+
+	if utils.count(shipDef.roles) == 0 then
+		info("Ship {id} has no roles and will not be used by most modules." % shipDef)
+	end
+
+	if shipDef.minCrew > shipDef.maxCrew then
+		error("Ship {id} has minCrew {minCrew} > maxCrew {maxCrew}." % shipDef)
+	end
+
+	if not shipDef.shipClass or shipDef.shipClass == "" then
+		warn("Ship {id} has invalid/empty ship_class field." % shipDef)
+	end
+
+	if not shipDef.manufacturer then
+		info("Ship {id} has no manufacturer set." % shipDef)
+	end
+end
+
+Loader.RegisterCheck("HullConfigs", function()
+
+	local configs = HullConfig.GetHullConfigs()
+
+	for _, config in pairs(configs) do
+		activeFile = config.path
+
+		for _, slot in pairs(config.slots) do checkSlot(slot) end
+		checkConfig(config)
+	end
+
+	for _, shipDef in pairs(ShipDef) do
+		activeFile = shipDef.path
+
+		checkShipDef(shipDef)
+	end
+
+end)
diff --git a/data/modules/Debug/DebugLoader.lua b/data/modules/Debug/DebugLoader.lua
new file mode 100644
index 0000000000..6303f18f15
--- /dev/null
+++ b/data/modules/Debug/DebugLoader.lua
@@ -0,0 +1,185 @@
+-- Copyright © 2008-2025 Pioneer Developers. See AUTHORS.txt for details
+-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt
+
+local Event = require 'Event'
+local utils = require 'utils'
+
+local ui = require 'pigui'
+
+local colors = ui.theme.colors
+local icons = ui.theme.icons
+
+local Notification = require 'pigui.libs.notification'
+local debugView    = require 'pigui.views.debug'
+
+local messageData = {
+	log = {},
+	bySource = utils.automagic()
+}
+
+local activeLoader = nil
+local messageCount = {}
+
+--=============================================================================
+
+local DebugLoader = {}
+
+DebugLoader.Type = {
+	Error = "ERROR",
+	Warn = "WARN",
+	Info = "INFO"
+}
+
+DebugLoader.checks = {}
+
+---@param name string
+---@param func fun()
+function DebugLoader.RegisterCheck(name, func)
+	table.insert(DebugLoader.checks, {
+		id = name,
+		run = func
+	})
+end
+
+---@param type string One of DebugLoader.Type
+---@param message string
+function DebugLoader.LogMessage(type, message)
+	table.insert(messageData.log, { type, message, activeLoader })
+	messageCount[type] = (messageCount[type] or 0) + 1
+
+	print("validation {}: {}" % { type, message })
+end
+
+---@param type string One of DebugLoader.Type
+---@param message string
+function DebugLoader.LogFileMessage(type, source, message)
+	table.insert(messageData.bySource[source], { type, message, activeLoader })
+	table.insert(messageData.log, { type, message, source })
+	messageCount[type] = (messageCount[type] or 0) + 1
+
+	print("validation {} [{}]: {}" % { type, source, message })
+end
+
+--=============================================================================
+
+local function scanForErrors()
+	for _, check in ipairs(DebugLoader.checks) do
+		activeLoader = check.id
+		check.run()
+	end
+end
+
+--=============================================================================
+
+local DebugLoaderUI = utils.class("Debug.LoaderUI", require 'pigui.libs.module')
+
+local msg_order = {
+	DebugLoader.Type.Error,
+	DebugLoader.Type.Warn,
+	DebugLoader.Type.Info,
+}
+
+local msg_icons = {
+	[DebugLoader.Type.Error] = icons.alert_generic,
+	[DebugLoader.Type.Warn] = icons.view_internal,
+	[DebugLoader.Type.Info] = icons.info
+}
+
+local msg_colors = {
+	[DebugLoader.Type.Error] = ui.theme.styleColors.danger_300,
+	[DebugLoader.Type.Warn] = ui.theme.styleColors.warning_300,
+	[DebugLoader.Type.Info] = colors.font
+}
+
+function DebugLoaderUI:Constructor()
+	self.Super().Constructor(self)
+
+	self.activeSource = nil
+end
+
+function DebugLoaderUI:setActiveSource(source)
+	self.activeSource = source
+end
+
+function DebugLoaderUI:render()
+	ui.horizontalGroup(function()
+		for _, type in ipairs(msg_order) do
+			ui.icon(msg_icons[type], Vector2(ui.getTextLineHeight()), msg_colors[type])
+			ui.text(ui.Format.Number(messageCount[type] or 0, 0))
+		end
+	end)
+
+	local function getSourceName(source)
+		return "{} ({})" % { source or "All", ui.Format.Number((source and #messageData.bySource[source] or #messageData.log), 0) }
+	end
+
+	local sourceList = utils.build_array(utils.keys(messageData.bySource))
+	table.sort(sourceList)
+
+	ui.comboBox("Source Filter", getSourceName(self.activeSource), function()
+		if ui.selectable(getSourceName(nil)) then
+			self:message("setActiveSource", nil)
+		end
+
+		for _, source in ipairs(sourceList) do
+			if ui.selectable(getSourceName(source)) then
+				self:message("setActiveSource", source)
+			end
+		end
+	end)
+
+	ui.separator()
+
+	ui.child("MessageView", function()
+
+		ui.pushTextWrapPos(ui.getContentRegion().x)
+
+		local source = self.activeSource and messageData.bySource[self.activeSource] or messageData.log
+
+		for _, log in ipairs(source) do
+			local type, msg, src = log[1], log[2], log[3]
+			ui.textColored(msg_colors[type], "[{}] {} {}" % { src, ui.get_icon_glyph(msg_icons[type]), msg })
+		end
+
+		ui.popTextWrapPos()
+
+	end)
+end
+
+--=============================================================================
+
+Event.Register("onEnterMainMenu", function()
+	-- Reset messages on menu load so they don't accumulate after each new game
+	messageData = {
+		log = {},
+		bySource = utils.automagic()
+	}
+
+	messageCount = {}
+
+	scanForErrors()
+
+	local message_body = "{} errors, {} warnings generated. See the debug Loading Messages tab (Ctrl+I) for more information."
+
+	local numErrors = messageCount[DebugLoader.Type.Error] or 0
+	local numWarnings = messageCount[DebugLoader.Type.Warn] or 0
+
+	if numErrors > 0 or numWarnings > 0 then
+		Notification.add(Notification.Type.Error, "Validation Issues Found",
+			message_body % { numErrors, numWarnings },
+			icons.repairs)
+	end
+end)
+
+debugView.registerTab("Loader", {
+	label = "Loading Messages",
+	icon = icons.repairs,
+	debugUI = DebugLoaderUI.New(),
+	show = function() return utils.count(messageCount) > 0 end,
+	draw = function(self)
+		self.debugUI:update()
+		self.debugUI:render()
+	end
+})
+
+return DebugLoader
diff --git a/data/modules/Debug/DebugShip.lua b/data/modules/Debug/DebugShip.lua
index ee471d789e..3dad4f90c9 100644
--- a/data/modules/Debug/DebugShip.lua
+++ b/data/modules/Debug/DebugShip.lua
@@ -366,6 +366,9 @@ function DebugShipTool:drawSlotDetail(slot)
 		drawSlotValue(slot, "size_min")
 		drawSlotValue(slot, "tag")
 		drawSlotValue(slot, "default")
+		if slot.required then
+			drawSlotValue(slot, "required")
+		end
 		drawSlotValue(slot, "hardpoint")
 		drawSlotValue(slot, "count")
 
diff --git a/data/modules/Equipment/Internal.lua b/data/modules/Equipment/Internal.lua
index d21204bb0e..39047b55d3 100644
--- a/data/modules/Equipment/Internal.lua
+++ b/data/modules/Equipment/Internal.lua
@@ -126,7 +126,7 @@ Equipment.Register("misc.hull_autorepair", EquipType.New {
 --===============================================
 
 -- S1 thrusters
-Equipment.Register("misc.thrusters_default_s1", ThrusterType.New {
+Equipment.Register("thruster.default_s1", ThrusterType.New {
 	l10n_key="THRUSTERS_DEFAULT", slots="thruster",
 	price=120, purchasable=true, tech_level=2,
 	slot = { type="thruster", size=1 },
@@ -134,7 +134,7 @@ Equipment.Register("misc.thrusters_default_s1", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_improved_s1", ThrusterType.New {
+Equipment.Register("thruster.improved_s1", ThrusterType.New {
 	l10n_key="THRUSTERS_IMPROVED", slots="thruster",
 	price=250, purchasable=true, tech_level=5,
 	slot = { type="thruster", size=1 },
@@ -142,7 +142,7 @@ Equipment.Register("misc.thrusters_improved_s1", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_optimised_s1", ThrusterType.New {
+Equipment.Register("thruster.optimised_s1", ThrusterType.New {
 	l10n_key="THRUSTERS_OPTIMISED", slots="thruster",
 	price=560, purchasable=true, tech_level=8,
 	slot = { type="thruster", size=1 },
@@ -150,7 +150,7 @@ Equipment.Register("misc.thrusters_optimised_s1", ThrusterType.New {
 	icon_name="equip_thrusters_medium"
 })
 
-Equipment.Register("misc.thrusters_naval_s1", ThrusterType.New {
+Equipment.Register("thruster.naval_s1", ThrusterType.New {
 	l10n_key="THRUSTERS_NAVAL", slots="thruster",
 	price=1400, purchasable=true, tech_level="MILITARY",
 	slot = { type="thruster", size=1 },
@@ -159,7 +159,7 @@ Equipment.Register("misc.thrusters_naval_s1", ThrusterType.New {
 })
 
 -- S2 thrusters
-Equipment.Register("misc.thrusters_default_s2", ThrusterType.New {
+Equipment.Register("thruster.default_s2", ThrusterType.New {
 	l10n_key="THRUSTERS_DEFAULT", slots="thruster",
 	price=220, purchasable=true, tech_level=2,
 	slot = { type="thruster", size=2 },
@@ -167,7 +167,7 @@ Equipment.Register("misc.thrusters_default_s2", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_improved_s2", ThrusterType.New {
+Equipment.Register("thruster.improved_s2", ThrusterType.New {
 	l10n_key="THRUSTERS_IMPROVED", slots="thruster",
 	price=460, purchasable=true, tech_level=5,
 	slot = { type="thruster", size=2 },
@@ -175,7 +175,7 @@ Equipment.Register("misc.thrusters_improved_s2", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_optimised_s2", ThrusterType.New {
+Equipment.Register("thruster.optimised_s2", ThrusterType.New {
 	l10n_key="THRUSTERS_OPTIMISED", slots="thruster",
 	price=1025, purchasable=true, tech_level=8,
 	slot = { type="thruster", size=2 },
@@ -183,7 +183,7 @@ Equipment.Register("misc.thrusters_optimised_s2", ThrusterType.New {
 	icon_name="equip_thrusters_medium"
 })
 
-Equipment.Register("misc.thrusters_naval_s2", ThrusterType.New {
+Equipment.Register("thruster.naval_s2", ThrusterType.New {
 	l10n_key="THRUSTERS_NAVAL", slots="thruster",
 	price=2565, purchasable=true, tech_level="MILITARY",
 	slot = { type="thruster", size=2 },
@@ -192,7 +192,7 @@ Equipment.Register("misc.thrusters_naval_s2", ThrusterType.New {
 })
 
 -- S3 thrusters
-Equipment.Register("misc.thrusters_default_s3", ThrusterType.New {
+Equipment.Register("thruster.default_s3", ThrusterType.New {
 	l10n_key="THRUSTERS_DEFAULT", slots="thruster",
 	price=420, purchasable=true, tech_level=2,
 	slot = { type="thruster", size=3 },
@@ -200,7 +200,7 @@ Equipment.Register("misc.thrusters_default_s3", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_improved_s3", ThrusterType.New {
+Equipment.Register("thruster.improved_s3", ThrusterType.New {
 	l10n_key="THRUSTERS_IMPROVED", slots="thruster",
 	price=880, purchasable=true, tech_level=5,
 	slot = { type="thruster", size=3 },
@@ -208,7 +208,7 @@ Equipment.Register("misc.thrusters_improved_s3", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_optimised_s3", ThrusterType.New {
+Equipment.Register("thruster.optimised_s3", ThrusterType.New {
 	l10n_key="THRUSTERS_OPTIMISED", slots="thruster",
 	price=1950, purchasable=true, tech_level=8,
 	slot = { type="thruster", size=3 },
@@ -216,7 +216,7 @@ Equipment.Register("misc.thrusters_optimised_s3", ThrusterType.New {
 	icon_name="equip_thrusters_medium"
 })
 
-Equipment.Register("misc.thrusters_naval_s3", ThrusterType.New {
+Equipment.Register("thruster.naval_s3", ThrusterType.New {
 	l10n_key="THRUSTERS_NAVAL", slots="thruster",
 	price=4970, purchasable=true, tech_level="MILITARY",
 	slot = { type="thruster", size=3 },
@@ -225,7 +225,7 @@ Equipment.Register("misc.thrusters_naval_s3", ThrusterType.New {
 })
 
 -- S4 Thrusters
-Equipment.Register("misc.thrusters_default_s4", ThrusterType.New {
+Equipment.Register("thruster.default_s4", ThrusterType.New {
 	l10n_key="THRUSTERS_DEFAULT", slots="thruster",
 	price=880, purchasable=true, tech_level=2,
 	slot = { type="thruster", size=4 },
@@ -233,7 +233,7 @@ Equipment.Register("misc.thrusters_default_s4", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_improved_s4", ThrusterType.New {
+Equipment.Register("thruster.improved_s4", ThrusterType.New {
 	l10n_key="THRUSTERS_IMPROVED", slots="thruster",
 	price=1850, purchasable=true, tech_level=5,
 	slot = { type="thruster", size=4 },
@@ -241,7 +241,7 @@ Equipment.Register("misc.thrusters_improved_s4", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_optimised_s4", ThrusterType.New {
+Equipment.Register("thruster.optimised_s4", ThrusterType.New {
 	l10n_key="THRUSTERS_OPTIMISED", slots="thruster",
 	price=4096, purchasable=true, tech_level=8,
 	slot = { type="thruster", size=4 },
@@ -249,7 +249,7 @@ Equipment.Register("misc.thrusters_optimised_s4", ThrusterType.New {
 	icon_name="equip_thrusters_medium"
 })
 
-Equipment.Register("misc.thrusters_naval_s4", ThrusterType.New {
+Equipment.Register("thruster.naval_s4", ThrusterType.New {
 	l10n_key="THRUSTERS_NAVAL", slots="thruster",
 	price=10240, purchasable=true, tech_level="MILITARY",
 	slot = { type="thruster", size=4 },
@@ -258,7 +258,7 @@ Equipment.Register("misc.thrusters_naval_s4", ThrusterType.New {
 })
 
 -- S5 thrusters
-Equipment.Register("misc.thrusters_default_s5", ThrusterType.New {
+Equipment.Register("thruster.default_s5", ThrusterType.New {
 	l10n_key="THRUSTERS_DEFAULT", slots="thruster",
 	price=1950, purchasable=true, tech_level=2,
 	slot = { type="thruster", size=5 },
@@ -266,7 +266,7 @@ Equipment.Register("misc.thrusters_default_s5", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_improved_s5", ThrusterType.New {
+Equipment.Register("thruster.improved_s5", ThrusterType.New {
 	l10n_key="THRUSTERS_IMPROVED", slots="thruster",
 	price=4090, purchasable=true, tech_level=5,
 	slot = { type="thruster", size=5 },
@@ -274,7 +274,7 @@ Equipment.Register("misc.thrusters_improved_s5", ThrusterType.New {
 	icon_name="equip_thrusters_basic"
 })
 
-Equipment.Register("misc.thrusters_optimised_s5", ThrusterType.New {
+Equipment.Register("thruster.optimised_s5", ThrusterType.New {
 	l10n_key="THRUSTERS_OPTIMISED", slots="thruster",
 	price=9050, purchasable=true, tech_level=8,
 	slot = { type="thruster", size=5 },
@@ -282,7 +282,7 @@ Equipment.Register("misc.thrusters_optimised_s5", ThrusterType.New {
 	icon_name="equip_thrusters_medium"
 })
 
-Equipment.Register("misc.thrusters_naval_s5", ThrusterType.New {
+Equipment.Register("thruster.naval_s5", ThrusterType.New {
 	l10n_key="THRUSTERS_NAVAL", slots="thruster",
 	price=22620, purchasable=true, tech_level="MILITARY",
 	slot = { type="thruster", size=5 },
diff --git a/data/modules/MissionUtils/ShipBuilder.lua b/data/modules/MissionUtils/ShipBuilder.lua
index ec3b8bd21e..fba45f5394 100644
--- a/data/modules/MissionUtils/ShipBuilder.lua
+++ b/data/modules/MissionUtils/ShipBuilder.lua
@@ -142,13 +142,14 @@ ShipPlan.freeVolume = 0
 ShipPlan.equipMass = 0
 ShipPlan.threat = 0
 ShipPlan.freeThreat = 0
-ShipPlan.filled = {}
-ShipPlan.equip = {}
-ShipPlan.install = {}
-ShipPlan.slots = {}
+ShipPlan.filled = {} ---@type table<string, EquipType>
+ShipPlan.equip = {} ---@type EquipType[]
+ShipPlan.install = {} ---@type string[]
+ShipPlan.slots = {} ---@type HullConfig.Slot[]
 
 function ShipPlan:__clone()
 	self.filled = {}
+	self.default = {}
 	self.equip = {}
 	self.install = {}
 	self.slots = {}
@@ -614,6 +615,31 @@ function ShipBuilder.MakePlan(template, shipConfig, threat)
 			shipConfig.id, hullThreat, threat})
 	end
 
+	-- Setup required equipment items for the ship
+	-- TODO: support op="replace" equipment rules to override required slots?
+	for _, slot in pairs(shipPlan.slots) do
+
+		if slot.required then
+			local defaultEquip = Equipment.Get(slot.default)
+
+			if defaultEquip then
+
+				local inst = defaultEquip:Instance()
+
+				if inst.SpecializeForShip then
+					inst:SpecializeForShip(shipPlan.config)
+				end
+
+				if slot.count then
+					inst:SetCount(slot.count)
+				end
+
+				shipPlan:AddEquipToPlan(inst, slot)
+			end
+		end
+
+	end
+
 	for _, rule in ipairs(template.rules) do
 
 		local canApplyRule = true
diff --git a/data/pigui/libs/equipment-outfitter.lua b/data/pigui/libs/equipment-outfitter.lua
index cd7ea1ad57..21bbc21a7c 100644
--- a/data/pigui/libs/equipment-outfitter.lua
+++ b/data/pigui/libs/equipment-outfitter.lua
@@ -123,11 +123,11 @@ function EquipCardUnavailable:tooltipContents(data, isSelected)
 			if not data.canInstall then
 				ui.textWrapped(l.NOT_SUPPORTED_ON_THIS_SHIP % { equipment = data.name } .. ".")
 			elseif not data.canReplace then
-				ui.textWrapped(l.CANNOT_SELL_NONEMPTY_EQUIP .. ".")
+				ui.textWrapped(l.CANNOT_SELL_NONEMPTY_EQUIP)
 			elseif data.outOfStock then
-				ui.textWrapped(l.OUT_OF_STOCK)
+				ui.textWrapped(l.ITEM_IS_OUT_OF_STOCK)
 			else
-				ui.textWrapped(l.YOU_NOT_ENOUGH_MONEY)
+				ui.textWrapped(l.YOU_NOT_ENOUGH_MONEY .. ".")
 			end
 
 		end)
@@ -178,6 +178,7 @@ function Outfitter:Constructor()
 	self.station = nil ---@type SpaceStation
 	self.filterSlot = nil ---@type HullConfig.Slot?
 	self.replaceEquip = nil ---@type EquipType?
+	self.canReplaceEquip = false
 	self.canSellEquip = false
 
 	self.sortId = nil ---@type string?
@@ -305,7 +306,7 @@ function Outfitter:buildEquipmentList()
 			data.canInstall = equipSet:CanInstallLoose(equip)
 		end
 
-		data.canReplace = not self.replaceEquip or self.canSellEquip
+		data.canReplace = not self.replaceEquip or self.canReplaceEquip
 
 		data.outOfStock = data.count <= 0
 
@@ -429,6 +430,7 @@ function Outfitter:drawEquipmentItem(data, isSelected)
 	end
 end
 
+---@param data UI.EquipmentOutfitter.EquipData
 function Outfitter:drawBuyButton(data)
 	local icon = icons.autopilot_dock
 	local price_text = ui.Format.Money(self:getInstallPrice(data.equip))
@@ -439,6 +441,7 @@ function Outfitter:drawBuyButton(data)
 	end
 end
 
+---@param data UI.EquipCard.Data
 function Outfitter:drawSellButton(data)
 	local icon = icons.autopilot_undock_illegal
 	local price_text = ui.Format.Money(self:getSellPrice(data.equip))
diff --git a/data/pigui/libs/notification.lua b/data/pigui/libs/notification.lua
index 7f76fce973..d7c42aec0d 100644
--- a/data/pigui/libs/notification.lua
+++ b/data/pigui/libs/notification.lua
@@ -178,6 +178,8 @@ end
 
 local windowFlags = ui.WindowFlags { "NoDecoration", "NoBackground", "NoMove" }
 
+local frameCounter = 3
+
 ui.registerModule('notification', function()
 	if #Notification.queue == 0 then
 		return
@@ -205,7 +207,7 @@ ui.registerModule('notification', function()
 			table.remove(Notification.queue, i)
 		else
 			-- TODO(screen-resize): this has to be re-calculated if the screen width changes
-			if not notif.size then
+			if not notif.size or frameCounter > 0 then
 				calcNotificationSize(notif, wrapWidth)
 			end
 
@@ -213,6 +215,8 @@ ui.registerModule('notification', function()
 		end
 	end
 
+	frameCounter = math.max(frameCounter - 1, 0)
+
 	-- Grow vertically, but fix horizontal size
 	local windowHeight = math.min(maxHeight, ui.screenHeight)
 	ui.setNextWindowSize(Vector2(maxWidth, windowHeight), "Always")
@@ -243,7 +247,7 @@ ui.registerModule('notification', function()
 		local hovered = drawNotification(notif, wrapWidth)
 
 		-- Prevent this notification from expiring while hovered
-		if hovered then
+		if hovered and notif.expiry then
 			notif.expiry = notif.expiry + Engine.frameTime
 		end
 
diff --git a/data/pigui/libs/ship-equipment.lua b/data/pigui/libs/ship-equipment.lua
index 182924c267..1579460554 100644
--- a/data/pigui/libs/ship-equipment.lua
+++ b/data/pigui/libs/ship-equipment.lua
@@ -187,9 +187,12 @@ function EquipmentWidget:onSelectSlot(slotData, children)
 		self.selectedEquip = slotData.equip
 		self.selectionActive = true
 
+		local hasChildren = children and children.count > 0
+
 		self.market.filterSlot = self.selectedSlot
 		self.market.replaceEquip = self.selectedEquip
-		self.market.canSellEquip = not children or children.count == 0
+		self.market.canReplaceEquip = not hasChildren
+		self.market.canSellEquip = not (self.selectedSlot and self.selectedSlot.required or hasChildren)
 		self.market:refresh()
 	end
 end
diff --git a/data/pigui/modules/new-game-window/class.lua b/data/pigui/modules/new-game-window/class.lua
index 4bb7d47650..2512e7edea 100644
--- a/data/pigui/modules/new-game-window/class.lua
+++ b/data/pigui/modules/new-game-window/class.lua
@@ -32,7 +32,7 @@ local equipment2 = {
 	sensor         = "sensor.radar",
 	hull_mod       = "hull.atmospheric_shielding",
 	hyperdrive     = "hyperspace.hyperdrive_2",
-	thruster       = "misc.thrusters_default_s1",
+	thruster       = "thruster.default_s1",
 	missile_bay_1  = "missile_bay.opli_internal_s2",
 	missile_bay_2  = "missile_bay.opli_internal_s2",
 }
diff --git a/data/pigui/modules/station-view/04-shipMarket.lua b/data/pigui/modules/station-view/04-shipMarket.lua
index 6ccb798574..d7516b9f51 100644
--- a/data/pigui/modules/station-view/04-shipMarket.lua
+++ b/data/pigui/modules/station-view/04-shipMarket.lua
@@ -32,6 +32,8 @@ local widgetSizes = ui.rescaleUI({
 widgetSizes.iconSpacer = (widgetSizes.buyButton - widgetSizes.iconSize)/2
 
 local shipMarket
+---@type table<table, { price: integer, equip: { [1]: string, [2]: EquipType }[] }
+local advertDataCache = {}
 local icons = {}
 local manufacturerIcons = {}
 local selectedItem
@@ -66,6 +68,46 @@ local shipClassString = {
 	unknown                    = "",
 }
 
+local function makeAdvertDataCacheEntry(shipOnSale)
+	local def = shipOnSale.def ---@type ShipDef
+	local config = assert(HullConfig.GetHullConfig(def.id))
+
+	-- TODO: some sort of condition-based discount or an alteration to the
+	-- price for purchasing directly from a manufacturer's station?
+	local shipPrice = def.basePrice
+
+	local equip = {}
+
+	for _, slot in pairs(config.slots) do
+
+		if slot.default then
+			local defaultEquip = Equipment.Get(slot.default)
+
+			-- Have to go through all of this to get an accurate cost for the ship...
+			-- TODO: consider adding a "cost function" to EquipType which moves
+			-- responsibility for computing accurate cost into the domain of the code
+			-- that knows how the equipment will be specialized for the ship?
+			if defaultEquip then
+				local inst = defaultEquip:Instance()
+
+				if inst.SpecializeForShip then
+					inst:SpecializeForShip(config)
+				end
+
+				if slot.count then
+					inst:SetCount(slot.count)
+				end
+
+				table.insert(equip, { slot.id, inst })
+				shipPrice = shipPrice + inst.price
+			end
+		end
+
+	end
+
+	return { price = shipPrice, equip = equip }
+end
+
 local function refreshModelSpinner()
 	if not selectedItem then return end
 	cachedShip = selectedItem.def.modelName
@@ -80,6 +122,9 @@ local function refreshShipMarket()
 	shipMarket.items = station:GetShipsOnSale()
 	selectedItem = nil
 	shipMarket.selectedItem = nil
+	advertDataCache = utils.map_table(shipMarket.items, function(_, sos)
+		return sos, makeAdvertDataCacheEntry(sos)
+	end)
 end
 
 local function manufacturerIcon (manufacturer)
@@ -96,14 +141,10 @@ local tradeInValue = function(ship)
 	local shipDef = ShipDef[ship.shipId]
 	local value = shipDef.basePrice * shipSellPriceReduction * ship.hullPercent/100
 
-	if shipDef.hyperdriveClass > 0 then
-		value = value - Equipment.new["hyperspace.hyperdrive_" .. shipDef.hyperdriveClass].price * equipSellPriceReduction
-	end
-
+	-- We don't need to remove the hyperdrive from the value of the ship since the player is charged for it when buying the ship
 	local equipment = ship:GetComponent("EquipSet"):GetInstalledEquipment()
 	for _, e in pairs(equipment) do
-		local n = e.count or 1
-		value = value + n * e.price * equipSellPriceReduction
+		value = value + e.price * equipSellPriceReduction
 	end
 
 	return math.ceil(value)
@@ -114,7 +155,9 @@ local function buyShip (mkt, sos)
 	local station = assert(player:GetDockedWith())
 	local def = sos.def
 
-	local cost = def.basePrice - tradeInValue(Game.player)
+	local shipData = advertDataCache[sos]
+
+	local cost = shipData.price - tradeInValue(Game.player)
 	if math.floor(cost) ~= cost then
 		error("Ship price non-integer value.")
 	end
@@ -151,10 +194,25 @@ local function buyShip (mkt, sos)
 	if sos.pattern then player.model:SetPattern(sos.pattern) end
 	player:SetLabel(sos.label)
 
-	-- TODO: ships on sale should have their own pre-installed set of equipment
-	-- items instead of being completely empty
+	-- TODO: ship ads should support an explicit list of (pre-owned) equipment as well as / instead of factory-default items
+	-- At current we just build a list from the HullConfig's default items
 
-	if def.hyperdriveClass > 0 then
+	local equipSet = player:GetComponent('EquipSet')
+
+	-- Install pre-built list of default equipment into ship
+	for _, pair in ipairs(shipData.equip) do
+		local handle = assert(equipSet:GetSlotHandle(pair[1]))
+
+		if equipSet:CanInstallInSlot(handle, pair[2]) then
+			equipSet:Install(pair[2], handle)
+		else
+			logWarning("Default equipment item {} for ship slot {}.{} is not compatible with slot." % { slot.default, player.shipId, slot.id })
+		end
+	end
+
+	-- FIXME: fallback pass. Hyperdrives should be specified as a default item on the hyperdrive slot
+	-- Once all hyperdrives are specified as default, this pass should be removed
+	if def.hyperdriveClass > 0 and not player:GetInstalledHyperdrive() then
 		local slot = player:GetComponent('EquipSet'):GetAllSlotsOfType('hyperdrive')[1]
 
 		-- Install the best-fitting non-military hyperdrive we can
@@ -376,10 +434,12 @@ local tradeMenu = function()
 					ui.text(shipClassString[selectedItem.def.shipClass])
 				end)
 
+				local cost = advertDataCache[selectedItem].price
+
 				ui.withFont(pionillium.heading, function()
-					ui.text(l.PRICE..": "..Format.Money(selectedItem.def.basePrice, false))
+					ui.text(l.PRICE..": "..Format.Money(cost, false))
 					ui.sameLine()
-					ui.text(l.AFTER_TRADE_IN..": "..Format.Money(selectedItem.def.basePrice - tradeInValue(Game.player), false))
+					ui.text(l.AFTER_TRADE_IN..": "..Format.Money(cost - tradeInValue(Game.player), false))
 				end)
 
 				ui.nextColumn()
@@ -478,7 +538,7 @@ shipMarket = Table.New("shipMarketWidget", false, {
 			ui.text(item.def.name)
 			ui.nextColumn()
 			ui.dummy(widgetSizes.rowVerticalSpacing)
-			ui.text(Format.Money(item.def.basePrice,false))
+			ui.text(Format.Money(advertDataCache[item].price, false))
 			ui.nextColumn()
 			ui.dummy(widgetSizes.rowVerticalSpacing)
 			ui.text(item.def.equipCapacity.."t")
diff --git a/data/pigui/views/debug.lua b/data/pigui/views/debug.lua
index c8d4c4c14a..17b3405ec5 100644
--- a/data/pigui/views/debug.lua
+++ b/data/pigui/views/debug.lua
@@ -52,6 +52,10 @@ function debugView.registerTab(name, tab)
 	local index = debugView.tabs[name] or #debugView.tabs + 1
 	debugView.tabs[index] = tab
 	debugView.tabs[name] = index
+
+	table.sort(debugView.tabs, function(a, b)
+		return (a.priority and not b.priority) or (a.priority and b.priority and a.priority < b.priority) or (a.priority == b.priority and a.label < b.label)
+	end)
 end
 
 function debugView.drawTabs(delta)
diff --git a/src/Pi.cpp b/src/Pi.cpp
index 47d545e889..4c812906a0 100644
--- a/src/Pi.cpp
+++ b/src/Pi.cpp
@@ -679,6 +679,8 @@ void MainMenu::Start()
 
 	perfInfoDisplay->ClearCounter(PiGui::PerfInfo::COUNTER_PHYS);
 	perfInfoDisplay->ClearCounter(PiGui::PerfInfo::COUNTER_PIGUI);
+
+	LuaEvent::Queue("onEnterMainMenu");
 }
 
 void MainMenu::Update(float deltaTime)
@@ -690,6 +692,8 @@ void MainMenu::Update(float deltaTime)
 
 	Pi::intro->Draw(deltaTime);
 
+	LuaEvent::Emit();
+
 	Pi::pigui->NewFrame();
 	PiGui::EmitEvents();
 	PiGui::RunHandler(deltaTime, "mainMenu");
diff --git a/src/lua/LuaShipDef.cpp b/src/lua/LuaShipDef.cpp
index ff31e1de9b..d5537f3873 100644
--- a/src/lua/LuaShipDef.cpp
+++ b/src/lua/LuaShipDef.cpp
@@ -238,6 +238,7 @@ void LuaShipDef::Register()
 		lua_newtable(l);
 
 		pi_lua_settable(l, "id", iter.first.c_str());
+		pi_lua_settable(l, "path", st.definitionPath.c_str());
 		pi_lua_settable(l, "name", st.name.c_str());
 		pi_lua_settable(l, "shipClass", st.shipClass.c_str());
 		pi_lua_settable(l, "manufacturer", st.manufacturer.c_str());