From 0002d321cac61acc6b95a301c1ca4de54d6d05d1 Mon Sep 17 00:00:00 2001
From: Jonathan Harris <jonathan@marginal.org.uk>
Date: Thu, 18 Jun 2015 23:01:02 +0100
Subject: [PATCH] Add option to output ship loadout in E:D Shipyard format.

---
 EDMarketConnector.py |  11 +-
 companion.py         |  69 ++++++---
 config.py            |   1 +
 loadout.py           |  98 ++++++++++++
 outfitting.py        | 346 +++++++++++++++++++++++++++++++++++++++++++
 prefs.py             |  16 +-
 6 files changed, 512 insertions(+), 29 deletions(-)
 create mode 100644 loadout.py
 create mode 100755 outfitting.py

diff --git a/EDMarketConnector.py b/EDMarketConnector.py
index ed0ad063..4b64c30e 100755
--- a/EDMarketConnector.py
+++ b/EDMarketConnector.py
@@ -17,6 +17,7 @@ import companion
 import bpc
 import td
 import eddn
+import loadout
 import prefs
 from config import appname, applongname, config
 
@@ -174,13 +175,19 @@ class AppWindow:
             # Validation
             if not data.get('commander') or not data['commander'].get('name','').strip():
                 self.status['text'] = "Who are you?!"	# Shouldn't happen
-            elif not data['commander'].get('docked'):
-                self.status['text'] = "You're not docked at a station!"
             elif not data.get('lastSystem') or not data['lastSystem'].get('name','').strip() or not data.get('lastStarport') or not data['lastStarport'].get('name','').strip():
                 self.status['text'] = "Where are you?!"	# Shouldn't happen
+            elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip():
+                self.status['text'] = "What are you flying?!"	# Shouldn't happen
+            elif not data['commander'].get('docked'):
+                if config.getint('output') & config.OUT_SHIP:
+                    loadout.export(data)	# do loadout even if not docked
+                self.status['text'] = "You're not docked at a station!"
             elif not data['lastStarport'].get('commodities'):
                 self.status['text'] = "Station doesn't have a market!"
             else:
+                if config.getint('output') & config.OUT_SHIP:
+                    loadout.export(data)
                 if config.getint('output') & config.OUT_CSV:
                     bpc.export(data, True)
                 if config.getint('output') & config.OUT_TD:
diff --git a/companion.py b/companion.py
index 08884648..8f01fcb6 100644
--- a/companion.py
+++ b/companion.py
@@ -23,22 +23,51 @@ holdoff = 120	# be nice
 
 # Map values reported by the Companion interface to names displayed in-game and recognized by trade tools
 
-categorymap = { 'Narcotics': 'Legal Drugs',
-                'Slaves': 'Slavery',
-                'NonMarketable': False, }
+category_map = {
+    'Narcotics'     : 'Legal Drugs',
+    'Slaves'        : 'Slavery',
+    'NonMarketable' : False,
+}
 
-commoditymap= { 'Agricultural Medicines': 'Agri-Medicines',
-                'Ai Relics' : 'AI Relics',
-                'Atmospheric Extractors': 'Atmospheric Processors',
-                'Auto Fabricators': 'Auto-Fabricators',
-                'Basic Narcotics': 'Narcotics',
-                'Bio Reducing Lichen': 'Bioreducing Lichen',
-                'Hazardous Environment Suits': 'H.E. Suits',
-                'Heliostatic Furnaces': 'Microbial Furnaces',
-                'Marine Supplies': 'Marine Equipment',
-                'Non Lethal Weapons': 'Non-Lethal Weapons',
-                'S A P8 Core Container': 'SAP 8 Core Container',
-                'Terrain Enrichment Systems': 'Land Enrichment Systems', }
+commodity_map= {
+    'Agricultural Medicines'      : 'Agri-Medicines',
+    'Ai Relics'                   : 'AI Relics',
+    'Atmospheric Extractors'      : 'Atmospheric Processors',
+    'Auto Fabricators'            : 'Auto-Fabricators',
+    'Basic Narcotics'             : 'Narcotics',
+    'Bio Reducing Lichen'         : 'Bioreducing Lichen',
+    'Hazardous Environment Suits' : 'H.E. Suits',
+    'Heliostatic Furnaces'        : 'Microbial Furnaces',
+    'Marine Supplies'             : 'Marine Equipment',
+    'Non Lethal Weapons'          : 'Non-Lethal Weapons',
+    'S A P8 Core Container'       : 'SAP 8 Core Container',
+    'Terrain Enrichment Systems'  : 'Land Enrichment Systems',
+}
+
+ship_map = {
+    'Adder'               : 'Adder',
+    'Anaconda'            : 'Anaconda',
+    'Asp'                 : 'Asp',
+    'CobraMkIII'          : 'Cobra Mk III',
+    'DiamondBack'         : 'Diamondback Scout',
+    'DiamondBackXL'       : 'Diamondback Explorer',
+    'Eagle'               : 'Eagle',
+    'Empire_Courier'      : 'Imperial Courier',
+    'Empire_Fighter'      : 'Imperial Fighter',
+    'Empire_Trader'       : 'Imperial Clipper',
+    'Federation_Dropship' : 'Federal Dropship',
+    'Federation_Fighter'  : 'F63 Condor',
+    'FerDeLance'          : 'Fer-de-Lance',
+    'Hauler'              : 'Hauler',
+    'Orca'                : 'Orca',
+    'Python'              : 'Python',
+    'SideWinder'          : 'Sidewinder',
+    'Type6'               : 'Type-6 Transporter',
+    'Type7'               : 'Type-7 Transporter',
+    'Type9'               : 'Type-9 Heavy',
+    'Viper'               : 'Viper',
+    'Vulture'             : 'Vulture',
+}
 
 
 class ServerError(Exception):
@@ -165,7 +194,7 @@ class Session:
                     if __debug__: print 'Invalid "%s":"%s" (%s) for "%s"' % (thing, commodity.get(thing), type(commodity.get(thing)), commodity.get('name', ''))
                     break
             else:
-                if not categorymap.get(commodity['categoryname'], True):	# Check marketable
+                if not category_map.get(commodity['categoryname'], True):	# Check marketable
                     pass
                 elif not commodity.get('categoryname', '').strip():
                     if __debug__: print 'Missing "categoryname" for "%s"' % commodity.get('name', '')
@@ -177,10 +206,10 @@ class Session:
                     if __debug__: print 'Invalid "stockBracket":"%s" for "%s"' % (commodity['stockBracket'], commodity['name'])
                 else:
                     # Rewrite text fields
-                    commodity['categoryname'] = categorymap.get(commodity['categoryname'].strip(),
-                                                                commodity['categoryname'].strip())
-                    commodity['name'] = commoditymap.get(commodity['name'].strip(),
-                                                         commodity['name'].strip())
+                    commodity['categoryname'] = category_map.get(commodity['categoryname'].strip(),
+                                                                 commodity['categoryname'].strip())
+                    commodity['name'] = commodity_map.get(commodity['name'].strip(),
+                                                          commodity['name'].strip())
 
                     # Force demand and stock to zero if their corresponding bracket is zero
                     # Fixes spurious "demand": 1 in ED 1.3
diff --git a/config.py b/config.py
index 4e5066cb..e69cfff3 100644
--- a/config.py
+++ b/config.py
@@ -69,6 +69,7 @@ class Config:
     OUT_BPC  = 2
     OUT_TD   = 4
     OUT_CSV  = 8
+    OUT_SHIP = 16
 
     if platform=='darwin':
 
diff --git a/loadout.py b/loadout.py
new file mode 100644
index 00000000..8ca557fe
--- /dev/null
+++ b/loadout.py
@@ -0,0 +1,98 @@
+# Export ship loadout
+
+from collections import defaultdict
+import os
+from os.path import join
+import re
+import time
+
+from config import config
+import outfitting
+from companion import ship_map
+
+
+# API slot names to E:D Shipyard slot names
+slot_map = {
+    'LargeHardpoint'   : 'L',
+    'MediumHardpoint'  : 'M',
+    'SmallHardpoint'   : 'S',
+    'TinyHardpoint'    : 'U',
+    'Armour'           : 'BH',
+    'PowerPlant'       : 'RB',
+    'MainEngines'      : 'TM',
+    'FrameShiftDrive'  : 'FH',
+    'LifeSupport'      : 'EC',
+    'PowerDistributor' : 'PC',
+    'Radar'            : 'SS',
+    'FuelTank'         : 'FS',
+}
+
+def export(data):
+
+    def class_rating(module):
+        if 'guidance' in module:
+            return module['class'] + module['rating'] + '/' + module.get('mount', 'F')[0] + module['guidance'][0] + ' '
+        elif 'mount' in module:
+            return module['class'] + module['rating'] + '/' + module['mount'][0] + ' '
+        else:
+            return module['class'] + module['rating'] + ' '
+
+    querytime = config.getint('querytime') or int(time.time())
+
+    ship = ship_map.get(data['ship']['name'], data['ship']['name'])
+
+    loadout = defaultdict(list)
+
+    for slot in sorted(data['ship']['modules']):
+
+        v = data['ship']['modules'][slot]
+        if not v or not v.get('module'):
+            continue
+        try:
+            module = outfitting.lookup(v['module'])
+            if not module: continue
+        except AssertionError as e:
+            if __debug__: print 'Loadout: %s' % e
+            continue	# Silently skip unrecognized modules
+        except:
+            if __debug__: raise
+
+        cr = class_rating(module)
+
+        # Specials
+        if module['name'] in ['Fuel Tank', 'Cargo Rack']:
+            name = '%s (Capacity: %d)' % (module['name'], 2**int(module['class']))
+        else:
+            name = module['name']
+
+        for s in slot_map:
+            if slot.startswith(s):
+                loadout[slot_map[s]].append(cr + name)
+                break
+        else:
+            if slot.startswith('Slot'):
+                loadout[slot[-1]].append(cr + name)
+            elif __debug__: print 'Loadout: Unknown slot %s' % slot
+
+    # Construct description
+    string = '[%s]\n' % ship
+    for slot in ['L', 'M', 'S', 'U', None, 'BH', 'RB', 'TM', 'FH', 'EC', 'PC', 'SS', 'FS', None, '9', '8', '7', '6', '5', '4', '3', '2', '1']:
+        if not slot:
+            string += '\n'
+        elif slot in loadout:
+            for name in loadout[slot]:
+                string += '%s: %s\n' % (slot, name)
+    string += '---\nCargo : %d T\nFuel  : %d T\nPrice : %s CR\n' % (data['ship']['cargo']['capacity'], data['ship']['fuel']['capacity'], '{:,}'.format(data['ship']['value']['total']))
+
+    # Look for last ship of this type
+    regexp = re.compile(re.escape(ship) + '\.\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d\.txt')
+    oldfiles = sorted([x for x in os.listdir(config.get('outdir')) if regexp.match(x)])
+    if oldfiles:
+        with open(join(config.get('outdir'), oldfiles[-1]), 'rU') as h:
+            if h.read() == string:
+                return	# same as last time - don't write
+
+    # Write
+    filename = join(config.get('outdir'), '%s.%s.txt' % (ship, time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(querytime))))
+    with open(filename, 'wt') as h:
+        h.write(string)
diff --git a/outfitting.py b/outfitting.py
new file mode 100755
index 00000000..eec669f7
--- /dev/null
+++ b/outfitting.py
@@ -0,0 +1,346 @@
+#!/usr/bin/python
+#
+# Script for building table ID->module mapping table from a dump of the Companion API output
+#
+
+import csv
+import json
+import os
+from os.path import exists, isfile
+import sys
+
+from companion import ship_map
+
+
+outfile = 'outfitting.csv'
+outfitting = {}
+
+armour_map = {
+    'Grade1'   : 'Lightweight Alloy',
+    'Grade2'   : 'Reinforced Alloy',
+    'Grade3'   : 'Military Grade Composite',
+    'Mirrored' : 'Mirrored Surface Composite',
+    'Reactive' : 'Reactive Surface Composite',
+}
+
+weapon_map = {
+    'AdvancedTorpPylon'   : 'Torpedo Pylon',
+    'BasicMissileRack'    : 'Missile Rack',
+    'BeamLaser'           : 'Beam Laser',
+    'Cannon'              : 'Cannon',
+    'DumbfireMissileRack' : 'Missile Rack',
+    'MineLauncher'        : 'Mine Launcher',
+    'MiningLaser'         : 'Mining Laser',
+    'MultiCannon'         : 'Multi-Cannon',
+    'PlasmaAccelerator'   : 'Plasma Accelerator',
+    'PulseLaser'          : 'Pulse Laser',
+    'PulseLaserBurst'     : 'Burst Laser',
+    'Railgun'             : 'Rail Gun',
+    'Slugshot'            : 'Fragment Cannon',
+}
+
+missiletype_map = {
+    'AdvancedTorpPylon'   : 'Seeker',
+    'BasicMissileRack'    : 'Seeker',
+    'DumbfireMissileRack' : 'Dumbfire',
+}
+
+weaponmount_map = {
+    'Fixed'  : 'Fixed',
+    'Gimbal' : 'Gimballed',
+    'Turret' : 'Turreted',
+}
+
+weaponclass_map = {
+    'Tiny'   : '0',
+    'Small'  : '1',
+    'Medium' : '2',
+    'Large'  : '3',
+    'Huge'   : '4',
+}
+
+# There's no discernable pattern for weapon ratings, so here's a lookup table
+weaponrating_map = {
+    'Hpt_AdvancedTorpPylon_Fixed_Small': 'I',
+    'Hpt_AdvancedTorpPylon_Fixed_Medium': 'I',
+    'Hpt_BasicMissileRack_Fixed_Small': 'B',
+    'Hpt_BasicMissileRack_Fixed_Medium': 'B',
+    'Hpt_BeamLaser_Fixed_Small': 'E',
+    'Hpt_BeamLaser_Fixed_Medium': 'D',
+    'Hpt_BeamLaser_Fixed_Large': 'C',
+    'Hpt_BeamLaser_Gimbal_Small': 'E',
+    'Hpt_BeamLaser_Gimbal_Medium': 'D',
+    'Hpt_BeamLaser_Gimbal_Large': 'C',
+    'Hpt_BeamLaser_Turret_Small': 'F',
+    'Hpt_BeamLaser_Turret_Medium': 'E',
+    'Hpt_BeamLaser_Turret_Large': 'D',
+    'Hpt_Cannon_Fixed_Small': 'D',
+    'Hpt_Cannon_Fixed_Medium': 'D',
+    'Hpt_Cannon_Fixed_Large': 'C',
+    'Hpt_Cannon_Fixed_Huge': 'B',
+    'Hpt_Cannon_Gimbal_Small': 'E',
+    'Hpt_Cannon_Gimbal_Medium': 'D',
+    'Hpt_Cannon_Gimbal_Large': 'C',
+    'Hpt_Cannon_Gimbal_Huge': 'B',
+    'Hpt_Cannon_Turret_Small': 'F',
+    'Hpt_Cannon_Turret_Medium': 'E',
+    'Hpt_Cannon_Turret_Large': 'D',
+    'Hpt_DumbfireMissileRack_Fixed_Small': 'B',
+    'Hpt_DumbfireMissileRack_Fixed_Medium': 'B',
+    'Hpt_MineLauncher_Fixed_Small': 'I',
+    'Hpt_MineLauncher_Fixed_Medium': 'I',
+    'Hpt_MiningLaser_Fixed_Small': 'D',
+    'Hpt_MiningLaser_Fixed_Medium': 'D',
+    'Hpt_MultiCannon_Fixed_Small': 'F',
+    'Hpt_MultiCannon_Fixed_Medium': 'E',
+    'Hpt_MultiCannon_Gimbal_Small': 'G',
+    'Hpt_MultiCannon_Gimbal_Medium': 'F',
+    'Hpt_MultiCannon_Turret_Small': 'G',
+    'Hpt_MultiCannon_Turret_Medium': 'F',
+    'Hpt_PlasmaAccelerator_Fixed_Medium': 'C',
+    'Hpt_PlasmaAccelerator_Fixed_Large': 'B',
+    'Hpt_PlasmaAccelerator_Fixed_Huge': 'A',
+    'Hpt_PulseLaser_Fixed_Small': 'F',
+    'Hpt_PulseLaser_Fixed_Medium': 'E',
+    'Hpt_PulseLaser_Fixed_Large': 'D',
+    'Hpt_PulseLaser_Gimbal_Small': 'G',
+    'Hpt_PulseLaser_Gimbal_Medium': 'F',
+    'Hpt_PulseLaser_Gimbal_Large': 'E',
+    'Hpt_PulseLaser_Turret_Small': 'G',
+    'Hpt_PulseLaser_Turret_Medium': 'F',
+    'Hpt_PulseLaser_Turret_Large': 'F',
+    'Hpt_PulseLaserBurst_Fixed_Small': 'F',
+    'Hpt_PulseLaserBurst_Fixed_Medium': 'E',
+    'Hpt_PulseLaserBurst_Fixed_Large': 'D',
+    'Hpt_PulseLaserBurst_Gimbal_Small': 'G',
+    'Hpt_PulseLaserBurst_Gimbal_Medium': 'F',
+    'Hpt_PulseLaserBurst_Gimbal_Large': 'E',
+    'Hpt_PulseLaserBurst_Turret_Small': 'G',
+    'Hpt_PulseLaserBurst_Turret_Medium': 'F',
+    'Hpt_PulseLaserBurst_Turret_Large': 'E',
+    'Hpt_Railgun_Fixed_Small': 'D',
+    'Hpt_Railgun_Fixed_Medium': 'B',
+    'Hpt_Slugshot_Fixed_Small': 'E',
+    'Hpt_Slugshot_Fixed_Medium': 'A',
+    'Hpt_Slugshot_Fixed_Large': 'C',
+    'Hpt_Slugshot_Gimbal_Small': 'E',
+    'Hpt_Slugshot_Gimbal_Medium': 'D',
+    'Hpt_Slugshot_Gimbal_Large': 'C',
+    'Hpt_Slugshot_Turret_Small': 'E',
+    'Hpt_Slugshot_Turret_Medium': 'D',
+    'Hpt_Slugshot_Turret_Large': 'C',
+}
+
+utility_map = {
+    'CargoScanner'             : 'Cargo Scanner',
+    'ChaffLauncher'            : 'Chaff Launcher',
+    'CloudScanner'             : 'Frame Shift Wake Scanner',
+    'CrimeScanner'             : 'Kill Warrant Scanner',
+    'ElectronicCountermeasure' : 'Electronic Countermeasure',
+    'HeatSinkLauncher'         : 'Heat Sink Launcher',
+    'PlasmaPointDefence'       : 'Point Defence',
+    'ShieldBooster'            : 'Shield Booster',
+}
+
+rating_map = {
+    '1': 'E',
+    '2': 'D',
+    '3': 'C',
+    '4': 'B',
+    '5': 'A',
+}
+
+standard_map = {
+    'Armour'           : 'Bulkheads',
+    'Engine'           : 'Thrusters',
+    'FuelTank'         : 'Fuel Tank',
+    'Hyperdrive'       : 'Frame Shift Drive',
+    'LifeSupport'      : 'Life Support',
+    'PowerDistributor' : 'Power Distributor',
+    'Powerplant'       : 'Power Plant',
+    'Sensors'          : 'Sensors',
+}
+
+
+stellar_map = {
+    'Standard'     : ('Basic Discovery Scanner', 'E'),
+    'Intermediate' : ('Intermediate Discovery Scanner', 'D'),
+    'Advanced'     : ('Advanced Discovery Scanner', 'C'),
+    'Tiny'         : ('Detailed Surface Scanner', 'C'),
+}
+
+internal_map = {
+    'CargoRack'         : 'Cargo Rack',
+    'Collection'        : 'Collector Limpet Controller',
+    'FSDInterdictor'    : 'Frame Shift Drive Interdictor',
+    'FuelScoop'         : 'Fuel Scoop',
+    'FuelTransfer'      : 'Fuel Transfer Limpet Controller',
+    'HullReinforcement' : 'Hull Reinforcement Package',
+    'Prospector'        : 'Prospector Limpet Controller',
+    'Refinery'          : 'Refinery',
+    'Repairer'          : 'Auto Field-Maintenance Unit',
+    'ResourceSiphon'    : 'Hatch Breaker Limpet Controller',
+    'ShieldCellBank'    : 'Shield Cell Bank',
+    'ShieldGenerator'   : 'Shield Generator',
+}
+
+
+# Given a module description from the Companion API returns a description of the module in the form of a
+# dict { category, name, [mount], [guidance], [ship], rating, class } using the same terms found in the
+# English langauge game.
+# Or returns None if the module is user-specific (i.e. decal, paintjob).
+# (Given the ad-hocery in this implementation a big lookup table might have been simpler and clearer).
+def lookup(module):
+
+    # if not module.get('category'): raise AssertionError('%s: Missing category' % module['id'])	# only present post 1.3, and not present in ship loadout
+    if not module.get('name'): raise AssertionError('%s: Missing name' % module['id'])
+
+    name = module['name'].split('_')
+    new = {}
+
+    # Armour - e.g. Federation_Dropship_Armour_Grade2
+    if name[-2] == 'Armour':
+        name = module['name'].rsplit('_', 2)	# Armour is ship-specific, and ship names can have underscores
+        new['category'] = 'standard'
+        new['name'] = armour_map[name[2]]
+        new['ship'] = ship_map.get(name[0], name[0])
+        new['class'] = '1'
+        new['rating'] = 'I'
+
+    # Skip uninteresting stuff
+    elif name[0].lower() in ['decal', 'paintjob']:	# Have seen "paintjob" and "PaintJob"
+        return None
+
+    # Shouldn't be listing player-specific paid stuff
+    elif module.get('sku'):
+        raise AssertionError('%s: Unexpected sku "%s"' % (module['id'], module['sku']))
+
+    # Hardpoints - e.g. Hpt_Slugshot_Fixed_Medium
+    elif name[0]=='Hpt' and name[1] in weapon_map:
+        if name[2] not in weaponmount_map: raise AssertionError('%s: Unknown weapon mount "%s"' % (module['id'], name[2]))
+        if name[3] not in weaponclass_map: raise AssertionError('%s: Unknown weapon class "%s"' % (module['id'], name[3]))
+        # if module['name'] not in weaponrating_map: raise AssertionError('%s: Unknown rating for this weapon' % module['id'])
+        new['category'] = 'hardpoint'
+        new['name'] =  weapon_map[name[1]]
+        new['mount'] = weaponmount_map[name[2]]
+        if name[1] in missiletype_map:	# e.g. Hpt_DumbfireMissileRack_Fixed_Small
+            new['guidance'] = missiletype_map[name[1]]
+        new['class'] = weaponclass_map[name[3]]
+        new['rating'] = weaponrating_map.get(module['name'], '?')	# no obvious rule - needs lookup table
+
+    # Utility - e.g. Hpt_CargoScanner_Size0_Class1
+    elif name[0]=='Hpt' and name[1] in utility_map:
+        new['category'] = 'utility'
+        new['name'] = utility_map[name[1]]
+        if name[-1] in weaponclass_map:	# e.g. Hpt_PlasmaPointDefence_Turret_Tiny
+            new['class'] = weaponclass_map[name[-1]]
+            new['rating'] = 'I'
+        else:
+            if not name[2].startswith('Size') or not name[3].startswith('Class'): raise AssertionError('%s: Unknown class/rating "%s/%s"' % (module['id'], name[2], name[3]))
+            new['class'] = name[2][4:]
+            new['rating'] = rating_map[name[3][5:]]
+
+    elif name[0]=='Hpt':
+        raise AssertionError('%s: Unknown weapon "%s"' % (module['id'], name[1]))
+
+    # Stellar scanners - e.g. Int_StellarBodyDiscoveryScanner_Standard
+    elif name[1] in ['StellarBodyDiscoveryScanner', 'DetailedSurfaceScanner']:
+        new['category'] = 'internal'
+        new['name'], new['rating'] = stellar_map[name[2]]
+        new['class'] = '1'
+
+    # Docking Computer - e.g. Int_DockingComputer_Standard
+    elif name[1] == 'DockingComputer' and name[2] == 'Standard':
+        new['category'] = 'internal'
+        new['name'] = 'Standard Docking Computer'
+        new['class'] = '1'
+        new['rating'] = 'E'
+
+    # Standard & Internal
+    else:
+        # Reported category is not necessarily helpful. e.g. "Int_DockingComputer_Standard" has category "utility"
+        if name[0] != 'Int': raise AssertionError('%s: Unknown prefix "%s"' % (module['id'], name[0]))
+
+        if name[1] == 'DroneControl':	# e.g. Int_DroneControl_Collection_Size1_Class1
+            name.pop(0)
+
+        if name[1] in standard_map:	# e.g. Int_Engine_Size2_Class1
+            new['category'] = 'standard'
+            new['name'] = standard_map[name[1]]
+        elif name[1] in internal_map:	# e.g. Int_CargoRack_Size8_Class1
+            new['category'] = 'internal'
+            new['name'] = internal_map[name[1]]
+        else:
+            raise AssertionError('%s: Unknown module "%s"' % (module['id'], name[1]))
+
+        if not name[2].startswith('Size') or not name[3].startswith('Class'): raise AssertionError('%s: Unknown class/rating "%s/%s"' % (module['id'], name[2], name[3]))
+        new['class'] = name[2][4:]
+        new['rating'] = rating_map[name[3][5:]]
+
+    # check we've filled out mandatory fields
+    for thing in ['category', 'name', 'class', 'rating']:
+        if not new.get('name'): raise AssertionError('%s: failed to set %s' % (module['id'], thing))
+
+    return new
+
+
+# add all the modules
+def addmodules(data):
+    if not data.get('lastStarport'):
+        print 'No Starport!'
+        return
+    elif not data['lastStarport'].get('modules'):
+        print 'No outfitting here'
+        return
+
+    # read into outfitting
+    if isfile(outfile):
+        with open(outfile) as csvfile:
+            reader = csv.DictReader(csvfile)
+            for row in reader:
+                key = int(row.pop('id'))	# index by int for easier lookup and sorting
+                outfitting[key] = row
+    size_pre = len(outfitting)
+
+    for key,module in data['lastStarport'].get('modules').iteritems():
+        # sanity check
+        if int(key) != module.get('id'): raise AssertionError('id: %s!=%s' % (key, module['id']))
+        new = lookup(module)
+        if new:
+            old = outfitting.get(int(key))
+            if old:
+                # check consistency with existing data
+                for thing in ['category', 'name', 'mount', 'guidance', 'ship', 'class', 'rating']:
+                    if new.get(thing,'') != old.get(thing): raise AssertionError('%s: %s "%s"!="%s"' % (key, thing, new.get(thing), old.get(thing)))
+            else:
+                outfitting[int(key)] = new
+
+    if len(outfitting) > size_pre:
+
+        if isfile(outfile):
+            if isfile(outfile+'.bak'):
+                os.unlink(outfile+'.bak')
+            os.rename(outfile, outfile+'.bak')
+
+        with open(outfile, 'wb') as csvfile:
+            writer = csv.DictWriter(csvfile, ['id', 'category', 'name', 'mount', 'guidance', 'ship', 'class', 'rating'])
+            writer.writeheader()
+            for key in sorted(outfitting):
+                row = outfitting[key]
+                row['id'] = key
+                writer.writerow(row)
+
+        print 'Added %d new modules' % (len(outfitting) - size_pre)
+
+    else:
+        print
+
+if __name__ == "__main__":
+    if len(sys.argv) <= 1:
+        print 'Usage: outfitting.py [dump.json]'
+    else:
+        # read from dumped json file(s)
+        for f in sys.argv[1:]:
+            with open(f) as h:
+                print f,
+                addmodules(json.loads(h.read()))
diff --git a/prefs.py b/prefs.py
index d0b64b62..68831974 100644
--- a/prefs.py
+++ b/prefs.py
@@ -76,8 +76,8 @@ class PreferencesDialog(tk.Toplevel):
         outframe.grid(padx=10, pady=10, sticky=tk.NSEW)
         outframe.columnconfigure(0, weight=1)
 
-        output = config.getint('output') or config.OUT_EDDN
-        ttk.Label(outframe, text="Please choose where you want the market data saved").grid(row=0, columnspan=2, padx=5, pady=3, sticky=tk.W)
+        output = config.getint('output') or (config.OUT_EDDN | config.OUT_SHIP)
+        ttk.Label(outframe, text="Please choose where you want the data saved").grid(row=0, columnspan=2, padx=5, pady=3, sticky=tk.W)
         self.out_eddn= tk.IntVar(value = (output & config.OUT_EDDN) and 1 or 0)
         ttk.Checkbutton(outframe, text="Online to the Elite Dangerous Data Network (EDDN)", variable=self.out_eddn).grid(row=1, columnspan=2, padx=5, sticky=tk.W)
         self.out_bpc = tk.IntVar(value = (output & config.OUT_BPC ) and 1 or 0)
@@ -86,12 +86,14 @@ class PreferencesDialog(tk.Toplevel):
         ttk.Checkbutton(outframe, text="Offline in Trade Dangerous format", variable=self.out_td, command=self.outvarchanged).grid(row=3, columnspan=2, padx=5, sticky=tk.W)
         self.out_csv = tk.IntVar(value = (output & config.OUT_CSV ) and 1 or 0)
         ttk.Checkbutton(outframe, text="Offline in CSV format", variable=self.out_csv, command=self.outvarchanged).grid(row=4, columnspan=2, padx=5, sticky=tk.W)
-        ttk.Label(outframe, text=(platform=='darwin' and 'Where:' or 'File location:')).grid(row=5, padx=5, pady=(5,0), sticky=tk.NSEW)
+        self.out_ship= tk.IntVar(value = (output & config.OUT_SHIP) and 1 or 0)
+        ttk.Checkbutton(outframe, text="Offline loadout in E:D Shipyard format", variable=self.out_ship, command=self.outvarchanged).grid(row=5, columnspan=2, padx=5, sticky=tk.W)
+        ttk.Label(outframe, text=(platform=='darwin' and 'Where:' or 'File location:')).grid(row=6, padx=5, pady=(5,0), sticky=tk.NSEW)
         self.outbutton = ttk.Button(outframe, text=(platform=='darwin' and 'Change...' or 'Browse...'), command=self.outbrowse)
-        self.outbutton.grid(row=5, column=1, padx=5, pady=(5,0), sticky=tk.NSEW)
+        self.outbutton.grid(row=6, column=1, padx=5, pady=(5,0), sticky=tk.NSEW)
         self.outdir = ttk.Entry(outframe)
         self.outdir.insert(0, config.get('outdir'))
-        self.outdir.grid(row=6, columnspan=2, padx=5, pady=5, sticky=tk.EW)
+        self.outdir.grid(row=7, columnspan=2, padx=5, pady=5, sticky=tk.EW)
         self.outvarchanged()
 
         privacyframe = ttk.LabelFrame(frame, text='Privacy')
@@ -118,7 +120,7 @@ class PreferencesDialog(tk.Toplevel):
         #self.wait_window(self)	# causes duplicate events on OSX
 
     def outvarchanged(self):
-        local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get()
+        local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get() or self.out_ship.get()
         self.outbutton['state'] = local and tk.NORMAL or tk.DISABLED
         self.outdir['state']    = local and 'readonly' or tk.DISABLED
 
@@ -157,7 +159,7 @@ class PreferencesDialog(tk.Toplevel):
         credentials = (config.get('username'), config.get('password'))
         config.set('username', self.username.get().strip())
         config.set('password', self.password.get().strip())
-        config.set('output', (self.out_eddn.get() and config.OUT_EDDN or 0) + (self.out_bpc.get() and config.OUT_BPC or 0) + (self.out_td.get() and config.OUT_TD or 0) + (self.out_csv.get() and config.OUT_CSV or 0))
+        config.set('output', (self.out_eddn.get() and config.OUT_EDDN or 0) + (self.out_bpc.get() and config.OUT_BPC or 0) + (self.out_td.get() and config.OUT_TD or 0) + (self.out_csv.get() and config.OUT_CSV or 0) + (self.out_ship.get() and config.OUT_SHIP or 0))
         config.set('outdir', self.outdir.get().strip())
         config.set('anonymous', self.out_anon.get())
         self.destroy()