409 lines
16 KiB
Python
409 lines
16 KiB
Python
# This is not a ZK Lua Widget, don't put it in your widgets folder.
|
|
# This is a companion script to export_UnitDefs.lua which will parse the lua output and generate something nicer.
|
|
# Currently, it shortlists the units and properties we care about and spits those out as JSON.
|
|
import pandas as pd
|
|
import slpp # https://github.com/SirAnthony/slpp
|
|
|
|
FILENAME = 'SPR_104.0.1-1510-g89bb8e3 maintenance_ZK_v1.8.5.3_unitdefs.lua' # TODO: pass it in as an arg or something
|
|
with open(FILENAME, 'r') as file:
|
|
unitdefs_text = file.read().lstrip('return')
|
|
unitdefs_full = {v['name']:v for v in slpp.slpp.decode(unitdefs_text).values()}
|
|
|
|
unit_shortlist = [
|
|
'cloakaa', 'cloakarty', 'cloakassault', 'cloakbomb', 'cloakcon', 'cloakheavyraid', 'cloakjammer', 'cloakraid', 'cloakriot', 'cloakskirm', 'cloaksnipe',
|
|
'shieldaa', 'shieldarty', 'shieldassault', 'shieldbomb', 'shieldcon', 'shieldfelon', 'shieldraid', 'shieldriot', 'shieldscout', 'shieldshield', 'shieldskirm',
|
|
'vehaa', 'veharty', 'wolverine_mine', 'vehassault', 'vehcapture', 'vehcon', 'vehheavyarty', 'vehraid', 'vehriot', 'vehscout', 'vehsupport',
|
|
'hoveraa', 'hoverarty', 'hoverassault', 'hovercon', 'hoverdepthcharge', 'hoverheavyraid', 'hoverminer', 'hoverraid', 'hoverriot', 'hovershotgun', 'hoverskirm', 'hoverskirm2', 'hoversonic',
|
|
'gunshipaa', 'gunshipassault', 'gunshipbomb', 'gunshipcon', 'gunshipemp', 'gunshipheavyskirm', 'gunshipheavytrans', 'gunshipkrow', 'gunshipraid', 'gunshipskirm', 'gunshiptrans', 'nebula',
|
|
'planecon', 'planefighter', 'planeheavyfighter', 'planelightscout', 'planescout',
|
|
'bomberassault', 'bomberdisarm', 'bomberheavy', 'bomberprec', 'bomberriot', 'bomberstrike',
|
|
'spideraa', 'spideranarchid', 'spiderantiheavy', 'spiderassault', 'spidercon', 'spidercrabe', 'spideremp', 'spiderriot', 'spiderscout', 'spiderskirm',
|
|
'jumpaa', 'jumparty', 'jumpassault', 'jumpblackhole', 'jumpbomb', 'jumpcon', 'jumpraid', 'jumpscout', 'jumpskirm', 'jumpsumo',
|
|
'tankaa', 'tankarty', 'tankassault', 'tankcon', 'tankheavyarty', 'tankheavyassault', 'tankheavyraid', 'tankraid', 'tankriot',
|
|
'amphaa', 'amphassault', 'amphbomb', 'amphcon', 'amphfloater', 'amphimpulse', 'amphlaunch', 'amphraid', 'amphriot', 'amphtele', 'tele_beacon', 'grebe',
|
|
'shipaa', 'shiparty', 'shipassault', 'shipcarrier', 'shipcon', 'shipheavyarty', 'shipriot', 'shipscout', 'shipskirm', 'shiptorpraider', 'assaultcruiser',
|
|
'subraider', 'subscout', 'subtacmissile',
|
|
'striderantiheavy', 'striderarty', 'striderbantha', 'striderdante', 'striderdetriment', 'striderfunnelweb', 'striderscorpion', 'athena',
|
|
'energyfusion', 'energygeo', 'energyheavygeo', 'energypylon', 'energysingu', 'energysolar', 'energywind',
|
|
'starlight_satellite', 'staticantinuke', 'staticarty', 'staticcon', 'staticheavyarty', 'staticheavyradar', 'staticjammer', 'staticmex', 'staticmissilesilo', 'staticnuke', 'staticradar', 'staticrearm', 'staticshield', 'staticsonar', 'staticstorage',
|
|
'turretaaclose', 'turretaafar', 'turretaaflak', 'turretaaheavy', 'turretaalaser', 'turretantiheavy', 'turretemp', 'turretgauss', 'turretheavy', 'turretheavylaser', 'turretimpulse', 'turretlaser', 'turretmissile', 'turretriot', 'turretsunlance', 'turrettorp',
|
|
'mahlazer', 'tacnuke', 'empmissile', 'napalmmissile', 'seismic', 'zenith', 'raveparty',
|
|
'dronecarry', 'dronefighter', 'droneheavyslow', 'dronelight',
|
|
'factoryamph', 'factorycloak', 'factorygunship', 'factoryhover', 'factoryjump', 'factoryplane', 'factoryshield', 'factoryship', 'factoryspider', 'factorytank', 'factoryveh', 'striderhub',
|
|
'roost',
|
|
'chicken', 'chicken_blimpy', 'chicken_digger', 'chicken_digger_b', 'chicken_dodo', 'chicken_dragon', 'chicken_drone', 'chicken_drone_starter', 'chicken_leaper', 'chicken_listener', 'chicken_listener_b', 'chicken_pigeon', 'chicken_rafflesia', 'chicken_roc', 'chicken_shield', 'chicken_spidermonkey', 'chicken_sporeshooter', 'chicken_tiamat', 'chickena', 'chickenblobber', 'chickenbroodqueen', 'chickenc', 'chickend', 'chickenf', 'chickenflyerqueen', 'chickenlandqueen', 'chickenr', 'chickens', 'chickenspire', 'chickenwurm',
|
|
]
|
|
|
|
unitdefkeys_shortlist = [
|
|
'name',
|
|
'humanName',
|
|
'tooltip',
|
|
'metalCost', #'energyCost', 'buildTime', # These should always be identical
|
|
'unitsPer10k', # Fake key calculated from metalCost for convenience
|
|
#'cost', # Seems to be slightly higher than the metalCost/energyCost/buildTime
|
|
'metalStorage', #'metalMake', 'metalUpkeep', # Make and Upkeep all 0 for some reason, handled elsewhere in ZK
|
|
'energyStorage', #'energyMake', 'energyUpkeep', # See above
|
|
'customParams',
|
|
'health',
|
|
'healthPerMetal', # Fake key calculated from health and metalCost for convenience
|
|
'speed', #'speedToFront',
|
|
'turnDegPerSec', 'turnSecPer180', # Fake keys calculated from turnRate for convenience
|
|
'turnRate', 'turnRadius', # turnRate*0.16 = degrees per second
|
|
'turnInPlaceSpeedLimit',
|
|
'hasShield', 'shieldPower', 'shieldWeaponDef',
|
|
'shieldRate', 'shieldRadius', 'shieldPowerPerMetal', 'shieldRatePerMetal', # Fake keys for convenience
|
|
'canCloak', 'cloakCost', 'cloakCostMoving',
|
|
'decloakDistance', 'decloakOnFire',
|
|
'startCloaked', 'stealth',
|
|
'airLosRadius', 'losHeight', 'losRadius',
|
|
'radarRadius', 'jammerRadius',
|
|
'sonarRadius',
|
|
|
|
'airStrafe',
|
|
'armorType',
|
|
'armoredMultiple',
|
|
'bankingAllowed',
|
|
'buildDistance',
|
|
'buildOptions',
|
|
'canReclaim', 'canRepair', 'canResurrect', # Builders should have first two, last is for Athenas only
|
|
'buildSpeed', #'reclaimSpeed', 'repairSpeed', 'resurrectSpeed', # Should all be identical in ZK
|
|
'terraformSpeed',
|
|
'idleTime',
|
|
'modCategories',
|
|
'springCategories',
|
|
'mass',
|
|
'transportSize', # 0, 4, 25
|
|
'onOffable',
|
|
'power',
|
|
'rSpeed',
|
|
'strafeToAttack',
|
|
|
|
'maxWeaponRange',
|
|
'primaryWeapon',
|
|
'stockpileWeaponDef',
|
|
'reloadTime',
|
|
'weapons',
|
|
|
|
#'sonarJamRadius', # All 0
|
|
#'sonarStealth', # All false
|
|
#'decloakSpherical', # All True
|
|
#'extractRange', # All 0
|
|
#'extractsMetal', # All 0
|
|
#'idleAutoHeal', # All 0, strangely. Seems to be handled in customParams.
|
|
#'turnInPlace', # All False
|
|
#'isAirUnit', 'isBomberAirUnit',
|
|
#'isBuilder', 'isBuilding',
|
|
#'isExtractor', # All False
|
|
#'isFactory',
|
|
#'isFirePlatform', # All False
|
|
#'isFighterAirUnit', 'isGroundUnit', 'isHoveringAirUnit', 'isImmobile', 'isMobileBuilder', 'isStaticBuilder', 'isStrafingAirUnit', 'isTransport',
|
|
#'canSelfRepair', # All false
|
|
#'buildRange3D', # All False
|
|
#'autoHeal', # All 0
|
|
|
|
#'activateWhenBuilt',
|
|
#'buildingDecalDeaySpeed', 'buildingDecalSizeX', 'buildingDecalSizeY', 'buildingDecalType', 'buildpicname',
|
|
#'canAssist', 'canBeAssisted', 'canCapture',
|
|
#'canAttack', 'canAttackWater', 'canFight', 'canFireControl', 'canManualFire', 'canLoopbackAttack',
|
|
#'canFly', 'canGuard', 'canKamikaze',
|
|
#'canMove', 'canPatrol',
|
|
#'canParalyze', 'canRepeat', 'canRestore', 'canSelfD',
|
|
#'canStockpile', 'canSubmerge', 'cantBeTransported',
|
|
#'capturable', 'captureSpeed',
|
|
#'cobID',
|
|
#'collide', 'collisionVolume',
|
|
#'crashDrag', 'deathExplosion',
|
|
#'dlHoverFactor',
|
|
#'factoryHeadingTakeoff',
|
|
#'fallSpeed',
|
|
#'fireState',
|
|
#'canDropFlare', 'flankingBonusDirX', 'flankingBonusDirY', 'flankingBonusDirZ', 'flankingBonusMax', 'flankingBonusMin', 'flankingBonusMobilityAdd', 'flankingBonusMode',
|
|
#'flareDelay', 'flareDropVectorX', 'flareDropVectorY', 'flareDropVectorZ', 'flareEfficiency', 'flareReloadTime', 'flareSalvoDelay', 'flareSalvoSize', 'flareTime',
|
|
#'floatOnWater',
|
|
#'frontToSpeed',
|
|
#'fullHealthFactory',
|
|
#'height',
|
|
#'hideDamage',
|
|
#'highTrajectoryType',
|
|
#'holdSteady',
|
|
#'hoverAttack',
|
|
#'iconType',
|
|
#'id',
|
|
#'isFeature',
|
|
#'kamikazeDist', 'kamikazeUseLOS',
|
|
#'leaveTracks',
|
|
#'levelGround',
|
|
#'loadingRadius',
|
|
#'makesMetal', # All 0
|
|
#'maxAcc', 'maxDec',
|
|
#'maxAileron', 'maxBank', 'maxElevator', 'maxHeightDif', 'maxPitch', 'maxRudder',
|
|
#'maxCoverage',
|
|
#'maxRepairSpeed',
|
|
#'maxThisUnit',
|
|
#'maxWaterDepth',
|
|
#'minCollisionSpeed',
|
|
#'minWaterDepth',
|
|
#'model', 'modelname', 'modelpath', 'modeltype',
|
|
#'moveDef', #'moveState',
|
|
#'myGravity',
|
|
#'showNanoFrame', 'showNanoSpray', 'nanoColorB', 'nanoColorG', 'nanoColorR',
|
|
#'needGeo',
|
|
#'noChaseCategories',
|
|
#'radius',
|
|
#'reclaimable', # Almost all True
|
|
#'releaseHeld',
|
|
#'repairable', # True for all shortlist
|
|
#'scriptName', 'scriptPath',
|
|
#'seismicRadius', 'seismicSignature',
|
|
#'selectionVolume',
|
|
#'selfDCountdown', 'selfDExplosion',
|
|
#'showPlayerName',
|
|
#'slideTolerance',
|
|
#'sounds',
|
|
#'stopToAttack',
|
|
#'targfac', # All False
|
|
#'totalEnergyOut', # All 0
|
|
#'trackOffset',
|
|
#'trackStrength',
|
|
#'trackStretch',
|
|
#'trackType',
|
|
#'trackWidth',
|
|
#'transportByEnemy', # All True
|
|
#'transportCapacity', # All 0 or 1
|
|
#'transportMass', # All 100000
|
|
#'transportUnloadMethod', # All 0
|
|
#'unitFallSpeed',
|
|
#'upright',
|
|
#'useBuildingGroundDecal',
|
|
#'useSmoothMesh',
|
|
#'verticalSpeed',
|
|
#'wantedHeight',
|
|
#'waterline',
|
|
#'tidalGenerator',
|
|
#'windGenerator',
|
|
#'wingAngle', 'wingDrag',
|
|
#'wreckName',
|
|
#'xsize', 'zsize'
|
|
]
|
|
|
|
weaponkeys_shortlist = [
|
|
'name',
|
|
'id',
|
|
'description',
|
|
'salvoDamage',
|
|
'salvoDuration',
|
|
'salvoDPS',
|
|
'sustainedDPS',
|
|
'range',
|
|
'reload',
|
|
'customParams',
|
|
'damages',
|
|
'paralyzer',
|
|
'isShield',
|
|
'salvoDelay',
|
|
'salvoSize',
|
|
'accuracy',
|
|
'tracks',
|
|
|
|
'badTargets', 'onlyTargets',
|
|
'canAttackGround',
|
|
'cegTag',
|
|
#'coverageRange', # All 0 or 100000
|
|
'craterAreaOfEffect',
|
|
'cylinderTargeting', 'cylinderTargetting',
|
|
'damageAreaOfEffect',
|
|
'duration',
|
|
'dynDamageExp', # 0, 1
|
|
#'dynDamageInverted', 'dynDamageMin', # All False, 0 respectively
|
|
'dynDamageRange',
|
|
'edgeEffectiveness',
|
|
'explosionSpeed',
|
|
'fireStarter',
|
|
'flightTime',
|
|
'manualFire',
|
|
'impactOnly',
|
|
'movingAccuracy',
|
|
'noAutoTarget',
|
|
'noExplode',
|
|
'noSelfDamage',
|
|
'numbounce',
|
|
'onlyForward',
|
|
'projectiles', 'projectilespeed',
|
|
'proximityPriority',
|
|
'selfExplode',
|
|
'sprayAngle',
|
|
'startvelocity',
|
|
'stockpile', 'stockpileTime',
|
|
'metalCost', 'energyCost',
|
|
'turnRate',
|
|
'turret',
|
|
'type',
|
|
'uptime',
|
|
'weaponAcceleration',
|
|
'weaponDef',
|
|
'wobble'
|
|
|
|
#'avoidFeature', 'avoidFriendly', 'avoidNeutral',
|
|
#'beamTTL', 'beamburst', 'beamtime',
|
|
#'bouncerebound',
|
|
#'collisionSize',
|
|
#'dance',
|
|
#'gravityAffected',
|
|
#'groundbounce', 'groundslip',
|
|
#'heightBoostFactor', 'heightMod',
|
|
#'highTrajectory',
|
|
#'intensity', 'minIntensity',
|
|
#'interceptSolo',
|
|
#'interceptedByShieldType',
|
|
#'interceptor',
|
|
#'largeBeamLaser',
|
|
#'laserHardStop',
|
|
#'leadBonus',
|
|
#'leadLimit',
|
|
#'mainDirX', 'mainDirY', 'mainDirZ',
|
|
#'maxAngle', 'maxAngleDif',
|
|
#'myGravity',
|
|
#'noEnemyCollide', 'noFeatureCollide', 'noFireBaseCollide', 'noFriendlyCollide', 'noGroundCollide', 'noNeutralCollide', 'noNonTargetCollide',
|
|
#'predictBoost',
|
|
#'fireSound', 'hitSound', 'soundTrigger',
|
|
|
|
#'shieldAlpha',
|
|
#'shieldBadColorA', 'shieldBadColorB', 'shieldBadColorG', 'shieldBadColorR',
|
|
#'shieldGoodColorA', 'shieldGoodColorB', 'shieldGoodColorG', 'shieldGoodColorR',
|
|
#'shieldEnergyUse',
|
|
#'shieldPower', 'shieldPowerRegen', 'shieldPowerRegenEnergy', 'shieldRadius', 'shieldRechargeDelay',
|
|
#'shieldForce',
|
|
#'shieldInterceptType',
|
|
#'shieldMaxSpeed',
|
|
#'shieldRepulser',
|
|
#'smartShield',
|
|
#'exteriorShield',
|
|
#'visibleShield', 'visibleShieldHitFrames', 'visibleShieldRepulse',
|
|
|
|
#'size',
|
|
#'sizeGrowth',
|
|
#'slavedTo', # All 0 on shortlist
|
|
#'sweepFire', # All false on shortlist
|
|
#'targetBorder', 'targetMoveError', 'targetable',
|
|
#'tdfId', # All 0 on shortlist
|
|
#'trajectoryHeight',
|
|
#'visuals',
|
|
#'waterWeapon', 'waterbounce',
|
|
]
|
|
|
|
|
|
#udefs_short = {u:{k:v for k,v in unitdefs_full[u].items() if k in unitdefkeys_shortlist} for u in unit_shortlist}
|
|
#udefs_short = {
|
|
#u: {
|
|
#k: (unitdefs_full[u][k] if k!='weapons' else
|
|
#[{wk: wep[wk] for wk in weaponkeys_shortlist if wk in wep} for wep in unitdefs_full[u]['weapons'].values()])
|
|
#for k in unitdefkeys_shortlist if k in unitdefs_full[u]
|
|
#}
|
|
#for u in unit_shortlist
|
|
#}
|
|
|
|
def wdef_salvoDamage(wdef):
|
|
if 'customParams' in wdef and 'shot_damage' in wdef['customParams']:
|
|
return float(wdef['customParams']['shot_damage']) # Yes, there are strings in the dataset for no reason
|
|
return float(wdef.get('salvoSize', 1)) * float(wdef.get('damages', {}).get(0, 0))
|
|
def wdef_salvoDuration(wdef):
|
|
return float(wdef.get('salvoSize', 1)) * float(wdef.get('salvoDelay', 0))
|
|
def wdef_salvoDPS(wdef):
|
|
dmg = wdef_salvoDamage(wdef)
|
|
if float(wdef.get('salvoSize', 1)) > 1:
|
|
duration = max(min(wdef_salvoDuration(wdef), wdef.get('reload', 1)), 0.000001) # Don't want divide-by-zero silliness
|
|
else:
|
|
duration = max(wdef.get('reload', 1), 0.000001) # Don't want divide-by-zero silliness
|
|
return dmg/duration
|
|
def wdef_sustainedDPS(wdef):
|
|
dmg = wdef_salvoDamage(wdef)
|
|
duration = max(wdef.get('reload', 1), 0.000001) # Don't want divide-by-zero silliness
|
|
return dmg/duration
|
|
wdef_xforms = {
|
|
'badTargets': lambda wdef: [i for i in wdef['badTargets'].keys()],
|
|
'onlyTargets': lambda wdef: [i for i in wdef['onlyTargets'].keys()],
|
|
'salvoDamage': wdef_salvoDamage,
|
|
'salvoDuration': wdef_salvoDuration,
|
|
'salvoDPS': wdef_salvoDPS,
|
|
'sustainedDPS': wdef_sustainedDPS
|
|
}
|
|
udef_xforms = {
|
|
#'weapons': lambda udef: [{wk: (wep[wk] if wk not in wdef_xforms else wdef_xforms[wk](wep)) for wk in weaponkeys_shortlist if wk in wep} for wep in udef['weapons'].values()],
|
|
'weapons': lambda udef: [{wk: wdef_xforms.get(wk, lambda x:x[wk])(wep) for wk in weaponkeys_shortlist if (wk in wep or wk in wdef_xforms)} for wep in udef['weapons'].values()],
|
|
'buildOptions': lambda udef: [i for i in udef['buildOptions'].values()],
|
|
'modCategories': lambda udef: [i for i in udef['modCategories'].keys()],
|
|
'springCategories': lambda udef: [i for i in udef['springCategories'].keys()],
|
|
'turnDegPerSec': lambda udef: udef['turnRate']*0.16, # Fake keys calculated from turnRate for convenience
|
|
'turnSecPer180': lambda udef: 180/((udef['turnRate'] if udef['turnRate']>0 else 1)*0.16),
|
|
'healthPerMetal': lambda udef: udef['health']/(1 if udef['metalCost']<1 else udef['metalCost']), # Fake key calculated from health and metalCost for convenience
|
|
'unitsPer10k': lambda udef: 10000/udef.get('metalCost', 1), # Fake key calculated from metalCost for convenience
|
|
'shieldRadius': lambda udef: float(udef.get('customParams', {}).get('shield_radius', 0)),
|
|
'shieldRate': lambda udef: float(udef.get('customParams', {}).get('shield_rate', 0)),
|
|
'shieldPowerPerMetal': lambda udef: float(udef.get('shieldPower', 0))/udef.get('metalCost', 1),
|
|
'shieldRatePerMetal': lambda udef: float(udef.get('customParams', {}).get('shield_rate', 0))/udef.get('metalCost', 1),
|
|
}
|
|
udefs_short = {
|
|
u: {
|
|
#k: (unitdefs_full[u][k] if k not in udef_xforms else udef_xforms[k](unitdefs_full[u]))
|
|
k: udef_xforms.get(k, lambda x:x[k])(unitdefs_full[u]) for k in unitdefkeys_shortlist if (k in unitdefs_full[u] or k in udef_xforms)
|
|
}
|
|
for u in unit_shortlist
|
|
}
|
|
for uname, udef in udefs_short.items():
|
|
wdefs = udef.get('weapons', [])
|
|
if len(wdefs) > 0:
|
|
cost = 1 if udef['metalCost']<1 else udef['metalCost']
|
|
udefs_short[uname]['max_salvoDPS'] = max([wdef.get('salvoDPS', 0) for wdef in wdefs])
|
|
udefs_short[uname]['max_sustainedDPS'] = max([wdef.get('sustainedDPS', 0) for wdef in wdefs])
|
|
udefs_short[uname]['max_salvoDPS_perMetal'] = udefs_short[uname]['max_salvoDPS']/cost
|
|
udefs_short[uname]['max_sustainedDPS_perMetal'] = udefs_short[uname]['max_sustainedDPS']/cost
|
|
|
|
df_udefs = pd.DataFrame.from_dict(udefs_short)
|
|
df_udefs.to_json(FILENAME.rpartition('.')[0] + '.json', orient='columns', force_ascii=False, indent=2)
|
|
|
|
udef_spreadsheet_keys = [
|
|
'name',
|
|
'humanName',
|
|
'tooltip',
|
|
'metalCost',
|
|
'unitsPer10k',
|
|
'health',
|
|
'shieldPower',
|
|
'shieldRate',
|
|
'max_salvoDPS',
|
|
'max_sustainedDPS',
|
|
'maxWeaponRange',
|
|
'reloadTime',
|
|
|
|
'healthPerMetal',
|
|
'max_salvoDPS_perMetal',
|
|
'max_sustainedDPS_perMetal',
|
|
'shieldPowerPerMetal',
|
|
'shieldRatePerMetal',
|
|
'speed',
|
|
'turnDegPerSec', 'turnSecPer180',
|
|
|
|
'shieldRadius',
|
|
'metalStorage',
|
|
'energyStorage',
|
|
'canCloak', 'startCloaked', 'stealth', 'cloakCost', 'cloakCostMoving', 'decloakDistance',
|
|
'airLosRadius', 'losHeight', 'losRadius',
|
|
'radarRadius', 'jammerRadius', 'sonarRadius',
|
|
|
|
'armorType', 'armoredMultiple',
|
|
'buildDistance', 'buildOptions',
|
|
'canReclaim', 'canRepair', 'canResurrect', # Builders should have first two, last is for Athenas only
|
|
'buildSpeed', 'terraformSpeed',
|
|
'idleTime',
|
|
'mass',
|
|
'transportSize', # 0, 4, 25
|
|
'onOffable',
|
|
'power',
|
|
'modCategories',
|
|
'springCategories',
|
|
]
|
|
udef_spreadsheet_dict = {unit_k:{k:udefs_short[unit_k].get(k,'') for k in udef_spreadsheet_keys} for unit_k in udefs_short.keys()}
|
|
udef_spreadsheet_df = pd.DataFrame.from_dict(udef_spreadsheet_dict)
|
|
import csv
|
|
udef_spreadsheet_df.T.to_csv(FILENAME.rpartition('.')[0] + '.tsv', sep='\t', index=False, quoting=csv.QUOTE_NONE)
|