mirror of
https://gitlab.com/the-no-frauds-club/cosmonarchy-bw-prerelease.git
synced 2026-09-17 01:01:12 +00:00
Merge branch 'feat/images.tbl.txt' into 'main'
25 August 2026 -- tbl encoder decoder and image.tbl.txt loader See merge request the-no-frauds-club/cosmonarchy-bw-prerelease!1
This commit is contained in:
commit
507aa3d4b8
2318
mpq/arr/images.tbl.txt
Normal file
2318
mpq/arr/images.tbl.txt
Normal file
File diff suppressed because it is too large
Load Diff
587
mpq/arr/portdata.tbl.txt
Normal file
587
mpq/arr/portdata.tbl.txt
Normal file
@ -0,0 +1,587 @@
|
||||
# tbl-text v1 - StarCraft: Brood War string table source
|
||||
# One entry per line, in index order.
|
||||
# <0> separates the parts of a multi-part entry
|
||||
# <N> raw byte N (decimal 0-255), e.g. <10> newline, <4> colour code
|
||||
# << a literal '<' <> nothing (empty-entry marker)
|
||||
# Lines starting with '#' are comments; blank lines are ignored.
|
||||
|
||||
tmarine\TMaFid0
|
||||
tmarine\TMaTlk0
|
||||
tghost\TGhFid0
|
||||
tghost\TGhTlk0
|
||||
tfirebat\TFbFid0
|
||||
tfirebat\TFbTlk0
|
||||
tvulture\TVuFid0
|
||||
tvulture\TVuTlk0
|
||||
TSpider\TMnFid0
|
||||
TSpider\TMnTlk0
|
||||
tgoliath\TGoFid0
|
||||
tgoliath\TGoTlk0
|
||||
ttank\TTaFid0
|
||||
ttank\TTaTlk0
|
||||
tSCV\TSCFid0
|
||||
tSCV\TSCTlk0
|
||||
tphoenix\TPhFid0
|
||||
tphoenix\TPhTlk0
|
||||
tvessel\TVeFid0
|
||||
tvessel\TVeTlk0
|
||||
tdropshi\TDrFid0
|
||||
tdropshi\TDrTlk0
|
||||
tbattlec\TBaFid0
|
||||
tbattlec\TBaTlk0
|
||||
UKerr\UKeFid0
|
||||
UKerr\UKeTlk0
|
||||
UJim\URaFid0
|
||||
UJim\URaTlk0
|
||||
UDuke\UDuFid0
|
||||
UDuke\UDuTlk0
|
||||
Ncivilian\NCiFid0
|
||||
Umengsk\UMeFid0
|
||||
Umengsk\UMeTlk0
|
||||
TAdvisor\TAdFid0
|
||||
TAdvisor\TAdTlk0
|
||||
ZLarva\ZLaFid0
|
||||
ZLarva\ZLaTlk0
|
||||
ZEgg\ZEgFid0
|
||||
ZEgg\ZEgTlk0
|
||||
ZZergl\ZZeFid0
|
||||
ZZergl\ZZeTlk0
|
||||
ZHydra\ZHyFid0
|
||||
ZHydra\ZHyTlk0
|
||||
ZUltra\ZUlFid0
|
||||
ZUltra\ZUlTlk0
|
||||
ZBrood\ZBrFid0
|
||||
ZBrood\ZBrTlk0
|
||||
ZDrone\ZDrFid0
|
||||
ZDrone\ZDrTlk0
|
||||
ZOver\ZOvFid0
|
||||
ZOver\ZOvTlk0
|
||||
ZMutalid\ZMuFid0
|
||||
ZMutalid\ZMuTlk0
|
||||
ZGuard\ZGuFid0
|
||||
ZGuard\ZGuTlk0
|
||||
ZQueen\ZQuFid0
|
||||
ZQueen\ZQuTlk0
|
||||
ZDefiler\ZDeFid0
|
||||
ZDefiler\ZDeTlk0
|
||||
ZAvenger\ZAvFid0
|
||||
ZAvenger\ZAvTlk0
|
||||
Zcocoon\ZCoFid0
|
||||
ZChrysalis\ZChFid0
|
||||
ZInfestT\ZBGFid0
|
||||
ZInfestT\ZBGTlk0
|
||||
UZasz\Fid0
|
||||
UZasz\Tlk0
|
||||
UDagg\UDgFid0
|
||||
UDagg\UDgTlk0
|
||||
UIKerr\UIKFid0
|
||||
UIKerr\UIKTlk0
|
||||
UHunters\UHuFid0
|
||||
UHunters\UHuTlk0
|
||||
ZAdvisor\ZAdFid0
|
||||
ZAdvisor\ZAdTlk0
|
||||
PProbe\PPrFid0
|
||||
PProbe\PPrTlk0
|
||||
PZealot\PZeFid0
|
||||
PZealot\PZeTlk0
|
||||
PDragoon\PDrFid0
|
||||
PDragoon\PDrTlk0
|
||||
PTemplar\PTeFid0
|
||||
PTemplar\PTeTlk0
|
||||
parchon\PArFid0
|
||||
parchon\PArTlk0
|
||||
pshuttle\PShFid0
|
||||
pshuttle\PShTlk0
|
||||
PScout\PScFid0
|
||||
PScout\PScTlk0
|
||||
PArbiter\PAbFid0
|
||||
PArbiter\PAbTlk0
|
||||
pcarrier\PCaFid0
|
||||
pcarrier\PCaTlk0
|
||||
PInterce\PInFid0
|
||||
PInterce\PInTlk0
|
||||
UDTemplar\UDTFid0
|
||||
UDTemplar\UDTTlk0
|
||||
UFenZeal\UFeFid0
|
||||
UFenZeal\UFeTlk0
|
||||
UFenDrag\UFdFid0
|
||||
UFenDrag\UFdTlk0
|
||||
UZeratul\UZeFid0
|
||||
UZeratul\UZeTlk0
|
||||
UTassadar\UTaFid0
|
||||
UTassadar\UTaTlk0
|
||||
PWitness\PWiFid0
|
||||
PWitness\PWiTlk0
|
||||
PTrilo\PTrFid0
|
||||
PTrilo\PTrTlk0
|
||||
Ukhad\NCrFid0
|
||||
Ukhad\NCrTlk0
|
||||
PAdvisor\PAdFid0
|
||||
PAdvisor\PAdTlk0
|
||||
nbiker\NBiFid0
|
||||
nbiker\NBiTlk0
|
||||
NBadCrit\NBCFid0
|
||||
NBadCrit\NBCTlk0
|
||||
NJungCrit\NJCFid0
|
||||
NJungCrit\NJCTlk0
|
||||
NLavaCrit\NLCFid0
|
||||
NLavaCrit\NLCTlk0
|
||||
NGasTank\NGTFid0
|
||||
NGOrb\NGOFid0
|
||||
NGasSac\NGSFid0
|
||||
NOre\NOCFid0
|
||||
UDisk\UDdFid0
|
||||
Udisk\UDdTlk0
|
||||
Upsi\UPsFid0
|
||||
Upsi\UPsTlk0
|
||||
Ushard\UShFid0
|
||||
Ushard\UShTlk0
|
||||
UFlag1\UF1Fid0
|
||||
UFlag1\UF1Tlk0
|
||||
UFlag2\UF2Fid0
|
||||
UFlag2\UF2Tlk0
|
||||
UFlag3\UF3Fid0
|
||||
UFlag3\UF3Tlk0
|
||||
UFlag4\UF4Fid0
|
||||
UFlag4\UF4Tlk0
|
||||
UFlag5\UF5Fid0
|
||||
UFlag5\UF5Tlk0
|
||||
UFlag6\UF6Fid0
|
||||
UFlag6\UF6Tlk0
|
||||
UFlag7\UF7Fid0
|
||||
UFlag7\UF7Tlk0
|
||||
UFlag8\UF8Fid0
|
||||
UFlag8\UF8Tlk0
|
||||
tmedic\TMeFid0
|
||||
tmedic\TMeTlk0
|
||||
Tbomber\TBmFid0
|
||||
TBomber\TBmTlk0
|
||||
TDugal\UdgFid0
|
||||
TDugal\UdgTlk0
|
||||
Talex\TaxFid0
|
||||
Talex\TaxTlk0
|
||||
TSam\TsmFid0
|
||||
TSam\TsmTlk0
|
||||
PArtanis\PatFid0
|
||||
PArtanis\PatTlk0
|
||||
PRaz\PRzFid0
|
||||
PRaz\PRzTlk0
|
||||
Zdevour\ZDvfid0
|
||||
Zdevour\ZDvtlk0
|
||||
ZLurker\ZLufid0
|
||||
ZLurker\ZLutlk0
|
||||
PDarchon\PDaFid0
|
||||
PDarchon\PDaTlk0
|
||||
PCorsair\PCsFid0
|
||||
PCorsair\PCsTlk0
|
||||
NRedCrit\NRDFid0
|
||||
NRedCrit\NRDTlk0
|
||||
NBluCrit\NLCFid0
|
||||
NBluCrit\NLCTlk0
|
||||
NIceCrit\NICFid0
|
||||
NIceCrit\NICTlk0
|
||||
UUraj\UUjFid0
|
||||
UUraj\UUjTlk0
|
||||
UKhalis\UKsFid0
|
||||
UKhalis\UKsTlk0
|
||||
UFlag9\UF9Fid0
|
||||
UFlag9\UF9Tlk0
|
||||
UFlag10\UF10Fid0
|
||||
UFlag10\UF10Tlk0
|
||||
UFlag11\UF11Fid0
|
||||
UFlag11\UF11Tlk0
|
||||
UFlag12\UF12Fid0
|
||||
UFlag12\UF12Tlk0
|
||||
t\cyprian\fid0
|
||||
t\cyprian\tlk0
|
||||
t\shaman\fid0
|
||||
t\shaman\tlk0
|
||||
p\augur\fid0
|
||||
p\augur\tlk0
|
||||
f\advisor_zorthos\fid0
|
||||
f\advisor_zorthos\tlk0
|
||||
z\vorvrokor\fid0
|
||||
z\vorvrokor\tlk0
|
||||
p\ecclesiast\fid0
|
||||
p\ecclesiast\tlk0
|
||||
p\patriarch\fid0
|
||||
p\patriarch\tlk0
|
||||
p\legionnaire\fid0
|
||||
p\legionnaire\tlk0
|
||||
z\protathalor\fid0
|
||||
z\protathalor\tlk0
|
||||
z\izirokor\fid0
|
||||
z\izirokor\tlk0
|
||||
p\amaranth\fid0
|
||||
p\amaranth\tlk0
|
||||
t\claymore\fid0
|
||||
t\claymore\tlk0
|
||||
t\dilettante\fid0
|
||||
t\dilettante\fid0
|
||||
t\wyvern\fid0
|
||||
t\wyvern\tlk0
|
||||
t\savant\fid0
|
||||
t\savant\tlk0
|
||||
p\potentate\fid0
|
||||
p\potentate\tlk0
|
||||
z\evigrilisk\fid0
|
||||
z\evigrilisk\tlk0
|
||||
p\sycophant\fid0
|
||||
p\sycophant\tlk0
|
||||
p\positron\fid0
|
||||
p\positron\tlk0
|
||||
t\madrigal\fid0
|
||||
t\madrigal\tlk0
|
||||
t\cyclops\fid0
|
||||
t\cyclops\tlk0
|
||||
t\sevenstep\fid0
|
||||
t\sevenstep\tlk0
|
||||
t\sundog\fid0
|
||||
t\sundog\tlk0
|
||||
t\olympian\fid0
|
||||
t\olympian\tlk0
|
||||
p\servitor\fid0
|
||||
p\servitor\tlk0
|
||||
sp\hadral\fid0
|
||||
sp\hadral\tlk0
|
||||
sp\thyron\fid0
|
||||
sp\thyron\tlk0
|
||||
t\salamander\fid0
|
||||
t\salamander\tlk0
|
||||
t\phobos\fid0
|
||||
t\phobos\tlk0
|
||||
sp\ackmed\fid0
|
||||
sp\ackmed\tlk0
|
||||
z\skithrokor\fid0
|
||||
z\skithrokor\tlk0
|
||||
p\vagrant\fid0
|
||||
p\vagrant\tlk0
|
||||
p\anthelion\fid0
|
||||
p\anthelion\tlk0
|
||||
f\advisor_nydustrinity\fid0
|
||||
f\advisor_nydustrinity\tlk0
|
||||
sp\nekron\fid0
|
||||
sp\nekron\tlk0
|
||||
p\gladius\fid0
|
||||
p\gladius\tlk0
|
||||
f\advisor_goldenrepublic\fid0
|
||||
f\advisor_goldenrepublic\tlk0
|
||||
p\herald\fid0
|
||||
p\herald\tlk0
|
||||
f\advisor_upstartt\fid0
|
||||
f\advisor_upstartt\tlk0
|
||||
f\advisor_starbound\fid0
|
||||
f\advisor_starbound\fid0
|
||||
p\optecton\fid0
|
||||
p\optecton\tlk0
|
||||
t\cataphract\fid0
|
||||
t\cataphract\tlk0
|
||||
a\advisor\fid0
|
||||
a\advisor\fid0
|
||||
t\striga\fid0
|
||||
t\striga\tlk0
|
||||
z\rililisk\fid0
|
||||
z\rililisk\tlk0
|
||||
p\pariah\fid0
|
||||
p\pariah\tlk0
|
||||
f\tarasque\fid0
|
||||
f\tarasque\tlk0
|
||||
p\exemplar\fid0
|
||||
p\exemplar\fid0
|
||||
f\advisor_futuristfederation\fid0
|
||||
f\advisor_futuristfederation\tlk0
|
||||
t\matador\fid0
|
||||
t\matador\tlk0
|
||||
f\advisor_imicren\fid0
|
||||
f\advisor_imicren\fid0
|
||||
p\atreus\fid0
|
||||
p\atreus\tlk0
|
||||
p\architect\fid0
|
||||
p\architect\fid0
|
||||
f\philoc\fid0
|
||||
f\philoc\tlk0
|
||||
t\gorgon\fid0
|
||||
t\gorgon\tlk0
|
||||
z\zoryusthaleth\fid0
|
||||
z\zoryusthaleth\tlk0
|
||||
f\adherent\fid0
|
||||
f\adherent\tlk0
|
||||
z\keskathalor\fid0
|
||||
z\keskathalor\tlk0
|
||||
z\mortothrokor\fid0
|
||||
z\mortothrokor\tlk0
|
||||
t\madcap\fid0
|
||||
t\madcap\tlk0
|
||||
p\hierophant\fid0
|
||||
p\hierophant\tlk0
|
||||
f\hotshot\fid0
|
||||
f\hotshot\tlk0
|
||||
t\hippogriff\fid0
|
||||
t\hippogriff\tlk0
|
||||
p\starsovereign\fid0
|
||||
p\starsovereign\tlk0
|
||||
p\epigraph\fid0
|
||||
p\epigraph\tlk0
|
||||
p\empyrean\fid0
|
||||
p\empyrean\tlk0
|
||||
p\empress\fid0
|
||||
p\empress\tlk0
|
||||
z\cikralisk\fid0
|
||||
z\cikralisk\tlk0
|
||||
z\kagralisk\fid0
|
||||
z\kagralisk\tlk0
|
||||
t\hypnagogue\fid0
|
||||
t\hypnagogue\tlk0
|
||||
t\pythean\fid0
|
||||
t\pythean\tlk0
|
||||
f\advisor_khalendricknights\fid0
|
||||
f\advisor_khalendricknights\tlk0
|
||||
f\advisor_orkoti\fid0
|
||||
f\advisor_orkoti\tlk0
|
||||
f\inzorthrokor\fid0
|
||||
f\inzorthrokor\tlk0
|
||||
p\artisan\fid0
|
||||
p\artisan\tlk0
|
||||
p\clarion\fid0
|
||||
p\clarion\tlk0
|
||||
p\charlatan\fid0
|
||||
p\charlatan\tlk0
|
||||
f\ashguard\fid0
|
||||
f\ashguard\tlk0
|
||||
p\oscilliant\fid0
|
||||
p\oscilliant\tlk0
|
||||
p\golem\fid0
|
||||
p\golem\tlk0
|
||||
p\magister\fid0
|
||||
p\magister\tlk0
|
||||
f\eidolon_nydus\fid0
|
||||
f\eidolon_nydus\tlk0
|
||||
sp\joran\fid0
|
||||
sp\joran\tlk0
|
||||
p\analogue\fid0
|
||||
p\analogue\tlk0
|
||||
sp\furen\fid0
|
||||
sp\furen\tlk0
|
||||
p\vassal\fid0
|
||||
p\vassal\tlk0
|
||||
p\vibrance\fid0
|
||||
p\vibrance\fid0
|
||||
p\idol\fid0
|
||||
p\idol\tlk0
|
||||
p\unar\fid0
|
||||
p\unar\tlk0
|
||||
sp\kortar\fid0
|
||||
sp\kortar\tlk0
|
||||
a\emissary\fid0
|
||||
a\emissary\tlk0
|
||||
sp\inc\goska\fid0
|
||||
sp\inc\goska\tlk0
|
||||
sp\inc\stroluum\fid0
|
||||
sp\inc\stroluum\tlk0
|
||||
sp\inc\tuzor\fid0
|
||||
sp\inc\tuzor\tlk0
|
||||
sp\inc\gorn\fid0
|
||||
sp\inc\gorn\tlk0
|
||||
sp\mephis\fid0
|
||||
sp\mephis\tlk0
|
||||
sp\inc\algannor\fid0
|
||||
sp\inc\algannor\tlk0
|
||||
sp\inc\thalazim\fid0
|
||||
sp\inc\thalazim\tlk0
|
||||
sp\inc\warmonger\fid0
|
||||
sp\inc\warmonger\tlk0
|
||||
sp\inc\yggdrasil\fid0
|
||||
sp\inc\yggdrasil\tlk0
|
||||
sp\organ_senescent\fid0
|
||||
sp\organ_senescent\tlk0
|
||||
sp\mirith\fid0
|
||||
sp\mirith\tlk0
|
||||
z\vilgorokor\fid0
|
||||
z\vilgorokor\tlk0
|
||||
z\tethzorokor\fid0
|
||||
z\tethzorokor\tlk0
|
||||
z\ghitorokor\fid0
|
||||
z\ghitorokor\tlk0
|
||||
z\tetcaerokor\fid0
|
||||
z\tetcaerokor\tlk0
|
||||
z\nathrokor\fid0
|
||||
z\nathrokor\tlk0
|
||||
z\sasyrvoleth\fid0
|
||||
z\sasyrvoleth\tlk0
|
||||
z\cherbrathalor\fid0
|
||||
z\cherbrathalor\tlk0
|
||||
z\okrimnilisk\fid0
|
||||
z\okrimnilisk\tlk0
|
||||
z\sovroleth\fid0
|
||||
z\sovroleth\tlk0
|
||||
z\algoztalisk\fid0
|
||||
z\algoztalisk\tlk0
|
||||
f\nathrelisk\fid0
|
||||
f\nathrelisk\tlk0
|
||||
z\almaksalisk\fid0
|
||||
z\almaksalisk\tlk0
|
||||
z\akistrokor\fid0
|
||||
z\akistrokor\tlk0
|
||||
z\kalkalisk\fid0
|
||||
z\kalkalisk\tlk0
|
||||
z\othstoleth\fid0
|
||||
z\othstoleth\tlk0
|
||||
z\zobriolisk\fid0
|
||||
z\zobriolisk\tlk0
|
||||
z\quazilisk\fid0
|
||||
z\quazilisk\tlk0
|
||||
z\tosgrilisk\fid0
|
||||
z\tosgrilisk\tlk0
|
||||
z\gosvileth\fid0
|
||||
z\gosvileth\tlk0
|
||||
z\gorgrokor\fid0
|
||||
z\gorgrokor\tlk0
|
||||
z\zarcavrokor\fid0
|
||||
z\zarcavrokor\tlk0
|
||||
z\liiralisk\fid0
|
||||
z\liiralisk\tlk0
|
||||
z\bactalisk\fid0
|
||||
z\bactalisk\tlk0
|
||||
z\iroleth\fid0
|
||||
z\iroleth\tlk0
|
||||
z\axitrilisk\fid0
|
||||
z\axitrilisk\tlk0
|
||||
t\ramesses\fid0
|
||||
t\ramesses\tlk0
|
||||
t\autocrat\fid0
|
||||
t\autocrat\tlk0
|
||||
t\heracles\fid0
|
||||
t\heracles\tlk0
|
||||
t\cuirass\fid0
|
||||
t\cuirass\tlk0
|
||||
f\auriel\fid0
|
||||
f\auriel\tlk0
|
||||
t\siren\fid0
|
||||
t\siren\tlk0
|
||||
sp\barlow\fid0
|
||||
sp\barlow\tlk0
|
||||
t\paladin\fid0
|
||||
t\paladin\tlk0
|
||||
sp\ajax\fid0
|
||||
sp\ajax\tlk0
|
||||
sp\eritreus\fid0
|
||||
sp\eritreus\tlk0
|
||||
t\apostle\fid0
|
||||
t\apostle\tlk0
|
||||
t\ancile\fid0
|
||||
t\ancile\tlk0
|
||||
f\wendigo\fid0
|
||||
f\wendigo\tlk0
|
||||
sp\piler\fid0
|
||||
sp\piler\tlk0
|
||||
sp\osina\fid0
|
||||
sp\osina\tlk0
|
||||
t\pazuzu\fid0
|
||||
t\pazuzu\tlk0
|
||||
sp\lannigan\fid0
|
||||
sp\lannigan\tlk0
|
||||
sp\esmond\fid0
|
||||
sp\esmond\tlk0
|
||||
sp\mazakel\fid0
|
||||
sp\mazakel\tlk0
|
||||
t\centaur\fid0
|
||||
t\centaur\tlk0
|
||||
t\aion\fid0
|
||||
t\aion\tlk0
|
||||
t\silvertongue\fid0
|
||||
t\silvertongue\tlk0
|
||||
t\durendal\fid0
|
||||
t\durendal\tlk0
|
||||
t\talos\fid0
|
||||
t\talos\tlk0
|
||||
z\konvilisk\fid0
|
||||
z\konvilisk\tlk0
|
||||
z\alkajelisk\fid0
|
||||
z\alkajelisk\tlk0
|
||||
t\anticthon\fid0
|
||||
t\anticthon\tlk0
|
||||
p\barghest\fid0
|
||||
p\barghest\tlk0
|
||||
p\simulacrum\fid0
|
||||
p\simulacrum\tlk0
|
||||
t\azazel\fid0
|
||||
t\azazel\tlk0
|
||||
sp\medly\fid0
|
||||
sp\medly\tlk0
|
||||
sp\terimanok\fid0
|
||||
sp\terimanok\tlk0
|
||||
sp\trodin\fid0
|
||||
sp\trodin\tlk0
|
||||
sp\tazarain\fid0
|
||||
sp\tazarain\tlk0
|
||||
sp\venammar\fid0
|
||||
sp\venammar\tlk0
|
||||
p\demiurge\fid0
|
||||
p\demiurge\tlk0
|
||||
p\aurora\fid0
|
||||
p\aurora\tlk0
|
||||
f\iziralisk\fid0
|
||||
f\iziralisk\tlk0
|
||||
a\avatar\fid0
|
||||
a\avatar\tlk0
|
||||
f\ecclesiast_khalendric\fid0
|
||||
f\ecclesiast_khalendric\tlk0
|
||||
f\ecclesiast_astral\fid0
|
||||
f\ecclesiast_astral\tlk0
|
||||
sp\pr0nogo\fid0
|
||||
sp\pr0nogo\tlk0
|
||||
f\arhaun\fid0
|
||||
f\arhaun\tlk0
|
||||
f\blackguard\fid0
|
||||
f\blackguard\tlk0
|
||||
f\blackjack_golden\fid0
|
||||
f\blackjack_golden\tlk0
|
||||
f\cicatus\fid0
|
||||
f\cicatus\tlk0
|
||||
f\cleric_futurist\fid0
|
||||
f\cleric_futurist\tlk0
|
||||
f\cyclops_futurist\fid0
|
||||
f\cyclops_futurist\tlk0
|
||||
f\dracadin_order\fid0
|
||||
f\dracadin_order\tlk0
|
||||
f\extatus\fid0
|
||||
f\extatus\tlk0
|
||||
f\harakan_golden\fid0
|
||||
f\harakan_golden\tlk0
|
||||
f\manifold_starbound\fid0
|
||||
f\manifold_starbound\tlk0
|
||||
f\qilin\fid0
|
||||
f\qilin\tlk0
|
||||
f\quazrokor\fid0
|
||||
f\quazrokor\tlk0
|
||||
f\renesair\fid0
|
||||
f\renesair\tlk0
|
||||
f\tiangou\fid0
|
||||
f\tiangou\tlk0
|
||||
f\zealot_starbound\fid0
|
||||
f\zealot_starbound\tlk0
|
||||
Ncivilian\NCiTlk0
|
||||
f\maverick_futurist\fid0
|
||||
f\maverick_futurist\tlk0
|
||||
f\maverick_upstart\fid0
|
||||
f\maverick_upstart\tlk0
|
||||
f\hydralisk_orkoti\fid0
|
||||
f\hydralisk_orkoti\tlk0
|
||||
f\maverick_golden\fid0
|
||||
f\maverick_golden\tlk0
|
||||
a\auxine\fid0
|
||||
a\auxine\tlk0
|
||||
sp\boyeau\fid0
|
||||
sp\boyeau\tlk0
|
||||
hl\fr\ferrum\fid0
|
||||
hl\fr\ferrum\tlk0
|
||||
hl\cv\prophet-1\fid0
|
||||
hl\cv\prophet-1\tlk0
|
||||
hl\cv\prophet-2\fid0
|
||||
hl\cv\prophet-2\tlk0
|
||||
b\advisor\fid0
|
||||
b\advisor\tlk0
|
||||
f\vestige\fid0
|
||||
f\vestige\tlk0
|
||||
f\rozirokor\fid0
|
||||
f\rozirokor\tlk0
|
||||
94
mpq/rez/gluHist.tbl.txt
Normal file
94
mpq/rez/gluHist.tbl.txt
Normal file
@ -0,0 +1,94 @@
|
||||
# tbl-text v1 - StarCraft: Brood War string table source
|
||||
# One entry per line, in index order.
|
||||
# <0> separates the parts of a multi-part entry
|
||||
# <N> raw byte N (decimal 0-255), e.g. <10> newline, <4> colour code
|
||||
# << a literal '<' <> nothing (empty-entry marker)
|
||||
# Lines starting with '#' are comments; blank lines are ignored.
|
||||
|
||||
Tutorial: Boot Camp
|
||||
1. Wasteland
|
||||
2. Backwater Station
|
||||
3. Desperate Alliance
|
||||
4. The Jacobs Installation
|
||||
5. Revolution
|
||||
6. Norad II
|
||||
7. The Trump Card
|
||||
8. The Big Push
|
||||
9. New Gettysberg
|
||||
10. The Hammer Falls
|
||||
Cinematic: Wasteland Patrol
|
||||
Cinematic: The Downing of Norad II
|
||||
Cinematic: Open Rebellion
|
||||
Cinematic: The Inauguration
|
||||
Terran Cinematic 5
|
||||
1. First Strike
|
||||
2. Into the Flames
|
||||
3. Higher Ground
|
||||
4. The Hunt for Tassadar
|
||||
5. Choosing Sides
|
||||
6. Into the Darkness
|
||||
7. Homeland
|
||||
8. The Trial of Tassadar
|
||||
9. Shadow Hunters
|
||||
10. Eye of the Storm
|
||||
Cinematic: The Fall of Fenix
|
||||
Cinematic: The Ambush
|
||||
Cinematic: The Return to Aiur
|
||||
Cinematic: The Death of the Overmind
|
||||
Protoss Cinematic 5
|
||||
1. Among the Ruins
|
||||
2. Egression
|
||||
3. The New Dominion
|
||||
4. Agent of the Swarm
|
||||
5. The Amerigo
|
||||
6. The Dark Templar
|
||||
7. The Culling
|
||||
8. Eye for an Eye
|
||||
9. The Invasion of Aiur
|
||||
10. Full Circle
|
||||
Cinematic: The Dream
|
||||
Cinematic: Battle on the Amerigo
|
||||
Cinematic: The Warp
|
||||
Cinematic: The Invasion of Aiur
|
||||
Zerg Cinematic 5
|
||||
Tutorial: Boot Camp
|
||||
1. Strongarm
|
||||
2. Den of the Beast
|
||||
3. Extermination
|
||||
Tutorial: Boot Camp
|
||||
1. Strongarm
|
||||
2. The Gauntlet
|
||||
3. Den of the Beast
|
||||
4. Force of Arms
|
||||
5. Extermination
|
||||
1. Escape from Aiur
|
||||
2. Dunes of Shakuras
|
||||
3. Legacy of the Xel<146>Naga
|
||||
4. The Quest for Uraj
|
||||
5. The Battle of Braxis
|
||||
6. Return to Char
|
||||
7. The Insurgent
|
||||
8. Countdown
|
||||
1. First Strike<32>
|
||||
2. The Dylarian Shipyards
|
||||
3. Ruins of Tarsonis
|
||||
4. Assault on Korhal
|
||||
5a. Emperor<146>s Fall (Ground Zero)
|
||||
5b. Emperor<146>s Fall (Birds of War)
|
||||
6. Emperor<146>s Flight
|
||||
7. Patriot<146>s Blood
|
||||
8. To Chain the Beast
|
||||
1. Vile Disruption
|
||||
2. Reign of Fire
|
||||
3. The Kel-Morian Combine
|
||||
4. The Liberation of Korhal
|
||||
5. True Colors
|
||||
6. Fury of the Swarm
|
||||
7. Drawing of the Web
|
||||
8. To Slay the Beast
|
||||
9. The Reckoning
|
||||
9a. Dark Origin
|
||||
10. Omega
|
||||
Cinematic: The Ascension
|
||||
Cinematic: Fury of the Xel'Naga
|
||||
Cinematic: UED Victory Report
|
||||
47
mpq/rez/help_txt.tbl.txt
Normal file
47
mpq/rez/help_txt.tbl.txt
Normal file
@ -0,0 +1,47 @@
|
||||
# tbl-text v1 - StarCraft: Brood War string table source
|
||||
# One entry per line, in index order.
|
||||
# <0> separates the parts of a multi-part entry
|
||||
# <N> raw byte N (decimal 0-255), e.g. <10> newline, <4> colour code
|
||||
# << a literal '<' <> nothing (empty-entry marker)
|
||||
# Lines starting with '#' are comments; blank lines are ignored.
|
||||
|
||||
<4>F10<2> - Game Menu<1>
|
||||
<4>Alt-M<2> - Game Menu
|
||||
<>
|
||||
<4>Alt-H<2> - Help Menu
|
||||
<4>Alt-O<2> - Options Menu
|
||||
<>
|
||||
<4>Ctrl-X<2> - Exit Starcraft
|
||||
<4>Alt-X<2> - Exit Starcraft
|
||||
<4>Ctrl-Q<2> - Quit Mission
|
||||
<4>Alt-Q<2> - Quit Mission
|
||||
<>
|
||||
<4>Ctrl-M<2> - Toggle Music On/Off
|
||||
<4>Ctrl-S<2> - Toggle SFX On/Off
|
||||
<>
|
||||
<4>Space Bar<2> - Last Transmission
|
||||
<>
|
||||
<4>Ctrl-<3>#<2> - Save Group <3>#
|
||||
<3>#<2> - Recall Group <3>#
|
||||
<4>Alt-<3>select<2> - Recall Group
|
||||
<4>Ctrl-<3>select<2> - Select Unit Type
|
||||
<3>double-click<2> - Select Unit Type
|
||||
<>
|
||||
<4>Shift-<3>F2<2> through <3>F8<2> - Save Camera Position
|
||||
<3>F2<2> through <3>F8><2> - Restore Camera Position
|
||||
<>
|
||||
<4>Shift-<3>command<2> - Queue Order
|
||||
<>
|
||||
<4>Ctrl-C<2> - Center on Current Unit
|
||||
<4>Alt-C<2> - Center on Current Unit
|
||||
<>
|
||||
<4>Tab<2> - Toggle Mini-map Terrain Mode
|
||||
<4>Shift-Tab<2> - Toggle Mini-map Unit Mode
|
||||
<>
|
||||
Multiplayer Features:
|
||||
<4>Enter<2> - Messaging Prompt
|
||||
<4>Shift-Enter<2> - To All Messaging Prompt
|
||||
<4>Ctrl-Enter<2> - To Allies Messaging Prompt
|
||||
<>
|
||||
<4>Ctrl-R<2> - Toggle Leaderboard Ranking Mode
|
||||
<>
|
||||
178
mpq/rez/network.tbl.txt
Normal file
178
mpq/rez/network.tbl.txt
Normal file
@ -0,0 +1,178 @@
|
||||
# tbl-text v1 - StarCraft: Brood War string table source
|
||||
# One entry per line, in index order.
|
||||
# <0> separates the parts of a multi-part entry
|
||||
# <N> raw byte N (decimal 0-255), e.g. <10> newline, <4> colour code
|
||||
# << a literal '<' <> nothing (empty-entry marker)
|
||||
# Lines starting with '#' are comments; blank lines are ignored.
|
||||
|
||||
An error occurred while deleting game '%s'
|
||||
An error occurred while replacing game '%s'
|
||||
Replace the contents of game '%s'?
|
||||
Delete the game '%s'?
|
||||
Cannot create any more save files.
|
||||
That file name is a reserved name. Please choose another name.
|
||||
Zerg
|
||||
Terran
|
||||
Protoss
|
||||
Random
|
||||
Deputy Dad
|
||||
hours
|
||||
hour
|
||||
minutes
|
||||
minute
|
||||
Mission completed. Continuing play with map triggers disabled.
|
||||
*\/:><<|&+^?"
|
||||
WARNING: Unplaceable unit! ID (%s), coordinates (%d,%d)
|
||||
Team 1
|
||||
Team 2
|
||||
Team 3
|
||||
Team 4
|
||||
Force 1
|
||||
Force 2
|
||||
Force 3
|
||||
Force 4
|
||||
Observer
|
||||
Observers
|
||||
Elite Guard
|
||||
Mar Sara
|
||||
Kel-Morian Combine
|
||||
Antiga
|
||||
Delta Squadron
|
||||
Omega Squadron
|
||||
Alpha Squadron
|
||||
Epsilon Squadron
|
||||
Tiamat Brood
|
||||
Surtur Brood
|
||||
Fenris Brood
|
||||
Jormungand Brood
|
||||
Garm Brood
|
||||
Grendel Brood
|
||||
Baelrog Brood
|
||||
Leviathan Brood
|
||||
Ara Tribe
|
||||
Sargas Tribe
|
||||
Akilae Tribe
|
||||
Furinax Tribe
|
||||
Auriga Tribe
|
||||
Venatir Tribe
|
||||
Shelak Tribe
|
||||
Velari Tribe
|
||||
%s was eliminated.
|
||||
Player %d was eliminated. BYE!
|
||||
Player %s left the game. BYE!
|
||||
%s had a Canada moment.
|
||||
%s paused the game. (%d timeouts remaining)
|
||||
%s resumed the game.
|
||||
* Message Sent *
|
||||
Building exit is blocked.
|
||||
Cannot create more u.
|
||||
Unable to create u.
|
||||
Select Target
|
||||
Select Location
|
||||
uhh
|
||||
u
|
||||
%s is now playing as an observer.
|
||||
You are now playing as an observer.
|
||||
Player %s set network for low brain cells.
|
||||
Player %s set network for high brain cells.
|
||||
Player %s set network for extra high brain cells.
|
||||
Anonymous
|
||||
Illegal number of teams.
|
||||
Bloop! It's %s!
|
||||
Bye %s!!!
|
||||
%s had a Canada moment.
|
||||
Ignoring messages from %s.
|
||||
Accepting messages from %s.
|
||||
<<To: %s>
|
||||
<<From: %s>
|
||||
A network error has occured.
|
||||
Error initializing standard.snp. Please reinstall StarCraft.
|
||||
Unable to initialize network provider.
|
||||
Unable to initialize device.
|
||||
Invalid network provider.
|
||||
Unable to register events.
|
||||
Invalid player.
|
||||
Invalid character.
|
||||
Unable to join game.
|
||||
Unable to join game. The host is running either a different version of Cosmonarchy Brood War, or a different version of Starcraft.
|
||||
Unable to send message.
|
||||
Unable to create single player game.
|
||||
Invalid game options.
|
||||
Unable to create multi player game.
|
||||
Save Game File Not Found.
|
||||
Error launching game.
|
||||
Error loading scenario file.
|
||||
Unable to load saved game file.
|
||||
Unable to load the SCM associated with this save file.
|
||||
Unable to distribute map.
|
||||
Unable to write save file.
|
||||
Unable to load campaign options. Please reinstall StarCraft.
|
||||
You must own the full version of StarCraft to play the single player campaigns.
|
||||
You must own the full version of StarCraft to create a multiplayer game.
|
||||
You can only join games created by the owner of the CD that spawned your copy.
|
||||
Unable to find the specified campaign map.
|
||||
Network upgrade failed.
|
||||
SYNC ERROR.
|
||||
Saving
|
||||
Screen captured to file '%s'.
|
||||
%s was %s and played for %s<10>
|
||||
On map "%s":<10>
|
||||
Overall Score %u<10> %10u for %s<10> %10u for %s<10> %10u for %s<10>
|
||||
Units Score %u<10> %10u Units %s<10> %10u Units %s<10> %10u Units %s<10>
|
||||
Structures Score %u<10> %10u Structures %s<10> %10u Structures %s<10> %10u Structures %s<10>
|
||||
Resources Score %u<10> %10u %s<10> %10u %s<10> %10u %s<10>
|
||||
Destroy all of an opponent's buildings to eliminate him.
|
||||
Accumulate 2500 ore.<10>Accumulate 2500 gas.
|
||||
Accumulate 5000 ore.<10>Accumulate 5000 gas.
|
||||
Accumulate 7500 ore.<10>Accumulate 7500 gas.
|
||||
Accumulate 10000 ore.<10>Accumulate 10000 gas.
|
||||
Get the most kills or razings before time runs out.
|
||||
Capture an opponent's flag to eliminate him.
|
||||
Destroy any opponent's HQs to eliminate him.
|
||||
%s had his flag taken!
|
||||
%s had their flag taken!
|
||||
%s had his flag return to his base.
|
||||
%s had their flag return to their base.
|
||||
%s had his flag captured!
|
||||
%s had their flag captured!
|
||||
You must configure your system for voice chat before attempting to change settings. To configure, please exit Starcraft and run the voice configuration utility "STARVOX.exe".
|
||||
Voice chat settings can only be modified while you are playing a multiplayer game.
|
||||
Your system does not have DirectSound installed. Please run the setup program supplied with Starcraft to install DirectX drivers for your system.
|
||||
Your system does not appear to be configured for voice chat. Please reinstall Starcraft using the install program on the Starcraft CDROM.
|
||||
Another application is using audio. Please exit the other application and try running this application again.
|
||||
An error occurred while attempting to initialize voice fonts: %s<10>Error 0x%08x %s
|
||||
An error occurred while attempting to initialize voice capture: %s<10>Error 0x%08x %s
|
||||
An error occurred while attempting to initialize sound: %s<10>Error 0x%08x %s
|
||||
An error occurred while attempting to initialize voice playback: %s<10>Error 0x%08x %s
|
||||
ERROR: cannot play music from network drive.
|
||||
We recommend that you complete the Terran Campaign before playing this campaign.<10><10>Do you wish to play the Zerg Campaign anyway?
|
||||
We recommend that you complete the Terran and Zerg Campaigns before playing this campaign.<10><10>Do you wish to play the Protoss Campaign anyway?
|
||||
We recommend that you complete the Protoss Campaign before playing this campaign.<10><10>Do you wish to play the Terran Campaign anyway?
|
||||
We recommend that you complete the Protoss and Terran Campaigns before playing this campaign.<10><10>Do you wish to play the Zerg Campaign anyway?
|
||||
The Ackmen
|
||||
Todny Terranus
|
||||
Deputy Dads
|
||||
Vacuum Busboys
|
||||
The Ciox Crew
|
||||
sk8rquippers
|
||||
Crows from Kenya
|
||||
Foreign Dogwalkers
|
||||
N's Miracle Workers
|
||||
Hydra5620
|
||||
Pathfinding Organ
|
||||
Whathere?
|
||||
The Friendship Buffet
|
||||
BonziBossy
|
||||
BingBongBorrowers
|
||||
Amon's Lost Dogs
|
||||
Knights of a Realm
|
||||
Champions of Iquaria
|
||||
Day 7: Autoveeqality
|
||||
Solawrong!
|
||||
Estranged Aviary
|
||||
F.R.A.U.D.s
|
||||
Tree Tribe
|
||||
Shadowcloaked Skullcrushers
|
||||
Askosi
|
||||
Bloodsummon
|
||||
Kogri
|
||||
1820
mpq/rez/stat_txt.tbl.txt
Normal file
1820
mpq/rez/stat_txt.tbl.txt
Normal file
File diff suppressed because it is too large
Load Diff
112
mpq/rez/tips.tbl.txt
Normal file
112
mpq/rez/tips.tbl.txt
Normal file
@ -0,0 +1,112 @@
|
||||
# tbl-text v1 - StarCraft: Brood War string table source
|
||||
# One entry per line, in index order.
|
||||
# <0> separates the parts of a multi-part entry
|
||||
# <N> raw byte N (decimal 0-255), e.g. <10> newline, <4> colour code
|
||||
# << a literal '<' <> nothing (empty-entry marker)
|
||||
# Lines starting with '#' are comments; blank lines are ignored.
|
||||
|
||||
Cosmonarchy generates a config file when you first run the game. Review it for options like quick-targeting and grid hotkeys.
|
||||
While selecting your own units or structures, right-clicking will issue a contextual order based on what your units and target are. These orders includes move, set rally point, follow, and enter transport.
|
||||
Subtitles in most scenarios can be toggled on or off in sound settings.
|
||||
Vespene Geysers can be directly harvested from. Geysers can be capped with race-specific structures to increase their Vespene yield.
|
||||
The letters in parenthesis on command card labels denote what keyboard shortcut you can use to issue the order.
|
||||
Mousing over a selected unit or structure's weapon or armor tooltip will provide at-a-glance statistics and statuses. Holding shift will toggle verbose tooltips based on your config.
|
||||
Mission objectives can be reviewed in the game menu.
|
||||
Units of any race can be loaded into a garrison, such as an Anchor. While garrisoned, units are shielded from attacks, and ranged units are free to return fire. Ranged units get +2 weapon range while inside a garrison.
|
||||
Terrans can use Vultures to lay Lobotomy Mines and consolidate map control. Lobotomy Mines are revealed by detection, but have punishing stun and slow effects if triggered.
|
||||
Remember that queueing units will spend your money without giving you immediate return. If you have many units in a queue, you should consider building more factories.
|
||||
Many units have active and passive abilities that allow skilled players to benefit from good positioning, timing, and precision. Refer to a unit's command card to see their abilities.
|
||||
A wide variety of build orders and opening strategies are at play in Cosmonarchy. Scout your opponent with a worker or early combatant to gather valuable information, and plan your composition and attack angles accordingly.
|
||||
You can augment your economy with supportive infrastructure like Quarries, Larvosk Cesiants, Embassies, and Crucibles; benefits include additional worker queues and empowered production rates.
|
||||
Some stealthy units are cloaked. Terran cloaking is negated by moving close to the unit, while Protoss cloaking is negated when the unit attacks. All cloaked units can be revealed by detectors.
|
||||
If a factory is destroyed, any queued units are cancelled, offering a full resource refund. Destroyed Eggs and structures in progress give no refunds, and cancelled structures give a 75% refund.
|
||||
Press Tab to toggle terrain on the minimap. This can be useful for focusing on known enemy positions.
|
||||
Transports like the Trojan, the Envoy, and the Ovileth can be used to ferry ground units and small structures between locations, but are fragile and defenseless. "Death drops" work best when you have the element of surprise, or when you distract your opponent's defense force with a cross-map attack.
|
||||
Holding shift and pressing tab will cycle between different minimap display modes, each with their own advantages and disadvantages.
|
||||
Quick-targeting is a config option that will instantly map order targets to your mouse cursor without asking you to confirm them with a left mouse click. Quick-building carries this functionality over to issuing construction orders.
|
||||
Terran structures can liftoff, fly to a new destination, and land.
|
||||
Issuing an attack command targeting terrain will order your selection to engage targets they encounter while moving towards the destination.
|
||||
Power fields generated by allied Pylons and Crucibles will also provide their benefits to your units and structures, allowing you to warp in infrastructure anywhere your team has power.
|
||||
Regardless of who they used to belong to, Terran addons can be reactivated by placing new factories in the appropriate positions.
|
||||
You can edit your config to change what APM thresholds you want warnings at. This is useful for setting benchmarks and goals for your improvement as a player.
|
||||
If you find the onscreen display of scenario objectives too distracting, you can disable it in your config.
|
||||
Anvils can arm warheads for deployment by Eidolons or Seraphs. If an armed Anvil is destroyed in any manner other than a controlled demolition, the warhead will detonate.
|
||||
You can deselect units and structures by holding the shift key while left-clicking them.
|
||||
While group-selecting, you can quickly select a specific unit or structure by left-clicking on its wireframe in the status display.
|
||||
With so many hidden dangers, it is often advisable to construct detectors like Anciles, Witnesses, and Ovileths. Not only will they reveal hidden units, they are often mobile enough to act as adept scouts.
|
||||
Some advanced transports provide much more space than their basic counterparts. These transports often move slower, making them ideal for large army transfers and less fitting for hit-and-run tactics.
|
||||
You can queue an unload order before loading any cargo. This can be ideal for planning quick harassment drops.
|
||||
Most Zerg structures require cagra, which is spread by Hachirosks, Cagrant Cesiants, and their mutations. Cagra also blocks enemies from constructing structures, as long as your cagra generators remain intact.
|
||||
Mind the minimap! While it holds valuable and ever-changing information, you can also use it instead of the game display to issue targeted orders.
|
||||
Join us on Discord to organize games, challenge other players, and share your thoughts! discord.com/invite/s5SKBmY
|
||||
Zerg structures can mutate into more advanced and specialized variants. This ranges from static defense, to unit production, to techtree advancements.
|
||||
You can recall up to 8 unique previous selections by holding alt and left-clicking a unit or structure. If they were previously in a selection with other units or structures, they will all be selected.
|
||||
Playing the tutorials and other singleplayer content is a great way to jump into the world of Cosmonarchy, and familiarize yourself with the techtrees. Some scenario content feature factional differences between the active armies, adding even more variety into the mix!
|
||||
Almost every Zerg unit can mutate into a more specialized strain. These may be superior to the base unit depending on your enemy's army, so choose your composition wisely.
|
||||
Visit fraudsclub.com for information on setting up your own custom scenarios!
|
||||
Keep an ear out for odd noises! Abilities and effects have audio cues associated with them, so an unexpected sound probably means trouble.
|
||||
At high tech tiers, expect to see units capable of reclaiming, tyrannizing, infesting, and hijacking the opposition.
|
||||
Skilled players make ample usage of queued orders by holding shift as they issue commands.
|
||||
Without a unit cap, there's nothing stopping you from stationing healthy defense forces to protect your flanks during a frontal assault on the enemy.
|
||||
Zerg ground units have the ability to Burrow, increasing health regeneration and concealing themselves from hostile vision unless revealed by a detector. Lakizilisks can attack while burrowed, allowing Zerg players to set deadly traps.
|
||||
Units will automatically acquire targets within their acquisition range. This range differs between units and is often based on their maximum weapon range.
|
||||
Overwhelmed by choice? You can see the techtrees of all races at fraudsclub.com/cmbw/
|
||||
Many units cost as much or more than entire structures, and often have much higher mineral costs than vespene costs, or vice versa. Balancing your spending and expanding your income is a vital skill in Cosmonarchy.
|
||||
While far from perfect, the AI of Cosmonarchy has plenty of tricks up their sleeve. Studying their tactics and compositions may prove useful for newer Cosmonarchs.
|
||||
The most deadly strategies take shape in the fog of war. Skilled players always seek to keep tabs on their enemies with active scouting and consistent map presence.
|
||||
Killing any unit or structure will leave behind remains that can be exploited by certain units. Keep an eye on this resource, as it grows in perpetuity.
|
||||
Selecting a single unit or structure will display their command card, player name, and force name. This is useful for pinpointing what a unit or structure does and who they belong to.
|
||||
When a push starts to falter, knowing when to begin retreating to a forward post is vital. Area denial and map control are huge aspects of keeping momentum on your side.
|
||||
Some powerful weapons and abilities leave behind effect fields. These can be threatening, supportive, or some combination of the two.
|
||||
Most maps have lanes of engagement that larger armies gravitate towards. Take advantage of smaller paths and unexpected angles to take your opponents by surprise.
|
||||
Ctrl+S toggles sound effects on and off. Ctrl+M does the same for music.
|
||||
After two workers per resource node, there are diminishing returns to saturating nodes with additional harvesters. If you just expanded to a new location, transfer some workers from a saturated base for a quick spike in income!
|
||||
The default competitive ruleset grants victory when all of your opponents lose all of their factories. This includes town centers and incomplete structures.
|
||||
While there are many ways to get advantages in Cosmonarchy, they primarily boil down to claiming map control, improving your economy, raising an army, and ascending to a new tech tier. You will rarely have enough resources to do all of these at once, so train your decisionmaking and coordinate with any allies you have.
|
||||
Construction orders will begin even if an allied unit is in the way. Hostile units will block construction attempts.
|
||||
In team games, it pays to have a game plan already figured out. Many maps bias players for or against certain strategies depending on their starting locations, encouraging different roles within a team. Learn the maps to better plan your team's path to victory.
|
||||
You can save a camera position by pressing shift+F1 through F8. Pressing the key without holding shift will move your camera to the saved position.
|
||||
While it might be tempting to move up the techtree, remember that you can't properly express the power of that tier without sufficient income. Higher-tier units are pricey, and so too are higher-tier factories.
|
||||
Reading tips is not as instructive as losing to the AI.
|
||||
Each race has their own unique construction method. Advanced workers are specialized for certain tasks, and will likely be more optimal to use in longer battles.
|
||||
Holding ctrl when left-clicking a unit or structure will select all nearby units or structures of that type. You can also do this by double-left-clicking.
|
||||
If an area of the map is becoming a hotspot, use your race's unique traits to lock it down. Terrans specialize in artillery and battlefield repairs, Protoss prefer to outlast the opposition, and Zerg tend to overwhelm hostiles with superior numbers and flanking positions.
|
||||
Everything's better with friends, and Cosmonarchy has plenty of player-vs-AI content!
|
||||
Unlike the other races, Protoss have no tech requirements for their structures. This compensates for their higher resource costs, but makes their openers dangerously unpredictable.
|
||||
Events like coming under attack and completing a unit or structure will send notifications on the minimap. You can center to the last notification by pressing spacebar.
|
||||
Cosmonarchy is all about the balance of power, and momentum can shift at any time. If you've been holding a push since the early game, your opponent probably doesn't have much infrastructure down, so don't give up over a few early losses!
|
||||
You can save your current selection to a control group by holding ctrl and pressing 1-0 on the home row. With extended control groups, you can also use R, T, F, G, V, and B. To add your current selection to a control group without overwriting it, use shift instead of ctrl.
|
||||
You can use an ally's transports, garrisons, and teleporters to reposition around the map, assist in fending off an attack, or consolidate a forward post.
|
||||
In team games, you can use ctrl+enter to write a message to your allies, or shift+enter to write a message to all players.
|
||||
Many units gain situational value based on nearby allies. Good target selection includes knowing when to focus these specialists and when to instead attack the surrounding units.
|
||||
Armor penetration allows a unit's damage to ignore a certain amount of their target's armor, but does not increase damage if there is more armor pen than armor.
|
||||
Basic combat roles are fulfilled by multiple units of different tiers. If you have enough resources, you can choose to replace your core lower-tier with higher-tier ones as the battle rages on.
|
||||
Many status effects will buff or debuff your units, changing their combat effectiveness. Some effects stun units, rendering them uncontrollable for a short time.
|
||||
With Skyways, Didacts, and Nydus Cesiants, there are many methods of relocating your army without hoofing it to every destination.
|
||||
If a leaderboard is active, you can use ctrl+R to toggle between displaying all players and displaying only the current leader.
|
||||
You can access the debug console by pressing F12. This is especially useful when playing the unit tester, where you can spawn different combinations of units and test out various compositions.
|
||||
If you like what you see in Cosmonarchy, you'd better get hyped for the future...
|
||||
Anticipating the enemy's movements is sometimes most important when using short-range units. Skilled players will lead their opponents and even surround their victims if they have enough units to do so.
|
||||
Practicing openers, either alone or against AI, is a great way to get familiar with the game's techtrees and internalize how powerful an enemy player could be given enough time.
|
||||
Replays are an excellent resource for those who wish to learn from their mistakes, especially if you were caught off-guard by your opponent's moves.
|
||||
Practice gets you ever closer to perfect, and we host tournaments through our Discord server. Consider signing up if you want an excuse to grind!
|
||||
The most powerful units in Cosmonarchy are often among the slowest. If you find yourself face to face with the biggest guns in the universe, remember that they simply cannot be everywhere at once!
|
||||
Team-based interactions are a big focus of Cosmonarchy's design. Collaborate with your ally by using supportive auras and abilities, and you'll be surprised how effective the races are together.
|
||||
Each race has multiple options for defensive and supportive structures. Knowledgeable players will design their base layouts to conform to the terrain while still benefiting from critical infrastructure.
|
||||
Unless you're saving up for something specific, avoid having a big bank of unspent resources. Those resources could be units and structures instead of a number on the screen!
|
||||
All races have ways of staving off your army's demise, be it through healing, shielding, or spawning reinforcements. Skillful use of these supportive specialists can make an enormous difference in the outcome of battles.
|
||||
Units on higher ground gain a weapon range bonus when engaging targets on low ground. This bonus is +2 for every height level difference, so use caution around cliffs!
|
||||
With the exception of the Terran Blackjack, there are no penalties for losing life on a unit, so remember to move wounded combatants out of immediate danger. This will extend their lifespan and allow them to keep contributing to the current skirmish.
|
||||
The most powerful abilities in Cosmonarchy have channel times and audiovisual reads associated with them. These omens should be heeded in case you need to focus a specific enemy unit or relocate your army out of harm's way.
|
||||
Some projectiles explode on contact, allowing enemies to intercept them. This adds another layer of positional gameplay, and stresses the importance of a durable frontline.
|
||||
Structures can be more than what they say on the tin. Intelligent base layouts give plenty of room for allies to move through, while still giving your enemies grief when they attempt to storm the gates.
|
||||
When facing off against experienced players, or even Cosmonarchy's built-in AI, don't expect to claim easy victories! There is a lot of information to learn and a lot of skills to practice, but with enough time and effort, you'll see marked improvement in your own gameplay.
|
||||
With all the responsibility of a Cosmonarch on your shoulders, it's easy to get overly-focused on one aspect of play. Micromanaging your army, expanding your economy and tech, and queueing reinforcements are all vital actions for success, so if you ever find yourself spending more than a few seconds doing any one task, consider switching to the next thing that demands your attention.
|
||||
You don't always need to expand to a resource area in order to gain an advantage. Sometimes, denying your opponent access to an asset is enough to give you the upper hand.
|
||||
Once you grow comfortable with the mechanics and techtrees of Cosmonarchy, consider how you might "mind-game" your opponents. Feinting with empty transports, hiding secret outposts, and even distracting them with chat messages are all fair-game on the battlefield!
|
||||
Getting a good night's sleep and staying physically active will help tremendously with focus, and could certainly make you a better RTS player!
|
||||
It's always a good idea to emulate more experienced players, even if you don't fully understand why they did something. If a strategy worked against you, it might work against your next opponent, too!
|
||||
Every action has an opportunity cost, and this is especially true for early moves. If you attempt a rush and fail to deal lasting damage, you'll have lost a lot of momentum!
|
||||
Sometimes, the greatest strategy is the one nobody's even dreamt of.
|
||||
If you're struggling with a matchup, ask a more experienced player for replays and advice! You might be surprised at what another perspective can reveal.
|
||||
You can lower the AI difficulty by becoming better.
|
||||
1547
mpq/rez/unitnames.tbl.txt
Normal file
1547
mpq/rez/unitnames.tbl.txt
Normal file
File diff suppressed because it is too large
Load Diff
36
tools/hooks/pre-commit
Executable file
36
tools/hooks/pre-commit
Executable file
@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# Refuse a commit whose .tbl files do not match their .tbl.txt sources.
|
||||
#
|
||||
# The .tbl.txt files are the source of truth; the binary .tbl files are build
|
||||
# artifacts that the game actually loads. Committing one without the other
|
||||
# ships strings that nobody authored.
|
||||
#
|
||||
# Install: ln -sf ../../tools/hooks/pre-commit .git/hooks/pre-commit
|
||||
# Bypass: git commit --no-verify
|
||||
|
||||
set -e
|
||||
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Nothing staged under mpq/ that could affect a table? Nothing to check.
|
||||
if ! git diff --cached --name-only --diff-filter=ACM | grep -qE '\.tbl(\.txt)?$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "pre-commit: bun not found; skipping .tbl freshness check" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "pre-commit: checking .tbl files against their .tbl.txt sources"
|
||||
if ! (cd "$root/tools/tbl" && bun run --silent check); then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
pre-commit: rejected - see STALE entries above.
|
||||
|
||||
cd tools/tbl && bun run build # recompile, then stage the .tbl files
|
||||
git commit --no-verify # or bypass, if you know why
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
55
tools/linux/README.md
Normal file
55
tools/linux/README.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Linux / Wine setup
|
||||
|
||||
Cosmonarchy runs under Wine, but a stock prefix gets four things wrong. Each one
|
||||
fails in a way that does not name its own cause, so they are collected here.
|
||||
|
||||
```sh
|
||||
tools/linux/setup-bottle.sh --check # report, change nothing
|
||||
tools/linux/setup-bottle.sh # apply
|
||||
```
|
||||
|
||||
Defaults to the Bottles bottle named `Blizzard`. Use `--bottle NAME` for a
|
||||
different bottle, `--prefix PATH` for a plain `WINEPREFIX`, and `--game-dir PATH`
|
||||
if the StarCraft directory cannot be read from the prefix's registry.
|
||||
|
||||
Every change is backed up next to the file it modifies. `--check` exits non-zero
|
||||
when something is missing, so it works in a pre-flight.
|
||||
|
||||
## What it fixes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| Fraud Launcher 2 exits instantly | .NET 8 loads `icu.dll`, which Wine forwards to a nonexistent `icuuc68.dll` | `DOTNET_SYSTEM_GLOBALIZATION_USENLS=1` |
|
||||
| `Fonts could not be loaded` | `aize_debug.qdp` opens `lucon.ttf` / `arialuni.ttf` with FreeType; Wine ships neither | drop DejaVu in under those filenames |
|
||||
| Entirely black screen | Wine loads its own `ddraw.dll`, ignoring cnc-ddraw, so StarCraft does a real 640×480 fullscreen mode switch | `ddraw=native,builtin` |
|
||||
| Black window, correct size | cnc-ddraw's `renderer=auto` draws black here | `renderer=gdi` |
|
||||
|
||||
### Notes
|
||||
|
||||
- The launcher failure shows up in Wine's output as
|
||||
`err:module:find_forwarded_export module not found for forward
|
||||
'icuuc68.u_charsToUChars_68'`. `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` also
|
||||
works but discards culture support, so NLS is preferred.
|
||||
- FreeType opens fonts by path, not by name, so any monospace / wide-coverage
|
||||
TTF under those two filenames satisfies it. Needs `fonts-dejavu` (or
|
||||
Liberation / Noto, which the script also accepts).
|
||||
- `renderer=gdi` and clearing `shader=` were changed together, so which one
|
||||
mattered is not isolated. The bundled GLSL shader only applies to the OpenGL
|
||||
renderer, so it is inert under GDI either way — if you want it back, try
|
||||
`renderer=opengl` first.
|
||||
- Only the global `[ddraw]` section of `ddraw.ini` is touched; the per-game
|
||||
preset sections below it are left alone, and CRLF endings are preserved.
|
||||
- The `ddraw` override is written to `user.reg` directly, so close the game
|
||||
first — Wine rewrites that file from memory when the last process exits.
|
||||
|
||||
## Verified on
|
||||
|
||||
Bottles `Blizzard` bottle, runner **GE-Proton10-25**, X11, dual 2560×1440.
|
||||
Plain system wine 10.0 could not boot even vanilla StarCraft here (null call
|
||||
through `storm.dll` ordinal 313 during 640×480 init), so GE-Proton is the
|
||||
supported runner.
|
||||
|
||||
Fraud Launcher 2 installs to `C:\Cosmonarchy\`: `Starcraft\` is a stock 1.16.1
|
||||
install, `Release\` is a git clone of this repo. The game runs from
|
||||
`Release\Cosmonarchy BW.exe`, and `_qdp-hotloader.qdp` packs `Release\mpq\` into
|
||||
`Release\temp.mpq` and loads it alongside the plugins.
|
||||
42
tools/linux/add-dll-override.py
Executable file
42
tools/linux/add-dll-override.py
Executable file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a DllOverrides entry to a Wine prefix's user.reg.
|
||||
|
||||
Usage: add-dll-override.py USER_REG DLL VALUE
|
||||
e.g. add-dll-override.py ~/.../Blizzard/user.reg ddraw native,builtin
|
||||
|
||||
Only safe while no wineserver is live for that prefix - Wine rewrites user.reg
|
||||
from memory when the last process exits, which would drop the edit.
|
||||
"""
|
||||
import sys
|
||||
|
||||
SECTION = r'[Software\\Wine\\DllOverrides]'
|
||||
|
||||
|
||||
def main(path, dll, value):
|
||||
with open(path, encoding='utf-8', errors='surrogateescape') as fh:
|
||||
text = fh.read()
|
||||
|
||||
entry = '"%s"="%s"\n' % (dll, value)
|
||||
if entry in text:
|
||||
print('already set: %s = %s' % (dll, value))
|
||||
return 0
|
||||
|
||||
i = text.find(SECTION)
|
||||
if i == -1:
|
||||
text = text.rstrip('\n') + '\n\n' + SECTION + ' 0\n' + entry
|
||||
else:
|
||||
j = text.index('\n', i) + 1 # past the section header
|
||||
while j < len(text) and text[j] == '#': # past #time= and friends
|
||||
j = text.index('\n', j) + 1
|
||||
text = text[:j] + entry + text[j:]
|
||||
|
||||
with open(path, 'w', encoding='utf-8', errors='surrogateescape') as fh:
|
||||
fh.write(text)
|
||||
print('set: %s = %s' % (dll, value))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 4:
|
||||
sys.exit(__doc__)
|
||||
sys.exit(main(*sys.argv[1:]))
|
||||
81
tools/linux/patch-ddraw-ini.py
Executable file
81
tools/linux/patch-ddraw-ini.py
Executable file
@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set keys in the [ddraw] section of a cnc-ddraw ddraw.ini.
|
||||
|
||||
Usage: patch-ddraw-ini.py [--check] DDRAW_INI KEY=VALUE [KEY=VALUE ...]
|
||||
|
||||
Only the global [ddraw] section is touched; the hundreds of per-game preset
|
||||
sections further down the file are left alone. CRLF line endings are preserved,
|
||||
because cnc-ddraw writes the file back out as a Windows INI.
|
||||
|
||||
Exit status: 0 if the file already matches (or was updated), 1 under --check if
|
||||
it does not match.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def main(argv):
|
||||
check = False
|
||||
if argv and argv[0] == '--check':
|
||||
check, argv = True, argv[1:]
|
||||
if len(argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
|
||||
path, assignments = argv[0], argv[1:]
|
||||
wanted = {}
|
||||
for a in assignments:
|
||||
if '=' not in a:
|
||||
sys.exit('not a KEY=VALUE pair: %s' % a)
|
||||
k, v = a.split('=', 1)
|
||||
wanted[k.strip().lower()] = v
|
||||
|
||||
with open(path, 'rb') as fh:
|
||||
raw = fh.read().decode('utf-8', 'surrogateescape')
|
||||
|
||||
newline = '\r\n' if '\r\n' in raw else '\n'
|
||||
lines = raw.split(newline)
|
||||
|
||||
out, in_ddraw, pending, changes = [], False, dict(wanted), []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('['):
|
||||
# leaving [ddraw]: append any keys the section never had
|
||||
if in_ddraw and pending:
|
||||
for k, v in pending.items():
|
||||
out.append('%s=%s' % (k, v))
|
||||
changes.append('added %s=%s' % (k, v))
|
||||
pending = {}
|
||||
in_ddraw = stripped.lower() == '[ddraw]'
|
||||
elif in_ddraw:
|
||||
m = re.match(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*)=(.*)$', line)
|
||||
if m and m.group(2).lower() in pending:
|
||||
key = m.group(2).lower()
|
||||
want = pending.pop(key)
|
||||
if m.group(4) != want:
|
||||
changes.append('%s=%s -> %s=%s' % (m.group(2), m.group(4), m.group(2), want))
|
||||
line = '%s=%s' % (m.group(2), want)
|
||||
out.append(line)
|
||||
|
||||
if in_ddraw and pending:
|
||||
for k, v in pending.items():
|
||||
out.append('%s=%s' % (k, v))
|
||||
changes.append('added %s=%s' % (k, v))
|
||||
|
||||
if not changes:
|
||||
print('already set: %s' % ', '.join('%s=%s' % kv for kv in wanted.items()))
|
||||
return 0
|
||||
|
||||
if check:
|
||||
for c in changes:
|
||||
print('would change: %s' % c)
|
||||
return 1
|
||||
|
||||
with open(path, 'wb') as fh:
|
||||
fh.write(newline.join(out).encode('utf-8', 'surrogateescape'))
|
||||
for c in changes:
|
||||
print(c)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
229
tools/linux/setup-bottle.sh
Executable file
229
tools/linux/setup-bottle.sh
Executable file
@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepare a Bottles/Wine prefix so Fraud Launcher 2 and Cosmonarchy BW will run.
|
||||
#
|
||||
# Fixes two things that a stock Wine prefix gets wrong:
|
||||
#
|
||||
# 1. Fraud Launcher 2 (.NET 8 WPF) aborts at startup because Wine's icu.dll
|
||||
# forwards to icuuc68.dll, which does not exist:
|
||||
# err:module:find_forwarded_export module not found for forward
|
||||
# 'icuuc68.u_charsToUChars_68' used by L"C:\\windows\\system32\\icu.dll"
|
||||
# Setting DOTNET_SYSTEM_GLOBALIZATION_USENLS=1 makes .NET use Windows NLS
|
||||
# instead of ICU. (DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 also works but
|
||||
# throws away culture support, so prefer NLS.)
|
||||
#
|
||||
# 2. aize_debug.qdp initialises its console with FreeType against two Windows
|
||||
# system fonts that Wine does not ship, and dies with "Fonts could not be
|
||||
# loaded":
|
||||
# %systemroot%\fonts\lucon.ttf (Lucida Console)
|
||||
# %systemroot%\fonts\arialuni.ttf (Arial Unicode MS)
|
||||
# FreeType opens these by path, not by name, so any monospace / wide-coverage
|
||||
# TTF dropped in under those filenames satisfies it.
|
||||
#
|
||||
# 3. Cosmonarchy ships cnc-ddraw (ddraw.dll beside StarCraft.exe), but Wine
|
||||
# loads its own builtin ddraw.dll unless told otherwise, so cnc-ddraw is
|
||||
# silently ignored. StarCraft then performs a real exclusive-fullscreen
|
||||
# 640x480 DirectDraw mode switch, which on a multi-monitor X11 desktop
|
||||
# shows up as an entirely black screen. Overriding ddraw to native,builtin
|
||||
# makes Wine load cnc-ddraw so the game renders in a normal window.
|
||||
#
|
||||
# 4. With cnc-ddraw actually loaded, its default renderer=auto still draws a
|
||||
# black window here; renderer=gdi renders correctly. The bundled GLSL
|
||||
# shader only applies to the OpenGL renderer, so it is cleared alongside.
|
||||
# (Both were changed together, so which one mattered is not isolated - if
|
||||
# you want the shader back, try renderer=opengl first.)
|
||||
#
|
||||
# Usage:
|
||||
# tools/linux/setup-bottle.sh [--check] [--bottle NAME | --prefix PATH]
|
||||
#
|
||||
# --check report what is missing, change nothing
|
||||
# --bottle NAME Bottles bottle name (default: Blizzard)
|
||||
# --prefix PATH a plain WINEPREFIX, instead of a Bottles bottle
|
||||
# --game-dir PATH the StarCraft directory holding ddraw.ini
|
||||
# (default: read from the prefix's registry InstallPath)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BOTTLE=Blizzard
|
||||
PREFIX=
|
||||
GAMEDIR=
|
||||
CHECK=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--check) CHECK=1; shift ;;
|
||||
--bottle) BOTTLE="${2:?--bottle needs a name}"; shift 2 ;;
|
||||
--prefix) PREFIX="${2:?--prefix needs a path}"; shift 2 ;;
|
||||
--game-dir) GAMEDIR="${2:?--game-dir needs a path}"; shift 2 ;;
|
||||
-h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$PREFIX" ]; then
|
||||
for base in \
|
||||
"$HOME/.var/app/com.usebottles.bottles/data/bottles/bottles" \
|
||||
"$HOME/.local/share/bottles/bottles"
|
||||
do
|
||||
if [ -d "$base/$BOTTLE" ]; then PREFIX="$base/$BOTTLE"; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$PREFIX" ] || [ ! -d "$PREFIX/drive_c" ]; then
|
||||
echo "error: no Wine prefix found (bottle '$BOTTLE')." >&2
|
||||
echo " pass --bottle NAME or --prefix /path/to/prefix" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "prefix: $PREFIX"
|
||||
rc=0
|
||||
|
||||
note() { if [ "$CHECK" = 1 ]; then echo " MISSING $1"; rc=1; else echo " fixed $1"; fi; }
|
||||
ok() { echo " ok $1"; }
|
||||
|
||||
# --- 1. fonts -----------------------------------------------------------------
|
||||
# Each entry is "target-filename<TAB>candidate source paths, first match wins".
|
||||
install_font() {
|
||||
local target="$1"; shift
|
||||
local fontdir dest src
|
||||
fontdir="$PREFIX/drive_c/windows/Fonts"
|
||||
[ -d "$fontdir" ] || fontdir="$PREFIX/drive_c/windows/fonts"
|
||||
mkdir -p "$fontdir"
|
||||
dest="$fontdir/$target"
|
||||
|
||||
if [ -s "$dest" ]; then ok "$target"; return 0; fi
|
||||
|
||||
if [ "$CHECK" = 1 ]; then note "$target"; return 0; fi
|
||||
|
||||
for src in "$@"; do
|
||||
if [ -s "$src" ]; then
|
||||
cp -- "$src" "$dest"
|
||||
note "$target <- $(basename "$src")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# last resort: ask fontconfig for anything matching
|
||||
if command -v fc-match >/dev/null 2>&1; then
|
||||
src=$(fc-match -f '%{file}' "${FC_QUERY:-sans}" 2>/dev/null || true)
|
||||
if [ -n "$src" ] && [ -s "$src" ]; then
|
||||
cp -- "$src" "$dest"
|
||||
note "$target <- $(basename "$src") (fontconfig)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " FAILED $target - no source font found; install fonts-dejavu" >&2
|
||||
rc=1
|
||||
}
|
||||
|
||||
echo "fonts (for aize_debug.qdp's FreeType console):"
|
||||
FC_QUERY=monospace install_font lucon.ttf \
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf \
|
||||
/usr/share/fonts/dejavu/DejaVuSansMono.ttf \
|
||||
/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf \
|
||||
/usr/share/fonts/liberation/LiberationMono-Regular.ttf
|
||||
FC_QUERY=sans install_font arialuni.ttf \
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf \
|
||||
/usr/share/fonts/dejavu/DejaVuSans.ttf \
|
||||
/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf \
|
||||
/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||
|
||||
# --- 2. .NET globalization (Fraud Launcher 2) ---------------------------------
|
||||
echo ".NET globalization (for Fraud Launcher 2):"
|
||||
VAR=DOTNET_SYSTEM_GLOBALIZATION_USENLS
|
||||
YML="$PREFIX/bottle.yml"
|
||||
|
||||
if [ -f "$YML" ]; then
|
||||
if grep -q "^ *$VAR:" "$YML"; then
|
||||
ok "$VAR set in bottle.yml"
|
||||
elif [ "$CHECK" = 1 ]; then
|
||||
note "$VAR in bottle.yml"
|
||||
else
|
||||
cp -- "$YML" "$YML.bak-$(date +%Y%m%d%H%M%S)"
|
||||
python3 - "$YML" "$VAR" <<'PY'
|
||||
import re, sys
|
||||
path, var = sys.argv[1], sys.argv[2]
|
||||
s = open(path, encoding='utf-8').read()
|
||||
line = " %s: '1'\n" % var
|
||||
if re.search(r'^Environment_Variables:\s*\{\}\s*$', s, re.M):
|
||||
s = re.sub(r'^Environment_Variables:\s*\{\}\s*$',
|
||||
"Environment_Variables:\n" + line.rstrip('\n'), s, count=1, flags=re.M)
|
||||
elif re.search(r'^Environment_Variables:\s*$', s, re.M):
|
||||
s = re.sub(r'^(Environment_Variables:\s*\n)', r'\1' + line, s, count=1, flags=re.M)
|
||||
else:
|
||||
raise SystemExit("could not find Environment_Variables: in %s" % path)
|
||||
open(path, 'w', encoding='utf-8').write(s)
|
||||
PY
|
||||
note "$VAR in bottle.yml (backup written alongside)"
|
||||
fi
|
||||
else
|
||||
# plain WINEPREFIX: no bottle.yml to edit, so tell the caller how to export it
|
||||
if [ "$CHECK" = 1 ]; then
|
||||
note "$VAR (no bottle.yml; export it yourself)"
|
||||
else
|
||||
echo " note no bottle.yml here - run the launcher with:"
|
||||
echo " $VAR=1 wine 'Fraud Launcher 2.exe'"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 3. ddraw override, so Wine loads cnc-ddraw rather than its own builtin ----
|
||||
echo "ddraw override (cnc-ddraw):"
|
||||
REG="$PREFIX/user.reg"
|
||||
HELPER="$(dirname "$0")/add-dll-override.py"
|
||||
if [ ! -f "$REG" ]; then
|
||||
echo " FAILED no user.reg in $PREFIX" >&2
|
||||
rc=1
|
||||
elif grep -q '^"ddraw"=' "$REG"; then
|
||||
ok "ddraw = $(grep -m1 '^\"ddraw\"=' "$REG" | cut -d= -f2- | tr -d '\"')"
|
||||
elif [ "$CHECK" = 1 ]; then
|
||||
note "ddraw=native,builtin in user.reg"
|
||||
elif pgrep -f "wineserver" >/dev/null 2>&1 && pgrep -af wineserver | grep -qF "$PREFIX"; then
|
||||
echo " SKIPPED a wineserver is live for this prefix - close the game and re-run" >&2
|
||||
rc=1
|
||||
elif [ ! -f "$HELPER" ]; then
|
||||
echo " FAILED helper not found: $HELPER" >&2
|
||||
rc=1
|
||||
else
|
||||
cp -- "$REG" "$REG.bak-$(date +%Y%m%d%H%M%S)"
|
||||
python3 "$HELPER" "$REG" ddraw native,builtin >/dev/null
|
||||
note "ddraw=native,builtin in user.reg (backup written alongside)"
|
||||
fi
|
||||
|
||||
|
||||
# --- 4. cnc-ddraw renderer ----------------------------------------------------
|
||||
echo "cnc-ddraw renderer:"
|
||||
if [ -z "$GAMEDIR" ]; then
|
||||
# HKLM\Software\Wow6432Node\Blizzard Entertainment\Starcraft -> InstallPath
|
||||
winpath=$(grep -a -m1 '^"InstallPath"=' "$PREFIX/system.reg" 2>/dev/null \
|
||||
| sed 's/^"InstallPath"="//; s/"$//')
|
||||
if [ -n "$winpath" ]; then
|
||||
rel=$(printf '%s' "$winpath" | sed 's/\\\\/\//g; s|^[A-Za-z]:||; s|/*$||')
|
||||
GAMEDIR="$PREFIX/drive_c$rel"
|
||||
fi
|
||||
fi
|
||||
|
||||
INI="$GAMEDIR/ddraw.ini"
|
||||
PATCHER="$(dirname "$0")/patch-ddraw-ini.py"
|
||||
if [ -z "$GAMEDIR" ] || [ ! -f "$INI" ]; then
|
||||
echo " SKIPPED no ddraw.ini found${GAMEDIR:+ at $GAMEDIR}; pass --game-dir" >&2
|
||||
elif [ ! -f "$PATCHER" ]; then
|
||||
echo " FAILED helper not found: $PATCHER" >&2
|
||||
rc=1
|
||||
elif python3 "$PATCHER" --check "$INI" renderer=gdi shader= >/dev/null 2>&1; then
|
||||
ok "renderer=gdi in $(basename "$GAMEDIR")/ddraw.ini"
|
||||
elif [ "$CHECK" = 1 ]; then
|
||||
note "renderer=gdi in $(basename "$GAMEDIR")/ddraw.ini"
|
||||
else
|
||||
cp -- "$INI" "$INI.bak-$(date +%Y%m%d%H%M%S)"
|
||||
python3 "$PATCHER" "$INI" renderer=gdi shader= >/dev/null
|
||||
note "renderer=gdi, shader= in $(basename "$GAMEDIR")/ddraw.ini (backup written alongside)"
|
||||
fi
|
||||
|
||||
|
||||
echo
|
||||
if [ "$CHECK" = 1 ] && [ "$rc" != 0 ]; then
|
||||
echo "check failed: run without --check to apply."
|
||||
elif [ "$rc" = 0 ]; then
|
||||
echo "bottle ready."
|
||||
fi
|
||||
exit $rc
|
||||
3
tools/tbl/.gitignore
vendored
Normal file
3
tools/tbl/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
167
tools/tbl/README.md
Normal file
167
tools/tbl/README.md
Normal file
@ -0,0 +1,167 @@
|
||||
# tbl
|
||||
|
||||
A round-trip compiler between StarCraft: Brood War `.tbl` string tables and a
|
||||
plain-text `.tbl.txt` source format. TypeScript, [Effect](https://effect.website) v4, Bun.
|
||||
|
||||
Every `.tbl` shipped in this repo decompiles and recompiles **byte for byte
|
||||
identically** — that is enforced by the test suite.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
bun install
|
||||
|
||||
bun run tbl decompile ../../mpq/arr/images.tbl # -> images.tbl.txt
|
||||
$EDITOR ../../mpq/arr/images.tbl.txt
|
||||
bun run tbl compile ../../mpq/arr/images.tbl.txt # -> images.tbl
|
||||
|
||||
bun run tbl verify ../../mpq/rez/stat_txt.tbl # prove a round trip is lossless
|
||||
bun run tbl info ../../mpq/rez/stat_txt.tbl # entry counts, offset headroom
|
||||
```
|
||||
|
||||
| Command | Flags |
|
||||
| --- | --- |
|
||||
| `decompile <in.tbl>` | `-o <out>`, `--annotate` (`# [index]` above each entry), `--bare` (no banner), `--dialect` |
|
||||
| `compile <in.tbl.txt>` | `-o <out>`, `--dedupe` (share identical entries), `--dialect` |
|
||||
| `build [root]` | `--check` (fail instead of writing), `--dedupe` |
|
||||
| `verify <in.tbl>` | — |
|
||||
| `info <in.tbl>` | — |
|
||||
|
||||
`build` walks a directory for `.tbl.txt` sources and recompiles each one in place —
|
||||
the whole `mpq` tree in one go:
|
||||
|
||||
```sh
|
||||
bun run build # recompile every table under ../../mpq
|
||||
bun run check # fail if any committed .tbl is out of date
|
||||
```
|
||||
|
||||
`tools/hooks/pre-commit` runs `check` so a stale `.tbl` cannot be committed beside an
|
||||
edited `.tbl.txt`. Install it with
|
||||
`ln -sf ../../tools/hooks/pre-commit .git/hooks/pre-commit`.
|
||||
|
||||
`decompile` defaults to `<input>.txt`, so `images.tbl` becomes `images.tbl.txt`
|
||||
and never lands on the unrelated `images.txt` that DatEdit uses for its name
|
||||
lists. `compile` strips that `.txt` back off.
|
||||
|
||||
## The binary format
|
||||
|
||||
```
|
||||
u16le count
|
||||
u16le offset[count] // absolute, from the start of the file
|
||||
... NUL-terminated string data
|
||||
```
|
||||
|
||||
One index does not always mean one string. `unitnames.tbl` stores
|
||||
`name\0subname\0group\0` under a single index, and `stat_txt.tbl` stores a
|
||||
hotkey byte followed by button text. Everything from `offset[i]` up to the next
|
||||
offset in the file therefore belongs to entry `i`, which is how this tool reads
|
||||
them — including bytes past the last offset, which `stat_txt.tbl` relies on.
|
||||
|
||||
## The text format
|
||||
|
||||
One entry per line, in index order. `.tbl` strings are raw bytes in a legacy
|
||||
code page and are full of Windows paths, so backslash escaping would be both
|
||||
lossy and miserable to hand-edit. Angle brackets are the only special
|
||||
characters:
|
||||
|
||||
| Token | Meaning |
|
||||
| --- | --- |
|
||||
| `<0>` | separates the parts of a multi-part entry |
|
||||
| `<N>` | the raw byte `N` (decimal 0-255) — colour codes, `<10>` newline |
|
||||
| `<<` | a literal `<` |
|
||||
| `<>` | nothing; marks an otherwise empty line |
|
||||
|
||||
Everything else is literal, so a GRP path stays readable:
|
||||
|
||||
```
|
||||
zerg\avenger.grp
|
||||
Terran Phalanx<0>Tank Mode<0>Ground Units
|
||||
e<0><4>Tank Mode <1>(<3>E<1>)<10>Returns the Phalanx's weapon and mobility to normal.
|
||||
```
|
||||
|
||||
Lines starting with `#` are comments and blank lines are ignored. An entry that
|
||||
would begin with `#` or a space, or end with a space, has that character written
|
||||
as `<35>` / `<32>` so no editor can quietly eat it. Output is ASCII by
|
||||
construction, so re-saving the file as UTF-8 cannot corrupt it — a stray
|
||||
non-ASCII character is a parse error pointing at the line and column.
|
||||
|
||||
## The GPTP dialect
|
||||
|
||||
Two tables in this repo are *already* loaded from text by the engine, not from a
|
||||
`.tbl` at all: `rez/stat.txt` and `arr/sounds.txt`. CM-GPTP's `load_stat_txt`
|
||||
(`hooks/limits/tbl_extender.cpp`) parses them with `collapse_control_codes` and
|
||||
builds a **u32-offset** string table in memory, which is how they escape the
|
||||
ceiling described below. `--dialect gptp` reads and writes that format.
|
||||
|
||||
The two dialects agree on what an entry is — bytes, NUL-separated — and differ
|
||||
only in escaping:
|
||||
|
||||
| | `--dialect gptp` | tbl-text v1 |
|
||||
| --- | --- | --- |
|
||||
| `<N>` | byte N; **wraps** above 255 (`char()` narrowing in the C++) | byte N; over 255 is an error |
|
||||
| `<<` | not special — two literal `<` | a literal `<` |
|
||||
| `<>` | not special — two literal chars | nothing (empty-entry marker) |
|
||||
| `\n`, `\\` | newline, one backslash | no backslash escapes |
|
||||
| bytes > 127 | allowed raw | must be written `<N>` |
|
||||
| `#` line | **an entry** whose text is `#…` | a comment |
|
||||
| blank line | **an entry** | ignored |
|
||||
|
||||
That last pair is the dangerous one: the engine turns *every* line into an entry,
|
||||
so a comment inserted into `stat.txt` would become a string and shift every index
|
||||
after it. `--dialect gptp` therefore refuses `--annotate` and writes no banner.
|
||||
|
||||
Writing is unambiguous rather than byte-faithful to any hand-written file: `<` is
|
||||
written `<60>` (this dialect has no `<<`, so a literal `<` before digits would be
|
||||
read back as a control code), `\` is written `\\` only when the next byte would
|
||||
make it an escape, and bytes above 127 are written `<N>` so output stays ASCII.
|
||||
Round trips are checked against both shipped files in `test/dialect.test.ts`.
|
||||
|
||||
## The 64 KiB ceiling
|
||||
|
||||
Offsets are `u16`, so no string may start past byte 65535. `stat_txt.tbl` is
|
||||
currently **26 bytes** away from that limit:
|
||||
|
||||
```
|
||||
$ bun run tbl info ../../mpq/rez/stat_txt.tbl
|
||||
entries 1813
|
||||
size 65590 bytes (3628 header + 61962 strings)
|
||||
highest offset 65509 of 65535 (26 bytes of headroom)
|
||||
```
|
||||
|
||||
`compile --dedupe` points identical entries at one copy of the string data,
|
||||
which buys room back without changing a single string:
|
||||
|
||||
```
|
||||
entries 1813 (904 sharing string data)
|
||||
size 64325 bytes (3628 header + 60697 strings)
|
||||
highest offset 64244 of 65535 (1291 bytes of headroom)
|
||||
```
|
||||
|
||||
It is off by default so that a plain recompile reproduces the original bytes.
|
||||
Overflowing the ceiling is a hard error that names the offending entry and
|
||||
suggests the flag.
|
||||
|
||||
## Library
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Tbl, TblFiles, TblText } from "./src/index.ts"
|
||||
|
||||
const program = Effect.gen(function*() {
|
||||
const files = yield* TblFiles
|
||||
const table = yield* files.readTbl("images.tbl") // ReadonlyArray<ReadonlyArray<Uint8Array>>
|
||||
yield* files.writeText("images.tbl.txt", table)
|
||||
})
|
||||
```
|
||||
|
||||
`Tbl.decode` / `Tbl.encode` and `TblText.decode` / `TblText.encode` are pure and
|
||||
need no services. Failures are tagged errors — `TblBinaryError`,
|
||||
`TblTextError`, `TblEncodeError` — so `Effect.catchTag` can pick them apart.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
bun test # 76 tests: byte-exact round trips over all 8 shipped .tbl files,
|
||||
# plus the GPTP dialect against rez/stat.txt and arr/sounds.txt
|
||||
bun run typecheck
|
||||
```
|
||||
62
tools/tbl/bun.lock
Normal file
62
tools/tbl/bun.lock
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@cosmonarchy/tbl",
|
||||
"dependencies": {
|
||||
"@effect/platform-bun": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.112",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@effect/platform-bun": ["@effect/platform-bun@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-Y5n2HhV/vsbrJls9ukX42RL7zqXQxISHegWFsP9njsCyDmGTnq7QYSO82rglwrLPwtTrv5dkSRTuGw4+oXxgMg=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
|
||||
|
||||
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.6", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-plGul/tqjt9vqWFR9zyqyLZls6gb5KTLvnsRO2B9+TZ8tNiXiVI/CR8LiDWlAX4IUeVkQ9gsmzKA6voC2QOciw=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
}
|
||||
}
|
||||
26
tools/tbl/package.json
Normal file
26
tools/tbl/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@cosmonarchy/tbl",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Round-trip compiler between StarCraft: Brood War .tbl string tables and a plain-text .tbl.txt source format.",
|
||||
"license": "MIT",
|
||||
"module": "src/index.ts",
|
||||
"bin": {
|
||||
"tbl": "src/cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"tbl": "bun run src/cli.ts",
|
||||
"build": "bun run src/cli.ts build ../../mpq",
|
||||
"check": "bun run src/cli.ts build ../../mpq --check",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-bun": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.112"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
29
tools/tbl/src/Errors.ts
Normal file
29
tools/tbl/src/Errors.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Tagged errors for every failure mode of the .tbl <-> .tbl.txt pipeline.
|
||||
*/
|
||||
import { Schema } from "effect"
|
||||
|
||||
/** The bytes of a `.tbl` file could not be interpreted as a string table. */
|
||||
export class TblBinaryError extends Schema.TaggedError<TblBinaryError>()("TblBinaryError", {
|
||||
message: Schema.String,
|
||||
/** Byte offset the failure was detected at, when known. */
|
||||
offset: Schema.optional(Schema.Number)
|
||||
}) {}
|
||||
|
||||
/** A table could not be laid out as a `.tbl` file (entry count / offset limits). */
|
||||
export class TblEncodeError extends Schema.TaggedError<TblEncodeError>()("TblEncodeError", {
|
||||
message: Schema.String,
|
||||
/** Entry index the failure was detected at, when known. */
|
||||
index: Schema.optional(Schema.Number),
|
||||
/** Byte offset that overflowed the 16-bit offset table, when known. */
|
||||
offset: Schema.optional(Schema.Number)
|
||||
}) {}
|
||||
|
||||
/** A `.tbl.txt` source file could not be parsed. */
|
||||
export class TblTextError extends Schema.TaggedError<TblTextError>()("TblTextError", {
|
||||
message: Schema.String,
|
||||
/** 1-based line number. */
|
||||
line: Schema.Number,
|
||||
/** 1-based column number. */
|
||||
column: Schema.Number
|
||||
}) {}
|
||||
200
tools/tbl/src/Tbl.ts
Normal file
200
tools/tbl/src/Tbl.ts
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The StarCraft: Brood War `.tbl` string table.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ```
|
||||
* u16le count
|
||||
* u16le offset[count] // absolute, from the start of the file
|
||||
* ... NUL-terminated string data
|
||||
* ```
|
||||
*
|
||||
* An indexed entry is not necessarily a single string: `unitnames.tbl` stores
|
||||
* `name\0subname\0group\0` under one index, and `stat_txt.tbl` stores a hotkey
|
||||
* byte followed by button text. Everything from `offset[i]` up to the next
|
||||
* offset in the file therefore belongs to entry `i`, which is why an entry is
|
||||
* modelled as a list of NUL-separated {@link TblEntry} parts.
|
||||
*/
|
||||
import { Effect } from "effect"
|
||||
import { TblBinaryError, TblEncodeError } from "./Errors.ts"
|
||||
|
||||
/** One indexed entry: its NUL-separated parts, without the terminators. */
|
||||
export type TblEntry = ReadonlyArray<Uint8Array>
|
||||
|
||||
/** A whole string table, in index order. */
|
||||
export type TblTable = ReadonlyArray<TblEntry>
|
||||
|
||||
/** Entry count and offsets are both `u16`, so a table holds at most 65535 entries. */
|
||||
export const MAX_ENTRIES = 0xffff
|
||||
|
||||
/** Every offset must be addressable by a `u16`. */
|
||||
export const MAX_OFFSET = 0xffff
|
||||
|
||||
const splitParts = (bytes: Uint8Array): ReadonlyArray<Uint8Array> => {
|
||||
const parts: Array<Uint8Array> = []
|
||||
let start = 0
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] === 0) {
|
||||
parts.push(bytes.subarray(start, i))
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts.push(bytes.subarray(start))
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Serialise one entry to `part\0part\0...\0`, including the final terminator. */
|
||||
const entryToBlock = (entry: TblEntry): Uint8Array => {
|
||||
const parts = entry.length === 0 ? [new Uint8Array(0)] : entry
|
||||
let size = parts.length
|
||||
for (const part of parts) size += part.length
|
||||
const block = new Uint8Array(size)
|
||||
let cursor = 0
|
||||
for (const part of parts) {
|
||||
block.set(part, cursor)
|
||||
cursor += part.length + 1 // the gap is already 0
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** A byte-exact string key for a block, used only to spot duplicate entries. */
|
||||
const blockKey = (block: Uint8Array): string => {
|
||||
let key = ""
|
||||
for (let i = 0; i < block.length; i += 4096) {
|
||||
key += String.fromCharCode(...block.subarray(i, i + 4096))
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the bytes of a `.tbl` file.
|
||||
*
|
||||
* Entry boundaries are derived from the sorted set of offsets, so shared and
|
||||
* out-of-order offsets decode correctly, and bytes that no index points at are
|
||||
* kept as trailing parts of the preceding entry rather than silently dropped.
|
||||
*/
|
||||
export const decode = Effect.fn("Tbl.decode")(
|
||||
function*(bytes: Uint8Array): Effect.fn.Return<TblTable, TblBinaryError> {
|
||||
if (bytes.length < 2) {
|
||||
return yield* new TblBinaryError({
|
||||
message: `truncated header: expected at least 2 bytes, got ${bytes.length}`
|
||||
})
|
||||
}
|
||||
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const count = view.getUint16(0, true)
|
||||
if (count === 0) return []
|
||||
|
||||
const headerEnd = 2 + count * 2
|
||||
if (bytes.length < headerEnd) {
|
||||
return yield* new TblBinaryError({
|
||||
message: `truncated offset table: ${count} entries need ${headerEnd} bytes, file is ${bytes.length}`,
|
||||
offset: bytes.length
|
||||
})
|
||||
}
|
||||
|
||||
const offsets: Array<number> = new Array(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = view.getUint16(2 + i * 2, true)
|
||||
if (offset < headerEnd || offset >= bytes.length) {
|
||||
return yield* new TblBinaryError({
|
||||
message: `entry ${i} points at ${offset}, outside the string data (${headerEnd}..${bytes.length - 1})`,
|
||||
offset
|
||||
})
|
||||
}
|
||||
offsets[i] = offset
|
||||
}
|
||||
|
||||
// Boundaries let an entry run to the next offset rather than the first NUL.
|
||||
const bounds = [...new Set(offsets)].sort((a, b) => a - b)
|
||||
const endOf = new Map<number, number>()
|
||||
for (let i = 0; i < bounds.length; i++) {
|
||||
endOf.set(bounds[i]!, bounds[i + 1] ?? bytes.length)
|
||||
}
|
||||
|
||||
const entries: Array<TblEntry> = new Array(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const start = offsets[i]!
|
||||
const end = endOf.get(start)!
|
||||
const block = bytes.subarray(start, end)
|
||||
if (block[block.length - 1] !== 0) {
|
||||
return yield* new TblBinaryError({
|
||||
message: `entry ${i} is not NUL-terminated before the next entry at ${end}`,
|
||||
offset: end - 1
|
||||
})
|
||||
}
|
||||
entries[i] = splitParts(block.subarray(0, block.length - 1))
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
)
|
||||
|
||||
export interface EncodeOptions {
|
||||
/**
|
||||
* Point identical entries at a single copy of the string data. Off by
|
||||
* default so that re-encoding a decoded file reproduces it byte for byte;
|
||||
* worth turning on for tables that are close to the 64 KiB offset ceiling.
|
||||
*/
|
||||
readonly dedupe?: boolean | undefined
|
||||
}
|
||||
|
||||
/** Encode a table into the bytes of a `.tbl` file. */
|
||||
export const encode = Effect.fn("Tbl.encode")(
|
||||
function*(table: TblTable, options?: EncodeOptions): Effect.fn.Return<Uint8Array, TblEncodeError> {
|
||||
const count = table.length
|
||||
if (count > MAX_ENTRIES) {
|
||||
return yield* new TblEncodeError({
|
||||
message: `${count} entries exceeds the ${MAX_ENTRIES} the u16 count field can hold`,
|
||||
index: MAX_ENTRIES
|
||||
})
|
||||
}
|
||||
|
||||
const headerEnd = 2 + count * 2
|
||||
const offsets: Array<number> = new Array(count)
|
||||
const blocks: Array<Uint8Array> = []
|
||||
const shared = new Map<string, number>()
|
||||
let cursor = headerEnd
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const block = entryToBlock(table[i]!)
|
||||
|
||||
if (options?.dedupe) {
|
||||
const key = blockKey(block)
|
||||
const hit = shared.get(key)
|
||||
if (hit !== undefined) {
|
||||
offsets[i] = hit
|
||||
continue
|
||||
}
|
||||
shared.set(key, cursor)
|
||||
}
|
||||
|
||||
if (cursor > MAX_OFFSET) {
|
||||
return yield* new TblEncodeError({
|
||||
message:
|
||||
`entry ${i} would start at ${cursor}, past the ${MAX_OFFSET} a u16 offset can address` +
|
||||
(options?.dedupe ? "" : " (try --dedupe)"),
|
||||
index: i,
|
||||
offset: cursor
|
||||
})
|
||||
}
|
||||
|
||||
offsets[i] = cursor
|
||||
blocks.push(block)
|
||||
cursor += block.length
|
||||
}
|
||||
|
||||
const out = new Uint8Array(cursor)
|
||||
const view = new DataView(out.buffer)
|
||||
view.setUint16(0, count, true)
|
||||
for (let i = 0; i < count; i++) view.setUint16(2 + i * 2, offsets[i]!, true)
|
||||
|
||||
let write = headerEnd
|
||||
for (const block of blocks) {
|
||||
out.set(block, write)
|
||||
write += block.length
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
)
|
||||
72
tools/tbl/src/TblFiles.ts
Normal file
72
tools/tbl/src/TblFiles.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* File-level operations: the two codecs plus a filesystem.
|
||||
*/
|
||||
import { Context, Effect, FileSystem, Layer } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import type { TblBinaryError, TblEncodeError, TblTextError } from "./Errors.ts"
|
||||
import * as Tbl from "./Tbl.ts"
|
||||
import * as TblText from "./TblText.ts"
|
||||
|
||||
const decoder = new TextDecoder("utf-8")
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
export class TblFiles extends Context.Service<TblFiles, {
|
||||
/** Read and decode a binary `.tbl`. */
|
||||
readonly readTbl: (path: string) => Effect.Effect<Tbl.TblTable, TblBinaryError | PlatformError>
|
||||
/** Encode and write a binary `.tbl`. */
|
||||
readonly writeTbl: (
|
||||
path: string,
|
||||
table: Tbl.TblTable,
|
||||
options?: Tbl.EncodeOptions
|
||||
) => Effect.Effect<number, TblEncodeError | PlatformError>
|
||||
/** Read and parse a `.tbl.txt` source file. */
|
||||
readonly readText: (
|
||||
path: string,
|
||||
options?: TblText.TextDecodeOptions
|
||||
) => Effect.Effect<Tbl.TblTable, TblTextError | PlatformError>
|
||||
/** Render and write a `.tbl.txt` source file. */
|
||||
readonly writeText: (
|
||||
path: string,
|
||||
table: Tbl.TblTable,
|
||||
options?: TblText.TextEncodeOptions
|
||||
) => Effect.Effect<number, PlatformError>
|
||||
}>()("cosmonarchy/tbl/TblFiles") {
|
||||
static readonly layer = Layer.effect(
|
||||
TblFiles,
|
||||
Effect.gen(function*() {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const readTbl = Effect.fn("TblFiles.readTbl")(function*(path: string) {
|
||||
return yield* Tbl.decode(yield* fs.readFile(path))
|
||||
})
|
||||
|
||||
const writeTbl = Effect.fn("TblFiles.writeTbl")(
|
||||
function*(path: string, table: Tbl.TblTable, options?: Tbl.EncodeOptions) {
|
||||
const bytes = yield* Tbl.encode(table, options)
|
||||
yield* fs.writeFile(path, bytes)
|
||||
return bytes.length
|
||||
}
|
||||
)
|
||||
|
||||
const readText = Effect.fn("TblFiles.readText")(
|
||||
function*(path: string, options?: TblText.TextDecodeOptions) {
|
||||
const bytes = yield* fs.readFile(path)
|
||||
// The GPTP dialect permits raw bytes above 127, so it is parsed from
|
||||
// bytes; v1 is ASCII by construction and safe to decode as text.
|
||||
if (options?.dialect === "gptp") return TblText.decodeGptp(bytes)
|
||||
return yield* TblText.decode(decoder.decode(bytes))
|
||||
}
|
||||
)
|
||||
|
||||
const writeText = Effect.fn("TblFiles.writeText")(
|
||||
function*(path: string, table: Tbl.TblTable, options?: TblText.TextEncodeOptions) {
|
||||
const bytes = encoder.encode(TblText.encode(table, options))
|
||||
yield* fs.writeFile(path, bytes)
|
||||
return bytes.length
|
||||
}
|
||||
)
|
||||
|
||||
return TblFiles.of({ readTbl, writeTbl, readText, writeText })
|
||||
})
|
||||
)
|
||||
}
|
||||
362
tools/tbl/src/TblText.ts
Normal file
362
tools/tbl/src/TblText.ts
Normal file
@ -0,0 +1,362 @@
|
||||
/**
|
||||
* The `.tbl.txt` source format: one entry per line, in index order.
|
||||
*
|
||||
* `.tbl` strings are raw bytes in a legacy code page and are full of Windows
|
||||
* paths, so backslash escaping would be both lossy and miserable to hand-edit.
|
||||
* Angle brackets are used instead, and they are the only special characters:
|
||||
*
|
||||
* | Token | Meaning |
|
||||
* | ------- | -------------------------------------------------- |
|
||||
* | `<0>` | separates the parts of a multi-part entry |
|
||||
* | `<N>` | the raw byte `N` (decimal, 0-255) - colours, `\n` |
|
||||
* | `<<` | a literal `<` |
|
||||
* | `<>` | nothing; marks an otherwise empty line |
|
||||
*
|
||||
* Everything else is written literally, so `zerg\avenger.grp` stays exactly
|
||||
* that. Lines starting with `#` are comments and blank lines are ignored; an
|
||||
* entry that would begin with `#` or a space, or end with a space, has that
|
||||
* character written as `<35>` / `<32>` so no editor can quietly eat it.
|
||||
*
|
||||
* Encoding is ASCII-only by construction, which keeps the file safe from being
|
||||
* re-saved as UTF-8.
|
||||
*/
|
||||
import { Effect } from "effect"
|
||||
import { TblTextError } from "./Errors.ts"
|
||||
import type { TblEntry, TblTable } from "./Tbl.ts"
|
||||
|
||||
const LT = 0x3c
|
||||
const HASH = 0x23
|
||||
const SPACE = 0x20
|
||||
const TAB = 0x09
|
||||
const TILDE = 0x7e
|
||||
const BACKSLASH = 0x5c
|
||||
const LOWER_N = 0x6e
|
||||
|
||||
/**
|
||||
* Which text dialect a file is written in.
|
||||
*
|
||||
* - `v1` - this tool's own `.tbl.txt` format, described above.
|
||||
* - `gptp` - the format CM-GPTP's `collapse_control_codes` accepts, used by the
|
||||
* tables the engine already reads as text: `rez/stat.txt` and `arr/sounds.txt`.
|
||||
*
|
||||
* They agree on what an entry is - bytes, NUL-separated - and differ only in
|
||||
* escaping. See {@link decodeEntryGptp} for the exact rules.
|
||||
*/
|
||||
export type Dialect = "v1" | "gptp"
|
||||
|
||||
const renderPart = (part: Uint8Array): string => {
|
||||
let out = ""
|
||||
for (const byte of part) {
|
||||
if (byte === LT) out += "<<"
|
||||
else if (byte >= SPACE && byte <= TILDE) out += String.fromCharCode(byte)
|
||||
else out += `<${byte}>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Render one entry as a single line. Never fails, and never returns `""`. */
|
||||
export const encodeEntry = (entry: TblEntry): string => {
|
||||
let line = entry.map(renderPart).join("<0>")
|
||||
if (line === "") return "<>"
|
||||
if (line.startsWith("#")) line = `<${HASH}>` + line.slice(1)
|
||||
else if (line.startsWith(" ")) line = `<${SPACE}>` + line.slice(1)
|
||||
if (line.endsWith(" ")) line = line.slice(0, -1) + `<${SPACE}>`
|
||||
return line
|
||||
}
|
||||
|
||||
/** Parse one line into an entry. `line` is 1-based, for error messages. */
|
||||
export const decodeEntry = Effect.fn("TblText.decodeEntry")(
|
||||
function*(source: string, line: number): Effect.fn.Return<TblEntry, TblTextError> {
|
||||
const parts: Array<Uint8Array> = []
|
||||
let current: Array<number> = []
|
||||
let i = 0
|
||||
|
||||
while (i < source.length) {
|
||||
const char = source.charCodeAt(i)
|
||||
|
||||
if (char === LT) {
|
||||
if (source.charCodeAt(i + 1) === LT) {
|
||||
current.push(LT)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
const close = source.indexOf(">", i + 1)
|
||||
if (close === -1) {
|
||||
return yield* new TblTextError({
|
||||
message: "unterminated escape: `<` with no matching `>` (write a literal `<` as `<<`)",
|
||||
line,
|
||||
column: i + 1
|
||||
})
|
||||
}
|
||||
const body = source.slice(i + 1, close)
|
||||
if (body === "") {
|
||||
i = close + 1 // `<>` contributes nothing
|
||||
continue
|
||||
}
|
||||
if (!/^[0-9]{1,3}$/.test(body)) {
|
||||
return yield* new TblTextError({
|
||||
message: `invalid escape \`<${body}>\`: expected a decimal byte 0-255, \`<<\` or \`<>\``,
|
||||
line,
|
||||
column: i + 1
|
||||
})
|
||||
}
|
||||
const value = Number(body)
|
||||
if (value > 0xff) {
|
||||
return yield* new TblTextError({
|
||||
message: `byte ${value} is out of range: expected 0-255`,
|
||||
line,
|
||||
column: i + 1
|
||||
})
|
||||
}
|
||||
if (value === 0) {
|
||||
parts.push(Uint8Array.from(current))
|
||||
current = []
|
||||
} else {
|
||||
current.push(value)
|
||||
}
|
||||
i = close + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (char > TILDE) {
|
||||
return yield* new TblTextError({
|
||||
message:
|
||||
`non-ASCII character ${JSON.stringify(source[i])} (U+${
|
||||
char.toString(16).toUpperCase().padStart(4, "0")
|
||||
}): .tbl strings are raw bytes, write it as \`<N>\``,
|
||||
line,
|
||||
column: i + 1
|
||||
})
|
||||
}
|
||||
if (char < SPACE && char !== TAB) {
|
||||
return yield* new TblTextError({
|
||||
message: `control character ${char} must be written as \`<${char}>\``,
|
||||
line,
|
||||
column: i + 1
|
||||
})
|
||||
}
|
||||
|
||||
current.push(char)
|
||||
i++
|
||||
}
|
||||
|
||||
parts.push(Uint8Array.from(current))
|
||||
return parts
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse one line the way CM-GPTP's `collapse_control_codes` does
|
||||
* (`hooks/limits/tbl_extender.cpp`), then split it into parts on NUL.
|
||||
*
|
||||
* The rules, transcribed from that function:
|
||||
*
|
||||
* - `<N>` where the body is entirely decimal digits becomes the byte `N`.
|
||||
* C++ parses it into a `u32` and narrows with `char(...)`, so values above
|
||||
* 255 wrap rather than being rejected - replicated here, because the point of
|
||||
* this decoder is to say what the engine will actually do.
|
||||
* - Anything else in angle brackets - `<>`, `<foo>`, an unclosed `<` - is left
|
||||
* completely literal. There is no `<<` escape in this dialect.
|
||||
* - `\n` becomes a newline and `\\` becomes one backslash. Any other backslash
|
||||
* pair is left alone, so `zerg\avenger.grp` survives but `zerg\night.grp`
|
||||
* would not.
|
||||
* - Every other byte is literal, including bytes above 127.
|
||||
*
|
||||
* Unlike {@link decode}, there is no comment or blank-line syntax: the engine
|
||||
* turns *every* line into an entry, so a `#` line would become a `#` string.
|
||||
*/
|
||||
export const decodeEntryGptp = (source: Uint8Array): TblEntry => {
|
||||
const out: Array<number> = []
|
||||
let i = 0
|
||||
|
||||
while (i < source.length) {
|
||||
const byte = source[i]!
|
||||
|
||||
if (byte === LT) {
|
||||
const close = source.indexOf(0x3e, i + 1) // '>'
|
||||
if (close !== -1) {
|
||||
let digits = close > i + 1
|
||||
for (let j = i + 1; j < close; j++) {
|
||||
const d = source[j]!
|
||||
if (d < 0x30 || d > 0x39) {
|
||||
digits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (digits) {
|
||||
let value = 0
|
||||
for (let j = i + 1; j < close; j++) value = value * 10 + (source[j]! - 0x30)
|
||||
// u32 parse then char() narrowing, exactly as the C++ does.
|
||||
if (value <= 0xffffffff) {
|
||||
out.push(value & 0xff)
|
||||
i = close + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(LT)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (byte === BACKSLASH && i + 1 < source.length) {
|
||||
const next = source[i + 1]!
|
||||
if (next === LOWER_N) {
|
||||
out.push(0x0a)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (next === BACKSLASH) {
|
||||
out.push(BACKSLASH)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.push(byte)
|
||||
i++
|
||||
}
|
||||
|
||||
return splitOnNul(Uint8Array.from(out))
|
||||
}
|
||||
|
||||
/** Split a collapsed line into its NUL-separated parts. */
|
||||
const splitOnNul = (bytes: Uint8Array): TblEntry => {
|
||||
const parts: Array<Uint8Array> = []
|
||||
let start = 0
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] === 0) {
|
||||
parts.push(bytes.subarray(start, i))
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts.push(bytes.subarray(start))
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one entry in the GPTP dialect.
|
||||
*
|
||||
* Escaping is chosen to be unambiguous under {@link decodeEntryGptp} rather
|
||||
* than to match any particular hand-written file byte for byte:
|
||||
*
|
||||
* - `<` is written `<60>`, because this dialect has no `<<` and a literal `<`
|
||||
* followed by digits and `>` would be read back as a control code.
|
||||
* - `\` is written `\\` only when the next byte would make it an escape (`n` or
|
||||
* another `\`), so `zerg\avenger.grp` stays readable.
|
||||
* - Bytes outside printable ASCII are written `<N>`, so output is always ASCII
|
||||
* and cannot be corrupted by a re-save. The engine accepts raw high bytes too
|
||||
* - `rez/stat.txt` uses them - but it does not need them.
|
||||
*/
|
||||
export const encodeEntryGptp = (entry: TblEntry): string => {
|
||||
const joined: Array<number> = []
|
||||
entry.forEach((part, index) => {
|
||||
if (index > 0) joined.push(0)
|
||||
for (const byte of part) joined.push(byte)
|
||||
})
|
||||
|
||||
let out = ""
|
||||
for (let i = 0; i < joined.length; i++) {
|
||||
const byte = joined[i]!
|
||||
const next = joined[i + 1]
|
||||
if (byte === LT) out += "<60>"
|
||||
else if (byte === BACKSLASH && (next === LOWER_N || next === BACKSLASH)) out += "\\\\"
|
||||
else if (byte >= SPACE && byte <= TILDE) out += String.fromCharCode(byte)
|
||||
else out += `<${byte}>`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface TextEncodeOptions {
|
||||
/** Prepend the format banner. Defaults to `true`. Ignored for `gptp`. */
|
||||
readonly header?: boolean | undefined
|
||||
/**
|
||||
* Emit a `# [index]` comment above every entry. Defaults to `false`.
|
||||
* Ignored for `gptp`, which has no comment syntax.
|
||||
*/
|
||||
readonly annotate?: boolean | undefined
|
||||
/** Which dialect to write. Defaults to `v1`. */
|
||||
readonly dialect?: Dialect | undefined
|
||||
}
|
||||
|
||||
export interface TextDecodeOptions {
|
||||
/** Which dialect to read. Defaults to `v1`. */
|
||||
readonly dialect?: Dialect | undefined
|
||||
}
|
||||
|
||||
const BANNER = [
|
||||
"# tbl-text v1 - StarCraft: Brood War string table source",
|
||||
"# One entry per line, in index order.",
|
||||
"# <0> separates the parts of a multi-part entry",
|
||||
"# <N> raw byte N (decimal 0-255), e.g. <10> newline, <4> colour code",
|
||||
"# << a literal '<' <> nothing (empty-entry marker)",
|
||||
"# Lines starting with '#' are comments; blank lines are ignored."
|
||||
]
|
||||
|
||||
/** Render a whole table as `.tbl.txt` source. */
|
||||
export const encode = (table: TblTable, options?: TextEncodeOptions): string => {
|
||||
if (options?.dialect === "gptp") return encodeGptp(table)
|
||||
const lines: Array<string> = []
|
||||
if (options?.header !== false) lines.push(...BANNER, "")
|
||||
for (let i = 0; i < table.length; i++) {
|
||||
if (options?.annotate) lines.push(`# [${i}]`)
|
||||
lines.push(encodeEntry(table[i]!))
|
||||
}
|
||||
return lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a whole table in the GPTP dialect: one line per entry, nothing else.
|
||||
*
|
||||
* There is deliberately no banner and no `--annotate` support here. The engine
|
||||
* reads every line as an entry, so a comment would silently become a string and
|
||||
* shift every index after it.
|
||||
*/
|
||||
export const encodeGptp = (table: TblTable): string =>
|
||||
table.map(encodeEntryGptp).join("\n") + "\n"
|
||||
|
||||
/**
|
||||
* Parse a whole file in the GPTP dialect.
|
||||
*
|
||||
* Takes bytes rather than a string because this dialect allows raw bytes above
|
||||
* 127, which have no single correct interpretation as text. Never fails: every
|
||||
* byte sequence is a valid GPTP source file.
|
||||
*/
|
||||
export const decodeGptp = (bytes: Uint8Array): TblTable => {
|
||||
const lines: Array<Uint8Array> = []
|
||||
let start = 0
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] === 0x0a) { // '\n'
|
||||
let end = i
|
||||
if (end > start && bytes[end - 1] === 0x0d) end-- // '\r'
|
||||
lines.push(bytes.subarray(start, end))
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
// A trailing newline ends the last line rather than starting an empty one,
|
||||
// matching std::getline.
|
||||
if (start < bytes.length) {
|
||||
let end = bytes.length
|
||||
if (end > start && bytes[end - 1] === 0x0d) end--
|
||||
lines.push(bytes.subarray(start, end))
|
||||
}
|
||||
return lines.map(decodeEntryGptp)
|
||||
}
|
||||
|
||||
/** Parse `.tbl.txt` source into a table. */
|
||||
export const decode = Effect.fn("TblText.decode")(
|
||||
function*(text: string): Effect.fn.Return<TblTable, TblTextError> {
|
||||
const source = text.startsWith("\uFEFF") ? text.slice(1) : text
|
||||
const entries: Array<TblEntry> = []
|
||||
const lines = source.split("\n")
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]!
|
||||
if (line.endsWith("\r")) line = line.slice(0, -1)
|
||||
if (line === "" || line.startsWith("#")) continue
|
||||
entries.push(yield* decodeEntry(line, i + 1))
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
)
|
||||
325
tools/tbl/src/cli.ts
Normal file
325
tools/tbl/src/cli.ts
Normal file
@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bun
|
||||
import { BunRuntime, BunServices } from "@effect/platform-bun"
|
||||
import { Console, Effect, FileSystem, Layer, Option } from "effect"
|
||||
import { Argument, Command, Flag } from "effect/unstable/cli"
|
||||
import { TblFiles } from "./TblFiles.ts"
|
||||
import * as Tbl from "./Tbl.ts"
|
||||
import * as TblText from "./TblText.ts"
|
||||
|
||||
/** `foo.tbl` -> `foo.tbl.txt`, so a decompile never lands on an unrelated `foo.txt`. */
|
||||
const textPathFor = (tbl: string) => `${tbl}.txt`
|
||||
|
||||
/** `foo.tbl.txt` -> `foo.tbl`, `foo.txt` -> `foo.tbl`. */
|
||||
const tblPathFor = (text: string) =>
|
||||
text.endsWith(".tbl.txt")
|
||||
? text.slice(0, -".txt".length)
|
||||
: text.endsWith(".txt")
|
||||
? `${text.slice(0, -".txt".length)}.tbl`
|
||||
: `${text}.tbl`
|
||||
|
||||
const resolve = (given: Option.Option<string>, fallback: string) =>
|
||||
Option.getOrElse(given, () => fallback)
|
||||
|
||||
const output = Flag.string("output").pipe(
|
||||
Flag.withAlias("o"),
|
||||
Flag.withDescription("Where to write the result"),
|
||||
Flag.optional
|
||||
)
|
||||
|
||||
const dialect = Flag.choice("dialect", ["v1", "gptp"]).pipe(
|
||||
Flag.withDescription(
|
||||
"Text format: v1 (this tool's .tbl.txt) or gptp (what CM-GPTP's collapse_control_codes reads)"
|
||||
),
|
||||
Flag.withDefault("v1" as const)
|
||||
)
|
||||
|
||||
const decompile = Command.make(
|
||||
"decompile",
|
||||
{
|
||||
input: Argument.file("input", { mustExist: true }).pipe(
|
||||
Argument.withDescription("The .tbl file to read")
|
||||
),
|
||||
output,
|
||||
annotate: Flag.boolean("annotate").pipe(
|
||||
Flag.withDescription("Emit a `# [index]` comment above every entry"),
|
||||
Flag.withDefault(false)
|
||||
),
|
||||
bare: Flag.boolean("bare").pipe(
|
||||
Flag.withDescription("Omit the format banner comment"),
|
||||
Flag.withDefault(false)
|
||||
),
|
||||
dialect
|
||||
},
|
||||
Effect.fn(function*({ annotate, bare, dialect, input, output }) {
|
||||
const files = yield* TblFiles
|
||||
if (dialect === "gptp" && annotate) {
|
||||
// The engine reads every line as an entry, so a `# [n]` comment would
|
||||
// become a string and shift every index after it.
|
||||
yield* Console.error("error: --annotate has no meaning for --dialect gptp, which has no comment syntax")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const target = resolve(output, textPathFor(input))
|
||||
const table = yield* files.readTbl(input)
|
||||
const written = yield* files.writeText(target, table, { annotate, header: !bare, dialect })
|
||||
yield* Console.log(
|
||||
`${input} -> ${target} (${table.length} entries, ${written} bytes${dialect === "gptp" ? ", gptp dialect" : ""})`
|
||||
)
|
||||
})
|
||||
).pipe(
|
||||
Command.withDescription("Read a binary .tbl and write editable .tbl.txt source"),
|
||||
Command.withAlias("d"),
|
||||
Command.withExamples([
|
||||
{ command: "tbl decompile mpq/arr/images.tbl", description: "Writes mpq/arr/images.tbl.txt" },
|
||||
{
|
||||
command: "tbl decompile mpq/rez/stat_txt.tbl --annotate -o stat_txt.txt",
|
||||
description: "Number every entry, for a table addressed by index"
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const compile = Command.make(
|
||||
"compile",
|
||||
{
|
||||
input: Argument.file("input", { mustExist: true }).pipe(
|
||||
Argument.withDescription("The .tbl.txt source file to read")
|
||||
),
|
||||
output,
|
||||
dedupe: Flag.boolean("dedupe").pipe(
|
||||
Flag.withDescription("Point identical entries at one copy of the string data"),
|
||||
Flag.withDefault(false)
|
||||
),
|
||||
dialect
|
||||
},
|
||||
Effect.fn(function*({ dedupe, dialect, input, output }) {
|
||||
const files = yield* TblFiles
|
||||
const target = resolve(output, tblPathFor(input))
|
||||
const table = yield* files.readText(input, { dialect })
|
||||
const written = yield* files.writeTbl(target, table, { dedupe })
|
||||
yield* Console.log(`${input} -> ${target} (${table.length} entries, ${written} bytes)`)
|
||||
})
|
||||
).pipe(
|
||||
Command.withDescription("Compile .tbl.txt source into a binary .tbl"),
|
||||
Command.withAlias("c"),
|
||||
Command.withExamples([
|
||||
{ command: "tbl compile mpq/arr/images.tbl.txt", description: "Writes mpq/arr/images.tbl" },
|
||||
{
|
||||
command: "tbl compile mpq/rez/stat_txt.tbl.txt --dedupe",
|
||||
description: "Share duplicate strings to stay under the 64 KiB offset ceiling"
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const bytesEqual = (a: Uint8Array, b: Uint8Array) =>
|
||||
a.length === b.length && a.every((byte, i) => byte === b[i])
|
||||
|
||||
/** Every `.tbl.txt` under `root`, depth first, in a stable order. */
|
||||
const sourcesUnder = Effect.fn("sourcesUnder")(function*(root: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const entries = yield* fs.readDirectory(root, { recursive: true })
|
||||
return entries
|
||||
.filter((entry) => entry.endsWith(".tbl.txt"))
|
||||
.map((entry) => `${root}/${entry}`)
|
||||
.sort()
|
||||
})
|
||||
|
||||
/**
|
||||
* Which dialect a source file is written in.
|
||||
*
|
||||
* `decompile` writes the `# tbl-text v1` banner unless `--bare`, and the GPTP
|
||||
* dialect cannot carry a comment at all - every line there is an entry - so the
|
||||
* banner is an unambiguous marker. Files the engine reads directly
|
||||
* (`arr/images.tbl.txt`) are GPTP; the rest are v1 build artefacts.
|
||||
*/
|
||||
const dialectOf = Effect.fn("dialectOf")(function*(path: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const head = (yield* fs.readFile(path)).subarray(0, 64)
|
||||
const text = new TextDecoder("utf-8", { fatal: false }).decode(head)
|
||||
return text.startsWith("# tbl-text v1") ? "v1" as const : "gptp" as const
|
||||
})
|
||||
|
||||
const build = Command.make(
|
||||
"build",
|
||||
{
|
||||
root: Argument.directory("root", { mustExist: true }).pipe(
|
||||
Argument.withDescription("Directory to search for .tbl.txt sources"),
|
||||
Argument.withDefault(".")
|
||||
),
|
||||
check: Flag.boolean("check").pipe(
|
||||
Flag.withDescription("Write nothing; exit non-zero if any .tbl is out of date"),
|
||||
Flag.withDefault(false)
|
||||
),
|
||||
dedupe: Flag.boolean("dedupe").pipe(
|
||||
Flag.withDescription("Point identical entries at one copy of the string data"),
|
||||
Flag.withDefault(false)
|
||||
)
|
||||
},
|
||||
Effect.fn(function*({ check, dedupe, root }) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const files = yield* TblFiles
|
||||
const sources = yield* sourcesUnder(root)
|
||||
|
||||
if (sources.length === 0) {
|
||||
yield* Console.error(`no .tbl.txt sources found under ${root}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
let stale = 0
|
||||
for (const source of sources) {
|
||||
const target = tblPathFor(source)
|
||||
const table = yield* files.readText(source, { dialect: yield* dialectOf(source) })
|
||||
|
||||
if (!check) {
|
||||
const written = yield* files.writeTbl(target, table, { dedupe })
|
||||
yield* Console.log(` built ${target} (${table.length} entries, ${written} bytes)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare against what is actually on disk, so a hand-edited .tbl is
|
||||
// caught just as surely as a forgotten recompile.
|
||||
const rebuilt = yield* Tbl.encode(table, { dedupe })
|
||||
const current = yield* fs.readFile(target).pipe(Effect.orElseSucceed(() => new Uint8Array()))
|
||||
if (bytesEqual(current, rebuilt)) {
|
||||
yield* Console.log(` ok ${target}`)
|
||||
} else {
|
||||
yield* Console.error(` STALE ${target} does not match ${source}`)
|
||||
stale++
|
||||
}
|
||||
}
|
||||
|
||||
if (check && stale > 0) {
|
||||
yield* Console.error(
|
||||
`\n${stale} of ${sources.length} table(s) out of date - run \`bun run tbl build ${root}\``
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
yield* Console.log(`\n${sources.length} table(s) ${check ? "up to date" : "built"}`)
|
||||
})
|
||||
).pipe(
|
||||
Command.withDescription("Compile every .tbl.txt under a directory into its binary .tbl"),
|
||||
Command.withAlias("b"),
|
||||
Command.withExamples([
|
||||
{ command: "tbl build mpq", description: "Recompile every table in the mpq tree" },
|
||||
{
|
||||
command: "tbl build mpq --check",
|
||||
description: "Fail if any committed .tbl is out of date - use in a pre-commit hook"
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const verify = Command.make(
|
||||
"verify",
|
||||
{
|
||||
input: Argument.file("input", { mustExist: true }).pipe(
|
||||
Argument.withDescription("The .tbl file to round-trip")
|
||||
)
|
||||
},
|
||||
Effect.fn(function*({ input }) {
|
||||
const files = yield* TblFiles
|
||||
const original = yield* files.readTbl(input)
|
||||
const text = TblText.encode(original)
|
||||
const reparsed = yield* TblText.decode(text)
|
||||
const rebuilt = yield* Tbl.encode(reparsed)
|
||||
const source = yield* Tbl.encode(original)
|
||||
|
||||
if (!bytesEqual(source, rebuilt)) {
|
||||
yield* Console.error(`${input}: FAIL - re-encoding does not reproduce the original bytes`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
yield* Console.log(`${input}: ok - ${original.length} entries survive .tbl -> .tbl.txt -> .tbl byte for byte`)
|
||||
})
|
||||
).pipe(
|
||||
Command.withDescription("Check that a .tbl survives a full round trip unchanged")
|
||||
)
|
||||
|
||||
const info = Command.make(
|
||||
"info",
|
||||
{
|
||||
input: Argument.file("input", { mustExist: true }).pipe(
|
||||
Argument.withDescription("The .tbl file to inspect")
|
||||
)
|
||||
},
|
||||
Effect.fn(function*({ input }) {
|
||||
// Read the offset table off the file itself: a re-encode would describe
|
||||
// what this tool would write, not what is actually on disk.
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const bytes = yield* fs.readFile(input)
|
||||
const table = yield* Tbl.decode(bytes)
|
||||
|
||||
const headerEnd = 2 + table.length * 2
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const distinct = new Set<number>()
|
||||
let highest = headerEnd
|
||||
for (let i = 0; i < table.length; i++) {
|
||||
const offset = view.getUint16(2 + i * 2, true)
|
||||
distinct.add(offset)
|
||||
if (offset > highest) highest = offset
|
||||
}
|
||||
|
||||
const parts = new Map<number, number>()
|
||||
const control = new Set<number>()
|
||||
for (const entry of table) {
|
||||
parts.set(entry.length, (parts.get(entry.length) ?? 0) + 1)
|
||||
for (const part of entry) {
|
||||
for (const byte of part) if (byte < 0x20 || byte > 0x7e) control.add(byte)
|
||||
}
|
||||
}
|
||||
|
||||
const shared = table.length - distinct.size
|
||||
|
||||
yield* Console.log(
|
||||
[
|
||||
input,
|
||||
` entries ${table.length}${shared === 0 ? "" : ` (${shared} sharing string data)`}`,
|
||||
` size ${bytes.length} bytes (${headerEnd} header + ${bytes.length - headerEnd} strings)`,
|
||||
` highest offset ${highest} of ${Tbl.MAX_OFFSET} (${Tbl.MAX_OFFSET - highest} bytes of headroom)`,
|
||||
` parts per entry ${
|
||||
[...parts.entries()].sort(([a], [b]) => a - b).map(([n, count]) => `${count}x${n}`).join(", ")
|
||||
}`,
|
||||
` non-ascii bytes ${
|
||||
control.size === 0 ? "none" : [...control].sort((a, b) => a - b).map((b) => `<${b}>`).join(" ")
|
||||
}`
|
||||
].join("\n")
|
||||
)
|
||||
})
|
||||
).pipe(
|
||||
Command.withDescription("Show entry counts, offset headroom and byte usage for a .tbl")
|
||||
)
|
||||
|
||||
const tbl = Command.make("tbl").pipe(
|
||||
Command.withDescription("Compile between StarCraft: Brood War .tbl string tables and .tbl.txt source"),
|
||||
Command.withSubcommands([decompile, compile, build, verify, info])
|
||||
)
|
||||
|
||||
tbl.pipe(
|
||||
Command.run({ version: "1.0.0" }),
|
||||
Effect.catchTags({
|
||||
TblBinaryError: (error) =>
|
||||
Effect.gen(function*() {
|
||||
yield* Console.error(
|
||||
`error: ${error.message}${error.offset === undefined ? "" : ` (at byte ${error.offset})`}`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
TblEncodeError: (error) =>
|
||||
Effect.gen(function*() {
|
||||
yield* Console.error(`error: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
TblTextError: (error) =>
|
||||
Effect.gen(function*() {
|
||||
yield* Console.error(`error: line ${error.line}, column ${error.column}: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
PlatformError: (error) =>
|
||||
Effect.gen(function*() {
|
||||
yield* Console.error(`error: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}),
|
||||
Effect.provide(Layer.provideMerge(TblFiles.layer, BunServices.layer)),
|
||||
BunRuntime.runMain
|
||||
)
|
||||
4
tools/tbl/src/index.ts
Normal file
4
tools/tbl/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./Errors.ts"
|
||||
export * as Tbl from "./Tbl.ts"
|
||||
export * from "./TblFiles.ts"
|
||||
export * as TblText from "./TblText.ts"
|
||||
131
tools/tbl/test/Tbl.test.ts
Normal file
131
tools/tbl/test/Tbl.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import * as Tbl from "../src/Tbl.ts"
|
||||
import { entry, failWith, show, succeed } from "./helpers.ts"
|
||||
|
||||
/** Hand-assemble a .tbl so the tests do not lean on the encoder. */
|
||||
const build = (blocks: Array<string>) => {
|
||||
const header = 2 + blocks.length * 2
|
||||
const data = blocks.join("")
|
||||
const bytes = new Uint8Array(header + data.length)
|
||||
const view = new DataView(bytes.buffer)
|
||||
view.setUint16(0, blocks.length, true)
|
||||
let cursor = header
|
||||
blocks.forEach((block, i) => {
|
||||
view.setUint16(2 + i * 2, cursor, true)
|
||||
for (let j = 0; j < block.length; j++) bytes[cursor + j] = block.charCodeAt(j)
|
||||
cursor += block.length
|
||||
})
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** Assemble a .tbl from an explicit offset table plus raw string data. */
|
||||
const buildWithOffsets = (offsets: Array<number>, data: string) => {
|
||||
const header = 2 + offsets.length * 2
|
||||
const bytes = new Uint8Array(header + data.length)
|
||||
const view = new DataView(bytes.buffer)
|
||||
view.setUint16(0, offsets.length, true)
|
||||
offsets.forEach((offset, i) => view.setUint16(2 + i * 2, offset, true))
|
||||
for (let j = 0; j < data.length; j++) bytes[header + j] = data.charCodeAt(j)
|
||||
return bytes
|
||||
}
|
||||
|
||||
describe("decode", () => {
|
||||
it("reads a simple table", async () => {
|
||||
const table = await succeed(Tbl.decode(build(["one\0", "two\0"])))
|
||||
expect(show(table)).toEqual([["one"], ["two"]])
|
||||
})
|
||||
|
||||
it("keeps the NUL-separated parts of a multi-part entry together", async () => {
|
||||
const table = await succeed(Tbl.decode(build(["Maverick\0*\0Ground Units\0", "Egg\0"])))
|
||||
expect(show(table)).toEqual([["Maverick", "*", "Ground Units"], ["Egg"]])
|
||||
})
|
||||
|
||||
it("gives the last entry everything up to the end of the file", async () => {
|
||||
const table = await succeed(Tbl.decode(build(["a\0", "hotkey\0button text\0"])))
|
||||
expect(show(table)).toEqual([["a"], ["hotkey", "button text"]])
|
||||
})
|
||||
|
||||
it("reads an empty string entry", async () => {
|
||||
expect(show(await succeed(Tbl.decode(build(["\0", "x\0"]))))).toEqual([[""], ["x"]])
|
||||
})
|
||||
|
||||
it("returns an empty table when the count is zero", async () => {
|
||||
expect(await succeed(Tbl.decode(Uint8Array.from([0, 0])))).toEqual([])
|
||||
})
|
||||
|
||||
it("resolves shared offsets to the same entry", async () => {
|
||||
// Entries 0 and 1 point at one copy of "one", as a deduped table would.
|
||||
const bytes = buildWithOffsets([8, 8, 12], "one\0three\0")
|
||||
expect(show(await succeed(Tbl.decode(bytes)))).toEqual([["one"], ["one"], ["three"]])
|
||||
})
|
||||
|
||||
it("keeps trailing bytes no offset points at, as stat_txt.tbl relies on", async () => {
|
||||
const bytes = buildWithOffsets([4], "a\0button text\0")
|
||||
expect(show(await succeed(Tbl.decode(bytes)))).toEqual([["a", "button text"]])
|
||||
})
|
||||
|
||||
it("rejects a file too short to hold a header", async () => {
|
||||
const error = await failWith(Tbl.decode(Uint8Array.from([1])))
|
||||
expect(error.message).toContain("truncated header")
|
||||
})
|
||||
|
||||
it("rejects a truncated offset table", async () => {
|
||||
const error = await failWith(Tbl.decode(Uint8Array.from([9, 0, 0, 0])))
|
||||
expect(error.message).toContain("truncated offset table")
|
||||
})
|
||||
|
||||
it("rejects an offset that points outside the string data", async () => {
|
||||
const bytes = build(["one\0"])
|
||||
new DataView(bytes.buffer).setUint16(2, 999, true)
|
||||
const error = await failWith(Tbl.decode(bytes))
|
||||
expect(error.message).toContain("outside the string data")
|
||||
expect(error.offset).toBe(999)
|
||||
})
|
||||
|
||||
it("rejects an entry that is not NUL-terminated", async () => {
|
||||
const error = await failWith(Tbl.decode(build(["one", "two\0"])))
|
||||
expect(error.message).toContain("not NUL-terminated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("encode", () => {
|
||||
it("reproduces the exact bytes it decoded", async () => {
|
||||
const bytes = build(["Maverick\0*\0Ground Units\0", "\0", "Egg\0"])
|
||||
expect(await succeed(Tbl.encode(await succeed(Tbl.decode(bytes))))).toEqual(bytes)
|
||||
})
|
||||
|
||||
it("writes a well-formed header", async () => {
|
||||
const bytes = await succeed(Tbl.encode([entry("ab"), entry("c")]))
|
||||
const view = new DataView(bytes.buffer)
|
||||
expect(view.getUint16(0, true)).toBe(2)
|
||||
expect(view.getUint16(2, true)).toBe(6)
|
||||
expect(view.getUint16(4, true)).toBe(9) // 6 + "ab\0"
|
||||
expect(bytes.length).toBe(11) // 6 header + "ab\0" + "c\0"
|
||||
})
|
||||
|
||||
it("treats an entry with no parts as a single empty string", async () => {
|
||||
expect(show(await succeed(Tbl.decode(await succeed(Tbl.encode([[]])))))).toEqual([[""]])
|
||||
})
|
||||
|
||||
it("shares identical entries when asked, and still decodes the same", async () => {
|
||||
const table = Array.from({ length: 64 }, () => entry("Ground Units"))
|
||||
const plain = await succeed(Tbl.encode(table))
|
||||
const shared = await succeed(Tbl.encode(table, { dedupe: true }))
|
||||
expect(shared.length).toBeLessThan(plain.length)
|
||||
expect(show(await succeed(Tbl.decode(shared)))).toEqual(show(table))
|
||||
})
|
||||
|
||||
it("refuses to write an offset a u16 cannot address", async () => {
|
||||
const table = Array.from({ length: 100 }, (_, i) => entry(`${i}`.padEnd(1000, "x")))
|
||||
const error = await failWith(Tbl.encode(table))
|
||||
expect(error.message).toContain("past the 65535")
|
||||
expect(error.message).toContain("--dedupe")
|
||||
expect(error.offset).toBeGreaterThan(Tbl.MAX_OFFSET)
|
||||
})
|
||||
|
||||
it("refuses to write more entries than the count field holds", async () => {
|
||||
const table = Array.from({ length: Tbl.MAX_ENTRIES + 1 }, () => entry(""))
|
||||
const error = await failWith(Tbl.encode(table))
|
||||
expect(error.message).toContain("exceeds the 65535")
|
||||
})
|
||||
})
|
||||
119
tools/tbl/test/TblText.test.ts
Normal file
119
tools/tbl/test/TblText.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import * as TblText from "../src/TblText.ts"
|
||||
import { entry, failWith, show, succeed } from "./helpers.ts"
|
||||
|
||||
describe("encodeEntry", () => {
|
||||
it("leaves backslashes and ordinary ASCII alone", () => {
|
||||
expect(TblText.encodeEntry(entry("zerg\\avenger.grp"))).toBe("zerg\\avenger.grp")
|
||||
})
|
||||
|
||||
it("joins the parts of a multi-part entry with <0>", () => {
|
||||
expect(TblText.encodeEntry(entry("Terran Maverick", "*", "Ground Units")))
|
||||
.toBe("Terran Maverick<0>*<0>Ground Units")
|
||||
})
|
||||
|
||||
it("escapes colour codes, newlines and high bytes as <N>", () => {
|
||||
const bytes = Uint8Array.from([0x04, 0x45, 0x01, 0x0a, 0xa4])
|
||||
expect(TblText.encodeEntry([bytes])).toBe("<4>E<1><10><164>")
|
||||
})
|
||||
|
||||
it("escapes a literal < as <<", () => {
|
||||
expect(TblText.encodeEntry(entry("a<b"))).toBe("a<<b")
|
||||
})
|
||||
|
||||
it("marks a wholly empty entry with <>", () => {
|
||||
expect(TblText.encodeEntry(entry(""))).toBe("<>")
|
||||
expect(TblText.encodeEntry(entry("", ""))).toBe("<0>")
|
||||
})
|
||||
|
||||
it("protects a leading # from being read back as a comment", () => {
|
||||
expect(TblText.encodeEntry(entry("#1 unit"))).toBe("<35>1 unit")
|
||||
})
|
||||
|
||||
it("protects leading and trailing spaces from editors that trim them", () => {
|
||||
expect(TblText.encodeEntry(entry(" pad "))).toBe("<32>pad<32>")
|
||||
expect(TblText.encodeEntry(entry(" "))).toBe("<32>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("decodeEntry", () => {
|
||||
const parse = (line: string) => succeed(TblText.decodeEntry(line, 1))
|
||||
|
||||
it("round-trips every construct the encoder emits", async () => {
|
||||
const cases = [
|
||||
entry("zerg\\avenger.grp"),
|
||||
entry("Terran Maverick", "*", "Ground Units"),
|
||||
entry(""),
|
||||
entry("", ""),
|
||||
entry("#1 unit"),
|
||||
entry(" pad "),
|
||||
entry("a<b"),
|
||||
[Uint8Array.from([0x04, 0x45, 0x01, 0x0a, 0xa4])]
|
||||
]
|
||||
for (const original of cases) {
|
||||
expect(show([await parse(TblText.encodeEntry(original))])).toEqual(show([original]))
|
||||
}
|
||||
})
|
||||
|
||||
it("treats <0> at the edges as empty parts", async () => {
|
||||
expect(show([await parse("<0>a<0>")])).toEqual([["", "a", ""]])
|
||||
})
|
||||
|
||||
it("reads <> as nothing anywhere in the line", async () => {
|
||||
expect(show([await parse("a<>b")])).toEqual([["ab"]])
|
||||
})
|
||||
|
||||
it("rejects an unterminated escape", async () => {
|
||||
const error = await failWith(TblText.decodeEntry("abc<12", 7))
|
||||
expect(error.message).toContain("unterminated escape")
|
||||
expect(error.line).toBe(7)
|
||||
expect(error.column).toBe(4)
|
||||
})
|
||||
|
||||
it("rejects a non-numeric escape", async () => {
|
||||
const error = await failWith(TblText.decodeEntry("a<nope>", 1))
|
||||
expect(error.message).toContain("invalid escape `<nope>`")
|
||||
expect(error.column).toBe(2)
|
||||
})
|
||||
|
||||
it("rejects a byte above 255", async () => {
|
||||
const error = await failWith(TblText.decodeEntry("<256>", 1))
|
||||
expect(error.message).toContain("out of range")
|
||||
})
|
||||
|
||||
it("rejects non-ASCII characters, which would be ambiguous bytes", async () => {
|
||||
const error = await failWith(TblText.decodeEntry("café", 3))
|
||||
expect(error.message).toContain("non-ASCII")
|
||||
expect(error.message).toContain("U+00E9")
|
||||
expect(error.column).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe("decode", () => {
|
||||
it("skips comments and blank lines", async () => {
|
||||
const table = await succeed(TblText.decode("# a comment\n\nfirst\n\n# another\nsecond\n"))
|
||||
expect(show(table)).toEqual([["first"], ["second"]])
|
||||
})
|
||||
|
||||
it("accepts CRLF line endings and a UTF-8 BOM", async () => {
|
||||
const table = await succeed(TblText.decode("# c\r\nfirst\r\nsecond\r\n"))
|
||||
expect(show(table)).toEqual([["first"], ["second"]])
|
||||
})
|
||||
|
||||
it("reports the real line number of a bad escape", async () => {
|
||||
const error = await failWith(TblText.decode("# banner\nfine\nbroken<9z>\n"))
|
||||
expect(error.line).toBe(3)
|
||||
})
|
||||
|
||||
it("emits a banner by default and omits it when bare", () => {
|
||||
const table = [entry("a")]
|
||||
expect(TblText.encode(table)).toStartWith("# tbl-text v1")
|
||||
expect(TblText.encode(table, { header: false })).toBe("a\n")
|
||||
expect(TblText.encode(table, { header: false, annotate: true })).toBe("# [0]\na\n")
|
||||
})
|
||||
|
||||
it("round-trips a whole table through the text form", async () => {
|
||||
const table = [entry("first"), entry("a", "b"), entry(""), entry("# hash")]
|
||||
expect(show(await succeed(TblText.decode(TblText.encode(table))))).toEqual(show(table))
|
||||
})
|
||||
})
|
||||
133
tools/tbl/test/dialect.test.ts
Normal file
133
tools/tbl/test/dialect.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The GPTP dialect: the text format CM-GPTP's `collapse_control_codes`
|
||||
* (`hooks/limits/tbl_extender.cpp`) accepts, which the engine already reads for
|
||||
* `rez/stat.txt` and `arr/sounds.txt`.
|
||||
*
|
||||
* These tests are the reference for that parser. If they and the C++ ever
|
||||
* disagree, one of the two is a bug.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import * as Tbl from "../src/Tbl.ts"
|
||||
import * as TblText from "../src/TblText.ts"
|
||||
import { failWith, show, succeed } from "./helpers.ts"
|
||||
|
||||
const repo = `${import.meta.dir}/../../..`
|
||||
const bytes = (text: string) => Buffer.from(text, "latin1")
|
||||
const one = (line: string) => show([TblText.decodeEntryGptp(bytes(line))])[0]!
|
||||
|
||||
describe("GPTP dialect - control codes", () => {
|
||||
it("collapses <N> into the raw byte N", () => {
|
||||
expect(one("a<65>b")).toEqual(["aAb"])
|
||||
expect(one("<4>Tank Mode")).toEqual(["\x04Tank Mode"])
|
||||
})
|
||||
|
||||
it("treats <0> as a part separator", () => {
|
||||
expect(one("e<0>Tank Mode")).toEqual(["e", "Tank Mode"])
|
||||
})
|
||||
|
||||
it("leaves a trailing <0> as an empty final part", () => {
|
||||
expect(one("Spider Mines<0>")).toEqual(["Spider Mines", ""])
|
||||
})
|
||||
|
||||
it("leaves non-numeric angle brackets completely literal", () => {
|
||||
// There is no `<<` escape in this dialect, and `<>` is not an empty marker.
|
||||
expect(one("<foo>")).toEqual(["<foo>"])
|
||||
expect(one("<>")).toEqual(["<>"])
|
||||
expect(one("a<b")).toEqual(["a<b"])
|
||||
expect(one("<<")).toEqual(["<<"])
|
||||
})
|
||||
|
||||
it("wraps values above 255, matching the C++ char() narrowing", () => {
|
||||
// from_chars parses into u32, then `line[i] = char(control_char)` truncates,
|
||||
// so <256> collapses to a NUL and therefore splits the entry.
|
||||
expect(one("<256>")).toEqual(["", ""])
|
||||
expect(one("<321>")).toEqual([String.fromCharCode(321 & 0xff)]) // 65 -> "A"
|
||||
})
|
||||
|
||||
it("handles the two backslash escapes and leaves other pairs alone", () => {
|
||||
expect(one("a\\nb")).toEqual(["a\nb"])
|
||||
expect(one("a\\\\b")).toEqual(["a\\b"])
|
||||
expect(one("zerg\\avenger.grp")).toEqual(["zerg\\avenger.grp"])
|
||||
})
|
||||
|
||||
it("keeps raw bytes above 127", () => {
|
||||
expect(one("\xa4passive")).toEqual(["\xa4passive"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("GPTP dialect - whole files", () => {
|
||||
it("has no comment or blank-line syntax: every line is an entry", () => {
|
||||
const table = TblText.decodeGptp(bytes("a\n\n# not a comment\nb\n"))
|
||||
expect(show(table)).toEqual([["a"], [""], ["# not a comment"], ["b"]])
|
||||
})
|
||||
|
||||
it("does not turn a trailing newline into an extra entry", () => {
|
||||
expect(TblText.decodeGptp(bytes("a\nb\n")).length).toBe(2)
|
||||
expect(TblText.decodeGptp(bytes("a\nb")).length).toBe(2)
|
||||
})
|
||||
|
||||
it("tolerates CRLF", () => {
|
||||
expect(show(TblText.decodeGptp(bytes("a\r\nb\r\n")))).toEqual([["a"], ["b"]])
|
||||
})
|
||||
})
|
||||
|
||||
describe("GPTP dialect - encoding is unambiguous", () => {
|
||||
const roundTrips = (entry: Tbl.TblEntry) =>
|
||||
show([TblText.decodeEntryGptp(bytes(TblText.encodeEntryGptp(entry)))])[0]
|
||||
|
||||
it("escapes a literal < so it cannot be read back as a control code", () => {
|
||||
const entry = [Buffer.from("<10>literal")]
|
||||
expect(TblText.encodeEntryGptp(entry)).toBe("<60>10>literal")
|
||||
expect(roundTrips(entry)).toEqual(["<10>literal"])
|
||||
})
|
||||
|
||||
it("escapes a backslash only when it would otherwise become an escape", () => {
|
||||
expect(TblText.encodeEntryGptp([Buffer.from("zerg\\avenger.grp")])).toBe("zerg\\avenger.grp")
|
||||
expect(TblText.encodeEntryGptp([Buffer.from("zerg\\night.grp")])).toBe("zerg\\\\night.grp")
|
||||
expect(roundTrips([Buffer.from("zerg\\night.grp")])).toEqual(["zerg\\night.grp"])
|
||||
})
|
||||
|
||||
it("writes pure ASCII even for high bytes", () => {
|
||||
const text = TblText.encodeEntryGptp([Buffer.from("\xa4passive", "latin1")])
|
||||
expect(text).toBe("<164>passive")
|
||||
expect([...text].every((c) => c.charCodeAt(0) <= 0x7e)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GPTP dialect - the shipped text tables", () => {
|
||||
const load = async (path: string) =>
|
||||
TblText.decodeGptp(new Uint8Array(await Bun.file(`${repo}/${path}`).arrayBuffer()))
|
||||
|
||||
// stat.txt has grown past the table it was cloned from: the strings added
|
||||
// beyond index 1813 (weapon names, mostly) live only in the text file, which
|
||||
// is what the engine reads. stat_txt.tbl is the frozen legacy artefact.
|
||||
it("rez/stat.txt has outgrown the 1813 entries stat_txt.tbl indexes", async () => {
|
||||
const text = await load("mpq/rez/stat.txt")
|
||||
const binary = await succeed(
|
||||
Tbl.decode(new Uint8Array(await Bun.file(`${repo}/mpq/rez/stat_txt.tbl`).arrayBuffer()))
|
||||
)
|
||||
expect(text.length).toBe(2999)
|
||||
expect(binary.length).toBe(1813)
|
||||
})
|
||||
|
||||
it("rez/stat.txt is far too large to be a u16 .tbl - the text loader is load-bearing", async () => {
|
||||
const table = await load("mpq/rez/stat.txt")
|
||||
const error = await failWith(Tbl.encode(table))
|
||||
expect(error._tag).toBe("TblEncodeError")
|
||||
expect(error.message).toContain("past the 65535")
|
||||
})
|
||||
|
||||
for (const path of ["mpq/rez/stat.txt", "mpq/arr/sounds.txt"]) {
|
||||
it(`${path} survives gptp -> table -> gptp with an identical table`, async () => {
|
||||
const table = await load(path)
|
||||
const reparsed = TblText.decodeGptp(bytes(TblText.encodeGptp(table)))
|
||||
expect(show(reparsed)).toEqual(show(table))
|
||||
})
|
||||
|
||||
it(`${path} converts losslessly to tbl-text v1 and back`, async () => {
|
||||
const table = await load(path)
|
||||
const back = await succeed(TblText.decode(TblText.encode(table)))
|
||||
expect(show(back)).toEqual(show(table))
|
||||
})
|
||||
}
|
||||
})
|
||||
16
tools/tbl/test/helpers.ts
Normal file
16
tools/tbl/test/helpers.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
/** Build an entry from ASCII part strings. */
|
||||
export const entry = (...parts: Array<string>) => parts.map((part) => encoder.encode(part))
|
||||
|
||||
/** Run an effect that is expected to succeed. */
|
||||
export const succeed = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromise(effect)
|
||||
|
||||
/** Run an effect that is expected to fail, returning the error it failed with. */
|
||||
export const failWith = <A, E>(effect: Effect.Effect<A, E>): Promise<E> => Effect.runPromise(Effect.flip(effect))
|
||||
|
||||
/** Readable view of a decoded table, for assertions. */
|
||||
export const show = (table: ReadonlyArray<ReadonlyArray<Uint8Array>>) =>
|
||||
table.map((e) => e.map((part) => Array.from(part).map((b) => String.fromCharCode(b)).join("")))
|
||||
84
tools/tbl/test/roundtrip.test.ts
Normal file
84
tools/tbl/test/roundtrip.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import * as Tbl from "../src/Tbl.ts"
|
||||
import * as TblText from "../src/TblText.ts"
|
||||
import { show, succeed } from "./helpers.ts"
|
||||
|
||||
const repo = `${import.meta.dir}/../../..`
|
||||
|
||||
const FIXTURES = [
|
||||
"mpq/arr/images.tbl",
|
||||
"mpq/arr/portdata.tbl",
|
||||
"mpq/rez/gluHist.tbl",
|
||||
"mpq/rez/help_txt.tbl",
|
||||
"mpq/rez/network.tbl",
|
||||
"mpq/rez/stat_txt.tbl",
|
||||
"mpq/rez/tips.tbl",
|
||||
"mpq/rez/unitnames.tbl"
|
||||
]
|
||||
|
||||
describe("round trip against the shipped game data", () => {
|
||||
for (const fixture of FIXTURES) {
|
||||
it(`${fixture} survives .tbl -> .tbl.txt -> .tbl byte for byte`, async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/${fixture}`).arrayBuffer())
|
||||
|
||||
const table = await succeed(Tbl.decode(original))
|
||||
const text = TblText.encode(table)
|
||||
const reparsed = await succeed(TblText.decode(text))
|
||||
const rebuilt = await succeed(Tbl.encode(reparsed))
|
||||
|
||||
expect(rebuilt.length).toBe(original.length)
|
||||
expect(rebuilt).toEqual(original)
|
||||
})
|
||||
|
||||
it(`${fixture} decompiles to pure ASCII`, async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/${fixture}`).arrayBuffer())
|
||||
const text = TblText.encode(await succeed(Tbl.decode(original)))
|
||||
const offender = [...text].find((char) => char.charCodeAt(0) > 0x7e)
|
||||
expect(offender).toBeUndefined()
|
||||
})
|
||||
}
|
||||
|
||||
it("renders images.tbl as plain, unescaped GRP paths", async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/mpq/arr/images.tbl`).arrayBuffer())
|
||||
const table = await succeed(Tbl.decode(original))
|
||||
const lines = TblText.encode(table, { header: false }).split("\n")
|
||||
expect(lines[0]).toBe("zerg\\avenger.grp")
|
||||
expect(lines[1]).toBe("protoss\\mshield.los")
|
||||
})
|
||||
|
||||
it("renders unitnames.tbl multi-part entries with <0> separators", async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/mpq/rez/unitnames.tbl`).arrayBuffer())
|
||||
const table = await succeed(Tbl.decode(original))
|
||||
const lines = TblText.encode(table, { header: false }).split("\n")
|
||||
expect(lines[0]).toBe("Terran Maverick<0>*<0>Ground Units")
|
||||
expect(lines[5]).toBe("Terran Phalanx<0>Tank Mode<0>Ground Units")
|
||||
})
|
||||
|
||||
it("renders stat_txt.tbl colour codes and newlines as <N>", async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/mpq/rez/stat_txt.tbl`).arrayBuffer())
|
||||
const table = await succeed(Tbl.decode(original))
|
||||
const line = TblText.encode(table, { header: false }).split("\n")[338]!
|
||||
expect(line).toBe(
|
||||
"e<0><4>Tank Mode <1>(<3>E<1>)<10>Returns the Phalanx's weapon and mobility to normal."
|
||||
)
|
||||
})
|
||||
|
||||
it("dedupe reclaims stat_txt.tbl headroom without changing a single string", async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/mpq/rez/stat_txt.tbl`).arrayBuffer())
|
||||
const table = await succeed(Tbl.decode(original))
|
||||
const deduped = await succeed(Tbl.encode(table, { dedupe: true }))
|
||||
|
||||
expect(deduped.length).toBeLessThan(original.length)
|
||||
expect(show(await succeed(Tbl.decode(deduped)))).toEqual(show(table))
|
||||
})
|
||||
|
||||
it("reports how little offset headroom stat_txt.tbl has left", async () => {
|
||||
const original = new Uint8Array(await Bun.file(`${repo}/mpq/rez/stat_txt.tbl`).arrayBuffer())
|
||||
const view = new DataView(original.buffer)
|
||||
const count = view.getUint16(0, true)
|
||||
let highest = 0
|
||||
for (let i = 0; i < count; i++) highest = Math.max(highest, view.getUint16(2 + i * 2, true))
|
||||
expect(highest).toBeLessThanOrEqual(Tbl.MAX_OFFSET)
|
||||
expect(Tbl.MAX_OFFSET - highest).toBeLessThan(64) // 26 bytes at time of writing
|
||||
})
|
||||
})
|
||||
23
tools/tbl/tsconfig.json
Normal file
23
tools/tbl/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"types": ["bun"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user